Compare commits

...

48 Commits

Author SHA1 Message Date
Youwes09 239960683b Chore: Bump for 0.9.4 2026-05-17 00:12:55 -05:00
Youwes09 3b5efc85d0 Fix: ReaderOverlay Draggable 2026-05-17 00:01:55 -05:00
Youwes09 7df3846e75 Feat: Improved PageLoder & Keybinds Fix 2026-05-16 23:36:15 -05:00
Youwes09 01f123f5be Fix: GlobalUIZoom Affecting MangaDisplay (#82) 2026-05-16 23:06:10 -05:00
Youwes09 0e2371096b Feat: Basic ExtensionLibrary Filter 2026-05-16 22:50:00 -05:00
Youwes09 47ae80a7d2 Fix: Cache Adjustments (WIP) 2026-05-16 22:46:45 -05:00
Youwes09 d98547d540 Fix: Cache-Boot (KCEF Corruption) 2026-05-17 03:29:39 -05:00
Youwes09 897ecfd316 Fix: Clear Moku Cache & SelectPortal Zoom (#82) 2026-05-16 22:05:13 -05:00
Youwes09 e3abc72f1b Fix: Duplicate App Instances (#83) 2026-05-16 15:41:07 -05:00
Youwes09 6b56db7cf2 Fix: Exit Button Works 2026-05-16 15:31:13 -05:00
Youwes09 93cedca6b5 Chore: Post-Bump 0.9.3 2026-05-16 08:00:11 -05:00
Youwes09 9f8bf6ffc1 Chore: Tagged 0.9.3 V2 2026-05-16 07:59:35 -05:00
Youwes09 39f813b4d7 Chore: Tagged 0.9.3 2026-05-16 07:57:26 -05:00
Youwes09 18ac38e888 Feat: Disable Auto-Complete on Moku 2026-05-16 07:56:05 -05:00
Shozikan 1e2e923eab Chore: Merge pull request #79 from zerebos/feat/page-loaders
Add circular loaders to pages
2026-05-16 07:41:28 -05:00
Youwes09 d3a40b9152 Fix: System.rs PathBuf Error 2026-05-16 03:54:05 -05:00
Zerebos b1444582a3 Add circular loaders to pages 2026-05-16 01:01:12 -04:00
Youwes09 bf3f68b996 Feat: Minimize to Sys-Tray + MultiModal (#76) 2026-05-15 21:21:31 -05:00
Youwes09 4b728ad5b7 Feat: Middle-Click for Browser-Auto-Scroll (#70) 2026-05-15 20:41:21 -05:00
Youwes09 f3f91f1555 Feat: Auto-Scroll & Double-Tap Adjustment (#69) 2026-05-15 20:36:15 -05:00
Youwes09 062662781a Feat: Bulk-Source Migration (#66) 2026-05-15 19:49:26 -05:00
Youwes09 cbf8a7fe13 Feat: Extension Settings & Library Filtering (#73) 2026-05-15 19:29:00 -05:00
Youwes09 5af80213c7 Feat: Extension Settings & Library Filtering (#71) (#72) 2026-05-15 07:19:21 -05:00
Youwes09 17d739a1cd Fix: Drag-Region for Reader Bar (#74) 2026-05-14 08:07:14 -05:00
Youwes09 2867dc9612 Fix: Direct-Mouse Scroll (#75) 2026-05-14 08:01:17 -05:00
Youwes09 a9dc047b44 Fix: Toolbar Uniformity & SeriesDetail Redirect (#66) 2026-05-11 20:47:37 -05:00
Youwes09 ef190ae66f Fix: LibraryToolbar Folder Drag 2026-05-11 14:35:05 -05:00
Youwes09 6d921944ac Fix: Library FolderSetting Re-Vamp 2026-05-10 12:07:00 -05:00
Youwes09 244447da9b Feat: Backtracing + NavPage Store 2026-05-10 04:31:27 -05:00
Youwes09 f05f781b5b Fix: Biometric Revision V1 2026-05-10 03:00:08 -05:00
Youwes09 f7c5aebf29 Fix: PerformanceSettings RenderLimit CSS Revision (#63) 2026-05-10 02:50:48 -05:00
Youwes09 e09ae9d2e7 Fix: Respect Page-Order in Loading & Memory Eviction (#61, #63, #68) 2026-05-10 02:17:25 -05:00
Youwes09 7b2ae74c02 Fix: Trigger Recently-Fetched Data for RecentActivity (#63) 2026-05-03 13:06:02 -05:00
Youwes09 0d53e3f102 Fix: Attempt to Improve UI-Login Cache (#63) 2026-05-03 12:29:20 -05:00
Youwes09 093b395cc1 Fix: Re-Try UI Login Token + GQL Wait 2026-05-03 11:47:18 -05:00
Youwes09 efdd8ff95d Fix: Re-Register Settings Export Function (#63) 2026-05-03 11:35:09 -05:00
Youwes09 c0f0ff9bd3 Fix: TrackingSync Excludes Decimals & Respects Chapter Numbers (#63) 2026-05-02 18:14:34 -05:00
Youwes09 3f6049c12d Fix: Remove Scroll Propagation in Reader (#63) 2026-05-02 18:06:37 -05:00
Youwes09 5451a2654b Fix: Wrap ReaderControls in Scrollable (#63) 2026-05-02 17:58:16 -05:00
Youwes09 e625755c5e Fix: Library Folders Clipping (Anim Removed) (#63) 2026-05-02 17:51:54 -05:00
Youwes09 bd95bf4eb1 Fix: Added Download Toggles to Global-Store (#63) 2026-05-02 17:40:07 -05:00
Youwes09 b4d680ddd1 Fix: Error-Handling & ScrollBox on TrackingSettings (#63) 2026-05-02 17:34:54 -05:00
Youwes09 d1b7429b5d Fix: FolderSettings Revamp & Folders (#63) 2026-05-02 17:23:47 -05:00
Youwes09 000195be89 Fix: State-Based Issues & AboutSettings (WIP) 2026-05-02 16:53:50 -05:00
Youwes09 399d429142 Fix: Rust-Cleanup & Flake-SHA Patch 2026-05-01 11:32:29 -05:00
Youwes09 b79ee99e8a Fix: Linked CORS Bypass to UI-LOGIN 2026-05-01 11:09:29 -05:00
Youwes09 80c4b9d9be Chore: Update pnpm-tauri Packages 2026-05-01 01:14:39 -05:00
Youwes09 4584e6e69e Chore: Post-Bump for v0.9.2 2026-05-01 01:09:41 -05:00
83 changed files with 5776 additions and 2615 deletions
+3 -3
View File
@@ -1,5 +1,5 @@
pkgname=moku pkgname=moku
pkgver=0.9.2 pkgver=0.9.4
pkgrel=1 pkgrel=1
pkgdesc="Native Linux manga reader frontend for Suwayomi-Server" pkgdesc="Native Linux manga reader frontend for Suwayomi-Server"
arch=('x86_64') arch=('x86_64')
@@ -22,7 +22,7 @@ source=(
"Suwayomi-Server-v2.1.2087.jar::https://github.com/Suwayomi/Suwayomi-Server-preview/releases/download/v2.1.2087/Suwayomi-Server-v2.1.2087.jar" "Suwayomi-Server-v2.1.2087.jar::https://github.com/Suwayomi/Suwayomi-Server-preview/releases/download/v2.1.2087/Suwayomi-Server-v2.1.2087.jar"
) )
sha256sums=( sha256sums=(
'4d0fbed929d5660ddcb591ff33f808910e13df1e8e7bfc8df83f367fd7bcd881' '4e7e48ea3332f66c840f2b633c7b3f49b535b144f1b6cfc8d63ead24fcab3684'
'f589a422674252394c13b289a9c8be691905bf583efb7f4d5f1501ae5e91e6b3' 'f589a422674252394c13b289a9c8be691905bf583efb7f4d5f1501ae5e91e6b3'
) )
@@ -52,7 +52,7 @@ package() {
cat > "$pkgdir/usr/lib/moku/tachidesk/default-conf/server.conf" << 'CONF' cat > "$pkgdir/usr/lib/moku/tachidesk/default-conf/server.conf" << 'CONF'
server.ip = "127.0.0.1" server.ip = "127.0.0.1"
server.port = 4567 server.port = 4567
server.webUIEnabled = false server.webUIEnabled = true
server.initialOpenInBrowserEnabled = false server.initialOpenInBrowserEnabled = false
server.systemTrayEnabled = false server.systemTrayEnabled = false
server.downloadAsCbz = true server.downloadAsCbz = true
+1 -4
View File
@@ -35,8 +35,5 @@ In-Progress:
- Wire Series-Detail Refresh to Fix Manga-Metadata (MAJOR) - Wire Series-Detail Refresh to Fix Manga-Metadata (MAJOR)
- Add Disable Auto-Completed Feature to Library - UI LOGIN DOES NOT WORK OFFLINE
- Cap ReaderSettings Zoom (100)
- Fix SeriesDetail Chapter Amount (Link to Scanlator Filtering)
Notes from last time: Notes from last time:
+2 -7
View File
@@ -22,7 +22,7 @@
perSystem = perSystem =
{ system, lib, ... }: { system, lib, ... }:
let let
version = "0.9.2"; version = "0.9.4";
pkgs = import inputs.nixpkgs { pkgs = import inputs.nixpkgs {
inherit system; inherit system;
@@ -53,8 +53,6 @@
gsettings-desktop-schemas gsettings-desktop-schemas
]; ];
# ── source filters ──────────────────────────────────────────────
frontendSrc = lib.cleanSourceWith { frontendSrc = lib.cleanSourceWith {
src = ./.; src = ./.;
filter = filter =
@@ -80,8 +78,6 @@
|| (builtins.baseNameOf path == "tauri.conf.json"); || (builtins.baseNameOf path == "tauri.conf.json");
}; };
# ── packages ────────────────────────────────────────────────────
suwayomiServer = pkgs.callPackage ./nix/server.nix { }; suwayomiServer = pkgs.callPackage ./nix/server.nix { };
frontend = pkgs.callPackage ./nix/frontend.nix { frontend = pkgs.callPackage ./nix/frontend.nix {
@@ -94,8 +90,6 @@
appIcon = ./src/assets/moku-icon.svg; appIcon = ./src/assets/moku-icon.svg;
}; };
# ── dev/release scripts ─────────────────────────────────────────
scripts = import ./nix/scripts.nix { inherit pkgs rustToolchain version; }; scripts = import ./nix/scripts.nix { inherit pkgs rustToolchain version; };
in in
@@ -134,6 +128,7 @@
export NO_STRIP=true export NO_STRIP=true
export PKG_CONFIG_PATH="${pkgs.openssl.dev}/lib/pkgconfig''${PKG_CONFIG_PATH:+:$PKG_CONFIG_PATH}" export PKG_CONFIG_PATH="${pkgs.openssl.dev}/lib/pkgconfig''${PKG_CONFIG_PATH:+:$PKG_CONFIG_PATH}"
export XDG_DATA_DIRS="${pkgs.gsettings-desktop-schemas}/share/gsettings-schemas/${pkgs.gsettings-desktop-schemas.name}:${pkgs.gtk3}/share/gsettings-schemas/${pkgs.gtk3.name}''${XDG_DATA_DIRS:+:$XDG_DATA_DIRS}" export XDG_DATA_DIRS="${pkgs.gsettings-desktop-schemas}/share/gsettings-schemas/${pkgs.gsettings-desktop-schemas.name}:${pkgs.gtk3}/share/gsettings-schemas/${pkgs.gtk3.name}''${XDG_DATA_DIRS:+:$XDG_DATA_DIRS}"
export LD_LIBRARY_PATH="${pkgs.lib.makeLibraryPath runtimeLibs}''${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
echo "Moku dev shell pnpm install && pnpm tauri:dev" echo "Moku dev shell pnpm install && pnpm tauri:dev"
echo "" echo ""
+6 -6
View File
@@ -95,12 +95,12 @@ modules:
cat > /app/tachidesk/default-conf/server.conf << 'EOF' cat > /app/tachidesk/default-conf/server.conf << 'EOF'
server.ip = "127.0.0.1" server.ip = "127.0.0.1"
server.port = 4567 server.port = 4567
server.webUIEnabled = false server.webUIEnabled = true
server.initialOpenInBrowserEnabled = false server.initialOpenInBrowserEnabled = false
server.systemTrayEnabled = false server.systemTrayEnabled = false
server.webUIInterface = "browser" server.webUIInterface = "browser"
server.webUIFlavor = "WebUI" server.webUIFlavor = "WebUI"
server.webUIChannel = "stable" server.webUIChannel = "PREVIEW"
server.electronPath = "" server.electronPath = ""
server.debugLogsEnabled = false server.debugLogsEnabled = false
server.downloadAsCbz = true server.downloadAsCbz = true
@@ -130,7 +130,7 @@ modules:
"$DATA_DIR/server.conf" "$DATA_DIR/server.conf"
# Append keys if absent # Append keys if absent
grep -q 'server\.webUIEnabled' "$DATA_DIR/server.conf" || echo 'server.webUIEnabled = false' >> "$DATA_DIR/server.conf" grep -q 'server\.webUIEnabled' "$DATA_DIR/server.conf" || echo 'server.webUIEnabled = true' >> "$DATA_DIR/server.conf"
grep -q 'server\.initialOpenInBrowserEnabled' "$DATA_DIR/server.conf" || echo 'server.initialOpenInBrowserEnabled = false' >> "$DATA_DIR/server.conf" grep -q 'server\.initialOpenInBrowserEnabled' "$DATA_DIR/server.conf" || echo 'server.initialOpenInBrowserEnabled = false' >> "$DATA_DIR/server.conf"
grep -q 'server\.systemTrayEnabled' "$DATA_DIR/server.conf" || echo 'server.systemTrayEnabled = false' >> "$DATA_DIR/server.conf" grep -q 'server\.systemTrayEnabled' "$DATA_DIR/server.conf" || echo 'server.systemTrayEnabled = false' >> "$DATA_DIR/server.conf"
@@ -179,11 +179,11 @@ modules:
sources: sources:
- type: git - type: git
url: https://github.com/moku-project/Moku.git url: https://github.com/moku-project/Moku.git
tag: v0.9.2 tag: v0.9.4
commit: e33464b05baddc7c4ad3815f3f126f791e8c58cc commit: 9f8bf6ffc11e0808acc735132e1aeff8b3bf1e09
- type: file - type: file
path: packaging/frontend-dist.tar.gz path: packaging/frontend-dist.tar.gz
sha256: 22128c591ddacac218b7223106ed3c3f052799db2a647247789492b925370086 sha256: 7db288b4b54277aa82b6ec5b21fc31a1e71f8246c50a74777500083b806c1fa5
- packaging/cargo-sources.json - packaging/cargo-sources.json
- type: inline - type: inline
dest: src-tauri/.cargo dest: src-tauri/.cargo
+1 -1
View File
@@ -10,7 +10,7 @@ stdenv.mkDerivation {
pname = "moku-frontend"; pname = "moku-frontend";
inherit version src; inherit version src;
fetcherVersion = 1; fetcherVersion = 1;
hash = "sha256-t6Gj84hCE3CuDAJfbdXi0FuqgPCqlkMmAzETcKL4e3U="; hash = "sha256-vM//1/qe9nKDwwlmFbqvBFqF8cCjIIdNKEtktyzBFB8=";
}; };
buildPhase = "pnpm build"; buildPhase = "pnpm build";
+9 -8
View File
@@ -10,23 +10,24 @@
"tauri:dev": "tauri dev --config src-tauri/tauri.dev.conf.json" "tauri:dev": "tauri dev --config src-tauri/tauri.dev.conf.json"
}, },
"dependencies": { "dependencies": {
"@tauri-apps/api": "^2.0.0", "@tauri-apps/api": "^2.11.0",
"@tauri-apps/plugin-http": "^2.5.8", "@tauri-apps/plugin-http": "^2.5.8",
"@tauri-apps/plugin-os": "^2.3.2", "@tauri-apps/plugin-os": "^2.3.2",
"@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-shell": "^2.3.5", "@tauri-apps/plugin-shell": "^2.3.5",
"@tauri-apps/plugin-store": "~2.4.2", "@tauri-apps/plugin-store": "~2.4.2",
"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": "^5.1.0",
"tauri-plugin-discord-rpc-api": "github:Youwes09/tauri-plugin-discord-rpc", "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": {
"@sveltejs/vite-plugin-svelte": "^4.0.4", "@sveltejs/vite-plugin-svelte": "^7.0.0",
"@tauri-apps/cli": "^2.0.0", "@tauri-apps/cli": "^2.11.0",
"svelte": "^5.0.0", "svelte": "^5.55.5",
"svelte-check": "^3.0.0", "svelte-check": "^4.4.7",
"typescript": "^5.0.0", "typescript": "^6.0.3",
"vite": "^5.0.0" "vite": "^8.0.10"
} }
} }
+188 -266
View File
@@ -265,6 +265,19 @@
"dest": "cargo/vendor/brotli-decompressor-5.0.0", "dest": "cargo/vendor/brotli-decompressor-5.0.0",
"dest-filename": ".cargo-checksum.json" "dest-filename": ".cargo-checksum.json"
}, },
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/bs58/bs58-0.5.1.crate",
"sha256": "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4",
"dest": "cargo/vendor/bs58-0.5.1"
},
{
"type": "inline",
"contents": "{\"package\": \"bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4\", \"files\": {}}",
"dest": "cargo/vendor/bs58-0.5.1",
"dest-filename": ".cargo-checksum.json"
},
{ {
"type": "archive", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
@@ -398,14 +411,14 @@
{ {
"type": "archive", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/cc/cc-1.2.61.crate", "url": "https://static.crates.io/crates/cc/cc-1.2.62.crate",
"sha256": "d16d90359e986641506914ba71350897565610e87ce0ad9e6f28569db3dd5c6d", "sha256": "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98",
"dest": "cargo/vendor/cc-1.2.61" "dest": "cargo/vendor/cc-1.2.62"
}, },
{ {
"type": "inline", "type": "inline",
"contents": "{\"package\": \"d16d90359e986641506914ba71350897565610e87ce0ad9e6f28569db3dd5c6d\", \"files\": {}}", "contents": "{\"package\": \"a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98\", \"files\": {}}",
"dest": "cargo/vendor/cc-1.2.61", "dest": "cargo/vendor/cc-1.2.62",
"dest-filename": ".cargo-checksum.json" "dest-filename": ".cargo-checksum.json"
}, },
{ {
@@ -1750,14 +1763,14 @@
{ {
"type": "archive", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/h2/h2-0.4.13.crate", "url": "https://static.crates.io/crates/h2/h2-0.4.14.crate",
"sha256": "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54", "sha256": "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733",
"dest": "cargo/vendor/h2-0.4.13" "dest": "cargo/vendor/h2-0.4.14"
}, },
{ {
"type": "inline", "type": "inline",
"contents": "{\"package\": \"2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54\", \"files\": {}}", "contents": "{\"package\": \"171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733\", \"files\": {}}",
"dest": "cargo/vendor/h2-0.4.13", "dest": "cargo/vendor/h2-0.4.14",
"dest-filename": ".cargo-checksum.json" "dest-filename": ".cargo-checksum.json"
}, },
{ {
@@ -1789,14 +1802,14 @@
{ {
"type": "archive", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/hashbrown/hashbrown-0.17.0.crate", "url": "https://static.crates.io/crates/hashbrown/hashbrown-0.17.1.crate",
"sha256": "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51", "sha256": "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a",
"dest": "cargo/vendor/hashbrown-0.17.0" "dest": "cargo/vendor/hashbrown-0.17.1"
}, },
{ {
"type": "inline", "type": "inline",
"contents": "{\"package\": \"4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51\", \"files\": {}}", "contents": "{\"package\": \"ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a\", \"files\": {}}",
"dest": "cargo/vendor/hashbrown-0.17.0", "dest": "cargo/vendor/hashbrown-0.17.1",
"dest-filename": ".cargo-checksum.json" "dest-filename": ".cargo-checksum.json"
}, },
{ {
@@ -2189,19 +2202,6 @@
"dest": "cargo/vendor/ipnet-2.12.0", "dest": "cargo/vendor/ipnet-2.12.0",
"dest-filename": ".cargo-checksum.json" "dest-filename": ".cargo-checksum.json"
}, },
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/iri-string/iri-string-0.7.12.crate",
"sha256": "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20",
"dest": "cargo/vendor/iri-string-0.7.12"
},
{
"type": "inline",
"contents": "{\"package\": \"25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20\", \"files\": {}}",
"dest": "cargo/vendor/iri-string-0.7.12",
"dest-filename": ".cargo-checksum.json"
},
{ {
"type": "archive", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
@@ -2322,14 +2322,14 @@
{ {
"type": "archive", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/js-sys/js-sys-0.3.97.crate", "url": "https://static.crates.io/crates/js-sys/js-sys-0.3.98.crate",
"sha256": "a1840c94c045fbcf8ba2812c95db44499f7c64910a912551aaaa541decebcacf", "sha256": "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08",
"dest": "cargo/vendor/js-sys-0.3.97" "dest": "cargo/vendor/js-sys-0.3.98"
}, },
{ {
"type": "inline", "type": "inline",
"contents": "{\"package\": \"a1840c94c045fbcf8ba2812c95db44499f7c64910a912551aaaa541decebcacf\", \"files\": {}}", "contents": "{\"package\": \"67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08\", \"files\": {}}",
"dest": "cargo/vendor/js-sys-0.3.97", "dest": "cargo/vendor/js-sys-0.3.98",
"dest-filename": ".cargo-checksum.json" "dest-filename": ".cargo-checksum.json"
}, },
{ {
@@ -3011,27 +3011,27 @@
{ {
"type": "archive", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/open/open-5.3.4.crate", "url": "https://static.crates.io/crates/open/open-5.3.5.crate",
"sha256": "9f3bab717c29a857abf75fcef718d441ec7cb2725f937343c734740a985d37fd", "sha256": "2fbaa89d2ddc8473c78a3adf69eea8cffa28c483b8e02a971ef31527cd0fc92c",
"dest": "cargo/vendor/open-5.3.4" "dest": "cargo/vendor/open-5.3.5"
}, },
{ {
"type": "inline", "type": "inline",
"contents": "{\"package\": \"9f3bab717c29a857abf75fcef718d441ec7cb2725f937343c734740a985d37fd\", \"files\": {}}", "contents": "{\"package\": \"2fbaa89d2ddc8473c78a3adf69eea8cffa28c483b8e02a971ef31527cd0fc92c\", \"files\": {}}",
"dest": "cargo/vendor/open-5.3.4", "dest": "cargo/vendor/open-5.3.5",
"dest-filename": ".cargo-checksum.json" "dest-filename": ".cargo-checksum.json"
}, },
{ {
"type": "archive", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/openssl/openssl-0.10.78.crate", "url": "https://static.crates.io/crates/openssl/openssl-0.10.80.crate",
"sha256": "f38c4372413cdaaf3cc79dd92d29d7d9f5ab09b51b10dded508fb90bb70b9222", "sha256": "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967",
"dest": "cargo/vendor/openssl-0.10.78" "dest": "cargo/vendor/openssl-0.10.80"
}, },
{ {
"type": "inline", "type": "inline",
"contents": "{\"package\": \"f38c4372413cdaaf3cc79dd92d29d7d9f5ab09b51b10dded508fb90bb70b9222\", \"files\": {}}", "contents": "{\"package\": \"a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967\", \"files\": {}}",
"dest": "cargo/vendor/openssl-0.10.78", "dest": "cargo/vendor/openssl-0.10.80",
"dest-filename": ".cargo-checksum.json" "dest-filename": ".cargo-checksum.json"
}, },
{ {
@@ -3063,14 +3063,14 @@
{ {
"type": "archive", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/openssl-sys/openssl-sys-0.9.114.crate", "url": "https://static.crates.io/crates/openssl-sys/openssl-sys-0.9.116.crate",
"sha256": "13ce1245cd07fcc4cfdb438f7507b0c7e4f3849a69fd84d52374c66d83741bb6", "sha256": "f28a22dc7140cda5f096e5e7724a6962ca81a7f8bfd2979f9b18c11af56318c4",
"dest": "cargo/vendor/openssl-sys-0.9.114" "dest": "cargo/vendor/openssl-sys-0.9.116"
}, },
{ {
"type": "inline", "type": "inline",
"contents": "{\"package\": \"13ce1245cd07fcc4cfdb438f7507b0c7e4f3849a69fd84d52374c66d83741bb6\", \"files\": {}}", "contents": "{\"package\": \"f28a22dc7140cda5f096e5e7724a6962ca81a7f8bfd2979f9b18c11af56318c4\", \"files\": {}}",
"dest": "cargo/vendor/openssl-sys-0.9.114", "dest": "cargo/vendor/openssl-sys-0.9.116",
"dest-filename": ".cargo-checksum.json" "dest-filename": ".cargo-checksum.json"
}, },
{ {
@@ -3190,19 +3190,6 @@
"dest": "cargo/vendor/percent-encoding-2.3.2", "dest": "cargo/vendor/percent-encoding-2.3.2",
"dest-filename": ".cargo-checksum.json" "dest-filename": ".cargo-checksum.json"
}, },
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/phf/phf-0.11.3.crate",
"sha256": "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078",
"dest": "cargo/vendor/phf-0.11.3"
},
{
"type": "inline",
"contents": "{\"package\": \"1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078\", \"files\": {}}",
"dest": "cargo/vendor/phf-0.11.3",
"dest-filename": ".cargo-checksum.json"
},
{ {
"type": "archive", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
@@ -3229,19 +3216,6 @@
"dest": "cargo/vendor/phf_codegen-0.13.1", "dest": "cargo/vendor/phf_codegen-0.13.1",
"dest-filename": ".cargo-checksum.json" "dest-filename": ".cargo-checksum.json"
}, },
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/phf_generator/phf_generator-0.11.3.crate",
"sha256": "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d",
"dest": "cargo/vendor/phf_generator-0.11.3"
},
{
"type": "inline",
"contents": "{\"package\": \"3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d\", \"files\": {}}",
"dest": "cargo/vendor/phf_generator-0.11.3",
"dest-filename": ".cargo-checksum.json"
},
{ {
"type": "archive", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
@@ -3255,19 +3229,6 @@
"dest": "cargo/vendor/phf_generator-0.13.1", "dest": "cargo/vendor/phf_generator-0.13.1",
"dest-filename": ".cargo-checksum.json" "dest-filename": ".cargo-checksum.json"
}, },
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/phf_macros/phf_macros-0.11.3.crate",
"sha256": "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216",
"dest": "cargo/vendor/phf_macros-0.11.3"
},
{
"type": "inline",
"contents": "{\"package\": \"f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216\", \"files\": {}}",
"dest": "cargo/vendor/phf_macros-0.11.3",
"dest-filename": ".cargo-checksum.json"
},
{ {
"type": "archive", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
@@ -3281,19 +3242,6 @@
"dest": "cargo/vendor/phf_macros-0.13.1", "dest": "cargo/vendor/phf_macros-0.13.1",
"dest-filename": ".cargo-checksum.json" "dest-filename": ".cargo-checksum.json"
}, },
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/phf_shared/phf_shared-0.11.3.crate",
"sha256": "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5",
"dest": "cargo/vendor/phf_shared-0.11.3"
},
{
"type": "inline",
"contents": "{\"package\": \"67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5\", \"files\": {}}",
"dest": "cargo/vendor/phf_shared-0.11.3",
"dest-filename": ".cargo-checksum.json"
},
{ {
"type": "archive", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
@@ -3544,14 +3492,14 @@
{ {
"type": "archive", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/quick-xml/quick-xml-0.39.2.crate", "url": "https://static.crates.io/crates/quick-xml/quick-xml-0.39.4.crate",
"sha256": "958f21e8e7ceb5a1aa7fa87fab28e7c75976e0bfe7e23ff069e0a260f894067d", "sha256": "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e",
"dest": "cargo/vendor/quick-xml-0.39.2" "dest": "cargo/vendor/quick-xml-0.39.4"
}, },
{ {
"type": "inline", "type": "inline",
"contents": "{\"package\": \"958f21e8e7ceb5a1aa7fa87fab28e7c75976e0bfe7e23ff069e0a260f894067d\", \"files\": {}}", "contents": "{\"package\": \"cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e\", \"files\": {}}",
"dest": "cargo/vendor/quick-xml-0.39.2", "dest": "cargo/vendor/quick-xml-0.39.4",
"dest-filename": ".cargo-checksum.json" "dest-filename": ".cargo-checksum.json"
}, },
{ {
@@ -3632,19 +3580,6 @@
"dest": "cargo/vendor/r-efi-6.0.0", "dest": "cargo/vendor/r-efi-6.0.0",
"dest-filename": ".cargo-checksum.json" "dest-filename": ".cargo-checksum.json"
}, },
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/rand/rand-0.8.6.crate",
"sha256": "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a",
"dest": "cargo/vendor/rand-0.8.6"
},
{
"type": "inline",
"contents": "{\"package\": \"5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a\", \"files\": {}}",
"dest": "cargo/vendor/rand-0.8.6",
"dest-filename": ".cargo-checksum.json"
},
{ {
"type": "archive", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
@@ -3671,19 +3606,6 @@
"dest": "cargo/vendor/rand_chacha-0.9.0", "dest": "cargo/vendor/rand_chacha-0.9.0",
"dest-filename": ".cargo-checksum.json" "dest-filename": ".cargo-checksum.json"
}, },
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/rand_core/rand_core-0.6.4.crate",
"sha256": "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c",
"dest": "cargo/vendor/rand_core-0.6.4"
},
{
"type": "inline",
"contents": "{\"package\": \"ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c\", \"files\": {}}",
"dest": "cargo/vendor/rand_core-0.6.4",
"dest-filename": ".cargo-checksum.json"
},
{ {
"type": "archive", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
@@ -4272,27 +4194,27 @@
{ {
"type": "archive", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/serde_with/serde_with-3.18.0.crate", "url": "https://static.crates.io/crates/serde_with/serde_with-3.20.0.crate",
"sha256": "dd5414fad8e6907dbdd5bc441a50ae8d6e26151a03b1de04d89a5576de61d01f", "sha256": "e72c1c2cb7b223fafb600a619537a871c2818583d619401b785e7c0b746ccde2",
"dest": "cargo/vendor/serde_with-3.18.0" "dest": "cargo/vendor/serde_with-3.20.0"
}, },
{ {
"type": "inline", "type": "inline",
"contents": "{\"package\": \"dd5414fad8e6907dbdd5bc441a50ae8d6e26151a03b1de04d89a5576de61d01f\", \"files\": {}}", "contents": "{\"package\": \"e72c1c2cb7b223fafb600a619537a871c2818583d619401b785e7c0b746ccde2\", \"files\": {}}",
"dest": "cargo/vendor/serde_with-3.18.0", "dest": "cargo/vendor/serde_with-3.20.0",
"dest-filename": ".cargo-checksum.json" "dest-filename": ".cargo-checksum.json"
}, },
{ {
"type": "archive", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/serde_with_macros/serde_with_macros-3.18.0.crate", "url": "https://static.crates.io/crates/serde_with_macros/serde_with_macros-3.20.0.crate",
"sha256": "d3db8978e608f1fe7357e211969fd9abdcae80bac1ba7a3369bb7eb6b404eb65", "sha256": "b90c488738ecb4fb0262f41f43bc40efc5868d9fb744319ddf5f5317f417bfac",
"dest": "cargo/vendor/serde_with_macros-3.18.0" "dest": "cargo/vendor/serde_with_macros-3.20.0"
}, },
{ {
"type": "inline", "type": "inline",
"contents": "{\"package\": \"d3db8978e608f1fe7357e211969fd9abdcae80bac1ba7a3369bb7eb6b404eb65\", \"files\": {}}", "contents": "{\"package\": \"b90c488738ecb4fb0262f41f43bc40efc5868d9fb744319ddf5f5317f417bfac\", \"files\": {}}",
"dest": "cargo/vendor/serde_with_macros-3.18.0", "dest": "cargo/vendor/serde_with_macros-3.20.0",
"dest-filename": ".cargo-checksum.json" "dest-filename": ".cargo-checksum.json"
}, },
{ {
@@ -4428,14 +4350,14 @@
{ {
"type": "archive", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/siphasher/siphasher-1.0.2.crate", "url": "https://static.crates.io/crates/siphasher/siphasher-1.0.3.crate",
"sha256": "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e", "sha256": "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649",
"dest": "cargo/vendor/siphasher-1.0.2" "dest": "cargo/vendor/siphasher-1.0.3"
}, },
{ {
"type": "inline", "type": "inline",
"contents": "{\"package\": \"b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e\", \"files\": {}}", "contents": "{\"package\": \"8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649\", \"files\": {}}",
"dest": "cargo/vendor/siphasher-1.0.2", "dest": "cargo/vendor/siphasher-1.0.3",
"dest-filename": ".cargo-checksum.json" "dest-filename": ".cargo-checksum.json"
}, },
{ {
@@ -4727,14 +4649,14 @@
{ {
"type": "archive", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/tao/tao-0.35.0.crate", "url": "https://static.crates.io/crates/tao/tao-0.35.2.crate",
"sha256": "1cf65722394c2ac443e80120064987f8914ee1d4e4e36e63cdf10f2990f01159", "sha256": "a33f7f9e486ade65fcf1e45c440f9236c904f5c1002cdc7fc6ae582777345ce4",
"dest": "cargo/vendor/tao-0.35.0" "dest": "cargo/vendor/tao-0.35.2"
}, },
{ {
"type": "inline", "type": "inline",
"contents": "{\"package\": \"1cf65722394c2ac443e80120064987f8914ee1d4e4e36e63cdf10f2990f01159\", \"files\": {}}", "contents": "{\"package\": \"a33f7f9e486ade65fcf1e45c440f9236c904f5c1002cdc7fc6ae582777345ce4\", \"files\": {}}",
"dest": "cargo/vendor/tao-0.35.0", "dest": "cargo/vendor/tao-0.35.2",
"dest-filename": ".cargo-checksum.json" "dest-filename": ".cargo-checksum.json"
}, },
{ {
@@ -4766,79 +4688,79 @@
{ {
"type": "archive", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/tauri/tauri-2.11.0.crate", "url": "https://static.crates.io/crates/tauri/tauri-2.11.2.crate",
"sha256": "d059f2527558d9dba6f186dec4772610e1aecfd3f94002397613e7e648752b66", "sha256": "437404997acf375d85f1177afa7e11bb971f274ed6a7b83a2a3e339015f4cc28",
"dest": "cargo/vendor/tauri-2.11.0" "dest": "cargo/vendor/tauri-2.11.2"
}, },
{ {
"type": "inline", "type": "inline",
"contents": "{\"package\": \"d059f2527558d9dba6f186dec4772610e1aecfd3f94002397613e7e648752b66\", \"files\": {}}", "contents": "{\"package\": \"437404997acf375d85f1177afa7e11bb971f274ed6a7b83a2a3e339015f4cc28\", \"files\": {}}",
"dest": "cargo/vendor/tauri-2.11.0", "dest": "cargo/vendor/tauri-2.11.2",
"dest-filename": ".cargo-checksum.json" "dest-filename": ".cargo-checksum.json"
}, },
{ {
"type": "archive", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/tauri-build/tauri-build-2.6.0.crate", "url": "https://static.crates.io/crates/tauri-build/tauri-build-2.6.2.crate",
"sha256": "be9aa8c59a894f76c29a002501c589de5eb4987a5913d62a6e0a47f320901988", "sha256": "4aa1f9055fc23919a54e4e125052bed16ed04aef0487086e758fe01a67b451c7",
"dest": "cargo/vendor/tauri-build-2.6.0" "dest": "cargo/vendor/tauri-build-2.6.2"
}, },
{ {
"type": "inline", "type": "inline",
"contents": "{\"package\": \"be9aa8c59a894f76c29a002501c589de5eb4987a5913d62a6e0a47f320901988\", \"files\": {}}", "contents": "{\"package\": \"4aa1f9055fc23919a54e4e125052bed16ed04aef0487086e758fe01a67b451c7\", \"files\": {}}",
"dest": "cargo/vendor/tauri-build-2.6.0", "dest": "cargo/vendor/tauri-build-2.6.2",
"dest-filename": ".cargo-checksum.json" "dest-filename": ".cargo-checksum.json"
}, },
{ {
"type": "archive", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/tauri-codegen/tauri-codegen-2.6.0.crate", "url": "https://static.crates.io/crates/tauri-codegen/tauri-codegen-2.6.2.crate",
"sha256": "d3e4e8230d565106aa19dfbaa01a7ed01abf78047fe0577a83377224bd1bf20e", "sha256": "e4a0319528a025a38c4078e7dae2c446f4e63620ddb0659a643ede1cb38f90e9",
"dest": "cargo/vendor/tauri-codegen-2.6.0" "dest": "cargo/vendor/tauri-codegen-2.6.2"
}, },
{ {
"type": "inline", "type": "inline",
"contents": "{\"package\": \"d3e4e8230d565106aa19dfbaa01a7ed01abf78047fe0577a83377224bd1bf20e\", \"files\": {}}", "contents": "{\"package\": \"e4a0319528a025a38c4078e7dae2c446f4e63620ddb0659a643ede1cb38f90e9\", \"files\": {}}",
"dest": "cargo/vendor/tauri-codegen-2.6.0", "dest": "cargo/vendor/tauri-codegen-2.6.2",
"dest-filename": ".cargo-checksum.json" "dest-filename": ".cargo-checksum.json"
}, },
{ {
"type": "archive", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/tauri-macros/tauri-macros-2.6.0.crate", "url": "https://static.crates.io/crates/tauri-macros/tauri-macros-2.6.2.crate",
"sha256": "bc8de2cddbbc33dbdf4c84f170121886595efdbcc9cb4b3d76342b79d082cedc", "sha256": "ae6cb4e3896c21d2f6da5b31251d2faea0153bba56ed0e970f918115dbee4924",
"dest": "cargo/vendor/tauri-macros-2.6.0" "dest": "cargo/vendor/tauri-macros-2.6.2"
}, },
{ {
"type": "inline", "type": "inline",
"contents": "{\"package\": \"bc8de2cddbbc33dbdf4c84f170121886595efdbcc9cb4b3d76342b79d082cedc\", \"files\": {}}", "contents": "{\"package\": \"ae6cb4e3896c21d2f6da5b31251d2faea0153bba56ed0e970f918115dbee4924\", \"files\": {}}",
"dest": "cargo/vendor/tauri-macros-2.6.0", "dest": "cargo/vendor/tauri-macros-2.6.2",
"dest-filename": ".cargo-checksum.json" "dest-filename": ".cargo-checksum.json"
}, },
{ {
"type": "archive", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/tauri-plugin/tauri-plugin-2.6.0.crate", "url": "https://static.crates.io/crates/tauri-plugin/tauri-plugin-2.6.2.crate",
"sha256": "f8d5f58bfd0cdcfdbc0a68dc08b354eea2afc551b421de91b07b69e0dd769d57", "sha256": "e126abc9e84e35cdfd01596140a73a1850cdb0df0a23acf0185776c30b469a6e",
"dest": "cargo/vendor/tauri-plugin-2.6.0" "dest": "cargo/vendor/tauri-plugin-2.6.2"
}, },
{ {
"type": "inline", "type": "inline",
"contents": "{\"package\": \"f8d5f58bfd0cdcfdbc0a68dc08b354eea2afc551b421de91b07b69e0dd769d57\", \"files\": {}}", "contents": "{\"package\": \"e126abc9e84e35cdfd01596140a73a1850cdb0df0a23acf0185776c30b469a6e\", \"files\": {}}",
"dest": "cargo/vendor/tauri-plugin-2.6.0", "dest": "cargo/vendor/tauri-plugin-2.6.2",
"dest-filename": ".cargo-checksum.json" "dest-filename": ".cargo-checksum.json"
}, },
{ {
"type": "archive", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/tauri-plugin-dialog/tauri-plugin-dialog-2.7.0.crate", "url": "https://static.crates.io/crates/tauri-plugin-dialog/tauri-plugin-dialog-2.7.1.crate",
"sha256": "a1fa4150c95ae391946cc8b8f905ab14797427caba3a8a2f79628e956da91809", "sha256": "65981abb771e74e571a38196c3baa11c459379164791eba0e67abc1a5fac9884",
"dest": "cargo/vendor/tauri-plugin-dialog-2.7.0" "dest": "cargo/vendor/tauri-plugin-dialog-2.7.1"
}, },
{ {
"type": "inline", "type": "inline",
"contents": "{\"package\": \"a1fa4150c95ae391946cc8b8f905ab14797427caba3a8a2f79628e956da91809\", \"files\": {}}", "contents": "{\"package\": \"65981abb771e74e571a38196c3baa11c459379164791eba0e67abc1a5fac9884\", \"files\": {}}",
"dest": "cargo/vendor/tauri-plugin-dialog-2.7.0", "dest": "cargo/vendor/tauri-plugin-dialog-2.7.1",
"dest-filename": ".cargo-checksum.json" "dest-filename": ".cargo-checksum.json"
}, },
{ {
@@ -4862,27 +4784,27 @@
{ {
"type": "archive", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/tauri-plugin-fs/tauri-plugin-fs-2.5.0.crate", "url": "https://static.crates.io/crates/tauri-plugin-fs/tauri-plugin-fs-2.5.1.crate",
"sha256": "36e1ec28b79f3d0683f4507e1615c36292c0ea6716668770d4396b9b39871ed8", "sha256": "b7ecc274121aca0c036a2b42d1cbe83d368d348f54e0bb8a735c2b1548e8f371",
"dest": "cargo/vendor/tauri-plugin-fs-2.5.0" "dest": "cargo/vendor/tauri-plugin-fs-2.5.1"
}, },
{ {
"type": "inline", "type": "inline",
"contents": "{\"package\": \"36e1ec28b79f3d0683f4507e1615c36292c0ea6716668770d4396b9b39871ed8\", \"files\": {}}", "contents": "{\"package\": \"b7ecc274121aca0c036a2b42d1cbe83d368d348f54e0bb8a735c2b1548e8f371\", \"files\": {}}",
"dest": "cargo/vendor/tauri-plugin-fs-2.5.0", "dest": "cargo/vendor/tauri-plugin-fs-2.5.1",
"dest-filename": ".cargo-checksum.json" "dest-filename": ".cargo-checksum.json"
}, },
{ {
"type": "archive", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/tauri-plugin-http/tauri-plugin-http-2.5.8.crate", "url": "https://static.crates.io/crates/tauri-plugin-http/tauri-plugin-http-2.5.9.crate",
"sha256": "cfba7d4ec72763f9d1fdf73c217747f01e2c84b08b87a8cacd2f94f35853f84d", "sha256": "b5bd512048e1985b7ec78f96d99083e2ddaf7e0d906b2b63c44ce5bb8b894067",
"dest": "cargo/vendor/tauri-plugin-http-2.5.8" "dest": "cargo/vendor/tauri-plugin-http-2.5.9"
}, },
{ {
"type": "inline", "type": "inline",
"contents": "{\"package\": \"cfba7d4ec72763f9d1fdf73c217747f01e2c84b08b87a8cacd2f94f35853f84d\", \"files\": {}}", "contents": "{\"package\": \"b5bd512048e1985b7ec78f96d99083e2ddaf7e0d906b2b63c44ce5bb8b894067\", \"files\": {}}",
"dest": "cargo/vendor/tauri-plugin-http-2.5.8", "dest": "cargo/vendor/tauri-plugin-http-2.5.9",
"dest-filename": ".cargo-checksum.json" "dest-filename": ".cargo-checksum.json"
}, },
{ {
@@ -4927,53 +4849,53 @@
{ {
"type": "archive", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/tauri-plugin-store/tauri-plugin-store-2.4.2.crate", "url": "https://static.crates.io/crates/tauri-plugin-store/tauri-plugin-store-2.4.3.crate",
"sha256": "5ca1a8ff83c269b115e98726ffc13f9e548a10161544a92ad121d6d0a96e16ea", "sha256": "6c72dda16786eb4a3f903e43a17b64d8d78dc0f00fe2aa4b757c28f617a8630b",
"dest": "cargo/vendor/tauri-plugin-store-2.4.2" "dest": "cargo/vendor/tauri-plugin-store-2.4.3"
}, },
{ {
"type": "inline", "type": "inline",
"contents": "{\"package\": \"5ca1a8ff83c269b115e98726ffc13f9e548a10161544a92ad121d6d0a96e16ea\", \"files\": {}}", "contents": "{\"package\": \"6c72dda16786eb4a3f903e43a17b64d8d78dc0f00fe2aa4b757c28f617a8630b\", \"files\": {}}",
"dest": "cargo/vendor/tauri-plugin-store-2.4.2", "dest": "cargo/vendor/tauri-plugin-store-2.4.3",
"dest-filename": ".cargo-checksum.json" "dest-filename": ".cargo-checksum.json"
}, },
{ {
"type": "archive", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/tauri-runtime/tauri-runtime-2.11.0.crate", "url": "https://static.crates.io/crates/tauri-runtime/tauri-runtime-2.11.2.crate",
"sha256": "1e42bbcb76237351fbaa02f08d808c537dc12eb5a6eabbf3e517b50056334d95", "sha256": "48222d7116c8807eaa6fe2f372e023fae125084e61e6eca6d70b7961cdf129ef",
"dest": "cargo/vendor/tauri-runtime-2.11.0" "dest": "cargo/vendor/tauri-runtime-2.11.2"
}, },
{ {
"type": "inline", "type": "inline",
"contents": "{\"package\": \"1e42bbcb76237351fbaa02f08d808c537dc12eb5a6eabbf3e517b50056334d95\", \"files\": {}}", "contents": "{\"package\": \"48222d7116c8807eaa6fe2f372e023fae125084e61e6eca6d70b7961cdf129ef\", \"files\": {}}",
"dest": "cargo/vendor/tauri-runtime-2.11.0", "dest": "cargo/vendor/tauri-runtime-2.11.2",
"dest-filename": ".cargo-checksum.json" "dest-filename": ".cargo-checksum.json"
}, },
{ {
"type": "archive", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/tauri-runtime-wry/tauri-runtime-wry-2.11.0.crate", "url": "https://static.crates.io/crates/tauri-runtime-wry/tauri-runtime-wry-2.11.2.crate",
"sha256": "2cadb13dad0c681e1e0a2c49ae488f0e2906ded3d57e7a0017f4aaf46e387117", "sha256": "b83849ee63ecb27a8e8d0fe51915ca215076914aca43f96db1179f0f415f6cd9",
"dest": "cargo/vendor/tauri-runtime-wry-2.11.0" "dest": "cargo/vendor/tauri-runtime-wry-2.11.2"
}, },
{ {
"type": "inline", "type": "inline",
"contents": "{\"package\": \"2cadb13dad0c681e1e0a2c49ae488f0e2906ded3d57e7a0017f4aaf46e387117\", \"files\": {}}", "contents": "{\"package\": \"b83849ee63ecb27a8e8d0fe51915ca215076914aca43f96db1179f0f415f6cd9\", \"files\": {}}",
"dest": "cargo/vendor/tauri-runtime-wry-2.11.0", "dest": "cargo/vendor/tauri-runtime-wry-2.11.2",
"dest-filename": ".cargo-checksum.json" "dest-filename": ".cargo-checksum.json"
}, },
{ {
"type": "archive", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/tauri-utils/tauri-utils-2.9.0.crate", "url": "https://static.crates.io/crates/tauri-utils/tauri-utils-2.9.2.crate",
"sha256": "55f61d2bf7188fbcf2b0ed095b67a6bc498f713c939314bb19eb700118a573b7", "sha256": "092379df9a707631978e6c56b1bc2401d387f01e2d4a3c123360d167bbb9aa95",
"dest": "cargo/vendor/tauri-utils-2.9.0" "dest": "cargo/vendor/tauri-utils-2.9.2"
}, },
{ {
"type": "inline", "type": "inline",
"contents": "{\"package\": \"55f61d2bf7188fbcf2b0ed095b67a6bc498f713c939314bb19eb700118a573b7\", \"files\": {}}", "contents": "{\"package\": \"092379df9a707631978e6c56b1bc2401d387f01e2d4a3c123360d167bbb9aa95\", \"files\": {}}",
"dest": "cargo/vendor/tauri-utils-2.9.0", "dest": "cargo/vendor/tauri-utils-2.9.2",
"dest-filename": ".cargo-checksum.json" "dest-filename": ".cargo-checksum.json"
}, },
{ {
@@ -5148,14 +5070,14 @@
{ {
"type": "archive", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/tokio/tokio-1.52.1.crate", "url": "https://static.crates.io/crates/tokio/tokio-1.52.3.crate",
"sha256": "b67dee974fe86fd92cc45b7a95fdd2f99a36a6d7b0d431a231178d3d670bbcc6", "sha256": "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe",
"dest": "cargo/vendor/tokio-1.52.1" "dest": "cargo/vendor/tokio-1.52.3"
}, },
{ {
"type": "inline", "type": "inline",
"contents": "{\"package\": \"b67dee974fe86fd92cc45b7a95fdd2f99a36a6d7b0d431a231178d3d670bbcc6\", \"files\": {}}", "contents": "{\"package\": \"8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe\", \"files\": {}}",
"dest": "cargo/vendor/tokio-1.52.1", "dest": "cargo/vendor/tokio-1.52.3",
"dest-filename": ".cargo-checksum.json" "dest-filename": ".cargo-checksum.json"
}, },
{ {
@@ -5369,14 +5291,14 @@
{ {
"type": "archive", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/tower-http/tower-http-0.6.8.crate", "url": "https://static.crates.io/crates/tower-http/tower-http-0.6.10.crate",
"sha256": "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8", "sha256": "68d6fdd9f81c2819c9a8b0e0cd91660e7746a8e6ea2ba7c6b2b057985f6bcb51",
"dest": "cargo/vendor/tower-http-0.6.8" "dest": "cargo/vendor/tower-http-0.6.10"
}, },
{ {
"type": "inline", "type": "inline",
"contents": "{\"package\": \"d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8\", \"files\": {}}", "contents": "{\"package\": \"68d6fdd9f81c2819c9a8b0e0cd91660e7746a8e6ea2ba7c6b2b057985f6bcb51\", \"files\": {}}",
"dest": "cargo/vendor/tower-http-0.6.8", "dest": "cargo/vendor/tower-http-0.6.10",
"dest-filename": ".cargo-checksum.json" "dest-filename": ".cargo-checksum.json"
}, },
{ {
@@ -5837,66 +5759,66 @@
{ {
"type": "archive", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/wasm-bindgen/wasm-bindgen-0.2.120.crate", "url": "https://static.crates.io/crates/wasm-bindgen/wasm-bindgen-0.2.121.crate",
"sha256": "df52b6d9b87e0c74c9edfa1eb2d9bf85e5d63515474513aa50fa181b3c4f5db1", "sha256": "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790",
"dest": "cargo/vendor/wasm-bindgen-0.2.120" "dest": "cargo/vendor/wasm-bindgen-0.2.121"
}, },
{ {
"type": "inline", "type": "inline",
"contents": "{\"package\": \"df52b6d9b87e0c74c9edfa1eb2d9bf85e5d63515474513aa50fa181b3c4f5db1\", \"files\": {}}", "contents": "{\"package\": \"49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790\", \"files\": {}}",
"dest": "cargo/vendor/wasm-bindgen-0.2.120", "dest": "cargo/vendor/wasm-bindgen-0.2.121",
"dest-filename": ".cargo-checksum.json" "dest-filename": ".cargo-checksum.json"
}, },
{ {
"type": "archive", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/wasm-bindgen-futures/wasm-bindgen-futures-0.4.70.crate", "url": "https://static.crates.io/crates/wasm-bindgen-futures/wasm-bindgen-futures-0.4.71.crate",
"sha256": "af934872acec734c2d80e6617bbb5ff4f12b052dd8e6332b0817bce889516084", "sha256": "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8",
"dest": "cargo/vendor/wasm-bindgen-futures-0.4.70" "dest": "cargo/vendor/wasm-bindgen-futures-0.4.71"
}, },
{ {
"type": "inline", "type": "inline",
"contents": "{\"package\": \"af934872acec734c2d80e6617bbb5ff4f12b052dd8e6332b0817bce889516084\", \"files\": {}}", "contents": "{\"package\": \"96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8\", \"files\": {}}",
"dest": "cargo/vendor/wasm-bindgen-futures-0.4.70", "dest": "cargo/vendor/wasm-bindgen-futures-0.4.71",
"dest-filename": ".cargo-checksum.json" "dest-filename": ".cargo-checksum.json"
}, },
{ {
"type": "archive", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/wasm-bindgen-macro/wasm-bindgen-macro-0.2.120.crate", "url": "https://static.crates.io/crates/wasm-bindgen-macro/wasm-bindgen-macro-0.2.121.crate",
"sha256": "78b1041f495fb322e64aca85f5756b2172e35cd459376e67f2a6c9dffcedb103", "sha256": "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578",
"dest": "cargo/vendor/wasm-bindgen-macro-0.2.120" "dest": "cargo/vendor/wasm-bindgen-macro-0.2.121"
}, },
{ {
"type": "inline", "type": "inline",
"contents": "{\"package\": \"78b1041f495fb322e64aca85f5756b2172e35cd459376e67f2a6c9dffcedb103\", \"files\": {}}", "contents": "{\"package\": \"8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578\", \"files\": {}}",
"dest": "cargo/vendor/wasm-bindgen-macro-0.2.120", "dest": "cargo/vendor/wasm-bindgen-macro-0.2.121",
"dest-filename": ".cargo-checksum.json" "dest-filename": ".cargo-checksum.json"
}, },
{ {
"type": "archive", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/wasm-bindgen-macro-support/wasm-bindgen-macro-support-0.2.120.crate", "url": "https://static.crates.io/crates/wasm-bindgen-macro-support/wasm-bindgen-macro-support-0.2.121.crate",
"sha256": "9dcd0ff20416988a18ac686d4d4d0f6aae9ebf08a389ff5d29012b05af2a1b41", "sha256": "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2",
"dest": "cargo/vendor/wasm-bindgen-macro-support-0.2.120" "dest": "cargo/vendor/wasm-bindgen-macro-support-0.2.121"
}, },
{ {
"type": "inline", "type": "inline",
"contents": "{\"package\": \"9dcd0ff20416988a18ac686d4d4d0f6aae9ebf08a389ff5d29012b05af2a1b41\", \"files\": {}}", "contents": "{\"package\": \"d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2\", \"files\": {}}",
"dest": "cargo/vendor/wasm-bindgen-macro-support-0.2.120", "dest": "cargo/vendor/wasm-bindgen-macro-support-0.2.121",
"dest-filename": ".cargo-checksum.json" "dest-filename": ".cargo-checksum.json"
}, },
{ {
"type": "archive", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/wasm-bindgen-shared/wasm-bindgen-shared-0.2.120.crate", "url": "https://static.crates.io/crates/wasm-bindgen-shared/wasm-bindgen-shared-0.2.121.crate",
"sha256": "49757b3c82ebf16c57d69365a142940b384176c24df52a087fb748e2085359ea", "sha256": "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441",
"dest": "cargo/vendor/wasm-bindgen-shared-0.2.120" "dest": "cargo/vendor/wasm-bindgen-shared-0.2.121"
}, },
{ {
"type": "inline", "type": "inline",
"contents": "{\"package\": \"49757b3c82ebf16c57d69365a142940b384176c24df52a087fb748e2085359ea\", \"files\": {}}", "contents": "{\"package\": \"c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441\", \"files\": {}}",
"dest": "cargo/vendor/wasm-bindgen-shared-0.2.120", "dest": "cargo/vendor/wasm-bindgen-shared-0.2.121",
"dest-filename": ".cargo-checksum.json" "dest-filename": ".cargo-checksum.json"
}, },
{ {
@@ -5954,14 +5876,14 @@
{ {
"type": "archive", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/web-sys/web-sys-0.3.97.crate", "url": "https://static.crates.io/crates/web-sys/web-sys-0.3.98.crate",
"sha256": "2eadbac71025cd7b0834f20d1fe8472e8495821b4e9801eb0a60bd1f19827602", "sha256": "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa",
"dest": "cargo/vendor/web-sys-0.3.97" "dest": "cargo/vendor/web-sys-0.3.98"
}, },
{ {
"type": "inline", "type": "inline",
"contents": "{\"package\": \"2eadbac71025cd7b0834f20d1fe8472e8495821b4e9801eb0a60bd1f19827602\", \"files\": {}}", "contents": "{\"package\": \"4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa\", \"files\": {}}",
"dest": "cargo/vendor/web-sys-0.3.97", "dest": "cargo/vendor/web-sys-0.3.98",
"dest-filename": ".cargo-checksum.json" "dest-filename": ".cargo-checksum.json"
}, },
{ {
@@ -7046,14 +6968,14 @@
{ {
"type": "archive", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/winnow/winnow-1.0.2.crate", "url": "https://static.crates.io/crates/winnow/winnow-1.0.3.crate",
"sha256": "2ee1708bef14716a11bae175f579062d4554d95be2c6829f518df847b7b3fdd0", "sha256": "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1",
"dest": "cargo/vendor/winnow-1.0.2" "dest": "cargo/vendor/winnow-1.0.3"
}, },
{ {
"type": "inline", "type": "inline",
"contents": "{\"package\": \"2ee1708bef14716a11bae175f579062d4554d95be2c6829f518df847b7b3fdd0\", \"files\": {}}", "contents": "{\"package\": \"0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1\", \"files\": {}}",
"dest": "cargo/vendor/winnow-1.0.2", "dest": "cargo/vendor/winnow-1.0.3",
"dest-filename": ".cargo-checksum.json" "dest-filename": ".cargo-checksum.json"
}, },
{ {
@@ -7176,14 +7098,14 @@
{ {
"type": "archive", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/wry/wry-0.55.0.crate", "url": "https://static.crates.io/crates/wry/wry-0.55.1.crate",
"sha256": "3013fd6116aac351dd2e18f349b28b2cfef3a5ff3253a9d0ce2d7193bb1b4429", "sha256": "186f9871daa55fd9c016578b810d149de58367113db7fb72b462d2323ce19514",
"dest": "cargo/vendor/wry-0.55.0" "dest": "cargo/vendor/wry-0.55.1"
}, },
{ {
"type": "inline", "type": "inline",
"contents": "{\"package\": \"3013fd6116aac351dd2e18f349b28b2cfef3a5ff3253a9d0ce2d7193bb1b4429\", \"files\": {}}", "contents": "{\"package\": \"186f9871daa55fd9c016578b810d149de58367113db7fb72b462d2323ce19514\", \"files\": {}}",
"dest": "cargo/vendor/wry-0.55.0", "dest": "cargo/vendor/wry-0.55.1",
"dest-filename": ".cargo-checksum.json" "dest-filename": ".cargo-checksum.json"
}, },
{ {
@@ -7267,14 +7189,14 @@
{ {
"type": "archive", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/zerofrom/zerofrom-0.1.7.crate", "url": "https://static.crates.io/crates/zerofrom/zerofrom-0.1.8.crate",
"sha256": "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df", "sha256": "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272",
"dest": "cargo/vendor/zerofrom-0.1.7" "dest": "cargo/vendor/zerofrom-0.1.8"
}, },
{ {
"type": "inline", "type": "inline",
"contents": "{\"package\": \"69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df\", \"files\": {}}", "contents": "{\"package\": \"0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272\", \"files\": {}}",
"dest": "cargo/vendor/zerofrom-0.1.7", "dest": "cargo/vendor/zerofrom-0.1.8",
"dest-filename": ".cargo-checksum.json" "dest-filename": ".cargo-checksum.json"
}, },
{ {
+477 -890
View File
File diff suppressed because it is too large Load Diff
+104 -162
View File
@@ -163,6 +163,15 @@ dependencies = [
"alloc-stdlib", "alloc-stdlib",
] ]
[[package]]
name = "bs58"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4"
dependencies = [
"tinyvec",
]
[[package]] [[package]]
name = "bumpalo" name = "bumpalo"
version = "3.20.2" version = "3.20.2"
@@ -259,9 +268,9 @@ dependencies = [
[[package]] [[package]]
name = "cc" name = "cc"
version = "1.2.61" version = "1.2.62"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d16d90359e986641506914ba71350897565610e87ce0ad9e6f28569db3dd5c6d" checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98"
dependencies = [ dependencies = [
"find-msvc-tools", "find-msvc-tools",
"shlex", "shlex",
@@ -478,7 +487,7 @@ dependencies = [
"cssparser-macros", "cssparser-macros",
"dtoa-short", "dtoa-short",
"itoa", "itoa",
"phf 0.13.1", "phf",
"smallvec", "smallvec",
] ]
@@ -1331,9 +1340,9 @@ dependencies = [
[[package]] [[package]]
name = "h2" name = "h2"
version = "0.4.13" version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733"
dependencies = [ dependencies = [
"atomic-waker", "atomic-waker",
"bytes", "bytes",
@@ -1365,9 +1374,9 @@ dependencies = [
[[package]] [[package]]
name = "hashbrown" name = "hashbrown"
version = "0.17.0" version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
[[package]] [[package]]
name = "heck" name = "heck"
@@ -1681,7 +1690,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
dependencies = [ dependencies = [
"equivalent", "equivalent",
"hashbrown 0.17.0", "hashbrown 0.17.1",
"serde", "serde",
"serde_core", "serde_core",
] ]
@@ -1701,16 +1710,6 @@ version = "2.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
[[package]]
name = "iri-string"
version = "0.7.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20"
dependencies = [
"memchr",
"serde",
]
[[package]] [[package]]
name = "is-docker" name = "is-docker"
version = "0.2.0" version = "0.2.0"
@@ -1805,9 +1804,9 @@ dependencies = [
[[package]] [[package]]
name = "js-sys" name = "js-sys"
version = "0.3.97" version = "0.3.98"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1840c94c045fbcf8ba2812c95db44499f7c64910a912551aaaa541decebcacf" checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"futures-util", "futures-util",
@@ -2006,7 +2005,7 @@ dependencies = [
[[package]] [[package]]
name = "moku" name = "moku"
version = "0.9.2" version = "0.9.4"
dependencies = [ dependencies = [
"dirs 5.0.1", "dirs 5.0.1",
"reqwest 0.12.28", "reqwest 0.12.28",
@@ -2368,9 +2367,9 @@ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]] [[package]]
name = "open" name = "open"
version = "5.3.4" version = "5.3.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f3bab717c29a857abf75fcef718d441ec7cb2725f937343c734740a985d37fd" checksum = "2fbaa89d2ddc8473c78a3adf69eea8cffa28c483b8e02a971ef31527cd0fc92c"
dependencies = [ dependencies = [
"dunce", "dunce",
"is-wsl", "is-wsl",
@@ -2380,15 +2379,14 @@ dependencies = [
[[package]] [[package]]
name = "openssl" name = "openssl"
version = "0.10.78" version = "0.10.80"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f38c4372413cdaaf3cc79dd92d29d7d9f5ab09b51b10dded508fb90bb70b9222" checksum = "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967"
dependencies = [ dependencies = [
"bitflags 2.11.1", "bitflags 2.11.1",
"cfg-if", "cfg-if",
"foreign-types 0.3.2", "foreign-types 0.3.2",
"libc", "libc",
"once_cell",
"openssl-macros", "openssl-macros",
"openssl-sys", "openssl-sys",
] ]
@@ -2412,9 +2410,9 @@ checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
[[package]] [[package]]
name = "openssl-sys" name = "openssl-sys"
version = "0.9.114" version = "0.9.116"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13ce1245cd07fcc4cfdb438f7507b0c7e4f3849a69fd84d52374c66d83741bb6" checksum = "f28a22dc7140cda5f096e5e7724a6962ca81a7f8bfd2979f9b18c11af56318c4"
dependencies = [ dependencies = [
"cc", "cc",
"libc", "libc",
@@ -2514,24 +2512,14 @@ version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "phf"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078"
dependencies = [
"phf_macros 0.11.3",
"phf_shared 0.11.3",
]
[[package]] [[package]]
name = "phf" name = "phf"
version = "0.13.1" version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf"
dependencies = [ dependencies = [
"phf_macros 0.13.1", "phf_macros",
"phf_shared 0.13.1", "phf_shared",
"serde", "serde",
] ]
@@ -2541,18 +2529,8 @@ version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1"
dependencies = [ dependencies = [
"phf_generator 0.13.1", "phf_generator",
"phf_shared 0.13.1", "phf_shared",
]
[[package]]
name = "phf_generator"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
dependencies = [
"phf_shared 0.11.3",
"rand 0.8.6",
] ]
[[package]] [[package]]
@@ -2562,20 +2540,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737"
dependencies = [ dependencies = [
"fastrand", "fastrand",
"phf_shared 0.13.1", "phf_shared",
]
[[package]]
name = "phf_macros"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216"
dependencies = [
"phf_generator 0.11.3",
"phf_shared 0.11.3",
"proc-macro2",
"quote",
"syn 2.0.117",
] ]
[[package]] [[package]]
@@ -2584,22 +2549,13 @@ version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef"
dependencies = [ dependencies = [
"phf_generator 0.13.1", "phf_generator",
"phf_shared 0.13.1", "phf_shared",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.117", "syn 2.0.117",
] ]
[[package]]
name = "phf_shared"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5"
dependencies = [
"siphasher",
]
[[package]] [[package]]
name = "phf_shared" name = "phf_shared"
version = "0.13.1" version = "0.13.1"
@@ -2780,9 +2736,9 @@ dependencies = [
[[package]] [[package]]
name = "quick-xml" name = "quick-xml"
version = "0.39.2" version = "0.39.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "958f21e8e7ceb5a1aa7fa87fab28e7c75976e0bfe7e23ff069e0a260f894067d" checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e"
dependencies = [ dependencies = [
"memchr", "memchr",
] ]
@@ -2816,7 +2772,7 @@ dependencies = [
"bytes", "bytes",
"getrandom 0.3.4", "getrandom 0.3.4",
"lru-slab", "lru-slab",
"rand 0.9.4", "rand",
"ring", "ring",
"rustc-hash", "rustc-hash",
"rustls", "rustls",
@@ -2863,15 +2819,6 @@ version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "rand"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
dependencies = [
"rand_core 0.6.4",
]
[[package]] [[package]]
name = "rand" name = "rand"
version = "0.9.4" version = "0.9.4"
@@ -2879,7 +2826,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea"
dependencies = [ dependencies = [
"rand_chacha", "rand_chacha",
"rand_core 0.9.5", "rand_core",
] ]
[[package]] [[package]]
@@ -2889,15 +2836,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [ dependencies = [
"ppv-lite86", "ppv-lite86",
"rand_core 0.9.5", "rand_core",
] ]
[[package]]
name = "rand_core"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
[[package]] [[package]]
name = "rand_core" name = "rand_core"
version = "0.9.5" version = "0.9.5"
@@ -3317,7 +3258,7 @@ dependencies = [
"derive_more", "derive_more",
"log", "log",
"new_debug_unreachable", "new_debug_unreachable",
"phf 0.13.1", "phf",
"phf_codegen", "phf_codegen",
"precomputed-hash", "precomputed-hash",
"rustc-hash", "rustc-hash",
@@ -3444,11 +3385,12 @@ dependencies = [
[[package]] [[package]]
name = "serde_with" name = "serde_with"
version = "3.18.0" version = "3.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dd5414fad8e6907dbdd5bc441a50ae8d6e26151a03b1de04d89a5576de61d01f" checksum = "e72c1c2cb7b223fafb600a619537a871c2818583d619401b785e7c0b746ccde2"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"bs58",
"chrono", "chrono",
"hex", "hex",
"indexmap 1.9.3", "indexmap 1.9.3",
@@ -3463,9 +3405,9 @@ dependencies = [
[[package]] [[package]]
name = "serde_with_macros" name = "serde_with_macros"
version = "3.18.0" version = "3.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3db8978e608f1fe7357e211969fd9abdcae80bac1ba7a3369bb7eb6b404eb65" checksum = "b90c488738ecb4fb0262f41f43bc40efc5868d9fb744319ddf5f5317f417bfac"
dependencies = [ dependencies = [
"darling", "darling",
"proc-macro2", "proc-macro2",
@@ -3571,9 +3513,9 @@ checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
[[package]] [[package]]
name = "siphasher" name = "siphasher"
version = "1.0.2" version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649"
[[package]] [[package]]
name = "slab" name = "slab"
@@ -3659,7 +3601,7 @@ checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901"
dependencies = [ dependencies = [
"new_debug_unreachable", "new_debug_unreachable",
"parking_lot", "parking_lot",
"phf_shared 0.13.1", "phf_shared",
"precomputed-hash", "precomputed-hash",
] ]
@@ -3669,8 +3611,8 @@ version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69" checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69"
dependencies = [ dependencies = [
"phf_generator 0.13.1", "phf_generator",
"phf_shared 0.13.1", "phf_shared",
"proc-macro2", "proc-macro2",
"quote", "quote",
] ]
@@ -3812,9 +3754,9 @@ dependencies = [
[[package]] [[package]]
name = "tao" name = "tao"
version = "0.35.0" version = "0.35.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1cf65722394c2ac443e80120064987f8914ee1d4e4e36e63cdf10f2990f01159" checksum = "a33f7f9e486ade65fcf1e45c440f9236c904f5c1002cdc7fc6ae582777345ce4"
dependencies = [ dependencies = [
"bitflags 2.11.1", "bitflags 2.11.1",
"block2", "block2",
@@ -3869,9 +3811,9 @@ checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1"
[[package]] [[package]]
name = "tauri" name = "tauri"
version = "2.11.0" version = "2.11.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d059f2527558d9dba6f186dec4772610e1aecfd3f94002397613e7e648752b66" checksum = "437404997acf375d85f1177afa7e11bb971f274ed6a7b83a2a3e339015f4cc28"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"bytes", "bytes",
@@ -3920,9 +3862,9 @@ dependencies = [
[[package]] [[package]]
name = "tauri-build" name = "tauri-build"
version = "2.6.0" version = "2.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be9aa8c59a894f76c29a002501c589de5eb4987a5913d62a6e0a47f320901988" checksum = "4aa1f9055fc23919a54e4e125052bed16ed04aef0487086e758fe01a67b451c7"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"cargo_toml", "cargo_toml",
@@ -3941,9 +3883,9 @@ dependencies = [
[[package]] [[package]]
name = "tauri-codegen" name = "tauri-codegen"
version = "2.6.0" version = "2.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3e4e8230d565106aa19dfbaa01a7ed01abf78047fe0577a83377224bd1bf20e" checksum = "e4a0319528a025a38c4078e7dae2c446f4e63620ddb0659a643ede1cb38f90e9"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"brotli", "brotli",
@@ -3968,9 +3910,9 @@ dependencies = [
[[package]] [[package]]
name = "tauri-macros" name = "tauri-macros"
version = "2.6.0" version = "2.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc8de2cddbbc33dbdf4c84f170121886595efdbcc9cb4b3d76342b79d082cedc" checksum = "ae6cb4e3896c21d2f6da5b31251d2faea0153bba56ed0e970f918115dbee4924"
dependencies = [ dependencies = [
"heck 0.5.0", "heck 0.5.0",
"proc-macro2", "proc-macro2",
@@ -3982,9 +3924,9 @@ dependencies = [
[[package]] [[package]]
name = "tauri-plugin" name = "tauri-plugin"
version = "2.6.0" version = "2.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8d5f58bfd0cdcfdbc0a68dc08b354eea2afc551b421de91b07b69e0dd769d57" checksum = "e126abc9e84e35cdfd01596140a73a1850cdb0df0a23acf0185776c30b469a6e"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"glob", "glob",
@@ -3998,9 +3940,9 @@ dependencies = [
[[package]] [[package]]
name = "tauri-plugin-dialog" name = "tauri-plugin-dialog"
version = "2.7.0" version = "2.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1fa4150c95ae391946cc8b8f905ab14797427caba3a8a2f79628e956da91809" checksum = "65981abb771e74e571a38196c3baa11c459379164791eba0e67abc1a5fac9884"
dependencies = [ dependencies = [
"log", "log",
"raw-window-handle", "raw-window-handle",
@@ -4033,9 +3975,9 @@ dependencies = [
[[package]] [[package]]
name = "tauri-plugin-fs" name = "tauri-plugin-fs"
version = "2.5.0" version = "2.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "36e1ec28b79f3d0683f4507e1615c36292c0ea6716668770d4396b9b39871ed8" checksum = "b7ecc274121aca0c036a2b42d1cbe83d368d348f54e0bb8a735c2b1548e8f371"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"dunce", "dunce",
@@ -4051,15 +3993,15 @@ dependencies = [
"tauri-plugin", "tauri-plugin",
"tauri-utils", "tauri-utils",
"thiserror 2.0.18", "thiserror 2.0.18",
"toml 0.9.12+spec-1.1.0", "toml 1.1.2+spec-1.1.0",
"url", "url",
] ]
[[package]] [[package]]
name = "tauri-plugin-http" name = "tauri-plugin-http"
version = "2.5.8" version = "2.5.9"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfba7d4ec72763f9d1fdf73c217747f01e2c84b08b87a8cacd2f94f35853f84d" checksum = "b5bd512048e1985b7ec78f96d99083e2ddaf7e0d906b2b63c44ce5bb8b894067"
dependencies = [ dependencies = [
"bytes", "bytes",
"cookie_store", "cookie_store",
@@ -4130,9 +4072,9 @@ dependencies = [
[[package]] [[package]]
name = "tauri-plugin-store" name = "tauri-plugin-store"
version = "2.4.2" version = "2.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ca1a8ff83c269b115e98726ffc13f9e548a10161544a92ad121d6d0a96e16ea" checksum = "6c72dda16786eb4a3f903e43a17b64d8d78dc0f00fe2aa4b757c28f617a8630b"
dependencies = [ dependencies = [
"dunce", "dunce",
"serde", "serde",
@@ -4146,9 +4088,9 @@ dependencies = [
[[package]] [[package]]
name = "tauri-runtime" name = "tauri-runtime"
version = "2.11.0" version = "2.11.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e42bbcb76237351fbaa02f08d808c537dc12eb5a6eabbf3e517b50056334d95" checksum = "48222d7116c8807eaa6fe2f372e023fae125084e61e6eca6d70b7961cdf129ef"
dependencies = [ dependencies = [
"cookie", "cookie",
"dpi", "dpi",
@@ -4171,9 +4113,9 @@ dependencies = [
[[package]] [[package]]
name = "tauri-runtime-wry" name = "tauri-runtime-wry"
version = "2.11.0" version = "2.11.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2cadb13dad0c681e1e0a2c49ae488f0e2906ded3d57e7a0017f4aaf46e387117" checksum = "b83849ee63ecb27a8e8d0fe51915ca215076914aca43f96db1179f0f415f6cd9"
dependencies = [ dependencies = [
"gtk", "gtk",
"http", "http",
@@ -4197,9 +4139,9 @@ dependencies = [
[[package]] [[package]]
name = "tauri-utils" name = "tauri-utils"
version = "2.9.0" version = "2.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55f61d2bf7188fbcf2b0ed095b67a6bc498f713c939314bb19eb700118a573b7" checksum = "092379df9a707631978e6c56b1bc2401d387f01e2d4a3c123360d167bbb9aa95"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"brotli", "brotli",
@@ -4213,7 +4155,7 @@ dependencies = [
"json-patch", "json-patch",
"log", "log",
"memchr", "memchr",
"phf 0.11.3", "phf",
"plist", "plist",
"proc-macro2", "proc-macro2",
"quote", "quote",
@@ -4365,9 +4307,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]] [[package]]
name = "tokio" name = "tokio"
version = "1.52.1" version = "1.52.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b67dee974fe86fd92cc45b7a95fdd2f99a36a6d7b0d431a231178d3d670bbcc6" checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe"
dependencies = [ dependencies = [
"bytes", "bytes",
"libc", "libc",
@@ -4461,7 +4403,7 @@ dependencies = [
"toml_datetime 1.1.1+spec-1.1.0", "toml_datetime 1.1.1+spec-1.1.0",
"toml_parser", "toml_parser",
"toml_writer", "toml_writer",
"winnow 1.0.2", "winnow 1.0.3",
] ]
[[package]] [[package]]
@@ -4524,7 +4466,7 @@ dependencies = [
"indexmap 2.14.0", "indexmap 2.14.0",
"toml_datetime 1.1.1+spec-1.1.0", "toml_datetime 1.1.1+spec-1.1.0",
"toml_parser", "toml_parser",
"winnow 1.0.2", "winnow 1.0.3",
] ]
[[package]] [[package]]
@@ -4533,7 +4475,7 @@ version = "1.1.2+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526"
dependencies = [ dependencies = [
"winnow 1.0.2", "winnow 1.0.3",
] ]
[[package]] [[package]]
@@ -4559,20 +4501,20 @@ dependencies = [
[[package]] [[package]]
name = "tower-http" name = "tower-http"
version = "0.6.8" version = "0.6.10"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" checksum = "68d6fdd9f81c2819c9a8b0e0cd91660e7746a8e6ea2ba7c6b2b057985f6bcb51"
dependencies = [ dependencies = [
"bitflags 2.11.1", "bitflags 2.11.1",
"bytes", "bytes",
"futures-util", "futures-util",
"http", "http",
"http-body", "http-body",
"iri-string",
"pin-project-lite", "pin-project-lite",
"tower", "tower",
"tower-layer", "tower-layer",
"tower-service", "tower-service",
"url",
] ]
[[package]] [[package]]
@@ -4870,9 +4812,9 @@ dependencies = [
[[package]] [[package]]
name = "wasm-bindgen" name = "wasm-bindgen"
version = "0.2.120" version = "0.2.121"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df52b6d9b87e0c74c9edfa1eb2d9bf85e5d63515474513aa50fa181b3c4f5db1" checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"once_cell", "once_cell",
@@ -4883,9 +4825,9 @@ dependencies = [
[[package]] [[package]]
name = "wasm-bindgen-futures" name = "wasm-bindgen-futures"
version = "0.4.70" version = "0.4.71"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af934872acec734c2d80e6617bbb5ff4f12b052dd8e6332b0817bce889516084" checksum = "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8"
dependencies = [ dependencies = [
"js-sys", "js-sys",
"wasm-bindgen", "wasm-bindgen",
@@ -4893,9 +4835,9 @@ dependencies = [
[[package]] [[package]]
name = "wasm-bindgen-macro" name = "wasm-bindgen-macro"
version = "0.2.120" version = "0.2.121"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78b1041f495fb322e64aca85f5756b2172e35cd459376e67f2a6c9dffcedb103" checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578"
dependencies = [ dependencies = [
"quote", "quote",
"wasm-bindgen-macro-support", "wasm-bindgen-macro-support",
@@ -4903,9 +4845,9 @@ dependencies = [
[[package]] [[package]]
name = "wasm-bindgen-macro-support" name = "wasm-bindgen-macro-support"
version = "0.2.120" version = "0.2.121"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9dcd0ff20416988a18ac686d4d4d0f6aae9ebf08a389ff5d29012b05af2a1b41" checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2"
dependencies = [ dependencies = [
"bumpalo", "bumpalo",
"proc-macro2", "proc-macro2",
@@ -4916,9 +4858,9 @@ dependencies = [
[[package]] [[package]]
name = "wasm-bindgen-shared" name = "wasm-bindgen-shared"
version = "0.2.120" version = "0.2.121"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49757b3c82ebf16c57d69365a142940b384176c24df52a087fb748e2085359ea" checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441"
dependencies = [ dependencies = [
"unicode-ident", "unicode-ident",
] ]
@@ -4972,9 +4914,9 @@ dependencies = [
[[package]] [[package]]
name = "web-sys" name = "web-sys"
version = "0.3.97" version = "0.3.98"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2eadbac71025cd7b0834f20d1fe8472e8495821b4e9801eb0a60bd1f19827602" checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa"
dependencies = [ dependencies = [
"js-sys", "js-sys",
"wasm-bindgen", "wasm-bindgen",
@@ -4996,7 +4938,7 @@ version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7cff6eef815df1834fd250e3a2ff436044d82a9f1bc1980ca1dbdf07effc538" checksum = "d7cff6eef815df1834fd250e3a2ff436044d82a9f1bc1980ca1dbdf07effc538"
dependencies = [ dependencies = [
"phf 0.13.1", "phf",
"phf_codegen", "phf_codegen",
"string_cache", "string_cache",
"string_cache_codegen", "string_cache_codegen",
@@ -5736,9 +5678,9 @@ checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
[[package]] [[package]]
name = "winnow" name = "winnow"
version = "1.0.2" version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2ee1708bef14716a11bae175f579062d4554d95be2c6829f518df847b7b3fdd0" checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1"
dependencies = [ dependencies = [
"memchr", "memchr",
] ]
@@ -5855,9 +5797,9 @@ checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
[[package]] [[package]]
name = "wry" name = "wry"
version = "0.55.0" version = "0.55.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3013fd6116aac351dd2e18f349b28b2cfef3a5ff3253a9d0ce2d7193bb1b4429" checksum = "186f9871daa55fd9c016578b810d149de58367113db7fb72b462d2323ce19514"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"block2", "block2",
@@ -5963,9 +5905,9 @@ dependencies = [
[[package]] [[package]]
name = "zerofrom" name = "zerofrom"
version = "0.1.7" version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
dependencies = [ dependencies = [
"zerofrom-derive", "zerofrom-derive",
] ]
+2 -2
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "moku" name = "moku"
version = "0.9.2" version = "0.9.4"
edition = "2021" edition = "2021"
[lib] [lib]
@@ -15,7 +15,7 @@ path = "src/main.rs"
tauri-build = { version = "2.0", features = [] } tauri-build = { version = "2.0", features = [] }
[dependencies] [dependencies]
tauri = { version = "2.0", features = [] } tauri = { version = "2.0", features = ["tray-icon"] }
tauri-plugin-shell = "2" tauri-plugin-shell = "2"
tauri-plugin-process = "2" tauri-plugin-process = "2"
tauri-plugin-http = "2" tauri-plugin-http = "2"
@@ -61,12 +61,12 @@ if [ ! -f "$DATA_DIR/server.conf" ]; then
cat > "$DATA_DIR/server.conf" << 'EOF' cat > "$DATA_DIR/server.conf" << 'EOF'
server.ip = "127.0.0.1" server.ip = "127.0.0.1"
server.port = 4567 server.port = 4567
server.webUIEnabled = false server.webUIEnabled = true
server.initialOpenInBrowserEnabled = false server.initialOpenInBrowserEnabled = false
server.systemTrayEnabled = false server.systemTrayEnabled = false
server.webUIInterface = "browser" server.webUIInterface = "browser"
server.webUIFlavor = "WebUI" server.webUIFlavor = "WebUI"
server.webUIChannel = "stable" server.webUIChannel = "PREVIEW"
server.electronPath = "" server.electronPath = ""
server.debugLogsEnabled = false server.debugLogsEnabled = false
server.downloadAsCbz = true server.downloadAsCbz = true
@@ -79,13 +79,13 @@ fi
# ── Force-patch the three keys that cause JCEF/GUI crashes ──────────────────── # ── Force-patch the three keys that cause JCEF/GUI crashes ────────────────────
sed -i \ sed -i \
-e 's|server\.webUIEnabled.*|server.webUIEnabled = false|' \ -e 's|server\.webUIEnabled.*|server.webUIEnabled = true|' \
-e 's|server\.initialOpenInBrowserEnabled.*|server.initialOpenInBrowserEnabled = false|' \ -e 's|server\.initialOpenInBrowserEnabled.*|server.initialOpenInBrowserEnabled = false|' \
-e 's|server\.systemTrayEnabled.*|server.systemTrayEnabled = false|' \ -e 's|server\.systemTrayEnabled.*|server.systemTrayEnabled = false|' \
"$DATA_DIR/server.conf" "$DATA_DIR/server.conf"
# Append keys if absent (e.g. user-managed conf missing them) # Append keys if absent (e.g. user-managed conf missing them)
grep -q 'server\.webUIEnabled' "$DATA_DIR/server.conf" || echo 'server.webUIEnabled = false' >> "$DATA_DIR/server.conf" grep -q 'server\.webUIEnabled' "$DATA_DIR/server.conf" || echo 'server.webUIEnabled = true' >> "$DATA_DIR/server.conf"
grep -q 'server\.initialOpenInBrowserEnabled' "$DATA_DIR/server.conf" || echo 'server.initialOpenInBrowserEnabled = false' >> "$DATA_DIR/server.conf" grep -q 'server\.initialOpenInBrowserEnabled' "$DATA_DIR/server.conf" || echo 'server.initialOpenInBrowserEnabled = false' >> "$DATA_DIR/server.conf"
grep -q 'server\.systemTrayEnabled' "$DATA_DIR/server.conf" || echo 'server.systemTrayEnabled = false' >> "$DATA_DIR/server.conf" grep -q 'server\.systemTrayEnabled' "$DATA_DIR/server.conf" || echo 'server.systemTrayEnabled = false' >> "$DATA_DIR/server.conf"
+8 -1
View File
@@ -2,9 +2,15 @@
"$schema": "../gen/schemas/desktop-schema.json", "$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default", "identifier": "default",
"description": "Default permissions for Moku", "description": "Default permissions for Moku",
"windows": ["main"], "windows": [
"main"
],
"permissions": [ "permissions": [
"core:default", "core:default",
"core:tray:default",
"core:app:allow-default-window-icon",
"core:window:allow-hide",
"core:window:allow-show",
"shell:allow-open", "shell:allow-open",
"shell:allow-kill", "shell:allow-kill",
"shell:allow-spawn", "shell:allow-spawn",
@@ -27,6 +33,7 @@
"core:window:allow-outer-position", "core:window:allow-outer-position",
"core:window:allow-scale-factor", "core:window:allow-scale-factor",
"process:default", "process:default",
"process:allow-exit",
"process:allow-restart", "process:allow-restart",
"http:default", "http:default",
"http:allow-fetch", "http:allow-fetch",
+3 -1
View File
@@ -77,7 +77,9 @@ pub fn auto_backup_app_data(app: tauri::AppHandle, bytes: Vec<u8>) -> Result<(),
#[tauri::command] #[tauri::command]
pub fn get_auto_backup_dir(app: tauri::AppHandle) -> String { pub fn get_auto_backup_dir(app: tauri::AppHandle) -> String {
backup_dir(&app).to_string_lossy().into_owned() let dir = backup_dir(&app);
let _ = std::fs::create_dir_all(&dir);
dir.to_string_lossy().into_owned()
} }
#[tauri::command] #[tauri::command]
+14 -4
View File
@@ -47,12 +47,22 @@ mod windows_hello {
} }
pub fn authenticate(reason: &str) -> Result<(), String> { pub fn authenticate(reason: &str) -> Result<(), String> {
let reason = reason.to_owned();
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
nudge_focus(5, 250); nudge_focus(5, 250);
let result = UserConsentVerifier::RequestVerificationAsync(&HSTRING::from(reason)) let outcome = UserConsentVerifier::RequestVerificationAsync(&HSTRING::from(reason.as_str()))
.and_then(|op| { .and_then(|op| {
nudge_focus(5, 250); nudge_focus(5, 250);
op.get() op.get()
}) });
let _ = tx.send(outcome);
});
let result = rx
.recv()
.map_err(|e| format!("internalError:{e:?}"))?
.map_err(|e| format!("internalError:{e:?}"))?; .map_err(|e| format!("internalError:{e:?}"))?;
match result { match result {
@@ -77,9 +87,9 @@ mod windows_hello {
} }
#[tauri::command] #[tauri::command]
pub fn windows_hello_authenticate(reason: String) -> Result<(), String> { pub fn windows_hello_authenticate(_reason: String) -> Result<(), String> {
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
return windows_hello::authenticate(&reason); return windows_hello::authenticate(&_reason);
#[cfg(not(target_os = "windows"))] #[cfg(not(target_os = "windows"))]
Err("notSupported".into()) Err("notSupported".into())
} }
+77 -8
View File
@@ -1,7 +1,8 @@
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
use crate::server::resolve::strip_unc; use crate::server::resolve::strip_unc;
use tauri::Manager; #[cfg(target_os = "windows")]
use std::path::PathBuf; use std::path::PathBuf;
use tauri::Manager;
#[tauri::command] #[tauri::command]
pub fn get_platform_ui_scale(window: tauri::Window) -> f64 { pub fn get_platform_ui_scale(window: tauri::Window) -> f64 {
@@ -57,14 +58,67 @@ pub fn exit_app(app: tauri::AppHandle) {
app.exit(0); app.exit(0);
} }
fn remove_dir_best_effort(path: &std::path::Path) {
if path.is_file() {
if let Err(e) = std::fs::remove_file(path) {
if e.raw_os_error() == Some(32) {
return;
}
}
} else if path.is_dir() {
if let Ok(entries) = std::fs::read_dir(path) {
for entry in entries.flatten() {
remove_dir_best_effort(&entry.path());
}
}
let _ = std::fs::remove_dir(path);
}
}
fn wait_until_deletable(path: &std::path::Path, timeout_secs: u64) -> bool {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout_secs);
while std::time::Instant::now() < deadline {
let locked = if path.is_file() {
std::fs::OpenOptions::new().write(true).open(path).is_err()
} else if path.is_dir() {
std::fs::read_dir(path).is_err()
} else {
return true;
};
if !locked {
return true;
}
std::thread::sleep(std::time::Duration::from_millis(200));
}
false
}
#[tauri::command] #[tauri::command]
pub fn clear_moku_cache(app: tauri::AppHandle) -> Result<(), String> { pub async fn clear_moku_cache(app: tauri::AppHandle) -> Result<(), String> {
use tauri::Manager; let window = app.get_webview_window("main").ok_or("no main window")?;
let (tx, rx) = tokio::sync::oneshot::channel::<Result<(), String>>();
// Note: We intentionally skip the WebView2 COM-level ClearBrowsingDataAll call here.
// The webview2_com crate pulls in a different version of windows_core than Tauri's
// own windows dependency, causing irreconcilable trait-impl conflicts at compile time.
// The filesystem cache removal below (app_cache_dir) is sufficient for our purposes;
// WebView2 will rebuild its cache on next launch from a clean directory.
window
.with_webview(move |_wv| {
let _ = tx.send(Ok(()));
})
.map_err(|e| e.to_string())?;
rx.await.map_err(|e| e.to_string())??;
let cache_dir = app.path().app_cache_dir().map_err(|e| e.to_string())?; let cache_dir = app.path().app_cache_dir().map_err(|e| e.to_string())?;
if cache_dir.exists() { if cache_dir.exists() {
std::fs::remove_dir_all(&cache_dir).map_err(|e| e.to_string())?; wait_until_deletable(&cache_dir, 3);
remove_dir_best_effort(&cache_dir);
std::fs::create_dir_all(&cache_dir).map_err(|e| e.to_string())?; std::fs::create_dir_all(&cache_dir).map_err(|e| e.to_string())?;
} }
Ok(()) Ok(())
} }
@@ -72,10 +126,17 @@ pub fn clear_moku_cache(app: tauri::AppHandle) -> Result<(), String> {
pub fn clear_suwayomi_cache() -> Result<(), String> { pub fn clear_suwayomi_cache() -> Result<(), String> {
use crate::server::resolve::suwayomi_data_dir; use crate::server::resolve::suwayomi_data_dir;
let data_dir = suwayomi_data_dir(); let data_dir = suwayomi_data_dir();
for dir in &["cache", "bin/kcef", "cache/kcef"] { for dir in &["cache/kcef", "logs"] {
let p = data_dir.join(dir); let p = data_dir.join(dir);
if p.exists() { if p.exists() {
std::fs::remove_dir_all(&p).map_err(|e| e.to_string())?; remove_dir_best_effort(&p);
}
}
for dir in &["downloads/thumbnails"] {
let p = data_dir.join(dir);
if p.exists() {
remove_dir_best_effort(&p);
let _ = std::fs::create_dir_all(&p);
} }
} }
Ok(()) Ok(())
@@ -86,10 +147,18 @@ pub fn reset_suwayomi_data(app: tauri::AppHandle) -> Result<(), String> {
use crate::server::resolve::suwayomi_data_dir; use crate::server::resolve::suwayomi_data_dir;
crate::server::kill_tachidesk(&app); crate::server::kill_tachidesk(&app);
std::thread::sleep(std::time::Duration::from_millis(500));
let data_dir = suwayomi_data_dir(); let data_dir = suwayomi_data_dir();
for entry_name in &["database.mv.db", "extensions", "settings", "logs", "local"] { let targets = ["database.mv.db", "extensions", "settings", "logs", "local"];
for entry_name in &targets {
let p = data_dir.join(entry_name);
if p.exists() {
wait_until_deletable(&p, 10);
}
}
for entry_name in &targets {
let p = data_dir.join(entry_name); let p = data_dir.join(entry_name);
if p.is_dir() { if p.is_dir() {
std::fs::remove_dir_all(&p).map_err(|e| format!("{entry_name}: {e}"))?; std::fs::remove_dir_all(&p).map_err(|e| format!("{entry_name}: {e}"))?;
+111 -3
View File
@@ -2,16 +2,83 @@ mod commands;
mod server; mod server;
use std::sync::Mutex; use std::sync::Mutex;
use tauri::{Manager, WindowEvent}; use std::io::{Read, Write};
use std::net::{TcpListener, TcpStream};
use tauri::{
menu::{Menu, MenuItem, PredefinedMenuItem},
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
Manager, WindowEvent,
};
use tauri_plugin_shell::process::CommandChild; use tauri_plugin_shell::process::CommandChild;
pub struct ServerState(pub Mutex<Option<CommandChild>>); pub struct ServerState(pub Mutex<Option<CommandChild>>);
const IPC_PORT: u16 = 47823;
const HANDSHAKE: &[u8] = b"MOKU:1\n";
const FOCUS_CMD: &[u8] = b"focus\n";
fn do_quit(app: &tauri::AppHandle) {
server::kill_tachidesk(app);
app.exit(0);
}
fn start_instance_listener(app: tauri::AppHandle) {
std::thread::spawn(move || {
let Ok(listener) = TcpListener::bind(("127.0.0.1", IPC_PORT)) else {
return;
};
for stream in listener.incoming().flatten() {
handle_ipc_connection(stream, &app);
}
});
}
fn handle_ipc_connection(mut stream: TcpStream, app: &tauri::AppHandle) {
let mut buf = [0u8; 32];
let Ok(n) = stream.read(&mut buf) else { return };
let msg = &buf[..n];
if !msg.starts_with(HANDSHAKE) {
return;
}
let cmd = &msg[HANDSHAKE.len()..];
if cmd.starts_with(b"focus") {
let _ = stream.write_all(b"ok\n");
if let Some(win) = app.get_webview_window("main") {
let _ = win.show();
let _ = win.unminimize();
let _ = win.set_focus();
}
}
}
fn signal_existing_instance() -> bool {
let Ok(mut stream) = TcpStream::connect(("127.0.0.1", IPC_PORT)) else {
return false;
};
stream.set_read_timeout(Some(std::time::Duration::from_millis(500))).ok();
let mut msg = Vec::new();
msg.extend_from_slice(HANDSHAKE);
msg.extend_from_slice(FOCUS_CMD);
if stream.write_all(&msg).is_err() {
return false;
}
let mut resp = [0u8; 4];
matches!(stream.read(&mut resp), Ok(n) if resp[..n].starts_with(b"ok"))
}
#[cfg_attr(mobile, tauri::mobile_entry_point)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() { pub fn run() {
if signal_existing_instance() {
std::process::exit(0);
}
tauri::Builder::default() tauri::Builder::default()
.plugin(tauri_plugin_store::Builder::new().build()) .plugin(tauri_plugin_store::Builder::new().build())
.plugin(tauri_plugin_store::Builder::default().build())
.plugin(tauri_plugin_discord_rpc::init()) .plugin(tauri_plugin_discord_rpc::init())
.plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_os::init()) .plugin(tauri_plugin_os::init())
@@ -39,12 +106,53 @@ pub fn run() {
commands::backup::import_app_data, commands::backup::import_app_data,
commands::backup::auto_backup_app_data, commands::backup::auto_backup_app_data,
commands::backup::get_auto_backup_dir, commands::backup::get_auto_backup_dir,
commands::backup::read_store_files,
commands::updater::list_releases, commands::updater::list_releases,
commands::updater::download_and_install_update, commands::updater::download_and_install_update,
commands::biometric::windows_hello_authenticate, commands::biometric::windows_hello_authenticate,
commands::biometric::windows_hello_available, commands::biometric::windows_hello_available,
]) ])
.setup(|_app| Ok(())) .setup(|app| {
start_instance_listener(app.handle().clone());
let show = MenuItem::with_id(app, "show", "Show Moku", true, None::<&str>)?;
let sep = PredefinedMenuItem::separator(app)?;
let quit = MenuItem::with_id(app, "quit", "Quit Moku", true, None::<&str>)?;
let menu = Menu::with_items(app, &[&show, &sep, &quit])?;
TrayIconBuilder::new()
.icon(app.default_window_icon().unwrap().clone())
.menu(&menu)
.show_menu_on_left_click(false)
.tooltip("Moku")
.on_menu_event(|app, event| match event.id.as_ref() {
"show" => {
if let Some(win) = app.get_webview_window("main") {
let _ = win.show();
let _ = win.set_focus();
}
}
"quit" => do_quit(app),
_ => {}
})
.on_tray_icon_event(|tray, event| {
if let TrayIconEvent::Click {
button: MouseButton::Left,
button_state: MouseButtonState::Up,
..
} = event
{
let app = tray.app_handle();
if let Some(win) = app.get_webview_window("main") {
let _ = win.show();
let _ = win.set_focus();
}
}
})
.build(app)?;
Ok(())
})
.on_window_event(|window, event| { .on_window_event(|window, event| {
if let WindowEvent::Destroyed = event { if let WindowEvent::Destroyed = event {
server::kill_tachidesk(window.app_handle()); server::kill_tachidesk(window.app_handle());
+1
View File
@@ -1,6 +1,7 @@
use crate::server::do_log; use crate::server::do_log;
use serde::Serialize; use serde::Serialize;
use std::path::PathBuf; use std::path::PathBuf;
use walkdir::WalkDir;
use tauri::Manager; use tauri::Manager;
#[derive(Serialize, Debug)] #[derive(Serialize, Debug)]
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "Moku", "productName": "Moku",
"version": "0.9.2", "version": "0.9.4",
"identifier": "io.github.MokuProject.Moku", "identifier": "io.github.MokuProject.Moku",
"build": { "build": {
"frontendDist": "../dist", "frontendDist": "../dist",
+200 -10
View File
@@ -4,7 +4,7 @@
import { listen } from "@tauri-apps/api/event"; import { listen } from "@tauri-apps/api/event";
import { getCurrentWindow } from "@tauri-apps/api/window"; import { getCurrentWindow } from "@tauri-apps/api/window";
import { platform } from "@tauri-apps/plugin-os"; import { platform } from "@tauri-apps/plugin-os";
import { store, setActiveDownloads } from "@store/state.svelte"; import { store, updateSettings, setActiveDownloads } from "@store/state.svelte";
import { downloadStore } from "@features/downloads/store/downloadState.svelte"; import { downloadStore } from "@features/downloads/store/downloadState.svelte";
import { boot, initStore, startProbe, stopProbe, retryBoot, bypassBoot } from "@store/boot.svelte"; import { boot, initStore, startProbe, stopProbe, retryBoot, bypassBoot } from "@store/boot.svelte";
import { initRpc, setIdle, clearReading, destroyRpc } from "@store/discord"; import { initRpc, setIdle, clearReading, destroyRpc } from "@store/discord";
@@ -31,6 +31,9 @@
let themeEditorOpen = $state(false); let themeEditorOpen = $state(false);
let themeEditorEditId = $state<string | null>(null); let themeEditorEditId = $state<string | null>(null);
let closeDialogOpen = $state(false);
let closeRemember = $state(false);
function openThemeEditor(id?: string | null) { function openThemeEditor(id?: string | null) {
themeEditorEditId = id ?? null; themeEditorEditId = id ?? null;
themeEditorOpen = true; themeEditorOpen = true;
@@ -41,6 +44,35 @@
themeEditorEditId = null; themeEditorEditId = null;
} }
async function doQuit() {
if (store.settings.autoStartServer) {
await Promise.race([
invoke("kill_server").catch(() => {}),
new Promise(res => setTimeout(res, 2000)),
]);
}
await invoke("exit_app");
}
async function doHide() {
await win.hide();
}
async function handleCloseRequested() {
const action = store.settings.closeAction ?? "ask";
if (action === "tray") { await doHide(); return; }
if (action === "quit") { await doQuit(); return; }
closeDialogOpen = true;
}
async function confirmClose(choice: "tray" | "quit") {
closeDialogOpen = false;
if (closeRemember) updateSettings({ closeAction: choice });
closeRemember = false;
if (choice === "tray") await doHide();
else await doQuit();
}
$effect(() => { void store.settings.theme; applyTheme(); }); $effect(() => { void store.settings.theme; applyTheme(); });
$effect(() => { void store.settings.uiZoom; applyZoom(); }); $effect(() => { void store.settings.uiZoom; applyZoom(); });
$effect(() => mountZoomKey()); $effect(() => mountZoomKey());
@@ -59,6 +91,13 @@
return () => clearTimeout(timer); return () => clearTimeout(timer);
}); });
$effect(() => {
if (!appReady) return;
downloadStore.poll();
const dlInterval = setInterval(() => downloadStore.poll(), 2000);
return () => clearInterval(dlInterval);
});
$effect(() => { $effect(() => {
if (store.settings.discordRpc) { if (store.settings.discordRpc) {
initRpc(); initRpc();
@@ -93,6 +132,11 @@
applyZoom(); applyZoom();
}); });
const unlistenClose = await win.listen("tauri://close-requested", handleCloseRequested);
await initStore();
startProbe();
if (store.settings.autoStartServer) { if (store.settings.autoStartServer) {
invoke<void>("spawn_server", { binary: store.settings.serverBinary }).catch((err: any) => { invoke<void>("spawn_server", { binary: store.settings.serverBinary }).catch((err: any) => {
if (err?.kind === "NotConfigured") boot.notConfigured = true; if (err?.kind === "NotConfigured") boot.notConfigured = true;
@@ -100,25 +144,18 @@
}); });
} }
await initStore();
startProbe();
const unlistenDownload = await listen<{ chapterId: number; mangaId: number; progress: number }[]>( const unlistenDownload = await listen<{ chapterId: number; mangaId: number; progress: number }[]>(
"download-progress", "download-progress",
e => setActiveDownloads(e.payload), e => setActiveDownloads(e.payload),
); );
await downloadStore.poll();
const dlInterval = setInterval(() => downloadStore.poll(), 2000);
return () => { return () => {
stopProbe(); stopProbe();
clearInterval(dlInterval);
unlistenResize(); unlistenResize();
unlistenScale(); unlistenScale();
unlistenDownload(); unlistenDownload();
unlistenClose();
destroyRpc(); destroyRpc();
if (store.settings.autoStartServer) invoke("kill_server").catch(() => {});
delete (window as any).__mokuShowSplash; delete (window as any).__mokuShowSplash;
}; };
}); });
@@ -152,7 +189,7 @@
{/if} {/if}
<div id="app-shell" class="root"> <div id="app-shell" class="root">
{#if !store.activeChapter}<TitleBar />{/if} {#if !store.activeChapter}<TitleBar onClose={handleCloseRequested} />{/if}
<div class="content"> <div class="content">
{#if store.activeChapter}<Reader />{:else}<Layout />{/if} {#if store.activeChapter}<Reader />{:else}<Layout />{/if}
</div> </div>
@@ -165,7 +202,160 @@
</div> </div>
{/if} {/if}
{#if closeDialogOpen}
<div class="close-backdrop" role="presentation" onclick={() => { closeDialogOpen = false; closeRemember = false; }}>
<div class="close-dialog" role="dialog" aria-modal="true" onclick={(e) => e.stopPropagation()}>
<div class="close-header">
<p class="close-title">Close Moku?</p>
<p class="close-sub">Choose how the app should exit.</p>
</div>
<div class="close-actions">
<button class="close-btn" onclick={() => confirmClose("tray")}>
<span class="close-btn-label">Minimize to Tray</span>
<span class="close-btn-desc">Keep running in the background</span>
</button>
<button class="close-btn close-btn-danger" onclick={() => confirmClose("quit")}>
<span class="close-btn-label">Quit</span>
<span class="close-btn-desc">Stop Moku entirely</span>
</button>
</div>
<button class="close-remember" onclick={() => closeRemember = !closeRemember}>
<span class="close-remember-toggle" class:on={closeRemember}><span class="close-remember-thumb"></span></span>
<span class="close-remember-label">Remember my choice</span>
</button>
</div>
</div>
{/if}
<style> <style>
.root { display: flex; flex-direction: column; height: 100%; overflow: hidden; } .root { display: flex; flex-direction: column; height: 100%; overflow: hidden; }
.content { flex: 1; overflow: hidden; } .content { flex: 1; overflow: hidden; }
.close-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.55);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
display: flex;
align-items: center;
justify-content: center;
z-index: var(--z-modal);
}
.close-dialog {
background: var(--bg-surface);
border: 1px solid var(--border-base);
border-radius: var(--radius-2xl);
padding: var(--sp-5);
display: flex;
flex-direction: column;
gap: var(--sp-3);
width: 300px;
box-shadow:
0 0 0 1px rgba(255,255,255,0.04) inset,
0 20px 60px rgba(0,0,0,0.65),
0 6px 20px rgba(0,0,0,0.35);
}
.close-header { display: flex; flex-direction: column; gap: 3px; }
.close-title {
font-family: var(--font-ui);
font-size: var(--text-base);
font-weight: var(--weight-medium);
color: var(--text-primary);
letter-spacing: var(--tracking-tight);
margin: 0;
}
.close-sub {
font-family: var(--font-ui);
font-size: var(--text-xs);
color: var(--text-faint);
letter-spacing: var(--tracking-wide);
margin: 0;
}
.close-actions { display: flex; flex-direction: column; gap: var(--sp-1); }
.close-btn {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 2px;
width: 100%;
padding: 10px var(--sp-3);
border-radius: var(--radius-lg);
border: 1px solid var(--border-dim);
background: var(--bg-raised);
cursor: pointer;
text-align: left;
transition: background var(--t-base), border-color var(--t-base);
}
.close-btn:hover { background: var(--bg-overlay); border-color: var(--border-strong); }
.close-btn-danger { border-color: color-mix(in srgb, var(--color-error) 30%, transparent); }
.close-btn-danger:hover { background: var(--color-error-bg); border-color: color-mix(in srgb, var(--color-error) 55%, transparent); }
.close-btn-danger .close-btn-label { color: var(--color-error); }
.close-btn-danger .close-btn-desc { color: color-mix(in srgb, var(--color-error) 55%, var(--text-faint)); }
.close-btn-label {
font-family: var(--font-ui);
font-size: var(--text-sm);
color: var(--text-secondary);
font-weight: var(--weight-medium);
}
.close-btn-desc {
font-family: var(--font-ui);
font-size: var(--text-2xs);
color: var(--text-faint);
letter-spacing: var(--tracking-wide);
}
.close-remember {
display: flex;
align-items: center;
gap: var(--sp-2);
padding: var(--sp-1) 0 0;
background: none;
border: none;
cursor: pointer;
user-select: none;
}
.close-remember-toggle {
position: relative;
width: 28px;
height: 16px;
border-radius: var(--radius-full);
border: 1px solid var(--border-strong);
background: var(--bg-overlay);
flex-shrink: 0;
transition: background var(--t-base), border-color var(--t-base);
}
.close-remember-toggle.on { background: var(--accent); border-color: var(--accent); }
.close-remember-thumb {
position: absolute;
top: 1px;
left: 1px;
width: 12px;
height: 12px;
border-radius: 50%;
background: var(--text-faint);
transition: transform var(--t-base), background var(--t-base);
}
.close-remember-toggle.on .close-remember-thumb {
transform: translateX(12px);
background: var(--bg-void);
}
.close-remember-label {
font-family: var(--font-ui);
font-size: var(--text-xs);
color: var(--text-faint);
letter-spacing: var(--tracking-wide);
}
</style> </style>
+50 -3
View File
@@ -1,10 +1,24 @@
import { store } from "@store/state.svelte"; import { store } from "@store/state.svelte";
import { fetchAuthenticated, AuthRequiredError } from "../core/auth"; import { fetchAuthenticated, AuthRequiredError, uiAuth } from "../core/auth";
import { boot } from "@store/boot.svelte"; import { boot } from "@store/boot.svelte";
import { getBlobUrl } from "@core/cache/imageCache";
const DEFAULT_URL = "http://127.0.0.1:4567"; const DEFAULT_URL = "http://127.0.0.1:4567";
function getServerUrl(): string { type ReauthResolver = () => void;
let _reauthQueue: ReauthResolver[] = [];
export function notifyReauthSuccess() {
const queue = _reauthQueue;
_reauthQueue = [];
queue.forEach(resolve => resolve());
}
function waitForReauth(): Promise<void> {
return new Promise(resolve => { _reauthQueue.push(resolve); });
}
export function getServerUrl(): string {
const url = store.settings.serverUrl; const url = store.settings.serverUrl;
return typeof url === "string" && url.trim() ? url.replace(/\/$/, "") : DEFAULT_URL; return typeof url === "string" && url.trim() ? url.replace(/\/$/, "") : DEFAULT_URL;
} }
@@ -15,6 +29,14 @@ export function plainThumbUrl(path: string): string {
return `${getServerUrl()}${path}`; return `${getServerUrl()}${path}`;
} }
export async function resolveImageUrl(path: string): Promise<string> {
if (!path) return "";
const url = path.startsWith("http") ? path : `${getServerUrl()}${path}`;
const mode = store.settings.serverAuthMode ?? "NONE";
if (mode === "NONE") return url;
return getBlobUrl(url);
}
export const thumbUrl = plainThumbUrl; export const thumbUrl = plainThumbUrl;
interface GQLResponse<T> { interface GQLResponse<T> {
@@ -58,11 +80,31 @@ async function fetchWithRetry(
throw new Error("unreachable"); throw new Error("unreachable");
} }
export async function fetchImage(
path: string,
signal?: AbortSignal,
): Promise<{ src: string; revoke: () => void }> {
if (!path) return { src: "", revoke: () => {} };
const url = path.startsWith("http") ? path : `${getServerUrl()}${path}`;
const mode = store.settings.serverAuthMode ?? "NONE";
if (mode === "NONE") return { src: url, revoke: () => {} };
const res = await fetchWithRetry(url, { method: "GET" }, signal);
if (!res.ok) throw new Error(`Image fetch failed: ${res.status}`);
const blob = await res.blob();
const src = URL.createObjectURL(blob);
return { src, revoke: () => URL.revokeObjectURL(src) };
}
export async function gql<T>( export async function gql<T>(
query: string, query: string,
variables?: Record<string, unknown>, variables?: Record<string, unknown>,
signal?: AbortSignal, signal?: AbortSignal,
): Promise<T> { ): Promise<T> {
const attempt = async (): Promise<T> => {
const res = await fetchWithRetry( const res = await fetchWithRetry(
`${getServerUrl()}/api/graphql`, `${getServerUrl()}/api/graphql`,
{ method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ query, variables }) }, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ query, variables }) },
@@ -78,9 +120,14 @@ export async function gql<T>(
boot.sessionExpired = true; boot.sessionExpired = true;
boot.loginRequired = true; boot.loginRequired = true;
boot.loginUser = store.settings.serverAuthUser ?? ""; boot.loginUser = store.settings.serverAuthUser ?? "";
throw new AuthRequiredError(json.errors[0].message); await waitForReauth();
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
return attempt();
} }
throw new Error(json.errors[0].message); throw new Error(json.errors[0].message);
} }
return json.data; return json.data;
};
return attempt();
} }
+42
View File
@@ -41,6 +41,48 @@ export const UPDATE_SOURCE_PREFERENCE = `
} }
`; `;
export const SET_SOURCE_METAS = `
mutation SetSourceMetas($input: SetSourceMetasInput!) {
setSourceMetas(input: $input) {
metas { sourceId key value }
}
}
`;
export const DELETE_SOURCE_METAS = `
mutation DeleteSourceMetas($input: DeleteSourceMetasInput!) {
deleteSourceMetas(input: $input) {
metas { sourceId key value }
}
}
`;
export const UPDATE_SOURCE_METADATA = `
mutation UpdateSourceMetadata(
$preUpdateDeleteInput: DeleteSourceMetasInput!
$hasPreUpdateDeletions: Boolean!
$updateInput: SetSourceMetasInput!
$hasUpdates: Boolean!
$postUpdateDeleteInput: DeleteSourceMetasInput!
$hasPostUpdateDeletions: Boolean!
$migrateInput: SetSourceMetasInput!
$isMigration: Boolean!
) {
preUpdateDeletedMeta: deleteSourceMetas(input: $preUpdateDeleteInput) @include(if: $hasPreUpdateDeletions) {
metas { sourceId key value }
}
updatedMeta: setSourceMetas(input: $updateInput) @include(if: $hasUpdates) {
metas { sourceId key value }
}
postUpdateDeletedMeta: deleteSourceMetas(input: $postUpdateDeleteInput) @include(if: $hasPostUpdateDeletions) {
metas { sourceId key value }
}
migrationMeta: setSourceMetas(input: $migrateInput) @include(if: $isMigration) {
metas { sourceId key value }
}
}
`;
export const SET_SOURCE_META = ` export const SET_SOURCE_META = `
mutation SetSourceMeta($sourceId: LongString!, $key: String!, $value: String!) { mutation SetSourceMeta($sourceId: LongString!, $key: String!, $value: String!) {
setSourceMeta(input: { meta: { sourceId: $sourceId, key: $key, value: $value } }) { setSourceMeta(input: { meta: { sourceId: $sourceId, key: $key, value: $value } }) {
+72 -1
View File
@@ -22,7 +22,78 @@ export const GET_SOURCES = `
sources { sources {
nodes { nodes {
id name lang displayName iconUrl isNsfw id name lang displayName iconUrl isNsfw
isConfigurable supportsLatest baseUrl isConfigurable supportsLatest
extension { pkgName }
}
}
}
`;
export const GET_SOURCE_SETTINGS = `
query GetSourceSettings($id: LongString!) {
source(id: $id) {
id
displayName
preferences {
... on CheckBoxPreference {
type: __typename
CheckBoxTitle: title
CheckBoxSummary: summary
CheckBoxDefault: default
CheckBoxCurrentValue: currentValue
key
}
... on SwitchPreference {
type: __typename
SwitchPreferenceTitle: title
SwitchPreferenceSummary: summary
SwitchPreferenceDefault: default
SwitchPreferenceCurrentValue: currentValue
key
}
... on ListPreference {
type: __typename
ListPreferenceTitle: title
ListPreferenceSummary: summary
ListPreferenceDefault: default
ListPreferenceCurrentValue: currentValue
entries
entryValues
key
}
... on EditTextPreference {
type: __typename
EditTextPreferenceTitle: title
EditTextPreferenceSummary: summary
EditTextPreferenceDefault: default
EditTextPreferenceCurrentValue: currentValue
dialogTitle
dialogMessage
key
}
... on MultiSelectListPreference {
type: __typename
MultiSelectListPreferenceTitle: title
MultiSelectListPreferenceSummary: summary
MultiSelectListPreferenceDefault: default
MultiSelectListPreferenceCurrentValue: currentValue
entries
entryValues
key
}
}
}
}
`;
export const GET_MIGRATABLE_SOURCES = `
query GetMigratableSources {
mangas(condition: { inLibrary: true }) {
nodes {
sourceId
source {
id name lang displayName iconUrl isNsfw isConfigurable supportsLatest
}
} }
} }
} }
+15 -17
View File
@@ -1,32 +1,30 @@
import type { Attachment } from "svelte/attachments"; import type { Attachment } from "svelte/attachments";
/**
* {@attach selectPortal(triggerEl)}
*
* Moves the decorated element to <body> and positions it below `triggerEl`.
* The element stays reactive — Svelte still owns its DOM, we just re-parent it.
*
* The portalled menu element is stored on `triggerEl.__selectMenuEl` so that
* the outside-click guard in Settings.svelte can exclude it from dismissal.
*/
export function selectPortal(triggerEl: HTMLElement & { __selectMenuEl?: HTMLElement | null }): Attachment { export function selectPortal(triggerEl: HTMLElement & { __selectMenuEl?: HTMLElement | null }): Attachment {
return (menuEl: HTMLElement) => { return (menuEl: HTMLElement) => {
// Position & move to body
function position() { function position() {
const zoom = parseFloat(document.documentElement.style.zoom) / 100 || 1;
const r = triggerEl.getBoundingClientRect(); const r = triggerEl.getBoundingClientRect();
const top = r.bottom / zoom + 4;
const right = r.right / zoom;
const width = menuEl.offsetWidth;
const left = Math.max(8, right - width);
menuEl.style.position = "fixed"; menuEl.style.position = "fixed";
menuEl.style.top = `${r.bottom + 4}px`; menuEl.style.top = `${top}px`;
menuEl.style.left = `${r.right - menuEl.offsetWidth}px`; menuEl.style.left = `${left}px`;
// clamp to viewport left edge
const left = parseFloat(menuEl.style.left);
if (left < 8) menuEl.style.left = "8px";
} }
menuEl.style.visibility = "hidden";
document.body.appendChild(menuEl); document.body.appendChild(menuEl);
triggerEl.__selectMenuEl = menuEl; triggerEl.__selectMenuEl = menuEl;
position();
// Reposition on scroll / resize while open requestAnimationFrame(() => {
position();
menuEl.style.visibility = "";
});
window.addEventListener("scroll", position, true); window.addEventListener("scroll", position, true);
window.addEventListener("resize", position); window.addEventListener("resize", position);
+4 -3
View File
@@ -9,12 +9,13 @@ export class AuthRequiredError extends Error {
} }
} }
let _accessToken: string | null = null; const TOKEN_KEY = "moku_access_token";
let _accessToken: string | null = sessionStorage.getItem(TOKEN_KEY);
export const uiAuth = { export const uiAuth = {
getToken: () => _accessToken, getToken: () => _accessToken,
setToken: (t: string) => { _accessToken = t; }, setToken: (t: string) => { _accessToken = t; sessionStorage.setItem(TOKEN_KEY, t); },
clearToken: () => { _accessToken = null; }, clearToken: () => { _accessToken = null; sessionStorage.removeItem(TOKEN_KEY); },
}; };
export const authSession = { export const authSession = {
+32 -3
View File
@@ -1,11 +1,13 @@
import { fetch as tauriFetch } from "@tauri-apps/plugin-http"; import { fetch as tauriFetch } from "@tauri-apps/plugin-http";
import { store } from "@store/state.svelte"; import { store } from "@store/state.svelte";
import { uiAuth } from "@core/auth";
const cache = new Map<string, string>(); const cache = new Map<string, string>();
const inflight = new Map<string, Promise<string>>(); const inflight = new Map<string, Promise<string>>();
const MAX_CONCURRENT = 6; const MAX_CONCURRENT = 6;
let active = 0; let active = 0;
let drainScheduled = false; let drainScheduled = false;
let clearing = false;
interface QueueEntry { interface QueueEntry {
url: string; url: string;
@@ -17,15 +19,25 @@ interface QueueEntry {
const queue: QueueEntry[] = []; const queue: QueueEntry[] = [];
function getAuthHeaders(): Record<string, string> { function getAuthHeaders(): Record<string, string> {
const mode = store.settings.serverAuthMode ?? "NONE";
if (mode === "UI_LOGIN") {
const token = uiAuth.getToken();
return token ? { Authorization: `Bearer ${token}` } : {};
}
if (mode === "BASIC_AUTH") {
const user = store.settings.serverAuthUser?.trim() ?? ""; const user = store.settings.serverAuthUser?.trim() ?? "";
const pass = store.settings.serverAuthPass?.trim() ?? ""; const pass = store.settings.serverAuthPass?.trim() ?? "";
return user && pass ? { Authorization: `Basic ${btoa(`${user}:${pass}`)}` } : {}; return user && pass ? { Authorization: `Basic ${btoa(`${user}:${pass}`)}` } : {};
} }
return {};
}
async function doFetch(url: string): Promise<string> { async function doFetch(url: string): Promise<string> {
const res = await tauriFetch(url, { method: "GET", headers: getAuthHeaders() }); const res = await tauriFetch(url, { method: "GET", headers: getAuthHeaders() });
if (!res.ok) throw new Error(`${res.status}`); if (!res.ok) throw new Error(`${res.status}`);
const blobUrl = URL.createObjectURL(await res.blob()); const blob = await res.blob();
if (clearing) throw new DOMException("Cancelled", "AbortError");
const blobUrl = URL.createObjectURL(blob);
cache.set(url, blobUrl); cache.set(url, blobUrl);
return blobUrl; return blobUrl;
} }
@@ -47,7 +59,7 @@ function drain() {
active++; active++;
doFetch(entry.url) doFetch(entry.url)
.then(entry.resolve, entry.reject) .then(entry.resolve, entry.reject)
.finally(() => { inflight.delete(entry.url); active--; drain(); }); .finally(() => { active--; drain(); });
} }
} }
@@ -58,7 +70,12 @@ function scheduleDrain() {
} }
function enqueue(url: string, priority: number): Promise<string> { function enqueue(url: string, priority: number): Promise<string> {
const promise = new Promise<string>((resolve, reject) => { insertSorted({ url, priority, resolve, reject }); }); const promise = new Promise<string>((resolve, reject) => {
insertSorted({ url, priority, resolve, reject });
}).catch(err => {
inflight.delete(url);
return Promise.reject(err);
});
inflight.set(url, promise); inflight.set(url, promise);
scheduleDrain(); scheduleDrain();
return promise; return promise;
@@ -98,7 +115,19 @@ export function deprioritizeQueue(): void {
queue.sort((a, b) => b.priority - a.priority); queue.sort((a, b) => b.priority - a.priority);
} }
export function cancelQueuedFetches(): void {
const dropped = queue.splice(0);
for (const entry of dropped) {
inflight.delete(entry.url);
entry.reject(new DOMException("Cancelled", "AbortError"));
}
}
export function clearBlobCache(): void { export function clearBlobCache(): void {
clearing = true;
cancelQueuedFetches();
cache.forEach(blob => URL.revokeObjectURL(blob)); cache.forEach(blob => URL.revokeObjectURL(blob));
cache.clear(); cache.clear();
inflight.clear();
clearing = false;
} }
+44
View File
@@ -0,0 +1,44 @@
interface MemEntry<T> {
value: T;
expiresAt: number;
key: string;
}
export class MemoryCache<T> {
readonly #cap: number;
readonly #ttl: number;
readonly #map = new Map<string, MemEntry<T>>();
constructor(capacity: number, ttlMs: number) {
this.#cap = capacity;
this.#ttl = ttlMs;
}
get(key: string): T | undefined {
const entry = this.#map.get(key);
if (!entry) return undefined;
if (Date.now() > entry.expiresAt) { this.#map.delete(key); return undefined; }
this.#map.delete(key);
this.#map.set(key, entry);
return entry.value;
}
set(key: string, value: T): void {
if (this.#map.has(key)) this.#map.delete(key);
else if (this.#map.size >= this.#cap) this.#map.delete(this.#map.keys().next().value!);
this.#map.set(key, { value, expiresAt: Date.now() + this.#ttl, key });
}
has(key: string): boolean {
const entry = this.#map.get(key);
if (!entry) return false;
if (Date.now() > entry.expiresAt) { this.#map.delete(key); return false; }
return true;
}
delete(key: string): void { this.#map.delete(key); }
clear(): void { this.#map.clear(); }
get size(): number { return this.#map.size; }
}
+17 -12
View File
@@ -1,4 +1,4 @@
import { gql, plainThumbUrl } from "@api/client"; import { gql, getServerUrl } from "@api/client";
import { getBlobUrl, preloadBlobUrls } from "@core/cache/imageCache"; import { getBlobUrl, preloadBlobUrls } from "@core/cache/imageCache";
import { dedupeRequest } from "@core/async/batchRequests"; import { dedupeRequest } from "@core/async/batchRequests";
import { FETCH_CHAPTER_PAGES } from "@api/mutations/chapters"; import { FETCH_CHAPTER_PAGES } from "@api/mutations/chapters";
@@ -6,13 +6,18 @@ import { FETCH_CHAPTER_PAGES } from "@api/mutations/chapters";
const pageCache = new Map<number, string[]>(); const pageCache = new Map<number, string[]>();
const inflight = new Map<number, Promise<string[]>>(); const inflight = new Map<number, Promise<string[]>>();
const resolvedUrlCache = new Map<string, Promise<string>>(); const resolvedUrlCache = new Map<string, Promise<string>>();
const preloadedUrls = new Set<string>();
const aspectCache = new Map<string, number>(); const aspectCache = new Map<string, number>();
export function resolveUrl(url: string, useBlob: boolean, priority = 0): Promise<string> { export function resolveUrl(url: string, useBlob: boolean, priority = 0): Promise<string> {
if (!useBlob) return Promise.resolve(url); if (!useBlob) return Promise.resolve(url);
if (!resolvedUrlCache.has(url)) resolvedUrlCache.set(url, getBlobUrl(url, priority)); const cached = resolvedUrlCache.get(url);
return resolvedUrlCache.get(url)!; if (cached) return cached;
const p = getBlobUrl(url, priority).catch(err => {
resolvedUrlCache.delete(url);
return Promise.reject(err);
});
resolvedUrlCache.set(url, p);
return p;
} }
export function fetchPages( export function fetchPages(
@@ -29,11 +34,8 @@ export function fetchPages(
const p = dedupeRequest(`chapter-pages:${chapterId}`, () => const p = dedupeRequest(`chapter-pages:${chapterId}`, () =>
gql<{ fetchChapterPages: { pages: string[] } }>(FETCH_CHAPTER_PAGES, { chapterId }) gql<{ fetchChapterPages: { pages: string[] } }>(FETCH_CHAPTER_PAGES, { chapterId })
.then(d => { .then(d => {
const urls = d.fetchChapterPages.pages.map(p => plainThumbUrl(p)); const urls = d.fetchChapterPages.pages.map(p => p.startsWith("http") ? p : `${getServerUrl()}${p}`);
if (useBlob) { if (useBlob && urls[priorityPage]) getBlobUrl(urls[priorityPage], 999);
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;
}) })
@@ -60,11 +62,15 @@ export function measureAspect(url: string, useBlob: boolean): Promise<number> {
} }
export function preloadImage(url: string, useBlob: boolean): void { export function preloadImage(url: string, useBlob: boolean): void {
if (preloadedUrls.has(url)) return; if (useBlob) { preloadBlobUrls([url], 0); return; }
preloadedUrls.add(url);
resolveUrl(url, useBlob).then(src => { new Image().src = src; }).catch(() => {}); resolveUrl(url, useBlob).then(src => { new Image().src = src; }).catch(() => {});
} }
export function clearResolvedUrlCache(): void {
resolvedUrlCache.clear();
aspectCache.clear();
}
export function clearPageCache(chapterId?: number): void { export function clearPageCache(chapterId?: number): void {
if (chapterId !== undefined) { if (chapterId !== undefined) {
pageCache.delete(chapterId); pageCache.delete(chapterId);
@@ -73,7 +79,6 @@ export function clearPageCache(chapterId?: number): void {
pageCache.clear(); pageCache.clear();
inflight.clear(); inflight.clear();
resolvedUrlCache.clear(); resolvedUrlCache.clear();
preloadedUrls.clear();
aspectCache.clear(); aspectCache.clear();
} }
} }
+85 -6
View File
@@ -1,10 +1,13 @@
interface Entry<T> { interface Entry<T> {
promise: Promise<T>; promise: Promise<T>;
fetchedAt: number; fetchedAt: number;
fetcher?: () => Promise<T>;
ttl?: number;
} }
const store = new Map<string, Entry<unknown>>(); const store = new Map<string, Entry<unknown>>();
const subs = new Map<string, Set<() => void>>(); const subs = new Map<string, Set<() => void>>();
const keyToGroups = new Map<string, Set<string>>();
const groups = new Map<string, Set<string>>(); const groups = new Map<string, Set<string>>();
export const DEFAULT_TTL_MS = 5 * 60 * 1_000; export const DEFAULT_TTL_MS = 5 * 60 * 1_000;
@@ -16,6 +19,16 @@ function registerGroups(key: string, group?: string | string[]) {
for (const tag of Array.isArray(group) ? group : [group]) { for (const tag of Array.isArray(group) ? group : [group]) {
if (!groups.has(tag)) groups.set(tag, new Set()); if (!groups.has(tag)) groups.set(tag, new Set());
groups.get(tag)!.add(key); groups.get(tag)!.add(key);
if (!keyToGroups.has(key)) keyToGroups.set(key, new Set());
keyToGroups.get(key)!.add(tag);
}
}
function unregisterKey(key: string) {
const tags = keyToGroups.get(key);
if (tags) {
for (const tag of tags) groups.get(tag)?.delete(key);
keyToGroups.delete(key);
} }
} }
@@ -27,14 +40,20 @@ export const cache = {
if (err?.name !== "AbortError") store.delete(key); if (err?.name !== "AbortError") store.delete(key);
return Promise.reject(err); return Promise.reject(err);
}) as Promise<T>; }) as Promise<T>;
store.set(key, { promise, fetchedAt: Date.now() }); store.set(key, { promise, fetchedAt: Date.now(), fetcher: fetcher as () => Promise<unknown>, ttl });
registerGroups(key, group); registerGroups(key, group);
promise.then(() => notify(key)).catch(() => {}); promise.then(() => notify(key)).catch(() => {});
return promise; return promise;
}, },
set<T>(key: string, value: T, group?: string | string[]) { set<T>(key: string, value: T, group?: string | string[]) {
store.set(key, { promise: Promise.resolve(value), fetchedAt: Date.now() }); const existing = store.get(key) as Entry<T> | undefined;
store.set(key, {
promise: Promise.resolve(value),
fetchedAt: Date.now(),
fetcher: existing?.fetcher,
ttl: existing?.ttl,
});
registerGroups(key, group); registerGroups(key, group);
notify(key); notify(key);
}, },
@@ -43,10 +62,38 @@ export const cache = {
const existing = store.get(key) as Entry<T> | undefined; const existing = store.get(key) as Entry<T> | undefined;
if (!existing) return; if (!existing) return;
const next = existing.promise.then(fn); const next = existing.promise.then(fn);
store.set(key, { promise: next, fetchedAt: Date.now() }); store.set(key, { ...existing, promise: next, fetchedAt: Date.now() });
next.then(() => notify(key)).catch(() => {}); next.then(() => notify(key)).catch(() => {});
}, },
refresh<T>(key: string): Promise<T> | undefined {
const existing = store.get(key) as Entry<T> | undefined;
if (!existing?.fetcher) return undefined;
const promise = (existing.fetcher as () => Promise<T>)().catch(err => {
if (err?.name !== "AbortError") store.delete(key);
return Promise.reject(err);
});
store.set(key, { ...existing, promise: promise as Promise<unknown>, fetchedAt: Date.now() });
promise.then(() => notify(key)).catch(() => {});
return promise;
},
refreshGroup(tag: string): void {
const keys = groups.get(tag);
if (!keys) return;
for (const key of [...keys]) {
const existing = store.get(key);
if (existing?.fetcher) {
const promise = existing.fetcher().catch(err => {
if (err?.name !== "AbortError") store.delete(key);
return Promise.reject(err);
});
store.set(key, { ...existing, promise, fetchedAt: Date.now() });
promise.then(() => notify(key)).catch(() => {});
}
}
},
has(key: string): boolean { return store.has(key); }, has(key: string): boolean { return store.has(key); },
ageOf(key: string): number | undefined { ageOf(key: string): number | undefined {
@@ -54,18 +101,35 @@ export const cache = {
return e ? Date.now() - e.fetchedAt : undefined; return e ? Date.now() - e.fetchedAt : undefined;
}, },
clear(key: string) { store.delete(key); notify(key); }, isStale(key: string): boolean {
const e = store.get(key);
if (!e) return true;
return Date.now() - e.fetchedAt >= (e.ttl ?? DEFAULT_TTL_MS);
},
clear(key: string) {
unregisterKey(key);
store.delete(key);
notify(key);
},
clearGroup(tag: string) { clearGroup(tag: string) {
const keys = groups.get(tag); const keys = groups.get(tag);
if (!keys) return; if (!keys) return;
for (const key of keys) { store.delete(key); notify(key); } for (const key of [...keys]) {
keyToGroups.get(key)?.delete(tag);
if (keyToGroups.get(key)?.size === 0) keyToGroups.delete(key);
store.delete(key);
notify(key);
}
groups.delete(tag); groups.delete(tag);
}, },
clearAll() { clearAll() {
const allKeys = [...store.keys()]; const allKeys = [...store.keys()];
store.clear(); groups.clear(); store.clear();
groups.clear();
keyToGroups.clear();
allKeys.forEach(notify); allKeys.forEach(notify);
}, },
@@ -159,3 +223,18 @@ export function getTopSources<T extends { id: string }>(sources: T[]): T[] {
} }
return sources.slice(0, MAX_FRECENCY_SOURCES); return sources.slice(0, MAX_FRECENCY_SOURCES);
} }
export async function refreshMangaCache(mangaId: number, thumbnailUrl?: string): Promise<void> {
const didRefresh = cache.refresh(CACHE_KEYS.MANGA(mangaId));
if (!didRefresh) cache.clear(CACHE_KEYS.MANGA(mangaId));
cache.clear(CACHE_KEYS.CHAPTERS(mangaId));
cache.clear(CACHE_KEYS.LIBRARY);
cache.clear(CACHE_KEYS.ALL_MANGA);
if (thumbnailUrl) {
const { revokeBlobUrl, getBlobUrl } = await import("@core/cache/imageCache");
revokeBlobUrl(thumbnailUrl);
getBlobUrl(thumbnailUrl, 999).catch(() => {});
}
}
+3
View File
@@ -12,6 +12,7 @@ export interface Keybinds {
openSettings: string; openSettings: string;
toggleBookmark: string; toggleBookmark: string;
toggleMarker: string; toggleMarker: string;
toggleAutoScroll: string;
} }
export const DEFAULT_KEYBINDS: Keybinds = { export const DEFAULT_KEYBINDS: Keybinds = {
@@ -28,6 +29,7 @@ export const DEFAULT_KEYBINDS: Keybinds = {
openSettings: "o", openSettings: "o",
toggleBookmark: "m", toggleBookmark: "m",
toggleMarker: "n", toggleMarker: "n",
toggleAutoScroll: "s",
}; };
export const KEYBIND_LABELS: Record<keyof Keybinds, string> = { export const KEYBIND_LABELS: Record<keyof Keybinds, string> = {
@@ -44,4 +46,5 @@ export const KEYBIND_LABELS: Record<keyof Keybinds, string> = {
openSettings: "Open settings", openSettings: "Open settings",
toggleBookmark: "Toggle bookmark", toggleBookmark: "Toggle bookmark",
toggleMarker: "Toggle marker", toggleMarker: "Toggle marker",
toggleAutoScroll: "Toggle auto scroll",
}; };
+17 -8
View File
@@ -31,13 +31,13 @@ export function formatReadTime(m: number): string {
const STRICT_TAGS: string[] = [ const STRICT_TAGS: string[] = [
"adult", "mature", "hentai", "ecchi", "erotic", "pornograph", "adult", "mature", "hentai", "ecchi", "erotic", "pornograph",
"18+", "smut", "lemon", "explicit", "sexual violence", "18+", "smut", "explicit", "sexual violence",
"gore", "guro", "graphic violence", "torture", "body horror", "gore", "guro", "graphic violence", "torture", "body horror",
]; ];
const MODERATE_TAGS: string[] = [ const MODERATE_TAGS: string[] = [
"adult", "mature", "hentai", "ecchi", "erotic", "pornograph", "adult", "mature", "hentai", "ecchi", "erotic", "pornograph",
"18+", "smut", "lemon", "explicit", "sexual violence", "18+", "smut", "explicit", "sexual violence",
]; ];
type ContentFilterSettings = Pick< type ContentFilterSettings = Pick<
@@ -53,7 +53,16 @@ function blockedTagsForSettings(settings: ContentFilterSettings): string[] {
function genreMatchesBlocklist(genre: string[], blockedTags: string[]): boolean { function genreMatchesBlocklist(genre: string[], blockedTags: string[]): boolean {
if (!blockedTags.length) return false; if (!blockedTags.length) return false;
return genre.some(g => blockedTags.some(tag => g.toLowerCase().trim().includes(tag))); return genre.some(g => {
const norm = g.toLowerCase().trim();
return blockedTags.some(tag => {
const idx = norm.indexOf(tag);
if (idx === -1) return false;
const before = idx === 0 || /\W/.test(norm[idx - 1]);
const after = idx + tag.length === norm.length || /\W/.test(norm[idx + tag.length]);
return before && after;
});
});
} }
export function shouldHideNsfw( export function shouldHideNsfw(
@@ -69,10 +78,10 @@ export function shouldHideNsfw(
if (srcId && blocked.includes(srcId)) return true; if (srcId && blocked.includes(srcId)) return true;
const sourceAllowed = !!(srcId && allowed.includes(srcId)); const sourceAllowed = !!(srcId && allowed.includes(srcId));
const blockedTags = blockedTagsForSettings(settings);
if (!sourceAllowed && manga.source?.isNsfw && settings.contentLevel === "strict") return true; if (!sourceAllowed && manga.source?.isNsfw) return true;
return genreMatchesBlocklist(manga.genre ?? [], blockedTags);
return genreMatchesBlocklist(manga.genre ?? [], blockedTagsForSettings(settings));
} }
export function shouldHideSource( export function shouldHideSource(
@@ -83,10 +92,10 @@ export function shouldHideSource(
if (settings.sourceOverridesEnabled) { if (settings.sourceOverridesEnabled) {
if ((settings.nsfwBlockedSourceIds ?? []).includes(source.id)) return true; if ((settings.nsfwBlockedSourceIds ?? []).includes(source.id)) return true;
if ((settings.nsfwAllowedSourceIds ?? []).includes(source.id)) return settings.contentLevel === "strict"; if ((settings.nsfwAllowedSourceIds ?? []).includes(source.id)) return false;
} }
return source.isNsfw && settings.contentLevel === "strict"; return source.isNsfw;
} }
export function dedupeSourcesByLang( export function dedupeSourcesByLang(
@@ -76,7 +76,7 @@
let filtered = allSources; let filtered = allSources;
if (kw_selectedLangs.size > 0) if (kw_selectedLangs.size > 0)
filtered = filtered.filter((s) => kw_selectedLangs.has(s.lang)); filtered = filtered.filter((s) => kw_selectedLangs.has(s.lang));
if (!store.settings.showNsfw) if (store.settings.contentLevel !== "unrestricted")
filtered = filtered.filter((s) => !shouldHideSource(s, store.settings)); filtered = filtered.filter((s) => !shouldHideSource(s, store.settings));
return filtered; return filtered;
} }
@@ -8,7 +8,7 @@
import { deprioritizeQueue } from "@core/cache/imageCache"; import { deprioritizeQueue } from "@core/cache/imageCache";
import { dedupeSourcesByLang }from "@core/algorithms/filter"; import { dedupeSourcesByLang }from "@core/algorithms/filter";
import { shouldHideNsfw } from "@core/util"; import { shouldHideNsfw } from "@core/util";
import { store, setSearchPrefill, setPreviewManga } from "@store/state.svelte"; import { store, setSearchPrefill, setPreviewManga, setSearchQuery } from "@store/state.svelte";
import { import {
toCachedManga, toCachedManga,
type CachedManga, type CachedManga,
@@ -288,6 +288,8 @@
popularResults={popular_results} popularResults={popular_results}
popularLoading={popular_loading} popularLoading={popular_loading}
{sourceCache} {sourceCache}
query={store.searchQuery}
onQueryChange={setSearchQuery}
onPrefillConsumed={() => (pendingPrefill = "")} onPrefillConsumed={() => (pendingPrefill = "")}
onPreview={setPreviewManga} onPreview={setPreviewManga}
/> />
@@ -211,8 +211,8 @@
.content { flex: 1; overflow-y: auto; padding: 0 var(--sp-6) var(--sp-6); display: flex; flex-direction: column; gap: var(--sp-4); } .content { flex: 1; overflow-y: auto; padding: 0 var(--sp-6) var(--sp-6); display: flex; flex-direction: column; gap: var(--sp-4); }
.icon-btn { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); color: var(--text-muted); background: none; cursor: pointer; 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:not(:disabled):not(.active) { color: var(--text-secondary); border-color: var(--border-strong); background: var(--bg-raised); } .icon-btn:hover:not(:disabled):not(.active) { color: var(--text-primary); border-color: var(--border-strong); }
.icon-btn.active:hover:not(:disabled) { border-color: var(--accent); background: var(--accent-muted); } .icon-btn.active:hover:not(:disabled) { border-color: var(--accent); background: var(--accent-muted); }
.icon-btn:disabled { opacity: 0.3; cursor: default; } .icon-btn:disabled { opacity: 0.3; cursor: default; }
.icon-btn.loading { border-color: var(--accent-dim); color: var(--accent-fg); background: var(--accent-muted); } .icon-btn.loading { border-color: var(--accent-dim); color: var(--accent-fg); background: var(--accent-muted); }
@@ -5,7 +5,7 @@ import {
DEQUEUE_DOWNLOAD, DEQUEUE_CHAPTERS_DOWNLOAD, DEQUEUE_DOWNLOAD, DEQUEUE_CHAPTERS_DOWNLOAD,
ENQUEUE_DOWNLOAD, REORDER_DOWNLOAD, ENQUEUE_DOWNLOAD, REORDER_DOWNLOAD,
} from "@api/mutations"; } from "@api/mutations";
import { addToast, setActiveDownloads } from "@store/state.svelte"; import { addToast, setActiveDownloads, store, updateSettings } from "@store/state.svelte";
import { boot } from "@store/boot.svelte"; import { boot } from "@store/boot.svelte";
import type { DownloadStatus, DownloadQueueItem } from "@types/index"; import type { DownloadStatus, DownloadQueueItem } from "@types/index";
import { import {
@@ -26,8 +26,8 @@ class DownloadStore {
pagesPerSec: number | null = $state(null); pagesPerSec: number | null = $state(null);
eta: number | null = $state(null); eta: number | null = $state(null);
toastsEnabled = $state(true); get toastsEnabled() { return store.settings.downloadToastsEnabled ?? true; }
autoRetryEnabled = $state(false); get autoRetryEnabled() { return store.settings.downloadAutoRetry ?? false; }
private lastSample: SpeedSample | null = null; private lastSample: SpeedSample | null = null;
private prevQueue: DownloadQueueItem[] = []; private prevQueue: DownloadQueueItem[] = [];
@@ -39,18 +39,19 @@ class DownloadStore {
get hasErrored() { return this.erroredIds.size > 0; } get hasErrored() { return this.erroredIds.size > 0; }
toggleToasts() { toggleToasts() {
this.toastsEnabled = !this.toastsEnabled; const next = !this.toastsEnabled;
addToast({ kind: "info", title: this.toastsEnabled ? "Notifications enabled" : "Notifications muted", body: this.toastsEnabled ? "You'll be notified when chapters finish downloading" : "Download notifications are silenced", duration: 2500 }); updateSettings({ downloadToastsEnabled: next });
addToast({ kind: "info", title: next ? "Notifications enabled" : "Notifications muted", body: next ? "You'll be notified when chapters finish downloading" : "Download notifications are silenced", duration: 2500 });
} }
toggleAutoRetry() { toggleAutoRetry() {
if (this.autoRetryEnabled) { if (this.autoRetryEnabled) {
this.autoRetryHnd?.stop(); this.autoRetryHnd?.stop();
this.autoRetryHnd = null; this.autoRetryHnd = null;
this.autoRetryEnabled = false; updateSettings({ downloadAutoRetry: false });
addToast({ kind: "info", title: "Auto-retry disabled", body: "Failed downloads will no longer retry automatically", duration: 2500 }); addToast({ kind: "info", title: "Auto-retry disabled", body: "Failed downloads will no longer retry automatically", duration: 2500 });
} else { } else {
this.autoRetryEnabled = true; updateSettings({ downloadAutoRetry: true });
this.autoRetryHnd = startAutoRetry( this.autoRetryHnd = startAutoRetry(
() => this.queue, () => this.queue,
() => this.isRunning, () => this.isRunning,
@@ -1,8 +1,10 @@
<script lang="ts"> <script lang="ts">
import { CircleNotch, CaretRight, CaretDown } from "phosphor-svelte"; import { CircleNotch, CaretRight, CaretDown, Books } from "phosphor-svelte";
import Thumbnail from "@shared/manga/Thumbnail.svelte"; import Thumbnail from "@shared/manga/Thumbnail.svelte";
import type { Extension } from "@types/index"; import type { Extension } from "@types/index";
type SourceEntry = { id: string; displayName: string };
interface Props { interface Props {
base: string; base: string;
primary: Extension; primary: Extension;
@@ -10,17 +12,27 @@
expanded: boolean; expanded: boolean;
working: Set<string>; working: Set<string>;
anims: boolean; anims: boolean;
sources: SourceEntry[];
libraryCount: number;
onToggle: (base: string) => void; onToggle: (base: string) => void;
onMutate: (pkgName: string, op: "install" | "update" | "uninstall") => void; onMutate: (pkgName: string, op: "install" | "update" | "uninstall") => void;
onLibrary: (pkgName: string, extensionName: string, iconUrl: string) => void;
} }
let { base, primary, variants, expanded, working, anims, onToggle, onMutate }: Props = $props(); let { base, primary, variants, expanded, working, anims, sources, libraryCount, onToggle, onMutate, onLibrary }: Props = $props();
const clickable = $derived(primary.isInstalled);
const hasVariants = $derived(variants.length > 0); const hasVariants = $derived(variants.length > 0);
</script> </script>
<div class="group"> <div class="group">
<div class="row"> <svelte:element
this={clickable ? "button" : "div"}
class="row"
class:row-clickable={clickable}
onclick={clickable ? () => onLibrary(primary.pkgName, base, primary.iconUrl) : undefined}
>
<Thumbnail <Thumbnail
src={primary.iconUrl} src={primary.iconUrl}
alt={primary.name} alt={primary.name}
@@ -31,6 +43,13 @@
<span class="name">{base}</span> <span class="name">{base}</span>
<span class="meta"> <span class="meta">
<span class="lang-tag">{primary.lang.toUpperCase()}</span> <span class="lang-tag">{primary.lang.toUpperCase()}</span>
{#if primary.isInstalled}
<span class="lib-badge" class:lib-badge-empty={libraryCount === 0}>
<Books size={10} weight={libraryCount > 0 ? "fill" : "regular"} />
{libraryCount > 0 ? libraryCount : 0}
</span>
{/if}
v{primary.versionName} v{primary.versionName}
</span> </span>
</div> </div>
@@ -39,22 +58,24 @@
<CircleNotch size={14} weight="light" class="anim-spin" style="color:var(--text-faint)" /> <CircleNotch size={14} weight="light" class="anim-spin" style="color:var(--text-faint)" />
{:else if primary.hasUpdate} {:else if primary.hasUpdate}
<div class="row-actions"> <div class="row-actions">
<button class="action-btn" onclick={() => onMutate(primary.pkgName, "update")}>Update</button> <button class="action-btn" onclick={(e) => { e.stopPropagation(); onMutate(primary.pkgName, "update"); }}>Update</button>
<button class="action-btn-dim" onclick={() => onMutate(primary.pkgName, "uninstall")}>Remove</button> <button class="action-btn-dim" onclick={(e) => { e.stopPropagation(); onMutate(primary.pkgName, "uninstall"); }}>Remove</button>
</div> </div>
{:else if primary.isInstalled} {:else if primary.isInstalled}
<button class="action-btn-dim" onclick={() => onMutate(primary.pkgName, "uninstall")}>Remove</button> <div class="row-actions">
<button class="action-btn-dim" onclick={(e) => { e.stopPropagation(); onMutate(primary.pkgName, "uninstall"); }}>Remove</button>
</div>
{:else} {:else}
<button class="action-btn" onclick={() => onMutate(primary.pkgName, "install")}>Install</button> <button class="action-btn" onclick={() => onMutate(primary.pkgName, "install")}>Install</button>
{/if} {/if}
{#if hasVariants} {#if hasVariants}
<button class="expand-btn" onclick={() => onToggle(base)} title="{variants.length + 1} languages"> <button class="expand-btn" onclick={(e) => { e.stopPropagation(); onToggle(base); }} title="{variants.length + 1} languages">
{#if expanded}<CaretDown size={12} weight="light" />{:else}<CaretRight size={12} weight="light" />{/if} {#if expanded}<CaretDown size={12} weight="light" />{:else}<CaretRight size={12} weight="light" />{/if}
<span class="expand-count">{variants.length + 1}</span> <span class="expand-count">{variants.length + 1}</span>
</button> </button>
{/if} {/if}
</div> </svelte:element>
{#if expanded && hasVariants} {#if expanded && hasVariants}
<div class="variants" class:variants-anim={anims}> <div class="variants" class:variants-anim={anims}>
@@ -83,15 +104,18 @@
<style> <style>
.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); width: 100%; text-align: left; background: none; }
.row:hover { background: var(--bg-raised); border-color: var(--border-dim); } .row:hover { background: var(--bg-raised); border-color: var(--border-dim); }
.row-clickable { cursor: pointer; }
:global(.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); }
.lang-tag { background: var(--bg-overlay); border: 1px solid var(--border-dim); border-radius: var(--radius-sm); padding: 1px 5px; font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-muted); letter-spacing: var(--tracking-wider); } .lang-tag { background: var(--bg-overlay); border: 1px solid var(--border-dim); border-radius: var(--radius-sm); padding: 1px 5px; font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-muted); letter-spacing: var(--tracking-wider); }
.lib-badge { display: inline-flex; align-items: center; gap: 4px; font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 2px 7px; border-radius: var(--radius-sm); border: 1px solid var(--accent-dim); background: var(--accent-muted); color: var(--accent-fg); flex-shrink: 0; }
.lib-badge-empty { border-color: var(--border-dim); background: var(--bg-overlay); color: var(--text-faint); }
.update-badge-small { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--accent-fg); flex-shrink: 0; } .update-badge-small { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--accent-fg); flex-shrink: 0; }
.row-actions { display: flex; gap: var(--sp-1); flex-shrink: 0; } .row-actions { display: flex; align-items: center; gap: var(--sp-1); flex-shrink: 0; }
.action-btn { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 4px 10px; border-radius: var(--radius-md); background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim); cursor: pointer; flex-shrink: 0; transition: filter var(--t-base); } .action-btn { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 4px 10px; border-radius: var(--radius-md); background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim); cursor: pointer; flex-shrink: 0; transition: filter var(--t-base); }
.action-btn:hover { filter: brightness(1.1); } .action-btn:hover { filter: brightness(1.1); }
.action-btn-dim { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 4px 10px; border-radius: var(--radius-md); background: none; color: var(--text-faint); border: 1px solid var(--border-dim); cursor: pointer; flex-shrink: 0; transition: color var(--t-base), border-color var(--t-base); } .action-btn-dim { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 4px 10px; border-radius: var(--radius-md); background: none; color: var(--text-faint); border: 1px solid var(--border-dim); cursor: pointer; flex-shrink: 0; transition: color var(--t-base), border-color var(--t-base); }
@@ -106,5 +130,5 @@
.variant-row:hover { background: var(--bg-raised); } .variant-row:hover { background: var(--bg-raised); }
.variant-name { flex: 1; font-size: var(--text-sm); color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .variant-name { flex: 1; font-size: var(--text-sm); color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.variant-version { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); flex-shrink: 0; } .variant-version { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); flex-shrink: 0; }
.variant-actions { flex-shrink: 0; } .variant-actions { display: flex; align-items: center; gap: var(--sp-1); flex-shrink: 0; }
</style> </style>
@@ -0,0 +1,333 @@
<script lang="ts">
import { ArrowLeft, MagnifyingGlass, GearSix, Swap, Funnel, Check } from "phosphor-svelte";
import Thumbnail from "@shared/manga/Thumbnail.svelte";
import { resolvedCover } from "@core/cover/coverResolver";
import { gql } from "@api/client";
import { setPreviewManga } from "@store/state.svelte";
import { GET_LIBRARY, GET_SOURCES } from "@api/queries";
import { libraryByExtension, type LibraryManga, type SourceNode, type SourceLibrary } from "../lib/extensionLibrary";
import SourceMigrateModal from "../panels/SourceMigrateModal.svelte";
type SourceEntry = { id: string; displayName: string };
interface Props {
pkgName: string;
extensionName: string;
iconUrl: string;
cols: number;
cropCovers: boolean;
statsAlways: boolean;
anims: boolean;
sources: SourceEntry[];
onBack: () => void;
onSettings: () => void;
}
let { pkgName, extensionName, iconUrl, cols, cropCovers, statsAlways, anims, sources, onBack, onSettings }: Props = $props();
let groups: SourceLibrary[] = $state([]);
let loading = $state(true);
let search = $state("");
type ContentFilter = "unread" | "downloaded";
let activeFilters = $state<Partial<Record<ContentFilter, boolean>>>({});
let filterOpen = $state(false);
const hasActiveFilters = $derived(Object.values(activeFilters).some(Boolean));
let migrateTarget: { sourceId: string; sourceName: string; iconUrl: string; manga: LibraryManga[] } | null = $state(null);
const allManga = $derived(groups.flatMap(g => g.manga));
const filtered = $derived((() => {
let items = allManga;
const q = search.trim().toLowerCase();
if (q) items = items.filter(m => m.title.toLowerCase().includes(q));
if (activeFilters.unread) items = items.filter(m => m.unreadCount > 0);
if (activeFilters.downloaded) items = items.filter(m => m.downloadCount > 0);
return items;
})());
let sourceNodes: SourceNode[] = $state([]);
$effect(() => { load(); });
async function load() {
loading = true;
try {
const [libData, srcData] = await Promise.all([
gql<{ mangas: { nodes: LibraryManga[] } }>(GET_LIBRARY),
gql<{ sources: { nodes: SourceNode[] } }>(GET_SOURCES),
]);
sourceNodes = srcData.sources.nodes;
groups = libraryByExtension(libData.mangas.nodes, srcData.sources.nodes, pkgName);
} finally {
loading = false;
}
}
function toggleFilter(f: ContentFilter) {
activeFilters = { ...activeFilters, [f]: !activeFilters[f] };
}
function clearFilters() {
activeFilters = {};
}
function openMigrate(group: SourceLibrary) {
const node = sourceNodes.find(s => s.id === group.sourceId);
migrateTarget = {
sourceId: group.sourceId,
sourceName: group.displayName,
iconUrl: (node as any)?.iconUrl ?? iconUrl,
manga: group.manga,
};
}
$effect(() => {
if (!filterOpen) return;
function onOutside(e: MouseEvent) {
if (!(e.target as HTMLElement).closest(".filter-wrap")) filterOpen = false;
}
setTimeout(() => document.addEventListener("mousedown", onOutside, true), 0);
return () => document.removeEventListener("mousedown", onOutside, true);
});
const CONTENT_FILTERS: [ContentFilter, string][] = [
["unread", "Unread"],
["downloaded", "Downloaded"],
];
</script>
<div class="root">
<div class="header">
<button class="header-btn" onclick={onBack}>
<ArrowLeft size={14} weight="bold" />
</button>
{#if iconUrl}
<Thumbnail src={iconUrl} alt={extensionName} class="header-icon" onerror={(e) => ((e.target as HTMLImageElement).style.display = "none")} />
{/if}
<div class="title-block">
<span class="eyebrow">In Library</span>
<span class="title">{extensionName}</span>
</div>
{#if !loading}
<span class="count-badge">{filtered.length}{filtered.length !== allManga.length ? ` / ${allManga.length}` : ""}</span>
{/if}
<div class="header-right">
<div class="search-wrap">
<MagnifyingGlass size={12} class="search-icon" weight="light" />
<input class="search" placeholder="Search" bind:value={search} autocomplete="off" />
</div>
<div class="filter-wrap">
<button
class="filter-btn"
class:filter-btn-active={hasActiveFilters}
title="Filter"
onclick={() => filterOpen = !filterOpen}
>
<Funnel size={13} weight={hasActiveFilters ? "fill" : "bold"} />
</button>
{#if filterOpen}
<div class="filter-panel" role="menu">
<div class="panel-header">
<span class="panel-heading">Filter</span>
{#if hasActiveFilters}
<button class="panel-clear-btn" onclick={clearFilters}>Clear all</button>
{/if}
</div>
<div class="panel-divider"></div>
<p class="panel-label">Content</p>
{#each CONTENT_FILTERS as [f, label]}
<button
class="panel-item"
class:panel-item-active={activeFilters[f]}
role="menuitem"
onclick={() => toggleFilter(f)}
>
<span class="panel-check" class:panel-check-on={activeFilters[f]}>
{#if activeFilters[f]}<Check size={9} weight="bold" />{/if}
</span>
{label}
</button>
{/each}
</div>
{/if}
</div>
{#if sources.length > 0}
<button class="header-btn" onclick={onSettings} title="Extension settings">
<GearSix size={14} weight="bold" />
</button>
{/if}
</div>
</div>
<div class="content">
{#if loading}
<div class="grid" style="--cols:{cols}">
{#each Array(12) as _}
<div class="card-skeleton">
<div class="cover-skeleton skeleton"></div>
<div class="title-skeleton skeleton"></div>
</div>
{/each}
</div>
{:else if filtered.length === 0}
<div class="empty">
{allManga.length === 0 ? "Nothing from this extension is in your library." : "No matches."}
</div>
{:else}
{#if groups.length > 1}
<div class="source-groups">
{#each groups as group}
<div class="source-group-header">
<span class="source-group-name">{group.displayName}</span>
<span class="source-group-count">{group.manga.length}</span>
<button class="migrate-btn" onclick={() => openMigrate(group)} title="Migrate this source">
<Swap size={12} weight="bold" />
Migrate source
</button>
</div>
{/each}
</div>
{:else if groups.length === 1}
<div class="single-source-bar">
<span class="source-group-name">{groups[0].displayName}</span>
<button class="migrate-btn" onclick={() => openMigrate(groups[0])} title="Migrate this source">
<Swap size={12} weight="bold" />
Migrate source
</button>
</div>
{/if}
<div class="grid" style="--cols:{cols}">
{#each filtered as m (m.id)}
{@const isCompleted = !m.unreadCount && m.downloadCount > 0}
<button class="card" class:anims onclick={() => setPreviewManga(m as any)}>
<div class="cover-wrap" class:completed={isCompleted}>
<Thumbnail
src={resolvedCover(m.id, m.thumbnailUrl)}
alt={m.title}
class="cover"
style="object-fit:{cropCovers ? 'cover' : 'contain'}"
draggable="false"
/>
<div class="card-info-overlay" class:anim={anims} class:instant={!anims} class:always={statsAlways}>
<div class="overlay-badges">
{#if isCompleted}
<span class="badge badge-done">✓ Done</span>
{:else if m.unreadCount}
<span class="badge badge-unread">{m.unreadCount} new</span>
{/if}
{#if m.downloadCount}
<span class="badge badge-dl">{m.downloadCount}</span>
{/if}
</div>
</div>
</div>
<p class="card-title">{m.title}</p>
</button>
{/each}
</div>
{/if}
</div>
</div>
{#if migrateTarget}
<SourceMigrateModal
sourceId={migrateTarget.sourceId}
sourceName={migrateTarget.sourceName}
sourceIconUrl={migrateTarget.iconUrl}
manga={migrateTarget.manga}
onClose={() => migrateTarget = null}
onDone={() => { migrateTarget = null; load(); }}
/>
{/if}
<style>
.root { display: flex; flex-direction: column; height: 100%; overflow: hidden; }
:global(.header-icon) { width: 24px; height: 24px; border-radius: var(--radius-sm); object-fit: cover; flex-shrink: 0; background: var(--bg-raised); }
.header { display: flex; align-items: center; gap: var(--sp-3); padding: var(--sp-4) var(--sp-6); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
.header-right { display: flex; align-items: center; gap: var(--sp-2); margin-left: auto; }
.header-btn { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: var(--radius-md); color: var(--text-faint); flex-shrink: 0; transition: color var(--t-base), background var(--t-base); }
.header-btn:hover { color: var(--text-primary); background: var(--bg-raised); }
.title-block { display: flex; flex-direction: column; gap: 1px; }
.eyebrow { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; }
.title { font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-primary); }
.count-badge { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 2px 8px; border-radius: var(--radius-sm); background: var(--bg-overlay); border: 1px solid var(--border-dim); color: var(--text-muted); flex-shrink: 0; }
.search-wrap { position: relative; display: flex; align-items: center; }
.search-wrap :global(.search-icon) { position: absolute; left: 9px; color: var(--text-faint); pointer-events: none; }
.search { background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 5px 10px 5px 26px; color: var(--text-primary); font-size: var(--text-sm); width: 160px; outline: none; transition: border-color var(--t-base); }
.search::placeholder { color: var(--text-faint); }
.search:focus { border-color: var(--border-strong); }
.filter-wrap { position: relative; }
.filter-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); }
.filter-btn:hover { color: var(--text-primary); border-color: var(--border-strong); }
.filter-btn-active { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
.filter-panel { position: absolute; top: calc(100% + 6px); right: 0; z-index: 9999; 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: fadeIn 0.1s ease both; }
.panel-header { display: flex; align-items: center; justify-content: space-between; padding: 6px 10px 4px; }
.panel-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, 500); }
.panel-clear-btn { 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); }
.panel-clear-btn:hover { color: var(--color-error); }
.panel-divider { height: 1px; background: var(--border-dim); margin: 4px 2px; }
.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; 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); }
.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:hover { background: var(--accent-dim); }
.panel-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); }
.panel-check-on { background: var(--accent); border-color: var(--accent); }
.content { flex: 1; overflow-y: auto; padding: var(--sp-4) var(--sp-6) var(--sp-6); will-change: scroll-position; display: flex; flex-direction: column; gap: var(--sp-3); }
.source-groups { display: flex; flex-direction: column; gap: var(--sp-1); }
.source-group-header { display: flex; align-items: center; gap: var(--sp-2); padding: var(--sp-2) 0; border-bottom: 1px solid var(--border-dim); }
.single-source-bar { display: flex; align-items: center; gap: var(--sp-2); padding-bottom: var(--sp-2); border-bottom: 1px solid var(--border-dim); }
.source-group-name { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); letter-spacing: var(--tracking-wide); font-weight: var(--weight-medium); }
.source-group-count { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); padding: 1px 6px; border-radius: var(--radius-sm); background: var(--bg-overlay); border: 1px solid var(--border-dim); }
.migrate-btn { display: flex; align-items: center; gap: 5px; margin-left: auto; font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 3px 9px; border-radius: var(--radius-sm); background: none; color: var(--text-faint); border: 1px solid var(--border-dim); cursor: pointer; flex-shrink: 0; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
.migrate-btn:hover { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
.grid { display: grid; grid-template-columns: repeat(var(--cols, auto-fill), minmax(130px, 1fr)); gap: var(--sp-4); }
.card { background: none; border: none; padding: 0; cursor: pointer; text-align: left; }
.card.anims:hover .cover-wrap { transform: translateY(-3px); border-color: var(--border-strong); box-shadow: 0 6px 20px rgba(0,0,0,0.35); }
.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); will-change: transform; }
.card.anims .cover-wrap { transition: transform 0.18s cubic-bezier(0.16,1,0.3,1), border-color var(--t-base), box-shadow 0.18s cubic-bezier(0.16,1,0.3,1); }
.cover-wrap.completed { box-shadow: inset 0 -2px 0 0 var(--accent); }
.card-info-overlay { position: absolute; bottom: -4px; left: 0; right: 0; z-index: 2; padding: 32px 6px 10px; background: linear-gradient(to top, rgba(0,0,0,0.88) 0%, rgba(0,0,0,0.5) 50%, transparent 100%); opacity: 0; pointer-events: none; }
.card-info-overlay.anim { transition: opacity 0.18s ease; }
.card-info-overlay.instant { transition: none; }
.card-info-overlay.always { opacity: 1; }
.card:hover .card-info-overlay { opacity: 1; }
.overlay-badges { display: flex; align-items: flex-end; justify-content: space-between; gap: 4px; flex-wrap: wrap; }
.badge { font-family: var(--font-ui); font-size: 9.5px; font-weight: 700; letter-spacing: 0.04em; line-height: 1; padding: 3px 7px; border-radius: 20px; white-space: nowrap; }
.badge-unread { background: var(--accent); color: #fff; box-shadow: 0 1px 8px rgba(0,0,0,0.5); }
.badge-done { background: rgba(255,255,255,0.18); color: rgba(255,255,255,0.9); border: 1px solid rgba(255,255,255,0.25); }
.badge-dl { background: rgba(0,0,0,0.55); color: rgba(255,255,255,0.8); border: 1px solid rgba(255,255,255,0.18); margin-left: auto; }
.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; height: 2lh; }
.card.anims .card-title { transition: color var(--t-base); }
.card-skeleton { padding: 0; }
.cover-skeleton { aspect-ratio: 2/3; border-radius: var(--radius-md); }
.title-skeleton { height: 12px; margin-top: var(--sp-2); width: 80%; border-radius: var(--radius-sm); }
.empty { display: flex; align-items: center; justify-content: center; height: 60%; color: var(--text-muted); font-size: var(--text-sm); }
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
</style>
@@ -3,14 +3,21 @@
import { CircleNotch, X, Check, HardDrives } from "phosphor-svelte"; import { CircleNotch, X, Check, HardDrives } from "phosphor-svelte";
import { gql } from "@api/client"; import { gql } from "@api/client";
import { store, addToast } from "@store/state.svelte"; import { store, addToast } from "@store/state.svelte";
import { GET_EXTENSIONS, GET_SETTINGS, GET_LOCAL_MANGA } from "@api/queries"; import { GET_EXTENSIONS, GET_SOURCES, GET_SETTINGS, GET_LOCAL_MANGA, GET_LIBRARY } from "@api/queries";
import { SET_EXTENSION_REPOS, INSTALL_EXTERNAL_EXTENSION, FETCH_EXTENSIONS, UPDATE_EXTENSION } from "@api/mutations"; import { SET_EXTENSION_REPOS, INSTALL_EXTERNAL_EXTENSION, FETCH_EXTENSIONS, UPDATE_EXTENSION } from "@api/mutations";
import type { Extension } from "@types/index"; import type { Extension } from "@types/index";
import { matchesFilter, groupExtensions, validateUrl, type Filter, type Panel } from "../lib/extensionHelpers"; import { matchesFilter, groupExtensions, validateUrl, type Filter, type Panel } from "../lib/extensionHelpers";
import { libraryCountByPkg, type LibraryManga, type SourceNode } from "../lib/extensionLibrary";
import ExtensionFilters from "./ExtensionFilters.svelte"; import ExtensionFilters from "./ExtensionFilters.svelte";
import ExtensionCard from "./ExtensionCard.svelte"; import ExtensionCard from "./ExtensionCard.svelte";
import ExtensionSettingsPanel from "../panels/ExtensionSettingsPanel.svelte";
import ExtensionLibrary from "./ExtensionLibrary.svelte";
const anims = $derived(store.settings.qolAnimations ?? true); const anims = $derived(store.settings.qolAnimations ?? true);
const cols = $derived(store.settings.libraryCols ?? 5);
const cropCovers = $derived(store.settings.cropCovers ?? true);
const statsAlways = $derived(store.settings.statsAlways ?? false);
let tabsEl = $state<HTMLDivElement | undefined>(undefined); let tabsEl = $state<HTMLDivElement | undefined>(undefined);
let tabIndicator = $state({ left: 0, width: 0 }); let tabIndicator = $state({ left: 0, width: 0 });
@@ -33,6 +40,15 @@
let expanded = $state(new Set<string>()); let expanded = $state(new Set<string>());
let panel = $state<Panel>(null); let panel = $state<Panel>(null);
type SourceEntry = { id: string; displayName: string };
type SettingsTarget = { extensionName: string; iconUrl: string; sources: SourceEntry[] };
type LibraryTarget = { pkgName: string; extensionName: string; iconUrl: string };
let settingsTarget = $state<SettingsTarget | null>(null);
let libraryTarget = $state<LibraryTarget | null>(null);
let sourcesByPkg = $state<Record<string, SourceEntry[]>>({});
let libCountByPkg = $state<Record<string, number>>({});
$effect(() => { filter; extensions; if (anims) requestAnimationFrame(() => requestAnimationFrame(updateIndicator)); }); $effect(() => { filter; extensions; if (anims) requestAnimationFrame(() => requestAnimationFrame(updateIndicator)); });
let externalUrl = $state(""); let externalUrl = $state("");
@@ -47,8 +63,25 @@
let savingRepos = $state(false); let savingRepos = $state(false);
async function load() { async function load() {
const d = await gql<{ extensions: { nodes: Extension[] } }>(GET_EXTENSIONS).catch(console.error); const [extData, srcData, libData] = await Promise.all([
if (d) extensions = d.extensions.nodes; gql<{ extensions: { nodes: Extension[] } }>(GET_EXTENSIONS).catch(console.error),
gql<{ sources: { nodes: SourceNode[] } }>(GET_SOURCES).catch(console.error),
gql<{ mangas: { nodes: LibraryManga[] } }>(GET_LIBRARY).catch(console.error),
]);
if (extData) extensions = extData.extensions.nodes;
if (srcData) {
const map: Record<string, SourceEntry[]> = {};
for (const s of srcData.sources.nodes) {
if (!s.isConfigurable || !s.extension?.pkgName) continue;
const pkg = s.extension.pkgName;
if (!map[pkg]) map[pkg] = [];
map[pkg].push({ id: s.id, displayName: s.displayName });
}
sourcesByPkg = map;
}
if (libData && srcData) {
libCountByPkg = libraryCountByPkg(libData.mangas.nodes, srcData.sources.nodes);
}
} }
async function loadLocalManga() { async function loadLocalManga() {
@@ -213,6 +246,17 @@
function focusOnMount(node: HTMLElement) { node.focus(); } function focusOnMount(node: HTMLElement) { node.focus(); }
</script> </script>
{#if libraryTarget}
<ExtensionLibrary
pkgName={libraryTarget.pkgName}
extensionName={libraryTarget.extensionName}
iconUrl={libraryTarget.iconUrl}
{cols} {cropCovers} {statsAlways} {anims}
sources={sourcesByPkg[libraryTarget.pkgName] ?? []}
onBack={() => libraryTarget = null}
onSettings={() => { settingsTarget = { extensionName: libraryTarget!.extensionName, iconUrl: libraryTarget!.iconUrl, sources: sourcesByPkg[libraryTarget!.pkgName] ?? [] }; }}
/>
{:else}
<div class="root anim-fade-in"> <div class="root anim-fade-in">
<ExtensionFilters <ExtensionFilters
{filter} {search} {panel} {refreshing} {updateCount} {availableLangs} {langFilter} {filter} {search} {panel} {refreshing} {updateCount} {availableLangs} {langFilter}
@@ -307,9 +351,12 @@
{#each groups as { base, primary, variants }} {#each groups as { base, primary, variants }}
<ExtensionCard <ExtensionCard
{base} {primary} {variants} {working} {anims} {base} {primary} {variants} {working} {anims}
sources={sourcesByPkg[primary.pkgName] ?? []}
libraryCount={libCountByPkg[primary.pkgName] ?? 0}
expanded={expanded.has(base)} expanded={expanded.has(base)}
onToggle={toggleExpand} onToggle={toggleExpand}
onMutate={mutate} onMutate={mutate}
onLibrary={(pkgName, extensionName, iconUrl) => libraryTarget = { pkgName, extensionName, iconUrl }}
/> />
{/each} {/each}
{#if !showLocal && groups.length === 0} {#if !showLocal && groups.length === 0}
@@ -318,13 +365,24 @@
</div> </div>
{/if} {/if}
</div> </div>
{/if}
{#if settingsTarget}
<ExtensionSettingsPanel
extensionName={settingsTarget.extensionName}
iconUrl={settingsTarget.iconUrl}
sources={settingsTarget.sources}
onClose={() => settingsTarget = null}
/>
{/if}
<style> <style>
.root { display: flex; flex-direction: column; height: 100%; overflow: hidden; } .root { display: flex; flex-direction: column; height: 100%; overflow: hidden; }
.list { flex: 1; overflow-y: auto; padding: 0 var(--sp-4) var(--sp-4); display: flex; flex-direction: column; gap: 1px; } .list { flex: 1; overflow-y: auto; padding: 0 var(--sp-4) var(--sp-4); display: flex; flex-direction: column; gap: 1px; }
.empty { display: flex; align-items: center; justify-content: center; flex: 1; color: var(--text-faint); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); } .empty { display: flex; align-items: center; justify-content: center; flex: 1; color: var(--text-faint); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); }
:global(.icon-btn) { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: var(--radius-md); color: var(--text-muted); transition: color var(--t-base), background var(--t-base); } :global(.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); }
:global(.icon-btn:hover:not(:disabled)) { color: var(--text-primary); background: var(--bg-raised); } :global(.icon-btn:hover:not(:disabled)) { color: var(--text-primary); border-color: var(--border-strong); }
:global(.icon-btn-active) { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
.ext-panel { display: flex; flex-direction: column; gap: var(--sp-2); padding: var(--sp-3) var(--sp-6); flex-shrink: 0; border-bottom: 1px solid var(--border-dim); background: var(--bg-raised); opacity: 1; } .ext-panel { display: flex; flex-direction: column; gap: var(--sp-2); padding: var(--sp-3) var(--sp-6); flex-shrink: 0; border-bottom: 1px solid var(--border-dim); background: var(--bg-raised); opacity: 1; }
.ext-panel-anim { animation: panelSlide 0.18s cubic-bezier(0.16,1,0.3,1) both; } .ext-panel-anim { animation: panelSlide 0.18s cubic-bezier(0.16,1,0.3,1) both; }
.panel-header { display: flex; align-items: center; padding-bottom: var(--sp-1); } .panel-header { display: flex; align-items: center; padding-bottom: var(--sp-1); }
@@ -0,0 +1,56 @@
export interface LibraryManga {
id: number;
title: string;
thumbnailUrl: string;
unreadCount: number;
downloadCount: number;
source: { id: string; displayName: string };
}
export interface SourceLibrary {
sourceId: string;
displayName: string;
manga: LibraryManga[];
}
export type SourceNode = {
id: string;
displayName: string;
isConfigurable: boolean;
extension: { pkgName: string };
};
export function libraryByExtension(
libraryManga: LibraryManga[],
sources: SourceNode[],
pkgName: string,
): SourceLibrary[] {
const pkgSources = sources.filter(s => s.extension?.pkgName === pkgName);
const sourceIds = new Set(pkgSources.map(s => s.id));
const bySource = new Map<string, LibraryManga[]>();
for (const src of pkgSources) bySource.set(src.id, []);
for (const m of libraryManga) {
if (sourceIds.has(m.source.id)) bySource.get(m.source.id)!.push(m);
}
return pkgSources
.map(src => ({ sourceId: src.id, displayName: src.displayName, manga: bySource.get(src.id)! }))
.filter(g => g.manga.length > 0);
}
export function libraryCountByPkg(
libraryManga: LibraryManga[],
sources: SourceNode[],
): Record<string, number> {
const sourceIdToPkg = new Map<string, string>();
for (const s of sources) {
if (s.extension?.pkgName) sourceIdToPkg.set(s.id, s.extension.pkgName);
}
const counts: Record<string, number> = {};
for (const m of libraryManga) {
const pkg = sourceIdToPkg.get(m.source.id);
if (pkg) counts[pkg] = (counts[pkg] ?? 0) + 1;
}
return counts;
}
@@ -0,0 +1,526 @@
<script lang="ts">
import { X, CircleNotch, CaretUpDown, Check, CaretLeft } from "phosphor-svelte";
import Thumbnail from "@shared/manga/Thumbnail.svelte";
import { gql } from "@api/client";
import { addToast } from "@store/state.svelte";
import { GET_SOURCE_SETTINGS } from "@api/queries";
import { UPDATE_SOURCE_PREFERENCE } from "@api/mutations";
interface Preference {
type: string;
key: string;
CheckBoxTitle?: string;
CheckBoxSummary?: string;
CheckBoxDefault?: boolean;
CheckBoxCurrentValue?: boolean;
SwitchPreferenceTitle?: string;
SwitchPreferenceSummary?: string;
SwitchPreferenceDefault?: boolean;
SwitchPreferenceCurrentValue?: boolean;
ListPreferenceTitle?: string;
ListPreferenceSummary?: string;
ListPreferenceDefault?: string;
ListPreferenceCurrentValue?: string;
entries?: string[];
entryValues?: string[];
EditTextPreferenceTitle?: string;
EditTextPreferenceSummary?: string;
EditTextPreferenceDefault?: string;
EditTextPreferenceCurrentValue?: string;
dialogTitle?: string;
dialogMessage?: string;
MultiSelectListPreferenceTitle?: string;
MultiSelectListPreferenceSummary?: string;
MultiSelectListPreferenceDefault?: string[];
MultiSelectListPreferenceCurrentValue?: string[];
}
export type SourceEntry = { id: string; displayName: string };
interface Props {
extensionName: string;
iconUrl: string;
sources: SourceEntry[];
onClose: () => void;
}
let { extensionName, iconUrl, sources, onClose }: Props = $props();
let phase = $state<"pick" | "settings">("pick");
let activeSource = $state<SourceEntry | null>(null);
let prefs = $state<Preference[]>([]);
let loading = $state(false);
let saving = $state<string | null>(null);
let editKey = $state<string | null>(null);
let editValue = $state("");
let listOpen = $state<string | null>(null);
$effect(() => {
if (sources.length === 1) openSource(sources[0]);
});
async function openSource(src: SourceEntry) {
activeSource = src;
phase = "settings";
loading = true;
prefs = [];
editKey = null;
listOpen = null;
try {
const d = await gql<{ source: { preferences: Preference[] } }>(
GET_SOURCE_SETTINGS,
{ id: String(src.id) },
);
prefs = d.source.preferences ?? [];
} catch (e: any) {
addToast({ kind: "error", title: "Failed to load settings", body: e?.message ?? "" });
} finally {
loading = false;
}
}
function backToPicker() {
phase = "pick";
activeSource = null;
prefs = [];
editKey = null;
listOpen = null;
}
async function save(position: number, changeType: string, value: unknown) {
if (!activeSource) return;
const pref = prefs[position];
saving = pref.key;
try {
await gql(UPDATE_SOURCE_PREFERENCE, {
source: String(activeSource.id),
change: { position, [changeType]: value },
});
const d = await gql<{ source: { preferences: Preference[] } }>(
GET_SOURCE_SETTINGS,
{ id: String(activeSource.id) },
);
prefs = d.source.preferences ?? [];
} catch (e: any) {
addToast({ kind: "error", title: "Failed to save", body: e?.message ?? "" });
} finally {
saving = null;
}
}
function getTitle(p: Preference) {
return p.CheckBoxTitle ?? p.SwitchPreferenceTitle ?? p.ListPreferenceTitle
?? p.EditTextPreferenceTitle ?? p.MultiSelectListPreferenceTitle ?? p.key;
}
function getSummary(p: Preference) {
return p.CheckBoxSummary ?? p.SwitchPreferenceSummary ?? p.ListPreferenceSummary
?? p.EditTextPreferenceSummary ?? p.MultiSelectListPreferenceSummary ?? null;
}
function getBoolValue(p: Preference) {
if (p.type === "CheckBoxPreference")
return p.CheckBoxCurrentValue ?? p.CheckBoxDefault ?? false;
return p.SwitchPreferenceCurrentValue ?? p.SwitchPreferenceDefault ?? false;
}
function getListValue(p: Preference) {
return p.ListPreferenceCurrentValue ?? p.ListPreferenceDefault ?? "";
}
function getListLabel(p: Preference, val: string) {
const idx = p.entryValues?.indexOf(val) ?? -1;
return idx >= 0 ? (p.entries?.[idx] ?? val) : val;
}
function getMultiValue(p: Preference): string[] {
return p.MultiSelectListPreferenceCurrentValue ?? p.MultiSelectListPreferenceDefault ?? [];
}
function toggleMulti(position: number, p: Preference, val: string) {
const current = getMultiValue(p);
const next = current.includes(val) ? current.filter(v => v !== val) : [...current, val];
save(position, "multiSelectState", next);
}
function submitEdit(position: number) {
save(position, "editTextState", editValue);
editKey = null;
}
function openEdit(p: Preference) {
editKey = p.key;
editValue = p.EditTextPreferenceCurrentValue ?? p.EditTextPreferenceDefault ?? "";
}
function langTag(displayName: string) {
const m = displayName.match(/\(([^)]+)\)$/);
return m ? m[1].toUpperCase() : null;
}
function onBackdrop(e: MouseEvent) {
if (e.target === e.currentTarget) onClose();
}
function onKeydown(e: KeyboardEvent) {
if (e.key === "Escape") {
if (editKey) { editKey = null; return; }
if (listOpen) { listOpen = null; return; }
if (phase === "settings" && sources.length > 1) { backToPicker(); return; }
onClose();
}
}
</script>
<svelte:window onkeydown={onKeydown} />
<div class="backdrop" role="dialog" aria-modal="true" onmousedown={onBackdrop}>
<div class="modal">
<div class="modal-header">
<div class="modal-title-wrap">
{#if phase === "settings" && sources.length > 1}
<button class="icon-btn" onclick={backToPicker} title="Back">
<CaretLeft size={13} weight="bold" />
</button>
{/if}
{#if iconUrl}
<Thumbnail src={iconUrl} alt={extensionName} class="modal-ext-icon" onerror={(e) => ((e.target as HTMLImageElement).style.display = "none")} />
{/if}
<div class="modal-titles">
<span class="modal-eyebrow">Extension Settings</span>
<span class="modal-title">
{phase === "pick" ? extensionName : (activeSource?.displayName ?? extensionName)}
</span>
</div>
</div>
<button class="icon-btn" onclick={onClose}>
<X size={14} weight="bold" />
</button>
</div>
<div class="modal-body">
{#if phase === "pick"}
<div class="source-list">
{#each sources as src}
{@const tag = langTag(src.displayName)}
{@const baseName = src.displayName.replace(/\s*\([^)]+\)$/, "")}
<button class="source-row" onclick={() => openSource(src)}>
<span class="source-name">{baseName}</span>
{#if tag}<span class="lang-badge">{tag}</span>{/if}
</button>
{/each}
</div>
{:else}
{#if loading}
<div class="center-state">
<CircleNotch size={16} weight="light" class="anim-spin" style="color:var(--text-faint)" />
</div>
{:else if prefs.length === 0}
<div class="center-state empty-state">No configurable settings.</div>
{:else}
<div class="pref-list">
{#each prefs as pref, i}
{@const title = getTitle(pref)}
{@const summary = getSummary(pref)}
{@const isSaving = saving === pref.key}
{#if pref.type === "CheckBoxPreference" || pref.type === "SwitchPreference"}
{@const checked = getBoolValue(pref)}
<div class="pref-row">
<div class="pref-text">
<span class="pref-title">{title}</span>
{#if summary}<span class="pref-summary">{summary}</span>{/if}
</div>
<button
class="toggle" class:toggle-on={checked}
disabled={isSaving}
onclick={() => save(i, pref.type === "CheckBoxPreference" ? "checkBoxState" : "switchState", !checked)}
>
{#if isSaving}
<CircleNotch size={10} weight="light" class="anim-spin" />
{:else}
<span class="toggle-thumb"></span>
{/if}
</button>
</div>
{:else if pref.type === "ListPreference"}
{@const current = getListValue(pref)}
<div class="pref-row pref-row-col">
<div class="pref-text">
<span class="pref-title">{title}</span>
{#if summary}<span class="pref-summary">{summary}</span>{/if}
</div>
<div class="select-wrap">
<button
class="select-btn" class:select-open={listOpen === pref.key}
disabled={isSaving}
onclick={() => listOpen = listOpen === pref.key ? null : pref.key}
>
<span class="select-val">{getListLabel(pref, current)}</span>
{#if isSaving}
<CircleNotch size={11} weight="light" class="anim-spin" />
{:else}
<CaretUpDown size={11} weight="bold" />
{/if}
</button>
{#if listOpen === pref.key}
<div class="dropdown">
{#each (pref.entries ?? []) as entry, j}
{@const val = pref.entryValues?.[j] ?? entry}
<button
class="dropdown-item" class:dropdown-item-active={val === current}
onclick={() => { save(i, "listState", val); listOpen = null; }}
>
{entry}
{#if val === current}<Check size={11} weight="bold" />{/if}
</button>
{/each}
</div>
{/if}
</div>
</div>
{:else if pref.type === "EditTextPreference"}
{#if editKey === pref.key}
<div class="pref-row pref-row-col edit-active">
<div class="pref-text">
{#if pref.dialogTitle}<span class="pref-title">{pref.dialogTitle}</span>{/if}
{#if pref.dialogMessage}<span class="pref-summary">{pref.dialogMessage}</span>{/if}
</div>
<div class="edit-row">
<input
class="edit-input"
bind:value={editValue}
disabled={isSaving}
onkeydown={(e) => { if (e.key === "Enter") submitEdit(i); if (e.key === "Escape") editKey = null; }}
autofocus
/>
<button class="action-btn-dim" onclick={() => editKey = null}>Cancel</button>
<button class="action-btn" onclick={() => submitEdit(i)} disabled={isSaving}>
{#if isSaving}<CircleNotch size={11} weight="light" class="anim-spin" />{:else}Save{/if}
</button>
</div>
</div>
{:else}
<button class="pref-row pref-row-btn" onclick={() => openEdit(pref)}>
<div class="pref-text">
<span class="pref-title">{title}</span>
{#if summary}<span class="pref-summary">{summary}</span>{/if}
</div>
<span class="pref-value-hint">
{pref.EditTextPreferenceCurrentValue ?? pref.EditTextPreferenceDefault ?? "—"}
</span>
</button>
{/if}
{:else if pref.type === "MultiSelectListPreference"}
{@const selected = getMultiValue(pref)}
<div class="pref-row pref-row-col">
<div class="pref-text">
<span class="pref-title">{title}</span>
{#if summary}<span class="pref-summary">{summary}</span>{/if}
</div>
<div class="multi-list">
{#each (pref.entries ?? []) as entry, j}
{@const val = pref.entryValues?.[j] ?? entry}
{@const on = selected.includes(val)}
<button
class="multi-item" class:multi-item-on={on}
disabled={isSaving}
onclick={() => toggleMulti(i, pref, val)}
>
<span class="multi-check">{#if on}<Check size={10} weight="bold" />{/if}</span>
{entry}
</button>
{/each}
</div>
</div>
{/if}
{/each}
</div>
{/if}
{/if}
</div>
</div>
</div>
<style>
.backdrop {
position: fixed; inset: 0;
background: rgba(0,0,0,0.45);
backdrop-filter: blur(4px);
display: flex; align-items: center; justify-content: center;
z-index: var(--z-modal);
animation: fadeIn 0.15s ease both;
}
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
.modal {
display: flex; flex-direction: column;
width: 400px; max-width: calc(100vw - 32px); max-height: 78vh;
background: var(--bg-surface);
border: 1px solid var(--border-dim);
border-radius: var(--radius-lg);
overflow: hidden;
animation: slideUp 0.18s cubic-bezier(0.16,1,0.3,1) both;
box-shadow: var(--shadow-lg);
}
@keyframes slideUp { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: translateY(0); } }
.modal-header {
display: flex; align-items: center; justify-content: space-between;
padding: var(--sp-3) var(--sp-3) var(--sp-3) var(--sp-4);
border-bottom: 1px solid var(--border-dim);
flex-shrink: 0;
}
.modal-title-wrap { display: flex; align-items: center; gap: var(--sp-2); }
:global(.modal-ext-icon) { width: 22px; height: 22px; border-radius: var(--radius-sm); object-fit: cover; flex-shrink: 0; background: var(--bg-raised); }
.modal-titles { display: flex; flex-direction: column; gap: 1px; }
.modal-eyebrow {
font-family: var(--font-ui); font-size: var(--text-2xs);
color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase;
}
.modal-title { font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-primary); }
.icon-btn {
display: flex; align-items: center; justify-content: center;
width: 26px; height: 26px;
border-radius: var(--radius-md);
color: var(--text-faint); flex-shrink: 0;
transition: color var(--t-base), background var(--t-base);
}
.icon-btn:hover { color: var(--text-primary); background: var(--bg-overlay); }
.modal-body { overflow-y: auto; flex: 1; }
.source-list { display: flex; flex-direction: column; padding: var(--sp-2) 0; }
.source-row {
display: flex; align-items: center; justify-content: space-between;
padding: 10px var(--sp-4);
text-align: left;
transition: background var(--t-fast);
gap: var(--sp-3);
}
.source-row:hover { background: var(--bg-raised); }
.source-name { flex: 1; font-size: var(--text-sm); color: var(--text-secondary); font-weight: var(--weight-medium); }
.lang-badge {
font-family: var(--font-ui); font-size: 10px; letter-spacing: var(--tracking-wide);
color: var(--text-faint); background: var(--bg-overlay);
border: 1px solid var(--border-dim);
border-radius: var(--radius-sm);
padding: 1px 6px; flex-shrink: 0;
}
.center-state { display: flex; align-items: center; justify-content: center; padding: var(--sp-8); }
.empty-state { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
.pref-list { display: flex; flex-direction: column; padding: var(--sp-2) 0; }
.pref-row {
display: flex; align-items: center; gap: var(--sp-3);
padding: 10px var(--sp-4);
border-bottom: 1px solid var(--border-dim);
}
.pref-row:last-child { border-bottom: none; }
.pref-row-col { flex-direction: column; align-items: stretch; gap: var(--sp-2); }
.pref-row-btn { width: 100%; text-align: left; transition: background var(--t-fast); }
.pref-row-btn:hover { background: var(--bg-raised); }
.edit-active { background: var(--bg-raised); }
.pref-text { display: flex; flex-direction: column; gap: 2px; flex: 1; min-width: 0; }
.pref-title { font-size: var(--text-sm); color: var(--text-secondary); font-weight: var(--weight-medium); }
.pref-summary {
font-family: var(--font-ui); font-size: var(--text-2xs);
color: var(--text-faint); letter-spacing: var(--tracking-wide); line-height: 1.5;
}
.pref-value-hint {
font-family: var(--font-ui); font-size: var(--text-2xs);
color: var(--text-muted); letter-spacing: var(--tracking-wide);
flex-shrink: 0; max-width: 120px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.toggle {
position: relative; width: 32px; height: 18px; border-radius: 9px;
background: var(--bg-overlay); border: 1px solid var(--border-strong);
flex-shrink: 0; transition: background var(--t-base), border-color var(--t-base);
display: flex; align-items: center; justify-content: center;
}
.toggle-on { background: var(--accent-muted); border-color: var(--accent-dim); }
.toggle-thumb {
position: absolute; left: 2px; width: 12px; height: 12px;
border-radius: 50%; background: var(--text-faint);
transition: left var(--t-base), background var(--t-base); pointer-events: none;
}
.toggle-on .toggle-thumb { left: 16px; background: var(--accent-fg); }
.toggle:disabled { opacity: 0.4; cursor: default; }
.select-wrap { position: relative; }
.select-btn {
display: flex; align-items: center; justify-content: space-between; gap: var(--sp-2);
width: 100%; padding: 6px var(--sp-3);
background: var(--bg-base); border: 1px solid var(--border-strong);
border-radius: var(--radius-md); color: var(--text-secondary); font-size: var(--text-sm);
transition: border-color var(--t-base);
}
.select-btn:hover:not(:disabled) { border-color: var(--border-focus); }
.select-btn:disabled { opacity: 0.4; cursor: default; }
.select-open { border-color: var(--border-focus); }
.select-val { flex: 1; text-align: left; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.dropdown {
position: absolute; top: calc(100% + 4px); left: 0; right: 0;
background: var(--bg-surface); border: 1px solid var(--border-strong);
border-radius: var(--radius-md); overflow: hidden;
box-shadow: var(--shadow-lg); z-index: 10;
animation: dropIn 0.12s cubic-bezier(0.16,1,0.3,1) both;
}
@keyframes dropIn { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: translateY(0); } }
.dropdown-item {
display: flex; align-items: center; justify-content: space-between;
width: 100%; padding: 7px var(--sp-3);
font-size: var(--text-sm); color: var(--text-secondary);
transition: background var(--t-fast);
}
.dropdown-item:hover { background: var(--bg-raised); }
.dropdown-item-active { color: var(--accent-fg); }
.edit-row { display: flex; gap: var(--sp-2); }
.edit-input {
flex: 1; background: var(--bg-base); border: 1px solid var(--border-strong);
border-radius: var(--radius-md); padding: 6px var(--sp-3);
color: var(--text-primary); font-size: var(--text-sm);
outline: none; transition: border-color var(--t-base);
}
.edit-input:focus { border-color: var(--border-focus); }
.action-btn {
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
padding: 5px 12px; border-radius: var(--radius-md);
background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim);
flex-shrink: 0; display: flex; align-items: center; gap: var(--sp-1);
transition: filter var(--t-base);
}
.action-btn:hover:not(:disabled) { filter: brightness(1.1); }
.action-btn:disabled { opacity: 0.4; cursor: default; }
.action-btn-dim {
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
padding: 5px 12px; border-radius: var(--radius-md);
background: none; color: var(--text-faint); border: 1px solid var(--border-dim);
flex-shrink: 0; transition: color var(--t-base), border-color var(--t-base);
}
.action-btn-dim:hover { color: var(--text-secondary); border-color: var(--border-strong); }
.multi-list { display: flex; flex-direction: column; gap: 1px; }
.multi-item {
display: flex; align-items: center; gap: var(--sp-2);
padding: 6px var(--sp-2); border-radius: var(--radius-md);
font-size: var(--text-sm); color: var(--text-muted); border: 1px solid transparent;
transition: background var(--t-fast), color var(--t-fast), border-color var(--t-fast);
}
.multi-item:hover:not(:disabled) { background: var(--bg-raised); color: var(--text-secondary); }
.multi-item-on { color: var(--accent-fg); background: var(--accent-muted); border-color: var(--accent-dim); }
.multi-check {
width: 14px; height: 14px; border-radius: var(--radius-sm);
border: 1px solid var(--border-strong); background: var(--bg-base);
display: flex; align-items: center; justify-content: center;
flex-shrink: 0; color: var(--accent-fg);
transition: background var(--t-fast), border-color var(--t-fast);
}
.multi-item-on .multi-check { background: var(--accent-muted); border-color: var(--accent-dim); }
</style>
@@ -0,0 +1,448 @@
<script lang="ts">
import { X, MagnifyingGlass, CircleNotch, ArrowRight, Check, Warning, Sparkle, Swap } from "phosphor-svelte";
import { gql } from "@api/client";
import Thumbnail from "@shared/manga/Thumbnail.svelte";
import { resolvedCover } from "@core/cover/coverResolver";
import { GET_SOURCES } from "@api/queries/extensions";
import { UPDATE_MANGA } from "@api/mutations/manga";
import { FETCH_SOURCE_MANGA } from "@api/mutations/downloads";
import { FETCH_CHAPTERS, UPDATE_CHAPTERS_PROGRESS } from "@api/mutations/chapters";
import { store, addToast } from "@store/state.svelte";
import type { Manga, Chapter, Source } from "@types";
import type { LibraryManga } from "../lib/extensionLibrary";
interface Props {
sourceId: string;
sourceName: string;
sourceIconUrl: string;
manga: LibraryManga[];
onClose: () => void;
onDone: () => void;
}
let { sourceId, sourceName, sourceIconUrl, manga, onClose, onDone }: Props = $props();
type Phase = "pick-target" | "review" | "migrating" | "done";
interface EntryResult {
manga: LibraryManga;
match: Manga | null;
chapters: Chapter[];
similarity: number;
status: "pending" | "searching" | "found" | "no-match" | "migrated" | "failed";
error?: string;
}
function titleSimilarity(a: string, b: string): number {
const norm = (s: string) =>
s.toLowerCase().replace(/[^a-z0-9\s]/g, "").split(/\s+/).filter(Boolean);
const wordsA = new Set(norm(a));
const wordsB = new Set(norm(b));
if (wordsA.size === 0 || wordsB.size === 0) return 0;
const intersection = [...wordsA].filter(w => wordsB.has(w)).length;
return intersection / new Set([...wordsA, ...wordsB]).size;
}
function onKey(e: KeyboardEvent) { if (e.key === "Escape" && phase !== "migrating") onClose(); }
let phase: Phase = $state("pick-target");
let allSources: Source[] = $state([]);
let loadingSources = $state(true);
let targetSource: Source | null = $state(null);
let selectedLang = $state("all");
let langStripEl: HTMLDivElement | undefined = $state();
let entries: EntryResult[] = $state([]);
let searchProgress = $state({ done: 0, total: 0 });
let migrateProgress = $state({ done: 0, total: 0, failed: 0 });
const availableLangs = $derived.by(() => {
const langs = Array.from(new Set<string>(allSources.map(s => s.lang))).sort();
const en = langs.indexOf("en");
if (en > 0) { langs.splice(en, 1); langs.unshift("en"); }
return langs;
});
const hasMultipleLangs = $derived(availableLangs.length > 1);
const visibleSources = $derived.by(() => {
if (selectedLang !== "all") return allSources.filter(s => s.lang === selectedLang);
const map = new Map<string, Source>();
for (const s of allSources) {
const existing = map.get(s.name);
if (!existing || s.lang < existing.lang) map.set(s.name, s);
}
return Array.from(map.values());
});
const foundCount = $derived(entries.filter(e => e.status === "found").length);
const noMatchCount = $derived(entries.filter(e => e.status === "no-match").length);
const migratedCount = $derived(entries.filter(e => e.status === "migrated").length);
const failedCount = $derived(entries.filter(e => e.status === "failed").length);
$effect(() => {
gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
.then(d => {
allSources = d.sources.nodes.filter(s => s.id !== "0" && s.id !== sourceId);
const prefLang = store?.settings?.preferredExtensionLang ?? "";
const langs = new Set(allSources.map(s => s.lang));
if (prefLang && langs.has(prefLang) && langs.size > 1) selectedLang = prefLang;
})
.catch(console.error)
.finally(() => { loadingSources = false; });
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
});
function scrollLangStrip(dir: -1 | 1) {
if (!langStripEl) return;
const chips = Array.from(langStripEl.children) as HTMLElement[];
const viewEnd = langStripEl.scrollLeft + langStripEl.clientWidth;
if (dir === 1) {
const next = chips.find(c => c.offsetLeft + c.offsetWidth > viewEnd + 2);
if (next) langStripEl.scrollTo({ left: next.offsetLeft, behavior: "smooth" });
} else {
const prev = [...chips].reverse().find(c => c.offsetLeft < langStripEl!.scrollLeft - 2);
if (prev) langStripEl.scrollTo({ left: prev.offsetLeft + prev.offsetWidth - langStripEl.clientWidth, behavior: "smooth" });
}
}
async function startSearch(target: Source) {
targetSource = target;
phase = "review";
entries = manga.map(m => ({ manga: m, match: null, chapters: [], similarity: 0, status: "pending" }));
searchProgress = { done: 0, total: manga.length };
for (let i = 0; i < entries.length; i++) {
entries[i] = { ...entries[i], status: "searching" };
try {
const d = await gql<{ fetchSourceManga: { mangas: Manga[] } }>(FETCH_SOURCE_MANGA, {
source: target.id, type: "SEARCH", page: 1, query: entries[i].manga.title,
});
const results = d.fetchSourceManga.mangas
.map(m => ({ manga: m, similarity: titleSimilarity(entries[i].manga.title, m.title) }))
.sort((a, b) => b.similarity - a.similarity);
if (results.length > 0 && results[0].similarity > 0.3) {
entries[i] = { ...entries[i], match: results[0].manga, similarity: results[0].similarity, status: "found" };
} else {
entries[i] = { ...entries[i], status: "no-match" };
}
} catch (e: any) {
entries[i] = { ...entries[i], status: "no-match", error: e.message };
}
searchProgress = { done: i + 1, total: manga.length };
}
}
function setEntryMatch(idx: number, match: Manga, similarity: number) {
entries[idx] = { ...entries[idx], match, similarity, status: "found" };
}
function excludeEntry(idx: number) {
entries[idx] = { ...entries[idx], status: "no-match", match: null };
}
async function startMigration() {
const toMigrate = entries.filter(e => e.status === "found" && e.match);
migrateProgress = { done: 0, total: toMigrate.length, failed: 0 };
phase = "migrating";
for (const entry of toMigrate) {
const idx = entries.indexOf(entry);
try {
const d = await gql<{ fetchChapters: { chapters: Chapter[] } }>(FETCH_CHAPTERS, { mangaId: entry.match!.id });
const newChaps = d.fetchChapters.chapters;
const toMarkRead: number[] = [];
const toMarkBookmarked: number[] = [];
for (const nc of newChaps) {
const oldIdx = entries[idx].manga;
if (oldIdx) {
toMarkRead.push(nc.id);
}
}
if (toMarkRead.length)
await gql(UPDATE_CHAPTERS_PROGRESS, { ids: toMarkRead, isRead: true });
await gql(UPDATE_MANGA, { id: entry.match!.id, inLibrary: true });
await gql(UPDATE_MANGA, { id: entry.manga.id, inLibrary: false });
entries[idx] = { ...entries[idx], status: "migrated" };
migrateProgress = { ...migrateProgress, done: migrateProgress.done + 1 };
} catch (e: any) {
entries[idx] = { ...entries[idx], status: "failed", error: e.message };
migrateProgress = { ...migrateProgress, done: migrateProgress.done + 1, failed: migrateProgress.failed + 1 };
}
}
phase = "done";
addToast({
kind: "success",
title: "Migration complete",
body: `${migrateProgress.done - migrateProgress.failed} migrated, ${migrateProgress.failed} failed`,
});
}
</script>
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
<div class="overlay" onclick={(e) => { if (e.target === e.currentTarget && phase !== "migrating") onClose(); }}>
<div class="modal">
<div class="modal-header">
<div class="source-context">
<div class="source-icon-wrap">
<Thumbnail src={sourceIconUrl} alt={sourceName} class="src-icon" onerror={(e) => (e.target as HTMLImageElement).style.display = "none"} />
</div>
<div class="source-context-info">
<span class="modal-eyebrow">Source migration</span>
<span class="modal-title">{sourceName}</span>
<span class="modal-sub">{manga.length} {manga.length === 1 ? "title" : "titles"} in library</span>
</div>
</div>
{#if phase !== "migrating"}
<button class="close-btn" onclick={onClose}><X size={14} weight="light" /></button>
{/if}
</div>
<div class="body">
{#if phase === "pick-target"}
<div class="phase-label-row">
<span class="phase-label">Select destination source</span>
</div>
{#if loadingSources}
<div class="centered"><CircleNotch size={16} weight="light" class="anim-spin" style="color:var(--text-faint)" /></div>
{:else if allSources.length === 0}
<div class="centered"><span class="hint">No other sources installed.</span></div>
{:else}
{#if hasMultipleLangs}
<div class="src-lang-bar">
<button class="src-lang-nav" onclick={() => scrollLangStrip(-1)}></button>
<div class="src-lang-chips" bind:this={langStripEl}>
<button class="src-lang-chip" class:src-lang-chip-active={selectedLang === "all"} onclick={() => selectedLang = "all"}>All</button>
{#each availableLangs as lang}
<button class="src-lang-chip" class:src-lang-chip-active={selectedLang === lang} onclick={() => selectedLang = lang}>
{lang.toUpperCase()}
</button>
{/each}
</div>
<button class="src-lang-nav" onclick={() => scrollLangStrip(1)}></button>
</div>
{/if}
<div class="source-list">
{#each visibleSources as src}
<button class="source-row" onclick={() => startSearch(src)}>
<div class="source-icon-wrap">
<Thumbnail src={src.iconUrl} alt={src.name} class="source-icon" onerror={(e) => (e.target as HTMLImageElement).style.display = "none"} />
</div>
<div class="source-info">
<span class="source-name">{src.displayName}</span>
<span class="source-meta">{src.lang.toUpperCase()}{src.isNsfw ? " · NSFW" : ""}</span>
</div>
<ArrowRight size={13} weight="light" class="source-arrow" />
</button>
{/each}
</div>
{/if}
{:else if phase === "review" || phase === "migrating" || phase === "done"}
<div class="review-header">
<div class="review-route">
<div class="review-source">
<div class="source-icon-wrap small">
<Thumbnail src={sourceIconUrl} alt={sourceName} class="source-icon" onerror={(e) => (e.target as HTMLImageElement).style.display = "none"} />
</div>
<span class="review-source-name">{sourceName}</span>
</div>
<ArrowRight size={14} weight="light" style="color:var(--text-faint);flex-shrink:0" />
{#if targetSource}
<div class="review-source">
<div class="source-icon-wrap small">
<Thumbnail src={targetSource.iconUrl} alt={targetSource.name} class="source-icon" onerror={(e) => (e.target as HTMLImageElement).style.display = "none"} />
</div>
<span class="review-source-name">{targetSource.displayName}</span>
</div>
{/if}
</div>
{#if phase === "review"}
<div class="review-progress-row">
<div class="review-progress-bar">
<div class="review-progress-fill" style="width:{searchProgress.total ? (searchProgress.done / searchProgress.total) * 100 : 0}%"></div>
</div>
<span class="review-progress-label">
{#if searchProgress.done < searchProgress.total}
Searching {searchProgress.done + 1} / {searchProgress.total}
{:else}
{foundCount} found · {noMatchCount} no match
{/if}
</span>
</div>
{:else if phase === "migrating"}
<div class="review-progress-row">
<div class="review-progress-bar">
<div class="review-progress-fill" style="width:{migrateProgress.total ? (migrateProgress.done / migrateProgress.total) * 100 : 0}%"></div>
</div>
<span class="review-progress-label">Migrating {migrateProgress.done} / {migrateProgress.total}</span>
</div>
{:else}
<div class="done-summary">
<Check size={13} weight="bold" style="color:var(--color-success)" />
<span class="done-label">{migratedCount} migrated{failedCount > 0 ? ` · ${failedCount} failed` : ""}</span>
</div>
{/if}
</div>
<div class="entry-list">
{#each entries as entry, idx}
<div class="entry-row" class:entry-migrated={entry.status === "migrated"} class:entry-failed={entry.status === "failed"}>
<div class="entry-cover-wrap">
<Thumbnail src={resolvedCover(entry.manga.id, entry.manga.thumbnailUrl)} alt={entry.manga.title} class="entry-cover" />
</div>
<div class="entry-info">
<span class="entry-title">{entry.manga.title}</span>
{#if entry.status === "found" && entry.match}
<span class="entry-match">
<Sparkle size={9} weight="fill" style="color:var(--accent-fg);flex-shrink:0" />
{entry.match.title}
<span class="entry-sim">{Math.round(entry.similarity * 100)}%</span>
</span>
{:else if entry.status === "no-match"}
<span class="entry-no-match">No match found</span>
{:else if entry.status === "searching"}
<span class="entry-searching">Searching…</span>
{:else if entry.status === "migrated"}
<span class="entry-done">Migrated</span>
{:else if entry.status === "failed"}
<span class="entry-fail">{entry.error ?? "Failed"}</span>
{/if}
</div>
<div class="entry-status">
{#if entry.status === "searching"}
<CircleNotch size={13} weight="light" class="anim-spin" style="color:var(--text-faint)" />
{:else if entry.status === "found"}
<div class="entry-cover-match">
<Thumbnail src={resolvedCover(entry.match!.id, entry.match!.thumbnailUrl)} alt={entry.match!.title} class="entry-match-cover" />
</div>
{#if phase === "review"}
<button class="entry-exclude-btn" onclick={() => excludeEntry(idx)} title="Exclude from migration">
<X size={10} weight="bold" />
</button>
{/if}
{:else if entry.status === "migrated"}
<Check size={13} weight="bold" style="color:var(--color-success)" />
{:else if entry.status === "failed"}
<Warning size={13} weight="light" style="color:var(--color-error)" />
{/if}
</div>
</div>
{/each}
</div>
{#if phase === "review" && searchProgress.done === searchProgress.total}
<div class="review-actions">
<button class="back-btn" onclick={() => { phase = "pick-target"; entries = []; }}>Change source</button>
<button class="migrate-btn" onclick={startMigration} disabled={foundCount === 0}>
<Swap size={13} weight="bold" />
Migrate {foundCount} {foundCount === 1 ? "title" : "titles"}
</button>
</div>
{/if}
{#if phase === "done"}
<div class="review-actions">
<button class="migrate-btn" onclick={onDone}><Check size={13} weight="bold" /> Done</button>
</div>
{/if}
{/if}
</div>
</div>
</div>
<style>
.overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.6); display: flex; align-items: center; justify-content: center; z-index: 200; animation: fadeIn 0.1s ease both; }
.modal { background: var(--bg-base); border: 1px solid var(--border-base); border-radius: var(--radius-xl); width: 560px; max-height: 84vh; display: flex; flex-direction: column; overflow: hidden; box-shadow: 0 8px 40px rgba(0,0,0,0.5); }
.modal-header { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--sp-3); padding: var(--sp-4) var(--sp-5); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
.source-context { display: flex; align-items: center; gap: var(--sp-3); min-width: 0; }
.source-icon-wrap { width: 36px; height: 36px; border-radius: var(--radius-md); overflow: hidden; flex-shrink: 0; background: var(--bg-raised); border: 1px solid var(--border-dim); }
.source-icon-wrap.small { width: 20px; height: 20px; border-radius: var(--radius-sm); }
:global(.src-icon) { width: 100%; height: 100%; object-fit: cover; }
:global(.source-icon) { width: 100%; height: 100%; object-fit: cover; }
.source-context-info { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.modal-eyebrow { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; }
.modal-title { font-size: var(--text-base); font-weight: var(--weight-medium); color: var(--text-primary); letter-spacing: var(--tracking-tight); }
.modal-sub { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
.close-btn { display: flex; align-items: center; justify-content: center; width: 24px; height: 24px; border-radius: var(--radius-md); color: var(--text-faint); background: none; border: none; cursor: pointer; transition: color var(--t-base), background var(--t-base); flex-shrink: 0; margin-top: 2px; }
.close-btn:hover { color: var(--text-muted); background: var(--bg-raised); }
.body { flex: 1; overflow: hidden; display: flex; flex-direction: column; min-height: 0; }
.phase-label-row { padding: var(--sp-3) var(--sp-4) var(--sp-2); flex-shrink: 0; }
.phase-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-widest); text-transform: uppercase; }
.centered { flex: 1; display: flex; align-items: center; justify-content: center; padding: var(--sp-8); }
.hint { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
.src-lang-bar { display: flex; align-items: center; gap: var(--sp-1); padding: var(--sp-2); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
.src-lang-nav { display: flex; align-items: center; justify-content: center; width: 22px; height: 22px; flex-shrink: 0; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); font-size: 15px; line-height: 1; cursor: pointer; transition: color var(--t-base), background var(--t-base); }
.src-lang-nav:hover { color: var(--text-muted); background: var(--bg-raised); }
.src-lang-chips { display: flex; align-items: center; gap: var(--sp-1); flex: 1; min-width: 0; overflow-x: auto; scrollbar-width: none; }
.src-lang-chips::-webkit-scrollbar { display: none; }
.src-lang-chip { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 3px 8px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); cursor: pointer; white-space: nowrap; flex-shrink: 0; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
.src-lang-chip:hover { color: var(--text-muted); background: var(--bg-raised); }
.src-lang-chip-active { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
.source-list { flex: 1; overflow-y: auto; padding: var(--sp-2); display: flex; flex-direction: column; gap: 1px; }
.source-row { display: flex; align-items: center; gap: var(--sp-3); padding: 8px 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-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-meta { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
:global(.source-arrow) { color: var(--text-faint); opacity: 0; transition: opacity var(--t-base); flex-shrink: 0; }
.source-row:hover :global(.source-arrow) { opacity: 1; }
.review-header { padding: var(--sp-3) var(--sp-4); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; display: flex; flex-direction: column; gap: var(--sp-2); }
.review-route { display: flex; align-items: center; gap: var(--sp-2); }
.review-source { display: flex; align-items: center; gap: var(--sp-2); min-width: 0; }
.review-source-name { font-size: var(--text-xs); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-weight: var(--weight-medium); }
.review-progress-row { display: flex; align-items: center; gap: var(--sp-3); }
.review-progress-bar { flex: 1; height: 3px; background: var(--bg-overlay); border-radius: var(--radius-full); overflow: hidden; }
.review-progress-fill { height: 100%; background: var(--accent); border-radius: var(--radius-full); transition: width 0.3s ease; }
.review-progress-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); white-space: nowrap; flex-shrink: 0; }
.done-summary { display: flex; align-items: center; gap: var(--sp-2); }
.done-label { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-secondary); letter-spacing: var(--tracking-wide); }
.entry-list { flex: 1; overflow-y: auto; padding: var(--sp-2); display: flex; flex-direction: column; gap: 1px; }
.entry-row { display: flex; align-items: center; gap: var(--sp-3); padding: 7px var(--sp-3); border-radius: var(--radius-md); border: 1px solid transparent; transition: background var(--t-fast); }
.entry-row:hover { background: var(--bg-raised); }
.entry-migrated { opacity: 0.5; }
.entry-failed { border-color: rgba(180,60,60,0.15); background: rgba(180,60,60,0.04); }
.entry-cover-wrap { width: 28px; height: 42px; border-radius: var(--radius-sm); overflow: hidden; background: var(--bg-raised); border: 1px solid var(--border-dim); flex-shrink: 0; }
:global(.entry-cover) { width: 100%; height: 100%; object-fit: cover; }
.entry-info { flex: 1; display: flex; flex-direction: column; gap: 3px; min-width: 0; overflow: hidden; }
.entry-title { font-size: var(--text-sm); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.entry-match { 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); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.entry-sim { background: var(--bg-overlay); border: 1px solid var(--border-dim); border-radius: var(--radius-sm); padding: 0 4px; font-size: 9px; flex-shrink: 0; }
.entry-no-match { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
.entry-searching { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
.entry-done { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--color-success); letter-spacing: var(--tracking-wide); }
.entry-fail { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--color-error); letter-spacing: var(--tracking-wide); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.entry-status { display: flex; align-items: center; gap: var(--sp-1); flex-shrink: 0; }
.entry-cover-match { width: 24px; height: 36px; border-radius: var(--radius-sm); overflow: hidden; background: var(--bg-raised); border: 1px solid var(--border-dim); flex-shrink: 0; }
:global(.entry-match-cover) { width: 100%; height: 100%; object-fit: cover; }
.entry-exclude-btn { display: flex; align-items: center; justify-content: center; width: 18px; height: 18px; border-radius: var(--radius-sm); color: var(--text-faint); background: none; border: none; cursor: pointer; transition: color var(--t-base), background var(--t-base); flex-shrink: 0; }
.entry-exclude-btn:hover { color: var(--color-error); background: var(--bg-raised); }
.review-actions { display: flex; justify-content: flex-end; gap: var(--sp-2); padding: var(--sp-3) var(--sp-4); border-top: 1px solid var(--border-dim); flex-shrink: 0; }
.back-btn { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 6px 10px; border-radius: var(--radius-md); background: none; color: var(--text-muted); border: 1px solid var(--border-dim); cursor: pointer; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
.back-btn:hover { color: var(--text-secondary); border-color: var(--border-strong); background: var(--bg-raised); }
.migrate-btn { display: flex; align-items: center; gap: var(--sp-2); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 7px 16px; border-radius: var(--radius-md); background: var(--accent-dim); border: 1px solid var(--accent); color: var(--accent-fg); cursor: pointer; transition: background var(--t-base), border-color var(--t-base); }
.migrate-btn:hover:not(:disabled) { background: var(--accent-muted); border-color: var(--accent-bright); }
.migrate-btn:disabled { opacity: 0.4; cursor: default; }
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
</style>
@@ -2,19 +2,26 @@
import { Play, ArrowRight, BookOpen, Clock } from "phosphor-svelte"; import { Play, ArrowRight, BookOpen, Clock } from "phosphor-svelte";
import Thumbnail from "@shared/manga/Thumbnail.svelte"; import Thumbnail from "@shared/manga/Thumbnail.svelte";
import type { HistoryEntry } from "@store/state.svelte"; import type { HistoryEntry } from "@store/state.svelte";
import type { Manga } from "@types";
import { timeAgo } from "../lib/homeHelpers"; import { timeAgo } from "../lib/homeHelpers";
let { let {
entries, entries,
libraryManga,
onresume, onresume,
onviewhistory, onviewhistory,
onopenlibrary, onopenlibrary,
}: { }: {
entries: HistoryEntry[]; entries: HistoryEntry[];
libraryManga: Manga[];
onresume: (entry: HistoryEntry) => void; onresume: (entry: HistoryEntry) => void;
onviewhistory: () => void; onviewhistory: () => void;
onopenlibrary: () => void; onopenlibrary: () => void;
} = $props(); } = $props();
function thumbFor(entry: HistoryEntry): string {
return libraryManga.find(m => m.id === entry.mangaId)?.thumbnailUrl ?? entry.thumbnailUrl ?? "";
}
</script> </script>
<div class="section"> <div class="section">
@@ -31,7 +38,7 @@
{#if entries.length > 0} {#if entries.length > 0}
{#each entries as entry (entry.chapterId)} {#each entries as entry (entry.chapterId)}
<button class="row" onclick={() => onresume(entry)}> <button class="row" onclick={() => onresume(entry)}>
<Thumbnail src={entry.thumbnailUrl} alt={entry.mangaTitle} class="row-thumb" /> <Thumbnail src={thumbFor(entry)} alt={entry.mangaTitle} class="row-thumb" />
<div class="row-info"> <div class="row-info">
<span class="row-title">{entry.mangaTitle}</span> <span class="row-title">{entry.mangaTitle}</span>
<span class="row-sub"> <span class="row-sub">
+33 -34
View File
@@ -1,7 +1,6 @@
<script lang="ts"> <script lang="ts">
import { onMount, untrack } from "svelte"; import { onMount, untrack } from "svelte";
import { gql, thumbUrl } from "@api/client"; import { gql, resolveImageUrl } from "@api/client";
import { getBlobUrl } from "@core/cache/imageCache";
import { GET_CHAPTERS } from "@api/queries/chapters"; import { GET_CHAPTERS } from "@api/queries/chapters";
import { GET_LIBRARY } from "@api/queries/manga"; import { GET_LIBRARY } from "@api/queries/manga";
import { cache, CACHE_KEYS } from "@core/cache"; import { cache, CACHE_KEYS } from "@core/cache";
@@ -88,36 +87,32 @@
let activeIdx = $state(0); let activeIdx = $state(0);
const activeSlot = $derived(resolvedSlots[activeIdx]); const activeSlot = $derived(resolvedSlots[activeIdx]);
const heroThumbSrc = $derived(
activeSlot?.kind === "pinned" ? (activeSlot.manga?.thumbnailUrl ?? "") :
activeSlot?.kind === "continue" ? (activeSlot.entry?.thumbnailUrl ?? "") : ""
);
let heroThumb = $state("");
$effect(() => {
const path = heroThumbSrc;
const mode = store.settings.serverAuthMode ?? "NONE";
if (!path) { heroThumb = ""; return; }
if (mode !== "BASIC_AUTH") { heroThumb = thumbUrl(path); return; }
getBlobUrl(thumbUrl(path))
.then(url => { heroThumb = url; })
.catch(() => { heroThumb = ""; });
});
const heroTitle = $derived(
activeSlot?.kind === "pinned" ? (activeSlot.manga?.title ?? "") :
activeSlot?.kind === "continue" ? (activeSlot.entry?.mangaTitle ?? "") : ""
);
const heroManga = $derived( const heroManga = $derived(
activeSlot?.kind === "pinned" ? activeSlot.manga : activeSlot?.kind === "pinned" ? activeSlot.manga :
activeSlot?.kind === "continue" ? libraryManga.find(m => m.id === activeSlot.entry?.mangaId) : null 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);
const heroMangaId = $derived(heroEntry?.mangaId ?? heroManga?.id ?? null); const heroMangaId = $derived(heroManga?.id ?? heroEntry?.mangaId ?? null);
const heroTitle = $derived(heroManga?.title ?? heroEntry?.mangaTitle ?? "");
const heroThumbSrc = $derived(
heroManga?.thumbnailUrl
?? (activeSlot?.kind === "continue" ? activeSlot.entry?.thumbnailUrl : undefined)
?? ""
);
let heroThumb = $state("");
$effect(() => {
const path = heroThumbSrc;
if (!path) { heroThumb = ""; return; }
resolveImageUrl(path)
.then(url => { heroThumb = url; })
.catch(() => { heroThumb = ""; });
});
const heroNewChapter = $derived( const heroNewChapter = $derived(
heroManga heroManga ? (libraryManga.find(m => m.id === heroManga!.id) as any)?.latestUploadedChapter ?? null : null
? (libraryManga.find(m => m.id === heroManga!.id) as any)?.latestUploadedChapter ?? null
: null
); );
function cycleNext() { activeIdx = (activeIdx + 1) % TOTAL_SLOTS; heroChapters = []; heroAllChapters = []; } function cycleNext() { activeIdx = (activeIdx + 1) % TOTAL_SLOTS; heroChapters = []; heroAllChapters = []; }
@@ -161,6 +156,10 @@
let resuming = $state(false); let resuming = $state(false);
function liveMangaStub(): Manga {
return heroManga ?? { id: heroMangaId!, title: heroTitle, thumbnailUrl: heroThumbSrc } as any;
}
async function openChapter(chapter: Chapter) { async function openChapter(chapter: Chapter) {
if (!heroMangaId) return; if (!heroMangaId) return;
resuming = true; resuming = true;
@@ -171,13 +170,12 @@
all = [...d.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder); all = [...d.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder);
} }
if (all.length) { if (all.length) {
const manga = heroManga ?? { id: heroMangaId, title: heroTitle, thumbnailUrl: heroManga?.thumbnailUrl ?? "" } as any; store.activeManga = liveMangaStub();
store.activeManga = manga;
const list = buildReaderChapterList(all, store.settings.mangaPrefs?.[heroMangaId]); const list = buildReaderChapterList(all, store.settings.mangaPrefs?.[heroMangaId]);
const target = list.find(c => c.id === chapter.id) ?? list[0]; const target = list.find(c => c.id === chapter.id) ?? list[0];
if (target) openReader(target, list); if (target) openReader(target, list);
} }
} catch { store.activeManga = { id: heroMangaId, title: heroTitle, thumbnailUrl: heroManga?.thumbnailUrl ?? "" } as any; } } catch { store.activeManga = liveMangaStub(); }
finally { resuming = false; } finally { resuming = false; }
} }
@@ -193,24 +191,24 @@
const list = buildReaderChapterList(raw, store.settings.mangaPrefs?.[heroEntry.mangaId]); const list = buildReaderChapterList(raw, store.settings.mangaPrefs?.[heroEntry.mangaId]);
const ch = list.find(c => c.id === heroEntry!.chapterId) ?? list[0]; 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 = liveMangaStub();
openReader(ch, list); openReader(ch, list);
} }
} catch { store.activeManga = { id: heroEntry.mangaId, title: heroEntry.mangaTitle, thumbnailUrl: heroEntry.thumbnailUrl } as any; } } catch { store.activeManga = liveMangaStub(); }
finally { resuming = false; } finally { resuming = false; }
} }
async function resumeEntry(entry: HistoryEntry) { async function resumeEntry(entry: HistoryEntry) {
const liveManga = libraryManga.find(m => m.id === entry.mangaId);
const stub = liveManga ?? { id: entry.mangaId, title: entry.mangaTitle, thumbnailUrl: liveManga?.thumbnailUrl ?? entry.thumbnailUrl } as any;
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 raw = [...d.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder); const raw = [...d.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder);
const list = buildReaderChapterList(raw, store.settings.mangaPrefs?.[entry.mangaId]); const list = buildReaderChapterList(raw, store.settings.mangaPrefs?.[entry.mangaId]);
const ch = list.find(c => c.id === entry.chapterId) ?? list[0]; const ch = list.find(c => c.id === entry.chapterId) ?? list[0];
if (ch) { store.activeManga = stub;
store.activeManga = { id: entry.mangaId, title: entry.mangaTitle, thumbnailUrl: entry.thumbnailUrl } as any; if (ch) openReader(ch, list);
openReader(ch, list); } catch { store.activeManga = stub; }
} else store.activeManga = { id: entry.mangaId, title: entry.mangaTitle, thumbnailUrl: entry.thumbnailUrl } as any;
} catch { store.activeManga = { id: entry.mangaId, title: entry.mangaTitle, thumbnailUrl: entry.thumbnailUrl } as any; }
} }
let pickerOpen = $state(false); let pickerOpen = $state(false);
@@ -259,6 +257,7 @@
<div class="mid-left"> <div class="mid-left">
<ActivityFeed <ActivityFeed
entries={recentHistory} entries={recentHistory}
{libraryManga}
onresume={resumeEntry} onresume={resumeEntry}
onviewhistory={() => setNavPage("history")} onviewhistory={() => setNavPage("history")}
onopenlibrary={() => setNavPage("library")} onopenlibrary={() => setNavPage("library")}
+67 -24
View File
@@ -18,6 +18,7 @@
store, setCategories, setLibraryUpdates, addToast, store, setCategories, setLibraryUpdates, addToast,
setTabSort, toggleTabSortDir, setTabStatus, toggleTabFilter, clearTabFilters, setTabSort, toggleTabSortDir, setTabStatus, toggleTabFilter, clearTabFilters,
} from "../store/libraryState.svelte"; } from "../store/libraryState.svelte";
import { saveScroll, getScroll } from "@store/state.svelte";
import type { LibrarySortMode, LibrarySortDir, LibraryStatusFilter, LibraryContentFilter, LibraryUpdateEntry } from "@store/state.svelte"; import type { LibrarySortMode, LibrarySortDir, LibraryStatusFilter, LibraryContentFilter, LibraryUpdateEntry } from "@store/state.svelte";
import type { Manga, Category, Chapter } from "@types"; import type { Manga, Category, Chapter } from "@types";
import { checkAndMarkCompleted as storeCheckAndMarkCompleted, updateSettings } from "@store/state.svelte"; import { checkAndMarkCompleted as storeCheckAndMarkCompleted, updateSettings } from "@store/state.svelte";
@@ -84,14 +85,46 @@
const hasActiveFilters = $derived(tabStatus !== "ALL" || Object.values(tabFilters).some(Boolean)); const hasActiveFilters = $derived(tabStatus !== "ALL" || Object.values(tabFilters).some(Boolean));
const cols = $derived(Math.max(1, Math.floor((containerWidth + CARD_GAP) / (CARD_MIN_W + CARD_GAP)))); const cols = $derived(Math.max(1, Math.floor((containerWidth + CARD_GAP) / (CARD_MIN_W + CARD_GAP))));
const BUILTIN_TABS = ["library", "downloaded"] as const;
const completedCatId = $derived(
store.categories.find(c => c.name === COMPLETED_NAME && c.id !== 0)?.id ?? null
);
const allTabIds = $derived((() => {
const catIds = store.categories.filter(c => c.id !== 0).map(c => String(c.id));
const pinned = store.settings.libraryPinnedTabOrder ?? [];
const known = new Set([...BUILTIN_TABS, ...catIds]);
const ordered = [...pinned.filter(id => known.has(id))];
const inOrder = new Set(ordered);
for (const id of [...BUILTIN_TABS, ...catIds]) {
if (!inOrder.has(id)) ordered.push(id);
}
return ordered;
})());
const hiddenTabs = $derived(new Set(store.settings.hiddenLibraryTabs ?? []));
const visibleTabIds = $derived(allTabIds.filter(id => !hiddenTabs.has(id)));
const virtualTabIds = $derived(visibleTabIds.filter(id =>
id === "library" || id === "downloaded" || (completedCatId !== null && id === String(completedCatId))
));
const folderTabIds = $derived(visibleTabIds.filter(id =>
id !== "library" && id !== "downloaded" && (completedCatId === null || id !== String(completedCatId))
));
const visibleCategories = $derived((() => { const visibleCategories = $derived((() => {
const defaultId = store.settings.defaultLibraryCategoryId ?? null; const defaultId = store.settings.defaultLibraryCategoryId ?? null;
return store.categories const pinned = store.settings.libraryPinnedTabOrder ?? [];
.filter(c => c.id !== 0 && !(store.settings.hiddenCategoryIds ?? []).includes(c.id)) const cats = store.categories.filter(c => c.id !== 0 && !hiddenTabs.has(String(c.id)));
.sort((a, b) => { const pinOrder = (id: number) => { const i = pinned.indexOf(String(id)); return i === -1 ? Infinity : i; };
return cats.sort((a, b) => {
if (a.id === defaultId) return -1; if (a.id === defaultId) return -1;
if (b.id === defaultId) return 1; if (b.id === defaultId) return 1;
return a.order - b.order; const pd = pinOrder(a.id) - pinOrder(b.id);
return pd !== 0 ? pd : a.order - b.order;
}); });
})()); })());
@@ -171,7 +204,18 @@
$effect(() => { filtered; untrack(() => { renderVisible = paginator.reset(); }); }); $effect(() => { filtered; untrack(() => { renderVisible = paginator.reset(); }); });
$effect(() => { retryCount; loading = true; error = null; if (retryCount > 0) cache.clear(CACHE_KEYS.LIBRARY); untrack(() => loadData()); }); $effect(() => { retryCount; loading = true; error = null; if (retryCount > 0) cache.clear(CACHE_KEYS.LIBRARY); untrack(() => loadData()); });
$effect(() => { if (scrollEl) scrollEl.scrollTo({ top: 0 }); }); let prevTab = $state(tab);
$effect(() => {
const nextTab = tab;
if (scrollEl && nextTab !== prevTab) {
saveScroll(`library:${prevTab}`, scrollEl.scrollTop);
const saved = getScroll(`library:${nextTab}`);
untrack(() => { scrollEl.scrollTo({ top: saved }); });
prevTab = nextTab;
} else if (scrollEl && nextTab === prevTab) {
scrollEl.scrollTo({ top: 0 });
}
});
$effect(() => { $effect(() => {
const f = tab; const f = tab;
if (f === "library" || f === "downloaded") return; if (f === "library" || f === "downloaded") return;
@@ -179,7 +223,7 @@
if (!store.categories.some(c => c.id === id)) untrack(() => { store.libraryFilter = "library"; }); if (!store.categories.some(c => c.id === id)) untrack(() => { store.libraryFilter = "library"; });
}); });
$effect(() => { tab; untrack(() => exitSelectMode()); }); $effect(() => { tab; untrack(() => exitSelectMode()); });
$effect(() => { tab; counts; requestAnimationFrame(updateTabIndicator); }); $effect(() => { tab; counts; });
let prevChapterId: number | null = null; let prevChapterId: number | null = null;
$effect(() => { $effect(() => {
@@ -188,13 +232,6 @@
if (wasOpen && !store.activeChapter) { cache.clear(CACHE_KEYS.LIBRARY); untrack(() => loadData()); } if (wasOpen && !store.activeChapter) { cache.clear(CACHE_KEYS.LIBRARY); untrack(() => loadData()); }
}); });
function updateTabIndicator() {
if (!tabsEl) return;
const active = tabsEl.querySelector<HTMLElement>(".tab.active");
if (!active) return;
tabIndicator = { left: active.offsetLeft, width: active.offsetWidth };
}
function enterSelectMode(id?: number) { selectMode = true; if (id !== undefined) selectedIds = new Set([id]); } function enterSelectMode(id?: number) { selectMode = true; if (id !== undefined) selectedIds = new Set([id]); }
function exitSelectMode() { selectMode = false; selectedIds = new Set(); } function exitSelectMode() { selectMode = false; selectedIds = new Set(); }
function toggleSelect(id: number) { const next = new Set(selectedIds); if (next.has(id)) next.delete(id); else next.add(id); selectedIds = next; if (next.size === 0) exitSelectMode(); } function toggleSelect(id: number) { const next = new Set(selectedIds); if (next.has(id)) next.delete(id); else next.add(id); selectedIds = next; if (next.size === 0) exitSelectMode(); }
@@ -503,17 +540,21 @@
dragInsertIdx = -1; dragInsertIdx = -1;
if (activeDragKind !== "tab" || dragTabId === null || dragTabId === dropCat.id) { dragTabId = null; return; } if (activeDragKind !== "tab" || dragTabId === null || dragTabId === dropCat.id) { dragTabId = null; return; }
const dragId = dragTabId; dragTabId = null; activeDragKind = null; const dragId = dragTabId; dragTabId = null; activeDragKind = null;
const sorted = [...store.categories].filter(c => c.id !== 0).sort((a, b) => a.order - b.order); const dragStrId = String(dragId);
const fromIdx = sorted.findIndex(c => c.id === dragId); const tabs = [...visibleTabIds];
const fromIdx = tabs.indexOf(dragStrId);
if (fromIdx < 0) return; if (fromIdx < 0) return;
const reordered = [...sorted]; tabs.splice(fromIdx, 1);
const [moved] = reordered.splice(fromIdx, 1); const dest = Math.max(0, Math.min(insertAt > fromIdx ? insertAt - 1 : insertAt, tabs.length));
const dest = Math.max(0, Math.min(insertAt > fromIdx ? insertAt - 1 : insertAt, reordered.length)); tabs.splice(dest, 0, dragStrId);
reordered.splice(dest, 0, moved); updateSettings({ libraryPinnedTabOrder: tabs });
const withNewOrder = reordered.map((c, i) => ({ ...c, order: i + 1 })); const catIds = tabs.filter(id => id !== "library" && id !== "downloaded");
setCategories(store.categories.map(c => withNewOrder.find(u => u.id === c.id) ?? c)); const zeroCat = store.categories.filter(c => c.id === 0);
const reordered = catIds.map((id, i) => { const c = store.categories.find(x => String(x.id) === id)!; return { ...c, order: i + 1 }; });
setCategories([...zeroCat, ...reordered]);
const serverPos = catIds.indexOf(dragStrId) + 1;
try { try {
await gql<{ updateCategoryOrder: { categories: Category[] } }>(UPDATE_CATEGORY_ORDER, { id: dragId, position: dest + 1 }); await gql<{ updateCategoryOrder: { categories: Category[] } }>(UPDATE_CATEGORY_ORDER, { id: dragId, position: serverPos });
} catch (err) { console.error("Tab reorder failed:", err); await reloadCategories(); } } catch (err) { console.error("Tab reorder failed:", err); await reloadCategories(); }
} }
@@ -541,7 +582,6 @@
window.addEventListener("keydown", onKeyDown); window.addEventListener("keydown", onKeyDown);
document.addEventListener("mousedown", onDocMouseDown, true); document.addEventListener("mousedown", onDocMouseDown, true);
requestAnimationFrame(updateTabIndicator);
return () => { return () => {
ro.disconnect(); unsub(); ro.disconnect(); unsub();
@@ -600,8 +640,11 @@
{tabFilters} {tabFilters}
{hasActiveFilters} {hasActiveFilters}
{anims} {anims}
{tabIndicator}
{visibleCategories} {visibleCategories}
{visibleTabIds}
{virtualTabIds}
{folderTabIds}
{completedCatId}
{counts} {counts}
{search} {search}
{refreshing} {refreshing}
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { import {
MagnifyingGlass, Books, DownloadSimple, Folder, FolderSimple, MagnifyingGlass, Books, DownloadSimple, Folder, FolderSimple,
SortAscending, CaretUp, CaretDown, ArrowsClockwise, Star, X, SortAscending, CaretUp, CaretDown, ArrowsClockwise, Star, X, CheckSquare,
} from "phosphor-svelte"; } from "phosphor-svelte";
import LibraryFilters from "./LibraryFilters.svelte"; import LibraryFilters from "./LibraryFilters.svelte";
import type { Category } from "@types"; import type { Category } from "@types";
@@ -15,14 +15,13 @@
tabFilters: Partial<Record<LibraryContentFilter, boolean>>; tabFilters: Partial<Record<LibraryContentFilter, boolean>>;
hasActiveFilters: boolean; hasActiveFilters: boolean;
anims: boolean; anims: boolean;
tabIndicator: { left: number; width: number };
visibleCategories: Category[]; visibleCategories: Category[];
visibleTabIds: string[];
virtualTabIds: string[];
folderTabIds: string[];
completedCatId: number | null;
counts: Record<string, number>; counts: Record<string, number>;
search: string; search: string;
refreshing: boolean;
refreshProgress: { finished: number; total: number };
refreshDone: boolean;
refreshingCatId: number | null;
activeDragKind: "tab" | null; activeDragKind: "tab" | null;
dragInsertIdx: number; dragInsertIdx: number;
dragTabId: number | null; dragTabId: number | null;
@@ -39,9 +38,6 @@
onFiltersClear: () => void; onFiltersClear: () => void;
onSortPanelToggle: () => void; onSortPanelToggle: () => void;
onFilterPanelToggle: () => void; onFilterPanelToggle: () => void;
onRefresh: () => void;
onCancelRefresh: () => void;
onRefreshCategory: (catId: number) => void;
onOpenDownloadsFolder: () => void; onOpenDownloadsFolder: () => void;
onTabDragStart: (e: DragEvent, cat: Category) => void; onTabDragStart: (e: DragEvent, cat: Category) => void;
onTabDragOver: (e: DragEvent, cat: Category, idx: number) => void; onTabDragOver: (e: DragEvent, cat: Category, idx: number) => void;
@@ -52,16 +48,36 @@
let { let {
tab, tabSortMode, tabSortDir, tabStatus, tabFilters, hasActiveFilters, tab, tabSortMode, tabSortDir, tabStatus, tabFilters, hasActiveFilters,
anims, tabIndicator, visibleCategories, counts, search, refreshing, anims, visibleCategories, visibleTabIds, virtualTabIds, folderTabIds, completedCatId,
refreshProgress, refreshDone, refreshingCatId, activeDragKind, dragInsertIdx, counts, search, refreshing, refreshProgress, refreshDone, activeDragKind, dragInsertIdx,
dragTabId, dragOverTabId, sortPanelOpen, filterPanelOpen, dragTabId, dragOverTabId, sortPanelOpen, filterPanelOpen,
tabsEl = $bindable(), tabsEl = $bindable(),
onSearchChange, onTabChange, onSortChange, onSortDirToggle, onStatusChange, onSearchChange, onTabChange, onSortChange, onSortDirToggle, onStatusChange,
onFilterToggle, onFiltersClear, onSortPanelToggle, onFilterPanelToggle, onFilterToggle, onFiltersClear, onSortPanelToggle, onFilterPanelToggle,
onRefresh, onCancelRefresh, onRefreshCategory, onOpenDownloadsFolder, onRefresh, onOpenDownloadsFolder,
onTabDragStart, onTabDragOver, onTabDragLeave, onTabDrop, onTabDragEnd, onTabDragStart, onTabDragOver, onTabDragLeave, onTabDrop, onTabDragEnd,
}: Props = $props(); }: Props = $props();
function onTabsWheel(e: WheelEvent) {
const ids = visibleTabIds.filter(id => id === "library" || id === "downloaded" || visibleCategories.some(c => String(c.id) === id));
const idx = ids.indexOf(tab);
if (e.deltaY > 0 && idx < ids.length - 1) onTabChange(ids[idx + 1]);
else if (e.deltaY < 0 && idx > 0) onTabChange(ids[idx - 1]);
}
$effect(() => {
tab;
if (!tabsEl) return;
const active = tabsEl.querySelector<HTMLElement>(".tab.active");
if (!active) return;
const pl = tabsEl.scrollLeft;
const cw = tabsEl.clientWidth;
const ol = active.offsetLeft;
const ow = active.offsetWidth;
if (ol < pl) tabsEl.scrollTo({ left: ol, behavior: "smooth" });
else if (ol + ow > pl + cw) tabsEl.scrollTo({ left: ol + ow - cw, behavior: "smooth" });
});
const SORT_LABELS: Record<LibrarySortMode, string> = { const SORT_LABELS: Record<LibrarySortMode, string> = {
az: "AZ", az: "AZ",
unreadCount: "Unread chapters", unreadCount: "Unread chapters",
@@ -75,70 +91,43 @@
const ALL_SORT_MODES: LibrarySortMode[] = [ const ALL_SORT_MODES: LibrarySortMode[] = [
"az", "unreadCount", "totalChapters", "recentlyAdded", "recentlyRead", "latestFetched", "latestUploaded", "az", "unreadCount", "totalChapters", "recentlyAdded", "recentlyRead", "latestFetched", "latestUploaded",
]; ];
const activeCatId = $derived(
tab !== "library" && tab !== "downloaded" ? Number(tab) : null
);
</script> </script>
<div class="header"> <div class="header">
<span class="heading">Library</span> <span class="heading">Library</span>
<div class="tabs" class:tabs-anims={anims} bind:this={tabsEl}> <div class="tabs" class:tabs-anims={anims} bind:this={tabsEl} onwheel={onTabsWheel}>
{#if anims && tabIndicator.width > 0} {#each visibleTabIds as id, idx}
<div class="tab-slide-indicator" style="left:{tabIndicator.left}px;width:{tabIndicator.width}px" aria-hidden="true"></div> {@const cat = visibleCategories.find(c => String(c.id) === id)}
{/if} {#if id === "library" || id === "downloaded" || cat}
{#each [["library", "Saved"], ["downloaded", "Downloaded"]] as [f, label]} {#if activeDragKind === "tab" && dragInsertIdx === idx}
<button class="tab" class:active={tab === f} onclick={() => onTabChange(f)}>
{#if f === "library"}<Books size={11} weight="bold" />
{:else if f === "downloaded"}<DownloadSimple size={11} weight="bold" />{/if}
{label}
<span class="tab-count">{counts[f] ?? 0}</span>
</button>
{/each}
{#if visibleCategories.length > 0}
<div class="tab-separator" aria-hidden="true"></div>
<div class="tabs-scroll">
{#each visibleCategories as cat, idx}
{#if dragInsertIdx === idx && activeDragKind === "tab"}
<div class="tab-insert-bar" aria-hidden="true"></div> <div class="tab-insert-bar" aria-hidden="true"></div>
{/if} {/if}
<button <button
class="tab" class="tab"
class:active={tab === String(cat.id)} class:active={tab === id}
class:tab-dragging={dragTabId === cat.id} class:tab-dragging={cat && dragTabId === cat.id}
draggable="true" draggable={!!cat && id !== String(completedCatId)}
onclick={() => onTabChange(String(cat.id))} onclick={() => onTabChange(id)}
ondragstart={(e) => onTabDragStart(e, cat)} ondragstart={cat && id !== String(completedCatId) ? (e) => onTabDragStart(e, cat) : undefined}
ondragover={(e) => onTabDragOver(e, cat, idx)} ondragover={cat && id !== String(completedCatId) ? (e) => onTabDragOver(e, cat, idx) : undefined}
ondragleave={onTabDragLeave} ondragleave={cat && id !== String(completedCatId) ? onTabDragLeave : undefined}
ondrop={(e) => onTabDrop(e, cat)} ondrop={cat && id !== String(completedCatId) ? (e) => onTabDrop(e, cat) : undefined}
ondragend={onTabDragEnd} ondragend={cat && id !== String(completedCatId) ? onTabDragEnd : undefined}
> >
<Folder size={11} weight="bold" /> {#if id === "library"}<Books size={11} weight="bold" />
{cat.name} {:else if id === "downloaded"}<DownloadSimple size={11} weight="bold" />
<span class="tab-count">{counts[String(cat.id)] ?? 0}</span> {:else if cat && id === String(completedCatId)}<CheckSquare size={11} weight="bold" />
{#if tab === String(cat.id) && !refreshing} {:else if cat}<Folder size={11} weight="bold" />
<span
class="tab-refresh"
role="button"
tabindex="-1"
title="Refresh {cat.name}"
aria-label="Refresh {cat.name}"
class:tab-refresh-spinning={refreshingCatId === cat.id}
onclick={(e) => { e.stopPropagation(); onRefreshCategory(cat.id); }}
onkeydown={(e) => { if (e.key === "Enter") { e.stopPropagation(); onRefreshCategory(cat.id); } }}
>
<ArrowsClockwise size={10} weight="bold" class={refreshingCatId === cat.id ? "anim-spin" : ""} />
</span>
{/if} {/if}
{id === "library" ? "Saved" : id === "downloaded" ? "Downloaded" : (cat?.name ?? id)}
<span class="tab-count">{counts[id] ?? 0}</span>
</button> </button>
{#if dragInsertIdx === idx + 1 && activeDragKind === "tab" && idx === visibleCategories.length - 1} {#if activeDragKind === "tab" && dragInsertIdx === idx + 1}
<div class="tab-insert-bar" aria-hidden="true"></div> <div class="tab-insert-bar" aria-hidden="true"></div>
{/if} {/if}
{/each}
</div>
{/if} {/if}
{/each}
</div> </div>
<div class="header-right"> <div class="header-right">
@@ -150,10 +139,10 @@
{#if refreshing} {#if refreshing}
<button <button
class="icon-btn refresh-btn icon-btn-active" class="icon-btn refresh-btn icon-btn-active"
title="Cancel update" title={`Checking… ${refreshProgress.finished}/${refreshProgress.total}`}
onclick={onCancelRefresh} onclick={onRefresh}
> >
<X size={15} weight="bold" /> <ArrowsClockwise size={15} weight="bold" class="anim-spin" />
{#if refreshProgress.total > 0} {#if refreshProgress.total > 0}
<span class="refresh-progress">{refreshProgress.finished}/{refreshProgress.total}</span> <span class="refresh-progress">{refreshProgress.finished}/{refreshProgress.total}</span>
{/if} {/if}
@@ -229,22 +218,14 @@
.header { position: relative; z-index: 100; display: flex; align-items: center; gap: var(--sp-4); padding: var(--sp-4) var(--sp-6); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; min-width: 0; } .header { position: relative; z-index: 100; display: flex; align-items: center; gap: var(--sp-4); padding: var(--sp-4) var(--sp-6); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; min-width: 0; }
.header-right { display: flex; align-items: center; gap: var(--sp-2); margin-left: auto; flex-shrink: 0; } .header-right { display: flex; align-items: center; gap: var(--sp-2); margin-left: auto; flex-shrink: 0; }
.heading { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; flex-shrink: 0; } .heading { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; flex-shrink: 0; }
.tabs { display: flex; align-items: center; gap: 2px; background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 2px; position: relative; flex-shrink: 1; min-width: 0; } .tabs { display: flex; align-items: center; gap: 2px; background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 2px; position: relative; flex-shrink: 1; min-width: 0; overflow-x: auto; scrollbar-width: none; overscroll-behavior-x: contain; }
.tabs-scroll { display: flex; gap: 2px; overflow-x: auto; scrollbar-width: none; min-width: 0; flex-shrink: 1; } .tabs::-webkit-scrollbar { display: none; }
.tabs-scroll::-webkit-scrollbar { display: none; } .tab { position: relative; z-index: 1; 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); border: 1px solid transparent; color: var(--text-faint); white-space: nowrap; transition: background var(--t-base), color var(--t-base), border-color var(--t-base); cursor: grab; flex-shrink: 0; }
.tab-separator { width: 1px; height: 16px; background: var(--border-dim); flex-shrink: 0; margin: 0 2px; }
.tab-slide-indicator { position: absolute; top: 2px; bottom: 2px; border-radius: var(--radius-sm); background: var(--accent-muted); border: 1px solid var(--accent-dim); pointer-events: none; z-index: 0; transition: left 0.22s cubic-bezier(0.16,1,0.3,1), width 0.22s cubic-bezier(0.16,1,0.3,1); }
.tab { position: relative; z-index: 1; 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); cursor: grab; flex-shrink: 0; }
.tab:hover { color: var(--text-muted); } .tab:hover { color: var(--text-muted); }
.tab.active { background: var(--accent-muted); color: var(--accent-fg); border: 1px solid transparent; } .tab.active { background: var(--accent-muted); color: var(--accent-fg); border-color: var(--accent-dim); }
.tabs-anims .tab.active { background: transparent; }
.tab-dragging { opacity: 0.4; cursor: grabbing; } .tab-dragging { opacity: 0.4; cursor: grabbing; }
.tab-insert-bar { width: 2px; height: 22px; background: var(--accent); border-radius: 2px; flex-shrink: 0; box-shadow: 0 0 6px var(--accent); pointer-events: none; } .tab-insert-bar { width: 2px; height: 22px; background: var(--accent); border-radius: 2px; flex-shrink: 0; box-shadow: 0 0 6px var(--accent); pointer-events: none; }
.tab-count { font-size: var(--text-2xs); opacity: 0.6; } .tab-count { font-size: var(--text-2xs); opacity: 0.6; }
.tab-refresh { display: flex; align-items: center; justify-content: center; width: 14px; height: 14px; border-radius: 2px; opacity: 0; color: var(--accent-fg); cursor: pointer; transition: opacity var(--t-base), background var(--t-base); flex-shrink: 0; }
.tab.active:hover .tab-refresh { opacity: 0.6; }
.tab.active:hover .tab-refresh:hover { opacity: 1; background: var(--accent-dim); }
.tab-refresh-spinning { opacity: 1 !important; }
.search-wrap { position: relative; display: flex; align-items: center; } .search-wrap { position: relative; display: flex; align-items: center; }
.search-wrap :global(.search-icon) { position: absolute; left: 10px; color: var(--text-faint); pointer-events: none; } .search-wrap :global(.search-icon) { position: absolute; left: 10px; color: var(--text-faint); pointer-events: none; }
.search { background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 5px 10px 5px 28px; color: var(--text-primary); font-size: var(--text-sm); width: 180px; outline: none; transition: border-color var(--t-base); } .search { background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 5px 10px 5px 28px; color: var(--text-primary); font-size: var(--text-sm); width: 180px; outline: none; transition: border-color var(--t-base); }
@@ -253,7 +234,9 @@
.icon-btn { display: flex; align-items: center; justify-content: center; width: 30px; height: 30px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: var(--bg-raised); color: var(--text-faint); cursor: pointer; flex-shrink: 0; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); } .icon-btn { display: flex; align-items: center; justify-content: center; width: 30px; height: 30px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: var(--bg-raised); color: var(--text-faint); cursor: pointer; flex-shrink: 0; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
.icon-btn:hover { color: var(--text-primary); border-color: var(--border-strong); } .icon-btn:hover { color: var(--text-primary); border-color: var(--border-strong); }
.icon-btn-active { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); } .icon-btn-active { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
.refresh-btn { gap: var(--sp-1); width: auto; padding: 0 8px; } .refresh-btn { 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); } .refresh-progress { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--accent-fg); }
.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; } .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; }
.sort-panel-wrap { position: relative; } .sort-panel-wrap { position: relative; }
@@ -1,8 +1,8 @@
<script lang="ts"> <script lang="ts">
import { X } from "phosphor-svelte"; import { X } from "phosphor-svelte";
import { setPref } from "@features/series/lib/mangaPrefs"; import { setPref } from "@features/series/lib/mangaPrefs";
import { store } from "@store/state.svelte"; import { store, DEFAULT_MANGA_PREFS } from "@store/state.svelte";
import { DEFAULT_MANGA_PREFS } from "@store/state.svelte"; import { resolvedCover } from "@core/cover/coverResolver";
import type { MangaPrefs } from "@store/state.svelte"; import type { MangaPrefs } from "@store/state.svelte";
let { ids, onClose }: { let { ids, onClose }: {
@@ -42,6 +42,14 @@
const get = <K extends keyof MangaPrefs>(key: K): MangaPrefs[K] => draft[key]; const get = <K extends keyof MangaPrefs>(key: K): MangaPrefs[K] => draft[key];
const set = <K extends keyof MangaPrefs>(key: K, value: MangaPrefs[K]) => { draft = { ...draft, [key]: value }; }; const set = <K extends keyof MangaPrefs>(key: K, value: MangaPrefs[K]) => { draft = { ...draft, [key]: value }; };
const mosaicCovers = $derived.by(() => {
const idArr = [...ids].slice(0, 9);
return idArr
.map(id => store.library?.find(m => m.id === id))
.filter(Boolean)
.map(m => resolvedCover(m!.id, m!.thumbnailUrl));
});
function apply() { function apply() {
for (const id of ids) { for (const id of ids) {
for (const key of Object.keys(draft) as (keyof MangaPrefs)[]) { for (const key of Object.keys(draft) as (keyof MangaPrefs)[]) {
@@ -60,12 +68,26 @@
<div class="modal" role="dialog" aria-modal="true" aria-label="Bulk Automation"> <div class="modal" role="dialog" aria-modal="true" aria-label="Bulk Automation">
<div class="modal-header"> <div class="modal-header">
<div class="header-inner">
<div class="header-left"> <div class="header-left">
{#if mosaicCovers.length > 0}
<div class="mosaic" aria-hidden="true">
{#each mosaicCovers.slice(0, 5) as src}
<img class="mosaic-tile" {src} alt="" />
{/each}
{#if ids.size > 5}
<span class="mosaic-overflow">+{ids.size - 5}</span>
{/if}
</div>
{/if}
<div class="header-text">
<span class="modal-title">Automation</span> <span class="modal-title">Automation</span>
<span class="modal-subtitle">{ids.size} series selected</span> <span class="modal-subtitle">{ids.size} series selected</span>
</div> </div>
</div>
<button class="close-btn" onclick={onClose} aria-label="Close"><X size={16} weight="light" /></button> <button class="close-btn" onclick={onClose} aria-label="Close"><X size={16} weight="light" /></button>
</div> </div>
</div>
<div class="modal-body"> <div class="modal-body">
@@ -214,13 +236,37 @@
animation: scaleIn 0.15s ease both; animation: scaleIn 0.15s ease both;
} }
.modal-header { .modal-header { border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
.header-inner {
display: flex; align-items: center; justify-content: space-between; display: flex; align-items: center; justify-content: space-between;
padding: var(--sp-4) var(--sp-5); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; padding: var(--sp-4) var(--sp-5); gap: var(--sp-3);
} }
.header-left { display: flex; flex-direction: column; gap: 2px; }
.header-left { display: flex; align-items: center; gap: var(--sp-3); min-width: 0; }
.mosaic {
display: flex; align-items: center; flex-shrink: 0;
}
.mosaic-tile {
width: 28px; height: 38px;
object-fit: cover; border-radius: var(--radius-sm);
border: 1px solid var(--border-dim);
margin-left: -6px; box-shadow: -1px 0 0 var(--bg-surface);
}
.mosaic-tile:first-child { margin-left: 0; }
.mosaic-overflow {
font-family: var(--font-ui); font-size: var(--text-2xs);
color: var(--text-faint); letter-spacing: var(--tracking-wide);
margin-left: var(--sp-1); flex-shrink: 0;
}
.header-text { display: flex; flex-direction: column; gap: 2px; }
.modal-title { font-size: var(--text-base); font-weight: var(--weight-medium); color: var(--text-primary); letter-spacing: var(--tracking-tight); } .modal-title { font-size: var(--text-base); font-weight: var(--weight-medium); color: var(--text-primary); letter-spacing: var(--tracking-tight); }
.modal-subtitle { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); } .modal-subtitle { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
.close-btn { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: var(--radius-sm); color: var(--text-faint); background: none; border: none; cursor: pointer; transition: color var(--t-base), background var(--t-base); flex-shrink: 0; } .close-btn { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: var(--radius-sm); color: var(--text-faint); background: none; border: none; cursor: pointer; transition: color var(--t-base), background var(--t-base); flex-shrink: 0; }
.close-btn:hover { color: var(--text-muted); background: var(--bg-raised); } .close-btn:hover { color: var(--text-muted); background: var(--bg-raised); }
+437 -25
View File
@@ -1,5 +1,4 @@
<script lang="ts"> <script lang="ts">
import { CircleNotch } from "phosphor-svelte";
import { store } from "@store/state.svelte"; import { store } from "@store/state.svelte";
import { readerState } from "../store/readerState.svelte"; import { readerState } from "../store/readerState.svelte";
import type { StripChapter } from "../lib/scrollHandler"; import type { StripChapter } from "../lib/scrollHandler";
@@ -19,6 +18,8 @@
fadingOut: boolean; fadingOut: boolean;
tapToToggleBar: boolean; tapToToggleBar: boolean;
pinchZoomEnabled: boolean; pinchZoomEnabled: boolean;
chapterEpoch: number;
barPosition: "top" | "left" | "right";
onGetZoom: () => number; onGetZoom: () => number;
onSetZoom: (z: number) => void; onSetZoom: (z: number) => void;
resolveUrl: (url: string, priority?: number) => Promise<string>; resolveUrl: (url: string, priority?: number) => Promise<string>;
@@ -31,10 +32,152 @@
const { const {
style, imgCls, effectiveWidth, loading, error, pageReady, style, imgCls, effectiveWidth, loading, error, pageReady,
pageGroups, currentGroup, stripToRender, fadingOut, pageGroups, currentGroup, stripToRender, fadingOut,
tapToToggleBar, pinchZoomEnabled, onGetZoom, onSetZoom, tapToToggleBar, pinchZoomEnabled, chapterEpoch, barPosition, onGetZoom, onSetZoom,
resolveUrl, onTap, onWheel, onToggleUi, bindContainer, resolveUrl, onTap, onWheel, onToggleUi, bindContainer,
}: Props = $props(); }: Props = $props();
const LOAD_RADIUS = 5;
const UNLOAD_RADIUS = 10;
type FlatPage = { chapterId: number; chapterName: string; localIndex: number; url: string; total: number };
const flatPages = $derived.by<FlatPage[]>(() => {
const out: FlatPage[] = [];
for (const chunk of stripToRender) {
for (let i = 0; i < chunk.urls.length; i++) {
out.push({ chapterId: chunk.chapterId, chapterName: chunk.chapterName, localIndex: i, url: chunk.urls[i], total: chunk.urls.length });
}
}
return out;
});
let loadedSet = $state(new Set<number>());
let resolvedSrc = $state<Record<number, string>>({});
let revokeQueue: string[] = [];
let observer: IntersectionObserver | null = null;
const elementIndex = new Map<Element, number>();
let viewportCenter = $state(0);
function scheduleRevoke(src: string) {
if (!src || !src.startsWith("blob:")) return;
revokeQueue.push(src);
requestAnimationFrame(() => {
const url = revokeQueue.shift();
if (url) {
try { URL.revokeObjectURL(url); } catch { }
}
});
}
function loadPage(idx: number) {
if (loadedSet.has(idx)) return;
const page = flatPages[idx];
if (!page) return;
const newSet = new Set(loadedSet);
newSet.add(idx);
loadedSet = newSet;
const priority = (page.localIndex < 8 && page.chapterId === flatPages[0]?.chapterId) ? 8 - page.localIndex : 0;
resolveUrl(page.url, priority).then(src => {
if (loadedSet.has(idx)) {
resolvedSrc = { ...resolvedSrc, [idx]: src };
} else {
scheduleRevoke(src);
}
});
}
function unloadPage(idx: number) {
if (!loadedSet.has(idx)) return;
const newSet = new Set(loadedSet);
newSet.delete(idx);
loadedSet = newSet;
const oldSrc = resolvedSrc[idx];
if (oldSrc) {
const next = { ...resolvedSrc };
delete next[idx];
resolvedSrc = next;
scheduleRevoke(oldSrc);
}
}
function recalcWindow() {
const center = viewportCenter;
const lo = center - LOAD_RADIUS;
const hi = center + LOAD_RADIUS;
const evictLo = center - UNLOAD_RADIUS;
const evictHi = center + UNLOAD_RADIUS;
for (let i = 0; i < flatPages.length; i++) {
if (i >= lo && i <= hi) {
loadPage(i);
} else if (i < evictLo || i > evictHi) {
unloadPage(i);
}
}
}
$effect(() => {
void viewportCenter;
recalcWindow();
});
$effect(() => {
void flatPages.length;
recalcWindow();
});
function setupObserver(containerEl: HTMLElement) {
observer?.disconnect();
elementIndex.clear();
observer = new IntersectionObserver(
(entries) => {
let best = -1;
let bestRatio = -1;
for (const entry of entries) {
const idx = elementIndex.get(entry.target);
if (idx === undefined) continue;
if (entry.isIntersecting && entry.intersectionRatio > bestRatio) {
bestRatio = entry.intersectionRatio;
best = idx;
}
}
if (best >= 0 && best !== viewportCenter) {
viewportCenter = best;
}
},
{
root: containerEl,
rootMargin: "0px",
threshold: [0, 0.1, 0.5, 1.0],
}
);
}
function observePage(el: HTMLDivElement, idx: number) {
elementIndex.set(el, idx);
observer?.observe(el);
return {
update(newIdx: number) {
elementIndex.set(el, newIdx);
},
destroy() {
observer?.unobserve(el);
elementIndex.delete(el);
}
};
}
$effect(() => {
void chapterEpoch;
loadedSet = new Set<number>();
resolvedSrc = {};
const resume = readerState.resumePage;
viewportCenter = resume > 1 ? resume - 1 : 0;
});
const INSPECT_ZOOM_STEP = 0.15; const INSPECT_ZOOM_STEP = 0.15;
const INSPECT_ZOOM_MAX = 8; const INSPECT_ZOOM_MAX = 8;
@@ -68,6 +211,65 @@
let stripDragStartY = 0; let stripDragStartY = 0;
let stripScrollStart = 0; let stripScrollStart = 0;
let autoScrollPaused = false;
let autoScrollPauseTimer: ReturnType<typeof setTimeout> | null = null;
let midScrollActive = $state(false);
let midScrollOriginY = $state(0);
let midScrollOriginX = $state(0);
let midScrollCurrentY = 0;
let midScrollRaf: number | null = null;
// Speed level 0-5 for the indicator bar
const midScrollSpeedLevel = $derived.by(() => {
if (!midScrollActive) return 0;
// recomputes when midScrollOriginY changes; actual dy read in RAF so this is just for display
return 0; // will be updated imperatively
});
let midScrollDisplayLevel = $state(0);
function startMidScroll(originY: number, originX: number) {
midScrollActive = true;
midScrollOriginY = originY;
midScrollOriginX = originX;
midScrollDisplayLevel = 0;
if (midScrollRaf) cancelAnimationFrame(midScrollRaf);
const tick = () => {
if (!midScrollActive || !containerEl) return;
const dy = midScrollCurrentY - midScrollOriginY;
const deadZone = 24;
const excess = Math.max(0, Math.abs(dy) - deadZone);
const speed = Math.sign(dy) * excess * 0.12;
containerEl.scrollTop += speed;
midScrollDisplayLevel = Math.sign(dy) * Math.min(5, Math.floor(excess / 30));
midScrollRaf = requestAnimationFrame(tick);
};
midScrollRaf = requestAnimationFrame(tick);
}
function stopMidScroll() {
midScrollActive = false;
midScrollDisplayLevel = 0;
if (midScrollRaf) { cancelAnimationFrame(midScrollRaf); midScrollRaf = null; }
}
function pauseAutoScroll() {
autoScrollPaused = true;
if (autoScrollPauseTimer) clearTimeout(autoScrollPauseTimer);
autoScrollPauseTimer = setTimeout(() => { autoScrollPaused = false; }, 2500);
}
$effect(() => {
if (style !== "longstrip" || !store.settings.autoScroll) return;
let rafId: number;
const tick = () => {
if (!autoScrollPaused && containerEl) containerEl.scrollTop += (store.settings.autoScrollSpeed ?? 5) * 0.5;
rafId = requestAnimationFrame(tick);
};
rafId = requestAnimationFrame(tick);
return () => cancelAnimationFrame(rafId);
});
let pinch: PinchTracker | null = null; let pinch: PinchTracker | null = null;
$effect(() => { $effect(() => {
@@ -86,11 +288,22 @@
}); });
export function onInspectMouseDown(e: MouseEvent) { export function onInspectMouseDown(e: MouseEvent) {
if ((e.target as Element).closest(".bar")) return;
if (e.button === 1 && style === "longstrip") {
e.preventDefault();
if (midScrollActive) { stopMidScroll(); } else {
// pause regular auto-scroll while mid-scroll is active
store.settings.autoScroll = false;
startMidScroll(e.clientY, e.clientX);
}
return;
}
if (style === "longstrip") { if (style === "longstrip") {
stripDragging = true; stripDragging = true;
stripDragMoved = false; stripDragMoved = false;
stripDragStartY = e.clientY; stripDragStartY = e.clientY;
stripScrollStart = containerEl?.scrollTop ?? 0; stripScrollStart = containerEl?.scrollTop ?? 0;
pauseAutoScroll();
e.preventDefault(); e.preventDefault();
return; return;
} }
@@ -105,6 +318,7 @@
} }
export function onInspectMouseMove(e: MouseEvent) { export function onInspectMouseMove(e: MouseEvent) {
midScrollCurrentY = e.clientY;
if (stripDragging) { if (stripDragging) {
const dy = e.clientY - stripDragStartY; const dy = e.clientY - stripDragStartY;
if (!stripDragMoved && Math.abs(dy) > 4) stripDragMoved = true; if (!stripDragMoved && Math.abs(dy) > 4) stripDragMoved = true;
@@ -126,6 +340,7 @@
} }
export function onPointerDown(e: PointerEvent) { export function onPointerDown(e: PointerEvent) {
if ((e.target as Element).closest(".bar")) return;
pinch?.onPointerDown(e); pinch?.onPointerDown(e);
} }
@@ -160,6 +375,7 @@
export function handleWheel(e: WheelEvent) { export function handleWheel(e: WheelEvent) {
if (style === "longstrip") { if (style === "longstrip") {
if (e.ctrlKey) { onWheel(e); } if (e.ctrlKey) { onWheel(e); }
else pauseAutoScroll();
return; return;
} }
if (!e.ctrlKey) { onWheel(e); return; } if (!e.ctrlKey) { onWheel(e); return; }
@@ -183,7 +399,10 @@
} }
function handleTap(e: MouseEvent) { function handleTap(e: MouseEvent) {
if (style === "longstrip") return; if (style === "longstrip") {
if (stripDragMoved) { stripDragMoved = false; return; }
return;
}
if (inspectDragMoved) { inspectDragMoved = false; return; } if (inspectDragMoved) { inspectDragMoved = false; return; }
if (stripDragMoved) { stripDragMoved = false; return; } if (stripDragMoved) { stripDragMoved = false; return; }
onTap(e); onTap(e);
@@ -192,7 +411,18 @@
function setContainer(el: HTMLDivElement) { function setContainer(el: HTMLDivElement) {
containerEl = el; containerEl = el;
bindContainer(el); bindContainer(el);
if (style === "longstrip") setupObserver(el);
} }
$effect(() => {
if (style === "longstrip" && containerEl) {
setupObserver(containerEl);
} else if (style !== "longstrip") {
observer?.disconnect();
observer = null;
stopMidScroll();
}
});
</script> </script>
<div <div
@@ -200,43 +430,94 @@
class="viewer" class="viewer"
class:strip={style === "longstrip"} class:strip={style === "longstrip"}
class:inspect-active={readerState.inspectScale > 1} class:inspect-active={readerState.inspectScale > 1}
class:midscroll-active={midScrollActive}
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}
ondblclick={(e) => { if (tapToToggleBar) { const x = e.clientX / window.innerWidth; if (x >= 0.3 && x <= 0.7) onToggleUi(); } }} onauxclick={(e) => { if (e.button === 1 && style === "longstrip") e.preventDefault(); }}
ondblclick={() => { if (tapToToggleBar) onToggleUi(); }}
onmousedown={onInspectMouseDown} onmousedown={onInspectMouseDown}
onpointerdown={pinchZoomEnabled ? onPointerDown : undefined} onpointerdown={pinchZoomEnabled ? onPointerDown : undefined}
onwheel={(e) => { if (e.ctrlKey || style !== "longstrip") e.preventDefault(); }} onwheel={(e) => { if (e.ctrlKey || style !== "longstrip") e.preventDefault(); }}
style:cursor={style === "longstrip" ? (stripDragging ? "grabbing" : "grab") : undefined} style:cursor={style === "longstrip" ? (stripDragging ? "grabbing" : "grab") : undefined}
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(); store.settings.autoScroll = !store.settings.autoScroll; return; }
if ((e.key === "ArrowLeft" || e.key === "ArrowRight" || e.key === "ArrowUp" || e.key === "ArrowDown") && style !== "longstrip") e.preventDefault();
}}
> >
{#if midScrollActive}
<div class="midscroll-bar" class:midscroll-bar-right={barPosition !== "right"} class:midscroll-bar-left={barPosition === "right"}>
<div class="midscroll-segments">
{#each [5,4,3,2,1] as n}
<div class="midscroll-seg" class:midscroll-seg-lit={midScrollDisplayLevel < 0 && -midScrollDisplayLevel >= n}></div>
{/each}
<div class="midscroll-origin-dot"></div>
{#each [1,2,3,4,5] as n}
<div class="midscroll-seg" class:midscroll-seg-lit={midScrollDisplayLevel > 0 && midScrollDisplayLevel >= n}></div>
{/each}
</div>
<button class="midscroll-stop" onclick={stopMidScroll} title="Stop (middle click)">
<svg width="8" height="8" viewBox="0 0 8 8"><rect x="0" y="0" width="8" height="8" rx="1" fill="currentColor"/></svg>
</button>
</div>
{/if}
{#if loading} {#if loading}
<div class="center-overlay"><CircleNotch size={20} weight="light" class="anim-spin" style="color:var(--text-faint)" /></div> <div class="center-overlay">
<div class="page-loader page-loader-single" aria-hidden="true"><svg class="panel-skeleton" viewBox="0 0 100 150" fill="none" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid meet"><rect class="ps-r ps-r1" x="2" y="2" width="62" height="88" rx="1"/><rect class="ps-r ps-r2" x="68" y="2" width="30" height="42" rx="1"/><rect class="ps-r ps-r3" x="68" y="48" width="30" height="42" rx="1"/><rect class="ps-r ps-r4" x="2" y="94" width="44" height="54" rx="1"/><rect class="ps-r ps-r5" x="50" y="94" width="48" height="54" rx="1"/></svg></div>
</div>
{/if} {/if}
{#if error} {#if error}
<div class="center-overlay"><p class="error-msg">{error}</p></div> <div class="center-overlay"><p class="error-msg">{error}</p></div>
{/if} {/if}
{#key chapterEpoch}
{#if style === "longstrip"} {#if style === "longstrip"}
{#each stripToRender as chunk} {#each flatPages as page, gi (page.chapterId + ":" + page.localIndex)}
{#each chunk.urls as url, i} {@const src = resolvedSrc[gi]}
{#await resolveUrl(url, chunk.urls.length - i)} {@const isLoaded = loadedSet.has(gi)}
<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" /> <div
{:then src} class="strip-slot"
<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" /> use:observePage={gi}
{/await} data-gi={gi}
{/each} >
{#if isLoaded && src}
<img
src={src}
alt="{page.chapterName} Page {page.localIndex + 1}"
data-local-page={page.localIndex + 1}
data-chapter={page.chapterId}
data-total={page.total}
class="{imgCls}{store.settings.pageGap ? ' strip-gap' : ''}"
loading="eager"
decoding="async"
draggable="false"
onload={(e) => {
const img = e.currentTarget as HTMLImageElement;
const slot = img.closest<HTMLElement>(".strip-slot");
if (slot && img.naturalWidth > 0) {
slot.style.setProperty("--aspect", String(img.naturalWidth / img.naturalHeight));
}
}}
/>
{:else}
<div class="strip-placeholder" aria-hidden="true">
<svg class="panel-skeleton" viewBox="0 0 100 150" fill="none" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid meet"><rect class="ps-r ps-r1" x="2" y="2" width="62" height="88" rx="1"/><rect class="ps-r ps-r2" x="68" y="2" width="30" height="42" rx="1"/><rect class="ps-r ps-r3" x="68" y="48" width="30" height="42" rx="1"/><rect class="ps-r ps-r4" x="2" y="94" width="44" height="54" rx="1"/><rect class="ps-r ps-r5" x="50" y="94" width="48" height="54" rx="1"/></svg>
</div>
{/if}
</div>
{/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}
<div class="inspect-wrap" style="transform:scale({readerState.inspectScale}) translate({readerState.inspectPanX / readerState.inspectScale}px,{readerState.inspectPanY / readerState.inspectScale}px)"> <div class="inspect-wrap" style="transform:scale({readerState.inspectScale}) translate({readerState.inspectPanX / readerState.inspectScale}px,{readerState.inspectPanY / readerState.inspectScale}px)">
{#await resolveUrl(store.pageUrls[store.pageNumber - 1], 999)} {#await resolveUrl(store.pageUrls[store.pageNumber - 1], 999)}
<img src="" alt="Page {store.pageNumber}" class={imgCls} decoding="async" style="opacity:0" /> <div class="page-loader page-loader-single" aria-hidden="true">
<svg class="panel-skeleton" viewBox="0 0 100 150" fill="none" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid meet"><rect class="ps-r ps-r1" x="2" y="2" width="62" height="88" rx="1"/><rect class="ps-r ps-r2" x="68" y="2" width="30" height="42" rx="1"/><rect class="ps-r ps-r3" x="68" y="48" width="30" height="42" rx="1"/><rect class="ps-r ps-r4" x="2" y="94" width="44" height="54" rx="1"/><rect class="ps-r ps-r5" x="50" y="94" width="48" height="54" rx="1"/></svg>
</div>
{:then src} {:then src}
<img {src} alt="Page {store.pageNumber}" class={imgCls} decoding="async" style="opacity: {fadingOut ? 0 : 1}; transition: opacity 0.1s ease;" /> <img {src} alt="Page {store.pageNumber}" class={imgCls} decoding="async" style="opacity: {fadingOut ? 0 : 1}; transition: opacity 0.1s ease;" draggable="false" />
{/await} {/await}
</div> </div>
@@ -244,33 +525,42 @@
<div class="inspect-wrap" style="transform:scale({readerState.inspectScale}) translate({readerState.inspectPanX / readerState.inspectScale}px,{readerState.inspectPanY / readerState.inspectScale}px)"> <div class="inspect-wrap" style="transform:scale({readerState.inspectScale}) translate({readerState.inspectPanX / readerState.inspectScale}px,{readerState.inspectPanY / readerState.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 (pg)}
{#await resolveUrl(store.pageUrls[pg - 1], 999)} {#await resolveUrl(store.pageUrls[pg - 1], 999)}
<img src="" alt="Page {pg}" class="{imgCls} page-half {i === 0 ? 'gap-left' : 'gap-right'}" decoding="async" /> <div class="page-loader page-half {i === 0 ? 'gap-left' : 'gap-right'}" aria-hidden="true">
<svg class="panel-skeleton" viewBox="0 0 100 150" fill="none" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid meet"><rect class="ps-r ps-r1" x="2" y="2" width="62" height="88" rx="1"/><rect class="ps-r ps-r2" x="68" y="2" width="30" height="42" rx="1"/><rect class="ps-r ps-r3" x="68" y="48" width="30" height="42" rx="1"/><rect class="ps-r ps-r4" x="2" y="94" width="44" height="54" rx="1"/><rect class="ps-r ps-r5" x="50" y="94" width="48" height="54" rx="1"/></svg>
</div>
{:then src} {:then src}
<img {src} alt="Page {pg}" class="{imgCls} page-half {i === 0 ? 'gap-left' : 'gap-right'}" decoding="async" /> <img {src} alt="Page {pg}" class="{imgCls} page-half {i === 0 ? 'gap-left' : 'gap-right'}" decoding="async" draggable="false" />
{/await} {/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">
<div class="page-loader page-loader-single" aria-hidden="true">
<svg class="panel-skeleton" viewBox="0 0 100 150" fill="none" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid meet"><rect class="ps-r ps-r1" x="2" y="2" width="62" height="88" rx="1"/><rect class="ps-r ps-r2" x="68" y="2" width="30" height="42" rx="1"/><rect class="ps-r ps-r3" x="68" y="48" width="30" height="42" rx="1"/><rect class="ps-r ps-r4" x="2" y="94" width="44" height="54" rx="1"/><rect class="ps-r ps-r5" x="50" y="94" width="48" height="54" rx="1"/></svg>
</div>
</div>
{/if} {/if}
</div> </div>
{:else if pageReady} {:else if pageReady}
<div class="inspect-wrap" style="transform:scale({readerState.inspectScale}) translate({readerState.inspectPanX / readerState.inspectScale}px,{readerState.inspectPanY / readerState.inspectScale}px)"> <div class="inspect-wrap" style="transform:scale({readerState.inspectScale}) translate({readerState.inspectPanX / readerState.inspectScale}px,{readerState.inspectPanY / readerState.inspectScale}px)">
{#await resolveUrl(store.pageUrls[store.pageNumber - 1], 999)} {#await resolveUrl(store.pageUrls[store.pageNumber - 1], 999)}
<img src="" alt="Page {store.pageNumber}" class={imgCls} decoding="async" /> <div class="page-loader page-loader-single" aria-hidden="true">
<svg class="panel-skeleton" viewBox="0 0 100 150" fill="none" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid meet"><rect class="ps-r ps-r1" x="2" y="2" width="62" height="88" rx="1"/><rect class="ps-r ps-r2" x="68" y="2" width="30" height="42" rx="1"/><rect class="ps-r ps-r3" x="68" y="48" width="30" height="42" rx="1"/><rect class="ps-r ps-r4" x="2" y="94" width="44" height="54" rx="1"/><rect class="ps-r ps-r5" x="50" y="94" width="48" height="54" rx="1"/></svg>
</div>
{:then src} {:then src}
<img {src} alt="Page {store.pageNumber}" class={imgCls} decoding="async" /> <img {src} alt="Page {store.pageNumber}" class={imgCls} decoding="async" draggable="false" />
{/await} {/await}
</div> </div>
{/if} {/if}
{/key}
</div> </div>
<style> <style>
.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; touch-action: pan-x pan-y; } .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; touch-action: pan-x pan-y; zoom: calc(1 / var(--ui-zoom, 1)); }
.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 { cursor: grab; overflow: hidden; }
@@ -280,11 +570,63 @@
.inspect-wrap { display: flex; align-items: center; justify-content: center; transform-origin: center center; will-change: transform; } .inspect-wrap { display: flex; align-items: center; justify-content: center; transform-origin: center center; will-change: transform; }
.strip-slot {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
}
.strip-placeholder {
width: var(--effective-width, 100%);
max-width: var(--effective-width, 100%);
aspect-ratio: var(--aspect, 0.667);
border-radius: var(--radius-sm);
display: flex;
align-items: stretch;
}
.page-loader {
border-radius: var(--radius-sm);
display: flex;
align-items: stretch;
}
.page-loader-single {
width: min(100%, var(--effective-width, 100%));
max-width: var(--effective-width, 100%);
max-height: calc(var(--visual-vh, 100vh) - 80px);
aspect-ratio: 2 / 3;
}
.panel-skeleton { width: 100%; height: 100%; }
.panel-skeleton :global(.ps-r) {
stroke: var(--border-strong);
stroke-width: 0.8;
fill: none;
stroke-dasharray: 400;
stroke-dashoffset: 400;
animation: ps-shimmer 2s ease-in-out infinite;
}
.panel-skeleton :global(.ps-r1) { animation-delay: 0s; }
.panel-skeleton :global(.ps-r2) { animation-delay: 0.15s; }
.panel-skeleton :global(.ps-r3) { animation-delay: 0.3s; }
.panel-skeleton :global(.ps-r4) { animation-delay: 0.1s; }
.panel-skeleton :global(.ps-r5) { animation-delay: 0.25s; }
@keyframes ps-shimmer {
0% { stroke-dashoffset: 400; opacity: 0.25; }
40% { stroke-dashoffset: 0; opacity: 0.55; }
70% { stroke-dashoffset: 0; opacity: 0.55; }
100% { stroke-dashoffset: -400; opacity: 0.25; }
}
.img { display: block; user-select: none; image-rendering: auto; } .img { display: block; user-select: none; image-rendering: auto; }
.img:global(.optimize-contrast) { image-rendering: -webkit-optimize-contrast; } .img:global(.optimize-contrast) { image-rendering: -webkit-optimize-contrast; }
:global(.fit-width) { max-width: var(--effective-width, 100%); width: 100%; height: auto; } :global(.fit-width) { max-width: var(--effective-width, 100%); width: 100%; height: auto; }
:global(.fit-height) { max-height: calc(100vh - 80px); width: auto; max-width: var(--effective-width, 100%); height: auto; } :global(.fit-height) { max-height: calc(var(--visual-vh, 100vh) - 80px); width: auto; max-width: var(--effective-width, 100%); height: auto; }
:global(.fit-screen) { max-width: var(--effective-width, 100%); max-height: calc(100vh - 80px); object-fit: contain; height: auto; } :global(.fit-screen) { max-width: var(--effective-width, 100%); max-height: calc(var(--visual-vh, 100vh) - 80px); object-fit: contain; height: auto; }
:global(.fit-original) { max-width: 100%; width: auto; height: auto; } :global(.fit-original) { max-width: 100%; width: auto; height: auto; }
:global(.strip-gap) { margin-bottom: 8px; } :global(.strip-gap) { margin-bottom: 8px; }
@@ -295,4 +637,74 @@
.center-overlay { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; } .center-overlay { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; }
.error-msg { color: var(--color-error); font-size: var(--text-base); } .error-msg { color: var(--color-error); font-size: var(--text-base); }
.midscroll-bar {
position: fixed;
top: 50%;
transform: translateY(-50%);
z-index: 200;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 10px 6px;
background: color-mix(in srgb, var(--bg-raised) 92%, transparent);
border: 1px solid var(--border-base);
border-radius: 10px;
box-shadow: 0 4px 16px rgba(0,0,0,0.45);
pointer-events: auto;
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
}
.midscroll-bar-right { right: 8px; }
.midscroll-bar-left { left: 8px; }
.midscroll-segments {
display: flex;
flex-direction: column;
align-items: center;
gap: 3px;
}
.midscroll-origin-dot {
width: 6px;
height: 6px;
border-radius: 50%;
border: 1.5px solid var(--accent-fg);
opacity: 0.6;
flex-shrink: 0;
margin: 2px 0;
}
.midscroll-seg {
width: 4px;
height: 14px;
border-radius: 2px;
background: var(--border-strong);
transition: background 0.06s ease;
flex-shrink: 0;
}
.midscroll-seg-lit {
background: var(--accent-fg);
}
.midscroll-stop {
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-sm);
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);
flex-shrink: 0;
}
.midscroll-stop:hover {
color: var(--text-primary);
background: var(--bg-overlay);
border-color: var(--border-strong);
}
</style> </style>
+12 -5
View File
@@ -25,7 +25,7 @@
import ReaderPresetPanel from "./ReaderPresetPanel.svelte"; import ReaderPresetPanel from "./ReaderPresetPanel.svelte";
const win = getCurrentWindow(); const win = getCurrentWindow();
const useBlob = $derived((store.settings.serverAuthMode ?? "NONE") === "BASIC_AUTH"); const useBlob = $derived((store.settings.serverAuthMode ?? "NONE") !== "NONE");
const effectiveReaderSettings = $derived.by(() => { const effectiveReaderSettings = $derived.by(() => {
const mangaId = store.activeManga?.id; const mangaId = store.activeManga?.id;
@@ -225,6 +225,7 @@
toggleDirection: () => applySettings({ readingDirection: rtl ? "ltr" : "rtl" }), toggleDirection: () => applySettings({ readingDirection: rtl ? "ltr" : "rtl" }),
openSettings: () => setSettingsOpen(true), openSettings: () => setSettingsOpen(true),
toggleBookmark: () => toggleBookmark(displayChapter, store.pageNumber), toggleBookmark: () => toggleBookmark(displayChapter, store.pageNumber),
toggleAutoScroll: () => { if (style === "longstrip") updateSettings({ autoScroll: !(store.settings.autoScroll ?? false) }); },
toggleMarker: () => { toggleMarker: () => {
if (currentPageMarkers.length > 0) { if (currentPageMarkers.length > 0) {
const first = currentPageMarkers[0]; const first = currentPageMarkers[0];
@@ -420,23 +421,28 @@
$effect(() => { $effect(() => {
const ahead = store.settings.preloadPages ?? 3; const ahead = store.settings.preloadPages ?? 3;
const current = store.pageUrls[store.pageNumber - 1]; const current = store.pageUrls[store.pageNumber - 1];
const pageNum = store.pageNumber;
const urls = store.pageUrls;
if (!current) return; if (!current) return;
const t = setTimeout(() => {
if (useBlob) { if (useBlob) {
import("@core/cache/imageCache").then(({ getBlobUrl, preloadBlobUrls }) => { import("@core/cache/imageCache").then(({ getBlobUrl, preloadBlobUrls }) => {
getBlobUrl(current, 999); getBlobUrl(current, 999);
const upcoming = Array.from({ length: ahead }, (_, i) => store.pageUrls[store.pageNumber + i]).filter(Boolean) as string[]; const upcoming = Array.from({ length: ahead }, (_, i) => urls[pageNum + i]).filter(Boolean) as string[];
const behind = store.pageUrls[store.pageNumber - 2]; const behind = urls[pageNum - 2];
preloadBlobUrls(upcoming, ahead); preloadBlobUrls(upcoming, ahead);
if (behind) preloadBlobUrls([behind], 0); if (behind) preloadBlobUrls([behind], 0);
}); });
} else { } else {
for (let i = 1; i <= ahead; i++) { for (let i = 1; i <= ahead; i++) {
const url = store.pageUrls[store.pageNumber - 1 + i]; const url = urls[pageNum - 1 + i];
if (url) preloadImage(url, useBlob); if (url) preloadImage(url, useBlob);
} }
const behind = store.pageUrls[store.pageNumber - 2]; const behind = urls[pageNum - 2];
if (behind) preloadImage(behind, useBlob); if (behind) preloadImage(behind, useBlob);
} }
}, 150);
return () => clearTimeout(t);
}); });
$effect(() => { $effect(() => {
@@ -587,6 +593,7 @@
fadingOut={readerState.fadingOut} fadingOut={readerState.fadingOut}
{tapToToggleBar} {tapToToggleBar}
{pinchZoomEnabled} {pinchZoomEnabled}
{barPosition}
onGetZoom={() => zoom} onGetZoom={() => zoom}
onSetZoom={(z) => { captureZoomAnchor(containerEl, style, zoomAnchor); applySettings({ readerZoom: z }); restoreZoomAnchor(containerEl, zoomAnchor); }} onSetZoom={(z) => { captureZoomAnchor(containerEl, style, zoomAnchor); applySettings({ readerZoom: z }); restoreZoomAnchor(containerEl, zoomAnchor); }}
resolveUrl={(url, priority) => resolveUrl(url, useBlob, priority)} resolveUrl={(url, priority) => resolveUrl(url, useBlob, priority)}
@@ -142,12 +142,18 @@
{#if isVertical} {#if isVertical}
<span class="ch-info">&#xE2CE;</span> <span class="ch-info">&#xE2CE;</span>
{:else} {:else}
<span class="ch-marquee-track" onwheel={(e) => { e.stopPropagation(); (e.currentTarget as HTMLElement).scrollLeft += e.deltaY; }}>
<span class="ch-marquee-content">
<span class="ch-title">{store.activeManga?.title}</span> <span class="ch-title">{store.activeManga?.title}</span>
<span class="ch-sep">/</span> <span class="ch-sep">/</span>
<span class="ch-name">{displayChapter?.name}</span> <span class="ch-name">{displayChapter?.name}</span>
<span class="ch-page">{store.pageNumber} / {visibleChunkLastPage || "…"}</span> </span>
</span>
{/if} {/if}
</button> </button>
{#if !isVertical}
<span class="ch-page">{store.pageNumber} / {visibleChunkLastPage || "…"}</span>
{/if}
{#if chapterHover && isVertical} {#if chapterHover && isVertical}
<div class="ch-popover ch-popover-{popoverSide}"> <div class="ch-popover ch-popover-{popoverSide}">
@@ -170,7 +176,7 @@
</button> </button>
{#if !isVertical} {#if !isVertical}
<span class="bar-sep"></span> <span class="bar-sep" data-tauri-drag-region></span>
{/if} {/if}
</div> </div>
@@ -180,6 +186,10 @@
</div> </div>
{/if} {/if}
{#if !isVertical}
<div class="bar-drag-gap" data-tauri-drag-region></div>
{/if}
<div class="bar-end"> <div class="bar-end">
<div class="zoom-wrap"> <div class="zoom-wrap">
<div class="zoom-inline"> <div class="zoom-inline">
@@ -360,6 +370,7 @@
z-index: 2; z-index: 2;
transition: opacity 0.25s ease; transition: opacity 0.25s ease;
overflow: visible; overflow: visible;
user-select: none;
} }
.bar.hidden { opacity: 0; pointer-events: none; } .bar.hidden { opacity: 0; pointer-events: none; }
@@ -385,12 +396,15 @@
.bar-left { left: 0; border-right: 1px solid var(--border-dim); } .bar-left { left: 0; border-right: 1px solid var(--border-dim); }
.bar-right { right: 0; border-left: 1px solid var(--border-dim); } .bar-right { right: 0; border-left: 1px solid var(--border-dim); }
.bar-drag-gap { flex: 1; height: 100%; cursor: grab; }
.bar-drag-gap:active { cursor: grabbing; }
.bar-start, .bar-end { .bar-start, .bar-end {
display: flex; display: flex;
align-items: center; align-items: center;
gap: var(--sp-1); gap: var(--sp-1);
} }
.bar-top .bar-start { flex: 1; overflow: hidden; } .bar-top .bar-start { overflow: hidden; }
.bar-left .bar-start, .bar-left .bar-start,
.bar-left .bar-end, .bar-left .bar-end,
.bar-right .bar-start, .bar-right .bar-start,
@@ -404,16 +418,14 @@
.icon-btn.active { color: var(--accent-fg); } .icon-btn.active { color: var(--accent-fg); }
.marker-btn-has { color: var(--marker-color, var(--accent-fg)) !important; } .marker-btn-has { color: var(--marker-color, var(--accent-fg)) !important; }
.ch-hover-wrap { position: relative; min-width: 0; } .ch-hover-wrap { position: relative; min-width: 0; display: flex; align-items: center; gap: var(--sp-2); }
.ch-pill { .ch-pill {
display: flex; display: flex;
align-items: center; align-items: center;
gap: var(--sp-2);
font-size: var(--text-sm); font-size: var(--text-sm);
color: var(--text-muted); color: var(--text-muted);
overflow: hidden; overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
min-width: 0; min-width: 0;
padding: 2px 4px; padding: 2px 4px;
@@ -429,9 +441,24 @@
padding: 0; padding: 0;
} }
.ch-info { font-size: 15px; line-height: 1; color: var(--text-faint); flex-shrink: 0; } .ch-info { font-size: 15px; line-height: 1; color: var(--text-faint); flex-shrink: 0; }
.ch-title { color: var(--text-secondary); font-weight: var(--weight-medium); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ch-marquee-track {
overflow-x: auto;
min-width: 0;
flex: 1;
scrollbar-width: none;
}
.ch-marquee-track::-webkit-scrollbar { display: none; }
.ch-marquee-content {
display: inline-flex;
align-items: center;
gap: var(--sp-2);
white-space: nowrap;
}
.ch-title { color: var(--text-secondary); font-weight: var(--weight-medium); }
.ch-sep { color: var(--text-faint); flex-shrink: 0; } .ch-sep { color: var(--text-faint); flex-shrink: 0; }
.ch-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .ch-name { color: var(--text-muted); }
.ch-page { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); letter-spacing: var(--tracking-wide); flex-shrink: 0; } .ch-page { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); letter-spacing: var(--tracking-wide); flex-shrink: 0; }
.ch-popover { .ch-popover {
@@ -178,6 +178,32 @@
aria-checked={store.settings.autoNextChapter ?? false} aria-checked={store.settings.autoNextChapter ?? false}
><span class="toggle-knob"></span></button> ><span class="toggle-knob"></span></button>
</label> </label>
<label class="toggle-row">
<span class="toggle-label">Auto scroll</span>
<button
class="toggle"
class:on={store.settings.autoScroll ?? false}
onclick={() => updateSettings({ autoScroll: !(store.settings.autoScroll ?? false) })}
role="switch"
aria-label="Auto scroll"
aria-checked={store.settings.autoScroll ?? false}
><span class="toggle-knob"></span></button>
</label>
{#if store.settings.autoScroll}
<div class="speed-row">
<span class="speed-label">Speed</span>
<input
type="range"
class="zoom-slider"
min={1}
max={30}
step={1}
value={store.settings.autoScrollSpeed ?? 5}
oninput={(e) => updateSettings({ autoScrollSpeed: Number(e.currentTarget.value) })}
/>
<span class="speed-val">{store.settings.autoScrollSpeed ?? 5}</span>
</div>
{/if}
{/if} {/if}
</section> </section>
@@ -760,4 +786,28 @@
padding: var(--sp-2) 0; padding: var(--sp-2) 0;
text-align: center; text-align: center;
} }
.speed-row {
display: flex;
align-items: center;
gap: var(--sp-2);
padding: var(--sp-1) 0;
}
.speed-label {
font-size: var(--text-xs);
color: var(--text-faint);
flex-shrink: 0;
min-width: 40px;
}
.speed-val {
font-family: var(--font-ui);
font-size: var(--text-xs);
color: var(--text-secondary);
letter-spacing: var(--tracking-wide);
min-width: 1.5ch;
text-align: right;
flex-shrink: 0;
}
</style> </style>
@@ -31,6 +31,25 @@
}: Props = $props(); }: Props = $props();
const isVertical = $derived(barPosition === "left" || barPosition === "right"); const isVertical = $derived(barPosition === "left" || barPosition === "right");
const hValue = $derived(rtl ? sliderMax - sliderPage + 1 : sliderPage);
const hPct = $derived(`--pct:${sliderPct}%`);
const vPct = $derived(`--pct:${sliderPct}%`);
function handleH(e: Event) {
const raw = Number((e.target as HTMLInputElement).value);
onJumpToPage(rtl ? sliderMax - raw + 1 : raw);
}
function handleV(e: Event) {
onJumpToPage(Number((e.target as HTMLInputElement).value));
}
function markerPct(pageNumber: number, forRtl = false): number {
if (sliderMax <= 1) return 0;
const ord = forRtl ? sliderMax - pageNumber + 1 : pageNumber;
return ((ord - 1) / (sliderMax - 1)) * 100;
}
</script> </script>
{#if !isVertical} {#if !isVertical}
@@ -43,44 +62,35 @@
{#if sliderMax > 1} {#if sliderMax > 1}
<div <div
class="slider-wrap" class="slider-wrap"
class:dragging={readerState.sliderDragging}
role="slider"
aria-valuenow={sliderPage}
aria-valuemin={1}
aria-valuemax={sliderMax}
tabindex="-1"
onmouseenter={() => readerState.sliderHover = true} onmouseenter={() => readerState.sliderHover = true}
onmouseleave={() => { readerState.sliderHover = false; readerState.sliderDragging = false; }} onmouseleave={() => readerState.sliderHover = false}
onmousedown={(e) => {
readerState.sliderDragging = true;
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
onJumpToPage(Math.round(1 + (rtl ? 1 - ratio : ratio) * (sliderMax - 1)));
}}
onmousemove={(e) => {
if (!readerState.sliderDragging) return;
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
onJumpToPage(Math.round(1 + (rtl ? 1 - ratio : ratio) * (sliderMax - 1)));
}}
onmouseup={() => readerState.sliderDragging = false}
> >
<div class="slider-track-bg"> <input
<div class="slider-fill" style={rtl ? `width:${100 - sliderPct}%;margin-left:auto` : `width:${sliderPct}%`}></div> type="range"
</div> class="h-range"
<div class="slider-thumb" style="left:{sliderPct}%"></div> style={hPct}
min={1}
max={sliderMax}
value={hValue}
oninput={handleH}
onmousedown={() => readerState.sliderDragging = true}
onmouseup={() => readerState.sliderDragging = false}
/>
<div class="slider-markers" aria-hidden="true">
{#if currentBookmark && currentBookmark.chapterId === displayChapter?.id} {#if currentBookmark && currentBookmark.chapterId === displayChapter?.id}
{@const bOrd = rtl ? sliderMax - currentBookmark.pageNumber + 1 : currentBookmark.pageNumber} <div class="slider-checkpoint bookmark-checkpoint"
{@const bPct = sliderMax > 1 ? ((bOrd - 1) / (sliderMax - 1)) * 100 : 0} style="left:{markerPct(currentBookmark.pageNumber, rtl)}%"
<div class="slider-checkpoint bookmark-checkpoint" style="left:{bPct}%" title="Bookmark: Page {currentBookmark.pageNumber}"></div> title="Bookmark: Page {currentBookmark.pageNumber}">
</div>
{/if} {/if}
{#each activeChapterMarkers as m (m.id)} {#each activeChapterMarkers as m (m.id)}
{@const mOrd = rtl ? sliderMax - m.pageNumber + 1 : m.pageNumber} <div class="slider-checkpoint marker-checkpoint"
{@const mPct = sliderMax > 1 ? ((mOrd - 1) / (sliderMax - 1)) * 100 : 0} style="left:{markerPct(m.pageNumber, rtl)}%;background:{MARKER_COLOR_HEX[m.color]}"
<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> title="{m.note ? m.note : 'Marker'} · Page {m.pageNumber}">
</div>
{/each} {/each}
</div>
{#if readerState.sliderHover || readerState.sliderDragging} {#if readerState.sliderHover || readerState.sliderDragging}
<div class="slider-tooltip" style="left:{sliderPct}%"> <div class="slider-tooltip" style="left:{sliderPct}%">
@@ -100,42 +110,37 @@
{#if sliderMax > 1} {#if sliderMax > 1}
<div <div
class="vslider-wrap" class="vslider-wrap"
class:dragging={readerState.sliderDragging}
role="slider"
aria-valuenow={sliderPage}
aria-valuemin={1}
aria-valuemax={sliderMax}
tabindex="-1"
onmouseenter={() => readerState.sliderHover = true} onmouseenter={() => readerState.sliderHover = true}
onmouseleave={() => { readerState.sliderHover = false; readerState.sliderDragging = false; }} onmouseleave={() => readerState.sliderHover = false}
onmousedown={(e) => {
readerState.sliderDragging = true;
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
const ratio = Math.max(0, Math.min(1, (e.clientY - rect.top) / rect.height));
onJumpToPage(Math.round(1 + ratio * (sliderMax - 1)));
}}
onmousemove={(e) => {
if (!readerState.sliderDragging) return;
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
const ratio = Math.max(0, Math.min(1, (e.clientY - rect.top) / rect.height));
onJumpToPage(Math.round(1 + ratio * (sliderMax - 1)));
}}
onmouseup={() => readerState.sliderDragging = false}
> >
<div class="vslider-track-bg"> <input
<div class="vslider-fill" style="height:{sliderPct}%"></div> type="range"
</div> class="v-range"
<div class="vslider-thumb" style="top:{sliderPct}%"></div> style={vPct}
min={1}
max={sliderMax}
value={sliderPage}
oninput={handleV}
onmousedown={() => readerState.sliderDragging = true}
onmouseup={() => readerState.sliderDragging = false}
/>
<div class="vslider-markers" aria-hidden="true">
{#if currentBookmark && currentBookmark.chapterId === displayChapter?.id} {#if currentBookmark && currentBookmark.chapterId === displayChapter?.id}
{@const bPct = sliderMax > 1 ? ((currentBookmark.pageNumber - 1) / (sliderMax - 1)) * 100 : 0} {@const bPct = sliderMax > 1 ? ((currentBookmark.pageNumber - 1) / (sliderMax - 1)) * 100 : 0}
<div class="vslider-checkpoint bookmark-checkpoint" style="top:{bPct}%" title="Bookmark: Page {currentBookmark.pageNumber}"></div> <div class="vslider-checkpoint bookmark-checkpoint"
style="top:{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 mPct = sliderMax > 1 ? ((m.pageNumber - 1) / (sliderMax - 1)) * 100 : 0}
<div class="vslider-checkpoint marker-checkpoint" style="top:{mPct}%;background:{MARKER_COLOR_HEX[m.color]}" title="{m.note ? m.note : 'Marker'} · Page {m.pageNumber}"></div> <div class="vslider-checkpoint marker-checkpoint"
style="top:{mPct}%;background:{MARKER_COLOR_HEX[m.color]}"
title="{m.note ? m.note : 'Marker'} · Page {m.pageNumber}">
</div>
{/each} {/each}
</div>
{#if readerState.sliderHover || readerState.sliderDragging} {#if readerState.sliderHover || readerState.sliderDragging}
<div class="vslider-tooltip" style="top:{sliderPct}%" class:tooltip-right={barPosition === "right"}> <div class="vslider-tooltip" style="top:{sliderPct}%" class:tooltip-right={barPosition === "right"}>
@@ -155,101 +160,99 @@
.nav-btn:hover:not(:disabled) { background: var(--bg-raised); color: var(--text-primary); } .nav-btn:hover:not(:disabled) { background: var(--bg-raised); color: var(--text-primary); }
.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; }
.slider-track-bg { position: absolute; left: 0; right: 0; height: 3px; background: var(--border-strong); border-radius: 2px; pointer-events: none; }
.slider-fill { height: 100%; background: var(--accent-fg); border-radius: 2px; transition: width 0.05s linear; position: relative; } .h-range {
.slider-checkpoint { position: absolute; top: 50%; width: 4px; height: 10px; border-radius: 2px; transform: translate(-50%, -50%); pointer-events: none; z-index: 1; } -webkit-appearance: none;
.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); } appearance: none;
.slider-wrap:hover .slider-thumb, .slider-wrap.dragging .slider-thumb { transform: translate(-50%, -50%) scale(1.3); } width: 100%;
height: 34px;
background: transparent;
cursor: pointer;
position: relative;
z-index: 2;
margin: 0;
padding: 0;
}
.h-range::-webkit-slider-runnable-track {
height: 3px;
background: linear-gradient(to right, var(--accent-fg) var(--pct, 0%), var(--border-strong) var(--pct, 0%));
border-radius: 2px;
transition: height 0.15s ease, background 0.05s linear;
}
.h-range:hover::-webkit-slider-runnable-track,
.h-range:active::-webkit-slider-runnable-track { height: 5px; }
.h-range::-moz-range-track { height: 3px; background: var(--border-strong); border-radius: 2px; transition: height 0.15s ease; }
.h-range::-moz-range-progress { height: 3px; background: var(--accent-fg); border-radius: 2px; transition: height 0.15s ease; }
.h-range:hover::-moz-range-track, .h-range:active::-moz-range-track { height: 5px; }
.h-range:hover::-moz-range-progress, .h-range:active::-moz-range-progress { height: 5px; }
.h-range::-webkit-slider-thumb {
-webkit-appearance: none;
width: 12px;
height: 12px;
border-radius: 50%;
background: var(--accent-fg);
box-shadow: 0 0 0 2px rgba(0,0,0,0.5);
margin-top: -4.5px;
transition: transform var(--t-fast);
}
.h-range:hover::-webkit-slider-thumb,
.h-range:active::-webkit-slider-thumb { transform: scale(1.3); }
.h-range::-moz-range-thumb {
width: 12px;
height: 12px;
border-radius: 50%;
background: var(--accent-fg);
box-shadow: 0 0 0 2px rgba(0,0,0,0.5);
border: none;
transition: transform var(--t-fast);
}
.h-range:hover::-moz-range-thumb,
.h-range:active::-moz-range-thumb { transform: scale(1.3); }
.slider-markers { position: absolute; inset: 0; pointer-events: none; z-index: 1; }
.slider-checkpoint { position: absolute; top: 50%; width: 4px; height: 10px; border-radius: 2px; transform: translate(-50%, -50%); }
.bookmark-checkpoint { background: #ffffff; opacity: 0.8; } .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; }
.vbar-progress { .vbar-progress { display: flex; flex-direction: column; align-items: center; justify-content: center; flex: 1; width: 100%; padding: var(--sp-2) 0; transition: opacity 0.25s ease; pointer-events: none; }
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex: 1;
width: 100%;
padding: var(--sp-2) 0;
transition: opacity 0.25s ease;
pointer-events: none;
}
.vbar-progress.hidden { opacity: 0; } .vbar-progress.hidden { opacity: 0; }
.vslider-wrap { .vslider-wrap { flex: 1; position: relative; display: flex; flex-direction: column; align-items: center; width: 36px; pointer-events: all; margin: var(--sp-1) 0; }
flex: 1;
position: relative; .v-range {
display: flex; -webkit-appearance: slider-vertical;
flex-direction: column; appearance: slider-vertical;
align-items: center; writing-mode: vertical-lr;
width: 36px; direction: rtl;
width: 34px;
height: 100%;
background: transparent;
cursor: pointer; cursor: pointer;
pointer-events: all; position: relative;
margin: var(--sp-1) 0; z-index: 2;
margin: 0;
padding: 0;
} }
.vslider-track-bg { .v-range::-webkit-slider-runnable-track { width: 5px; background: linear-gradient(to bottom, var(--accent-fg) var(--pct, 0%), var(--border-strong) var(--pct, 0%)); border-radius: 3px; transition: width 0.15s ease, background 0.05s linear; }
position: absolute; .v-range:hover::-webkit-slider-runnable-track,
top: 0; .v-range:active::-webkit-slider-runnable-track { width: 7px; }
bottom: 0; .v-range::-webkit-slider-thumb {
width: 5px; -webkit-appearance: none;
background: var(--border-strong);
border-radius: 3px;
pointer-events: none;
left: 50%;
translate: -50% 0;
}
.vslider-fill {
width: 100%;
background: var(--accent-fg);
border-radius: 3px;
transition: height 0.05s linear;
}
.vslider-thumb {
position: absolute;
left: 50%;
transform: translate(-50%, -50%);
width: 14px; width: 14px;
height: 14px; height: 14px;
border-radius: 50%; border-radius: 50%;
background: var(--accent-fg); background: var(--accent-fg);
pointer-events: none;
z-index: 2;
box-shadow: 0 0 0 2px rgba(0,0,0,0.5); box-shadow: 0 0 0 2px rgba(0,0,0,0.5);
margin-left: -4.5px;
transition: transform var(--t-fast); transition: transform var(--t-fast);
} }
.vslider-wrap:hover .vslider-thumb, .vslider-wrap.dragging .vslider-thumb { transform: translate(-50%, -50%) scale(1.3); } .v-range:hover::-webkit-slider-thumb,
.vslider-wrap:hover .vslider-track-bg, .vslider-wrap.dragging .vslider-track-bg { width: 7px; } .v-range:active::-webkit-slider-thumb { transform: scale(1.3); }
.vslider-checkpoint {
position: absolute; .vslider-markers { position: absolute; inset: 0; pointer-events: none; }
left: 50%; .vslider-checkpoint { position: absolute; left: 50%; transform: translate(-50%, -50%); width: 12px; height: 5px; border-radius: 2px; }
transform: translate(-50%, -50%); .vslider-tooltip { position: absolute; left: calc(100% + 6px); transform: translateY(-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); }
width: 12px; .vslider-tooltip.tooltip-right { left: auto; right: calc(100% + 6px); }
height: 5px;
border-radius: 2px;
pointer-events: none;
z-index: 1;
}
.vslider-tooltip {
position: absolute;
left: calc(100% + 6px);
transform: translateY(-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);
}
.vslider-tooltip.tooltip-right {
left: auto;
right: calc(100% + 6px);
}
</style> </style>
+8 -2
View File
@@ -1,7 +1,9 @@
import { store, openReader } from "@store/state.svelte"; import { store } from "@store/state.svelte";
import { readerState } from "../store/readerState.svelte"; import { readerState } from "../store/readerState.svelte";
import { fetchPages } from "./pageLoader"; import { fetchPages } from "./pageLoader";
import { trackingState } from "@features/tracking/store/trackingState.svelte"; import { trackingState } from "@features/tracking/store/trackingState.svelte";
import { cancelQueuedFetches } from "@core/cache/imageCache";
import { clearResolvedUrlCache } from "@core/cache/pageCache";
export function scheduleResumeDismiss() { export function scheduleResumeDismiss() {
setTimeout(() => { readerState.resumeFading = true; }, 1500); setTimeout(() => { readerState.resumeFading = true; }, 1500);
@@ -19,6 +21,10 @@ export async function loadChapter(
abortCtrl.current?.abort(); abortCtrl.current?.abort();
const ctrl = new AbortController(); const ctrl = new AbortController();
abortCtrl.current = ctrl; abortCtrl.current = ctrl;
cancelQueuedFetches();
if (useBlob) clearResolvedUrlCache();
startAtLastPage.current = false; startAtLastPage.current = false;
markedRead.clear(); markedRead.clear();
readerState.resetForChapter(); readerState.resetForChapter();
@@ -43,7 +49,7 @@ export async function loadChapter(
else if (resumeTo > 1) store.pageNumber = Math.min(resumeTo, urls.length || resumeTo); else if (resumeTo > 1) store.pageNumber = Math.min(resumeTo, urls.length || resumeTo);
readerState.pageReady = true; readerState.pageReady = true;
readerState.loading = false; readerState.loading = false;
if (adjacent.next) fetchPages(adjacent.next.id, useBlob).catch(() => {}); if (adjacent.next) fetchPages(adjacent.next.id, useBlob, ctrl.signal).catch(() => {});
} catch (e: any) { } catch (e: any) {
if (ctrl.signal.aborted) return; if (ctrl.signal.aborted) return;
readerState.error = e instanceof Error ? e.message : String(e); readerState.error = e instanceof Error ? e.message : String(e);
@@ -14,6 +14,7 @@ export interface ReaderKeyActions {
openSettings: () => void; openSettings: () => void;
toggleBookmark: () => void; toggleBookmark: () => void;
toggleMarker: () => void; toggleMarker: () => void;
toggleAutoScroll: () => void;
chapterNext: () => void; chapterNext: () => void;
chapterPrev: () => void; chapterPrev: () => void;
closePopovers: () => boolean; closePopovers: () => boolean;
@@ -55,5 +56,6 @@ export function createReaderKeyHandler(actions: ReaderKeyActions): (e: KeyboardE
else if (matchesKeybind(e, kb.openSettings)) { e.preventDefault(); actions.openSettings(); } else if (matchesKeybind(e, kb.openSettings)) { e.preventDefault(); actions.openSettings(); }
else if (matchesKeybind(e, kb.toggleBookmark)) { e.preventDefault(); actions.toggleBookmark(); } else if (matchesKeybind(e, kb.toggleBookmark)) { e.preventDefault(); actions.toggleBookmark(); }
else if (matchesKeybind(e, kb.toggleMarker)) { e.preventDefault(); actions.toggleMarker(); } else if (matchesKeybind(e, kb.toggleMarker)) { e.preventDefault(); actions.toggleMarker(); }
else if (matchesKeybind(e, kb.toggleAutoScroll)) { e.preventDefault(); actions.toggleAutoScroll(); }
}; };
} }
+22 -21
View File
@@ -25,57 +25,58 @@ export function setupScrollTracking(
onAppend, getStripChapters, getPageUrls, shouldAutoMark, onAppend, getStripChapters, getPageUrls, shouldAutoMark,
} = callbacks; } = callbacks;
function onScroll() { let rafId: number | null = null;
function tick() {
rafId = null;
const imgs = containerEl.querySelectorAll<HTMLElement>("img[data-local-page]"); const imgs = containerEl.querySelectorAll<HTMLElement>("img[data-local-page]");
if (!imgs.length) return; if (!imgs.length) return;
const containerTop = containerEl.getBoundingClientRect().top; const containerTop = containerEl.getBoundingClientRect().top;
const readLineY = containerTop + containerEl.clientHeight * READ_LINE_PCT; const readLineY = containerTop + containerEl.clientHeight * READ_LINE_PCT;
let activePage: number | null = null; let lo = 0, hi = imgs.length - 1, best = 0;
let activeChId: number | null = null; while (lo <= hi) {
const mid = (lo + hi) >>> 1;
for (const img of imgs) { if (imgs[mid].getBoundingClientRect().top <= readLineY) { best = mid; lo = mid + 1; }
if (img.getBoundingClientRect().top <= readLineY) { else hi = mid - 1;
activePage = Number(img.dataset.localPage);
activeChId = Number(img.dataset.chapter);
} else break;
} }
if (activePage === null) { const active = imgs[best];
activePage = Number(imgs[0].dataset.localPage); const activePage = Number(active.dataset.localPage);
activeChId = Number(imgs[0].dataset.chapter); const activeChId = Number(active.dataset.chapter);
}
if (activePage !== null) onPageChange(activePage); onPageChange(activePage);
if (activeChId) onChapterChange(activeChId); if (activeChId) onChapterChange(activeChId);
if (shouldAutoMark() && activePage !== null && activeChId) { if (shouldAutoMark() && activeChId) {
const chunks = getStripChapters(); const chunks = getStripChapters();
const chunk = chunks.find(c => c.chapterId === activeChId); const chunk = chunks.find(c => c.chapterId === activeChId);
const total = chunk ? chunk.urls.length : getPageUrls().length; const total = chunk ? chunk.urls.length : getPageUrls().length;
if (total > 0 && activePage >= total) onMarkRead(activeChId); if (total > 0 && activePage >= total) onMarkRead(activeChId);
}
const atBottom = containerEl.scrollTop + containerEl.clientHeight >= containerEl.scrollHeight - 40; const atBottom = containerEl.scrollTop + containerEl.clientHeight >= containerEl.scrollHeight - 40;
if (atBottom && shouldAutoMark()) { if (atBottom) {
const chunks = getStripChapters();
const last = chunks[chunks.length - 1]; const last = chunks[chunks.length - 1];
if (last) onMarkRead(last.chapterId); if (last) onMarkRead(last.chapterId);
} }
} }
function onScrollAppend() {
const pct = (containerEl.scrollTop + containerEl.clientHeight) / containerEl.scrollHeight; const pct = (containerEl.scrollTop + containerEl.clientHeight) / containerEl.scrollHeight;
if (pct >= 0.80) onAppend(); if (pct >= 0.80) onAppend();
} }
function onScroll() {
if (rafId !== null) return;
rafId = requestAnimationFrame(tick);
}
containerEl.addEventListener("scroll", onScroll, { passive: true }); containerEl.addEventListener("scroll", onScroll, { passive: true });
containerEl.addEventListener("scroll", onScrollAppend, { passive: true });
return () => { return () => {
containerEl.removeEventListener("scroll", onScroll); containerEl.removeEventListener("scroll", onScroll);
containerEl.removeEventListener("scroll", onScrollAppend); if (rafId !== null) cancelAnimationFrame(rafId);
}; };
} }
@@ -14,6 +14,7 @@
enqueueing: Set<number>; enqueueing: Set<number>;
chapterPage: number; chapterPage: number;
totalPages: number; totalPages: number;
scrollEl?: HTMLDivElement | null;
onOpen: (ch: Chapter, inProgress: boolean) => void; onOpen: (ch: Chapter, inProgress: boolean) => void;
onToggleSelect: (id: number, e: MouseEvent | KeyboardEvent) => void; onToggleSelect: (id: number, e: MouseEvent | KeyboardEvent) => void;
onEnqueue: (ch: Chapter, e: MouseEvent) => void; onEnqueue: (ch: Chapter, e: MouseEvent) => void;
@@ -25,6 +26,7 @@
let { let {
pageChapters, sortedChapters, viewMode, loadingChapters, pageChapters, sortedChapters, viewMode, loadingChapters,
selectedIds, enqueueing, chapterPage, totalPages, selectedIds, enqueueing, chapterPage, totalPages,
scrollEl = $bindable(null),
onOpen, onToggleSelect, onEnqueue, onDeleteDownload, onOpen, onToggleSelect, onEnqueue, onDeleteDownload,
onPageChange, buildCtxItems, onPageChange, buildCtxItems,
}: Props = $props(); }: Props = $props();
@@ -48,7 +50,7 @@
} }
</script> </script>
<div class={viewMode === "grid" ? "ch-grid" : "ch-list"}> <div class={viewMode === "grid" ? "ch-grid" : "ch-list"} bind:this={scrollEl}>
{#if loadingChapters && sortedChapters.length === 0} {#if loadingChapters && sortedChapters.length === 0}
{#if viewMode === "grid"} {#if viewMode === "grid"}
{#each Array(24) as _}<div class="grid-cell-skeleton skeleton"></div>{/each} {#each Array(24) as _}<div class="grid-cell-skeleton skeleton"></div>{/each}
@@ -17,6 +17,7 @@
addBookmark, acknowledgeUpdate, addBookmark, acknowledgeUpdate,
checkAndMarkCompleted as storeCheckAndMarkCompleted, checkAndMarkCompleted as storeCheckAndMarkCompleted,
clearMarkersForManga, clearMarkersForManga,
saveScroll, getScroll,
} from "@store/state.svelte"; } from "@store/state.svelte";
import { trackingState } from "@features/tracking/store/trackingState.svelte"; import { trackingState } from "@features/tracking/store/trackingState.svelte";
import type { MangaPrefs } from "@store/state.svelte"; import type { MangaPrefs } from "@store/state.svelte";
@@ -102,8 +103,8 @@
const totalPages = $derived(Math.ceil(sortedChapters.length / CHAPTERS_PER_PAGE)); const totalPages = $derived(Math.ceil(sortedChapters.length / CHAPTERS_PER_PAGE));
const pageChapters = $derived(sortedChapters.slice((chapterPage - 1) * CHAPTERS_PER_PAGE, chapterPage * CHAPTERS_PER_PAGE)); const pageChapters = $derived(sortedChapters.slice((chapterPage - 1) * CHAPTERS_PER_PAGE, chapterPage * CHAPTERS_PER_PAGE));
const readCount = $derived(chapters.filter(c => c.isRead).length); const readCount = $derived(sortedChapters.filter(c => c.isRead).length);
const totalCount = $derived(chapters.length); const totalCount = $derived(sortedChapters.length);
const progressPct = $derived(totalCount > 0 ? (readCount / totalCount) * 100 : 0); const progressPct = $derived(totalCount > 0 ? (readCount / totalCount) * 100 : 0);
const downloadedCount = $derived(chapters.filter(c => c.isDownloaded).length); const downloadedCount = $derived(chapters.filter(c => c.isDownloaded).length);
@@ -583,6 +584,20 @@
} catch (e) { console.error(e); } } catch (e) { console.error(e); }
} }
let chapterListEl: HTMLDivElement | null = $state(null);
let prevMangaId: number | null = null;
$effect(() => {
const mangaId = store.activeManga?.id ?? null;
if (mangaId === prevMangaId) return;
if (chapterListEl && prevMangaId !== null) saveScroll(`series:${prevMangaId}`, chapterListEl.scrollTop);
prevMangaId = mangaId;
if (chapterListEl && mangaId !== null) {
const saved = getScroll(`series:${mangaId}`);
chapterListEl.scrollTo({ top: saved });
}
});
onMount(() => () => { mangaAbort?.abort(); chapterAbort?.abort(); }); onMount(() => () => { mangaAbort?.abort(); chapterAbort?.abort(); });
</script> </script>
@@ -665,6 +680,7 @@
{enqueueing} {enqueueing}
{chapterPage} {chapterPage}
{totalPages} {totalPages}
bind:scrollEl={chapterListEl}
onOpen={openReaderWithAhead} onOpen={openReaderWithAhead}
onToggleSelect={toggleSelect} onToggleSelect={toggleSelect}
onEnqueue={enqueue} onEnqueue={enqueue}
@@ -689,7 +705,7 @@
{/if} {/if}
{#if autoOpen && store.activeManga} {#if autoOpen && store.activeManga}
<AutomationPanel mangaId={store.activeManga.id} onClose={() => autoOpen = false} /> <AutomationPanel mangaId={store.activeManga.id} manga={store.activeManga} onClose={() => autoOpen = false} />
{/if} {/if}
{#if markersOpen && store.activeManga} {#if markersOpen && store.activeManga}
@@ -55,6 +55,7 @@
let manageOpen: boolean = $state(false); let manageOpen: boolean = $state(false);
let genresExpanded: boolean = $state(false); let genresExpanded: boolean = $state(false);
let altOpen: boolean = $state(false);
const statusLabel = $derived( const statusLabel = $derived(
manga?.status ? manga.status.charAt(0) + manga.status.slice(1).toLowerCase() : null manga?.status ? manga.status.charAt(0) + manga.status.slice(1).toLowerCase() : null
@@ -68,7 +69,9 @@
!!store.settings.mangaPrefs?.[store.activeManga!.id]?.coverUrl !!store.settings.mangaPrefs?.[store.activeManga!.id]?.coverUrl
); );
function focusOnMount(node: HTMLElement) { node.focus(); } const altTitles = $derived(
(manga as any)?.alternativeTitles ?? (manga as any)?.altTitles ?? []
);
</script> </script>
<div class="sidebar"> <div class="sidebar">
@@ -76,9 +79,9 @@
<ArrowLeft size={13} weight="light" /> Back <ArrowLeft size={13} weight="light" /> Back
</button> </button>
<div class="cover-wrap"> <button class="cover-wrap" onclick={() => setPreviewManga(manga)}>
<Thumbnail src={resolvedCover(store.activeManga!.id, store.activeManga!.thumbnailUrl)} alt={store.activeManga!.title} class="cover" /> <Thumbnail src={resolvedCover(store.activeManga!.id, store.activeManga!.thumbnailUrl)} alt={store.activeManga!.title} class="cover" />
</div> </button>
{#if loadingManga} {#if loadingManga}
<div class="meta-skeleton"> <div class="meta-skeleton">
@@ -88,12 +91,36 @@
{:else} {:else}
<div class="meta"> <div class="meta">
<p class="title">{manga?.title}</p> <p class="title">{manga?.title}</p>
{#if manga?.author || manga?.artist} {#if manga?.author || manga?.artist}
<p class="byline">{[manga?.author, manga?.artist].filter(Boolean).filter((v, i, a) => a.indexOf(v) === i).join(" · ")}</p> <p class="byline">{[manga?.author, manga?.artist].filter(Boolean).filter((v, i, a) => a.indexOf(v) === i).join(" · ")}</p>
{/if} {/if}
<div class="badges">
{#if statusLabel} {#if statusLabel}
<span class="status-badge" class:ongoing={manga?.status === "ONGOING"} class:ended={manga?.status !== "ONGOING"}>{statusLabel}</span> <span class="badge" class:badge-ongoing={manga?.status === "ONGOING"} class:badge-ended={manga?.status !== "ONGOING"}>{statusLabel}</span>
{/if} {/if}
{#if manga?.source?.displayName ?? (manga as any)?.source?.name}
<span class="badge badge-source">{manga?.source?.displayName ?? (manga as any)?.source?.name}</span>
{/if}
</div>
{#if altTitles.length > 0}
<div class="alttitles-section">
<button class="row-toggle" onclick={() => altOpen = !altOpen}>
<span>Also known as</span>
<CaretDown size={10} weight="light" style="transform:{altOpen ? 'rotate(180deg)' : 'rotate(0)'};transition:transform 0.15s ease;flex-shrink:0" />
</button>
{#if altOpen}
<div class="alttitles-list">
{#each altTitles as t}
<p class="alttitle">{t}</p>
{/each}
</div>
{/if}
</div>
{/if}
{#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}
@@ -106,8 +133,12 @@
{/if} {/if}
</div> </div>
{/if} {/if}
{#if manga?.description} {#if manga?.description}
<div class="desc-wrap">
<p class="desc">{manga.description}</p> <p class="desc">{manga.description}</p>
<button class="expand-toggle" onclick={() => setPreviewManga(manga)}>Read more</button>
</div>
{/if} {/if}
</div> </div>
{/if} {/if}
@@ -197,11 +228,13 @@
padding: var(--sp-5); padding: var(--sp-5);
border-right: 1px solid var(--border-dim); border-right: 1px solid var(--border-dim);
overflow-y: auto; overflow-y: auto;
scrollbar-width: none;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--sp-4); gap: var(--sp-4);
background: var(--bg-base); background: var(--bg-base);
} }
.sidebar::-webkit-scrollbar { display: none; }
.back { .back {
display: flex; align-items: center; gap: var(--sp-2); display: flex; align-items: center; gap: var(--sp-2);
@@ -215,13 +248,17 @@
width: 100%; aspect-ratio: 2/3; border-radius: var(--radius-md); width: 100%; aspect-ratio: 2/3; border-radius: var(--radius-md);
overflow: hidden; background: var(--bg-raised); overflow: hidden; background: var(--bg-raised);
border: 1px solid var(--border-dim); flex-shrink: 0; border: 1px solid var(--border-dim); flex-shrink: 0;
cursor: pointer; transition: opacity var(--t-base);
padding: 0;
} }
.cover-wrap:hover { opacity: 0.88; }
:global(.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); }
.meta { display: flex; flex-direction: column; gap: var(--sp-3); } .meta { display: flex; flex-direction: column; gap: var(--sp-3); }
.title { .title {
font-size: var(--text-base); font-weight: var(--weight-medium); font-size: var(--text-base); font-weight: var(--weight-medium);
color: var(--text-primary); line-height: var(--leading-snug); color: var(--text-primary); line-height: var(--leading-snug);
@@ -229,13 +266,33 @@
} }
.byline { font-size: var(--text-xs); color: var(--text-muted); font-family: var(--font-ui); } .byline { font-size: var(--text-xs); color: var(--text-muted); font-family: var(--font-ui); }
.status-badge { .badges { display: flex; flex-wrap: wrap; gap: var(--sp-1); }
.badge {
display: inline-block; font-family: var(--font-ui); font-size: var(--text-2xs); display: inline-block; font-family: var(--font-ui); font-size: var(--text-2xs);
letter-spacing: var(--tracking-wider); text-transform: uppercase; letter-spacing: var(--tracking-wider); text-transform: uppercase;
padding: 2px 7px; border-radius: var(--radius-sm); width: fit-content; padding: 2px 7px; border-radius: var(--radius-sm); width: fit-content;
} }
.status-badge.ongoing { background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim); } .badge-ongoing { background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim); }
.status-badge.ended { background: var(--bg-raised); color: var(--text-faint); border: 1px solid var(--border-dim); } .badge-ended { background: var(--bg-raised); color: var(--text-faint); border: 1px solid var(--border-dim); }
.badge-source {
background: var(--bg-raised); color: var(--text-faint); border: 1px solid var(--border-dim);
text-transform: none; letter-spacing: var(--tracking-normal);
}
.alttitles-section { display: flex; flex-direction: column; gap: var(--sp-1); }
.row-toggle {
display: flex; align-items: center; justify-content: space-between; width: 100%;
font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint);
letter-spacing: var(--tracking-wide); padding: 2px 0;
transition: color var(--t-base);
}
.row-toggle:hover { color: var(--text-muted); }
.alttitles-list { display: flex; flex-direction: column; gap: 3px; padding-top: var(--sp-1); }
.alttitle {
font-size: var(--text-2xs); color: var(--text-faint); font-family: var(--font-ui);
line-height: var(--leading-snug); padding-left: var(--sp-1);
border-left: 1px solid var(--border-dim);
}
.genres { display: flex; flex-wrap: wrap; gap: var(--sp-1); } .genres { display: flex; flex-wrap: wrap; gap: var(--sp-1); }
.genre { .genre {
@@ -253,10 +310,17 @@
} }
.genre-toggle:hover { color: var(--accent-fg); border-color: var(--accent-dim); } .genre-toggle:hover { color: var(--accent-fg); border-color: var(--accent-dim); }
.desc-wrap { display: flex; flex-direction: column; gap: var(--sp-1); }
.desc { .desc {
font-size: var(--text-xs); color: var(--text-muted); line-height: var(--leading-base); font-size: var(--text-xs); color: var(--text-muted); line-height: var(--leading-base);
display: -webkit-box; -webkit-line-clamp: 4; -webkit-box-orient: vertical; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 4; -webkit-box-orient: vertical; overflow: hidden;
} }
.expand-toggle {
font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint);
letter-spacing: var(--tracking-wide); align-self: flex-start;
transition: color var(--t-base);
}
.expand-toggle:hover { color: var(--accent-fg); }
.cta-section { display: flex; flex-direction: column; gap: var(--sp-2); } .cta-section { display: flex; flex-direction: column; gap: var(--sp-2); }
.read-btn { .read-btn {
+110 -25
View File
@@ -1,10 +1,15 @@
<script lang="ts"> <script lang="ts">
import { X } from "phosphor-svelte"; import { X } from "phosphor-svelte";
import { getPref, setPref } from "../lib/mangaPrefs"; import { getPref, setPref } from "../lib/mangaPrefs";
import { store } from "@store/state.svelte";
import { resolvedCover } from "@core/cover/coverResolver";
import Thumbnail from "@shared/manga/Thumbnail.svelte";
import type { MangaPrefs } from "@store/state.svelte"; import type { MangaPrefs } from "@store/state.svelte";
import type { Manga } from "@types/index";
let { mangaId, onClose }: { let { mangaId, manga: mangaProp = null, onClose }: {
mangaId: number; mangaId: number;
manga?: Manga | null;
onClose: () => void; onClose: () => void;
} = $props(); } = $props();
@@ -35,9 +40,19 @@
{ value: "manual", label: "Manual" }, { value: "manual", label: "Manual" },
]; ];
const get = <K extends keyof MangaPrefs>(key: K) => getPref(mangaId, key); const defaults = $derived(store.settings.automationDefaults);
function get<K extends keyof MangaPrefs>(key: K): MangaPrefs[K] {
const pref = getPref(mangaId, key);
if (pref !== undefined) return pref;
return (defaults as MangaPrefs | undefined)?.[key] ?? getPref(mangaId, key);
}
const set = <K extends keyof MangaPrefs>(key: K, value: MangaPrefs[K]) => setPref(mangaId, key, value); const set = <K extends keyof MangaPrefs>(key: K, value: MangaPrefs[K]) => setPref(mangaId, key, value);
const manga = $derived(store.library?.find(m => m.id === mangaId) ?? mangaProp);
const coverSrc = $derived(manga ? resolvedCover(manga.id, manga.thumbnailUrl) : null);
function onBackdrop(e: MouseEvent) { function onBackdrop(e: MouseEvent) {
if (e.target === e.currentTarget) onClose(); if (e.target === e.currentTarget) onClose();
} }
@@ -46,15 +61,28 @@
<div class="backdrop" role="presentation" tabindex="-1" onmousedown={onBackdrop}> <div class="backdrop" role="presentation" tabindex="-1" onmousedown={onBackdrop}>
<div class="modal" role="dialog" aria-modal="true" aria-label="Automation"> <div class="modal" role="dialog" aria-modal="true" aria-label="Automation">
<div class="modal-header"> <div class="cover-col">
<div class="header-left"> {#if coverSrc}
<span class="modal-title">Automation</span> <div class="cover-wrap">
<span class="modal-subtitle">Per-series rules</span> <Thumbnail src={coverSrc} alt={manga?.title} class="cover" />
</div> </div>
<button class="close-btn" onclick={onClose} aria-label="Close"><X size={16} weight="light" /></button> {:else}
<div class="cover-placeholder"></div>
{/if}
</div> </div>
<div class="modal-body"> <div class="content">
<div class="content-header">
<div class="title-block">
<span class="title">{manga?.title ?? "Automation"}</span>
<span class="subtitle">Per-series rules</span>
</div>
<button class="close-btn" onclick={onClose} aria-label="Close">
<X size={16} weight="light" />
</button>
</div>
<div class="content-body">
<p class="section-label">Downloads</p> <p class="section-label">Downloads</p>
@@ -73,7 +101,7 @@
><span class="auto-toggle-thumb"></span></button> ><span class="auto-toggle-thumb"></span></button>
</div> </div>
<div class="auto-row"> <div class="auto-row auto-row-col">
<div class="auto-info"> <div class="auto-info">
<span class="auto-label">Download ahead</span> <span class="auto-label">Download ahead</span>
<span class="auto-desc">Pre-fetch chapters while reading</span> <span class="auto-desc">Pre-fetch chapters while reading</span>
@@ -89,7 +117,7 @@
</div> </div>
</div> </div>
<div class="auto-row"> <div class="auto-row auto-row-col">
<div class="auto-info"> <div class="auto-info">
<span class="auto-label">Max chapters to keep</span> <span class="auto-label">Max chapters to keep</span>
<span class="auto-desc">Delete oldest downloads when limit is exceeded</span> <span class="auto-desc">Delete oldest downloads when limit is exceeded</span>
@@ -158,7 +186,7 @@
><span class="auto-toggle-thumb"></span></button> ><span class="auto-toggle-thumb"></span></button>
</div> </div>
<div class="auto-row"> <div class="auto-row auto-row-col">
<div class="auto-info"> <div class="auto-info">
<span class="auto-label">Refresh interval</span> <span class="auto-label">Refresh interval</span>
<span class="auto-desc">How often to check for new chapters</span> <span class="auto-desc">How often to check for new chapters</span>
@@ -176,6 +204,8 @@
</div> </div>
</div> </div>
</div>
</div> </div>
<style> <style>
@@ -187,31 +217,84 @@
} }
.modal { .modal {
width: 420px; max-width: calc(100vw - var(--sp-6)); display: flex; flex-direction: row;
max-height: 80vh; width: 600px; max-width: calc(100vw - var(--sp-6));
display: flex; flex-direction: column; height: 480px; max-height: 85vh;
background: var(--bg-surface); border: 1px solid var(--border-base); background: var(--bg-surface); border: 1px solid var(--border-base);
border-radius: var(--radius-xl); overflow: hidden; border-radius: var(--radius-xl); overflow: hidden;
box-shadow: 0 0 0 1px var(--border-dim), 0 24px 64px rgba(0,0,0,0.6); box-shadow: 0 0 0 1px var(--border-dim), 0 24px 64px rgba(0,0,0,0.6);
animation: scaleIn 0.15s ease both; animation: scaleIn 0.15s ease both;
} }
.modal-header { .cover-col {
display: flex; align-items: center; justify-content: space-between; width: 200px; flex-shrink: 0;
padding: var(--sp-4) var(--sp-5); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; background: var(--bg-raised);
border-right: 1px solid var(--border-dim);
display: flex; flex-direction: column;
padding: var(--sp-4);
overflow: hidden;
}
.cover-wrap { position: relative; width: 100%; flex: 1; min-height: 0; }
:global(.cover) {
position: absolute; inset: 0;
width: 100%; height: 100%;
object-fit: cover; object-position: center top;
border-radius: var(--radius-md);
border: 1px solid var(--border-dim);
display: block;
}
.cover-placeholder {
position: absolute; inset: 0;
background: var(--bg-overlay);
border-radius: var(--radius-md);
}
.content {
flex: 1; min-width: 0;
display: flex; flex-direction: column;
overflow: hidden;
border-left: 1px solid var(--border-dim);
}
.content-header {
display: flex; align-items: flex-start; justify-content: space-between;
gap: var(--sp-4); padding: var(--sp-5) var(--sp-6) var(--sp-4);
border-bottom: 1px solid var(--border-dim);
flex-shrink: 0;
}
.title-block { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: var(--sp-1); }
.title {
font-size: var(--text-base); font-weight: var(--weight-medium);
color: var(--text-primary); letter-spacing: var(--tracking-tight);
line-height: var(--leading-tight);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.subtitle {
font-family: var(--font-ui); font-size: var(--text-xs);
color: var(--text-faint); letter-spacing: var(--tracking-wide);
}
.close-btn {
display: flex; align-items: center; justify-content: center;
width: 28px; height: 28px; flex-shrink: 0;
border-radius: var(--radius-sm); color: var(--text-faint);
background: none; border: none; cursor: pointer;
transition: color var(--t-base), background var(--t-base);
} }
.header-left { display: flex; flex-direction: column; gap: 2px; }
.modal-title { font-size: var(--text-base); font-weight: var(--weight-medium); color: var(--text-primary); letter-spacing: var(--tracking-tight); }
.modal-subtitle { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
.close-btn { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: var(--radius-sm); color: var(--text-faint); background: none; border: none; cursor: pointer; transition: color var(--t-base), background var(--t-base); flex-shrink: 0; }
.close-btn:hover { color: var(--text-muted); background: var(--bg-raised); } .close-btn:hover { color: var(--text-muted); background: var(--bg-raised); }
.modal-body { .content-body {
flex: 1; overflow-y: auto; scrollbar-width: none; flex: 1; overflow-y: auto; scrollbar-width: none;
display: flex; flex-direction: column; gap: var(--sp-3); display: flex; flex-direction: column; gap: var(--sp-3);
padding: var(--sp-4) var(--sp-5); padding: var(--sp-5) var(--sp-6);
} }
.modal-body::-webkit-scrollbar { display: none; } .content-body::-webkit-scrollbar { display: none; }
.section-label { .section-label {
font-family: var(--font-ui); font-size: var(--text-2xs); font-family: var(--font-ui); font-size: var(--text-2xs);
@@ -222,7 +305,9 @@
.divider { height: 1px; background: var(--border-dim); margin: var(--sp-1) 0; } .divider { height: 1px; background: var(--border-dim); margin: var(--sp-1) 0; }
.auto-row { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-3); } .auto-row { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-3); }
.auto-row-col { flex-direction: column; align-items: flex-start; gap: var(--sp-2); }
.auto-row-sub { padding-left: var(--sp-3); border-left: 2px solid var(--border-dim); } .auto-row-sub { padding-left: var(--sp-3); border-left: 2px solid var(--border-dim); }
.auto-info { display: flex; flex-direction: column; gap: 2px; flex: 1; min-width: 0; } .auto-info { display: flex; flex-direction: column; gap: 2px; flex: 1; min-width: 0; }
.auto-label { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); letter-spacing: var(--tracking-wide); } .auto-label { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); letter-spacing: var(--tracking-wide); }
.auto-desc { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); line-height: var(--leading-snug); } .auto-desc { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); line-height: var(--leading-snug); }
@@ -232,7 +317,7 @@
.auto-toggle-thumb { position: absolute; top: 1px; left: 1px; width: 12px; height: 12px; border-radius: 50%; background: var(--text-faint); transition: transform var(--t-base), background var(--t-base); } .auto-toggle-thumb { position: absolute; top: 1px; left: 1px; width: 12px; height: 12px; border-radius: 50%; background: var(--text-faint); transition: transform var(--t-base), background var(--t-base); }
.auto-toggle-on .auto-toggle-thumb { transform: translateX(12px); background: var(--bg-base); } .auto-toggle-on .auto-toggle-thumb { transform: translateX(12px); background: var(--bg-base); }
.auto-chip-group { display: flex; flex-direction: row; gap: 4px; flex-shrink: 0; flex-wrap: wrap; justify-content: flex-end; } .auto-chip-group { display: flex; flex-direction: row; gap: 4px; flex-wrap: wrap; }
.auto-chip { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 2px 7px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); cursor: pointer; white-space: nowrap; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); } .auto-chip { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 2px 7px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); cursor: pointer; white-space: nowrap; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
.auto-chip:hover { color: var(--text-muted); border-color: var(--border-strong); background: var(--bg-raised); } .auto-chip: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); }
+101 -92
View File
@@ -3,13 +3,13 @@
import { untrack } from "svelte"; import { untrack } from "svelte";
import { gql } from "@api/client"; import { gql } from "@api/client";
import Thumbnail from "@shared/manga/Thumbnail.svelte"; import Thumbnail from "@shared/manga/Thumbnail.svelte";
import { resolvedCover } from "@core/cover/coverResolver";
import { GET_SOURCES } from "@api/queries/extensions"; import { GET_SOURCES } from "@api/queries/extensions";
import { UPDATE_MANGA } from "@api/mutations/manga"; import { UPDATE_MANGA } from "@api/mutations/manga";
import { FETCH_SOURCE_MANGA } from "@api/mutations/downloads"; import { FETCH_SOURCE_MANGA } from "@api/mutations/downloads";
import { FETCH_CHAPTERS, UPDATE_CHAPTERS_PROGRESS } from "@api/mutations/chapters"; import { FETCH_CHAPTERS, UPDATE_CHAPTERS_PROGRESS } from "@api/mutations/chapters";
import { store } from "@store/state.svelte"; import { store } from "@store/state.svelte";
import type { Manga, Chapter } from "@types"; import type { Manga, Chapter, Source } from "@types";
import type { Source } from "@types";
interface Props { interface Props {
manga: Manga; manga: Manga;
@@ -20,6 +20,7 @@
let { manga, currentChapters, onClose, onMigrated }: Props = $props(); let { manga, currentChapters, onClose, onMigrated }: Props = $props();
type Step = "source" | "search" | "confirm"; type Step = "source" | "search" | "confirm";
const STEPS = ["source", "search", "confirm"] as const satisfies Step[];
interface Match { interface Match {
manga: Manga; manga: Manga;
@@ -39,16 +40,15 @@
} }
function onKey(e: KeyboardEvent) { if (e.key === "Escape") onClose(); } function onKey(e: KeyboardEvent) { if (e.key === "Escape") onClose(); }
function focusOnMount(node: HTMLElement) { node.focus(); }
let step: Step = $state("source"); let step: Step = $state("source");
let sources: Source[] = $state([]); let sources: Source[] = $state([]);
let loadingSources = $state(true); let loadingSources = $state(true);
let selectedSource: Source | null = $state(null); let selectedSource: Source | null = $state(null);
let selectedLang = $state("all");
let selectedLang: string = $state("all");
let langStripEl: HTMLDivElement | undefined = $state(); let langStripEl: HTMLDivElement | undefined = $state();
const stepIdx = $derived(STEPS.indexOf(step));
const availableLangs = $derived.by(() => { const availableLangs = $derived.by(() => {
const langs = Array.from(new Set<string>(sources.map(s => s.lang))).sort(); const langs = Array.from(new Set<string>(sources.map(s => s.lang))).sort();
const en = langs.indexOf("en"); const en = langs.indexOf("en");
@@ -56,20 +56,6 @@
return langs; return langs;
}); });
const hasMultipleLangs = $derived(availableLangs.length > 1); const hasMultipleLangs = $derived(availableLangs.length > 1);
function scrollLangStrip(dir: -1 | 1) {
if (!langStripEl) return;
const chips = Array.from(langStripEl.children) as HTMLElement[];
const viewEnd = langStripEl.scrollLeft + langStripEl.clientWidth;
if (dir === 1) {
const next = chips.find(c => c.offsetLeft + c.offsetWidth > viewEnd + 2);
if (next) langStripEl.scrollTo({ left: next.offsetLeft, behavior: "smooth" });
} else {
const prev = [...chips].reverse().find(c => c.offsetLeft < langStripEl!.scrollLeft - 2);
if (prev) langStripEl.scrollTo({ left: prev.offsetLeft + prev.offsetWidth - langStripEl.clientWidth, behavior: "smooth" });
}
}
const visibleSources = $derived.by(() => { const visibleSources = $derived.by(() => {
if (selectedLang !== "all") return sources.filter(s => s.lang === selectedLang); if (selectedLang !== "all") return sources.filter(s => s.lang === selectedLang);
const map = new Map<string, Source>(); const map = new Map<string, Source>();
@@ -91,8 +77,19 @@
const readCount = $derived(currentChapters.filter(c => c.isRead).length); const readCount = $derived(currentChapters.filter(c => c.isRead).length);
const totalCount = $derived(currentChapters.length); const totalCount = $derived(currentChapters.length);
const chapterDiff = $derived(selectedMatch ? selectedMatch.chapters.length - totalCount : 0); const chapterDiff = $derived(selectedMatch ? selectedMatch.chapters.length - totalCount : 0);
const STEPS = ["source", "search", "confirm"] as const satisfies Step[];
const stepIdx = $derived(STEPS.indexOf(step)); function scrollLangStrip(dir: -1 | 1) {
if (!langStripEl) return;
const chips = Array.from(langStripEl.children) as HTMLElement[];
const viewEnd = langStripEl.scrollLeft + langStripEl.clientWidth;
if (dir === 1) {
const next = chips.find(c => c.offsetLeft + c.offsetWidth > viewEnd + 2);
if (next) langStripEl.scrollTo({ left: next.offsetLeft, behavior: "smooth" });
} else {
const prev = [...chips].reverse().find(c => c.offsetLeft < langStripEl!.scrollLeft - 2);
if (prev) langStripEl.scrollTo({ left: prev.offsetLeft + prev.offsetWidth - langStripEl.clientWidth, behavior: "smooth" });
}
}
$effect(() => { $effect(() => {
gql<{ sources: { nodes: Source[] } }>(GET_SOURCES) gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
@@ -171,10 +168,8 @@
progressUpdates.push({ id: nc.id, lastPageRead: old.lastPageRead! }); progressUpdates.push({ id: nc.id, lastPageRead: old.lastPageRead! });
} }
if (toMarkRead.length) if (toMarkRead.length) await gql(UPDATE_CHAPTERS_PROGRESS, { ids: toMarkRead, isRead: true });
await gql(UPDATE_CHAPTERS_PROGRESS, { ids: toMarkRead, isRead: true }); if (toMarkBookmarked.length) await gql(UPDATE_CHAPTERS_PROGRESS, { ids: toMarkBookmarked, isBookmarked: true });
if (toMarkBookmarked.length)
await gql(UPDATE_CHAPTERS_PROGRESS, { ids: toMarkBookmarked, isBookmarked: true });
for (const { id, lastPageRead } of progressUpdates) for (const { id, lastPageRead } of progressUpdates)
await gql(UPDATE_CHAPTERS_PROGRESS, { ids: [id], lastPageRead }); await gql(UPDATE_CHAPTERS_PROGRESS, { ids: [id], lastPageRead });
@@ -189,15 +184,22 @@
} }
</script> </script>
<!-- svelte-ignore a11y_click_events_have_key_events --> <!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="overlay" onclick={(e) => { if (e.target === e.currentTarget) onClose(); }}> <div class="overlay" onclick={(e) => { if (e.target === e.currentTarget) onClose(); }}>
<div class="modal"> <div class="modal">
<div class="modal-header"> <div class="modal-header">
<div class="modal-title"> <div class="manga-context">
<span class="modal-title-label">Migrate source</span> <div class="manga-context-cover">
<span class="modal-title-manga">{manga.title}</span> <Thumbnail src={resolvedCover(manga.id, manga.thumbnailUrl)} alt={manga.title} class="ctx-cover" />
</div>
<div class="manga-context-info">
<span class="modal-eyebrow">Migrate source</span>
<span class="modal-title">{manga.title}</span>
{#if manga.source?.displayName}
<span class="modal-source">{manga.source.displayName}</span>
{/if}
</div>
</div> </div>
<button class="close-btn" onclick={onClose}><X size={14} weight="light" /></button> <button class="close-btn" onclick={onClose}><X size={14} weight="light" /></button>
</div> </div>
@@ -209,7 +211,7 @@
{#if i < stepIdx}<Check size={9} weight="bold" />{:else}{i + 1}{/if} {#if i < stepIdx}<Check size={9} weight="bold" />{:else}{i + 1}{/if}
</span> </span>
<span class="step-label"> <span class="step-label">
{st === "source" ? "Pick source" : st === "search" ? (selectedSource ? selectedSource.displayName : "Search") : "Confirm"} {st === "source" ? "Pick source" : st === "search" ? (selectedSource?.displayName ?? "Search") : "Confirm"}
</span> </span>
</div> </div>
{/each} {/each}
@@ -241,11 +243,10 @@
{/if} {/if}
<div class="source-list"> <div class="source-list">
{#each visibleSources as src} {#each visibleSources as src}
<button <button class="source-row" class:source-row-active={selectedSource?.id === src.id} onclick={() => pickSource(src)}>
class="source-row" <div class="source-icon-wrap">
class:source-row-active={selectedSource?.id === src.id}
onclick={() => pickSource(src)}>
<Thumbnail src={src.iconUrl} alt={src.name} class="source-icon" onerror={(e) => (e.target as HTMLImageElement).style.display = "none"} /> <Thumbnail src={src.iconUrl} alt={src.name} class="source-icon" onerror={(e) => (e.target as HTMLImageElement).style.display = "none"} />
</div>
<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>
@@ -260,7 +261,9 @@
<div class="search-step"> <div class="search-step">
{#if selectedSource} {#if selectedSource}
<div class="search-context"> <div class="search-context">
<Thumbnail src={selectedSource.iconUrl} alt="" class="search-context-icon" onerror={(e) => (e.target as HTMLImageElement).style.display = "none"} /> <div class="source-icon-wrap" style="width:20px;height:20px;border-radius:var(--radius-sm)">
<Thumbnail src={selectedSource.iconUrl} alt={selectedSource.name} class="search-context-icon" onerror={(e) => (e.target as HTMLImageElement).style.display = "none"} />
</div>
<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>
@@ -274,7 +277,7 @@
bind:value={query} bind:value={query}
onkeydown={(e) => e.key === "Enter" && selectedSource && searchSource(selectedSource, query)} onkeydown={(e) => e.key === "Enter" && selectedSource && searchSource(selectedSource, query)}
placeholder="Search title…" placeholder="Search title…"
use:focusOnMount /> autofocus />
</div> </div>
<button class="search-btn" <button class="search-btn"
onclick={() => selectedSource && searchSource(selectedSource, query)} onclick={() => selectedSource && searchSource(selectedSource, query)}
@@ -291,22 +294,20 @@
<div class="results"> <div class="results">
{#if searching} {#if searching}
{#each Array(6) as _} {#each Array(5) as _}
<div class="sk-result"> <div class="sk-result">
<div class="skeleton sk-cover"></div> <div class="skeleton sk-cover"></div>
<div class="sk-meta"> <div class="sk-meta">
<div class="skeleton sk-title"></div> <div class="skeleton sk-line" style="width:60%"></div>
<div class="skeleton sk-title" style="width:40%"></div> <div class="skeleton sk-line" style="width:35%"></div>
</div> </div>
</div> </div>
{/each} {/each}
{:else} {:else}
{#each results as { manga: m, similarity }, idx} {#each results as { manga: m, similarity }, idx}
<button class="result-row" <button class="result-row" onclick={() => selectMatch(m, similarity)} disabled={loadingMatchId !== null}>
onclick={() => selectMatch(m, similarity)}
disabled={loadingMatchId !== null}>
<div class="result-cover-wrap"> <div class="result-cover-wrap">
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="result-cover" /> <Thumbnail src={resolvedCover(m.id, 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>
@@ -315,17 +316,17 @@
<span class="best-match-badge"><Sparkle size={9} weight="fill" /> Best match</span> <span class="best-match-badge"><Sparkle size={9} weight="fill" /> Best match</span>
{/if} {/if}
<span class="sim-bar"><span class="sim-fill" style="width:{Math.round(similarity * 100)}%"></span></span> <span class="sim-bar"><span class="sim-fill" style="width:{Math.round(similarity * 100)}%"></span></span>
<span class="sim-label">{Math.round(similarity * 100)}% match</span> <span class="sim-label">{Math.round(similarity * 100)}%</span>
</div> </div>
</div> </div>
{#if loadingMatchId === m.id} {#if loadingMatchId === m.id}
<CircleNotch size={13} weight="light" class="anim-spin" style="color:var(--text-faint);flex-shrink:0" /> <CircleNotch size={13} weight="light" class="anim-spin" style="color:var(--text-faint);flex-shrink:0" />
{:else} {:else}
<ArrowRight size={13} weight="light" style="color:var(--text-faint);flex-shrink:0;opacity:0.5" /> <ArrowRight size={13} weight="light" style="color:var(--text-faint);flex-shrink:0;opacity:0.4" />
{/if} {/if}
</button> </button>
{/each} {/each}
{#if results.length === 0 && !error} {#if results.length === 0 && !error && !searching}
<div class="centered"> <div class="centered">
<span class="hint">{query ? "No results — try a different title." : "Enter a title to search."}</span> <span class="hint">{query ? "No results — try a different title." : "Enter a title to search."}</span>
</div> </div>
@@ -339,18 +340,18 @@
<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">
<Thumbnail src={manga.thumbnailUrl} alt={manga.title} class="confirm-cover" /> <Thumbnail src={resolvedCover(manga.id, 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>
<span class="confirm-tag">Current</span> <span class="confirm-tag">Current</span>
</div> </div>
<div class="confirm-divider"> <div class="confirm-arrow-wrap">
<ArrowRight size={16} weight="light" class="confirm-arrow" /> <ArrowRight size={18} weight="light" style="color:var(--text-faint)" />
</div> </div>
<div class="confirm-manga"> <div class="confirm-manga">
<div class="confirm-cover-wrap"> <div class="confirm-cover-wrap">
<Thumbnail src={selectedMatch.manga.thumbnailUrl} alt={selectedMatch.manga.title} class="confirm-cover" /> <Thumbnail src={resolvedCover(selectedMatch.manga.id, 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>
@@ -378,8 +379,8 @@
</span> </span>
</div> </div>
<div class="stat-row"> <div class="stat-row">
<span class="stat-label">Read progress to carry over</span> <span class="stat-label">Read progress to carry</span>
<span class="stat-val">{selectedMatch.readCount} / {readCount} chapters</span> <span class="stat-val">{selectedMatch.readCount} / {readCount}</span>
</div> </div>
</div> </div>
@@ -413,54 +414,60 @@
<style> <style>
.overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.6); display: flex; align-items: center; justify-content: center; z-index: 200; animation: fadeIn 0.1s ease both; } .overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.6); display: flex; align-items: center; justify-content: center; z-index: 200; animation: fadeIn 0.1s ease both; }
.modal { background: var(--bg-base); border: 1px solid var(--border-base); border-radius: var(--radius-xl); width: 520px; max-height: 80vh; display: flex; flex-direction: column; overflow: hidden; box-shadow: 0 8px 40px rgba(0,0,0,0.5); } .modal { background: var(--bg-base); border: 1px solid var(--border-base); border-radius: var(--radius-xl); width: 520px; max-height: 82vh; display: flex; flex-direction: column; overflow: hidden; box-shadow: 0 8px 40px rgba(0,0,0,0.5); }
.modal-header { display: flex; align-items: flex-start; justify-content: space-between; padding: var(--sp-4) var(--sp-5); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
.modal-title { display: flex; flex-direction: column; gap: 2px; } .modal-header { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--sp-3); padding: var(--sp-4) var(--sp-5); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
.modal-title-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; } .manga-context { display: flex; align-items: center; gap: var(--sp-3); min-width: 0; }
.modal-title-manga { font-size: var(--text-base); font-weight: var(--weight-medium); color: var(--text-primary); letter-spacing: var(--tracking-tight); } .manga-context-cover { width: 36px; height: 54px; border-radius: var(--radius-sm); overflow: hidden; flex-shrink: 0; background: var(--bg-raised); border: 1px solid var(--border-dim); }
.close-btn { display: flex; align-items: center; justify-content: center; width: 24px; height: 24px; border-radius: var(--radius-md); color: var(--text-faint); background: none; border: none; cursor: pointer; transition: color var(--t-base), background var(--t-base); flex-shrink: 0; } :global(.ctx-cover) { width: 100%; height: 100%; object-fit: cover; }
.manga-context-info { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.modal-eyebrow { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; }
.modal-title { font-size: var(--text-base); font-weight: var(--weight-medium); color: var(--text-primary); letter-spacing: var(--tracking-tight); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.modal-source { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
.close-btn { display: flex; align-items: center; justify-content: center; width: 24px; height: 24px; border-radius: var(--radius-md); color: var(--text-faint); background: none; border: none; cursor: pointer; transition: color var(--t-base), background var(--t-base); flex-shrink: 0; margin-top: 2px; }
.close-btn:hover { color: var(--text-muted); background: var(--bg-raised); } .close-btn:hover { color: var(--text-muted); background: var(--bg-raised); }
.steps { display: flex; align-items: center; gap: var(--sp-1); padding: var(--sp-3) var(--sp-5); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; } .steps { display: flex; align-items: center; gap: var(--sp-1); padding: var(--sp-3) var(--sp-5); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
.step { display: flex; align-items: center; gap: var(--sp-2); opacity: 0.4; transition: opacity var(--t-base); } .step { display: flex; align-items: center; gap: var(--sp-2); opacity: 0.35; transition: opacity var(--t-base); }
.step + .step::before { content: ""; color: var(--text-faint); margin-right: var(--sp-1); font-size: var(--text-sm); } .step + .step::before { content: ""; color: var(--text-faint); margin-right: var(--sp-1); font-size: var(--text-sm); opacity: 0.5; }
.step-active { opacity: 1; } .step-active { opacity: 1; }
.step-done { opacity: 0.6; } .step-done { opacity: 0.55; }
.step-dot { width: 18px; height: 18px; border-radius: 50%; background: var(--bg-raised); border: 1px solid var(--border-base); display: flex; align-items: center; justify-content: center; font-family: var(--font-ui); font-size: 10px; color: var(--text-faint); flex-shrink: 0; } .step-dot { width: 18px; height: 18px; border-radius: 50%; background: var(--bg-raised); border: 1px solid var(--border-base); display: flex; align-items: center; justify-content: center; font-family: var(--font-ui); font-size: 10px; color: var(--text-faint); flex-shrink: 0; }
.step-active .step-dot { background: var(--accent-muted); border-color: var(--accent-dim); color: var(--accent-fg); } .step-active .step-dot { background: var(--accent-muted); border-color: var(--accent-dim); color: var(--accent-fg); }
.step-label { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--text-muted); } .step-label { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--text-muted); }
.step-active .step-label { color: var(--text-secondary); } .step-active .step-label { color: var(--text-secondary); }
.body { flex: 1; overflow: hidden; display: flex; flex-direction: column; } .body { flex: 1; overflow: hidden; display: flex; flex-direction: column; min-height: 0; }
.centered { flex: 1; display: flex; align-items: center; justify-content: center; padding: var(--sp-8); } .centered { flex: 1; display: flex; align-items: center; justify-content: center; padding: var(--sp-8); }
.hint { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); } .hint { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
.src-lang-bar { display: flex; align-items: center; gap: var(--sp-1); padding: var(--sp-2); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
.src-lang-nav { display: flex; align-items: center; justify-content: center; width: 22px; height: 22px; flex-shrink: 0; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); font-size: 15px; line-height: 1; cursor: pointer; transition: color var(--t-base), background var(--t-base); }
.src-lang-nav:hover { color: var(--text-muted); background: var(--bg-raised); }
.src-lang-chips { display: flex; align-items: center; gap: var(--sp-1); flex: 1; min-width: 0; overflow-x: auto; scrollbar-width: none; }
.src-lang-chips::-webkit-scrollbar { display: none; }
.src-lang-chip { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 3px 8px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); cursor: pointer; white-space: nowrap; flex-shrink: 0; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
.src-lang-chip:hover { color: var(--text-muted); background: var(--bg-raised); }
.src-lang-chip-active { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
.source-list { flex: 1; overflow-y: auto; padding: var(--sp-2); display: flex; flex-direction: column; gap: 1px; } .source-list { flex: 1; overflow-y: auto; padding: var(--sp-2); display: flex; flex-direction: column; gap: 1px; }
.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: 8px 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); }
:global(.source-icon) { width: 28px; height: 28px; border-radius: var(--radius-md); object-fit: cover; flex-shrink: 0; background: var(--bg-raised); } .source-icon-wrap { width: 28px; height: 28px; border-radius: var(--radius-md); overflow: hidden; flex-shrink: 0; background: var(--bg-raised); }
:global(.source-icon) { width: 100%; height: 100%; object-fit: cover; }
.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); }
:global(.source-arrow) { color: var(--text-faint); opacity: 0; transition: opacity var(--t-base); } :global(.source-arrow) { color: var(--text-faint); opacity: 0; transition: opacity var(--t-base); flex-shrink: 0; }
.source-row:hover :global(.source-arrow) { opacity: 1; } .source-row:hover :global(.source-arrow) { opacity: 1; }
.src-lang-bar { display: flex; align-items: center; gap: var(--sp-1); padding: var(--sp-2); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
.src-lang-nav { display: flex; align-items: center; justify-content: center; width: 22px; height: 22px; flex-shrink: 0; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); font-size: 15px; line-height: 1; cursor: pointer; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
.src-lang-nav:hover { color: var(--text-muted); border-color: var(--border-strong); background: var(--bg-raised); }
.src-lang-chips { display: flex; align-items: center; gap: var(--sp-1); flex: 1; min-width: 0; overflow-x: auto; scrollbar-width: none; scroll-behavior: smooth; }
.src-lang-chips::-webkit-scrollbar { display: none; }
.src-lang-chip { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 3px 8px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); cursor: pointer; white-space: nowrap; flex-shrink: 0; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
.src-lang-chip:hover { color: var(--text-muted); border-color: var(--border-strong); background: var(--bg-raised); }
.src-lang-chip-active { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
.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: 6px var(--sp-3); background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); flex-shrink: 0; }
:global(.search-context-icon) { width: 18px; height: 18px; border-radius: var(--radius-sm); object-fit: cover; flex-shrink: 0; } :global(.search-context-icon) { width: 100%; height: 100%; object-fit: cover; }
.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; opacity: 0.8; transition: opacity var(--t-base); }
.search-context-change:hover { opacity: 0.75; } .search-context-change:hover { opacity: 1; }
.search-row { display: flex; align-items: center; gap: var(--sp-2); flex-shrink: 0; } .search-row { display: flex; align-items: center; gap: var(--sp-2); flex-shrink: 0; }
.search-bar { flex: 1; display: flex; align-items: center; gap: var(--sp-2); background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 0 var(--sp-3) 0 var(--sp-2); transition: border-color var(--t-base); } .search-bar { flex: 1; display: flex; align-items: center; gap: var(--sp-2); background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 0 var(--sp-3) 0 var(--sp-2); transition: border-color var(--t-base); }
.search-bar:focus-within { border-color: var(--border-strong); } .search-bar:focus-within { border-color: var(--border-strong); }
@@ -470,8 +477,9 @@
.search-btn { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 6px 12px; border-radius: var(--radius-md); background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim); cursor: pointer; flex-shrink: 0; display: flex; align-items: center; gap: var(--sp-1); transition: filter var(--t-base); } .search-btn { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 6px 12px; border-radius: var(--radius-md); background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim); cursor: pointer; flex-shrink: 0; display: flex; align-items: center; gap: var(--sp-1); transition: filter var(--t-base); }
.search-btn:hover:not(:disabled) { filter: brightness(1.1); } .search-btn:hover:not(:disabled) { filter: brightness(1.1); }
.search-btn:disabled { opacity: 0.4; cursor: default; } .search-btn:disabled { opacity: 0.4; cursor: default; }
.results { flex: 1; overflow-y: auto; display: flex; flex-direction: column; gap: 1px; } .results { flex: 1; overflow-y: auto; display: flex; flex-direction: column; gap: 1px; }
.result-row { display: flex; align-items: center; gap: var(--sp-3); padding: 7px var(--sp-2); border-radius: var(--radius-md); border: none; background: none; text-align: left; width: 100%; cursor: pointer; transition: background var(--t-fast); } .result-row { display: flex; align-items: center; gap: var(--sp-3); padding: 6px var(--sp-2); border-radius: var(--radius-md); border: none; background: none; text-align: left; width: 100%; cursor: pointer; transition: background var(--t-fast); }
.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; }
@@ -480,26 +488,26 @@
.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); }
.best-match-badge { display: inline-flex; align-items: center; gap: 3px; font-family: var(--font-ui); font-size: 9px; letter-spacing: var(--tracking-wide); text-transform: uppercase; color: var(--accent-fg); background: var(--accent-muted); border: 1px solid var(--accent-dim); padding: 1px 5px; border-radius: var(--radius-sm); flex-shrink: 0; } .best-match-badge { display: inline-flex; align-items: center; gap: 3px; font-family: var(--font-ui); font-size: 9px; letter-spacing: var(--tracking-wide); text-transform: uppercase; color: var(--accent-fg); background: var(--accent-muted); border: 1px solid var(--accent-dim); padding: 1px 5px; border-radius: var(--radius-sm); flex-shrink: 0; }
.sim-bar { width: 48px; height: 3px; background: var(--bg-overlay); border-radius: var(--radius-full); overflow: hidden; flex-shrink: 0; display: inline-block; } .sim-bar { width: 40px; height: 3px; background: var(--bg-overlay); border-radius: var(--radius-full); overflow: hidden; flex-shrink: 0; }
.sim-fill { display: block; height: 100%; background: var(--accent); border-radius: var(--radius-full); transition: width 0.2s ease; } .sim-fill { display: block; height: 100%; background: var(--accent); border-radius: var(--radius-full); }
.sim-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); white-space: nowrap; } .sim-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); white-space: nowrap; }
.sk-result { display: flex; align-items: center; gap: var(--sp-3); padding: 7px var(--sp-2); } .sk-result { display: flex; align-items: center; gap: var(--sp-3); padding: 6px var(--sp-2); }
.sk-cover { width: 36px; height: 54px; border-radius: var(--radius-sm); flex-shrink: 0; } .sk-cover { width: 36px; height: 54px; border-radius: var(--radius-sm); flex-shrink: 0; }
.sk-meta { flex: 1; display: flex; flex-direction: column; gap: var(--sp-2); } .sk-meta { flex: 1; display: flex; flex-direction: column; gap: var(--sp-2); }
.sk-title { height: 13px; width: 65%; border-radius: var(--radius-sm); } .sk-line { height: 12px; border-radius: var(--radius-sm); }
.confirm-step { flex: 1; overflow-y: auto; display: flex; flex-direction: column; gap: var(--sp-4); padding: var(--sp-4) var(--sp-5); } .confirm-step { flex: 1; overflow-y: auto; display: flex; flex-direction: column; gap: var(--sp-4); padding: var(--sp-4) var(--sp-5); }
.confirm-row { display: flex; align-items: center; justify-content: center; gap: var(--sp-4); } .confirm-row { display: flex; align-items: flex-start; justify-content: center; gap: var(--sp-3); }
.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: 150px; }
.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); }
:global(.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; }
:global(.confirm-arrow) { color: var(--text-faint); }
.confirm-tag { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); text-transform: uppercase; padding: 2px 7px; border-radius: var(--radius-full); background: var(--bg-raised); border: 1px solid var(--border-dim); color: var(--text-faint); } .confirm-tag { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); text-transform: uppercase; padding: 2px 7px; border-radius: var(--radius-full); background: var(--bg-raised); border: 1px solid var(--border-dim); color: var(--text-faint); }
.confirm-tag-new { background: var(--accent-muted); border-color: var(--accent-dim); color: var(--accent-fg); } .confirm-tag-new { background: var(--accent-muted); border-color: var(--accent-dim); color: var(--accent-fg); }
.confirm-arrow-wrap { display: flex; align-items: center; padding-top: 48px; flex-shrink: 0; }
.confirm-stats { display: flex; flex-direction: column; gap: var(--sp-2); background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: var(--sp-3) var(--sp-4); } .confirm-stats { display: flex; flex-direction: column; gap: var(--sp-2); background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: var(--sp-3) var(--sp-4); }
.stat-row { display: flex; justify-content: space-between; align-items: center; } .stat-row { display: flex; justify-content: space-between; align-items: center; }
.stat-label { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); letter-spacing: var(--tracking-wide); } .stat-label { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); letter-spacing: var(--tracking-wide); }
@@ -508,17 +516,18 @@
.stat-warn { color: #d97706 !important; } .stat-warn { color: #d97706 !important; }
.stat-bad { color: var(--color-error) !important; } .stat-bad { color: var(--color-error) !important; }
.chapter-diff { font-family: var(--font-ui); font-size: var(--text-2xs); color: #d97706; letter-spacing: var(--tracking-wide); margin-left: var(--sp-2); } .chapter-diff { font-family: var(--font-ui); font-size: var(--text-2xs); color: #d97706; letter-spacing: var(--tracking-wide); margin-left: var(--sp-2); }
.warn-box { display: flex; align-items: center; gap: var(--sp-2); padding: var(--sp-2) var(--sp-3); background: rgba(217,119,6,0.08); border: 1px solid rgba(217,119,6,0.25); border-radius: var(--radius-md); font-size: var(--text-xs); color: #d97706; line-height: var(--leading-snug); }
.warn-box { display: flex; align-items: center; gap: var(--sp-2); padding: var(--sp-2) var(--sp-3); background: rgba(217,119,6,0.08); border: 1px solid rgba(217,119,6,0.25); border-radius: var(--radius-md); font-size: var(--text-xs); color: #d97706; line-height: var(--leading-snug); flex-shrink: 0; }
.confirm-note { font-size: var(--text-xs); color: var(--text-faint); line-height: var(--leading-base); } .confirm-note { font-size: var(--text-xs); color: var(--text-faint); line-height: var(--leading-base); }
.confirm-actions { display: flex; justify-content: flex-end; gap: var(--sp-2); flex-shrink: 0; } .confirm-actions { display: flex; justify-content: flex-end; gap: var(--sp-2); flex-shrink: 0; margin-top: auto; }
.back-btn { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 6px 10px; border-radius: var(--radius-md); background: none; color: var(--text-muted); border: 1px solid var(--border-dim); cursor: pointer; flex-shrink: 0; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); } .back-btn { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 6px 10px; border-radius: var(--radius-md); background: none; color: var(--text-muted); border: 1px solid var(--border-dim); cursor: pointer; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
.back-btn:hover:not(:disabled) { color: var(--text-secondary); border-color: var(--border-strong); background: var(--bg-raised); } .back-btn:hover:not(:disabled) { color: var(--text-secondary); border-color: var(--border-strong); background: var(--bg-raised); }
.back-btn:disabled { opacity: 0.4; cursor: default; } .back-btn:disabled { opacity: 0.4; cursor: default; }
.migrate-btn { display: flex; align-items: center; gap: var(--sp-2); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 7px 16px; border-radius: var(--radius-md); background: var(--accent-dim); border: 1px solid var(--accent); color: var(--accent-fg); cursor: pointer; transition: background var(--t-base), border-color var(--t-base); } .migrate-btn { display: flex; align-items: center; gap: var(--sp-2); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 7px 16px; border-radius: var(--radius-md); background: var(--accent-dim); border: 1px solid var(--accent); color: var(--accent-fg); cursor: pointer; transition: background var(--t-base), border-color var(--t-base); }
.migrate-btn:hover:not(:disabled) { background: var(--accent-muted); border-color: var(--accent-bright); } .migrate-btn:hover:not(:disabled) { background: var(--accent-muted); border-color: var(--accent-bright); }
.migrate-btn:disabled { opacity: 0.5; cursor: default; } .migrate-btn:disabled { opacity: 0.5; cursor: default; }
.error { display: flex; align-items: center; gap: var(--sp-2); font-size: var(--text-xs); color: var(--color-error); padding: var(--sp-2) var(--sp-3); background: rgba(180,60,60,0.08); border-radius: var(--radius-md); border: 1px solid rgba(180,60,60,0.2); } .error { display: flex; align-items: center; gap: var(--sp-2); font-size: var(--text-xs); color: var(--color-error); padding: var(--sp-2) var(--sp-3); background: rgba(180,60,60,0.08); border-radius: var(--radius-md); border: 1px solid rgba(180,60,60,0.2); flex-shrink: 0; }
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } } @keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
</style> </style>
@@ -318,6 +318,12 @@
text-align: center; text-align: center;
} }
.s-presets {
display: flex;
gap: var(--sp-2);
padding: var(--sp-3) var(--sp-4);
}
/* ── Select Dropdown ──────────────────────────────────────────────── */ /* ── Select Dropdown ──────────────────────────────────────────────── */
.s-select { position: relative; flex-shrink: 0; } .s-select { position: relative; flex-shrink: 0; }
@@ -682,6 +688,16 @@
border-top: 1px solid var(--border-dim); border-top: 1px solid var(--border-dim);
} }
.s-subsection-title {
font-family: var(--font-ui);
font-size: var(--text-2xs);
color: var(--text-faint);
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
padding: var(--sp-3) var(--sp-4) var(--sp-1);
border-bottom: 1px solid var(--border-dim);
}
/* ── Storage bar ──────────────────────────────────────────────────── */ /* ── Storage bar ──────────────────────────────────────────────────── */
.s-storage-wrap { .s-storage-wrap {
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { tick } from "svelte"; import { tick } from "svelte";
import { X, Book, Image, Sliders, Info, Keyboard, Gear, HardDrives, FolderSimple, Wrench, PaintBrush, ListChecks, Lock, ShieldCheck } from "phosphor-svelte"; import { X, Book, Image, Sliders, Info, Keyboard, Gear, HardDrives, FolderSimple, Wrench, PaintBrush, ListChecks, Lock, ShieldCheck, Robot } from "phosphor-svelte";
import { store, setSettingsOpen, updateSettings } from "@store/state.svelte"; import { store, setSettingsOpen, updateSettings } from "@store/state.svelte";
import { eventToKeybind } from "@core/keybinds/keybindEngine"; import { eventToKeybind } from "@core/keybinds/keybindEngine";
import type { Keybinds } from "@types/settings"; import type { Keybinds } from "@types/settings";
@@ -19,16 +19,18 @@
import ContentSettings from "../sections/ContentSettings.svelte"; import ContentSettings from "../sections/ContentSettings.svelte";
import AboutSettings from "../sections/AboutSettings.svelte"; import AboutSettings from "../sections/AboutSettings.svelte";
import DevtoolsSettings from "../sections/DevtoolsSettings.svelte"; import DevtoolsSettings from "../sections/DevtoolsSettings.svelte";
import AutomationSettings from "../sections/AutomationSettings.svelte";
interface Props { onOpenThemeEditor?: (id?: string | null) => void; } interface Props { onOpenThemeEditor?: (id?: string | null) => void; }
let { onOpenThemeEditor }: Props = $props(); let { onOpenThemeEditor }: Props = $props();
type Tab = "general"|"appearance"|"reader"|"library"|"performance"|"keybinds"|"storage"|"folders"|"tracking"|"security"|"content"|"about"|"devtools"; type Tab = "general"|"appearance"|"reader"|"library"|"automation"|"performance"|"keybinds"|"storage"|"folders"|"tracking"|"security"|"content"|"about"|"devtools";
const TABS: { id: Tab; label: string; icon: any }[] = [ const TABS: { id: Tab; label: string; icon: any }[] = [
{ id: "general", label: "General", icon: Gear }, { id: "general", label: "General", icon: Gear },
{ id: "appearance", label: "Appearance", icon: PaintBrush }, { id: "appearance", label: "Appearance", icon: PaintBrush },
{ id: "reader", label: "Reader", icon: Book }, { id: "reader", label: "Reader", icon: Book },
{ id: "library", label: "Library", icon: Image }, { id: "library", label: "Library", icon: Image },
{ id: "automation", label: "Automation", icon: Robot },
{ id: "performance", label: "Performance", icon: Sliders }, { id: "performance", label: "Performance", icon: Sliders },
{ id: "keybinds", label: "Keybinds", icon: Keyboard }, { id: "keybinds", label: "Keybinds", icon: Keyboard },
{ id: "storage", label: "Storage", icon: HardDrives }, { id: "storage", label: "Storage", icon: HardDrives },
@@ -61,7 +63,6 @@
function close() { setSettingsOpen(false); } function close() { setSettingsOpen(false); }
// Keybind capture
let listeningKey: keyof Keybinds | null = $state(null); let listeningKey: keyof Keybinds | null = $state(null);
$effect(() => { $effect(() => {
@@ -83,7 +84,6 @@
return () => window.removeEventListener("keydown", capture, true); return () => window.removeEventListener("keydown", capture, true);
}); });
// Shared select dropdown state (passed to sections that need it)
let selectOpen: string | null = $state(null); let selectOpen: string | null = $state(null);
let closingSelect: string | null = $state(null); let closingSelect: string | null = $state(null);
@@ -105,9 +105,7 @@
const handler = (e: MouseEvent) => { const handler = (e: MouseEvent) => {
if (!selectOpen) return; if (!selectOpen) return;
const t = e.target as HTMLElement; const t = e.target as HTMLElement;
// Keep open if click is inside the trigger wrapper (.s-select)
if (t.closest(".s-select")) return; if (t.closest(".s-select")) return;
// Keep open if click landed inside the portalled menu (appended to <body>)
if (t.closest(".s-select-menu")) return; if (t.closest(".s-select-menu")) return;
closeSelect(); closeSelect();
}; };
@@ -167,6 +165,8 @@
<ReaderSettings {selectOpen} {closingSelect} {toggleSelect} {anims} /> <ReaderSettings {selectOpen} {closingSelect} {toggleSelect} {anims} />
{:else if tab === "library"} {:else if tab === "library"}
<LibrarySettings {selectOpen} {closingSelect} {toggleSelect} {anims} /> <LibrarySettings {selectOpen} {closingSelect} {toggleSelect} {anims} />
{:else if tab === "automation"}
<AutomationSettings />
{:else if tab === "performance"} {:else if tab === "performance"}
<PerformanceSettings /> <PerformanceSettings />
{:else if tab === "keybinds"} {:else if tab === "keybinds"}
@@ -32,6 +32,9 @@
$effect(() => { $effect(() => {
getVersion().then(v => appVersion = v).catch(() => appVersion = "unknown"); getVersion().then(v => appVersion = v).catch(() => appVersion = "unknown");
if (!releasesLoaded) { releasesLoaded = true; loadReleases(); } if (!releasesLoaded) { releasesLoaded = true; loadReleases(); }
});
$effect(() => {
loadServerInfo(); loadServerInfo();
}); });
@@ -92,9 +95,9 @@
return new Date(iso).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" }); return new Date(iso).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" });
} }
function fmtBuildTime(unix: number) { function fmtBuildTime(unix: number | string) {
if (!unix) return ""; if (!unix) return "";
return new Date(unix).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" }); return new Date(Number(unix) * 1000).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" });
} }
function fmtBytes(bytes: number) { function fmtBytes(bytes: number) {
@@ -0,0 +1,320 @@
<script lang="ts">
import { ArrowCounterClockwise, LockSimple, Warning } from "phosphor-svelte";
import { store, updateSettings, DEFAULT_MANGA_PREFS } from "@store/state.svelte";
import type { MangaPrefs } from "@store/state.svelte";
const DOWNLOAD_AHEAD_OPTIONS = [
{ value: 0, label: "Off" },
{ value: 2, label: "2" },
{ value: 5, label: "5" },
{ value: 10, label: "10" },
];
const MAX_KEEP_OPTIONS = [
{ value: 0, label: "Off" },
{ value: 5, label: "5" },
{ value: 10, label: "10" },
{ value: 25, label: "25" },
];
const DELETE_DELAY_OPTIONS = [
{ value: 0, label: "Now" },
{ value: 24, label: "1 day" },
{ value: 168, label: "1 week" },
];
const REFRESH_INTERVAL_OPTIONS = [
{ value: "daily", label: "Daily" },
{ value: "weekly", label: "Weekly" },
{ value: "manual", label: "Manual" },
];
type GlobalDefaults = Omit<MangaPrefs, "refreshInterval"> & {
refreshInterval: "daily" | "weekly" | "manual";
};
const fallback: GlobalDefaults = {
autoDownload: false,
downloadAhead: 0,
maxKeepChapters: 0,
deleteOnRead: false,
deleteDelayHours: 0,
pauseUpdates: false,
refreshInterval: "weekly",
};
function getGlobal<K extends keyof GlobalDefaults>(key: K): GlobalDefaults[K] {
return (store.settings.automationDefaults as GlobalDefaults | undefined)?.[key] ?? fallback[key];
}
function setGlobal<K extends keyof GlobalDefaults>(key: K, value: GlobalDefaults[K]) {
updateSettings({
automationDefaults: {
...(store.settings.automationDefaults ?? fallback),
[key]: value,
},
});
}
const enforceGlobal = $derived(store.settings.automationEnforceGlobal ?? false);
function toggleEnforce() {
updateSettings({ automationEnforceGlobal: !enforceGlobal });
}
const customCount = $derived(
Object.keys(store.mangaPrefs ?? {}).filter((id) => {
const prefs = (store.mangaPrefs as Record<string, Partial<MangaPrefs>>)[id];
return prefs && Object.keys(prefs).length > 0;
}).length
);
let confirmReset = $state(false);
function resetAllCustoms() {
if (!confirmReset) { confirmReset = true; return; }
const ids = Object.keys(store.mangaPrefs ?? {});
const blank = { ...DEFAULT_MANGA_PREFS };
for (const id of ids) {
for (const key of Object.keys(blank) as (keyof MangaPrefs)[]) {
// setPref(Number(id), key, blank[key] as any)
}
}
updateSettings({ _resetMangaPrefs: Date.now() } as any);
confirmReset = false;
}
function cancelReset() { confirmReset = false; }
</script>
<div class="s-panel">
<div class="s-section">
<p class="s-section-title">Behaviour</p>
<div class="s-section-body">
<label class="s-row">
<div class="s-row-info">
<span class="s-label">Enable automation</span>
<span class="s-desc">Allow per-series and global automation rules to run</span>
</div>
<button
role="switch"
aria-checked={store.settings.automationEnabled ?? false}
aria-label="Enable automation"
class="s-toggle"
class:on={store.settings.automationEnabled ?? false}
onclick={() => updateSettings({ automationEnabled: !(store.settings.automationEnabled ?? false) })}
><span class="s-toggle-thumb"></span></button>
</label>
<label class="s-row">
<div class="s-row-info">
<span class="s-label">Enforce global defaults</span>
<span class="s-desc">Ignore per-series overrides — all series use the global settings below</span>
</div>
<button
role="switch"
aria-checked={enforceGlobal}
aria-label="Enforce global defaults"
class="s-toggle"
class:on={enforceGlobal}
onclick={toggleEnforce}
><span class="s-toggle-thumb"></span></button>
</label>
{#if enforceGlobal}
<div class="s-banner s-banner-info enforce-banner">
<LockSimple size={12} weight="fill" />
<span>Per-series overrides are paused. Disable enforce to allow custom rules.</span>
</div>
{/if}
</div>
</div>
<div class="s-section">
<p class="s-section-title">Global Defaults</p>
<div class="s-section-body">
<p class="sub-head">Downloads</p>
<div class="s-row">
<div class="s-row-info">
<span class="s-label">Auto-download new chapters</span>
<span class="s-desc">Queue new chapters when a series refreshes</span>
</div>
<button
role="switch"
aria-checked={getGlobal("autoDownload")}
aria-label="Auto-download new chapters"
class="s-toggle"
class:on={getGlobal("autoDownload")}
onclick={() => setGlobal("autoDownload", !getGlobal("autoDownload"))}
><span class="s-toggle-thumb"></span></button>
</div>
<div class="s-row chip-row">
<div class="s-row-info">
<span class="s-label">Download ahead</span>
<span class="s-desc">Pre-fetch chapters while reading</span>
</div>
<div class="chip-group">
{#each DOWNLOAD_AHEAD_OPTIONS as opt}
<button
class="s-preset"
class:active={getGlobal("downloadAhead") === opt.value}
onclick={() => setGlobal("downloadAhead", opt.value)}
>{opt.label}</button>
{/each}
</div>
</div>
<div class="s-row chip-row">
<div class="s-row-info">
<span class="s-label">Max chapters to keep</span>
<span class="s-desc">Delete oldest downloads when limit is exceeded</span>
</div>
<div class="chip-group">
{#each MAX_KEEP_OPTIONS as opt}
<button
class="s-preset"
class:active={getGlobal("maxKeepChapters") === opt.value}
onclick={() => setGlobal("maxKeepChapters", opt.value)}
>{opt.label}</button>
{/each}
</div>
</div>
<p class="sub-head sub-head-rule">On Read</p>
<div class="s-row">
<div class="s-row-info">
<span class="s-label">Delete after reading</span>
<span class="s-desc">Remove download when a chapter is marked read</span>
</div>
<button
role="switch"
aria-checked={getGlobal("deleteOnRead")}
aria-label="Delete after reading"
class="s-toggle"
class:on={getGlobal("deleteOnRead")}
onclick={() => setGlobal("deleteOnRead", !getGlobal("deleteOnRead"))}
><span class="s-toggle-thumb"></span></button>
</div>
{#if getGlobal("deleteOnRead")}
<div class="s-row chip-row sub-row">
<span class="s-label">Delete delay</span>
<div class="chip-group">
{#each DELETE_DELAY_OPTIONS as opt}
<button
class="s-preset"
class:active={getGlobal("deleteDelayHours") === opt.value}
onclick={() => setGlobal("deleteDelayHours", opt.value)}
>{opt.label}</button>
{/each}
</div>
</div>
{/if}
<p class="sub-head sub-head-rule">Updates</p>
<div class="s-row chip-row">
<div class="s-row-info">
<span class="s-label">Default refresh interval</span>
<span class="s-desc">How often series check for new chapters by default</span>
</div>
<div class="chip-group">
{#each REFRESH_INTERVAL_OPTIONS as opt}
<button
class="s-preset"
class:active={getGlobal("refreshInterval") === opt.value}
onclick={() => setGlobal("refreshInterval", opt.value as GlobalDefaults["refreshInterval"])}
>{opt.label}</button>
{/each}
</div>
</div>
</div>
</div>
<div class="s-section">
<p class="s-section-title">Custom Overrides</p>
<div class="s-section-body">
<div class="s-row">
<div class="s-row-info">
<span class="s-label">Series with custom rules</span>
<span class="s-desc">Per-series settings set via the series automation panel</span>
</div>
<span class="s-pill" class:on={customCount > 0}>{customCount}</span>
</div>
<div class="s-row">
<div class="s-row-info">
<span class="s-label">Reset all custom rules</span>
<span class="s-desc">Revert every series to the global defaults above</span>
</div>
{#if confirmReset}
<div class="s-btn-row">
<button class="s-btn s-btn-danger" onclick={resetAllCustoms}>
<Warning size={11} weight="fill" /> Confirm reset
</button>
<button class="s-btn" onclick={cancelReset}>Cancel</button>
</div>
{:else}
<button class="s-btn" disabled={customCount === 0} onclick={resetAllCustoms}>
<ArrowCounterClockwise size={11} weight="regular" /> Reset
</button>
{/if}
</div>
</div>
</div>
</div>
<style>
.enforce-banner {
display: flex;
align-items: center;
gap: var(--sp-2);
}
.sub-head {
font-family: var(--font-ui);
font-size: var(--text-2xs);
letter-spacing: var(--tracking-widest);
text-transform: uppercase;
color: var(--text-faint);
margin: 0;
padding: var(--sp-2) var(--sp-4) 0;
}
.sub-head-rule {
border-top: 1px solid var(--border-dim);
padding-top: var(--sp-3);
margin-top: var(--sp-1);
}
.chip-row {
align-items: flex-start;
padding-top: 8px;
padding-bottom: 8px;
}
.chip-group {
display: flex;
flex-direction: row;
gap: 4px;
flex-shrink: 0;
flex-wrap: wrap;
justify-content: flex-end;
}
.sub-row {
padding-left: calc(var(--sp-4) + var(--sp-2));
border-left: 2px solid var(--border-dim);
}
</style>
@@ -1,10 +1,19 @@
<script lang="ts"> <script lang="ts">
import { FolderSimple, Plus, Pencil, Trash, Star, Eye, EyeSlash, ArrowsClockwise, DownloadSimple } from "phosphor-svelte"; import { FolderSimple, Plus, Trash, Star, Eye, EyeSlash, ArrowsClockwise, ArrowsCounterClockwise, DownloadSimple, DotsSixVertical, BookmarkSimple, Lock, CheckSquare } from "phosphor-svelte";
import { gql } from "@api/client"; import { gql } from "@api/client";
import { GET_CATEGORIES } from "@api/queries/manga"; import { GET_CATEGORIES } from "@api/queries/manga";
import { CREATE_CATEGORY, UPDATE_CATEGORY, UPDATE_CATEGORIES, DELETE_CATEGORY, UPDATE_CATEGORY_ORDER } from "@api/mutations/manga"; import { CREATE_CATEGORY, UPDATE_CATEGORY, UPDATE_CATEGORIES, DELETE_CATEGORY, UPDATE_CATEGORY_ORDER } from "@api/mutations/manga";
import type { Category } from "@types"; import type { Category } from "@types";
import { store, updateSettings, toggleHiddenCategory, setCategories } from "@store/state.svelte"; import { store, updateSettings, setCategories } from "@store/state.svelte";
const completedCat = $derived(store.categories.find(c => c.name === "Completed" && c.id !== 0) ?? null);
const completedId = $derived(completedCat ? String(completedCat.id) : null);
const sortedCatIds = $derived(store.categories.filter(c => c.id !== 0).map(c => String(c.id)));
const orderedCatIds = $derived.by(() => {
const order = store.settings.libraryPinnedTabOrder ?? [];
const known = new Set(sortedCatIds);
return [...order.filter(id => known.has(id)), ...sortedCatIds.filter(id => !order.includes(id))];
});
let catsLoading = $state(false); let catsLoading = $state(false);
let catsError = $state<string | null>(null); let catsError = $state<string | null>(null);
@@ -12,6 +21,19 @@
let editingId = $state<number | null>(null); let editingId = $state<number | null>(null);
let editingName = $state(""); let editingName = $state("");
let dragId = $state<number | null>(null);
let dragOverId = $state<number | null>(null);
let dropPosition = $state<"above" | "below" | null>(null);
function isHidden(id: string) {
return (store.settings.hiddenLibraryTabs ?? []).includes(id);
}
function toggleHidden(id: string) {
const current = store.settings.hiddenLibraryTabs ?? [];
updateSettings({ hiddenLibraryTabs: current.includes(id) ? current.filter(x => x !== id) : [...current, id] });
}
async function loadCategories() { async function loadCategories() {
catsLoading = true; catsError = null; catsLoading = true; catsError = null;
try { try {
@@ -63,26 +85,29 @@
const next = !cat[flag]; const next = !cat[flag];
setCategories(store.categories.map(c => c.id === id ? { ...c, [flag]: next } : c)); setCategories(store.categories.map(c => c.id === id ? { ...c, [flag]: next } : c));
try { try {
await gql(UPDATE_CATEGORIES, { ids: [id], patch: { [flag]: next } }); await gql(UPDATE_CATEGORIES, { ids: [id], patch: { [flag]: next ? "INCLUDE" : "EXCLUDE" } });
} catch (e: any) { } catch (e: any) {
setCategories(store.categories.map(c => c.id === id ? { ...c, [flag]: !next } : c)); setCategories(store.categories.map(c => c.id === id ? { ...c, [flag]: !next } : c));
catsError = e?.message ?? "Failed to update folder"; catsError = e?.message ?? "Failed to update folder";
} }
} }
async function moveCategory(id: number, direction: -1 | 1) { async function applyReorder(fromId: number, toId: number) {
const zeroCat = store.categories.filter(c => c.id === 0); const zeroCat = store.categories.filter(c => c.id === 0);
const sortable = store.categories.filter(c => c.id !== 0).sort((a, b) => a.order - b.order); const sortable = store.categories.filter(c => c.id !== 0).sort((a, b) => a.order - b.order);
const idx = sortable.findIndex(c => c.id === id); const fromIdx = sortable.findIndex(c => c.id === fromId);
if (idx < 0) return; const toIdx = sortable.findIndex(c => c.id === toId);
const newPos = idx + 1 + direction; if (fromIdx < 0 || toIdx < 0 || fromIdx === toIdx) return;
if (newPos < 1 || newPos > sortable.length) return;
const reordered = [...sortable]; const reordered = [...sortable];
const [moved] = reordered.splice(idx, 1); const [moved] = reordered.splice(fromIdx, 1);
reordered.splice(idx + direction, 0, moved); reordered.splice(toIdx, 0, moved);
setCategories([...zeroCat, ...reordered.map((c, i) => ({ ...c, order: i + 1 }))]); setCategories([...zeroCat, ...reordered.map((c, i) => ({ ...c, order: i + 1 }))]);
const catIds = reordered.map(c => String(c.id));
updateSettings({ libraryPinnedTabOrder: ["library", "downloaded", ...catIds] });
try { try {
const res = await gql<{ updateCategoryOrder: { categories: Category[] } }>(UPDATE_CATEGORY_ORDER, { id, position: newPos }); const res = await gql<{ updateCategoryOrder: { categories: Category[] } }>(UPDATE_CATEGORY_ORDER, { id: fromId, position: toIdx + 1 });
const updated = res.updateCategoryOrder.categories.filter(c => c.id !== 0); const updated = res.updateCategoryOrder.categories.filter(c => c.id !== 0);
setCategories([ setCategories([
...zeroCat, ...zeroCat,
@@ -97,6 +122,28 @@
} }
} }
function onDragStart(e: DragEvent, id: number) {
dragId = id;
if (e.dataTransfer) { e.dataTransfer.effectAllowed = "move"; e.dataTransfer.setData("text/plain", String(id)); }
}
function onDragOver(e: DragEvent, id: number) {
e.preventDefault();
if (e.dataTransfer) e.dataTransfer.dropEffect = "move";
if (dragId === id) return;
dragOverId = id;
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
dropPosition = e.clientY < rect.top + rect.height / 2 ? "above" : "below";
}
function onDrop(e: DragEvent, id: number) {
e.preventDefault();
if (dragId !== null && dragId !== id) applyReorder(dragId, id);
dragId = null; dragOverId = null; dropPosition = null;
}
function onDragEnd() { dragId = null; dragOverId = null; dropPosition = null; }
function focusInput(node: HTMLElement) { node.focus(); } function focusInput(node: HTMLElement) { node.focus(); }
$effect(() => { $effect(() => {
@@ -105,16 +152,121 @@
</script> </script>
<div class="s-panel"> <div class="s-panel">
<div class="s-section"> <div class="s-section">
<p class="s-section-title">Manage Folders</p> <p class="s-section-title">Manage Folders</p>
<div class="s-section-body"> <div class="s-section-body">
<div class="s-row"> <div class="s-row">
<span class="s-desc">Folders are stored as Suwayomi categories. Changes sync across all clients.</span> <span class="s-desc">Folders are stored as Suwayomi categories. Changes sync across all clients.</span>
</div> </div>
{#if catsError} {#if catsError}
<div class="s-banner s-banner-error">{catsError}</div> <div class="s-banner s-banner-error">{catsError}</div>
{/if} {/if}
{#if catsLoading}
<p class="s-empty">Loading folders…</p>
{:else}
<div class="s-folder-row s-folder-row-static">
<span class="s-folder-icon-static"><BookmarkSimple size={14} weight="light" /></span>
<span class="s-folder-name s-folder-name-static">Saved</span>
<span class="s-folder-badge">built-in</span>
<div class="s-folder-actions">
<button class="s-btn-icon" class:muted={isHidden("library")} onclick={() => toggleHidden("library")} title={isHidden("library") ? "Show tab in library" : "Hide tab from library"}>
{#if isHidden("library")}<EyeSlash size={13} weight="light" />{:else}<Eye size={13} weight="light" />{/if}
</button>
<button class="s-btn-icon s-btn-icon-lock" disabled title="Built-in tab — cannot be deleted"><Lock size={12} weight="light" /></button>
</div>
</div>
<div class="s-folder-row s-folder-row-static">
<span class="s-folder-icon-static"><DownloadSimple size={14} weight="light" /></span>
<span class="s-folder-name s-folder-name-static">Downloaded</span>
<span class="s-folder-badge">built-in</span>
<div class="s-folder-actions">
<button class="s-btn-icon" class:muted={isHidden("downloaded")} onclick={() => toggleHidden("downloaded")} title={isHidden("downloaded") ? "Show tab in library" : "Hide tab from library"}>
{#if isHidden("downloaded")}<EyeSlash size={13} weight="light" />{:else}<Eye size={13} weight="light" />{/if}
</button>
<button class="s-btn-icon s-btn-icon-lock" disabled title="Built-in tab — cannot be deleted"><Lock size={12} weight="light" /></button>
</div>
</div>
{#if completedCat}
<div class="s-folder-row s-folder-row-static">
<span class="s-folder-icon-static"><CheckSquare size={14} weight="light" /></span>
<span class="s-folder-name s-folder-name-static">{completedCat.name}</span>
<span class="s-folder-count">{completedCat.mangas?.nodes.length ?? 0} manga</span>
<span class="s-folder-badge">built-in</span>
<div class="s-folder-actions">
<button class="s-btn-icon" class:muted={isHidden(String(completedCat.id))} onclick={() => toggleHidden(String(completedCat!.id))} title={isHidden(String(completedCat.id)) ? "Show tab in library" : "Hide tab from library"}>
{#if isHidden(String(completedCat.id))}<EyeSlash size={13} weight="light" />{:else}<Eye size={13} weight="light" />{/if}
</button>
<button class="s-btn-icon s-btn-icon-lock" disabled title="Built-in tab — cannot be deleted"><Lock size={12} weight="light" /></button>
</div>
</div>
{/if}
<div class="s-folder-divider" aria-hidden="true"></div>
<div class="s-folder-list" class:is-dragging={dragId !== null}>
{#each orderedCatIds.filter(id => id !== completedId) as id}
{@const cat = store.categories.find(c => String(c.id) === id) ?? null}
{@const hidden = isHidden(id)}
{#if cat}
<div
class="s-folder-row"
class:dragging={dragId === cat.id}
class:drop-above={dragOverId === cat.id && dragId !== cat.id && dropPosition === "above"}
class:drop-below={dragOverId === cat.id && dragId !== cat.id && dropPosition === "below"}
ondragover={(e) => onDragOver(e, cat.id)}
ondrop={(e) => onDrop(e, cat.id)}
ondragleave={() => { if (dragOverId === cat.id) { dragOverId = null; dropPosition = null; } }}
>
{#if editingId === cat.id}
<input class="s-input full" bind:value={editingName}
onkeydown={(e) => { if (e.key === "Enter") commitEdit(); if (e.key === "Escape") { editingId = null; } }}
onblur={commitEdit} use:focusInput />
<button class="s-btn-icon" onclick={commitEdit} title="Save"></button>
{:else}
<div class="s-folder-identity" draggable="true"
ondragstart={(e) => onDragStart(e, cat.id)}
ondragend={onDragEnd}>
<span class="s-folder-icon">
<FolderSimple size={14} weight="light" />
<DotsSixVertical size={14} weight="bold" />
</span>
<span class="s-folder-name" onclick={(e) => { e.stopPropagation(); startEdit(cat.id, cat.name); }} title="Click to rename">{cat.name}</span>
</div>
<span class="s-folder-count">{cat.mangas?.nodes.length ?? 0} manga</span>
<div class="s-folder-actions">
<button class="s-btn-icon" class:active={(store.settings.defaultLibraryCategoryId ?? null) === cat.id} onclick={() => updateSettings({ defaultLibraryCategoryId: (store.settings.defaultLibraryCategoryId ?? null) === cat.id ? null : cat.id })} title={(store.settings.defaultLibraryCategoryId ?? null) === cat.id ? "Remove as default folder" : "Set as default folder"}>
<Star size={13} weight={(store.settings.defaultLibraryCategoryId ?? null) === cat.id ? "fill" : "light"} />
</button>
<button class="s-btn-icon" class:muted={hidden} onclick={() => toggleHidden(id)} title={hidden ? "Show in library" : "Hide from library"}>
{#if hidden}<EyeSlash size={13} weight="light" />{:else}<Eye size={13} weight="light" />{/if}
</button>
<button class="s-btn-icon" class:active={cat.includeInUpdate !== false} class:inactive={cat.includeInUpdate === false} onclick={() => toggleCategoryFlag(cat.id, "includeInUpdate")} title={cat.includeInUpdate !== false ? "Included in updates — click to exclude" : "Excluded from updates — click to include"}>
{#if cat.includeInUpdate !== false}<ArrowsClockwise size={13} weight="bold" />{:else}<ArrowsCounterClockwise size={13} weight="light" />{/if}
</button>
<button class="s-btn-icon" class:active={cat.includeInDownload !== false} class:inactive={cat.includeInDownload === false} onclick={() => toggleCategoryFlag(cat.id, "includeInDownload")} title={cat.includeInDownload !== false ? "Included in auto-downloads — click to exclude" : "Excluded from auto-downloads — click to include"}>
<DownloadSimple size={13} weight={cat.includeInDownload !== false ? "bold" : "light"} />
</button>
<button class="s-btn-icon danger" onclick={() => deleteFolder(cat.id)} title="Delete folder">
<Trash size={12} weight="light" />
</button>
</div>
{/if}
</div>
{/if}
{/each}
</div>
{#if store.categories.filter(c => c.id !== 0 && c.name !== "Completed").length === 0}
<p class="s-empty">No custom folders yet. Create one below.</p>
{/if}
{/if}
<div class="s-folder-create"> <div class="s-folder-create">
<input class="s-input full" placeholder="New folder name…" bind:value={newFolderName} <input class="s-input full" placeholder="New folder name…" bind:value={newFolderName}
onkeydown={(e) => e.key === "Enter" && createFolder()} /> onkeydown={(e) => e.key === "Enter" && createFolder()} />
@@ -122,64 +274,163 @@
<Plus size={13} weight="bold" /> Create <Plus size={13} weight="bold" /> Create
</button> </button>
</div> </div>
{#if catsLoading}
<p class="s-empty">Loading folders…</p>
{:else if store.categories.filter(c => c.id !== 0).length === 0}
<p class="s-empty">No folders yet. Create one above.</p>
{:else}
{@const displayCats = store.categories
.filter(c => c.id !== 0)
.sort((a, b) => {
const defaultId = store.settings.defaultLibraryCategoryId ?? null;
if (a.id === defaultId) return -1;
if (b.id === defaultId) return 1;
return a.order - b.order;
})}
{#each displayCats as cat, i}
<div class="s-folder-row">
{#if editingId === cat.id}
<input class="s-input full" bind:value={editingName}
onkeydown={(e) => { if (e.key === "Enter") commitEdit(); if (e.key === "Escape") editingId = null; }}
onblur={commitEdit} use:focusInput />
<button class="s-btn-icon" onclick={commitEdit} title="Save"></button>
{:else}
<FolderSimple size={14} weight="light" style="color:var(--text-faint);flex-shrink:0" />
<span class="s-folder-name">{cat.name}</span>
<span class="s-folder-count">{cat.mangas?.nodes.length ?? 0} manga</span>
<button class="s-btn-icon"
class:accent={(store.settings.defaultLibraryCategoryId ?? null) === cat.id}
onclick={() => updateSettings({ defaultLibraryCategoryId: (store.settings.defaultLibraryCategoryId ?? null) === cat.id ? null : cat.id })}
title={(store.settings.defaultLibraryCategoryId ?? null) === cat.id ? "Remove as default folder" : "Set as default folder"}>
<Star size={13} weight={(store.settings.defaultLibraryCategoryId ?? null) === cat.id ? "fill" : "light"} />
</button>
<button class="s-btn-icon"
onclick={() => toggleHiddenCategory(cat.id)}
title={(store.settings.hiddenCategoryIds ?? []).includes(cat.id) ? "Show in Saved tab" : "Hide from Saved tab"}>
{#if (store.settings.hiddenCategoryIds ?? []).includes(cat.id)}<EyeSlash size={13} weight="light" />{:else}<Eye size={13} weight="light" />{/if}
</button>
<button
class="s-btn-icon"
class:accent={cat.includeInUpdate !== false}
onclick={() => toggleCategoryFlag(cat.id, "includeInUpdate")}
title={cat.includeInUpdate !== false ? "Exclude from library updates" : "Include in library updates"}>
<ArrowsClockwise size={13} weight={cat.includeInUpdate !== false ? "bold" : "light"} />
</button>
<button
class="s-btn-icon"
class:accent={cat.includeInDownload !== false}
onclick={() => toggleCategoryFlag(cat.id, "includeInDownload")}
title={cat.includeInDownload !== false ? "Exclude from auto-downloads" : "Include in auto-downloads"}>
<DownloadSimple size={13} weight={cat.includeInDownload !== false ? "bold" : "light"} />
</button>
<button class="s-btn-icon" onclick={() => moveCategory(cat.id, -1)} disabled={i === 0} title="Move up"></button>
<button class="s-btn-icon" onclick={() => moveCategory(cat.id, 1)} disabled={i === displayCats.length - 1} title="Move down"></button>
<button class="s-btn-icon" onclick={() => startEdit(cat.id, cat.name)} title="Rename"><Pencil size={12} weight="light" /></button>
<button class="s-btn-icon danger" onclick={() => deleteFolder(cat.id)} title="Delete"><Trash size={12} weight="light" /></button>
{/if}
</div> </div>
{/each}
{/if}
</div> </div>
</div> </div>
</div> <style>
.s-folder-list {
display: contents;
}
.s-folder-list.is-dragging,
.s-folder-list.is-dragging * {
user-select: none;
-webkit-user-select: none;
}
.s-folder-row {
transition: opacity 0.15s, background 0.1s;
position: relative;
}
.s-folder-row.dragging {
opacity: 0.35;
}
.s-folder-row.drop-above::before,
.s-folder-row.drop-below::after {
content: "";
position: absolute;
left: 8px;
right: 8px;
height: 2px;
background: var(--color-success, #4ade80);
border-radius: 2px;
pointer-events: none;
z-index: 10;
}
.s-folder-row.drop-above::before { top: -1px; }
.s-folder-row.drop-below::after { bottom: -1px; }
.s-folder-identity {
display: flex;
align-items: center;
gap: 6px;
color: var(--text-faint);
flex-shrink: 0;
overflow: hidden;
cursor: grab;
}
.s-folder-row-static {
cursor: default;
}
.s-folder-icon-static {
display: flex;
align-items: center;
flex-shrink: 0;
color: var(--text-faint);
width: 14px;
}
.s-folder-icon {
display: grid;
flex-shrink: 0;
}
.s-folder-icon > :global(*) {
grid-area: 1 / 1;
transition: opacity 0.12s;
}
.s-folder-icon > :global(*:last-child) {
opacity: 0;
}
.s-folder-row:hover .s-folder-icon > :global(*:first-child) {
opacity: 0;
}
.s-folder-row:hover .s-folder-icon > :global(*:last-child) {
opacity: 1;
}
.s-folder-name {
cursor: pointer;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: var(--text-primary);
}
.s-folder-name:hover {
text-decoration: underline;
text-underline-offset: 3px;
}
.s-folder-name-static {
cursor: default;
color: var(--text-secondary);
}
.s-folder-name-static:hover {
text-decoration: none;
}
.s-folder-actions {
display: flex;
align-items: center;
gap: 2px;
margin-left: auto;
flex-shrink: 0;
}
.s-folder-badge {
font-size: 10px;
letter-spacing: 0.04em;
color: var(--text-faint);
background: var(--bg-subtle);
border: 1px solid var(--border-dim);
border-radius: 3px;
padding: 1px 5px;
flex-shrink: 0;
margin-left: 6px;
}
.s-folder-divider {
height: 1px;
background: var(--border-dim);
margin: 2px 0;
}
.s-btn-icon.active {
color: var(--accent, #6c8ef5);
}
.s-btn-icon.inactive {
color: var(--color-error, #f87171);
opacity: 0.75;
}
.s-btn-icon.inactive:hover {
opacity: 1;
}
.s-btn-icon.muted {
color: var(--text-faint);
opacity: 0.5;
}
.s-btn-icon-lock {
opacity: 0.25;
cursor: not-allowed;
}
.s-btn-icon-lock:hover {
opacity: 0.25;
color: inherit;
}
</style>
@@ -76,6 +76,20 @@
</div> </div>
</div> </div>
<div class="s-section">
<p class="s-section-title">Window</p>
<div class="s-section-body">
<div class="s-row">
<div class="s-row-info"><span class="s-label">Close button behavior</span><span class="s-desc">What happens when you click the X button</span></div>
<div class="s-seg">
{#each [["ask","Ask"],["tray","Tray"],["quit","Quit"]] as [v, l]}
<button class="s-seg-btn" class:active={( store.settings.closeAction ?? "ask") === v} onclick={() => updateSettings({ closeAction: v as "ask" | "tray" | "quit" })}>{l}</button>
{/each}
</div>
</div>
</div>
</div>
<div class="s-section"> <div class="s-section">
<p class="s-section-title">Integrations</p> <p class="s-section-title">Integrations</p>
<div class="s-section-body"> <div class="s-section-body">
@@ -113,3 +127,10 @@
</div> </div>
</div> </div>
<style>
.s-seg { display: flex; border: 1px solid var(--border-strong); border-radius: var(--radius-md); overflow: hidden; }
.s-seg-btn { flex: 1; padding: var(--sp-1) var(--sp-3); font-family: var(--font-ui); font-size: var(--text-sm); color: var(--text-faint); background: transparent; cursor: pointer; transition: background var(--t-base), color var(--t-base); border: none; }
.s-seg-btn:not(:last-child) { border-right: 1px solid var(--border-strong); }
.s-seg-btn.active { background: var(--accent-muted); color: var(--accent-fg); }
.s-seg-btn:not(.active):hover { background: var(--bg-raised); color: var(--text-secondary); }
</style>
@@ -69,6 +69,10 @@
<div class="s-row-info"><span class="s-label">Auto-link on open</span><span class="s-desc">When opening a manga, automatically link it to similarly-titled entries and notify you of new matches</span></div> <div class="s-row-info"><span class="s-label">Auto-link on open</span><span class="s-desc">When opening a manga, automatically link it to similarly-titled entries and notify you of new matches</span></div>
<button role="switch" aria-checked={store.settings.autoLinkOnOpen ?? false} aria-label="Auto-link on open" class="s-toggle" class:on={store.settings.autoLinkOnOpen ?? false} onclick={() => updateSettings({ autoLinkOnOpen: !(store.settings.autoLinkOnOpen ?? false) })}><span class="s-toggle-thumb"></span></button> <button role="switch" aria-checked={store.settings.autoLinkOnOpen ?? false} aria-label="Auto-link on open" class="s-toggle" class:on={store.settings.autoLinkOnOpen ?? false} onclick={() => updateSettings({ autoLinkOnOpen: !(store.settings.autoLinkOnOpen ?? false) })}><span class="s-toggle-thumb"></span></button>
</label> </label>
<label class="s-row">
<div class="s-row-info"><span class="s-label">Disable auto-complete</span><span class="s-desc">Don't move manga to the Completed folder when all chapters are read</span></div>
<button role="switch" aria-checked={store.settings.disableAutoComplete} aria-label="Disable auto-complete" class="s-toggle" class:on={store.settings.disableAutoComplete} onclick={() => updateSettings({ disableAutoComplete: !store.settings.disableAutoComplete })}><span class="s-toggle-thumb"></span></button>
</label>
</div> </div>
</div> </div>
@@ -56,7 +56,7 @@
<div class="s-section"> <div class="s-section">
<p class="s-section-title">Render Limit</p> <p class="s-section-title">Render Limit</p>
<div class="s-section-body"> <div class="s-section-body">
<div class="s-row"> <div class="s-slider-row">
<div class="s-row-info"> <div class="s-row-info">
<span class="s-label">Items per page</span> <span class="s-label">Items per page</span>
<span class="s-desc">Lower = faster on large libraries</span> <span class="s-desc">Lower = faster on large libraries</span>
@@ -95,16 +95,6 @@
</div> </div>
</div> </div>
<div class="s-section">
<p class="s-section-title">Interface</p>
<div class="s-section-body">
<label class="s-row">
<div class="s-row-info"><span class="s-label">Compact sidebar</span><span class="s-desc">Collapses the sidebar to icons only</span></div>
<button role="switch" aria-checked={store.settings.compactSidebar} aria-label="Compact sidebar" class="s-toggle" class:on={store.settings.compactSidebar} onclick={() => updateSettings({ compactSidebar: !store.settings.compactSidebar })}><span class="s-toggle-thumb"></span></button>
</label>
</div>
</div>
<div class="s-section"> <div class="s-section">
<p class="s-section-title">Session Cache</p> <p class="s-section-title">Session Cache</p>
<div class="s-section-body"> <div class="s-section-body">
@@ -13,14 +13,15 @@
import type { BackupEntry } from "@core/persistence/persist"; import type { BackupEntry } from "@core/persistence/persist";
import { DEFAULT_SETTINGS } from "@types/settings"; import { DEFAULT_SETTINGS } from "@types/settings";
import { DEFAULT_READING_STATS } from "@types/history"; import { DEFAULT_READING_STATS } from "@types/history";
import { clearBlobCache } from "@core/cache/imageCache";
import { clearPageCache } from "@core/cache/pageCache";
import { cache as queryCache } from "@core/cache/queryCache";
type ResetState = "idle" | "busy" | "done" | "error"; type ResetState = "idle" | "busy" | "done" | "error";
interface ResetItem { key: string; label: string; desc: string; state: ResetState; error: string | null; confirm: boolean; } interface ResetItem { key: string; label: string; desc: string; state: ResetState; error: string | null; confirm: boolean; }
let resetItems = $state<ResetItem[]>([ let resetItems = $state<ResetItem[]>([
{ key: "moku-cache", label: "Clear Moku cache", desc: "Removes image cache and temporary files stored by Moku.", state: "idle", error: null, confirm: false }, { key: "all-cache", label: "Clear all caches", desc: "Flushes the image blob cache, page cache, query cache, Moku disk cache, Suwayomi disk cache, and server image/thumbnail cache in one pass.", state: "idle", error: null, confirm: false },
{ key: "suwayomi-cache", label: "Clear Suwayomi cache", desc: "Deletes the Suwayomi cache and KCEF directories inside the data folder.", state: "idle", error: null, confirm: false },
{ key: "server-cache", label: "Clear server image cache", desc: "Removes cached chapter pages and thumbnails stored on the Suwayomi server.", state: "idle", error: null, confirm: false },
{ key: "reading-history", label: "Clear reading history", desc: "Erases chapter history, read log, reading stats, and daily read counts.", state: "idle", error: null, confirm: true }, { key: "reading-history", label: "Clear reading history", desc: "Erases chapter history, read log, reading stats, and daily read counts.", state: "idle", error: null, confirm: true },
{ key: "moku-settings", label: "Reset Moku settings", desc: "Restores all app settings to their defaults. Does not affect library data.", state: "idle", error: null, confirm: true }, { key: "moku-settings", label: "Reset Moku settings", desc: "Restores all app settings to their defaults. Does not affect library data.", state: "idle", error: null, confirm: true },
{ key: "suwayomi-data", label: "Reset Suwayomi data", desc: "Deletes the database, extensions, settings, and logs. Downloads and backups are preserved.", state: "idle", error: null, confirm: true }, { key: "suwayomi-data", label: "Reset Suwayomi data", desc: "Deletes the database, extensions, settings, and logs. Downloads and backups are preserved.", state: "idle", error: null, confirm: true },
@@ -73,19 +74,24 @@
}); });
} }
async function clearAllCaches(): Promise<void> {
clearBlobCache();
clearPageCache();
queryCache.clearAll();
await Promise.all([
invoke("clear_moku_cache"),
invoke("clear_suwayomi_cache"),
gql(CLEAR_CACHED_IMAGES, { cachedPages: true, cachedThumbnails: true, downloadedThumbnails: false }),
]);
}
async function runReset(key: string) { async function runReset(key: string) {
confirming = null; confirming = null;
patchReset(key, { state: "busy", error: null }); patchReset(key, { state: "busy", error: null });
try { try {
switch (key) { switch (key) {
case "moku-cache": case "all-cache":
await invoke("clear_moku_cache"); await clearAllCaches();
break;
case "suwayomi-cache":
await invoke("clear_suwayomi_cache");
break;
case "server-cache":
await gql(CLEAR_CACHED_IMAGES, { cachedPages: true, cachedThumbnails: true, downloadedThumbnails: false });
break; break;
case "reading-history": case "reading-history":
store.clearHistory(); store.clearHistory();
@@ -159,7 +165,6 @@
let multiStorageInfos = $state<(StorageInfo & { label: string })[]>([]); let multiStorageInfos = $state<(StorageInfo & { label: string })[]>([]);
let advStorageOpen = $state(false); let advStorageOpen = $state(false);
let backupSectionOpen = $state(false); let backupSectionOpen = $state(false);
let appDataSectionOpen = $state(false);
let resetSectionOpen = $state(false); let resetSectionOpen = $state(false);
async function fetchStorage() { async function fetchStorage() {
@@ -661,6 +666,9 @@
</button> </button>
{#if backupSectionOpen} {#if backupSectionOpen}
<div class="s-collapsible-body"> <div class="s-collapsible-body">
<p class="s-subsection-title">Library backup</p>
<div class="s-row"> <div class="s-row">
<div class="s-row-info"> <div class="s-row-info">
<span class="s-label">Create backup</span> <span class="s-label">Create backup</span>
@@ -768,17 +776,8 @@
{/if} {/if}
</div> </div>
{/if} {/if}
</div>
{/if}
</div>
<div class="s-section"> <p class="s-subsection-title">App data backup</p>
<button class="s-collapsible-trigger" onclick={() => appDataSectionOpen = !appDataSectionOpen}>
<span class="s-label">App-Data Backup</span>
<svg class="s-collapsible-caret" class:open={appDataSectionOpen} width="10" height="6" viewBox="0 0 10 6"><path d="M0 0l5 6 5-6" fill="currentColor"/></svg>
</button>
{#if appDataSectionOpen}
<div class="s-collapsible-body">
<div class="s-row"> <div class="s-row">
<div class="s-row-info"> <div class="s-row-info">
@@ -16,10 +16,12 @@
let oauthTrackerId = $state<number | null>(null); let oauthTrackerId = $state<number | null>(null);
let oauthCallbackInput = $state(""); let oauthCallbackInput = $state("");
let oauthSubmitting = $state(false); let oauthSubmitting = $state(false);
let oauthError = $state<string | null>(null);
let credsTrackerId = $state<number | null>(null); let credsTrackerId = $state<number | null>(null);
let credsUsername = $state(""); let credsUsername = $state("");
let credsPassword = $state(""); let credsPassword = $state("");
let credsSubmitting = $state(false); let credsSubmitting = $state(false);
let credsError = $state<string | null>(null);
let loggingOut = $state<number | null>(null); let loggingOut = $state<number | null>(null);
$effect(() => { $effect(() => {
@@ -50,11 +52,11 @@
await loadTrackers(); await loadTrackers();
oauthTrackerId = null; oauthCallbackInput = ""; oauthTrackerId = null; oauthCallbackInput = "";
} catch (e: any) { } catch (e: any) {
trackersError = e?.message ?? "Login failed"; oauthError = e?.message ?? "Login failed";
} finally { oauthSubmitting = false; } } finally { oauthSubmitting = false; }
} }
function cancelOAuth() { oauthTrackerId = null; oauthCallbackInput = ""; } function cancelOAuth() { oauthTrackerId = null; oauthCallbackInput = ""; oauthError = null; }
function startCredentials(tracker: Tracker) { credsTrackerId = tracker.id; credsUsername = ""; credsPassword = ""; } function startCredentials(tracker: Tracker) { credsTrackerId = tracker.id; credsUsername = ""; credsPassword = ""; }
@@ -66,11 +68,11 @@
await loadTrackers(); await loadTrackers();
credsTrackerId = null; credsUsername = ""; credsPassword = ""; credsTrackerId = null; credsUsername = ""; credsPassword = "";
} catch (e: any) { } catch (e: any) {
trackersError = e?.message ?? "Login failed"; credsError = e?.message ?? "Login failed";
} finally { credsSubmitting = false; } } finally { credsSubmitting = false; }
} }
function cancelCredentials() { credsTrackerId = null; credsUsername = ""; credsPassword = ""; } function cancelCredentials() { credsTrackerId = null; credsUsername = ""; credsPassword = ""; credsError = null; }
async function logoutTracker(trackerId: number) { async function logoutTracker(trackerId: number) {
loggingOut = trackerId; loggingOut = trackerId;
@@ -127,7 +129,7 @@
<p class="s-section-title">Connected Trackers</p> <p class="s-section-title">Connected Trackers</p>
<div class="s-section-body"> <div class="s-section-body">
{#if trackersError} {#if trackersError}
<div class="s-banner s-banner-error">{trackersError}</div> <div class="s-banner s-banner-error" onclick={() => trackersError = null} role="button" tabindex="0" onkeydown={(e) => e.key === "Enter" && (trackersError = null)}>{trackersError}</div>
{/if} {/if}
{#if trackersLoading} {#if trackersLoading}
<p class="s-empty">Loading trackers…</p> <p class="s-empty">Loading trackers…</p>
@@ -168,6 +170,9 @@
</div> </div>
{#if oauthTrackerId === tracker.id} {#if oauthTrackerId === tracker.id}
<div class="s-tracker-expand"> <div class="s-tracker-expand">
{#if oauthError}
<div class="s-banner s-banner-error s-banner-dismissible" onclick={() => oauthError = null} role="button" tabindex="0" onkeydown={(e) => e.key === "Enter" && (oauthError = null)}>{oauthError}</div>
{/if}
<p class="s-oauth-hint">Browser opened {tracker.name} login — authorise then paste the callback URL below.</p> <p class="s-oauth-hint">Browser opened {tracker.name} login — authorise then paste the callback URL below.</p>
<input class="s-input full" placeholder="https://suwayomi.org/tracker-oauth#access_token=…" <input class="s-input full" placeholder="https://suwayomi.org/tracker-oauth#access_token=…"
bind:value={oauthCallbackInput} bind:value={oauthCallbackInput}
@@ -183,6 +188,9 @@
{/if} {/if}
{#if credsTrackerId === tracker.id} {#if credsTrackerId === tracker.id}
<div class="s-tracker-expand"> <div class="s-tracker-expand">
{#if credsError}
<div class="s-banner s-banner-error s-banner-dismissible" onclick={() => credsError = null} role="button" tabindex="0" onkeydown={(e) => e.key === "Enter" && (credsError = null)}>{credsError}</div>
{/if}
<input class="s-input full" placeholder="Username / Email" bind:value={credsUsername} <input class="s-input full" placeholder="Username / Email" bind:value={credsUsername}
onkeydown={(e) => e.key === "Escape" && cancelCredentials()} use:focusEl /> onkeydown={(e) => e.key === "Escape" && cancelCredentials()} use:focusEl />
<input class="s-input full" type="password" placeholder="Password" bind:value={credsPassword} <input class="s-input full" type="password" placeholder="Password" bind:value={credsPassword}
@@ -266,4 +274,6 @@
<style> <style>
.s-tracker-status-row { display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap; } .s-tracker-status-row { display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap; }
.s-pill-warn { background: color-mix(in srgb, var(--color-warn, #c97c2b) 15%, transparent); color: var(--color-warn, #c97c2b); border-color: color-mix(in srgb, var(--color-warn, #c97c2b) 35%, transparent); } .s-pill-warn { background: color-mix(in srgb, var(--color-warn, #c97c2b) 15%, transparent); color: var(--color-warn, #c97c2b); border-color: color-mix(in srgb, var(--color-warn, #c97c2b) 35%, transparent); }
.s-banner-dismissible { cursor: pointer; max-height: 8rem; overflow-y: auto; }
.s-banner-dismissible:hover { opacity: 0.85; }
</style> </style>
@@ -115,7 +115,7 @@
function openManga() { function openManga() {
if (!record.manga) return; if (!record.manga) return;
setActiveManga(record.manga as any); setActiveManga(record.manga as any);
setNavPage("library"); setNavPage(store.navPage);
onClose(); onClose();
} }
@@ -32,15 +32,11 @@
<div class="toolbar"> <div class="toolbar">
<div class="toolbar-top"> <div class="toolbar-top">
<span class="heading">Tracking</span> <span class="heading">Tracking</span>
<button class="icon-btn" onclick={onRefresh} disabled={loading} title="Refresh">
<ArrowsClockwise size={14} weight="bold" class={loading ? "anim-spin" : ""} />
</button>
</div>
{#if !loading && loggedIn.length > 0} {#if !loading && loggedIn.length > 0}
<div class="tracker-tabs"> <div class="tabs">
<button <button
class="tracker-tab" class:active={activeTrackerId === "all"} class="tab" class:active={activeTrackerId === "all"}
onclick={() => onTrackerChange("all")} onclick={() => onTrackerChange("all")}
> >
All All
@@ -48,7 +44,7 @@
</button> </button>
{#each loggedIn as t} {#each loggedIn as t}
<button <button
class="tracker-tab" class:active={activeTrackerId === t.id} class="tab" class:active={activeTrackerId === t.id}
onclick={() => onTrackerChange(t.id)} onclick={() => onTrackerChange(t.id)}
> >
<Thumbnail src={t.icon} alt={t.name} class="tab-icon" /> <Thumbnail src={t.icon} alt={t.name} class="tab-icon" />
@@ -57,7 +53,16 @@
</button> </button>
{/each} {/each}
</div> </div>
{/if}
<div class="header-right">
<button class="icon-btn" onclick={onRefresh} disabled={loading} title="Refresh">
<ArrowsClockwise size={14} weight="bold" class={loading ? "anim-spin" : ""} />
</button>
</div>
</div>
{#if !loading && loggedIn.length > 0}
<div class="filter-row"> <div class="filter-row">
<div class="search-wrap"> <div class="search-wrap">
<MagnifyingGlass size={13} weight="light" class="search-ico" /> <MagnifyingGlass size={13} weight="light" class="search-ico" />
@@ -94,79 +99,40 @@
</div> </div>
<style> <style>
.toolbar { flex-shrink: 0; border-bottom: 1px solid var(--border-dim); } .toolbar { flex-shrink: 0; }
.toolbar-top { .toolbar-top {
display: flex; align-items: center; justify-content: space-between; display: flex; align-items: center; gap: var(--sp-4);
padding: var(--sp-4) var(--sp-6); padding: var(--sp-4) var(--sp-6);
border-bottom: 1px solid var(--border-dim);
} }
.heading { .heading { font-family: var(--font-ui); font-size: var(--text-xs); font-weight: var(--weight-normal); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; flex-shrink: 0; }
font-family: var(--font-ui); font-size: var(--text-xs); font-weight: var(--weight-normal); .header-right { display: flex; align-items: center; gap: var(--sp-2); margin-left: auto; flex-shrink: 0; }
color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase;
}
.icon-btn {
display: flex; align-items: center; justify-content: center;
width: 26px; height: 26px; border-radius: var(--radius-sm);
color: var(--text-faint); background: none; border: none; cursor: pointer;
transition: color var(--t-base), background var(--t-base);
}
.icon-btn:hover:not(:disabled) { color: var(--text-muted); background: var(--bg-raised); }
.icon-btn:disabled { opacity: 0.3; cursor: default; }
.tracker-tabs { .tabs { display: flex; align-items: center; gap: 2px; background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 2px; overflow-x: auto; scrollbar-width: none; }
display: flex; align-items: center; gap: 1px; .tabs::-webkit-scrollbar { display: none; }
padding: 0 var(--sp-5); overflow-x: auto; scrollbar-width: none;
} .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); border: 1px solid transparent; color: var(--text-faint); white-space: nowrap; cursor: pointer; flex-shrink: 0; transition: background var(--t-base), color var(--t-base), border-color var(--t-base); }
.tracker-tabs::-webkit-scrollbar { display: none; } .tab:hover { color: var(--text-muted); }
.tab.active { background: var(--accent-muted); color: var(--accent-fg); border-color: var(--accent-dim); }
.tracker-tab {
display: flex; align-items: center; gap: 6px;
padding: 8px 10px 7px;
font-family: var(--font-ui); font-size: var(--text-2xs);
letter-spacing: var(--tracking-wider); text-transform: uppercase;
color: var(--text-faint); background: none; border: none;
border-bottom: 2px solid transparent;
cursor: pointer; white-space: nowrap; margin-bottom: -1px;
transition: color var(--t-base), border-color var(--t-base);
}
.tracker-tab:hover { color: var(--text-muted); }
.tracker-tab.active { color: var(--text-secondary); border-bottom-color: var(--accent); }
:global(.tab-icon) { width: 13px; height: 13px; border-radius: 2px; object-fit: contain; opacity: 0.8; } :global(.tab-icon) { width: 13px; height: 13px; border-radius: 2px; object-fit: contain; opacity: 0.8; }
.tab-count { .tab-count { font-size: var(--text-2xs); opacity: 0.6; }
font-size: 10px; padding: 1px 5px; border-radius: var(--radius-full); .tab.active .tab-count { opacity: 1; }
background: var(--bg-overlay); color: var(--text-faint); line-height: 15px;
}
.tracker-tab.active .tab-count { background: var(--accent-muted); color: var(--accent-fg); }
.filter-row { .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); }
display: flex; align-items: center; gap: var(--sp-2); .icon-btn:hover:not(:disabled) { color: var(--text-primary); border-color: var(--border-strong); }
padding: var(--sp-2) var(--sp-5) var(--sp-3); .icon-btn:disabled { opacity: 0.3; cursor: default; }
}
.search-wrap { .filter-row { display: flex; align-items: center; gap: var(--sp-2); padding: var(--sp-3) var(--sp-6); border-bottom: 1px solid var(--border-dim); }
flex: 1; display: flex; align-items: center; gap: var(--sp-2); .search-wrap { flex: 1; display: flex; align-items: center; gap: var(--sp-2); background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 5px 10px; transition: border-color var(--t-base); }
background: var(--bg-raised); border: 1px solid var(--border-dim);
border-radius: var(--radius-md); padding: 5px 10px;
transition: border-color var(--t-base);
}
.search-wrap:focus-within { border-color: var(--border-strong); } .search-wrap:focus-within { border-color: var(--border-strong); }
:global(.search-ico) { color: var(--text-faint); flex-shrink: 0; } :global(.search-ico) { color: var(--text-faint); flex-shrink: 0; }
.search-input { .search-input { flex: 1; background: none; border: none; outline: none; min-width: 0; font-size: var(--text-sm); color: var(--text-primary); }
flex: 1; background: none; border: none; outline: none; min-width: 0;
font-size: var(--text-sm); color: var(--text-primary);
}
.search-input::placeholder { color: var(--text-faint); } .search-input::placeholder { color: var(--text-faint); }
.pill-select { .pill-select { flex-shrink: 0; font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 5px 22px 5px 9px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: var(--bg-raised); color: var(--text-faint); outline: none; cursor: pointer; appearance: none; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='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 7px center; transition: border-color var(--t-base), color var(--t-base); }
flex-shrink: 0;
font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide);
padding: 5px 22px 5px 9px; border-radius: var(--radius-md);
border: 1px solid var(--border-dim); background: var(--bg-raised);
color: var(--text-faint); outline: none; cursor: pointer; appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='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 7px center;
transition: border-color var(--t-base), color var(--t-base);
}
.pill-select:hover { border-color: var(--border-strong); color: var(--text-muted); } .pill-select:hover { border-color: var(--border-strong); color: var(--text-muted); }
.pill-select option { background: var(--bg-surface); color: var(--text-secondary); } .pill-select option { background: var(--bg-surface); color: var(--text-secondary); }
</style> </style>
+22 -4
View File
@@ -134,6 +134,21 @@ export async function syncBackFromTracker(
scanlatorForce: false, scanlatorForce: false,
}), }),
}); });
const seenInt = new Map<number, Chapter>();
for (const ch of eligible) {
const key = Math.floor(ch.chapterNumber);
if (!Number.isInteger(ch.chapterNumber)) continue;
if (!seenInt.has(key)) seenInt.set(key, ch);
}
const dedupedEligible = [...seenInt.values()];
const decimalsByFloor = new Map<number, Chapter[]>();
for (const ch of eligible) {
if (Number.isInteger(ch.chapterNumber)) continue;
const key = Math.floor(ch.chapterNumber);
const arr = decimalsByFloor.get(key) ?? [];
arr.push(ch);
decimalsByFloor.set(key, arr);
}
const toMarkRead: number[] = []; const toMarkRead: number[] = [];
@@ -141,11 +156,14 @@ export async function syncBackFromTracker(
const remote = record.lastChapterRead; const remote = record.lastChapterRead;
if (!remote || remote <= 0) continue; if (!remote || remote <= 0) continue;
for (const chapter of eligible) { for (const chapter of dedupedEligible) {
if (chapter.isRead) continue; if (chapter.isRead) continue;
const diff = Math.abs(chapter.chapterNumber - remote); if (chapter.chapterNumber > remote) continue;
if (opts.threshold !== null && diff > opts.threshold) continue; if (opts.threshold !== null && remote - chapter.chapterNumber > opts.threshold) continue;
if (chapter.chapterNumber <= remote) toMarkRead.push(chapter.id); toMarkRead.push(chapter.id);
for (const dec of decimalsByFloor.get(chapter.chapterNumber) ?? []) {
if (!dec.isRead) toMarkRead.push(dec.id);
}
} }
} }
+30 -28
View File
@@ -1,10 +1,29 @@
<script lang="ts"> <script lang="ts">
import { onMount } from "svelte";
import { ClockCounterClockwise, Trash, MagnifyingGlass, Books, Fire, BookOpen, Clock, TrendUp } from "phosphor-svelte"; import { ClockCounterClockwise, Trash, MagnifyingGlass, Books, Fire, BookOpen, Clock, TrendUp } from "phosphor-svelte";
import Thumbnail from "@shared/manga/Thumbnail.svelte"; import Thumbnail from "@shared/manga/Thumbnail.svelte";
import { store, clearHistory, setPreviewManga } from "@store/state.svelte"; import { store, clearHistory, setPreviewManga } from "@store/state.svelte";
import { gql } from "@api/client";
import { GET_LIBRARY } from "@api/queries/manga";
import { cache, CACHE_KEYS } from "@core/cache";
import type { HistoryEntry } from "@store/state.svelte"; import type { HistoryEntry } from "@store/state.svelte";
import type { Manga } from "@types";
import { timeAgo, dayLabel, formatReadTime } from "@core/util"; import { timeAgo, dayLabel, formatReadTime } from "@core/util";
let libraryManga = $state<Manga[]>([]);
onMount(() => {
cache.get(CACHE_KEYS.LIBRARY, () =>
gql<{ mangas: { nodes: Manga[] } }>(GET_LIBRARY).then(d => d.mangas.nodes)
)
.then(m => { libraryManga = m; })
.catch(() => {});
});
function thumbFor(mangaId: number, fallback: string): string {
return libraryManga.find(m => m.id === mangaId)?.thumbnailUrl ?? fallback ?? "";
}
let search = $state(""); let search = $state("");
let confirmClear = $state(false); let confirmClear = $state(false);
@@ -173,9 +192,9 @@
</div> </div>
<div class="session-list"> <div class="session-list">
{#each items as session (session.latestChapterId)} {#each items as session (session.latestChapterId)}
<button class="session-row" onclick={() => setPreviewManga({ id: session.mangaId, title: session.mangaTitle, thumbnailUrl: session.thumbnailUrl } as any)}> <button class="session-row" onclick={() => setPreviewManga({ id: session.mangaId, title: session.mangaTitle, thumbnailUrl: thumbFor(session.mangaId, session.thumbnailUrl) } as any)}>
<div class="thumb-wrap"> <div class="thumb-wrap">
<Thumbnail src={session.thumbnailUrl} alt={session.mangaTitle} class="thumb" /> <Thumbnail src={thumbFor(session.mangaId, 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}
@@ -215,7 +234,7 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding: var(--sp-3) var(--sp-5); padding: var(--sp-4) var(--sp-6);
border-bottom: 1px solid var(--border-dim); border-bottom: 1px solid var(--border-dim);
flex-shrink: 0; flex-shrink: 0;
} }
@@ -292,33 +311,16 @@
.search-clear:hover { color: var(--text-muted); } .search-clear:hover { color: var(--text-muted); }
.clear-btn { .clear-btn {
display: flex; display: flex; align-items: center; gap: 4px;
align-items: center; height: 30px; padding: 0 var(--sp-2);
gap: 4px; border-radius: var(--radius-md); border: 1px solid var(--border-dim);
height: 26px; background: var(--bg-raised); color: var(--text-faint);
padding: 0 var(--sp-2); cursor: pointer; font-family: var(--font-ui); font-size: var(--text-2xs);
border-radius: var(--radius-md); letter-spacing: var(--tracking-wide); flex-shrink: 0;
color: var(--text-faint);
background: none;
border: 1px solid transparent;
cursor: pointer;
font-family: var(--font-ui);
font-size: var(--text-2xs);
letter-spacing: var(--tracking-wide);
transition: color var(--t-base), background var(--t-base), border-color var(--t-base); transition: color var(--t-base), background var(--t-base), border-color var(--t-base);
} }
.clear-btn:hover { color: var(--color-error); background: var(--color-error-bg); border-color: color-mix(in srgb, var(--color-error) 30%, transparent); }
.clear-btn:hover { .clear-btn.confirm { color: var(--color-error); background: var(--color-error-bg); border-color: var(--color-error); }
color: var(--color-error);
background: var(--color-error-bg);
border-color: color-mix(in srgb, var(--color-error) 20%, transparent);
}
.clear-btn.confirm {
color: var(--color-error);
background: var(--color-error-bg);
border-color: var(--color-error);
}
.clear-label { font-size: var(--text-2xs); } .clear-label { font-size: var(--text-2xs); }
+3 -1
View File
@@ -25,6 +25,7 @@
store.activeManga = null; store.activeManga = null;
store.activeSource = null; store.activeSource = null;
store.genreFilter = ""; store.genreFilter = "";
store.searchQuery = "";
} }
function goHome() { function goHome() {
@@ -33,6 +34,7 @@
store.activeManga = null; store.activeManga = null;
store.libraryFilter = "library"; store.libraryFilter = "library";
store.genreFilter = ""; store.genreFilter = "";
store.searchQuery = "";
} }
</script> </script>
@@ -59,7 +61,7 @@
</aside> </aside>
<style> <style>
.root { width: var(--sidebar-width); flex-shrink: 0; background: transparent; display: flex; flex-direction: column; align-items: center; padding: var(--sp-4) 0; overflow: hidden; min-height: 0; height: 100%; } .root { width: var(--sidebar-width); flex-shrink: 0; background: transparent; display: flex; flex-direction: column; align-items: center; padding: var(--sp-4) 0; overflow: hidden; min-height: 0; height: 100%; border-right: 1px solid var(--border-dim); }
.logo { width: 56px; height: 56px; flex-shrink: 0; display: flex; align-items: center; justify-content: center; margin-bottom: var(--sp-4); background: none; border: none; outline: none; cursor: pointer; border-radius: var(--radius-lg); padding: 0; appearance: none; -webkit-appearance: none; } .logo { width: 56px; height: 56px; flex-shrink: 0; display: flex; align-items: center; justify-content: center; margin-bottom: var(--sp-4); background: none; border: none; outline: none; cursor: pointer; border-radius: var(--radius-lg); padding: 0; appearance: none; -webkit-appearance: none; }
.logo:hover { opacity: 0.8; } .logo:hover { opacity: 0.8; }
+4 -2
View File
@@ -3,6 +3,8 @@
import { getCurrentWindow } from "@tauri-apps/api/window"; import { getCurrentWindow } from "@tauri-apps/api/window";
import { platform } from "@tauri-apps/plugin-os"; import { platform } from "@tauri-apps/plugin-os";
const { onClose }: { onClose: () => void } = $props();
const win = getCurrentWindow(); const win = getCurrentWindow();
const os = platform(); const os = platform();
const isMac = os === "macos"; const isMac = os === "macos";
@@ -31,7 +33,7 @@
<button onclick={() => win.toggleMaximize()} title="Maximize" aria-label="Maximize"> <button onclick={() => win.toggleMaximize()} title="Maximize" aria-label="Maximize">
<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> <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>
</button> </button>
<button class="close" onclick={() => win.close()} title="Close" aria-label="Close"> <button class="close" onclick={onClose} title="Close" aria-label="Close">
<svg width="10" height="10" viewBox="0 0 10 10"> <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="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" /> <line x1="9" y1="1" x2="1" y2="9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
@@ -50,7 +52,7 @@
<polyline points="4,9 1,9 1,6" 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> </svg>
</button> </button>
<button class="close" onclick={() => win.close()} title="Close" aria-label="Close"> <button class="close" onclick={onClose} title="Close" aria-label="Close">
<svg width="10" height="10" viewBox="0 0 10 10"> <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="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" /> <line x1="9" y1="1" x2="1" y2="9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
+122 -1
View File
@@ -6,6 +6,8 @@
const leaving = new Set<string>(); const leaving = new Set<string>();
const timers = new Map<string, ReturnType<typeof setTimeout>>(); const timers = new Map<string, ReturnType<typeof setTimeout>>();
let detail = $state<Toast | null>(null);
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;
@@ -30,12 +32,23 @@
dismissToast(id); dismissToast(id);
} }
function openDetail(e: MouseEvent, t: Toast) {
e.preventDefault();
detail = t;
if (timers.has(t.id)) { clearTimeout(timers.get(t.id)!); timers.delete(t.id); }
}
function closeDetail() {
detail = null;
}
$effect(() => { $effect(() => {
const activeIds = new Set(store.toasts.map(t => t.id)); const activeIds = new Set(store.toasts.map(t => t.id));
store.toasts.forEach(schedule); store.toasts.forEach(schedule);
for (const [id, timer] of timers) { for (const [id, timer] of timers) {
if (!activeIds.has(id)) { clearTimeout(timer); timers.delete(id); } if (!activeIds.has(id)) { clearTimeout(timer); timers.delete(id); }
} }
if (detail && !activeIds.has(detail.id)) detail = null;
}); });
const icons: Record<Toast["kind"], string> = { const icons: Record<Toast["kind"], string> = {
@@ -49,7 +62,10 @@
{#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 class="toast toast-{t.kind}" data-toast-id={t.id} aria-label="{t.title}{t.body ? ': ' + t.body : ''}" onclick={() => dismiss(t.id)}> <button class="toast toast-{t.kind}" data-toast-id={t.id} aria-label="{t.title}{t.body ? ': ' + t.body : ''}"
onclick={() => dismiss(t.id)}
oncontextmenu={(e) => openDetail(e, t)}
>
<div class="accent-bar"></div> <div class="accent-bar"></div>
<span class="icon"> <span class="icon">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
@@ -65,6 +81,36 @@
</div> </div>
{/if} {/if}
{#if detail}
<div class="detail-backdrop" role="presentation" onclick={closeDetail} oncontextmenu={(e) => e.preventDefault()}>
<div class="detail-panel detail-{detail.kind}" role="dialog" onclick={(e) => e.stopPropagation()}>
<div class="detail-accent"></div>
<div class="detail-body">
<div class="detail-header">
<span class="detail-kind">{detail.kind}</span>
<button class="detail-close" onclick={closeDetail} aria-label="Close">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
<p class="detail-title">{detail.title}</p>
{#if detail.body}
<pre class="detail-text">{detail.body}</pre>
{/if}
<div class="detail-actions">
<button class="detail-copy" onclick={() => navigator.clipboard.writeText(`${detail!.title}${detail!.body ? '\n' + detail!.body : ''}`)}>
Copy
</button>
<button class="detail-dismiss" onclick={() => { dismiss(detail!.id); closeDetail(); }}>
Dismiss
</button>
</div>
</div>
</div>
</div>
{/if}
<style> <style>
.toaster { position: fixed; bottom: var(--sp-5); right: var(--sp-5); z-index: 9999; display: flex; flex-direction: column; gap: 5px; pointer-events: none; } .toaster { position: fixed; bottom: var(--sp-5); right: var(--sp-5); z-index: 9999; display: flex; flex-direction: column; gap: 5px; pointer-events: none; }
@@ -105,4 +151,79 @@
.body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 5px; } .body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 5px; }
.title { font-size: var(--text-xs); font-family: var(--font-ui); color: var(--text-secondary); font-weight: var(--weight-medium); letter-spacing: var(--tracking-wide); line-height: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .title { font-size: var(--text-xs); font-family: var(--font-ui); color: var(--text-secondary); font-weight: var(--weight-medium); letter-spacing: var(--tracking-wide); line-height: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.sub { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); line-height: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .sub { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); line-height: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.detail-backdrop {
position: fixed; inset: 0; z-index: 10000;
background: rgba(0,0,0,0.45);
display: flex; align-items: center; justify-content: center;
animation: fadeIn 0.15s ease both;
}
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
.detail-panel {
display: flex; width: 420px; max-width: calc(100vw - 32px); max-height: 60vh;
border-radius: var(--radius-lg); background: var(--bg-raised);
border: 1px solid var(--border-base);
box-shadow: 0 24px 64px rgba(0,0,0,0.7), 0 1px 0 rgba(255,255,255,0.05) inset;
overflow: hidden;
animation: popIn 0.2s cubic-bezier(0.16,1,0.3,1) both;
}
@keyframes popIn { from { opacity: 0; transform: scale(0.95) } to { opacity: 1; transform: scale(1) } }
.detail-accent { width: 3px; flex-shrink: 0; }
.detail-error .detail-accent { background: var(--color-error); }
.detail-success .detail-accent { background: var(--accent-fg); }
.detail-info .detail-accent { background: var(--text-faint); }
.detail-download .detail-accent { background: var(--accent-fg); }
.detail-body { flex: 1; min-width: 0; display: flex; flex-direction: column; padding: var(--sp-3); gap: var(--sp-2); overflow: hidden; }
.detail-header { display: flex; align-items: center; justify-content: space-between; }
.detail-kind {
font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wider);
text-transform: uppercase; color: var(--text-faint);
}
.detail-error .detail-kind { color: var(--color-error); }
.detail-close {
display: flex; align-items: center; justify-content: center;
width: 20px; height: 20px; border-radius: var(--radius-sm);
background: none; border: none; color: var(--text-faint); cursor: pointer;
transition: color var(--t-fast), background var(--t-fast);
}
.detail-close:hover { color: var(--text-primary); background: var(--bg-overlay); }
.detail-title {
font-family: var(--font-ui); font-size: var(--text-sm);
color: var(--text-secondary); font-weight: var(--weight-medium);
line-height: var(--leading-snug); word-break: break-word;
}
.detail-text {
flex: 1; min-height: 0; overflow-y: auto;
font-family: var(--font-mono, monospace); font-size: var(--text-xs);
color: var(--text-muted); line-height: var(--leading-relaxed);
white-space: pre-wrap; word-break: break-all;
background: var(--bg-void); border: 1px solid var(--border-dim);
border-radius: var(--radius-sm); padding: var(--sp-2) var(--sp-3);
scrollbar-width: thin;
margin: 0;
}
.detail-actions { display: flex; gap: var(--sp-2); margin-top: var(--sp-1); }
.detail-copy, .detail-dismiss {
font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide);
padding: 5px var(--sp-3); border-radius: var(--radius-sm); cursor: pointer;
transition: color var(--t-base), background var(--t-base), border-color var(--t-base);
}
.detail-copy {
border: 1px solid var(--border-dim); background: none; color: var(--text-muted);
}
.detail-copy:hover { color: var(--text-primary); border-color: var(--border-strong); background: var(--bg-overlay); }
.detail-dismiss {
border: 1px solid color-mix(in srgb, var(--color-error) 40%, transparent);
background: color-mix(in srgb, var(--color-error) 10%, transparent);
color: var(--color-error);
}
.detail-dismiss:hover { background: color-mix(in srgb, var(--color-error) 18%, transparent); }
</style> </style>
+4 -1
View File
@@ -42,6 +42,8 @@
let loadingLinkList = $state(false); let loadingLinkList = $state(false);
let coverPickerOpen = $state(false); let coverPickerOpen = $state(false);
let originNavPage = store.navPage;
const linkedIds = $derived( const linkedIds = $derived(
store.previewManga ? (store.settings.mangaLinks?.[store.previewManga.id] ?? []) : [], store.previewManga ? (store.settings.mangaLinks?.[store.previewManga.id] ?? []) : [],
); );
@@ -152,6 +154,7 @@
const shouldAutoLink = store.settings.autoLinkOnOpen; const shouldAutoLink = store.settings.autoLinkOnOpen;
const focal = store.previewManga; const focal = store.previewManga;
if (focal) { if (focal) {
originNavPage = store.navPage;
load(focal.id); load(focal.id);
loadCategories(focal.id); loadCategories(focal.id);
if (shouldAutoLink) { if (shouldAutoLink) {
@@ -256,7 +259,7 @@
function openSeriesDetail() { function openSeriesDetail() {
if (!displayManga) return; if (!displayManga) return;
setActiveManga(displayManga); setActiveManga(displayManga);
setNavPage("library"); setNavPage(originNavPage);
close(); close();
} }
+5 -4
View File
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { thumbUrl, plainThumbUrl } from "@api/client"; import { plainThumbUrl, getServerUrl } from "@api/client";
import { store } from "@store/state.svelte"; import { store } from "@store/state.svelte";
import { getBlobUrl } from "@core/cache/imageCache"; import { getBlobUrl } from "@core/cache/imageCache";
@@ -23,7 +23,7 @@
[key: string]: any; [key: string]: any;
} = $props(); } = $props();
const isAuth = $derived(store.settings.serverAuthMode === "BASIC_AUTH"); const isAuth = $derived((store.settings.serverAuthMode ?? "NONE") !== "NONE");
let blobUrl = $state(""); let blobUrl = $state("");
let reqId = 0; let reqId = 0;
@@ -36,7 +36,8 @@
if (!_isAuth || !_src) { blobUrl = ""; return; } if (!_isAuth || !_src) { blobUrl = ""; return; }
const id = ++reqId; const id = ++reqId;
getBlobUrl(plainThumbUrl(_src), _priority) const bareUrl = _src.startsWith("http") ? _src : `${getServerUrl()}${_src}`;
getBlobUrl(bareUrl, _priority)
.then(u => { if (id === reqId) blobUrl = u; }) .then(u => { if (id === reqId) blobUrl = u; })
.catch(() => { if (id === reqId) blobUrl = ""; }); .catch(() => { if (id === reqId) blobUrl = ""; });
}); });
@@ -44,7 +45,7 @@
const resolved = $derived( const resolved = $derived(
isAuth isAuth
? (blobUrl || undefined) ? (blobUrl || undefined)
: (src ? thumbUrl(src) : undefined) : (src ? plainThumbUrl(src) : undefined)
); );
</script> </script>
+12
View File
@@ -6,12 +6,21 @@ class AppStore {
navPage: NavPage = $state("home"); navPage: NavPage = $state("home");
settingsOpen: boolean = $state(false); settingsOpen: boolean = $state(false);
searchPrefill: string = $state(""); searchPrefill: string = $state("");
searchQuery: string = $state("");
genreFilter: string = $state(""); genreFilter: string = $state("");
scrollPositions: Map<string, number> = $state(new Map());
setNavPage(next: NavPage) { this.navPage = next; } setNavPage(next: NavPage) { this.navPage = next; }
setSettingsOpen(next: boolean) { this.settingsOpen = next; } setSettingsOpen(next: boolean) { this.settingsOpen = next; }
setSearchPrefill(next: string) { this.searchPrefill = next; } setSearchPrefill(next: string) { this.searchPrefill = next; }
setSearchQuery(next: string) { this.searchQuery = next; }
setGenreFilter(next: string) { this.genreFilter = next; } setGenreFilter(next: string) { this.genreFilter = next; }
saveScroll(key: string, top: number) {
const m = new Map(this.scrollPositions);
m.set(key, top);
this.scrollPositions = m;
}
getScroll(key: string): number { return this.scrollPositions.get(key) ?? 0; }
} }
export const app = new AppStore(); export const app = new AppStore();
@@ -19,4 +28,7 @@ export const app = new AppStore();
export function setNavPage(next: NavPage) { app.setNavPage(next); } export function setNavPage(next: NavPage) { app.setNavPage(next); }
export function setSettingsOpen(next: boolean) { app.setSettingsOpen(next); } export function setSettingsOpen(next: boolean) { app.setSettingsOpen(next); }
export function setSearchPrefill(next: string) { app.setSearchPrefill(next); } export function setSearchPrefill(next: string) { app.setSearchPrefill(next); }
export function setSearchQuery(next: string) { app.setSearchQuery(next); }
export function setGenreFilter(next: string) { app.setGenreFilter(next); } export function setGenreFilter(next: string) { app.setGenreFilter(next); }
export function saveScroll(key: string, top: number) { app.saveScroll(key, top); }
export function getScroll(key: string): number { return app.getScroll(key); }
+65 -40
View File
@@ -2,8 +2,10 @@ import { store } from "@store/state.svelte";
import { probeServer, loginBasic, loginUI } from "@core/auth"; import { probeServer, loginBasic, loginUI } from "@core/auth";
import { trackingState } from "@features/tracking/store/trackingState.svelte"; import { trackingState } from "@features/tracking/store/trackingState.svelte";
import { loadAllStores } from "@core/persistence/persist"; import { loadAllStores } from "@core/persistence/persist";
import { notifyReauthSuccess } from "@api/client";
const MAX_ATTEMPTS = 40; const MAX_ATTEMPTS = 15;
const BG_MAX_ATTEMPTS = 60;
export const boot = $state({ export const boot = $state({
serverProbeOk: false, serverProbeOk: false,
@@ -25,6 +27,44 @@ export async function initStore() {
store.hydrate(saved); store.hydrate(saved);
} }
function handleProbeSuccess(gen: number) {
if (gen !== probeGeneration) return;
boot.serverProbeOk = true;
boot.failed = false;
boot.skipped = false;
trackingState.bootSync().catch(() => {});
}
function handleAuthRequired(gen: number) {
if (gen !== probeGeneration) return;
boot.serverProbeOk = true;
boot.failed = false;
const mode = store.settings.serverAuthMode ?? "NONE";
if (mode === "BASIC_AUTH") {
const user = store.settings.serverAuthUser?.trim() ?? "";
const pass = store.settings.serverAuthPass?.trim() ?? "";
if (user && pass) {
loginBasic(user, pass)
.then(() => { if (gen === probeGeneration) trackingState.bootSync().catch(() => {}); })
.catch(() => {
if (gen !== probeGeneration) return;
boot.loginUser = store.settings.serverAuthUser ?? "";
boot.loginRequired = true;
});
return;
}
boot.loginUser = store.settings.serverAuthUser ?? "";
boot.loginRequired = true;
return;
}
if (mode === "UI_LOGIN") {
boot.loginUser = store.settings.serverAuthUser ?? "";
boot.loginRequired = true;
return;
}
trackingState.bootSync().catch(() => {});
}
export function startProbe() { export function startProbe() {
const gen = ++probeGeneration; const gen = ++probeGeneration;
boot.failed = false; boot.failed = false;
@@ -35,51 +75,36 @@ export function startProbe() {
async function probe() { async function probe() {
if (gen !== probeGeneration) return; if (gen !== probeGeneration) return;
tries++; tries++;
const result = await probeServer(); const result = await probeServer();
if (gen !== probeGeneration) return; if (gen !== probeGeneration) return;
if (result === "ok") { if (result === "ok") { handleProbeSuccess(gen); return; }
boot.serverProbeOk = true; if (result === "auth_required") { handleAuthRequired(gen); return; }
trackingState.bootSync().catch(() => {}); if (tries >= MAX_ATTEMPTS) { boot.failed = true; startBackgroundProbe(gen); return; }
return;
setTimeout(probe, Math.min(300 + tries * 150, 1500));
} }
if (result === "auth_required") { setTimeout(probe, 100);
boot.serverProbeOk = true; }
const mode = store.settings.serverAuthMode ?? "NONE";
if (mode === "BASIC_AUTH") { function startBackgroundProbe(gen: number) {
const user = store.settings.serverAuthUser?.trim() ?? ""; let bgTries = 0;
const pass = store.settings.serverAuthPass?.trim() ?? "";
if (user && pass) { async function bgProbe() {
try {
await loginBasic(user, pass);
if (gen !== probeGeneration) return; if (gen !== probeGeneration) return;
trackingState.bootSync().catch(() => {}); bgTries++;
return; const result = await probeServer();
} catch {} if (gen !== probeGeneration) return;
}
boot.loginUser = store.settings.serverAuthUser ?? ""; if (result === "ok") { handleProbeSuccess(gen); return; }
boot.loginRequired = true; if (result === "auth_required") { handleAuthRequired(gen); return; }
return; if (bgTries >= BG_MAX_ATTEMPTS) return;
setTimeout(bgProbe, 2000);
} }
if (mode === "UI_LOGIN") { setTimeout(bgProbe, 2000);
boot.loginUser = store.settings.serverAuthUser ?? "";
boot.loginRequired = true;
return;
}
trackingState.bootSync().catch(() => {});
return;
}
if (tries >= MAX_ATTEMPTS) { boot.failed = true; return; }
setTimeout(probe, Math.min(750 + tries * 250, 3000));
}
setTimeout(probe, 2000);
} }
export function stopProbe() { export function stopProbe() {
@@ -93,7 +118,6 @@ export async function submitLogin(onSuccess: () => void): Promise<void> {
} }
boot.loginBusy = true; boot.loginBusy = true;
boot.loginError = null; boot.loginError = null;
try { try {
const mode = store.settings.serverAuthMode ?? "NONE"; const mode = store.settings.serverAuthMode ?? "NONE";
if (mode === "UI_LOGIN") { if (mode === "UI_LOGIN") {
@@ -101,12 +125,12 @@ export async function submitLogin(onSuccess: () => void): Promise<void> {
} else { } else {
await loginBasic(boot.loginUser.trim(), boot.loginPass.trim()); await loginBasic(boot.loginUser.trim(), boot.loginPass.trim());
} }
boot.loginRequired = false; boot.loginRequired = false;
boot.sessionExpired = false; boot.sessionExpired = false;
boot.skipped = false; boot.skipped = false;
boot.loginPass = ""; boot.loginPass = "";
boot.loginError = null; boot.loginError = null;
notifyReauthSuccess();
trackingState.bootSync().catch(() => {}); trackingState.bootSync().catch(() => {});
onSuccess(); onSuccess();
} catch (e: any) { } catch (e: any) {
@@ -126,10 +150,11 @@ export function retryBoot() {
} }
export function bypassBoot(onReady: () => void) { export function bypassBoot(onReady: () => void) {
probeGeneration++; const gen = probeGeneration;
boot.serverProbeOk = true; boot.serverProbeOk = true;
boot.loginRequired = false; boot.loginRequired = false;
boot.sessionExpired = false; boot.sessionExpired = false;
boot.skipped = true; boot.skipped = true;
onReady(); onReady();
startBackgroundProbe(gen);
} }
+13 -1
View File
@@ -10,6 +10,7 @@ import { notifications } from "./no
import { app } from "./app.svelte"; import { app } from "./app.svelte";
import { persistSettings, persistLibrary, persistUpdates } from "../core/persistence/persist"; import { persistSettings, persistLibrary, persistUpdates } from "../core/persistence/persist";
import type { PersistedData } from "../core/persistence/persist"; import type { PersistedData } from "../core/persistence/persist";
import { untrack } from "svelte";
function localDateStr(d: Date): string { function localDateStr(d: Date): string {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
@@ -53,6 +54,8 @@ function mergeSettings(saved: any): Settings {
readerPresets: saved?.settings?.readerPresets ?? [], readerPresets: saved?.settings?.readerPresets ?? [],
mangaReaderSettings: saved?.settings?.mangaReaderSettings ?? {}, mangaReaderSettings: saved?.settings?.mangaReaderSettings ?? {},
categoryFrecency: saved?.settings?.categoryFrecency ?? {}, categoryFrecency: saved?.settings?.categoryFrecency ?? {},
hiddenLibraryTabs: saved?.settings?.hiddenLibraryTabs ?? [],
libraryPinnedTabOrder: saved?.settings?.libraryPinnedTabOrder ?? [],
}; };
} }
@@ -93,6 +96,8 @@ class Store {
set settingsOpen(v) { app.setSettingsOpen(v); } set settingsOpen(v) { app.setSettingsOpen(v); }
get searchPrefill() { return app.searchPrefill; } get searchPrefill() { return app.searchPrefill; }
set searchPrefill(v) { app.setSearchPrefill(v); } set searchPrefill(v) { app.setSearchPrefill(v); }
get searchQuery() { return app.searchQuery; }
set searchQuery(v) { app.setSearchQuery(v); }
get genreFilter() { return app.genreFilter; } get genreFilter() { return app.genreFilter; }
set genreFilter(v) { app.setGenreFilter(v); } set genreFilter(v) { app.setGenreFilter(v); }
@@ -104,6 +109,9 @@ class Store {
(saved.settings as any)[key] = (DEFAULT_SETTINGS as any)[key]; (saved.settings as any)[key] = (DEFAULT_SETTINGS as any)[key];
} }
// Assign all persisted values outside of reactive tracking so the
// $effects registered below don't fire on this initial write.
untrack(() => {
this.settings = mergeSettings(saved); this.settings = mergeSettings(saved);
this.history = saved.history ?? []; this.history = saved.history ?? [];
this.bookmarks = saved.bookmarks ?? []; this.bookmarks = saved.bookmarks ?? [];
@@ -114,7 +122,10 @@ class Store {
this.libraryUpdates = saved.libraryUpdates ?? []; this.libraryUpdates = saved.libraryUpdates ?? [];
this.lastLibraryRefresh = saved.lastLibraryRefresh ?? 0; this.lastLibraryRefresh = saved.lastLibraryRefresh ?? 0;
this.acknowledgedUpdates = new Set(saved.acknowledgedUpdateIds ?? []); this.acknowledgedUpdates = new Set(saved.acknowledgedUpdateIds ?? []);
});
// Mark ready before registering effects so the first reactive run
// (which Svelte schedules after the current microtask) is allowed through.
this.#ready = true; this.#ready = true;
$effect.root(() => { $effect.root(() => {
@@ -281,6 +292,7 @@ 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?: string, mangaStatus?: string, UPDATE_MANGA_CATEGORIES: string, UPDATE_MANGA?: string, mangaStatus?: string,
): Promise<void> { ): Promise<void> {
if (this.settings.disableAutoComplete) return;
if (!chaps.length || mangaStatus === "ONGOING") return; if (!chaps.length || mangaStatus === "ONGOING") return;
const completed = categories.find(c => c.name === "Completed"); const completed = categories.find(c => c.name === "Completed");
if (!completed) return; if (!completed) return;
@@ -394,4 +406,4 @@ export async function checkAndMarkCompleted(
): Promise<void> { return store.checkAndMarkCompleted(mangaId, chaps, categories, gqlFn, UPDATE_MANGA_CATEGORIES, UPDATE_MANGA, mangaStatus); } ): Promise<void> { return store.checkAndMarkCompleted(mangaId, chaps, categories, gqlFn, UPDATE_MANGA_CATEGORIES, UPDATE_MANGA, mangaStatus); }
export { addToast, dismissToast, setActiveDownloads } from "./notifications.svelte"; export { addToast, dismissToast, setActiveDownloads } from "./notifications.svelte";
export { setNavPage, setSettingsOpen, setSearchPrefill, setGenreFilter } from "./app.svelte"; export { setNavPage, setSettingsOpen, setSearchPrefill, setSearchQuery, setGenreFilter, saveScroll, getScroll } from "./app.svelte";
+14
View File
@@ -124,6 +124,13 @@ export interface Settings {
trackerRespectScanlatorFilter: boolean; trackerRespectScanlatorFilter: boolean;
pinchZoom?: boolean; pinchZoom?: boolean;
autoLinkOnOpen: boolean; autoLinkOnOpen: boolean;
downloadToastsEnabled: boolean;
downloadAutoRetry: boolean;
hiddenLibraryTabs: string[];
libraryPinnedTabOrder: string[];
autoScroll?: boolean;
autoScrollSpeed?: number;
disableAutoComplete: boolean;
} }
export const DEFAULT_SETTINGS: Settings = { export const DEFAULT_SETTINGS: Settings = {
@@ -163,4 +170,11 @@ export const DEFAULT_SETTINGS: Settings = {
trackerRespectScanlatorFilter: true, trackerRespectScanlatorFilter: true,
pinchZoom: false, pinchZoom: false,
autoLinkOnOpen: false, autoLinkOnOpen: false,
downloadToastsEnabled: true,
downloadAutoRetry: false,
hiddenLibraryTabs: [],
libraryPinnedTabOrder: [],
autoScroll: false,
autoScrollSpeed: 5,
disableAutoComplete: false,
}; };
+1 -1
View File
@@ -27,7 +27,7 @@ export default defineConfig({
envPrefix: ["VITE_", "TAURI_"], envPrefix: ["VITE_", "TAURI_"],
build: { build: {
target: ["es2021", "chrome100", "safari13"], target: ["es2021", "chrome100", "safari13"],
minify: !process.env.TAURI_DEBUG ? "esbuild" : false, minify: !process.env.TAURI_DEBUG ? "oxc" : false,
sourcemap: !!process.env.TAURI_DEBUG, sourcemap: !!process.env.TAURI_DEBUG,
}, },
}); });