mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 09:19:56 -05:00
Compare commits
33 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 023b23288b | |||
| 67a9f0b944 | |||
| 56392e2427 | |||
| 843e205072 | |||
| ee708d85d0 | |||
| 8005c82654 | |||
| d989b2d67e | |||
| 6446a19b2d | |||
| 5cd96abc0c | |||
| db44afc4dc | |||
| 4248e344ab | |||
| 8941bfef10 | |||
| 11cd6ff870 | |||
| 15adb02be3 | |||
| 51bb6cdab9 | |||
| 454a674ada | |||
| f146de5c02 | |||
| 04f680c3bb | |||
| f49f7e7ac1 | |||
| a62512bf42 | |||
| d91ed2e6d1 | |||
| 61e3c4ee2f | |||
| 9151820843 | |||
| 63c890dadf | |||
| 51a33679d5 | |||
| 82f8a9a36b | |||
| 4decce9a7f | |||
| a69d5eacc5 | |||
| 4959722759 | |||
| 35ba0171c7 | |||
| d26fa50e76 | |||
| fd9d216325 | |||
| 581eb2adb0 |
@@ -7,7 +7,7 @@
|
|||||||
[](https://github.com/Youwes09/Moku/releases/latest)
|
[](https://github.com/Youwes09/Moku/releases/latest)
|
||||||
[](https://github.com/Youwes09/Moku/releases/latest)
|
[](https://github.com/Youwes09/Moku/releases/latest)
|
||||||
[](./LICENSE)
|
[](./LICENSE)
|
||||||
[](https://discord.gg/cfncTbJ2)
|
[](https://discord.gg/x97hj8zR72)
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -131,7 +131,7 @@ pnpm tauri:dev
|
|||||||
|
|
||||||
Questions, feedback, or just want to hang out — join the Discord.
|
Questions, feedback, or just want to hang out — join the Discord.
|
||||||
|
|
||||||
[](https://discord.gg/cfncTbJ2)
|
[](https://discord.gg/x97hj8zR72)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,21 +1,24 @@
|
|||||||
Major Revisions:
|
Major Revisions:
|
||||||
- Moku + Crossplatform Support (MacOS Remaining)
|
|
||||||
- Contemplate Anime Support, Add Novel Support (Consumet API)
|
|
||||||
- Enable Cloudflare Bypass (Suwayomi Config)
|
|
||||||
- Moku-Share to Easily Migrate/Share Manga (Investigate Usecase/Feasibility)
|
- Moku-Share to Easily Migrate/Share Manga (Investigate Usecase/Feasibility)
|
||||||
- Adjustment in Settings for Theme Editor:
|
|
||||||
- Allow User to Edit/Create Themes
|
|
||||||
- Allow For Command-Line IPC for Temporary (Apply Once) & Permanent Themes OR External Methodology for Matugen Colors
|
|
||||||
|
|
||||||
Minor Revisions:
|
Minor Revisions:
|
||||||
- Improve Deduplication Algorithm with Advanced Option Settings (Implement Chapter & Author-based Comparisons, Resource Intensive)
|
- Improve Deduplication Algorithm with Advanced Option Settings (Implement Chapter & Author-based Comparisons, Resource Intensive)
|
||||||
- Integrate Download Directory Changes (Settings)
|
|
||||||
- Investigate feasibility of Multi-Page Screenshot (Reader)
|
- Investigate feasibility of Multi-Page Screenshot (Reader)
|
||||||
- Add Hover Info on Library (Make sure doesn't conflict with additional clicks)
|
- Add Hover Info on Library (Make sure doesn't conflict with additional clicks)
|
||||||
|
- Revise Migration (https://github.com/Suwayomi/Suwayomi-WebUI/pull/1073)
|
||||||
|
- Look at how Manga are Organized in WebUI and Implement into Series-Detail (Chapter Display is Off)
|
||||||
|
- Adjustment in Settings for Theme Editor:
|
||||||
|
- Patch Color-Picker to Work Properly
|
||||||
|
- Moku Discord RPC
|
||||||
|
- Write a better library for Discord RPC & Tauri
|
||||||
|
- Integrate Download Directory Changes (Settings)
|
||||||
|
|
||||||
Priority Bugs:
|
Priority Bugs:
|
||||||
- Cache ALL Cover Pictures & Details for Manga in Library
|
- Cache ALL Cover Pictures & Details for Manga in Library
|
||||||
- MacOS Full-Screen & UI Compatability (TitleBar)
|
- Fix Library Build not Updating
|
||||||
|
- Check Auth System (Only Supports Basic-Auth)
|
||||||
|
|
||||||
|
|
||||||
General/Misc Bugs:
|
General/Misc Bugs:
|
||||||
- Fix Highlightable Elements
|
- Fix Highlightable Elements
|
||||||
@@ -24,19 +27,17 @@ General/Misc Bugs:
|
|||||||
- Fix Delete-All Crash (Deletes All but Cripples App)
|
- Fix Delete-All Crash (Deletes All but Cripples App)
|
||||||
- Investigate Prod vs. Dev Directory Change/Data Load (Caused by Library Algorithm Revision?)
|
- Investigate Prod vs. Dev Directory Change/Data Load (Caused by Library Algorithm Revision?)
|
||||||
|
|
||||||
In-Progress:`
|
|
||||||
- Fix Reader Chapter Shifts (Glitched Sentinel)
|
In-Progress:
|
||||||
- Still Shifts Down after reading ~8+ Chapters?
|
- Enable Cloudflare Bypass (Suwayomi Config) (Requires Patching)
|
||||||
- Identify When Chapters are Unloaded, How to Preserve Structure
|
|
||||||
|
- Patch Migrate Modal to Fill Language Options, not Limit to 7-9.
|
||||||
|
|
||||||
|
|
||||||
Important Commands:
|
|
||||||
cd ~/Projects/Manga/Moku
|
|
||||||
pnpm build
|
|
||||||
tar -czf packaging/frontend-dist.tar.gz -C dist .
|
|
||||||
sha256sum packaging/frontend-dist.tar.gz | awk '{print $1}'
|
|
||||||
|
|
||||||
|
|
||||||
nix-shell -p "python311.withPackages(ps: [ ps.aiohttp ps.tomlkit ])" --run "python3 packaging/flatpak-cargo-generator.py src-tauri/Cargo.lock -o packaging/cargo-sources.json"
|
Testing:
|
||||||
nix shell nixpkgs#appstream nixpkgs#flatpak-builder --command flatpak-builder --repo=repo --force-clean build-dir dev.moku.app.yml
|
|
||||||
flatpak build-bundle repo moku.flatpak dev.moku.app
|
- Fix TitleBar not Appearing on Windows in Fullscreen (Locks in User)
|
||||||
|
- Integrate Download Directory Changes (Settings)
|
||||||
|
- Fix Source Allow in Content (Doesn't even work)
|
||||||
+1
-1
@@ -181,7 +181,7 @@ modules:
|
|||||||
path: .
|
path: .
|
||||||
- type: file
|
- type: file
|
||||||
path: packaging/frontend-dist.tar.gz
|
path: packaging/frontend-dist.tar.gz
|
||||||
sha256: 3f18e4cc9153e28fd9020f7de22aac6dad1891034833b683c4bc0f5d0e04fc2b
|
sha256: d3ebde4d39e3de61420b78a9506df1a5c77c14d705e42662a45a2179bc96030e
|
||||||
- packaging/cargo-sources.json
|
- packaging/cargo-sources.json
|
||||||
- type: inline
|
- type: inline
|
||||||
dest: src-tauri/.cargo
|
dest: src-tauri/.cargo
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
|
|
||||||
perSystem = { system, lib, ... }:
|
perSystem = { system, lib, ... }:
|
||||||
let
|
let
|
||||||
version = "0.6.0";
|
version = "0.7.1";
|
||||||
|
|
||||||
pkgs = import inputs.nixpkgs {
|
pkgs = import inputs.nixpkgs {
|
||||||
inherit system;
|
inherit system;
|
||||||
@@ -71,7 +71,7 @@
|
|||||||
inherit version;
|
inherit version;
|
||||||
src = frontendSrc;
|
src = frontendSrc;
|
||||||
fetcherVersion = 1;
|
fetcherVersion = 1;
|
||||||
hash = "sha256-ezlckHhfSIe/Hs30tbiN0a/EuvGxhO5L020aup23Ozg=";
|
hash = "sha256-nlhm3NYn4x+JlKcCgj1lAX43muB3QRKGDzaxfQNfJwc=";
|
||||||
};
|
};
|
||||||
|
|
||||||
buildPhase = "pnpm build";
|
buildPhase = "pnpm build";
|
||||||
@@ -264,6 +264,15 @@ EOF
|
|||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
tunnelScript = pkgs.writeShellApplication {
|
||||||
|
name = "moku-tunnel";
|
||||||
|
runtimeInputs = with pkgs; [ cloudflared ];
|
||||||
|
text = ''
|
||||||
|
PORT="''${1:-4567}"
|
||||||
|
cloudflared tunnel --url "http://localhost:$PORT"
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
apps = {
|
apps = {
|
||||||
@@ -272,6 +281,7 @@ EOF
|
|||||||
bump = { type = "app"; program = "${bumpScript}/bin/moku-bump"; };
|
bump = { type = "app"; program = "${bumpScript}/bin/moku-bump"; };
|
||||||
flatpak = { type = "app"; program = "${flatpakScript}/bin/moku-flatpak"; };
|
flatpak = { type = "app"; program = "${flatpakScript}/bin/moku-flatpak"; };
|
||||||
pkgbuild-bump = { type = "app"; program = "${pkgbuildBumpScript}/bin/moku-pkgbuild-bump"; };
|
pkgbuild-bump = { type = "app"; program = "${pkgbuildBumpScript}/bin/moku-pkgbuild-bump"; };
|
||||||
|
tunnel = { type = "app"; program = "${tunnelScript}/bin/moku-tunnel"; };
|
||||||
};
|
};
|
||||||
|
|
||||||
packages = {
|
packages = {
|
||||||
@@ -288,6 +298,7 @@ EOF
|
|||||||
nodejs_22
|
nodejs_22
|
||||||
pnpm
|
pnpm
|
||||||
suwayomi-server
|
suwayomi-server
|
||||||
|
cloudflared
|
||||||
xdg-utils
|
xdg-utils
|
||||||
];
|
];
|
||||||
shellHook = ''
|
shellHook = ''
|
||||||
@@ -301,6 +312,7 @@ EOF
|
|||||||
echo " nix run .#bump -- <ver> bump versions only"
|
echo " nix run .#bump -- <ver> bump versions only"
|
||||||
echo " nix run .#flatpak -- <ver> full flatpak build"
|
echo " nix run .#flatpak -- <ver> full flatpak build"
|
||||||
echo " nix run .#pkgbuild-bump -- <ver> patch PKGBUILD (after tag push)"
|
echo " nix run .#pkgbuild-bump -- <ver> patch PKGBUILD (after tag push)"
|
||||||
|
echo " nix run .#tunnel -- [port] cloudflare tunnel (default 4567)"
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -11,11 +11,13 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tauri-apps/api": "^2.0.0",
|
"@tauri-apps/api": "^2.0.0",
|
||||||
|
"@tauri-apps/plugin-http": "^2.5.8",
|
||||||
"@tauri-apps/plugin-os": "^2.3.2",
|
"@tauri-apps/plugin-os": "^2.3.2",
|
||||||
"@tauri-apps/plugin-shell": "^2.3.5",
|
"@tauri-apps/plugin-shell": "^2.3.5",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"phosphor-svelte": "^3.1.0",
|
"phosphor-svelte": "^3.1.0",
|
||||||
"svelte-spa-router": "^4.0.1",
|
"svelte-spa-router": "^4.0.1",
|
||||||
|
"tauri-plugin-discord-rpc-api": "github:Youwes09/tauri-plugin-discord-rpc",
|
||||||
"tauri-plugin-drpc": "^1.0.3"
|
"tauri-plugin-drpc": "^1.0.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
+219
-193
@@ -1952,14 +1952,14 @@
|
|||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
"url": "https://static.crates.io/crates/hyper/hyper-1.8.1.crate",
|
"url": "https://static.crates.io/crates/hyper/hyper-1.9.0.crate",
|
||||||
"sha256": "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11",
|
"sha256": "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca",
|
||||||
"dest": "cargo/vendor/hyper-1.8.1"
|
"dest": "cargo/vendor/hyper-1.9.0"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "inline",
|
"type": "inline",
|
||||||
"contents": "{\"package\": \"2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11\", \"files\": {}}",
|
"contents": "{\"package\": \"6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca\", \"files\": {}}",
|
||||||
"dest": "cargo/vendor/hyper-1.8.1",
|
"dest": "cargo/vendor/hyper-1.9.0",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -2030,92 +2030,92 @@
|
|||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
"url": "https://static.crates.io/crates/icu_collections/icu_collections-2.1.1.crate",
|
"url": "https://static.crates.io/crates/icu_collections/icu_collections-2.2.0.crate",
|
||||||
"sha256": "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43",
|
"sha256": "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c",
|
||||||
"dest": "cargo/vendor/icu_collections-2.1.1"
|
"dest": "cargo/vendor/icu_collections-2.2.0"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "inline",
|
"type": "inline",
|
||||||
"contents": "{\"package\": \"4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43\", \"files\": {}}",
|
"contents": "{\"package\": \"2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c\", \"files\": {}}",
|
||||||
"dest": "cargo/vendor/icu_collections-2.1.1",
|
"dest": "cargo/vendor/icu_collections-2.2.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/icu_locale_core/icu_locale_core-2.1.1.crate",
|
"url": "https://static.crates.io/crates/icu_locale_core/icu_locale_core-2.2.0.crate",
|
||||||
"sha256": "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6",
|
"sha256": "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29",
|
||||||
"dest": "cargo/vendor/icu_locale_core-2.1.1"
|
"dest": "cargo/vendor/icu_locale_core-2.2.0"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "inline",
|
"type": "inline",
|
||||||
"contents": "{\"package\": \"edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6\", \"files\": {}}",
|
"contents": "{\"package\": \"92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29\", \"files\": {}}",
|
||||||
"dest": "cargo/vendor/icu_locale_core-2.1.1",
|
"dest": "cargo/vendor/icu_locale_core-2.2.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/icu_normalizer/icu_normalizer-2.1.1.crate",
|
"url": "https://static.crates.io/crates/icu_normalizer/icu_normalizer-2.2.0.crate",
|
||||||
"sha256": "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599",
|
"sha256": "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4",
|
||||||
"dest": "cargo/vendor/icu_normalizer-2.1.1"
|
"dest": "cargo/vendor/icu_normalizer-2.2.0"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "inline",
|
"type": "inline",
|
||||||
"contents": "{\"package\": \"5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599\", \"files\": {}}",
|
"contents": "{\"package\": \"c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4\", \"files\": {}}",
|
||||||
"dest": "cargo/vendor/icu_normalizer-2.1.1",
|
"dest": "cargo/vendor/icu_normalizer-2.2.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/icu_normalizer_data/icu_normalizer_data-2.1.1.crate",
|
"url": "https://static.crates.io/crates/icu_normalizer_data/icu_normalizer_data-2.2.0.crate",
|
||||||
"sha256": "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a",
|
"sha256": "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38",
|
||||||
"dest": "cargo/vendor/icu_normalizer_data-2.1.1"
|
"dest": "cargo/vendor/icu_normalizer_data-2.2.0"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "inline",
|
"type": "inline",
|
||||||
"contents": "{\"package\": \"7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a\", \"files\": {}}",
|
"contents": "{\"package\": \"da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38\", \"files\": {}}",
|
||||||
"dest": "cargo/vendor/icu_normalizer_data-2.1.1",
|
"dest": "cargo/vendor/icu_normalizer_data-2.2.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/icu_properties/icu_properties-2.1.2.crate",
|
"url": "https://static.crates.io/crates/icu_properties/icu_properties-2.2.0.crate",
|
||||||
"sha256": "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec",
|
"sha256": "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de",
|
||||||
"dest": "cargo/vendor/icu_properties-2.1.2"
|
"dest": "cargo/vendor/icu_properties-2.2.0"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "inline",
|
"type": "inline",
|
||||||
"contents": "{\"package\": \"020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec\", \"files\": {}}",
|
"contents": "{\"package\": \"bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de\", \"files\": {}}",
|
||||||
"dest": "cargo/vendor/icu_properties-2.1.2",
|
"dest": "cargo/vendor/icu_properties-2.2.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/icu_properties_data/icu_properties_data-2.1.2.crate",
|
"url": "https://static.crates.io/crates/icu_properties_data/icu_properties_data-2.2.0.crate",
|
||||||
"sha256": "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af",
|
"sha256": "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14",
|
||||||
"dest": "cargo/vendor/icu_properties_data-2.1.2"
|
"dest": "cargo/vendor/icu_properties_data-2.2.0"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "inline",
|
"type": "inline",
|
||||||
"contents": "{\"package\": \"616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af\", \"files\": {}}",
|
"contents": "{\"package\": \"8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14\", \"files\": {}}",
|
||||||
"dest": "cargo/vendor/icu_properties_data-2.1.2",
|
"dest": "cargo/vendor/icu_properties_data-2.2.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/icu_provider/icu_provider-2.1.1.crate",
|
"url": "https://static.crates.io/crates/icu_provider/icu_provider-2.2.0.crate",
|
||||||
"sha256": "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614",
|
"sha256": "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421",
|
||||||
"dest": "cargo/vendor/icu_provider-2.1.1"
|
"dest": "cargo/vendor/icu_provider-2.2.0"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "inline",
|
"type": "inline",
|
||||||
"contents": "{\"package\": \"85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614\", \"files\": {}}",
|
"contents": "{\"package\": \"139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421\", \"files\": {}}",
|
||||||
"dest": "cargo/vendor/icu_provider-2.1.1",
|
"dest": "cargo/vendor/icu_provider-2.2.0",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -2186,14 +2186,14 @@
|
|||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
"url": "https://static.crates.io/crates/indexmap/indexmap-2.13.0.crate",
|
"url": "https://static.crates.io/crates/indexmap/indexmap-2.13.1.crate",
|
||||||
"sha256": "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017",
|
"sha256": "45a8a2b9cb3e0b0c1803dbb0758ffac5de2f425b23c28f518faabd9d805342ff",
|
||||||
"dest": "cargo/vendor/indexmap-2.13.0"
|
"dest": "cargo/vendor/indexmap-2.13.1"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "inline",
|
"type": "inline",
|
||||||
"contents": "{\"package\": \"7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017\", \"files\": {}}",
|
"contents": "{\"package\": \"45a8a2b9cb3e0b0c1803dbb0758ffac5de2f425b23c28f518faabd9d805342ff\", \"files\": {}}",
|
||||||
"dest": "cargo/vendor/indexmap-2.13.0",
|
"dest": "cargo/vendor/indexmap-2.13.1",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -2225,14 +2225,14 @@
|
|||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
"url": "https://static.crates.io/crates/iri-string/iri-string-0.7.11.crate",
|
"url": "https://static.crates.io/crates/iri-string/iri-string-0.7.12.crate",
|
||||||
"sha256": "d8e7418f59cc01c88316161279a7f665217ae316b388e58a0d10e29f54f1e5eb",
|
"sha256": "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20",
|
||||||
"dest": "cargo/vendor/iri-string-0.7.11"
|
"dest": "cargo/vendor/iri-string-0.7.12"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "inline",
|
"type": "inline",
|
||||||
"contents": "{\"package\": \"d8e7418f59cc01c88316161279a7f665217ae316b388e58a0d10e29f54f1e5eb\", \"files\": {}}",
|
"contents": "{\"package\": \"25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20\", \"files\": {}}",
|
||||||
"dest": "cargo/vendor/iri-string-0.7.11",
|
"dest": "cargo/vendor/iri-string-0.7.12",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -2355,14 +2355,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.92.crate",
|
"url": "https://static.crates.io/crates/js-sys/js-sys-0.3.94.crate",
|
||||||
"sha256": "cc4c90f45aa2e6eacbe8645f77fdea542ac97a494bcd117a67df9ff4d611f995",
|
"sha256": "2e04e2ef80ce82e13552136fabeef8a5ed1f985a96805761cbb9a2c34e7664d9",
|
||||||
"dest": "cargo/vendor/js-sys-0.3.92"
|
"dest": "cargo/vendor/js-sys-0.3.94"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "inline",
|
"type": "inline",
|
||||||
"contents": "{\"package\": \"cc4c90f45aa2e6eacbe8645f77fdea542ac97a494bcd117a67df9ff4d611f995\", \"files\": {}}",
|
"contents": "{\"package\": \"2e04e2ef80ce82e13552136fabeef8a5ed1f985a96805761cbb9a2c34e7664d9\", \"files\": {}}",
|
||||||
"dest": "cargo/vendor/js-sys-0.3.92",
|
"dest": "cargo/vendor/js-sys-0.3.94",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -2459,14 +2459,14 @@
|
|||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
"url": "https://static.crates.io/crates/libc/libc-0.2.183.crate",
|
"url": "https://static.crates.io/crates/libc/libc-0.2.184.crate",
|
||||||
"sha256": "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d",
|
"sha256": "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af",
|
||||||
"dest": "cargo/vendor/libc-0.2.183"
|
"dest": "cargo/vendor/libc-0.2.184"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "inline",
|
"type": "inline",
|
||||||
"contents": "{\"package\": \"b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d\", \"files\": {}}",
|
"contents": "{\"package\": \"48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af\", \"files\": {}}",
|
||||||
"dest": "cargo/vendor/libc-0.2.183",
|
"dest": "cargo/vendor/libc-0.2.184",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -2511,14 +2511,14 @@
|
|||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
"url": "https://static.crates.io/crates/litemap/litemap-0.8.1.crate",
|
"url": "https://static.crates.io/crates/litemap/litemap-0.8.2.crate",
|
||||||
"sha256": "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77",
|
"sha256": "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0",
|
||||||
"dest": "cargo/vendor/litemap-0.8.1"
|
"dest": "cargo/vendor/litemap-0.8.2"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "inline",
|
"type": "inline",
|
||||||
"contents": "{\"package\": \"6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77\", \"files\": {}}",
|
"contents": "{\"package\": \"92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0\", \"files\": {}}",
|
||||||
"dest": "cargo/vendor/litemap-0.8.1",
|
"dest": "cargo/vendor/litemap-0.8.2",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -2719,14 +2719,14 @@
|
|||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
"url": "https://static.crates.io/crates/muda/muda-0.17.1.crate",
|
"url": "https://static.crates.io/crates/muda/muda-0.17.2.crate",
|
||||||
"sha256": "01c1738382f66ed56b3b9c8119e794a2e23148ac8ea214eda86622d4cb9d415a",
|
"sha256": "7c9fec5a4e89860383d778d10563a605838f8f0b2f9303868937e5ff32e86177",
|
||||||
"dest": "cargo/vendor/muda-0.17.1"
|
"dest": "cargo/vendor/muda-0.17.2"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "inline",
|
"type": "inline",
|
||||||
"contents": "{\"package\": \"01c1738382f66ed56b3b9c8119e794a2e23148ac8ea214eda86622d4cb9d415a\", \"files\": {}}",
|
"contents": "{\"package\": \"7c9fec5a4e89860383d778d10563a605838f8f0b2f9303868937e5ff32e86177\", \"files\": {}}",
|
||||||
"dest": "cargo/vendor/muda-0.17.1",
|
"dest": "cargo/vendor/muda-0.17.2",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -3522,19 +3522,6 @@
|
|||||||
"dest": "cargo/vendor/pin-project-lite-0.2.17",
|
"dest": "cargo/vendor/pin-project-lite-0.2.17",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"type": "archive",
|
|
||||||
"archive-type": "tar-gzip",
|
|
||||||
"url": "https://static.crates.io/crates/pin-utils/pin-utils-0.1.0.crate",
|
|
||||||
"sha256": "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184",
|
|
||||||
"dest": "cargo/vendor/pin-utils-0.1.0"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "inline",
|
|
||||||
"contents": "{\"package\": \"8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184\", \"files\": {}}",
|
|
||||||
"dest": "cargo/vendor/pin-utils-0.1.0",
|
|
||||||
"dest-filename": ".cargo-checksum.json"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
@@ -3590,14 +3577,14 @@
|
|||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
"url": "https://static.crates.io/crates/potential_utf/potential_utf-0.1.4.crate",
|
"url": "https://static.crates.io/crates/potential_utf/potential_utf-0.1.5.crate",
|
||||||
"sha256": "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77",
|
"sha256": "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564",
|
||||||
"dest": "cargo/vendor/potential_utf-0.1.4"
|
"dest": "cargo/vendor/potential_utf-0.1.5"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "inline",
|
"type": "inline",
|
||||||
"contents": "{\"package\": \"b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77\", \"files\": {}}",
|
"contents": "{\"package\": \"0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564\", \"files\": {}}",
|
||||||
"dest": "cargo/vendor/potential_utf-0.1.4",
|
"dest": "cargo/vendor/potential_utf-0.1.5",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -4198,6 +4185,19 @@
|
|||||||
"dest": "cargo/vendor/ring-0.17.14",
|
"dest": "cargo/vendor/ring-0.17.14",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "archive",
|
||||||
|
"archive-type": "tar-gzip",
|
||||||
|
"url": "https://static.crates.io/crates/rpcdiscord/rpcdiscord-0.2.6.crate",
|
||||||
|
"sha256": "71aa9a2097dc0176805e24debcb5d3ea5a17b796cd1d28e76b29f78fb49d7d5d",
|
||||||
|
"dest": "cargo/vendor/rpcdiscord-0.2.6"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "inline",
|
||||||
|
"contents": "{\"package\": \"71aa9a2097dc0176805e24debcb5d3ea5a17b796cd1d28e76b29f78fb49d7d5d\", \"files\": {}}",
|
||||||
|
"dest": "cargo/vendor/rpcdiscord-0.2.6",
|
||||||
|
"dest-filename": ".cargo-checksum.json"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
@@ -4604,14 +4604,14 @@
|
|||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
"url": "https://static.crates.io/crates/serde_spanned/serde_spanned-1.1.0.crate",
|
"url": "https://static.crates.io/crates/serde_spanned/serde_spanned-1.1.1.crate",
|
||||||
"sha256": "876ac351060d4f882bb1032b6369eb0aef79ad9df1ea8bc404874d8cc3d0cd98",
|
"sha256": "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26",
|
||||||
"dest": "cargo/vendor/serde_spanned-1.1.0"
|
"dest": "cargo/vendor/serde_spanned-1.1.1"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "inline",
|
"type": "inline",
|
||||||
"contents": "{\"package\": \"876ac351060d4f882bb1032b6369eb0aef79ad9df1ea8bc404874d8cc3d0cd98\", \"files\": {}}",
|
"contents": "{\"package\": \"6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26\", \"files\": {}}",
|
||||||
"dest": "cargo/vendor/serde_spanned-1.1.0",
|
"dest": "cargo/vendor/serde_spanned-1.1.1",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -5238,6 +5238,19 @@
|
|||||||
"dest": "cargo/vendor/tauri-plugin-2.5.4",
|
"dest": "cargo/vendor/tauri-plugin-2.5.4",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "archive",
|
||||||
|
"archive-type": "tar-gzip",
|
||||||
|
"url": "https://static.crates.io/crates/tauri-plugin-drpc/tauri-plugin-drpc-0.1.6.crate",
|
||||||
|
"sha256": "7b291669b7dbc05471fba380eeecf31e3f733ae6013aaa5216a43ca376027e5a",
|
||||||
|
"dest": "cargo/vendor/tauri-plugin-drpc-0.1.6"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "inline",
|
||||||
|
"contents": "{\"package\": \"7b291669b7dbc05471fba380eeecf31e3f733ae6013aaa5216a43ca376027e5a\", \"files\": {}}",
|
||||||
|
"dest": "cargo/vendor/tauri-plugin-drpc-0.1.6",
|
||||||
|
"dest-filename": ".cargo-checksum.json"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
@@ -5501,14 +5514,14 @@
|
|||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
"url": "https://static.crates.io/crates/tinystr/tinystr-0.8.2.crate",
|
"url": "https://static.crates.io/crates/tinystr/tinystr-0.8.3.crate",
|
||||||
"sha256": "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869",
|
"sha256": "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d",
|
||||||
"dest": "cargo/vendor/tinystr-0.8.2"
|
"dest": "cargo/vendor/tinystr-0.8.3"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "inline",
|
"type": "inline",
|
||||||
"contents": "{\"package\": \"42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869\", \"files\": {}}",
|
"contents": "{\"package\": \"c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d\", \"files\": {}}",
|
||||||
"dest": "cargo/vendor/tinystr-0.8.2",
|
"dest": "cargo/vendor/tinystr-0.8.3",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -5644,14 +5657,14 @@
|
|||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
"url": "https://static.crates.io/crates/toml_datetime/toml_datetime-1.1.0+spec-1.1.0.crate",
|
"url": "https://static.crates.io/crates/toml_datetime/toml_datetime-1.1.1+spec-1.1.0.crate",
|
||||||
"sha256": "97251a7c317e03ad83774a8752a7e81fb6067740609f75ea2b585b569a59198f",
|
"sha256": "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7",
|
||||||
"dest": "cargo/vendor/toml_datetime-1.1.0+spec-1.1.0"
|
"dest": "cargo/vendor/toml_datetime-1.1.1+spec-1.1.0"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "inline",
|
"type": "inline",
|
||||||
"contents": "{\"package\": \"97251a7c317e03ad83774a8752a7e81fb6067740609f75ea2b585b569a59198f\", \"files\": {}}",
|
"contents": "{\"package\": \"3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7\", \"files\": {}}",
|
||||||
"dest": "cargo/vendor/toml_datetime-1.1.0+spec-1.1.0",
|
"dest": "cargo/vendor/toml_datetime-1.1.1+spec-1.1.0",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -5683,40 +5696,40 @@
|
|||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
"url": "https://static.crates.io/crates/toml_edit/toml_edit-0.25.8+spec-1.1.0.crate",
|
"url": "https://static.crates.io/crates/toml_edit/toml_edit-0.25.10+spec-1.1.0.crate",
|
||||||
"sha256": "16bff38f1d86c47f9ff0647e6838d7bb362522bdf44006c7068c2b1e606f1f3c",
|
"sha256": "a82418ca169e235e6c399a84e395ab6debeb3bc90edc959bf0f48647c6a32d1b",
|
||||||
"dest": "cargo/vendor/toml_edit-0.25.8+spec-1.1.0"
|
"dest": "cargo/vendor/toml_edit-0.25.10+spec-1.1.0"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "inline",
|
"type": "inline",
|
||||||
"contents": "{\"package\": \"16bff38f1d86c47f9ff0647e6838d7bb362522bdf44006c7068c2b1e606f1f3c\", \"files\": {}}",
|
"contents": "{\"package\": \"a82418ca169e235e6c399a84e395ab6debeb3bc90edc959bf0f48647c6a32d1b\", \"files\": {}}",
|
||||||
"dest": "cargo/vendor/toml_edit-0.25.8+spec-1.1.0",
|
"dest": "cargo/vendor/toml_edit-0.25.10+spec-1.1.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/toml_parser/toml_parser-1.1.0+spec-1.1.0.crate",
|
"url": "https://static.crates.io/crates/toml_parser/toml_parser-1.1.2+spec-1.1.0.crate",
|
||||||
"sha256": "2334f11ee363607eb04df9b8fc8a13ca1715a72ba8662a26ac285c98aabb4011",
|
"sha256": "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526",
|
||||||
"dest": "cargo/vendor/toml_parser-1.1.0+spec-1.1.0"
|
"dest": "cargo/vendor/toml_parser-1.1.2+spec-1.1.0"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "inline",
|
"type": "inline",
|
||||||
"contents": "{\"package\": \"2334f11ee363607eb04df9b8fc8a13ca1715a72ba8662a26ac285c98aabb4011\", \"files\": {}}",
|
"contents": "{\"package\": \"a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526\", \"files\": {}}",
|
||||||
"dest": "cargo/vendor/toml_parser-1.1.0+spec-1.1.0",
|
"dest": "cargo/vendor/toml_parser-1.1.2+spec-1.1.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/toml_writer/toml_writer-1.1.0+spec-1.1.0.crate",
|
"url": "https://static.crates.io/crates/toml_writer/toml_writer-1.1.1+spec-1.1.0.crate",
|
||||||
"sha256": "d282ade6016312faf3e41e57ebbba0c073e4056dab1232ab1cb624199648f8ed",
|
"sha256": "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db",
|
||||||
"dest": "cargo/vendor/toml_writer-1.1.0+spec-1.1.0"
|
"dest": "cargo/vendor/toml_writer-1.1.1+spec-1.1.0"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "inline",
|
"type": "inline",
|
||||||
"contents": "{\"package\": \"d282ade6016312faf3e41e57ebbba0c073e4056dab1232ab1cb624199648f8ed\", \"files\": {}}",
|
"contents": "{\"package\": \"756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db\", \"files\": {}}",
|
||||||
"dest": "cargo/vendor/toml_writer-1.1.0+spec-1.1.0",
|
"dest": "cargo/vendor/toml_writer-1.1.1+spec-1.1.0",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -6018,6 +6031,19 @@
|
|||||||
"dest": "cargo/vendor/utf8_iter-1.0.4",
|
"dest": "cargo/vendor/utf8_iter-1.0.4",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "archive",
|
||||||
|
"archive-type": "tar-gzip",
|
||||||
|
"url": "https://static.crates.io/crates/uuid/uuid-0.8.2.crate",
|
||||||
|
"sha256": "bc5cf98d8186244414c848017f0e2676b3fcb46807f6668a97dfe67359a3c4b7",
|
||||||
|
"dest": "cargo/vendor/uuid-0.8.2"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "inline",
|
||||||
|
"contents": "{\"package\": \"bc5cf98d8186244414c848017f0e2676b3fcb46807f6668a97dfe67359a3c4b7\", \"files\": {}}",
|
||||||
|
"dest": "cargo/vendor/uuid-0.8.2",
|
||||||
|
"dest-filename": ".cargo-checksum.json"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
@@ -6164,66 +6190,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.115.crate",
|
"url": "https://static.crates.io/crates/wasm-bindgen/wasm-bindgen-0.2.117.crate",
|
||||||
"sha256": "6523d69017b7633e396a89c5efab138161ed5aafcbc8d3e5c5a42ae38f50495a",
|
"sha256": "0551fc1bb415591e3372d0bc4780db7e587d84e2a7e79da121051c5c4b89d0b0",
|
||||||
"dest": "cargo/vendor/wasm-bindgen-0.2.115"
|
"dest": "cargo/vendor/wasm-bindgen-0.2.117"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "inline",
|
"type": "inline",
|
||||||
"contents": "{\"package\": \"6523d69017b7633e396a89c5efab138161ed5aafcbc8d3e5c5a42ae38f50495a\", \"files\": {}}",
|
"contents": "{\"package\": \"0551fc1bb415591e3372d0bc4780db7e587d84e2a7e79da121051c5c4b89d0b0\", \"files\": {}}",
|
||||||
"dest": "cargo/vendor/wasm-bindgen-0.2.115",
|
"dest": "cargo/vendor/wasm-bindgen-0.2.117",
|
||||||
"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.65.crate",
|
"url": "https://static.crates.io/crates/wasm-bindgen-futures/wasm-bindgen-futures-0.4.67.crate",
|
||||||
"sha256": "2d1faf851e778dfa54db7cd438b70758eba9755cb47403f3496edd7c8fc212f0",
|
"sha256": "03623de6905b7206edd0a75f69f747f134b7f0a2323392d664448bf2d3c5d87e",
|
||||||
"dest": "cargo/vendor/wasm-bindgen-futures-0.4.65"
|
"dest": "cargo/vendor/wasm-bindgen-futures-0.4.67"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "inline",
|
"type": "inline",
|
||||||
"contents": "{\"package\": \"2d1faf851e778dfa54db7cd438b70758eba9755cb47403f3496edd7c8fc212f0\", \"files\": {}}",
|
"contents": "{\"package\": \"03623de6905b7206edd0a75f69f747f134b7f0a2323392d664448bf2d3c5d87e\", \"files\": {}}",
|
||||||
"dest": "cargo/vendor/wasm-bindgen-futures-0.4.65",
|
"dest": "cargo/vendor/wasm-bindgen-futures-0.4.67",
|
||||||
"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.115.crate",
|
"url": "https://static.crates.io/crates/wasm-bindgen-macro/wasm-bindgen-macro-0.2.117.crate",
|
||||||
"sha256": "4e3a6c758eb2f701ed3d052ff5737f5bfe6614326ea7f3bbac7156192dc32e67",
|
"sha256": "7fbdf9a35adf44786aecd5ff89b4563a90325f9da0923236f6104e603c7e86be",
|
||||||
"dest": "cargo/vendor/wasm-bindgen-macro-0.2.115"
|
"dest": "cargo/vendor/wasm-bindgen-macro-0.2.117"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "inline",
|
"type": "inline",
|
||||||
"contents": "{\"package\": \"4e3a6c758eb2f701ed3d052ff5737f5bfe6614326ea7f3bbac7156192dc32e67\", \"files\": {}}",
|
"contents": "{\"package\": \"7fbdf9a35adf44786aecd5ff89b4563a90325f9da0923236f6104e603c7e86be\", \"files\": {}}",
|
||||||
"dest": "cargo/vendor/wasm-bindgen-macro-0.2.115",
|
"dest": "cargo/vendor/wasm-bindgen-macro-0.2.117",
|
||||||
"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.115.crate",
|
"url": "https://static.crates.io/crates/wasm-bindgen-macro-support/wasm-bindgen-macro-support-0.2.117.crate",
|
||||||
"sha256": "921de2737904886b52bcbb237301552d05969a6f9c40d261eb0533c8b055fedf",
|
"sha256": "dca9693ef2bab6d4e6707234500350d8dad079eb508dca05530c85dc3a529ff2",
|
||||||
"dest": "cargo/vendor/wasm-bindgen-macro-support-0.2.115"
|
"dest": "cargo/vendor/wasm-bindgen-macro-support-0.2.117"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "inline",
|
"type": "inline",
|
||||||
"contents": "{\"package\": \"921de2737904886b52bcbb237301552d05969a6f9c40d261eb0533c8b055fedf\", \"files\": {}}",
|
"contents": "{\"package\": \"dca9693ef2bab6d4e6707234500350d8dad079eb508dca05530c85dc3a529ff2\", \"files\": {}}",
|
||||||
"dest": "cargo/vendor/wasm-bindgen-macro-support-0.2.115",
|
"dest": "cargo/vendor/wasm-bindgen-macro-support-0.2.117",
|
||||||
"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.115.crate",
|
"url": "https://static.crates.io/crates/wasm-bindgen-shared/wasm-bindgen-shared-0.2.117.crate",
|
||||||
"sha256": "a93e946af942b58934c604527337bad9ae33ba1d5c6900bbb41c2c07c2364a93",
|
"sha256": "39129a682a6d2d841b6c429d0c51e5cb0ed1a03829d8b3d1e69a011e62cb3d3b",
|
||||||
"dest": "cargo/vendor/wasm-bindgen-shared-0.2.115"
|
"dest": "cargo/vendor/wasm-bindgen-shared-0.2.117"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "inline",
|
"type": "inline",
|
||||||
"contents": "{\"package\": \"a93e946af942b58934c604527337bad9ae33ba1d5c6900bbb41c2c07c2364a93\", \"files\": {}}",
|
"contents": "{\"package\": \"39129a682a6d2d841b6c429d0c51e5cb0ed1a03829d8b3d1e69a011e62cb3d3b\", \"files\": {}}",
|
||||||
"dest": "cargo/vendor/wasm-bindgen-shared-0.2.115",
|
"dest": "cargo/vendor/wasm-bindgen-shared-0.2.117",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -6281,14 +6307,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.92.crate",
|
"url": "https://static.crates.io/crates/web-sys/web-sys-0.3.94.crate",
|
||||||
"sha256": "84cde8507f4d7cfcb1185b8cb5890c494ffea65edbe1ba82cfd63661c805ed94",
|
"sha256": "cd70027e39b12f0849461e08ffc50b9cd7688d942c1c8e3c7b22273236b4dd0a",
|
||||||
"dest": "cargo/vendor/web-sys-0.3.92"
|
"dest": "cargo/vendor/web-sys-0.3.94"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "inline",
|
"type": "inline",
|
||||||
"contents": "{\"package\": \"84cde8507f4d7cfcb1185b8cb5890c494ffea65edbe1ba82cfd63661c805ed94\", \"files\": {}}",
|
"contents": "{\"package\": \"cd70027e39b12f0849461e08ffc50b9cd7688d942c1c8e3c7b22273236b4dd0a\", \"files\": {}}",
|
||||||
"dest": "cargo/vendor/web-sys-0.3.92",
|
"dest": "cargo/vendor/web-sys-0.3.94",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -7308,14 +7334,14 @@
|
|||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
"url": "https://static.crates.io/crates/winnow/winnow-1.0.0.crate",
|
"url": "https://static.crates.io/crates/winnow/winnow-1.0.1.crate",
|
||||||
"sha256": "a90e88e4667264a994d34e6d1ab2d26d398dcdca8b7f52bec8668957517fc7d8",
|
"sha256": "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5",
|
||||||
"dest": "cargo/vendor/winnow-1.0.0"
|
"dest": "cargo/vendor/winnow-1.0.1"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "inline",
|
"type": "inline",
|
||||||
"contents": "{\"package\": \"a90e88e4667264a994d34e6d1ab2d26d398dcdca8b7f52bec8668957517fc7d8\", \"files\": {}}",
|
"contents": "{\"package\": \"09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5\", \"files\": {}}",
|
||||||
"dest": "cargo/vendor/winnow-1.0.0",
|
"dest": "cargo/vendor/winnow-1.0.1",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -7412,14 +7438,14 @@
|
|||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
"url": "https://static.crates.io/crates/writeable/writeable-0.6.2.crate",
|
"url": "https://static.crates.io/crates/writeable/writeable-0.6.3.crate",
|
||||||
"sha256": "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9",
|
"sha256": "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4",
|
||||||
"dest": "cargo/vendor/writeable-0.6.2"
|
"dest": "cargo/vendor/writeable-0.6.3"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "inline",
|
"type": "inline",
|
||||||
"contents": "{\"package\": \"9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9\", \"files\": {}}",
|
"contents": "{\"package\": \"1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4\", \"files\": {}}",
|
||||||
"dest": "cargo/vendor/writeable-0.6.2",
|
"dest": "cargo/vendor/writeable-0.6.3",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -7477,27 +7503,27 @@
|
|||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
"url": "https://static.crates.io/crates/yoke/yoke-0.8.1.crate",
|
"url": "https://static.crates.io/crates/yoke/yoke-0.8.2.crate",
|
||||||
"sha256": "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954",
|
"sha256": "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca",
|
||||||
"dest": "cargo/vendor/yoke-0.8.1"
|
"dest": "cargo/vendor/yoke-0.8.2"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "inline",
|
"type": "inline",
|
||||||
"contents": "{\"package\": \"72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954\", \"files\": {}}",
|
"contents": "{\"package\": \"abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca\", \"files\": {}}",
|
||||||
"dest": "cargo/vendor/yoke-0.8.1",
|
"dest": "cargo/vendor/yoke-0.8.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/yoke-derive/yoke-derive-0.8.1.crate",
|
"url": "https://static.crates.io/crates/yoke-derive/yoke-derive-0.8.2.crate",
|
||||||
"sha256": "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d",
|
"sha256": "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e",
|
||||||
"dest": "cargo/vendor/yoke-derive-0.8.1"
|
"dest": "cargo/vendor/yoke-derive-0.8.2"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "inline",
|
"type": "inline",
|
||||||
"contents": "{\"package\": \"b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d\", \"files\": {}}",
|
"contents": "{\"package\": \"de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e\", \"files\": {}}",
|
||||||
"dest": "cargo/vendor/yoke-derive-0.8.1",
|
"dest": "cargo/vendor/yoke-derive-0.8.2",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -7529,27 +7555,27 @@
|
|||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
"url": "https://static.crates.io/crates/zerofrom/zerofrom-0.1.6.crate",
|
"url": "https://static.crates.io/crates/zerofrom/zerofrom-0.1.7.crate",
|
||||||
"sha256": "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5",
|
"sha256": "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df",
|
||||||
"dest": "cargo/vendor/zerofrom-0.1.6"
|
"dest": "cargo/vendor/zerofrom-0.1.7"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "inline",
|
"type": "inline",
|
||||||
"contents": "{\"package\": \"50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5\", \"files\": {}}",
|
"contents": "{\"package\": \"69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df\", \"files\": {}}",
|
||||||
"dest": "cargo/vendor/zerofrom-0.1.6",
|
"dest": "cargo/vendor/zerofrom-0.1.7",
|
||||||
"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/zerofrom-derive/zerofrom-derive-0.1.6.crate",
|
"url": "https://static.crates.io/crates/zerofrom-derive/zerofrom-derive-0.1.7.crate",
|
||||||
"sha256": "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502",
|
"sha256": "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1",
|
||||||
"dest": "cargo/vendor/zerofrom-derive-0.1.6"
|
"dest": "cargo/vendor/zerofrom-derive-0.1.7"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "inline",
|
"type": "inline",
|
||||||
"contents": "{\"package\": \"d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502\", \"files\": {}}",
|
"contents": "{\"package\": \"11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1\", \"files\": {}}",
|
||||||
"dest": "cargo/vendor/zerofrom-derive-0.1.6",
|
"dest": "cargo/vendor/zerofrom-derive-0.1.7",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -7568,40 +7594,40 @@
|
|||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
"url": "https://static.crates.io/crates/zerotrie/zerotrie-0.2.3.crate",
|
"url": "https://static.crates.io/crates/zerotrie/zerotrie-0.2.4.crate",
|
||||||
"sha256": "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851",
|
"sha256": "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf",
|
||||||
"dest": "cargo/vendor/zerotrie-0.2.3"
|
"dest": "cargo/vendor/zerotrie-0.2.4"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "inline",
|
"type": "inline",
|
||||||
"contents": "{\"package\": \"2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851\", \"files\": {}}",
|
"contents": "{\"package\": \"0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf\", \"files\": {}}",
|
||||||
"dest": "cargo/vendor/zerotrie-0.2.3",
|
"dest": "cargo/vendor/zerotrie-0.2.4",
|
||||||
"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/zerovec/zerovec-0.11.5.crate",
|
"url": "https://static.crates.io/crates/zerovec/zerovec-0.11.6.crate",
|
||||||
"sha256": "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002",
|
"sha256": "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239",
|
||||||
"dest": "cargo/vendor/zerovec-0.11.5"
|
"dest": "cargo/vendor/zerovec-0.11.6"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "inline",
|
"type": "inline",
|
||||||
"contents": "{\"package\": \"6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002\", \"files\": {}}",
|
"contents": "{\"package\": \"90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239\", \"files\": {}}",
|
||||||
"dest": "cargo/vendor/zerovec-0.11.5",
|
"dest": "cargo/vendor/zerovec-0.11.6",
|
||||||
"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/zerovec-derive/zerovec-derive-0.11.2.crate",
|
"url": "https://static.crates.io/crates/zerovec-derive/zerovec-derive-0.11.3.crate",
|
||||||
"sha256": "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3",
|
"sha256": "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555",
|
||||||
"dest": "cargo/vendor/zerovec-derive-0.11.2"
|
"dest": "cargo/vendor/zerovec-derive-0.11.3"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "inline",
|
"type": "inline",
|
||||||
"contents": "{\"package\": \"eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3\", \"files\": {}}",
|
"contents": "{\"package\": \"625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555\", \"files\": {}}",
|
||||||
"dest": "cargo/vendor/zerovec-derive-0.11.2",
|
"dest": "cargo/vendor/zerovec-derive-0.11.3",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
Generated
+21
@@ -11,6 +11,9 @@ importers:
|
|||||||
'@tauri-apps/api':
|
'@tauri-apps/api':
|
||||||
specifier: ^2.0.0
|
specifier: ^2.0.0
|
||||||
version: 2.10.1
|
version: 2.10.1
|
||||||
|
'@tauri-apps/plugin-http':
|
||||||
|
specifier: ^2.5.8
|
||||||
|
version: 2.5.8
|
||||||
'@tauri-apps/plugin-os':
|
'@tauri-apps/plugin-os':
|
||||||
specifier: ^2.3.2
|
specifier: ^2.3.2
|
||||||
version: 2.3.2
|
version: 2.3.2
|
||||||
@@ -26,6 +29,9 @@ importers:
|
|||||||
svelte-spa-router:
|
svelte-spa-router:
|
||||||
specifier: ^4.0.1
|
specifier: ^4.0.1
|
||||||
version: 4.0.2
|
version: 4.0.2
|
||||||
|
tauri-plugin-discord-rpc-api:
|
||||||
|
specifier: github:Youwes09/tauri-plugin-discord-rpc
|
||||||
|
version: https://codeload.github.com/Youwes09/tauri-plugin-discord-rpc/tar.gz/4b20388e4b65e0efcff2aa9a8622b5884554cd8a
|
||||||
tauri-plugin-drpc:
|
tauri-plugin-drpc:
|
||||||
specifier: ^1.0.3
|
specifier: ^1.0.3
|
||||||
version: 1.0.3(typescript@5.9.3)
|
version: 1.0.3(typescript@5.9.3)
|
||||||
@@ -442,6 +448,9 @@ packages:
|
|||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
'@tauri-apps/plugin-http@2.5.8':
|
||||||
|
resolution: {integrity: sha512-oxd7oypzQeu8kAfFCrw534Kq7Cw+NzozcnCY21O4rz3A+veJiIiuSCMIprgGcZOcLAXFP9GmDhKUbhuKWcunRw==}
|
||||||
|
|
||||||
'@tauri-apps/plugin-os@2.3.2':
|
'@tauri-apps/plugin-os@2.3.2':
|
||||||
resolution: {integrity: sha512-n+nXWeuSeF9wcEsSPmRnBEGrRgOy6jjkSU+UVCOV8YUGKb2erhDOxis7IqRXiRVHhY8XMKks00BJ0OAdkpf6+A==}
|
resolution: {integrity: sha512-n+nXWeuSeF9wcEsSPmRnBEGrRgOy6jjkSU+UVCOV8YUGKb2erhDOxis7IqRXiRVHhY8XMKks00BJ0OAdkpf6+A==}
|
||||||
|
|
||||||
@@ -747,6 +756,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-TTDxwYnHkova6Wsyj1PGt9TByuWqvMoeY1bQiuAf2DM/JeDSMw7FjRKzk8K/5mJ99vGOKhbCqTDpyAKwjp4igg==}
|
resolution: {integrity: sha512-TTDxwYnHkova6Wsyj1PGt9TByuWqvMoeY1bQiuAf2DM/JeDSMw7FjRKzk8K/5mJ99vGOKhbCqTDpyAKwjp4igg==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
tauri-plugin-discord-rpc-api@https://codeload.github.com/Youwes09/tauri-plugin-discord-rpc/tar.gz/4b20388e4b65e0efcff2aa9a8622b5884554cd8a:
|
||||||
|
resolution: {tarball: https://codeload.github.com/Youwes09/tauri-plugin-discord-rpc/tar.gz/4b20388e4b65e0efcff2aa9a8622b5884554cd8a}
|
||||||
|
version: 0.1.0
|
||||||
|
|
||||||
tauri-plugin-drpc@1.0.3:
|
tauri-plugin-drpc@1.0.3:
|
||||||
resolution: {integrity: sha512-vl5dXhjKbl7+Nf9veW12usdmIUtZXwEf91SzxQPZlbRRJ/sjizbbQlnkUTtx6baJuGzz0KXXgP9xUhF39BdiXQ==}
|
resolution: {integrity: sha512-vl5dXhjKbl7+Nf9veW12usdmIUtZXwEf91SzxQPZlbRRJ/sjizbbQlnkUTtx6baJuGzz0KXXgP9xUhF39BdiXQ==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -1046,6 +1059,10 @@ snapshots:
|
|||||||
'@tauri-apps/cli-win32-ia32-msvc': 2.10.1
|
'@tauri-apps/cli-win32-ia32-msvc': 2.10.1
|
||||||
'@tauri-apps/cli-win32-x64-msvc': 2.10.1
|
'@tauri-apps/cli-win32-x64-msvc': 2.10.1
|
||||||
|
|
||||||
|
'@tauri-apps/plugin-http@2.5.8':
|
||||||
|
dependencies:
|
||||||
|
'@tauri-apps/api': 2.10.1
|
||||||
|
|
||||||
'@tauri-apps/plugin-os@2.3.2':
|
'@tauri-apps/plugin-os@2.3.2':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@tauri-apps/api': 2.10.1
|
'@tauri-apps/api': 2.10.1
|
||||||
@@ -1372,6 +1389,10 @@ snapshots:
|
|||||||
magic-string: 0.30.21
|
magic-string: 0.30.21
|
||||||
zimmerframe: 1.1.4
|
zimmerframe: 1.1.4
|
||||||
|
|
||||||
|
tauri-plugin-discord-rpc-api@https://codeload.github.com/Youwes09/tauri-plugin-discord-rpc/tar.gz/4b20388e4b65e0efcff2aa9a8622b5884554cd8a:
|
||||||
|
dependencies:
|
||||||
|
'@tauri-apps/api': 2.10.1
|
||||||
|
|
||||||
tauri-plugin-drpc@1.0.3(typescript@5.9.3):
|
tauri-plugin-drpc@1.0.3(typescript@5.9.3):
|
||||||
dependencies:
|
dependencies:
|
||||||
typescript: 5.9.3
|
typescript: 5.9.3
|
||||||
|
|||||||
Generated
+277
-155
@@ -268,9 +268,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cc"
|
name = "cc"
|
||||||
version = "1.2.58"
|
version = "1.2.59"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e1e928d4b69e3077709075a938a05ffbedfa53a84c8f766efbf8220bb1ff60e1"
|
checksum = "b7a4d3ec6524d28a329fc53654bbadc9bdd7b0431f5d65f1a56ffb28a1ee5283"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"find-msvc-tools",
|
"find-msvc-tools",
|
||||||
"shlex",
|
"shlex",
|
||||||
@@ -354,24 +354,6 @@ dependencies = [
|
|||||||
"version_check",
|
"version_check",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "cookie_store"
|
|
||||||
version = "0.21.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "2eac901828f88a5241ee0600950ab981148a18f2f756900ffba1b125ca6a3ef9"
|
|
||||||
dependencies = [
|
|
||||||
"cookie",
|
|
||||||
"document-features",
|
|
||||||
"idna",
|
|
||||||
"log",
|
|
||||||
"publicsuffix",
|
|
||||||
"serde",
|
|
||||||
"serde_derive",
|
|
||||||
"serde_json",
|
|
||||||
"time",
|
|
||||||
"url",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cookie_store"
|
name = "cookie_store"
|
||||||
version = "0.22.1"
|
version = "0.22.1"
|
||||||
@@ -425,7 +407,7 @@ dependencies = [
|
|||||||
"bitflags 2.11.0",
|
"bitflags 2.11.0",
|
||||||
"core-foundation 0.10.1",
|
"core-foundation 0.10.1",
|
||||||
"core-graphics-types",
|
"core-graphics-types",
|
||||||
"foreign-types",
|
"foreign-types 0.5.0",
|
||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -699,6 +681,21 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "discord-rich-presence"
|
||||||
|
version = "1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "90c55d69cab17c19677ce3a5f8face993a9e6eaf847fecac3547f3a3ff4a2494"
|
||||||
|
dependencies = [
|
||||||
|
"log",
|
||||||
|
"serde",
|
||||||
|
"serde_derive",
|
||||||
|
"serde_json",
|
||||||
|
"serde_repr",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
"uuid 0.8.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dispatch2"
|
name = "dispatch2"
|
||||||
version = "0.3.1"
|
version = "0.3.1"
|
||||||
@@ -869,9 +866,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fastrand"
|
name = "fastrand"
|
||||||
version = "2.3.0"
|
version = "2.4.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
|
checksum = "a043dc74da1e37d6afe657061213aa6f425f855399a11d3463c6ecccc4dfda1f"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fdeflate"
|
name = "fdeflate"
|
||||||
@@ -937,6 +934,15 @@ version = "0.2.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
|
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "foreign-types"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
|
||||||
|
dependencies = [
|
||||||
|
"foreign-types-shared 0.1.1",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "foreign-types"
|
name = "foreign-types"
|
||||||
version = "0.5.0"
|
version = "0.5.0"
|
||||||
@@ -944,7 +950,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965"
|
checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"foreign-types-macros",
|
"foreign-types-macros",
|
||||||
"foreign-types-shared",
|
"foreign-types-shared 0.3.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -958,6 +964,12 @@ dependencies = [
|
|||||||
"syn 2.0.117",
|
"syn 2.0.117",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "foreign-types-shared"
|
||||||
|
version = "0.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "foreign-types-shared"
|
name = "foreign-types-shared"
|
||||||
version = "0.3.1"
|
version = "0.3.1"
|
||||||
@@ -990,6 +1002,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
|
checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"futures-core",
|
"futures-core",
|
||||||
|
"futures-sink",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1393,7 +1406,7 @@ dependencies = [
|
|||||||
"futures-core",
|
"futures-core",
|
||||||
"futures-sink",
|
"futures-sink",
|
||||||
"http",
|
"http",
|
||||||
"indexmap 2.13.0",
|
"indexmap 2.13.1",
|
||||||
"slab",
|
"slab",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-util",
|
"tokio-util",
|
||||||
@@ -1502,9 +1515,9 @@ checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hyper"
|
name = "hyper"
|
||||||
version = "1.8.1"
|
version = "1.9.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11"
|
checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"atomic-waker",
|
"atomic-waker",
|
||||||
"bytes",
|
"bytes",
|
||||||
@@ -1516,7 +1529,6 @@ dependencies = [
|
|||||||
"httparse",
|
"httparse",
|
||||||
"itoa",
|
"itoa",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"pin-utils",
|
|
||||||
"smallvec",
|
"smallvec",
|
||||||
"tokio",
|
"tokio",
|
||||||
"want",
|
"want",
|
||||||
@@ -1539,6 +1551,22 @@ dependencies = [
|
|||||||
"webpki-roots",
|
"webpki-roots",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hyper-tls"
|
||||||
|
version = "0.6.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"http-body-util",
|
||||||
|
"hyper",
|
||||||
|
"hyper-util",
|
||||||
|
"native-tls",
|
||||||
|
"tokio",
|
||||||
|
"tokio-native-tls",
|
||||||
|
"tower-service",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hyper-util"
|
name = "hyper-util"
|
||||||
version = "0.1.20"
|
version = "0.1.20"
|
||||||
@@ -1600,12 +1628,13 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "icu_collections"
|
name = "icu_collections"
|
||||||
version = "2.1.1"
|
version = "2.2.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43"
|
checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"displaydoc",
|
"displaydoc",
|
||||||
"potential_utf",
|
"potential_utf",
|
||||||
|
"utf8_iter",
|
||||||
"yoke",
|
"yoke",
|
||||||
"zerofrom",
|
"zerofrom",
|
||||||
"zerovec",
|
"zerovec",
|
||||||
@@ -1613,9 +1642,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "icu_locale_core"
|
name = "icu_locale_core"
|
||||||
version = "2.1.1"
|
version = "2.2.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6"
|
checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"displaydoc",
|
"displaydoc",
|
||||||
"litemap",
|
"litemap",
|
||||||
@@ -1626,9 +1655,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "icu_normalizer"
|
name = "icu_normalizer"
|
||||||
version = "2.1.1"
|
version = "2.2.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599"
|
checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"icu_collections",
|
"icu_collections",
|
||||||
"icu_normalizer_data",
|
"icu_normalizer_data",
|
||||||
@@ -1640,15 +1669,15 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "icu_normalizer_data"
|
name = "icu_normalizer_data"
|
||||||
version = "2.1.1"
|
version = "2.2.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a"
|
checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "icu_properties"
|
name = "icu_properties"
|
||||||
version = "2.1.2"
|
version = "2.2.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec"
|
checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"icu_collections",
|
"icu_collections",
|
||||||
"icu_locale_core",
|
"icu_locale_core",
|
||||||
@@ -1660,15 +1689,15 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "icu_properties_data"
|
name = "icu_properties_data"
|
||||||
version = "2.1.2"
|
version = "2.2.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af"
|
checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "icu_provider"
|
name = "icu_provider"
|
||||||
version = "2.1.1"
|
version = "2.2.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614"
|
checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"displaydoc",
|
"displaydoc",
|
||||||
"icu_locale_core",
|
"icu_locale_core",
|
||||||
@@ -1725,9 +1754,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "indexmap"
|
name = "indexmap"
|
||||||
version = "2.13.0"
|
version = "2.13.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017"
|
checksum = "45a8a2b9cb3e0b0c1803dbb0758ffac5de2f425b23c28f518faabd9d805342ff"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"equivalent",
|
"equivalent",
|
||||||
"hashbrown 0.16.1",
|
"hashbrown 0.16.1",
|
||||||
@@ -1854,9 +1883,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "js-sys"
|
name = "js-sys"
|
||||||
version = "0.3.92"
|
version = "0.3.94"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "cc4c90f45aa2e6eacbe8645f77fdea542ac97a494bcd117a67df9ff4d611f995"
|
checksum = "2e04e2ef80ce82e13552136fabeef8a5ed1f985a96805761cbb9a2c34e7664d9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
@@ -1905,7 +1934,7 @@ checksum = "02cb977175687f33fa4afa0c95c112b987ea1443e5a51c8f8ff27dc618270cc2"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"cssparser 0.29.6",
|
"cssparser 0.29.6",
|
||||||
"html5ever 0.29.1",
|
"html5ever 0.29.1",
|
||||||
"indexmap 2.13.0",
|
"indexmap 2.13.1",
|
||||||
"selectors 0.24.0",
|
"selectors 0.24.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -1941,9 +1970,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libc"
|
name = "libc"
|
||||||
version = "0.2.183"
|
version = "0.2.184"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d"
|
checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libloading"
|
name = "libloading"
|
||||||
@@ -1975,9 +2004,9 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "litemap"
|
name = "litemap"
|
||||||
version = "0.8.1"
|
version = "0.8.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77"
|
checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "litrs"
|
name = "litrs"
|
||||||
@@ -2104,28 +2133,31 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "moku"
|
name = "moku"
|
||||||
version = "0.6.0"
|
version = "0.7.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dirs 5.0.1",
|
"dirs 5.0.1",
|
||||||
|
"reqwest 0.12.28",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sysinfo",
|
"sysinfo 0.32.1",
|
||||||
"tauri",
|
"tauri",
|
||||||
"tauri-build",
|
"tauri-build",
|
||||||
"tauri-plugin-drpc",
|
"tauri-plugin-discord-rpc",
|
||||||
"tauri-plugin-http",
|
"tauri-plugin-http",
|
||||||
"tauri-plugin-os",
|
"tauri-plugin-os",
|
||||||
"tauri-plugin-process",
|
"tauri-plugin-process",
|
||||||
"tauri-plugin-shell",
|
"tauri-plugin-shell",
|
||||||
"tauri-plugin-updater",
|
"tauri-plugin-updater",
|
||||||
|
"tokio",
|
||||||
|
"urlencoding",
|
||||||
"walkdir",
|
"walkdir",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "muda"
|
name = "muda"
|
||||||
version = "0.17.1"
|
version = "0.17.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "01c1738382f66ed56b3b9c8119e794a2e23148ac8ea214eda86622d4cb9d415a"
|
checksum = "7c9fec5a4e89860383d778d10563a605838f8f0b2f9303868937e5ff32e86177"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"crossbeam-channel",
|
"crossbeam-channel",
|
||||||
"dpi",
|
"dpi",
|
||||||
@@ -2142,6 +2174,23 @@ dependencies = [
|
|||||||
"windows-sys 0.60.2",
|
"windows-sys 0.60.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "native-tls"
|
||||||
|
version = "0.2.18"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"log",
|
||||||
|
"openssl",
|
||||||
|
"openssl-probe",
|
||||||
|
"openssl-sys",
|
||||||
|
"schannel",
|
||||||
|
"security-framework",
|
||||||
|
"security-framework-sys",
|
||||||
|
"tempfile",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ndk"
|
name = "ndk"
|
||||||
version = "0.9.0"
|
version = "0.9.0"
|
||||||
@@ -2370,6 +2419,16 @@ dependencies = [
|
|||||||
"objc2-core-foundation",
|
"objc2-core-foundation",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "objc2-io-kit"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"objc2-core-foundation",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "objc2-io-surface"
|
name = "objc2-io-surface"
|
||||||
version = "0.3.2"
|
version = "0.3.2"
|
||||||
@@ -2468,12 +2527,50 @@ dependencies = [
|
|||||||
"pathdiff",
|
"pathdiff",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "openssl"
|
||||||
|
version = "0.10.76"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "951c002c75e16ea2c65b8c7e4d3d51d5530d8dfa7d060b4776828c88cfb18ecf"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.11.0",
|
||||||
|
"cfg-if",
|
||||||
|
"foreign-types 0.3.2",
|
||||||
|
"libc",
|
||||||
|
"once_cell",
|
||||||
|
"openssl-macros",
|
||||||
|
"openssl-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "openssl-macros"
|
||||||
|
version = "0.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.117",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openssl-probe"
|
name = "openssl-probe"
|
||||||
version = "0.2.1"
|
version = "0.2.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
|
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "openssl-sys"
|
||||||
|
version = "0.9.112"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "57d55af3b3e226502be1526dfdba67ab0e9c96fc293004e79576b2b9edb0dbdb"
|
||||||
|
dependencies = [
|
||||||
|
"cc",
|
||||||
|
"libc",
|
||||||
|
"pkg-config",
|
||||||
|
"vcpkg",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "option-ext"
|
name = "option-ext"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
@@ -2773,12 +2870,6 @@ version = "0.2.17"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "pin-utils"
|
|
||||||
version = "0.1.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pkg-config"
|
name = "pkg-config"
|
||||||
version = "0.3.32"
|
version = "0.3.32"
|
||||||
@@ -2798,7 +2889,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07"
|
checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"indexmap 2.13.0",
|
"indexmap 2.13.1",
|
||||||
"quick-xml",
|
"quick-xml",
|
||||||
"serde",
|
"serde",
|
||||||
"time",
|
"time",
|
||||||
@@ -2819,9 +2910,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "potential_utf"
|
name = "potential_utf"
|
||||||
version = "0.1.4"
|
version = "0.1.5"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77"
|
checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"zerovec",
|
"zerovec",
|
||||||
]
|
]
|
||||||
@@ -2883,7 +2974,7 @@ version = "3.5.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
|
checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"toml_edit 0.25.8+spec-1.1.0",
|
"toml_edit 0.25.10+spec-1.1.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3260,19 +3351,23 @@ dependencies = [
|
|||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"bytes",
|
"bytes",
|
||||||
"cookie",
|
"cookie",
|
||||||
"cookie_store 0.22.1",
|
"cookie_store",
|
||||||
"encoding_rs",
|
"encoding_rs",
|
||||||
|
"futures-channel",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
|
"futures-util",
|
||||||
"h2",
|
"h2",
|
||||||
"http",
|
"http",
|
||||||
"http-body",
|
"http-body",
|
||||||
"http-body-util",
|
"http-body-util",
|
||||||
"hyper",
|
"hyper",
|
||||||
"hyper-rustls",
|
"hyper-rustls",
|
||||||
|
"hyper-tls",
|
||||||
"hyper-util",
|
"hyper-util",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"log",
|
"log",
|
||||||
"mime",
|
"mime",
|
||||||
|
"native-tls",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"quinn",
|
"quinn",
|
||||||
@@ -3283,6 +3378,7 @@ dependencies = [
|
|||||||
"serde_urlencoded",
|
"serde_urlencoded",
|
||||||
"sync_wrapper",
|
"sync_wrapper",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"tokio-native-tls",
|
||||||
"tokio-rustls",
|
"tokio-rustls",
|
||||||
"tower",
|
"tower",
|
||||||
"tower-http",
|
"tower-http",
|
||||||
@@ -3347,19 +3443,6 @@ dependencies = [
|
|||||||
"windows-sys 0.52.0",
|
"windows-sys 0.52.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "rpcdiscord"
|
|
||||||
version = "0.2.6"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "71aa9a2097dc0176805e24debcb5d3ea5a17b796cd1d28e76b29f78fb49d7d5d"
|
|
||||||
dependencies = [
|
|
||||||
"serde",
|
|
||||||
"serde_derive",
|
|
||||||
"serde_json",
|
|
||||||
"serde_repr",
|
|
||||||
"uuid 0.8.2",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustc-hash"
|
name = "rustc-hash"
|
||||||
version = "2.1.2"
|
version = "2.1.2"
|
||||||
@@ -3611,9 +3694,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "semver"
|
name = "semver"
|
||||||
version = "1.0.27"
|
version = "1.0.28"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2"
|
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
"serde_core",
|
"serde_core",
|
||||||
@@ -3707,9 +3790,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde_spanned"
|
name = "serde_spanned"
|
||||||
version = "1.1.0"
|
version = "1.1.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "876ac351060d4f882bb1032b6369eb0aef79ad9df1ea8bc404874d8cc3d0cd98"
|
checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"serde_core",
|
"serde_core",
|
||||||
]
|
]
|
||||||
@@ -3736,7 +3819,7 @@ dependencies = [
|
|||||||
"chrono",
|
"chrono",
|
||||||
"hex",
|
"hex",
|
||||||
"indexmap 1.9.3",
|
"indexmap 1.9.3",
|
||||||
"indexmap 2.13.0",
|
"indexmap 2.13.1",
|
||||||
"schemars 0.9.0",
|
"schemars 0.9.0",
|
||||||
"schemars 1.2.1",
|
"schemars 1.2.1",
|
||||||
"serde_core",
|
"serde_core",
|
||||||
@@ -4088,6 +4171,20 @@ dependencies = [
|
|||||||
"windows 0.57.0",
|
"windows 0.57.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sysinfo"
|
||||||
|
version = "0.36.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "252800745060e7b9ffb7b2badbd8b31cfa4aa2e61af879d0a3bf2a317c20217d"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"memchr",
|
||||||
|
"ntapi",
|
||||||
|
"objc2-core-foundation",
|
||||||
|
"objc2-io-kit",
|
||||||
|
"windows 0.61.3",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "system-configuration"
|
name = "system-configuration"
|
||||||
version = "0.7.0"
|
version = "0.7.0"
|
||||||
@@ -4320,15 +4417,16 @@ dependencies = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tauri-plugin-drpc"
|
name = "tauri-plugin-discord-rpc"
|
||||||
version = "0.1.6"
|
version = "0.1.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "git+https://github.com/Youwes09/tauri-plugin-discord-rpc#d2fd312945d0573153e0e7e2d2dfb131acecc52c"
|
||||||
checksum = "7b291669b7dbc05471fba380eeecf31e3f733ae6013aaa5216a43ca376027e5a"
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"discord-rich-presence",
|
||||||
|
"libc",
|
||||||
"log",
|
"log",
|
||||||
"rpcdiscord",
|
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"sysinfo 0.36.1",
|
||||||
"tauri",
|
"tauri",
|
||||||
"tauri-plugin",
|
"tauri-plugin",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
@@ -4337,13 +4435,15 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tauri-plugin-fs"
|
name = "tauri-plugin-fs"
|
||||||
version = "2.4.5"
|
version = "2.5.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ed390cc669f937afeb8b28032ce837bac8ea023d975a2e207375ec05afaf1804"
|
checksum = "36e1ec28b79f3d0683f4507e1615c36292c0ea6716668770d4396b9b39871ed8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"dunce",
|
"dunce",
|
||||||
"glob",
|
"glob",
|
||||||
|
"log",
|
||||||
|
"objc2-foundation",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"schemars 0.8.22",
|
"schemars 0.8.22",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -4359,12 +4459,12 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tauri-plugin-http"
|
name = "tauri-plugin-http"
|
||||||
version = "2.5.7"
|
version = "2.5.8"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d8f069451c4e87e7e2636b7f065a4c52866c4ce5e60e2d53fa1038edb6d184dc"
|
checksum = "cfba7d4ec72763f9d1fdf73c217747f01e2c84b08b87a8cacd2f94f35853f84d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bytes",
|
"bytes",
|
||||||
"cookie_store 0.21.1",
|
"cookie_store",
|
||||||
"data-url",
|
"data-url",
|
||||||
"http",
|
"http",
|
||||||
"regex",
|
"regex",
|
||||||
@@ -4432,9 +4532,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tauri-plugin-updater"
|
name = "tauri-plugin-updater"
|
||||||
version = "2.10.0"
|
version = "2.10.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3fe8e9bebd88fc222938ffdfbdcfa0307081423bd01e3252fc337d8bde81fc61"
|
checksum = "806d9dac662c2e4594ff03c647a552f2c9bd544e7d0f683ec58f872f952ce4af"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"dirs 6.0.0",
|
"dirs 6.0.0",
|
||||||
@@ -4670,9 +4770,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tinystr"
|
name = "tinystr"
|
||||||
version = "0.8.2"
|
version = "0.8.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869"
|
checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"displaydoc",
|
"displaydoc",
|
||||||
"zerovec",
|
"zerovec",
|
||||||
@@ -4695,9 +4795,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tokio"
|
name = "tokio"
|
||||||
version = "1.50.0"
|
version = "1.51.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d"
|
checksum = "2bd1c4c0fc4a7ab90fc15ef6daaa3ec3b893f004f915f2392557ed23237820cd"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bytes",
|
"bytes",
|
||||||
"libc",
|
"libc",
|
||||||
@@ -4710,15 +4810,25 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tokio-macros"
|
name = "tokio-macros"
|
||||||
version = "2.6.1"
|
version = "2.7.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c"
|
checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 2.0.117",
|
"syn 2.0.117",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tokio-native-tls"
|
||||||
|
version = "0.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
|
||||||
|
dependencies = [
|
||||||
|
"native-tls",
|
||||||
|
"tokio",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tokio-rustls"
|
name = "tokio-rustls"
|
||||||
version = "0.26.4"
|
version = "0.26.4"
|
||||||
@@ -4760,9 +4870,9 @@ version = "0.9.12+spec-1.1.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863"
|
checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"indexmap 2.13.0",
|
"indexmap 2.13.1",
|
||||||
"serde_core",
|
"serde_core",
|
||||||
"serde_spanned 1.1.0",
|
"serde_spanned 1.1.1",
|
||||||
"toml_datetime 0.7.5+spec-1.1.0",
|
"toml_datetime 0.7.5+spec-1.1.0",
|
||||||
"toml_parser",
|
"toml_parser",
|
||||||
"toml_writer",
|
"toml_writer",
|
||||||
@@ -4789,9 +4899,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "toml_datetime"
|
name = "toml_datetime"
|
||||||
version = "1.1.0+spec-1.1.0"
|
version = "1.1.1+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 = "97251a7c317e03ad83774a8752a7e81fb6067740609f75ea2b585b569a59198f"
|
checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"serde_core",
|
"serde_core",
|
||||||
]
|
]
|
||||||
@@ -4802,7 +4912,7 @@ version = "0.19.15"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421"
|
checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"indexmap 2.13.0",
|
"indexmap 2.13.1",
|
||||||
"toml_datetime 0.6.3",
|
"toml_datetime 0.6.3",
|
||||||
"winnow 0.5.40",
|
"winnow 0.5.40",
|
||||||
]
|
]
|
||||||
@@ -4813,7 +4923,7 @@ version = "0.20.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338"
|
checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"indexmap 2.13.0",
|
"indexmap 2.13.1",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_spanned 0.6.9",
|
"serde_spanned 0.6.9",
|
||||||
"toml_datetime 0.6.3",
|
"toml_datetime 0.6.3",
|
||||||
@@ -4822,30 +4932,30 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "toml_edit"
|
name = "toml_edit"
|
||||||
version = "0.25.8+spec-1.1.0"
|
version = "0.25.10+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 = "16bff38f1d86c47f9ff0647e6838d7bb362522bdf44006c7068c2b1e606f1f3c"
|
checksum = "a82418ca169e235e6c399a84e395ab6debeb3bc90edc959bf0f48647c6a32d1b"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"indexmap 2.13.0",
|
"indexmap 2.13.1",
|
||||||
"toml_datetime 1.1.0+spec-1.1.0",
|
"toml_datetime 1.1.1+spec-1.1.0",
|
||||||
"toml_parser",
|
"toml_parser",
|
||||||
"winnow 1.0.0",
|
"winnow 1.0.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "toml_parser"
|
name = "toml_parser"
|
||||||
version = "1.1.0+spec-1.1.0"
|
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 = "2334f11ee363607eb04df9b8fc8a13ca1715a72ba8662a26ac285c98aabb4011"
|
checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"winnow 1.0.0",
|
"winnow 1.0.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "toml_writer"
|
name = "toml_writer"
|
||||||
version = "1.1.0+spec-1.1.0"
|
version = "1.1.1+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 = "d282ade6016312faf3e41e57ebbba0c073e4056dab1232ab1cb624199648f8ed"
|
checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tower"
|
name = "tower"
|
||||||
@@ -5029,6 +5139,12 @@ dependencies = [
|
|||||||
"serde_derive",
|
"serde_derive",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "urlencoding"
|
||||||
|
version = "2.1.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "urlpattern"
|
name = "urlpattern"
|
||||||
version = "0.3.0"
|
version = "0.3.0"
|
||||||
@@ -5074,6 +5190,12 @@ dependencies = [
|
|||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "vcpkg"
|
||||||
|
version = "0.2.15"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "version-compare"
|
name = "version-compare"
|
||||||
version = "0.2.1"
|
version = "0.2.1"
|
||||||
@@ -5157,9 +5279,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasm-bindgen"
|
name = "wasm-bindgen"
|
||||||
version = "0.2.115"
|
version = "0.2.117"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6523d69017b7633e396a89c5efab138161ed5aafcbc8d3e5c5a42ae38f50495a"
|
checksum = "0551fc1bb415591e3372d0bc4780db7e587d84e2a7e79da121051c5c4b89d0b0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
@@ -5170,9 +5292,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasm-bindgen-futures"
|
name = "wasm-bindgen-futures"
|
||||||
version = "0.4.65"
|
version = "0.4.67"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2d1faf851e778dfa54db7cd438b70758eba9755cb47403f3496edd7c8fc212f0"
|
checksum = "03623de6905b7206edd0a75f69f747f134b7f0a2323392d664448bf2d3c5d87e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
@@ -5180,9 +5302,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasm-bindgen-macro"
|
name = "wasm-bindgen-macro"
|
||||||
version = "0.2.115"
|
version = "0.2.117"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "4e3a6c758eb2f701ed3d052ff5737f5bfe6614326ea7f3bbac7156192dc32e67"
|
checksum = "7fbdf9a35adf44786aecd5ff89b4563a90325f9da0923236f6104e603c7e86be"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"quote",
|
"quote",
|
||||||
"wasm-bindgen-macro-support",
|
"wasm-bindgen-macro-support",
|
||||||
@@ -5190,9 +5312,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasm-bindgen-macro-support"
|
name = "wasm-bindgen-macro-support"
|
||||||
version = "0.2.115"
|
version = "0.2.117"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "921de2737904886b52bcbb237301552d05969a6f9c40d261eb0533c8b055fedf"
|
checksum = "dca9693ef2bab6d4e6707234500350d8dad079eb508dca05530c85dc3a529ff2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bumpalo",
|
"bumpalo",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
@@ -5203,9 +5325,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasm-bindgen-shared"
|
name = "wasm-bindgen-shared"
|
||||||
version = "0.2.115"
|
version = "0.2.117"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a93e946af942b58934c604527337bad9ae33ba1d5c6900bbb41c2c07c2364a93"
|
checksum = "39129a682a6d2d841b6c429d0c51e5cb0ed1a03829d8b3d1e69a011e62cb3d3b"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
]
|
]
|
||||||
@@ -5227,7 +5349,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
|
checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"indexmap 2.13.0",
|
"indexmap 2.13.1",
|
||||||
"wasm-encoder",
|
"wasm-encoder",
|
||||||
"wasmparser",
|
"wasmparser",
|
||||||
]
|
]
|
||||||
@@ -5253,15 +5375,15 @@ checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.11.0",
|
"bitflags 2.11.0",
|
||||||
"hashbrown 0.15.5",
|
"hashbrown 0.15.5",
|
||||||
"indexmap 2.13.0",
|
"indexmap 2.13.1",
|
||||||
"semver",
|
"semver",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "web-sys"
|
name = "web-sys"
|
||||||
version = "0.3.92"
|
version = "0.3.94"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "84cde8507f4d7cfcb1185b8cb5890c494ffea65edbe1ba82cfd63661c805ed94"
|
checksum = "cd70027e39b12f0849461e08ffc50b9cd7688d942c1c8e3c7b22273236b4dd0a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
@@ -5968,9 +6090,9 @@ checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "winnow"
|
name = "winnow"
|
||||||
version = "1.0.0"
|
version = "1.0.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a90e88e4667264a994d34e6d1ab2d26d398dcdca8b7f52bec8668957517fc7d8"
|
checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"memchr",
|
"memchr",
|
||||||
]
|
]
|
||||||
@@ -6013,7 +6135,7 @@ checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"heck 0.5.0",
|
"heck 0.5.0",
|
||||||
"indexmap 2.13.0",
|
"indexmap 2.13.1",
|
||||||
"prettyplease",
|
"prettyplease",
|
||||||
"syn 2.0.117",
|
"syn 2.0.117",
|
||||||
"wasm-metadata",
|
"wasm-metadata",
|
||||||
@@ -6044,7 +6166,7 @@ checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"bitflags 2.11.0",
|
"bitflags 2.11.0",
|
||||||
"indexmap 2.13.0",
|
"indexmap 2.13.1",
|
||||||
"log",
|
"log",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_derive",
|
"serde_derive",
|
||||||
@@ -6063,7 +6185,7 @@ checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"id-arena",
|
"id-arena",
|
||||||
"indexmap 2.13.0",
|
"indexmap 2.13.1",
|
||||||
"log",
|
"log",
|
||||||
"semver",
|
"semver",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -6075,9 +6197,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "writeable"
|
name = "writeable"
|
||||||
version = "0.6.2"
|
version = "0.6.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9"
|
checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wry"
|
name = "wry"
|
||||||
@@ -6156,9 +6278,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "yoke"
|
name = "yoke"
|
||||||
version = "0.8.1"
|
version = "0.8.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954"
|
checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"stable_deref_trait",
|
"stable_deref_trait",
|
||||||
"yoke-derive",
|
"yoke-derive",
|
||||||
@@ -6167,9 +6289,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "yoke-derive"
|
name = "yoke-derive"
|
||||||
version = "0.8.1"
|
version = "0.8.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d"
|
checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
@@ -6199,18 +6321,18 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zerofrom"
|
name = "zerofrom"
|
||||||
version = "0.1.6"
|
version = "0.1.7"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5"
|
checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"zerofrom-derive",
|
"zerofrom-derive",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zerofrom-derive"
|
name = "zerofrom-derive"
|
||||||
version = "0.1.6"
|
version = "0.1.7"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502"
|
checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
@@ -6226,9 +6348,9 @@ checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zerotrie"
|
name = "zerotrie"
|
||||||
version = "0.2.3"
|
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 = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851"
|
checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"displaydoc",
|
"displaydoc",
|
||||||
"yoke",
|
"yoke",
|
||||||
@@ -6237,9 +6359,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zerovec"
|
name = "zerovec"
|
||||||
version = "0.11.5"
|
version = "0.11.6"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002"
|
checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"yoke",
|
"yoke",
|
||||||
"zerofrom",
|
"zerofrom",
|
||||||
@@ -6248,9 +6370,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zerovec-derive"
|
name = "zerovec-derive"
|
||||||
version = "0.11.2"
|
version = "0.11.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3"
|
checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
@@ -6265,7 +6387,7 @@ checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"arbitrary",
|
"arbitrary",
|
||||||
"crc32fast",
|
"crc32fast",
|
||||||
"indexmap 2.13.0",
|
"indexmap 2.13.1",
|
||||||
"memchr",
|
"memchr",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
+20
-17
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "moku"
|
name = "moku"
|
||||||
version = "0.6.0"
|
version = "0.7.1"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[lib]
|
[lib]
|
||||||
@@ -15,22 +15,25 @@ 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 = [] }
|
||||||
tauri-plugin-shell = "2"
|
tauri-plugin-shell = "2"
|
||||||
tauri-plugin-updater = "2"
|
tauri-plugin-updater = "2"
|
||||||
tauri-plugin-process = "2"
|
tauri-plugin-process = "2"
|
||||||
tauri-plugin-http = "2"
|
tauri-plugin-http = "2"
|
||||||
serde = { version = "1", features = ["derive"] }
|
tauri-plugin-os = "2.3.2"
|
||||||
serde_json = "1"
|
tauri-plugin-discord-rpc = { git = "https://github.com/Youwes09/tauri-plugin-discord-rpc" }
|
||||||
walkdir = "2"
|
serde = { version = "1", features = ["derive"] }
|
||||||
sysinfo = "0.32"
|
serde_json = "1"
|
||||||
dirs = "5"
|
walkdir = "2"
|
||||||
tauri-plugin-os = "2.3.2"
|
sysinfo = "0.32"
|
||||||
tauri-plugin-drpc = "0.1.6"
|
dirs = "5"
|
||||||
|
urlencoding = "2"
|
||||||
|
tokio = { version = "1", features = ["rt-multi-thread"] }
|
||||||
|
reqwest = { version = "0.12", features = ["blocking"] }
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
codegen-units = 1
|
codegen-units = 1
|
||||||
lto = true
|
lto = true
|
||||||
opt-level = "s"
|
opt-level = "s"
|
||||||
panic = "abort"
|
panic = "abort"
|
||||||
strip = true
|
strip = true
|
||||||
|
|||||||
@@ -33,11 +33,11 @@
|
|||||||
"process:allow-restart",
|
"process:allow-restart",
|
||||||
"http:default",
|
"http:default",
|
||||||
"http:allow-fetch",
|
"http:allow-fetch",
|
||||||
"drpc:default",
|
"discord-rpc:default",
|
||||||
"drpc:allow-is-running",
|
"discord-rpc:allow-connect",
|
||||||
"drpc:allow-spawn-thread",
|
"discord-rpc:allow-disconnect",
|
||||||
"drpc:allow-destroy-thread",
|
"discord-rpc:allow-set-activity",
|
||||||
"drpc:allow-set-activity",
|
"discord-rpc:allow-clear-activity",
|
||||||
"drpc:allow-clear-activity"
|
"discord-rpc:allow-is-running"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"$schema": "../gen/schemas/desktop-schema.json",
|
||||||
|
"identifier": "http-scope",
|
||||||
|
"description": "HTTP fetch scope",
|
||||||
|
"windows": ["main"],
|
||||||
|
"permissions": [
|
||||||
|
{
|
||||||
|
"identifier": "http:default",
|
||||||
|
"allow": [
|
||||||
|
{ "url": "http://*:*/*" },
|
||||||
|
{ "url": "https://*:*/*" },
|
||||||
|
{ "url": "http://*/*" },
|
||||||
|
{ "url": "https://*/*" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
+162
-112
@@ -26,9 +26,6 @@ pub enum SpawnError {
|
|||||||
SpawnFailed(String),
|
SpawnFailed(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Update types ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// A single GitHub release returned to the frontend.
|
|
||||||
#[derive(Serialize, Clone)]
|
#[derive(Serialize, Clone)]
|
||||||
pub struct ReleaseInfo {
|
pub struct ReleaseInfo {
|
||||||
pub tag_name: String,
|
pub tag_name: String,
|
||||||
@@ -38,7 +35,6 @@ pub struct ReleaseInfo {
|
|||||||
pub html_url: String,
|
pub html_url: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Progress event emitted during download — matches what the frontend listens for.
|
|
||||||
#[derive(Clone, serde::Serialize)]
|
#[derive(Clone, serde::Serialize)]
|
||||||
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||||||
struct UpdateProgress {
|
struct UpdateProgress {
|
||||||
@@ -46,8 +42,6 @@ struct UpdateProgress {
|
|||||||
total: Option<u64>,
|
total: Option<u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Strip the \\?\ extended-length path prefix that Windows adds to long paths.
|
|
||||||
/// Java and many other tools do not accept this prefix and will fail silently.
|
|
||||||
fn strip_unc(path: PathBuf) -> PathBuf {
|
fn strip_unc(path: PathBuf) -> PathBuf {
|
||||||
let s = path.to_string_lossy();
|
let s = path.to_string_lossy();
|
||||||
if let Some(stripped) = s.strip_prefix(r"\\?\") {
|
if let Some(stripped) = s.strip_prefix(r"\\?\") {
|
||||||
@@ -64,7 +58,7 @@ fn resolve_downloads_path(downloads_path: &str) -> PathBuf {
|
|||||||
let base = std::env::var("XDG_DATA_HOME")
|
let base = std::env::var("XDG_DATA_HOME")
|
||||||
.map(PathBuf::from)
|
.map(PathBuf::from)
|
||||||
.unwrap_or_else(|_| dirs::data_dir().unwrap_or_else(|| PathBuf::from("/")));
|
.unwrap_or_else(|_| dirs::data_dir().unwrap_or_else(|| PathBuf::from("/")));
|
||||||
base.join("Tachidesk/downloads")
|
base.join("Tachidesk").join("downloads")
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -104,11 +98,65 @@ fn get_storage_info(downloads_path: String) -> Result<StorageInfo, String> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the OS/monitor DPI scale factor for the window's current monitor.
|
#[tauri::command]
|
||||||
/// This is the real hardware scale — 1.0 on standard displays, 2.0 on HiDPI/4K,
|
fn get_default_downloads_path() -> String {
|
||||||
/// 1.25–1.5 on Windows displays with OS-level scaling applied.
|
resolve_downloads_path("").to_string_lossy().into_owned()
|
||||||
/// The frontend multiplies this by the user's uiZoom preference to get the
|
}
|
||||||
/// final effective zoom applied to document.documentElement.
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn check_path_exists(path: String) -> bool {
|
||||||
|
std::path::Path::new(path.trim()).is_dir()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn create_directory(path: String) -> Result<(), String> {
|
||||||
|
std::fs::create_dir_all(path.trim()).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn migrate_downloads(app: tauri::AppHandle, src: String, dst: String) -> Result<(), String> {
|
||||||
|
use tauri::Emitter;
|
||||||
|
use std::fs;
|
||||||
|
|
||||||
|
let src_path = std::path::PathBuf::from(src.trim());
|
||||||
|
let dst_path = std::path::PathBuf::from(dst.trim());
|
||||||
|
|
||||||
|
if !src_path.is_dir() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let total: u64 = WalkDir::new(&src_path)
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|e| e.ok())
|
||||||
|
.filter(|e| e.file_type().is_file())
|
||||||
|
.count() as u64;
|
||||||
|
|
||||||
|
let _ = app.emit("migrate_progress", serde_json::json!({ "done": 0u64, "total": total, "current": "" }));
|
||||||
|
|
||||||
|
let mut done: u64 = 0;
|
||||||
|
|
||||||
|
for entry in WalkDir::new(&src_path).into_iter().filter_map(|e| e.ok()) {
|
||||||
|
let rel = entry.path().strip_prefix(&src_path).map_err(|e| e.to_string())?;
|
||||||
|
let target = dst_path.join(rel);
|
||||||
|
|
||||||
|
if entry.file_type().is_dir() {
|
||||||
|
fs::create_dir_all(&target).map_err(|e| e.to_string())?;
|
||||||
|
} else {
|
||||||
|
if let Some(parent) = target.parent() {
|
||||||
|
fs::create_dir_all(parent).map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
fs::copy(entry.path(), &target).map_err(|e| e.to_string())?;
|
||||||
|
done += 1;
|
||||||
|
let _ = app.emit("migrate_progress", serde_json::json!({
|
||||||
|
"done": done, "total": total, "current": rel.to_string_lossy()
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fs::remove_dir_all(&src_path).map_err(|e| e.to_string())?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn get_platform_ui_scale(window: tauri::Window) -> f64 {
|
fn get_platform_ui_scale(window: tauri::Window) -> f64 {
|
||||||
window.scale_factor().unwrap_or(1.0)
|
window.scale_factor().unwrap_or(1.0)
|
||||||
@@ -130,8 +178,6 @@ fn kill_tachidesk(app: &tauri::AppHandle) {
|
|||||||
.creation_flags(CREATE_NO_WINDOW)
|
.creation_flags(CREATE_NO_WINDOW)
|
||||||
.status();
|
.status();
|
||||||
|
|
||||||
// Poll until no java.exe remains (up to ~3 s) so the installer can
|
|
||||||
// overwrite the JRE DLLs without hitting a sharing-violation error.
|
|
||||||
for _ in 0..30 {
|
for _ in 0..30 {
|
||||||
let still_running = std::process::Command::new("tasklist")
|
let still_running = std::process::Command::new("tasklist")
|
||||||
.args(["/FI", "IMAGENAME eq java.exe", "/NH"])
|
.args(["/FI", "IMAGENAME eq java.exe", "/NH"])
|
||||||
@@ -140,20 +186,15 @@ fn kill_tachidesk(app: &tauri::AppHandle) {
|
|||||||
.map(|o| String::from_utf8_lossy(&o.stdout).contains("java.exe"))
|
.map(|o| String::from_utf8_lossy(&o.stdout).contains("java.exe"))
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
|
|
||||||
if !still_running {
|
if !still_running { break; }
|
||||||
break;
|
|
||||||
}
|
|
||||||
std::thread::sleep(std::time::Duration::from_millis(100));
|
std::thread::sleep(std::time::Duration::from_millis(100));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(target_os = "windows"))]
|
#[cfg(not(target_os = "windows"))]
|
||||||
let _ = std::process::Command::new("pkill")
|
let _ = std::process::Command::new("pkill").args(["-f", "tachidesk"]).status();
|
||||||
.args(["-f", "tachidesk"])
|
|
||||||
.status();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const DEFAULT_SERVER_CONF: &str = r#"server.ip = "127.0.0.1"
|
const DEFAULT_SERVER_CONF: &str = r#"server.ip = "127.0.0.1"
|
||||||
server.port = 4567
|
server.port = 4567
|
||||||
server.webUIEnabled = false
|
server.webUIEnabled = false
|
||||||
@@ -248,7 +289,19 @@ struct ServerInvocation {
|
|||||||
working_dir: Option<PathBuf>,
|
working_dir: Option<PathBuf>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "macos"))]
|
||||||
|
fn find_java_in_bundle(bundle_dir: &PathBuf, log: &mut Option<std::fs::File>) -> Option<PathBuf> {
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
let java = strip_unc(bundle_dir.join("jre").join("bin").join("java.exe"));
|
||||||
|
#[cfg(not(target_os = "windows"))]
|
||||||
|
let java = bundle_dir.join("jre").join("bin").join("java");
|
||||||
|
|
||||||
|
do_log(log, &format!("[find_java] path: {:?} exists: {}", java, java.exists()));
|
||||||
|
if java.exists() { Some(java) } else { None }
|
||||||
|
}
|
||||||
|
|
||||||
fn do_log(log: &mut Option<std::fs::File>, msg: &str) {
|
fn do_log(log: &mut Option<std::fs::File>, msg: &str) {
|
||||||
|
eprintln!("{}", msg);
|
||||||
if let Some(f) = log {
|
if let Some(f) = log {
|
||||||
let _ = writeln!(f, "{}", msg);
|
let _ = writeln!(f, "{}", msg);
|
||||||
}
|
}
|
||||||
@@ -259,9 +312,8 @@ fn resolve_server_binary(
|
|||||||
app: &tauri::AppHandle,
|
app: &tauri::AppHandle,
|
||||||
log: &mut Option<std::fs::File>,
|
log: &mut Option<std::fs::File>,
|
||||||
) -> Result<ServerInvocation, SpawnError> {
|
) -> Result<ServerInvocation, SpawnError> {
|
||||||
do_log(log, &format!("[resolve] binary arg = {:?}", binary));
|
do_log(log, &format!("[resolve] binary = {:?}", binary));
|
||||||
|
|
||||||
// 1. User-specified binary path
|
|
||||||
if !binary.trim().is_empty() {
|
if !binary.trim().is_empty() {
|
||||||
let path = strip_unc(PathBuf::from(binary.trim()));
|
let path = strip_unc(PathBuf::from(binary.trim()));
|
||||||
do_log(log, &format!("[resolve] user path: {:?} exists={}", path, path.exists()));
|
do_log(log, &format!("[resolve] user path: {:?} exists={}", path, path.exists()));
|
||||||
@@ -272,48 +324,91 @@ fn resolve_server_binary(
|
|||||||
working_dir: path.parent().map(|p| p.to_path_buf()),
|
working_dir: path.parent().map(|p| p.to_path_buf()),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return Err(SpawnError::NotConfigured(
|
do_log(log, "[resolve] user path not found, falling through");
|
||||||
format!("Configured binary not found: {}", path.display()),
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Bundled sidecar (Windows / Linux AppImage)
|
#[cfg(not(target_os = "macos"))]
|
||||||
|
let resource_dir = {
|
||||||
|
let raw = app.path().resource_dir().unwrap_or_default();
|
||||||
|
let stripped = strip_unc(raw);
|
||||||
|
do_log(log, &format!("[resolve] resource_dir = {:?}", stripped));
|
||||||
|
stripped
|
||||||
|
};
|
||||||
|
|
||||||
#[cfg(not(target_os = "macos"))]
|
#[cfg(not(target_os = "macos"))]
|
||||||
{
|
{
|
||||||
let resource_dir = app.path().resource_dir().unwrap_or_default();
|
let bundle_dir = resource_dir.join("binaries").join("suwayomi-bundle");
|
||||||
let candidates = ["suwayomi-launcher", "suwayomi-launcher.sh", "tachidesk-server"];
|
let jar = bundle_dir.join("bin").join("Suwayomi-Server.jar");
|
||||||
for name in &candidates {
|
|
||||||
|
do_log(log, &format!("[resolve] bundle_dir={:?} exists={}", bundle_dir, bundle_dir.exists()));
|
||||||
|
do_log(log, &format!("[resolve] jar={:?} exists={}", jar, jar.exists()));
|
||||||
|
|
||||||
|
match find_java_in_bundle(&bundle_dir, log) {
|
||||||
|
Some(java) if jar.exists() => {
|
||||||
|
do_log(log, "[resolve] using bundled JRE");
|
||||||
|
return Ok(ServerInvocation {
|
||||||
|
bin: java.to_string_lossy().into_owned(),
|
||||||
|
args: vec!["-jar".to_string(), jar.to_string_lossy().into_owned()],
|
||||||
|
working_dir: Some(bundle_dir),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
_ => do_log(log, "[resolve] bundled JRE/jar not found, falling through"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "macos"))]
|
||||||
|
{
|
||||||
|
for name in &["suwayomi-launcher", "suwayomi-launcher.sh", "tachidesk-server"] {
|
||||||
let p = resource_dir.join(name);
|
let p = resource_dir.join(name);
|
||||||
do_log(log, &format!("[resolve] sidecar candidate: {:?} exists={}", p, p.exists()));
|
do_log(log, &format!("[resolve] sidecar: {:?} exists={}", p, p.exists()));
|
||||||
if p.exists() {
|
if p.exists() {
|
||||||
do_log(log, &format!("[resolve] using sidecar: {:?}", p));
|
|
||||||
return Ok(ServerInvocation {
|
return Ok(ServerInvocation {
|
||||||
bin: p.to_string_lossy().into_owned(),
|
bin: p.to_string_lossy().into_owned(),
|
||||||
args: vec![],
|
args: vec![],
|
||||||
|
working_dir: Some(resource_dir.clone()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(java) = find_java_in_bundle(&resource_dir, log) {
|
||||||
|
let jar = std::fs::read_dir(&resource_dir)
|
||||||
|
.ok()
|
||||||
|
.and_then(|mut rd| {
|
||||||
|
rd.find(|e| e.as_ref().map(|e| e.file_name().to_string_lossy().ends_with(".jar")).unwrap_or(false))
|
||||||
|
.and_then(|e| e.ok())
|
||||||
|
.map(|e| e.path())
|
||||||
|
});
|
||||||
|
|
||||||
|
if let Some(jar_path) = jar {
|
||||||
|
do_log(log, &format!("[resolve] generic JRE java={:?} jar={:?}", java, jar_path));
|
||||||
|
return Ok(ServerInvocation {
|
||||||
|
bin: java.to_string_lossy().into_owned(),
|
||||||
|
args: vec!["-jar".to_string(), jar_path.to_string_lossy().into_owned()],
|
||||||
working_dir: Some(resource_dir),
|
working_dir: Some(resource_dir),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. macOS app bundle — look in MacOS/ and Resources/
|
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
{
|
{
|
||||||
let resource_dir = app.path().resource_dir().unwrap_or_default();
|
let resource_dir = app.path().resource_dir().unwrap_or_default();
|
||||||
let macos_dir = resource_dir.parent()
|
let macos_dir = resource_dir.parent().map(|p| p.join("MacOS")).unwrap_or_default();
|
||||||
.map(|p| p.join("MacOS"))
|
|
||||||
.unwrap_or_default();
|
|
||||||
|
|
||||||
let candidates = ["suwayomi-launcher", "suwayomi-launcher.sh", "tachidesk-server"];
|
let candidates = [
|
||||||
|
"suwayomi-server",
|
||||||
|
"suwayomi-server-aarch64-apple-darwin",
|
||||||
|
"suwayomi-server-x86_64-apple-darwin",
|
||||||
|
"suwayomi-launcher",
|
||||||
|
"suwayomi-launcher.sh",
|
||||||
|
"tachidesk-server",
|
||||||
|
];
|
||||||
|
|
||||||
// Search MacOS/ first (correct location), then Resources/ as fallback
|
|
||||||
// for flat dev layouts where the script sits next to resources.
|
|
||||||
for search_dir in &[&macos_dir, &resource_dir] {
|
for search_dir in &[&macos_dir, &resource_dir] {
|
||||||
for name in &candidates {
|
for name in &candidates {
|
||||||
let p = search_dir.join(name);
|
let p = search_dir.join(name);
|
||||||
do_log(log, &format!("[resolve] macOS candidate: {:?} exists={}", p, p.exists()));
|
do_log(log, &format!("[resolve] macOS candidate: {:?} exists={}", p, p.exists()));
|
||||||
if p.exists() {
|
if p.exists() {
|
||||||
do_log(log, &format!("[resolve] using macOS sidecar: {:?}", p));
|
|
||||||
return Ok(ServerInvocation {
|
return Ok(ServerInvocation {
|
||||||
bin: p.to_string_lossy().into_owned(),
|
bin: p.to_string_lossy().into_owned(),
|
||||||
args: vec![],
|
args: vec![],
|
||||||
@@ -324,27 +419,17 @@ fn resolve_server_binary(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
do_log(log, "[resolve] trying PATH fallback");
|
|
||||||
for name in &["suwayomi-server", "tachidesk-server"] {
|
for name in &["suwayomi-server", "tachidesk-server"] {
|
||||||
let found = std::process::Command::new("which")
|
#[cfg(target_os = "windows")]
|
||||||
.arg(name)
|
let found = std::process::Command::new("where").arg(name).output().map(|o| o.status.success()).unwrap_or(false);
|
||||||
.output()
|
#[cfg(not(target_os = "windows"))]
|
||||||
.map(|o| o.status.success())
|
let found = std::process::Command::new("which").arg(name).output().map(|o| o.status.success()).unwrap_or(false);
|
||||||
.unwrap_or(false);
|
|
||||||
|
|
||||||
do_log(log, &format!("[resolve] PATH check {:?}: found={}", name, found));
|
|
||||||
|
|
||||||
if found {
|
if found {
|
||||||
do_log(log, &format!("[resolve] using PATH binary: {}", name));
|
return Ok(ServerInvocation { bin: name.to_string(), args: vec![], working_dir: None });
|
||||||
return Ok(ServerInvocation {
|
|
||||||
bin: name.to_string(),
|
|
||||||
args: vec![],
|
|
||||||
working_dir: None,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
do_log(log, "[resolve] FAILED — no binary found anywhere");
|
|
||||||
Err(SpawnError::NotConfigured(
|
Err(SpawnError::NotConfigured(
|
||||||
"Server binary not found. Install Suwayomi-Server or set the path in Settings.".to_string(),
|
"Server binary not found. Install Suwayomi-Server or set the path in Settings.".to_string(),
|
||||||
))
|
))
|
||||||
@@ -360,50 +445,28 @@ fn spawn_server(binary: String, app: tauri::AppHandle) -> Result<(), SpawnError>
|
|||||||
}
|
}
|
||||||
|
|
||||||
let data_dir = suwayomi_data_dir();
|
let data_dir = suwayomi_data_dir();
|
||||||
|
|
||||||
let log_path = data_dir.join("moku-spawn.log");
|
let log_path = data_dir.join("moku-spawn.log");
|
||||||
let _ = std::fs::create_dir_all(&data_dir);
|
let _ = std::fs::create_dir_all(&data_dir);
|
||||||
let mut log = std::fs::OpenOptions::new()
|
let mut log = std::fs::OpenOptions::new().create(true).append(true).open(&log_path).ok();
|
||||||
.create(true)
|
|
||||||
.append(true)
|
|
||||||
.open(&log_path)
|
|
||||||
.ok();
|
|
||||||
|
|
||||||
do_log(&mut log, "");
|
do_log(&mut log, &format!("[spawn_server] binary={:?} data_dir={:?}", binary, data_dir));
|
||||||
do_log(&mut log, "========================================");
|
|
||||||
do_log(&mut log, &format!("[spawn_server] called at {:?}", std::time::SystemTime::now()));
|
|
||||||
do_log(&mut log, &format!("[spawn_server] binary arg = {:?}", binary));
|
|
||||||
do_log(&mut log, &format!("[spawn_server] data_dir = {:?}", data_dir));
|
|
||||||
do_log(&mut log, &format!("[spawn_server] log file = {:?}", log_path));
|
|
||||||
do_log(&mut log, &format!("[spawn_server] APPDATA = {:?}", std::env::var("APPDATA")));
|
|
||||||
do_log(&mut log, &format!("[spawn_server] LOCALAPPDATA = {:?}", std::env::var("LOCALAPPDATA")));
|
|
||||||
do_log(&mut log, &format!("[spawn_server] current_dir = {:?}", std::env::current_dir()));
|
|
||||||
|
|
||||||
seed_server_conf(&data_dir);
|
seed_server_conf(&data_dir);
|
||||||
do_log(&mut log, "[spawn_server] server.conf seeded");
|
|
||||||
|
|
||||||
let mut invocation = match resolve_server_binary(&binary, &app, &mut log) {
|
let mut invocation = resolve_server_binary(&binary, &app, &mut log).map_err(|e| {
|
||||||
Ok(i) => i,
|
do_log(&mut log, &format!("[spawn_server] resolve failed: {:?}", e));
|
||||||
Err(e) => {
|
e
|
||||||
do_log(&mut log, &format!("[spawn_server] resolve FAILED: {:?}", e));
|
})?;
|
||||||
return Err(e);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let bin_display = invocation.bin.clone();
|
|
||||||
let rootdir_flag = format!(
|
let rootdir_flag = format!(
|
||||||
"-Dsuwayomi.tachidesk.config.server.rootDir={}",
|
"-Dsuwayomi.tachidesk.config.server.rootDir={}",
|
||||||
data_dir.to_string_lossy()
|
data_dir.to_string_lossy()
|
||||||
);
|
);
|
||||||
|
|
||||||
invocation.args.insert(0, rootdir_flag);
|
invocation.args.insert(0, rootdir_flag);
|
||||||
|
|
||||||
let working_dir = invocation.working_dir
|
let working_dir = invocation.working_dir.unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
|
||||||
.unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
|
|
||||||
|
|
||||||
do_log(&mut log, &format!("[spawn_server] bin = {:?}", bin_display));
|
do_log(&mut log, &format!("[spawn_server] bin={:?} args={:?} cwd={:?}", invocation.bin, invocation.args, working_dir));
|
||||||
do_log(&mut log, &format!("[spawn_server] args = {:?}", invocation.args));
|
|
||||||
do_log(&mut log, &format!("[spawn_server] working_dir = {:?}", working_dir));
|
|
||||||
|
|
||||||
let cmd = app.shell()
|
let cmd = app.shell()
|
||||||
.command(&invocation.bin)
|
.command(&invocation.bin)
|
||||||
@@ -411,17 +474,13 @@ fn spawn_server(binary: String, app: tauri::AppHandle) -> Result<(), SpawnError>
|
|||||||
.args(&invocation.args)
|
.args(&invocation.args)
|
||||||
.current_dir(&working_dir);
|
.current_dir(&working_dir);
|
||||||
|
|
||||||
do_log(&mut log, "[spawn_server] calling cmd.spawn()...");
|
|
||||||
|
|
||||||
match cmd.spawn() {
|
match cmd.spawn() {
|
||||||
Ok((_rx, child)) => {
|
Ok((_rx, child)) => {
|
||||||
do_log(&mut log, &format!("[spawn_server] SUCCESS — spawned: {}", bin_display));
|
|
||||||
*app.state::<ServerState>().0.lock().unwrap() = Some(child);
|
*app.state::<ServerState>().0.lock().unwrap() = Some(child);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
do_log(&mut log, &format!("[spawn_server] SPAWN FAILED: {}", e));
|
do_log(&mut log, &format!("[spawn_server] spawn failed: {}", e));
|
||||||
do_log(&mut log, &format!("[spawn_server] error kind: {:?}", e));
|
|
||||||
Err(SpawnError::SpawnFailed(e.to_string()))
|
Err(SpawnError::SpawnFailed(e.to_string()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -433,10 +492,6 @@ fn kill_server(app: tauri::AppHandle) -> Result<(), String> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Update commands ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// Fetch the list of all GitHub releases so the frontend can show a version picker.
|
|
||||||
/// Uses tauri-plugin-http so it goes through Tauri's permission system.
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
async fn list_releases() -> Result<Vec<ReleaseInfo>, String> {
|
async fn list_releases() -> Result<Vec<ReleaseInfo>, String> {
|
||||||
use tauri_plugin_http::reqwest;
|
use tauri_plugin_http::reqwest;
|
||||||
@@ -468,22 +523,15 @@ async fn list_releases() -> Result<Vec<ReleaseInfo>, String> {
|
|||||||
let body = resp.text().await.map_err(|e| e.to_string())?;
|
let body = resp.text().await.map_err(|e| e.to_string())?;
|
||||||
let releases: Vec<GhRelease> = serde_json::from_str(&body).map_err(|e| e.to_string())?;
|
let releases: Vec<GhRelease> = serde_json::from_str(&body).map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
Ok(releases
|
Ok(releases.into_iter().map(|r| ReleaseInfo {
|
||||||
.into_iter()
|
tag_name: r.tag_name.clone(),
|
||||||
.map(|r| ReleaseInfo {
|
name: r.name.unwrap_or_else(|| r.tag_name.clone()),
|
||||||
tag_name: r.tag_name.clone(),
|
body: r.body.unwrap_or_default(),
|
||||||
name: r.name.unwrap_or_else(|| r.tag_name.clone()),
|
published_at: r.published_at.unwrap_or_default(),
|
||||||
body: r.body.unwrap_or_default(),
|
html_url: r.html_url,
|
||||||
published_at: r.published_at.unwrap_or_default(),
|
}).collect())
|
||||||
html_url: r.html_url,
|
|
||||||
})
|
|
||||||
.collect())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Download and install the latest update using tauri-plugin-updater.
|
|
||||||
/// Emits `update-progress` events with `{ downloaded, total }` while downloading.
|
|
||||||
/// On Windows the installer runs in passive (silent) mode; the frontend prompts restart.
|
|
||||||
/// On other platforms this command is a no-op — the frontend opens the GitHub page instead.
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[allow(unused_variables)]
|
#[allow(unused_variables)]
|
||||||
async fn download_and_install_update(app: tauri::AppHandle) -> Result<(), String> {
|
async fn download_and_install_update(app: tauri::AppHandle) -> Result<(), String> {
|
||||||
@@ -498,7 +546,7 @@ async fn download_and_install_update(app: tauri::AppHandle) -> Result<(), String
|
|||||||
let update = updater.check().await.map_err(|e| e.to_string())?;
|
let update = updater.check().await.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
let Some(update) = update else {
|
let Some(update) = update else {
|
||||||
return Err("No update available from the updater endpoint.".into());
|
return Err("No update available.".into());
|
||||||
};
|
};
|
||||||
|
|
||||||
let app_clone = app.clone();
|
let app_clone = app.clone();
|
||||||
@@ -516,18 +564,16 @@ async fn download_and_install_update(app: tauri::AppHandle) -> Result<(), String
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Restart the app after a successful update install.
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn restart_app(app: tauri::AppHandle) {
|
fn restart_app(app: tauri::AppHandle) {
|
||||||
tauri::process::restart(&app.env());
|
tauri::process::restart(&app.env());
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── App entry point ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
.plugin(tauri_plugin_drpc::init())
|
.plugin(tauri_plugin_discord_rpc::init())
|
||||||
.plugin(tauri_plugin_os::init())
|
.plugin(tauri_plugin_os::init())
|
||||||
.plugin(tauri_plugin_shell::init())
|
.plugin(tauri_plugin_shell::init())
|
||||||
.plugin(tauri_plugin_http::init())
|
.plugin(tauri_plugin_http::init())
|
||||||
@@ -536,6 +582,10 @@ pub fn run() {
|
|||||||
.manage(ServerState(Mutex::new(None)))
|
.manage(ServerState(Mutex::new(None)))
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
get_storage_info,
|
get_storage_info,
|
||||||
|
get_default_downloads_path,
|
||||||
|
check_path_exists,
|
||||||
|
create_directory,
|
||||||
|
migrate_downloads,
|
||||||
spawn_server,
|
spawn_server,
|
||||||
kill_server,
|
kill_server,
|
||||||
get_platform_ui_scale,
|
get_platform_ui_scale,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "Moku",
|
"productName": "Moku",
|
||||||
"version": "0.6.0",
|
"version": "0.7.1",
|
||||||
"identifier": "dev.moku.app",
|
"identifier": "dev.moku.app",
|
||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../dist",
|
"frontendDist": "../dist",
|
||||||
|
|||||||
+185
-55
@@ -5,17 +5,19 @@
|
|||||||
import { getVersion } from "@tauri-apps/api/app";
|
import { getVersion } from "@tauri-apps/api/app";
|
||||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||||
import { gql } from "./lib/client";
|
import { gql } from "./lib/client";
|
||||||
|
import logoUrl from "./assets/moku-icon-splash.svg";
|
||||||
|
import { probeServer, loginBasic, authSession, logout } from "./lib/auth";
|
||||||
import { GET_DOWNLOAD_STATUS } from "./lib/queries";
|
import { GET_DOWNLOAD_STATUS } from "./lib/queries";
|
||||||
import { store, addToast, setActiveDownloads, setSettingsOpen } from "./store/state.svelte";
|
import { store, addToast, setActiveDownloads, setSettingsOpen } from "./store/state.svelte";
|
||||||
import { initRpc, setIdle, clearReading, destroyRpc } from "./lib/discord";
|
import { initRpc, setIdle, clearReading, destroyRpc } from "./lib/discord";
|
||||||
import type { DownloadStatus, DownloadQueueItem } from "./lib/types";
|
import type { DownloadStatus, DownloadQueueItem } from "./lib/types";
|
||||||
import Layout from "./components/layout/Layout.svelte";
|
import Layout from "./components/chrome/Layout.svelte";
|
||||||
import Reader from "./components/reader/Reader.svelte";
|
import Reader from "./components/reader/Reader.svelte";
|
||||||
import Settings from "./components/settings/Settings.svelte";
|
import Settings from "./components/settings/Settings.svelte";
|
||||||
import ThemeEditor from "./components/settings/ThemeEditor.svelte";
|
import ThemeEditor from "./components/settings/ThemeEditor.svelte";
|
||||||
import TitleBar from "./components/layout/TitleBar.svelte";
|
import TitleBar from "./components/chrome/TitleBar.svelte";
|
||||||
import Toaster from "./components/layout/Toaster.svelte";
|
import Toaster from "./components/chrome/Toaster.svelte";
|
||||||
import SplashScreen from "./components/layout/SplashScreen.svelte";
|
import SplashScreen from "./components/chrome/SplashScreen.svelte";
|
||||||
import MangaPreview from "./components/shared/MangaPreview.svelte";
|
import MangaPreview from "./components/shared/MangaPreview.svelte";
|
||||||
|
|
||||||
let themeStyleEl: HTMLStyleElement | null = null;
|
let themeStyleEl: HTMLStyleElement | null = null;
|
||||||
@@ -76,22 +78,32 @@
|
|||||||
let idle = $state(false);
|
let idle = $state(false);
|
||||||
let devSplash = $state(false);
|
let devSplash = $state(false);
|
||||||
|
|
||||||
// The OS/monitor DPI scale factor for the current display.
|
let loginRequired = $state(false);
|
||||||
// Queried from Rust (window.scale_factor()) on mount and updated live
|
let loginUser = $state(store.settings.serverAuthUser ?? "");
|
||||||
// whenever the window moves to a different monitor via the scaleChanged event.
|
let loginPass = $state("");
|
||||||
// 1.0 = standard display, 2.0 = HiDPI/4K, 1.25–1.5 = Windows scaled display.
|
let loginError = $state<string | null>(null);
|
||||||
let platformScale = $state(1.0);
|
let loginBusy = $state(false);
|
||||||
|
let unsupportedMode = $state(false);
|
||||||
|
|
||||||
|
let platformScale = $state(1.0);
|
||||||
|
let _appliedZoom = -1;
|
||||||
|
let _vhRafId: number | null = null;
|
||||||
|
|
||||||
// effectiveZoom = platformScale × uiZoom (user preference, float, default 1.0)
|
|
||||||
// Applied to document.documentElement so the entire UI scales correctly.
|
|
||||||
function applyZoom() {
|
function applyZoom() {
|
||||||
const uiZoom = store.settings.uiZoom ?? 1.5;
|
const uiZoom = store.settings.uiZoom ?? 1.0;
|
||||||
const effective = platformScale * uiZoom;
|
if (uiZoom === _appliedZoom) return;
|
||||||
const pct = effective * 100;
|
_appliedZoom = uiZoom;
|
||||||
|
|
||||||
|
const pct = uiZoom * 100;
|
||||||
|
document.documentElement.style.setProperty("--ui-zoom", String(uiZoom));
|
||||||
|
document.documentElement.style.setProperty("--ui-scale", String(uiZoom));
|
||||||
document.documentElement.style.zoom = `${pct}%`;
|
document.documentElement.style.zoom = `${pct}%`;
|
||||||
document.documentElement.style.setProperty("--ui-scale", String(effective));
|
|
||||||
// visual-vh compensates for the zoom so 100vh-based calculations stay correct.
|
if (_vhRafId !== null) cancelAnimationFrame(_vhRafId);
|
||||||
document.documentElement.style.setProperty("--visual-vh", `${window.innerHeight / effective}px`);
|
_vhRafId = requestAnimationFrame(() => {
|
||||||
|
_vhRafId = null;
|
||||||
|
document.documentElement.style.setProperty("--visual-vh", `${window.innerHeight / uiZoom}px`);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let prevQueue: DownloadQueueItem[] = [];
|
let prevQueue: DownloadQueueItem[] = [];
|
||||||
@@ -136,9 +148,8 @@
|
|||||||
return () => idleEvents.forEach(e => window.removeEventListener(e, resetIdle));
|
return () => idleEvents.forEach(e => window.removeEventListener(e, resetIdle));
|
||||||
});
|
});
|
||||||
|
|
||||||
// Re-apply zoom whenever uiZoom setting or platformScale changes.
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
store.settings.uiZoom; platformScale;
|
void store.settings.uiZoom;
|
||||||
applyZoom();
|
applyZoom();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -193,30 +204,44 @@
|
|||||||
function startProbe() {
|
function startProbe() {
|
||||||
cancelProbe = false;
|
cancelProbe = false;
|
||||||
failed = false;
|
failed = false;
|
||||||
|
loginRequired = false;
|
||||||
let tries = 0;
|
let tries = 0;
|
||||||
|
|
||||||
async function probe() {
|
async function probe() {
|
||||||
if (cancelProbe) return;
|
if (cancelProbe) return;
|
||||||
tries++;
|
tries++;
|
||||||
try {
|
const result = await probeServer();
|
||||||
const rawUrl = store.settings.serverUrl;
|
if (cancelProbe) return;
|
||||||
const base = typeof rawUrl === "string" && rawUrl.trim()
|
|
||||||
? rawUrl.replace(/\/$/, "")
|
if (result === "ok") {
|
||||||
: "http://127.0.0.1:4567";
|
serverProbeOk = true;
|
||||||
const s = store.settings;
|
loginRequired = false;
|
||||||
const auth: Record<string, string> = s.serverAuthEnabled && s.serverAuthUser && s.serverAuthPass
|
return;
|
||||||
? { Authorization: `Basic ${btoa(`${s.serverAuthUser.trim()}:${s.serverAuthPass.trim()}`)}` }
|
}
|
||||||
: {};
|
|
||||||
const res = await fetch(`${base}/api/graphql`, {
|
if (result === "auth_required") {
|
||||||
method: "POST",
|
serverProbeOk = true;
|
||||||
headers: { "Content-Type": "application/json", ...auth },
|
const savedUser = store.settings.serverAuthUser?.trim() ?? "";
|
||||||
body: JSON.stringify({ query: "{ __typename }" }),
|
const savedPass = store.settings.serverAuthPass?.trim() ?? "";
|
||||||
signal: AbortSignal.timeout(2000),
|
if (savedUser && savedPass) {
|
||||||
});
|
try {
|
||||||
if (res.ok && !cancelProbe) { serverProbeOk = true; return; }
|
await loginBasic(savedUser, savedPass);
|
||||||
} catch {}
|
loginRequired = false;
|
||||||
if (tries >= MAX_ATTEMPTS && !cancelProbe) { failed = true; return; }
|
return;
|
||||||
if (!cancelProbe) setTimeout(probe, 750);
|
} catch {}
|
||||||
|
}
|
||||||
|
loginRequired = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result === "unsupported_mode") {
|
||||||
|
serverProbeOk = true;
|
||||||
|
unsupportedMode = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tries >= MAX_ATTEMPTS) { failed = true; return; }
|
||||||
|
setTimeout(probe, 750);
|
||||||
}
|
}
|
||||||
|
|
||||||
setTimeout(probe, 800);
|
setTimeout(probe, 800);
|
||||||
@@ -226,8 +251,6 @@
|
|||||||
document.addEventListener("contextmenu", e => e.preventDefault());
|
document.addEventListener("contextmenu", e => e.preventDefault());
|
||||||
(window as any).__mokuShowSplash = () => devSplash = true;
|
(window as any).__mokuShowSplash = () => devSplash = true;
|
||||||
|
|
||||||
// Fetch the real monitor scale factor from Rust (window.scale_factor()).
|
|
||||||
// This reflects actual DPI — 2.0 on HiDPI, 1.25 on Windows scaled displays, etc.
|
|
||||||
platformScale = await invoke<number>("get_platform_ui_scale").catch(() => 1.0);
|
platformScale = await invoke<number>("get_platform_ui_scale").catch(() => 1.0);
|
||||||
applyZoom();
|
applyZoom();
|
||||||
|
|
||||||
@@ -237,9 +260,6 @@
|
|||||||
store.isFullscreen = await win.isFullscreen();
|
store.isFullscreen = await win.isFullscreen();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Re-query the scale factor when the window moves to a different monitor.
|
|
||||||
// Tauri emits this event whenever the DPI changes (e.g. dragging window
|
|
||||||
// from a 1080p display to a 4K display).
|
|
||||||
const unlistenScale = await win.onScaleChanged(async (event) => {
|
const unlistenScale = await win.onScaleChanged(async (event) => {
|
||||||
platformScale = event.payload.scaleFactor;
|
platformScale = event.payload.scaleFactor;
|
||||||
applyZoom();
|
applyZoom();
|
||||||
@@ -288,44 +308,131 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
// When the reader closes, show idle presence.
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!store.activeChapter) {
|
if (!store.activeChapter) {
|
||||||
if (store.settings.discordRpc) setIdle();
|
if (store.settings.discordRpc) setIdle();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function handleZoomKey(e: KeyboardEvent) {
|
||||||
|
if (!e.ctrlKey) return;
|
||||||
|
if (e.key === "=" || e.key === "+") {
|
||||||
|
e.preventDefault();
|
||||||
|
store.settings.uiZoom = Math.min(2.0, Math.round(((store.settings.uiZoom ?? 1.0) + 0.1) * 10) / 10);
|
||||||
|
} else if (e.key === "-") {
|
||||||
|
e.preventDefault();
|
||||||
|
store.settings.uiZoom = Math.max(0.5, Math.round(((store.settings.uiZoom ?? 1.0) - 0.1) * 10) / 10);
|
||||||
|
} else if (e.key === "0") {
|
||||||
|
e.preventDefault();
|
||||||
|
store.settings.uiZoom = 1.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
window.addEventListener("keydown", handleZoomKey);
|
||||||
|
return () => window.removeEventListener("keydown", handleZoomKey);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function handleLogin() {
|
||||||
|
if (!loginUser.trim() || !loginPass.trim()) {
|
||||||
|
loginError = "Username and password are required";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loginBusy = true;
|
||||||
|
loginError = null;
|
||||||
|
try {
|
||||||
|
await loginBasic(loginUser.trim(), loginPass.trim());
|
||||||
|
loginRequired = false;
|
||||||
|
loginPass = "";
|
||||||
|
loginError = null;
|
||||||
|
appReady = true;
|
||||||
|
} catch (e: any) {
|
||||||
|
loginError = e?.message ?? "Login failed";
|
||||||
|
} finally {
|
||||||
|
loginBusy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function handleRetry() {
|
function handleRetry() {
|
||||||
failed = false;
|
failed = false;
|
||||||
notConfigured = false;
|
notConfigured = false;
|
||||||
serverProbeOk = false;
|
serverProbeOk = false;
|
||||||
|
loginRequired = false;
|
||||||
|
unsupportedMode = false;
|
||||||
startProbe();
|
startProbe();
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleBypass() {
|
function handleBypass() {
|
||||||
cancelProbe = true;
|
cancelProbe = true;
|
||||||
serverProbeOk = true;
|
serverProbeOk = true;
|
||||||
appReady = true;
|
loginRequired = false;
|
||||||
|
unsupportedMode = false;
|
||||||
|
appReady = true;
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if devSplash}
|
{#if devSplash}
|
||||||
<SplashScreen mode="idle" showFps showCards={store.settings.splashCards ?? true}
|
<SplashScreen mode="idle" showFps showCards={store.settings.splashCards ?? true}
|
||||||
onDismiss={() => setTimeout(() => devSplash = false, 340)} />
|
onDismiss={() => setTimeout(() => devSplash = false, 340)} />
|
||||||
{:else if !appReady}
|
{:else if !appReady && !loginRequired && !unsupportedMode}
|
||||||
<SplashScreen mode="loading" ringFull={serverProbeOk} {failed} {notConfigured}
|
<SplashScreen mode="loading" ringFull={serverProbeOk} {failed} {notConfigured}
|
||||||
showCards={store.settings.splashCards ?? true}
|
showCards={store.settings.splashCards ?? true}
|
||||||
onReady={() => appReady = true}
|
onReady={() => { appReady = true; }}
|
||||||
onRetry={handleRetry}
|
onRetry={handleRetry}
|
||||||
onBypass={handleBypass} />
|
onBypass={handleBypass} />
|
||||||
|
{:else if unsupportedMode}
|
||||||
|
<SplashScreen mode="loading" ringFull={true} showCards={store.settings.splashCards ?? true} />
|
||||||
|
<div class="auth-overlay">
|
||||||
|
<div class="auth-card">
|
||||||
|
<img src={logoUrl} alt="Moku" class="auth-logo" />
|
||||||
|
<p class="auth-title">moku</p>
|
||||||
|
<span class="auth-mode-badge auth-mode-badge--warn">{
|
||||||
|
store.settings.serverAuthMode === "SIMPLE_LOGIN" ? "Simple Login" :
|
||||||
|
store.settings.serverAuthMode === "UI_LOGIN" ? "UI Login" : "Unsupported Auth"
|
||||||
|
}</span>
|
||||||
|
<p class="auth-host">{store.settings.serverUrl || "localhost:4567"}</p>
|
||||||
|
<p class="auth-body">
|
||||||
|
<strong>{
|
||||||
|
store.settings.serverAuthMode === "SIMPLE_LOGIN" ? "Simple Login" :
|
||||||
|
store.settings.serverAuthMode === "UI_LOGIN" ? "UI Login" : "This auth mode"
|
||||||
|
}</strong> is not supported. Switch your server to <strong>Basic Auth</strong> and update Settings → Security.
|
||||||
|
</p>
|
||||||
|
<button class="auth-btn auth-btn--ghost" onclick={handleBypass}>Continue anyway</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else if loginRequired}
|
||||||
|
<SplashScreen mode="loading" ringFull={true} showCards={store.settings.splashCards ?? true} />
|
||||||
|
<div class="auth-overlay">
|
||||||
|
<div class="auth-card">
|
||||||
|
<img src={logoUrl} alt="Moku" class="auth-logo" />
|
||||||
|
<p class="auth-title">moku</p>
|
||||||
|
<span class="auth-mode-badge">Basic Auth</span>
|
||||||
|
<p class="auth-host">{store.settings.serverUrl || "localhost:4567"}</p>
|
||||||
|
{#if loginError}
|
||||||
|
<p class="auth-error">{loginError}</p>
|
||||||
|
{/if}
|
||||||
|
<div class="auth-fields">
|
||||||
|
<input class="auth-input" type="text" placeholder="Username"
|
||||||
|
bind:value={loginUser} disabled={loginBusy} autocomplete="username"
|
||||||
|
onkeydown={(e) => e.key === "Enter" && handleLogin()} />
|
||||||
|
<input class="auth-input" type="password" placeholder="Password"
|
||||||
|
bind:value={loginPass} disabled={loginBusy} autocomplete="current-password"
|
||||||
|
onkeydown={(e) => e.key === "Enter" && handleLogin()} />
|
||||||
|
</div>
|
||||||
|
<button class="auth-btn" onclick={handleLogin}
|
||||||
|
disabled={loginBusy || !loginUser.trim() || !loginPass.trim()}>
|
||||||
|
{loginBusy ? "Signing in…" : "Sign in"}
|
||||||
|
</button>
|
||||||
|
<button class="auth-btn auth-btn--ghost" onclick={handleBypass}>Skip</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="root">
|
<div id="app-shell" class="root">
|
||||||
{#if idle && !store.activeChapter}
|
{#if idle && !store.activeChapter}
|
||||||
<SplashScreen mode="idle" showCards={store.settings.splashCards ?? true}
|
<SplashScreen mode="idle" showCards={store.settings.splashCards ?? true}
|
||||||
onDismiss={() => { idle = false; resetIdle(); }} />
|
onDismiss={() => { idle = false; resetIdle(); }} />
|
||||||
{/if}
|
{/if}
|
||||||
{#if !store.activeChapter && !store.isFullscreen}<TitleBar />{/if}
|
{#if !store.activeChapter}<TitleBar />{/if}
|
||||||
<div class="content">
|
<div class="content">
|
||||||
{#if store.activeChapter}<Reader />{:else}<Layout />{/if}
|
{#if store.activeChapter}<Reader />{:else}<Layout />{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -344,4 +451,27 @@
|
|||||||
<style>
|
<style>
|
||||||
.root { display: flex; flex-direction: column; height: 100%; overflow: hidden; }
|
.root { display: flex; flex-direction: column; height: 100%; overflow: hidden; }
|
||||||
.content { flex: 1; overflow: hidden; }
|
.content { flex: 1; overflow: hidden; }
|
||||||
|
|
||||||
|
/* Auth overlay — floats above the SplashScreen */
|
||||||
|
.auth-overlay { position: fixed; inset: 0; z-index: 10000; display: flex; align-items: center; justify-content: center; pointer-events: none; }
|
||||||
|
.auth-card { pointer-events: auto; width: min(280px, calc(100vw - 48px)); background: var(--bg-surface); border: 1px solid var(--border-base); border-radius: var(--radius-xl); padding: var(--sp-6) var(--sp-5); display: flex; flex-direction: column; align-items: center; gap: var(--sp-3); box-shadow: 0 32px 80px rgba(0,0,0,0.75); animation: authIn 0.28s cubic-bezier(0.16,1,0.3,1) both; text-align: center; }
|
||||||
|
@keyframes authIn { from { opacity: 0; transform: translateY(10px) scale(0.97); } to { opacity: 1; transform: none; } }
|
||||||
|
|
||||||
|
.auth-logo { width: 56px; height: 56px; border-radius: 14px; display: block; }
|
||||||
|
.auth-title { font-family: var(--font-ui); font-size: 11px; font-weight: 500; letter-spacing: 0.26em; text-transform: uppercase; color: var(--text-secondary); margin: -6px 0 0; user-select: none; }
|
||||||
|
.auth-mode-badge { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wider); text-transform: uppercase; color: var(--accent-fg); background: var(--accent-muted); border: 1px solid var(--accent-dim); border-radius: var(--radius-full); padding: 2px 10px; }
|
||||||
|
.auth-mode-badge--warn { color: var(--color-error); background: var(--color-error-bg); border-color: var(--color-error); }
|
||||||
|
.auth-host { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); margin: -4px 0 0; }
|
||||||
|
.auth-body { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); letter-spacing: var(--tracking-wide); line-height: var(--leading-snug); margin: 0; }
|
||||||
|
.auth-body strong { color: var(--text-secondary); font-weight: var(--weight-medium); }
|
||||||
|
.auth-error { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--color-error); background: var(--color-error-bg); border: 1px solid var(--color-error); border-radius: var(--radius-sm); padding: var(--sp-2) var(--sp-3); margin: 0; width: 100%; box-sizing: border-box; }
|
||||||
|
.auth-fields { display: flex; flex-direction: column; gap: var(--sp-2); width: 100%; }
|
||||||
|
.auth-input { width: 100%; background: var(--bg-raised); border: 1px solid var(--border-strong); border-radius: var(--radius-md); padding: 8px 12px; font-size: var(--text-sm); color: var(--text-primary); outline: none; box-sizing: border-box; transition: border-color var(--t-base), box-shadow var(--t-base); font-family: inherit; }
|
||||||
|
.auth-input:focus { border-color: var(--border-focus); box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 20%, transparent); }
|
||||||
|
.auth-input:disabled { opacity: 0.5; }
|
||||||
|
.auth-btn { width: 100%; padding: 9px; border-radius: var(--radius-md); background: var(--accent); border: 1px solid var(--accent); color: var(--accent-fg); font-size: var(--text-sm); font-family: var(--font-ui); letter-spacing: var(--tracking-wide); cursor: pointer; transition: opacity var(--t-base); }
|
||||||
|
.auth-btn:hover:not(:disabled) { opacity: 0.85; }
|
||||||
|
.auth-btn:disabled { opacity: 0.35; cursor: default; }
|
||||||
|
.auth-btn--ghost { background: none; border-color: transparent; color: var(--text-faint); font-size: var(--text-xs); padding: 4px; }
|
||||||
|
.auth-btn--ghost:hover:not(:disabled) { color: var(--text-muted); opacity: 1; }
|
||||||
</style>
|
</style>
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
import Sidebar from "./Sidebar.svelte";
|
import Sidebar from "./Sidebar.svelte";
|
||||||
import Home from "../pages/Home.svelte";
|
import Home from "../pages/Home.svelte";
|
||||||
import Library from "../pages/Library.svelte";
|
import Library from "../pages/Library.svelte";
|
||||||
import SeriesDetail from "../pages/SeriesDetail.svelte";
|
import SeriesDetail from "../series/SeriesDetail.svelte";
|
||||||
import RecentActivity from "./RecentActivity.svelte";
|
import RecentActivity from "./RecentActivity.svelte";
|
||||||
import Search from "../pages/Search.svelte";
|
import Search from "../pages/Search.svelte";
|
||||||
import Discover from "../pages/Discover.svelte";
|
import Discover from "../pages/Discover.svelte";
|
||||||
+3
-3
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { ClockCounterClockwise, Trash, MagnifyingGlass, Play, Books, Fire, BookOpen, Clock, TrendUp } from "phosphor-svelte";
|
import { ClockCounterClockwise, Trash, MagnifyingGlass, Play, Books, Fire, BookOpen, Clock, TrendUp } from "phosphor-svelte";
|
||||||
import { thumbUrl } from "../../lib/client";
|
import Thumbnail from "../shared/Thumbnail.svelte";
|
||||||
import { store, clearHistory, openReader, setActiveManga } from "../../store/state.svelte";
|
import { store, clearHistory, openReader, setActiveManga } from "../../store/state.svelte";
|
||||||
import type { HistoryEntry } from "../../store/state.svelte";
|
import type { HistoryEntry } from "../../store/state.svelte";
|
||||||
|
|
||||||
@@ -192,7 +192,7 @@
|
|||||||
{#each items as session (session.latestChapterId)}
|
{#each items as session (session.latestChapterId)}
|
||||||
<button class="session-row" onclick={() => resume(session)}>
|
<button class="session-row" onclick={() => resume(session)}>
|
||||||
<div class="thumb-wrap">
|
<div class="thumb-wrap">
|
||||||
<img src={thumbUrl(session.thumbnailUrl)} alt={session.mangaTitle} class="thumb" />
|
<Thumbnail src={session.thumbnailUrl} alt={session.mangaTitle} class="thumb" />
|
||||||
{#if session.chapterCount > 1}
|
{#if session.chapterCount > 1}
|
||||||
<span class="session-count">{session.chapterCount}</span>
|
<span class="session-count">{session.chapterCount}</span>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -290,7 +290,7 @@
|
|||||||
.session-row:hover .play-pill { opacity: 1; transform: translateX(0); }
|
.session-row:hover .play-pill { opacity: 1; transform: translateX(0); }
|
||||||
|
|
||||||
.thumb-wrap { position: relative; flex-shrink: 0; }
|
.thumb-wrap { position: relative; flex-shrink: 0; }
|
||||||
.thumb { width: 38px; height: 54px; border-radius: var(--radius-sm); object-fit: cover; display: block; background: var(--bg-raised); border: 1px solid var(--border-dim); }
|
:global(.thumb) { width: 38px; height: 54px; border-radius: var(--radius-sm); object-fit: cover; display: block; background: var(--bg-raised); border: 1px solid var(--border-dim); }
|
||||||
.session-count {
|
.session-count {
|
||||||
position: absolute; bottom: -4px; right: -6px;
|
position: absolute; bottom: -4px; right: -6px;
|
||||||
background: var(--accent-muted); border: 1px solid var(--accent-dim); color: var(--accent-fg);
|
background: var(--accent-muted); border: 1px solid var(--accent-dim); color: var(--accent-fg);
|
||||||
@@ -50,19 +50,20 @@
|
|||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.root { width: var(--sidebar-width); flex-shrink: 0; background: var(--bg-void); display: flex; flex-direction: column; align-items: center; padding: var(--sp-4) 0; }
|
.root { width: var(--sidebar-width); flex-shrink: 0; background: var(--bg-void); display: flex; flex-direction: column; align-items: center; padding: var(--sp-4) 0; overflow: hidden; min-height: 0; height: 100%; }
|
||||||
.logo { width: 80px; height: 80px; display: flex; align-items: center; justify-content: center; margin-bottom: var(--sp-3); background: none; border: none; outline: none; cursor: pointer; border-radius: var(--radius-lg); transition: opacity var(--t-base), transform var(--t-base); padding: 0; appearance: none; -webkit-appearance: none; }
|
.logo { width: 80px; height: 80px; flex-shrink: 0; display: flex; align-items: center; justify-content: center; margin-bottom: var(--sp-3); background: none; border: none; outline: none; cursor: pointer; border-radius: var(--radius-lg); transition: opacity var(--t-base), transform var(--t-base); padding: 0; appearance: none; -webkit-appearance: none; }
|
||||||
.logo:hover { opacity: 0.8; transform: scale(0.96); }
|
.logo:hover { opacity: 0.8; transform: scale(0.96); }
|
||||||
.logo:active { transform: scale(0.92); }
|
.logo:active { transform: scale(0.92); }
|
||||||
.logo:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
.logo:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||||
.logo-icon { width: 67px; height: 67px; background-color: var(--accent); mask-image: url("../../assets/moku-icon-wordmark.svg"); mask-repeat: no-repeat; mask-position: center; mask-size: contain; -webkit-mask-image: url("../../assets/moku-icon-wordmark.svg"); -webkit-mask-repeat: no-repeat; -webkit-mask-position: center; -webkit-mask-size: contain; filter: drop-shadow(0 0 8px rgba(107,143,107,0.35)); pointer-events: none; }
|
.logo-icon { width: 67px; height: 67px; background-color: var(--accent); mask-image: url("../../assets/moku-icon-wordmark.svg"); mask-repeat: no-repeat; mask-position: center; mask-size: contain; -webkit-mask-image: url("../../assets/moku-icon-wordmark.svg"); -webkit-mask-repeat: no-repeat; -webkit-mask-position: center; -webkit-mask-size: contain; filter: drop-shadow(0 0 8px rgba(107,143,107,0.35)); pointer-events: none; }
|
||||||
.nav { flex: 1; display: flex; flex-direction: column; align-items: center; gap: var(--sp-1); width: 100%; padding: 0 var(--sp-2); }
|
.nav { flex: 1; min-height: 0; display: flex; flex-direction: column; align-items: center; gap: var(--sp-1); width: 100%; padding: 0 var(--sp-2); overflow-y: auto; overflow-x: hidden; scrollbar-width: none; }
|
||||||
.tab { width: 36px; height: 36px; display: flex; align-items: center; justify-content: center; border-radius: var(--radius-md); color: var(--text-faint); background: none; border: none; outline: none; cursor: pointer; padding: 0; appearance: none; -webkit-appearance: none; transition: color var(--t-base), background var(--t-base); }
|
.nav::-webkit-scrollbar { display: none; }
|
||||||
|
.tab { width: 36px; height: 36px; flex-shrink: 0; display: flex; align-items: center; justify-content: center; border-radius: var(--radius-md); color: var(--text-faint); background: none; border: none; outline: none; cursor: pointer; padding: 0; appearance: none; -webkit-appearance: none; transition: color var(--t-base), background var(--t-base); }
|
||||||
.tab:hover { color: var(--text-muted); background: var(--bg-raised); }
|
.tab:hover { color: var(--text-muted); background: var(--bg-raised); }
|
||||||
.tab:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
|
.tab:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
|
||||||
.tab.active { color: var(--accent-fg); background: var(--accent-muted); }
|
.tab.active { color: var(--accent-fg); background: var(--accent-muted); }
|
||||||
.tab.active:hover { color: var(--accent-fg); background: var(--accent-muted); }
|
.tab.active:hover { color: var(--accent-fg); background: var(--accent-muted); }
|
||||||
.bottom { display: flex; flex-direction: column; align-items: center; width: 100%; padding: var(--sp-3) var(--sp-2) 0; border-top: 1px solid var(--border-dim); margin-top: var(--sp-3); }
|
.bottom { flex-shrink: 0; display: flex; flex-direction: column; align-items: center; width: 100%; padding: var(--sp-3) var(--sp-2) 0; border-top: 1px solid var(--border-dim); margin-top: var(--sp-3); }
|
||||||
.settings-btn { width: 36px; height: 36px; display: flex; align-items: center; justify-content: center; border-radius: var(--radius-md); color: var(--text-faint); background: none; border: none; outline: none; cursor: pointer; padding: 0; appearance: none; -webkit-appearance: none; transition: color var(--t-base), background var(--t-base), transform var(--t-slow); }
|
.settings-btn { width: 36px; height: 36px; display: flex; align-items: center; justify-content: center; border-radius: var(--radius-md); color: var(--text-faint); background: none; border: none; outline: none; cursor: pointer; padding: 0; appearance: none; -webkit-appearance: none; transition: color var(--t-base), background var(--t-base), transform var(--t-slow); }
|
||||||
.settings-btn:hover { color: var(--text-muted); background: var(--bg-raised); transform: rotate(30deg); }
|
.settings-btn:hover { color: var(--text-muted); background: var(--bg-raised); transform: rotate(30deg); }
|
||||||
.settings-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
|
.settings-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
|
||||||
+141
-130
@@ -17,8 +17,11 @@
|
|||||||
onDismiss?: () => void;
|
onDismiss?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { mode = "loading", ringFull = false, failed = false, notConfigured = false,
|
let {
|
||||||
showCards = true, showFps = false, onReady, onRetry, onBypass, onDismiss }: Props = $props();
|
mode = "loading", ringFull = false, failed = false,
|
||||||
|
notConfigured = false, showCards = true, showFps = false,
|
||||||
|
onReady, onRetry, onBypass, onDismiss,
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
const lockEnabled = $derived(
|
const lockEnabled = $derived(
|
||||||
store.settings.appLockEnabled && (store.settings.appLockPin?.length ?? 0) >= 4
|
store.settings.appLockEnabled && (store.settings.appLockPin?.length ?? 0) >= 4
|
||||||
@@ -28,6 +31,21 @@
|
|||||||
let pinShake = $state(false);
|
let pinShake = $state(false);
|
||||||
let pinUnlocked = $state(false);
|
let pinUnlocked = $state(false);
|
||||||
let pinVisible = $state(false);
|
let pinVisible = $state(false);
|
||||||
|
let uiScale = $state(1);
|
||||||
|
let fpsEl = $state<HTMLSpanElement | undefined>(undefined);
|
||||||
|
|
||||||
|
const logoLoadingSize = 140;
|
||||||
|
const logoIdleSize = 128;
|
||||||
|
const logoLockSize = 96;
|
||||||
|
|
||||||
|
const ringR = $derived(70);
|
||||||
|
const ringPad = $derived(12);
|
||||||
|
const ringSize = $derived((ringR + ringPad) * 2);
|
||||||
|
const ringC = $derived(ringR + ringPad);
|
||||||
|
const ringCirc = $derived(2 * Math.PI * ringR);
|
||||||
|
const ringArc = $derived(ringCirc * Math.min(Math.max(ringProg, 0.025), 0.999));
|
||||||
|
const ringTop = $derived(-((ringSize - logoLoadingSize) / 2));
|
||||||
|
const ringLeft = $derived(-((ringSize - logoLoadingSize) / 2));
|
||||||
|
|
||||||
function submitPin() {
|
function submitPin() {
|
||||||
if (pinEntry === store.settings.appLockPin) {
|
if (pinEntry === store.settings.appLockPin) {
|
||||||
@@ -36,13 +54,13 @@
|
|||||||
if (mode === "idle") triggerExit(onDismiss);
|
if (mode === "idle") triggerExit(onDismiss);
|
||||||
} else {
|
} else {
|
||||||
pinShake = true;
|
pinShake = true;
|
||||||
pinEntry = "";
|
pinEntry = "";
|
||||||
setTimeout(() => pinShake = false, 500);
|
setTimeout(() => (pinShake = false), 500);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onPinKey(e: KeyboardEvent) {
|
function onPinKey(e: KeyboardEvent) {
|
||||||
if (e.key === "Enter") { submitPin(); return; }
|
if (e.key === "Enter") { submitPin(); return; }
|
||||||
if (e.key === "Backspace") { pinEntry = pinEntry.slice(0, -1); return; }
|
if (e.key === "Backspace") { pinEntry = pinEntry.slice(0, -1); return; }
|
||||||
if (/^\d$/.test(e.key)) {
|
if (/^\d$/.test(e.key)) {
|
||||||
pinEntry = (pinEntry + e.key).slice(0, 8);
|
pinEntry = (pinEntry + e.key).slice(0, 8);
|
||||||
@@ -50,10 +68,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleRetry() { onRetry?.(); }
|
const EXIT_MS = 320;
|
||||||
function handleBypass() { onBypass?.(); }
|
|
||||||
|
|
||||||
const EXIT_MS = 320;
|
|
||||||
const PHASE1_TARGET = 0.85;
|
const PHASE1_TARGET = 0.85;
|
||||||
const PHASE1_MS = 3000;
|
const PHASE1_MS = 3000;
|
||||||
const PHASE2_TARGET = 0.95;
|
const PHASE2_TARGET = 0.95;
|
||||||
@@ -64,8 +79,6 @@
|
|||||||
let exiting = $state(false);
|
let exiting = $state(false);
|
||||||
let exitLock = false;
|
let exitLock = false;
|
||||||
|
|
||||||
let fpsEl = $state<HTMLSpanElement | undefined>(undefined);
|
|
||||||
|
|
||||||
function triggerExit(cb?: () => void) {
|
function triggerExit(cb?: () => void) {
|
||||||
if (exitLock) return;
|
if (exitLock) return;
|
||||||
exitLock = true;
|
exitLock = true;
|
||||||
@@ -81,18 +94,14 @@
|
|||||||
if (exitLock) return;
|
if (exitLock) return;
|
||||||
if (animStart === null) animStart = ts;
|
if (animStart === null) animStart = ts;
|
||||||
const elapsed = ts - animStart;
|
const elapsed = ts - animStart;
|
||||||
|
|
||||||
if (animPhase === 1) {
|
if (animPhase === 1) {
|
||||||
const t = Math.min(elapsed / PHASE1_MS, 1);
|
const t = Math.min(elapsed / PHASE1_MS, 1);
|
||||||
const eased = 1 - Math.pow(1 - t, 3);
|
ringProg = 0.025 + (1 - Math.pow(1 - t, 3)) * (PHASE1_TARGET - 0.025);
|
||||||
ringProg = 0.025 + eased * (PHASE1_TARGET - 0.025);
|
|
||||||
if (t >= 1) { animPhase = 2; animStart = ts; }
|
if (t >= 1) { animPhase = 2; animStart = ts; }
|
||||||
} else if (animPhase === 2) {
|
} else {
|
||||||
const t = Math.min(elapsed / PHASE2_MS, 1);
|
const t = Math.min(elapsed / PHASE2_MS, 1);
|
||||||
const eased = 1 - Math.pow(1 - t, 4);
|
ringProg = PHASE1_TARGET + (1 - Math.pow(1 - t, 4)) * (PHASE2_TARGET - PHASE1_TARGET);
|
||||||
ringProg = PHASE1_TARGET + eased * (PHASE2_TARGET - PHASE1_TARGET);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
animFrame = requestAnimationFrame(animateRing);
|
animFrame = requestAnimationFrame(animateRing);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,26 +113,39 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (ringFull) {
|
if (!ringFull) return;
|
||||||
cancelAnimationFrame(animFrame);
|
cancelAnimationFrame(animFrame);
|
||||||
ringProg = 1;
|
ringProg = 1;
|
||||||
if (lockEnabled && !pinUnlocked) {
|
if (lockEnabled && !pinUnlocked) {
|
||||||
setTimeout(() => { pinVisible = true; }, 400);
|
setTimeout(() => (pinVisible = true), 400);
|
||||||
} else {
|
} else {
|
||||||
setTimeout(() => triggerExit(onReady), 650);
|
setTimeout(() => triggerExit(onReady), 650);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const needsPin =
|
||||||
|
(mode === "idle" && lockEnabled) ||
|
||||||
|
(mode === "loading" && lockEnabled && ringFull && !pinUnlocked);
|
||||||
|
if (!needsPin) return;
|
||||||
|
window.addEventListener("keydown", onPinKey);
|
||||||
|
return () => window.removeEventListener("keydown", onPinKey);
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (pinUnlocked && mode !== "idle") triggerExit(onReady);
|
||||||
|
});
|
||||||
|
|
||||||
const dotsInterval = setInterval(() => {
|
const dotsInterval = setInterval(() => {
|
||||||
dots = dots.length >= 3 ? "" : dots + ".";
|
dots = dots.length >= 3 ? "" : dots + ".";
|
||||||
}, 420);
|
}, 420);
|
||||||
|
|
||||||
onMount(() => {
|
onMount(async () => {
|
||||||
|
const win = getCurrentWindow();
|
||||||
|
uiScale = await win.scaleFactor();
|
||||||
|
|
||||||
if (mode === "idle" && onDismiss) {
|
if (mode === "idle" && onDismiss) {
|
||||||
if (lockEnabled) {
|
if (lockEnabled) return () => clearInterval(dotsInterval);
|
||||||
return () => clearInterval(dotsInterval);
|
|
||||||
}
|
|
||||||
const handler = () => triggerExit(onDismiss);
|
const handler = () => triggerExit(onDismiss);
|
||||||
const t = setTimeout(() => {
|
const t = setTimeout(() => {
|
||||||
window.addEventListener("keydown", handler, { once: true });
|
window.addEventListener("keydown", handler, { once: true });
|
||||||
@@ -141,8 +163,9 @@
|
|||||||
return () => clearInterval(dotsInterval);
|
return () => clearInterval(dotsInterval);
|
||||||
});
|
});
|
||||||
|
|
||||||
interface CardDef { cx: number; w: number; h: number; lines: number; alpha: number; speed: number; cycleSec: number; phase: number; travel: number; yStart: number; angleStart: number; tilt: number; }
|
interface CardDef { cx: number; w: number; h: number; lines: number; alpha: number; speed: number; cycleSec: number; phase: number; travel: number; yStart: number; angleStart: number; tilt: number; }
|
||||||
interface CardTrig { cosA: number; sinA: number; tiltRad: number; }
|
interface CardTrig { cosA: number; sinA: number; tiltRad: number; }
|
||||||
|
interface RenderState { cards: CardDef[]; trigs: CardTrig[]; stamps: HTMLCanvasElement[]; vignette: HTMLCanvasElement; CW: number; CH: number; scale: number; }
|
||||||
|
|
||||||
const LAYER_CFG = [
|
const LAYER_CFG = [
|
||||||
{ wMin: 26, wMax: 40, speedMin: 30, speedMax: 50, alpha: 0.22 },
|
{ wMin: 26, wMax: 40, speedMin: 30, speedMax: 50, alpha: 0.22 },
|
||||||
@@ -159,29 +182,34 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildCards(vw: number, vh: number) {
|
function buildCards(vw: number, vh: number) {
|
||||||
const cards: CardDef[] = [], laneW = vw / COLS;
|
const cards: CardDef[] = [];
|
||||||
|
const laneW = vw / COLS;
|
||||||
for (let layer = 0; layer < 3; layer++) {
|
for (let layer = 0; layer < 3; layer++) {
|
||||||
const cfg = LAYER_CFG[layer];
|
const cfg = LAYER_CFG[layer];
|
||||||
for (let col = 0; col < COLS; col++) {
|
for (let col = 0; col < COLS; col++) {
|
||||||
const seed = col * 31 + layer * 97 + 7;
|
const seed = col * 31 + layer * 97 + 7;
|
||||||
const w = cfg.wMin + hash(seed + 1) * (cfg.wMax - cfg.wMin);
|
const w = cfg.wMin + hash(seed + 1) * (cfg.wMax - cfg.wMin);
|
||||||
const h = w * 1.44;
|
const h = w * 1.44;
|
||||||
const speed = cfg.speedMin + hash(seed + 5) * (cfg.speedMax - cfg.speedMin);
|
const speed = cfg.speedMin + hash(seed + 5) * (cfg.speedMax - cfg.speedMin);
|
||||||
const travel = vh + h + BUF;
|
const travel = vh + h + BUF;
|
||||||
cards.push({
|
cards.push({
|
||||||
cx: (col + 0.5) * laneW + (hash(seed + 2) * 2 - 1) * Math.max(0, (laneW - w) / 2 - 2),
|
cx: (col + 0.5) * laneW + (hash(seed + 2) * 2 - 1) * Math.max(0, (laneW - w) / 2 - 2),
|
||||||
w, h, lines: 1 + Math.floor(hash(seed + 7) * 3), alpha: cfg.alpha, speed,
|
w, h,
|
||||||
|
lines: 1 + Math.floor(hash(seed + 7) * 3),
|
||||||
|
alpha: cfg.alpha,
|
||||||
|
speed,
|
||||||
cycleSec: travel / speed,
|
cycleSec: travel / speed,
|
||||||
phase: ((col / COLS) + hash(seed + 6) * 0.6 + layer * 0.23) % 1,
|
phase: ((col / COLS) + hash(seed + 6) * 0.6 + layer * 0.23) % 1,
|
||||||
travel, yStart: vh + h / 2 + BUF / 2,
|
travel,
|
||||||
|
yStart: vh + h / 2 + BUF / 2,
|
||||||
angleStart: hash(seed + 3) * 50 - 25,
|
angleStart: hash(seed + 3) * 50 - 25,
|
||||||
tilt: (hash(seed + 4) * 2 - 1) * 18,
|
tilt: (hash(seed + 4) * 2 - 1) * 18,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const trigs: CardTrig[] = cards.map(c => ({
|
const trigs: CardTrig[] = cards.map(c => ({
|
||||||
cosA: Math.cos(c.angleStart * (Math.PI / 180)),
|
cosA: Math.cos(c.angleStart * (Math.PI / 180)),
|
||||||
sinA: Math.sin(c.angleStart * (Math.PI / 180)),
|
sinA: Math.sin(c.angleStart * (Math.PI / 180)),
|
||||||
tiltRad: c.tilt * (Math.PI / 180),
|
tiltRad: c.tilt * (Math.PI / 180),
|
||||||
}));
|
}));
|
||||||
return { cards, trigs };
|
return { cards, trigs };
|
||||||
@@ -189,29 +217,30 @@
|
|||||||
|
|
||||||
function rrect(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number) {
|
function rrect(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number) {
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.moveTo(x + r, y); ctx.lineTo(x + w - r, y); ctx.arcTo(x + w, y, x + w, y + r, r);
|
ctx.moveTo(x + r, y); ctx.lineTo(x + w - r, y); ctx.arcTo(x + w, y, x + w, y + r, r);
|
||||||
ctx.lineTo(x + w, y + h - r); ctx.arcTo(x + w, y + h, x + w - r, y + h, r);
|
ctx.lineTo(x + w, y + h - r); ctx.arcTo(x + w, y + h, x + w - r, y + h, r);
|
||||||
ctx.lineTo(x + r, y + h); ctx.arcTo(x, y + h, x, y + h - r, r);
|
ctx.lineTo(x + r, y + h); ctx.arcTo(x, y + h, x, y + h - r, r);
|
||||||
ctx.lineTo(x, y + r); ctx.arcTo(x, y, x + r, y, r);
|
ctx.lineTo(x, y + r); ctx.arcTo(x, y, x + r, y, r);
|
||||||
ctx.closePath();
|
ctx.closePath();
|
||||||
}
|
}
|
||||||
|
|
||||||
const STAMP_PAD = 6;
|
const STAMP_PAD = 6;
|
||||||
|
|
||||||
function buildStamp(c: CardDef, dpr: number): HTMLCanvasElement {
|
function buildStamp(c: CardDef, dpr: number): HTMLCanvasElement {
|
||||||
const oc = document.createElement("canvas");
|
const oc = document.createElement("canvas");
|
||||||
oc.width = Math.round(Math.ceil(c.w + STAMP_PAD * 2) * dpr);
|
oc.width = Math.round(Math.ceil(c.w + STAMP_PAD * 2) * dpr);
|
||||||
oc.height = Math.round(Math.ceil(c.h + STAMP_PAD * 2) * dpr);
|
oc.height = Math.round(Math.ceil(c.h + STAMP_PAD * 2) * dpr);
|
||||||
const ctx = oc.getContext("2d")!;
|
const ctx = oc.getContext("2d")!;
|
||||||
ctx.scale(dpr, dpr);
|
ctx.scale(dpr, dpr);
|
||||||
const x0 = STAMP_PAD, y0 = STAMP_PAD;
|
const x0 = STAMP_PAD, y0 = STAMP_PAD;
|
||||||
const coverH = (c.w * 0.72) * 1.05;
|
const coverH = c.w * 0.72 * 1.05;
|
||||||
const lineY0 = y0 + 3 + coverH + 5;
|
const lineY0 = y0 + 3 + coverH + 5;
|
||||||
ctx.fillStyle = "rgba(0,0,0,0.5)"; rrect(ctx, x0 + 2, y0 + 2, c.w, c.h, 4); ctx.fill();
|
ctx.fillStyle = "rgba(0,0,0,0.5)"; rrect(ctx, x0 + 2, y0 + 2, c.w, c.h, 4); ctx.fill();
|
||||||
ctx.fillStyle = "rgba(255,255,255,0.07)"; rrect(ctx, x0, y0, c.w, c.h, 4); ctx.fill();
|
ctx.fillStyle = "rgba(255,255,255,0.07)"; rrect(ctx, x0, y0, c.w, c.h, 4); ctx.fill();
|
||||||
ctx.strokeStyle = "rgba(255,255,255,0.75)"; ctx.lineWidth = 1.2; rrect(ctx, x0, y0, c.w, c.h, 4); ctx.stroke();
|
ctx.strokeStyle = "rgba(255,255,255,0.75)";
|
||||||
ctx.fillStyle = "rgba(255,255,255,0.15)"; rrect(ctx, x0 + 3, y0 + 3, c.w - 6, coverH, 3); ctx.fill();
|
ctx.lineWidth = 1.2; rrect(ctx, x0, y0, c.w, c.h, 4); ctx.stroke();
|
||||||
ctx.fillStyle = "rgba(255,255,255,0.08)"; rrect(ctx, x0 + 3, y0 + 3, (c.w - 6) * 0.45, coverH, 3); ctx.fill();
|
ctx.fillStyle = "rgba(255,255,255,0.15)"; rrect(ctx, x0 + 3, y0 + 3, c.w - 6, coverH, 3); ctx.fill();
|
||||||
|
ctx.fillStyle = "rgba(255,255,255,0.08)"; rrect(ctx, x0 + 3, y0 + 3, (c.w - 6) * 0.45, coverH, 3); ctx.fill();
|
||||||
for (let li = 0; li < c.lines; li++) {
|
for (let li = 0; li < c.lines; li++) {
|
||||||
ctx.fillStyle = li === 0 ? "rgba(255,255,255,0.35)" : "rgba(255,255,255,0.20)";
|
ctx.fillStyle = li === 0 ? "rgba(255,255,255,0.35)" : "rgba(255,255,255,0.20)";
|
||||||
ctx.fillRect(x0 + 4, lineY0 + li * 8, (c.w - 8) * (li === 0 ? 0.78 : 0.52), li === 0 ? 3 : 2);
|
ctx.fillRect(x0 + 4, lineY0 + li * 8, (c.w - 8) * (li === 0 ? 0.78 : 0.52), li === 0 ? 3 : 2);
|
||||||
@@ -220,13 +249,18 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildVignette(vw: number, vh: number, dpr: number): HTMLCanvasElement {
|
function buildVignette(vw: number, vh: number, dpr: number): HTMLCanvasElement {
|
||||||
const oc = document.createElement("canvas");
|
const oc = document.createElement("canvas");
|
||||||
oc.width = Math.round(vw * dpr); oc.height = Math.round(vh * dpr);
|
oc.width = Math.round(vw * dpr);
|
||||||
|
oc.height = Math.round(vh * dpr);
|
||||||
const ctx = oc.getContext("2d")!;
|
const ctx = oc.getContext("2d")!;
|
||||||
ctx.scale(dpr, dpr);
|
ctx.scale(dpr, dpr);
|
||||||
const g = ctx.createRadialGradient(vw / 2, vh / 2, 0, vw / 2, vh / 2, Math.max(vw, vh) * 0.65);
|
const g = ctx.createRadialGradient(vw / 2, vh / 2, 0, vw / 2, vh / 2, Math.max(vw, vh) * 0.65);
|
||||||
g.addColorStop(0, "rgba(0,0,0,0)"); g.addColorStop(0.4, "rgba(0,0,0,0)"); g.addColorStop(0.7, "rgba(0,0,0,0.25)"); g.addColorStop(1, "rgba(0,0,0,0.65)");
|
g.addColorStop(0, "rgba(0,0,0,0)");
|
||||||
ctx.fillStyle = g; ctx.fillRect(0, 0, vw, vh);
|
g.addColorStop(0.4, "rgba(0,0,0,0)");
|
||||||
|
g.addColorStop(0.7, "rgba(0,0,0,0.25)");
|
||||||
|
g.addColorStop(1, "rgba(0,0,0,0.65)");
|
||||||
|
ctx.fillStyle = g;
|
||||||
|
ctx.fillRect(0, 0, vw, vh);
|
||||||
return oc;
|
return oc;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -236,21 +270,22 @@
|
|||||||
) {
|
) {
|
||||||
ctx.clearRect(0, 0, cw, ch);
|
ctx.clearRect(0, 0, cw, ch);
|
||||||
for (let i = 0; i < cards.length; i++) {
|
for (let i = 0; i < cards.length; i++) {
|
||||||
const c = cards[i];
|
const c = cards[i];
|
||||||
const p = ((t / c.cycleSec) + c.phase) % 1;
|
const p = ((t / c.cycleSec) + c.phase) % 1;
|
||||||
const alpha = p < 0.07 ? (p / 0.07) * c.alpha : p > 0.86 ? ((1 - p) / 0.14) * c.alpha : c.alpha;
|
const alpha = p < 0.07 ? (p / 0.07) * c.alpha : p > 0.86 ? ((1 - p) / 0.14) * c.alpha : c.alpha;
|
||||||
if (alpha < 0.005) continue;
|
if (alpha < 0.005) continue;
|
||||||
const cy = c.yStart - p * c.travel;
|
const cy = c.yStart - p * c.travel;
|
||||||
const tg = trigs[i];
|
const tg = trigs[i];
|
||||||
const delta = tg.tiltRad * p;
|
const delta = tg.tiltRad * p;
|
||||||
const cos = tg.cosA * Math.cos(delta) - tg.sinA * Math.sin(delta);
|
const cos = tg.cosA * Math.cos(delta) - tg.sinA * Math.sin(delta);
|
||||||
const sin = tg.sinA * Math.cos(delta) + tg.cosA * Math.sin(delta);
|
const sin = tg.sinA * Math.cos(delta) + tg.cosA * Math.sin(delta);
|
||||||
ctx.globalAlpha = alpha;
|
ctx.globalAlpha = alpha;
|
||||||
ctx.setTransform(cos * dpr, sin * dpr, -sin * dpr, cos * dpr, c.cx * dpr, cy * dpr);
|
ctx.setTransform(cos * dpr, sin * dpr, -sin * dpr, cos * dpr, c.cx * dpr, cy * dpr);
|
||||||
const sw = stamps[i].width, sh = stamps[i].height;
|
const sw = stamps[i].width / dpr, sh = stamps[i].height / dpr;
|
||||||
ctx.drawImage(stamps[i], -sw / 2, -sh / 2, sw, sh);
|
ctx.drawImage(stamps[i], -sw / 2, -sh / 2, sw, sh);
|
||||||
}
|
}
|
||||||
ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.globalAlpha = 1;
|
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
||||||
|
ctx.globalAlpha = 1;
|
||||||
ctx.drawImage(vignette, 0, 0, cw, ch);
|
ctx.drawImage(vignette, 0, 0, cw, ch);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -258,8 +293,9 @@
|
|||||||
function tickFps(now: number) {
|
function tickFps(now: number) {
|
||||||
fpsFrames++;
|
fpsFrames++;
|
||||||
if (now - fpsLast >= 500) {
|
if (now - fpsLast >= 500) {
|
||||||
fps = Math.round(fpsFrames / ((now - fpsLast) / 1000));
|
fps = Math.round(fpsFrames / ((now - fpsLast) / 1000));
|
||||||
fpsFrames = 0; fpsLast = now;
|
fpsFrames = 0;
|
||||||
|
fpsLast = now;
|
||||||
if (fpsEl) fpsEl.textContent = `${fps} fps`;
|
if (fpsEl) fpsEl.textContent = `${fps} fps`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -267,10 +303,6 @@
|
|||||||
function mountCanvas(el: HTMLCanvasElement) {
|
function mountCanvas(el: HTMLCanvasElement) {
|
||||||
const win = getCurrentWindow();
|
const win = getCurrentWindow();
|
||||||
const ctx = el.getContext("2d")!;
|
const ctx = el.getContext("2d")!;
|
||||||
interface RenderState {
|
|
||||||
cards: CardDef[]; trigs: CardTrig[]; stamps: HTMLCanvasElement[];
|
|
||||||
vignette: HTMLCanvasElement; CW: number; CH: number; scale: number;
|
|
||||||
}
|
|
||||||
let live: RenderState | null = null;
|
let live: RenderState | null = null;
|
||||||
let lastLogW = 0, lastLogH = 0, lastScale = 0, buildGen = 0;
|
let lastLogW = 0, lastLogH = 0, lastScale = 0, buildGen = 0;
|
||||||
|
|
||||||
@@ -289,7 +321,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ro = new ResizeObserver(() => syncSize());
|
const ro = new ResizeObserver(() => syncSize());
|
||||||
ro.observe(el); syncSize();
|
ro.observe(el);
|
||||||
|
syncSize();
|
||||||
|
|
||||||
let raf = 0, t0 = -1;
|
let raf = 0, t0 = -1;
|
||||||
function frame(now: number) {
|
function frame(now: number) {
|
||||||
@@ -303,30 +336,6 @@
|
|||||||
raf = requestAnimationFrame(frame);
|
raf = requestAnimationFrame(frame);
|
||||||
return () => { cancelAnimationFrame(raf); ro.disconnect(); };
|
return () => { cancelAnimationFrame(raf); ro.disconnect(); };
|
||||||
}
|
}
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
const needsPin =
|
|
||||||
(mode === "idle" && lockEnabled) ||
|
|
||||||
(mode === "loading" && lockEnabled && ringFull && !pinUnlocked);
|
|
||||||
if (!needsPin) return;
|
|
||||||
window.addEventListener("keydown", onPinKey);
|
|
||||||
return () => window.removeEventListener("keydown", onPinKey);
|
|
||||||
});
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (pinUnlocked && mode !== "idle") {
|
|
||||||
triggerExit(onReady);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const ringR = $derived(70);
|
|
||||||
const ringPad = $derived(12);
|
|
||||||
const ringSize = $derived((ringR + ringPad) * 2);
|
|
||||||
const ringC = $derived(ringR + ringPad);
|
|
||||||
const ringCirc = $derived(2 * Math.PI * ringR);
|
|
||||||
const ringArc = $derived(ringCirc * Math.min(Math.max(ringProg, 0.025), 0.999));
|
|
||||||
const ringTop = $derived(-((ringSize - 140) / 2));
|
|
||||||
const ringLeft = $derived(-((ringSize - 140) / 2));
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="splash" class:exiting style="cursor: {mode === 'idle' && !lockEnabled ? 'pointer' : 'default'}">
|
<div class="splash" class:exiting style="cursor: {mode === 'idle' && !lockEnabled ? 'pointer' : 'default'}">
|
||||||
@@ -339,9 +348,9 @@
|
|||||||
|
|
||||||
{#if mode === "idle" && lockEnabled}
|
{#if mode === "idle" && lockEnabled}
|
||||||
<div style="z-index:1;display:flex;flex-direction:column;align-items:center;gap:var(--sp-6)">
|
<div style="z-index:1;display:flex;flex-direction:column;align-items:center;gap:var(--sp-6)">
|
||||||
<div style="position:relative;width:96px;height:96px">
|
<div style="position:relative;width:{logoLockSize}px;height:{logoLockSize}px">
|
||||||
<div class="logo-glow"></div>
|
<div class="logo-glow"></div>
|
||||||
<img src={logoUrl} alt="Moku" class="logo-breathe" style="width:96px;height:96px;border-radius:22px;display:block;position:relative" />
|
<img src={logoUrl} alt="Moku" class="logo-breathe" style="width:{logoLockSize}px;height:{logoLockSize}px;border-radius:22px;display:block;position:relative" />
|
||||||
</div>
|
</div>
|
||||||
<div class="pin-block">
|
<div class="pin-block">
|
||||||
<div class="pin-dots" class:pin-shake={pinShake}>
|
<div class="pin-dots" class:pin-shake={pinShake}>
|
||||||
@@ -355,15 +364,15 @@
|
|||||||
|
|
||||||
{:else if mode === "idle"}
|
{:else if mode === "idle"}
|
||||||
<div style="z-index:1;display:flex;flex-direction:column;align-items:center">
|
<div style="z-index:1;display:flex;flex-direction:column;align-items:center">
|
||||||
<div style="position:relative;width:128px;height:128px;margin-bottom:32px">
|
<div style="position:relative;width:{logoIdleSize}px;height:{logoIdleSize}px;margin-bottom:32px">
|
||||||
<div class="logo-glow"></div>
|
<div class="logo-glow"></div>
|
||||||
<img src={logoUrl} alt="Moku" class="logo-breathe" style="width:128px;height:128px;border-radius:28px;display:block;position:relative" />
|
<img src={logoUrl} alt="Moku" class="logo-breathe" style="width:{logoIdleSize}px;height:{logoIdleSize}px;border-radius:28px;display:block;position:relative" />
|
||||||
</div>
|
</div>
|
||||||
<p class="hint">press any key to continue</p>
|
<p class="hint">press any key to continue</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{:else}
|
{:else}
|
||||||
<div style="position:relative;width:140px;height:140px;margin-bottom:20px;z-index:1">
|
<div style="position:relative;width:{logoLoadingSize}px;height:{logoLoadingSize}px;margin-bottom:20px;z-index:1">
|
||||||
{#if !failed && !notConfigured}
|
{#if !failed && !notConfigured}
|
||||||
<svg width={ringSize} height={ringSize}
|
<svg width={ringSize} height={ringSize}
|
||||||
class="loading-ring"
|
class="loading-ring"
|
||||||
@@ -377,7 +386,7 @@
|
|||||||
style="transition: stroke-dasharray 0.4s cubic-bezier(0.4,0,0.2,1)" />
|
style="transition: stroke-dasharray 0.4s cubic-bezier(0.4,0,0.2,1)" />
|
||||||
</svg>
|
</svg>
|
||||||
{/if}
|
{/if}
|
||||||
<img src={logoUrl} alt="Moku" style="width:140px;height:140px;border-radius:32px;display:block" />
|
<img src={logoUrl} alt="Moku" style="width:{logoLoadingSize}px;height:{logoLoadingSize}px;border-radius:32px;display:block" />
|
||||||
</div>
|
</div>
|
||||||
<p class="title-label">moku</p>
|
<p class="title-label">moku</p>
|
||||||
|
|
||||||
@@ -385,12 +394,10 @@
|
|||||||
<div class="status-slot" class:status-slot-hide={lockEnabled && pinVisible}>
|
<div class="status-slot" class:status-slot-hide={lockEnabled && pinVisible}>
|
||||||
{#if failed || notConfigured}
|
{#if failed || notConfigured}
|
||||||
<div class="error-box">
|
<div class="error-box">
|
||||||
<p class="error-label">
|
<p class="error-label">{failed ? "Could not reach server" : "Server not configured"}</p>
|
||||||
{failed ? "Could not reach server" : "Server not configured"}
|
|
||||||
</p>
|
|
||||||
<div class="error-actions">
|
<div class="error-actions">
|
||||||
<button class="err-btn" onclick={handleRetry}>Retry</button>
|
<button class="err-btn" onclick={() => onRetry?.()}>Retry</button>
|
||||||
<button class="err-btn err-btn--primary" onclick={handleBypass}>Enter app</button>
|
<button class="err-btn err-btn--primary" onclick={() => onBypass?.()}>Enter app</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
@@ -415,36 +422,40 @@
|
|||||||
<style>
|
<style>
|
||||||
.splash { position: fixed; inset: 0; z-index: 9999; background: var(--bg-base); overflow: hidden; display: flex; flex-direction: column; align-items: center; justify-content: center; animation: spIn 0.35s cubic-bezier(0,0,0.2,1) both; }
|
.splash { position: fixed; inset: 0; z-index: 9999; background: var(--bg-base); overflow: hidden; display: flex; flex-direction: column; align-items: center; justify-content: center; animation: spIn 0.35s cubic-bezier(0,0,0.2,1) both; }
|
||||||
.splash.exiting { animation: spOut 320ms cubic-bezier(0.4,0,1,1) both; }
|
.splash.exiting { animation: spOut 320ms cubic-bezier(0.4,0,1,1) both; }
|
||||||
@keyframes spIn { from { opacity:0; transform:scale(1.015) } to { opacity:1; transform:scale(1) } }
|
|
||||||
@keyframes spOut { from { opacity:1; transform:scale(1) } to { opacity:0; transform:scale(0.96) } }
|
@keyframes spIn { from { opacity:0; transform:scale(1.015) } to { opacity:1; transform:scale(1) } }
|
||||||
@keyframes logoBreathe { 0%,100% { transform:scale(1); filter:drop-shadow(0 0 0px rgba(255,255,255,0)) } 50% { transform:scale(1.04); filter:drop-shadow(0 0 18px rgba(255,255,255,0.12)) } }
|
@keyframes spOut { from { opacity:1; transform:scale(1) } to { opacity:0; transform:scale(0.96) } }
|
||||||
@keyframes hintFade { 0%,100% { opacity:0.35 } 50% { opacity:0.7 } }
|
@keyframes logoBreathe { 0%,100% { transform:scale(1); filter:drop-shadow(0 0 0px rgba(255,255,255,0)) } 50% { transform:scale(1.04); filter:drop-shadow(0 0 18px rgba(255,255,255,0.12)) } }
|
||||||
.logo-glow { position: absolute; inset: -20px; border-radius: 50%; background: radial-gradient(circle, rgba(255,255,255,0.06) 0%, transparent 70%); animation: logoBreathe 4s ease-in-out infinite; }
|
@keyframes hintFade { 0%,100% { opacity:0.35 } 50% { opacity:0.7 } }
|
||||||
|
@keyframes errIn { from { opacity:0; transform:translateY(4px) } to { opacity:1; transform:translateY(0) } }
|
||||||
|
@keyframes pinShake { 0%,100% { transform:translateX(0) } 20%,60% { transform:translateX(-6px) } 40%,80% { transform:translateX(6px) } }
|
||||||
|
|
||||||
|
.logo-glow { position: absolute; inset: -20px; border-radius: 50%; background: radial-gradient(circle, rgba(255,255,255,0.06) 0%, transparent 70%); animation: logoBreathe 4s ease-in-out infinite; }
|
||||||
.logo-breathe { animation: logoBreathe 4s ease-in-out infinite; }
|
.logo-breathe { animation: logoBreathe 4s ease-in-out infinite; }
|
||||||
.hint { font-family: var(--font-ui); font-size: 10px; color: var(--text-faint); letter-spacing: 0.22em; text-transform: uppercase; margin: 0; user-select: none; animation: hintFade 3.5s ease-in-out infinite; }
|
.hint { font-family: var(--font-ui); font-size: 10px; color: var(--text-faint); letter-spacing: 0.22em; text-transform: uppercase; margin: 0; user-select: none; animation: hintFade 3.5s ease-in-out infinite; }
|
||||||
.title-label { font-family: var(--font-ui); font-size: 11px; font-weight: 500; letter-spacing: 0.26em; text-transform: uppercase; color: var(--text-secondary); margin: 0 0 8px; z-index: 1; user-select: none; }
|
.title-label { font-family: var(--font-ui); font-size: 11px; font-weight: 500; letter-spacing: 0.26em; text-transform: uppercase; color: var(--text-secondary); margin: 0 0 8px; z-index: 1; user-select: none; }
|
||||||
.error-box { display: flex; flex-direction: column; align-items: center; gap: 12px; padding: 16px 20px; border-radius: var(--radius-lg); background: var(--bg-surface); border: 1px solid var(--border-base); min-width: 200px; text-align: center; animation: errIn 0.25s cubic-bezier(0,0,0.2,1) both; }
|
|
||||||
@keyframes errIn { from { opacity:0; transform:translateY(4px) } to { opacity:1; transform:translateY(0) } }
|
.error-box { display: flex; flex-direction: column; align-items: center; gap: 12px; padding: 16px 20px; border-radius: var(--radius-lg); background: var(--bg-surface); border: 1px solid var(--border-base); min-width: 200px; text-align: center; animation: errIn 0.25s cubic-bezier(0,0,0.2,1) both; }
|
||||||
.error-label { font-family: var(--font-ui); font-size: 11px; font-weight: 500; color: var(--text-muted); letter-spacing: 0.06em; margin: 0; }
|
.error-label { font-family: var(--font-ui); font-size: 11px; font-weight: 500; color: var(--text-muted); letter-spacing: 0.06em; margin: 0; }
|
||||||
.error-actions { display: flex; gap: 6px; }
|
.error-actions { display: flex; gap: 6px; }
|
||||||
.err-btn { padding: 5px 14px; border-radius: var(--radius-md); border: 1px solid var(--border-base); background: transparent; color: var(--text-muted); cursor: pointer; font-family: var(--font-ui); font-size: 11px; letter-spacing: 0.04em; transition: border-color 0.15s, color 0.15s; }
|
.err-btn { padding: 5px 14px; border-radius: var(--radius-md); border: 1px solid var(--border-base); background: transparent; color: var(--text-muted); cursor: pointer; font-family: var(--font-ui); font-size: 11px; letter-spacing: 0.04em; transition: border-color 0.15s, color 0.15s; }
|
||||||
.err-btn:hover { border-color: var(--border-strong); color: var(--text-secondary); }
|
.err-btn:hover { border-color: var(--border-strong); color: var(--text-secondary); }
|
||||||
.err-btn--primary { border-color: var(--accent-dim); color: var(--accent-fg); background: var(--accent-muted); }
|
.err-btn--primary { border-color: var(--accent-dim); color: var(--accent-fg); background: var(--accent-muted); }
|
||||||
.err-btn--primary:hover { border-color: var(--accent); color: var(--accent-bright); }
|
.err-btn--primary:hover { border-color: var(--accent); color: var(--accent-bright); }
|
||||||
|
|
||||||
.bottom-area { display: flex; align-items: center; justify-content: center; min-height: 48px; position: relative; }
|
.bottom-area { display: flex; align-items: center; justify-content: center; min-height: 48px; position: relative; }
|
||||||
.status-slot { display: flex; align-items: center; justify-content: center; transition: opacity 0.35s ease; position: absolute; }
|
.status-slot { display: flex; align-items: center; justify-content: center; transition: opacity 0.35s ease; position: absolute; }
|
||||||
.status-slot-hide { opacity: 0; pointer-events: none; }
|
.status-slot-hide { opacity: 0; pointer-events: none; }
|
||||||
.status-text { font-family: var(--font-ui); font-size: 10px; color: var(--text-faint); letter-spacing: 0.12em; margin: 0; min-width: 160px; text-align: center; }
|
.status-text { font-family: var(--font-ui); font-size: 10px; color: var(--text-faint); letter-spacing: 0.12em; margin: 0; min-width: 160px; text-align: center; }
|
||||||
.loading-ring { transition: opacity 0.5s ease; }
|
.loading-ring { transition: opacity 0.5s ease; }
|
||||||
.ring-hide { opacity: 0; }
|
.ring-hide { opacity: 0; }
|
||||||
.pin-slot { display: flex; flex-direction: column; align-items: center; gap: var(--sp-3); position: absolute; opacity: 0; transform: translateY(4px); transition: opacity 0.4s ease, transform 0.4s ease; pointer-events: none; }
|
|
||||||
|
.pin-slot { display: flex; flex-direction: column; align-items: center; gap: var(--sp-3); position: absolute; opacity: 0; transform: translateY(4px); transition: opacity 0.4s ease, transform 0.4s ease; pointer-events: none; }
|
||||||
.pin-slot-visible { opacity: 1; transform: translateY(0); pointer-events: auto; }
|
.pin-slot-visible { opacity: 1; transform: translateY(0); pointer-events: auto; }
|
||||||
.pin-block { display: flex; flex-direction: column; align-items: center; gap: var(--sp-3); position: relative; }
|
.pin-block { display: flex; flex-direction: column; align-items: center; gap: var(--sp-3); position: relative; }
|
||||||
.pin-dots { display: flex; gap: 12px; align-items: center; }
|
.pin-dots { display: flex; gap: 12px; align-items: center; }
|
||||||
.pin-dot { width: 10px; height: 10px; border-radius: 50%; border: 1px solid var(--border-strong); background: transparent; transition: background 0.12s, border-color 0.12s; }
|
.pin-dot { width: 10px; height: 10px; border-radius: 50%; border: 1px solid var(--border-strong); background: transparent; transition: background 0.12s, border-color 0.12s; }
|
||||||
.pin-dot-filled { background: var(--accent); border-color: var(--accent); }
|
.pin-dot-filled { background: var(--accent); border-color: var(--accent); }
|
||||||
@keyframes pinShake { 0%,100% { transform:translateX(0) } 20%,60% { transform:translateX(-6px) } 40%,80% { transform:translateX(6px) } }
|
.pin-shake { animation: pinShake 0.42s ease; }
|
||||||
.pin-shake { animation: pinShake 0.42s ease; }
|
.pin-submit-btn { opacity: 0; width: 1px; height: 1px; overflow: hidden; padding: 0; border: none; background: none; cursor: pointer; pointer-events: auto; position: absolute; }
|
||||||
.pin-submit-btn { opacity: 0; width: 1px; height: 1px; overflow: hidden; padding: 0; border: none; background: none; cursor: pointer; pointer-events: auto; position: absolute; }
|
|
||||||
</style>
|
</style>
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||||
|
import { platform } from "@tauri-apps/plugin-os";
|
||||||
|
|
||||||
|
const win = getCurrentWindow();
|
||||||
|
const os = platform();
|
||||||
|
const isMac = os === "macos";
|
||||||
|
const isWindows = os === "windows";
|
||||||
|
|
||||||
|
let isFullscreen = $state(false);
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
isFullscreen = await win.isFullscreen();
|
||||||
|
const unlisten = await win.onResized(async () => {
|
||||||
|
isFullscreen = await win.isFullscreen();
|
||||||
|
});
|
||||||
|
return unlisten;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if !isFullscreen}
|
||||||
|
<div class="bar" data-tauri-drag-region>
|
||||||
|
{#if isMac}<div class="mac-spacer"></div>{/if}
|
||||||
|
<span class="title" data-tauri-drag-region>Moku</span>
|
||||||
|
{#if !isMac}
|
||||||
|
<div class="controls">
|
||||||
|
<button onclick={() => win.minimize()} title="Minimize" aria-label="Minimize">
|
||||||
|
<svg width="10" height="1" viewBox="0 0 10 1">
|
||||||
|
<line x1="0" y1="0.5" x2="10" y2="0.5" stroke="currentColor" stroke-width="1.5" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<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>
|
||||||
|
</button>
|
||||||
|
<button class="close" onclick={() => win.close()} title="Close" aria-label="Close">
|
||||||
|
<svg width="10" height="10" viewBox="0 0 10 10">
|
||||||
|
<line x1="1" y1="1" x2="9" y2="9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
|
||||||
|
<line x1="9" y1="1" x2="1" y2="9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else if isWindows}
|
||||||
|
<!-- On Windows, fullscreen hides the native titlebar — show a hoverable overlay so the user isn't locked in -->
|
||||||
|
<div class="fullscreen-controls">
|
||||||
|
<button onclick={() => win.setFullscreen(false)} title="Exit Fullscreen" aria-label="Exit Fullscreen">
|
||||||
|
<svg width="10" height="10" viewBox="0 0 10 10">
|
||||||
|
<polyline points="1,4 1,1 4,1" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<polyline points="6,1 9,1 9,4" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<polyline points="9,6 9,9 6,9" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<polyline points="4,9 1,9 1,6" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button class="close" onclick={() => win.close()} title="Close" aria-label="Close">
|
||||||
|
<svg width="10" height="10" viewBox="0 0 10 10">
|
||||||
|
<line x1="1" y1="1" x2="9" y2="9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
|
||||||
|
<line x1="9" y1="1" x2="1" y2="9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.fullscreen-controls {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 9999;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 4px;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
-webkit-app-region: no-drag;
|
||||||
|
}
|
||||||
|
.fullscreen-controls:hover { opacity: 1; }
|
||||||
|
|
||||||
|
.bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
height: 32px;
|
||||||
|
padding: 0 var(--sp-3) 0 var(--sp-4);
|
||||||
|
background: var(--bg-void);
|
||||||
|
border-bottom: 1px solid var(--border-dim);
|
||||||
|
flex-shrink: 0;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-app-region: drag;
|
||||||
|
}
|
||||||
|
/* Spacer to clear the native macOS traffic lights (~70px) */
|
||||||
|
.mac-spacer {
|
||||||
|
width: 70px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
-webkit-app-region: drag;
|
||||||
|
}
|
||||||
|
.title {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-2xs);
|
||||||
|
color: var(--text-faint);
|
||||||
|
letter-spacing: var(--tracking-wider);
|
||||||
|
text-transform: uppercase;
|
||||||
|
-webkit-app-region: drag;
|
||||||
|
}
|
||||||
|
.controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
-webkit-app-region: no-drag;
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 28px; height: 28px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--text-faint);
|
||||||
|
transition: color var(--t-base), background var(--t-base);
|
||||||
|
-webkit-app-region: no-drag;
|
||||||
|
}
|
||||||
|
button:hover { color: var(--text-muted); background: var(--bg-raised); }
|
||||||
|
.close:hover { color: #fff; background: #c0392b; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { store, dismissToast } from "../../store/state.svelte";
|
||||||
|
import type { Toast } from "../../store/state.svelte";
|
||||||
|
|
||||||
|
const EXIT_MS = 280;
|
||||||
|
const leaving = new Set<string>();
|
||||||
|
const timers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||||
|
|
||||||
|
function schedule(t: Toast) {
|
||||||
|
if (timers.has(t.id)) return;
|
||||||
|
const dur = t.duration ?? 3500;
|
||||||
|
if (dur === 0) return;
|
||||||
|
timers.set(t.id, setTimeout(() => dismiss(t.id), dur));
|
||||||
|
}
|
||||||
|
|
||||||
|
function dismiss(id: string) {
|
||||||
|
if (leaving.has(id)) return;
|
||||||
|
leaving.add(id);
|
||||||
|
if (timers.has(id)) { clearTimeout(timers.get(id)!); timers.delete(id); }
|
||||||
|
|
||||||
|
const el = document.querySelector<HTMLElement>(`[data-toast-id="${id}"]`);
|
||||||
|
if (!el) { finalize(id); return; }
|
||||||
|
|
||||||
|
const h = el.offsetHeight;
|
||||||
|
el.style.setProperty("--exit-h", `${h}px`);
|
||||||
|
el.classList.add("leaving");
|
||||||
|
setTimeout(() => finalize(id), EXIT_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
function finalize(id: string) {
|
||||||
|
leaving.delete(id);
|
||||||
|
dismissToast(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
store.toasts.forEach(schedule);
|
||||||
|
return () => timers.forEach(clearTimeout);
|
||||||
|
});
|
||||||
|
|
||||||
|
const icons: Record<Toast["kind"], string> = {
|
||||||
|
success: "M20 6L9 17l-5-5",
|
||||||
|
error: "M12 9v4M12 17h.01M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z",
|
||||||
|
info: "M12 16v-4M12 8h.01M12 2a10 10 0 1 0 0 20A10 10 0 0 0 12 2z",
|
||||||
|
download: "M12 3v13M7 11l5 5 5-5M5 21h14",
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if store.toasts.length}
|
||||||
|
<div class="toaster" aria-live="polite">
|
||||||
|
{#each store.toasts as t (t.id)}
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
class="toast toast-{t.kind}"
|
||||||
|
data-toast-id={t.id}
|
||||||
|
onclick={() => dismiss(t.id)}
|
||||||
|
>
|
||||||
|
<div class="accent-bar"></div>
|
||||||
|
<span class="icon">
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none"
|
||||||
|
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d={icons[t.kind]} />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<div class="body">
|
||||||
|
<p class="title">{t.title}</p>
|
||||||
|
{#if t.body}<p class="sub">{t.body}</p>{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.toaster {
|
||||||
|
position: fixed;
|
||||||
|
bottom: var(--sp-5);
|
||||||
|
right: var(--sp-5);
|
||||||
|
z-index: 9999;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
pointer-events: none;
|
||||||
|
max-width: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
padding: 10px var(--sp-3) 10px 0;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--bg-raised);
|
||||||
|
border: 1px solid var(--border-dim);
|
||||||
|
box-shadow: 0 8px 32px rgba(0,0,0,0.5), 0 1px 0 rgba(255,255,255,0.04) inset;
|
||||||
|
pointer-events: all;
|
||||||
|
min-width: 200px;
|
||||||
|
overflow: hidden;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: inherit;
|
||||||
|
color: inherit;
|
||||||
|
text-align: left;
|
||||||
|
will-change: transform, opacity;
|
||||||
|
animation: slideIn 0.35s cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||||
|
transition: border-color 0.15s ease, box-shadow 0.15s ease, transform 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast:hover {
|
||||||
|
border-color: var(--border-base);
|
||||||
|
box-shadow: 0 12px 40px rgba(0,0,0,0.6), 0 1px 0 rgba(255,255,255,0.06) inset;
|
||||||
|
transform: translateX(-3px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast:active { transform: translateX(0) scale(0.98); }
|
||||||
|
|
||||||
|
:global(.toast.leaving) {
|
||||||
|
animation: slideOut 0.28s cubic-bezier(0.4, 0, 1, 1) forwards !important;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideIn {
|
||||||
|
from { opacity: 0; transform: translateX(20px) scale(0.96); }
|
||||||
|
to { opacity: 1; transform: translateX(0) scale(1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideOut {
|
||||||
|
0% { opacity: 1; transform: translateX(0) scale(1); max-height: var(--exit-h, 80px); margin-bottom: 0; }
|
||||||
|
40% { opacity: 0; transform: translateX(14px) scale(0.96); max-height: var(--exit-h, 80px); margin-bottom: 0; }
|
||||||
|
100% { opacity: 0; transform: translateX(14px) scale(0.96); max-height: 0; margin-bottom: -6px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.accent-bar {
|
||||||
|
width: 3px;
|
||||||
|
align-self: stretch;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-radius: 0 2px 2px 0;
|
||||||
|
margin-right: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-success .accent-bar { background: var(--accent-fg); }
|
||||||
|
.toast-error .accent-bar { background: var(--color-error); }
|
||||||
|
.toast-info .accent-bar { background: var(--text-faint); }
|
||||||
|
.toast-download .accent-bar { background: var(--accent-fg); }
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-success .icon { color: var(--accent-fg); }
|
||||||
|
.toast-error .icon { color: var(--color-error); }
|
||||||
|
.toast-info .icon { color: var(--text-muted); }
|
||||||
|
.toast-download .icon { color: var(--accent-fg); }
|
||||||
|
|
||||||
|
.body {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-weight: var(--weight-medium);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-2xs);
|
||||||
|
color: var(--text-faint);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { onMount } from "svelte";
|
|
||||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
|
||||||
import { platform } from "@tauri-apps/plugin-os";
|
|
||||||
|
|
||||||
const win = getCurrentWindow();
|
|
||||||
const isMac = platform() === "macos";
|
|
||||||
|
|
||||||
let isFullscreen = $state(false);
|
|
||||||
|
|
||||||
onMount(async () => {
|
|
||||||
isFullscreen = await win.isFullscreen();
|
|
||||||
const unlisten = await win.onResized(async () => {
|
|
||||||
isFullscreen = await win.isFullscreen();
|
|
||||||
});
|
|
||||||
return unlisten;
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#if !isFullscreen}
|
|
||||||
<div class="bar" data-tauri-drag-region>
|
|
||||||
{#if isMac}<div class="mac-spacer"></div>{/if}
|
|
||||||
<span class="title" data-tauri-drag-region>Moku</span>
|
|
||||||
{#if !isMac}
|
|
||||||
<div class="controls">
|
|
||||||
<button onclick={() => win.minimize()} title="Minimize" aria-label="Minimize">
|
|
||||||
<svg width="10" height="1" viewBox="0 0 10 1">
|
|
||||||
<line x1="0" y1="0.5" x2="10" y2="0.5" stroke="currentColor" stroke-width="1.5" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<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>
|
|
||||||
</button>
|
|
||||||
<button class="close" onclick={() => win.close()} title="Close" aria-label="Close">
|
|
||||||
<svg width="10" height="10" viewBox="0 0 10 10">
|
|
||||||
<line x1="1" y1="1" x2="9" y2="9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
|
|
||||||
<line x1="9" y1="1" x2="1" y2="9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.bar {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
height: 32px;
|
|
||||||
padding: 0 var(--sp-3) 0 var(--sp-4);
|
|
||||||
background: var(--bg-void);
|
|
||||||
border-bottom: 1px solid var(--border-dim);
|
|
||||||
flex-shrink: 0;
|
|
||||||
user-select: none;
|
|
||||||
-webkit-app-region: drag;
|
|
||||||
}
|
|
||||||
/* Spacer to clear the native macOS traffic lights (~70px) */
|
|
||||||
.mac-spacer {
|
|
||||||
width: 70px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
-webkit-app-region: drag;
|
|
||||||
}
|
|
||||||
.title {
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-2xs);
|
|
||||||
color: var(--text-faint);
|
|
||||||
letter-spacing: var(--tracking-wider);
|
|
||||||
text-transform: uppercase;
|
|
||||||
-webkit-app-region: drag;
|
|
||||||
}
|
|
||||||
.controls {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 2px;
|
|
||||||
-webkit-app-region: no-drag;
|
|
||||||
}
|
|
||||||
button {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 28px; height: 28px;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
color: var(--text-faint);
|
|
||||||
transition: color var(--t-base), background var(--t-base);
|
|
||||||
-webkit-app-region: no-drag;
|
|
||||||
}
|
|
||||||
button:hover { color: var(--text-muted); background: var(--bg-raised); }
|
|
||||||
.close:hover { color: #fff; background: #c0392b; }
|
|
||||||
</style>
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { store, dismissToast } from "../../store/state.svelte";
|
|
||||||
import type { Toast } from "../../store/state.svelte";
|
|
||||||
|
|
||||||
const timers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
||||||
|
|
||||||
function schedule(t: Toast) {
|
|
||||||
if (timers.has(t.id)) return;
|
|
||||||
const dur = t.duration ?? 3500;
|
|
||||||
if (dur === 0) return;
|
|
||||||
timers.set(t.id, setTimeout(() => dismissToast(t.id), dur));
|
|
||||||
}
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
store.toasts.forEach(schedule);
|
|
||||||
return () => timers.forEach(clearTimeout);
|
|
||||||
});
|
|
||||||
|
|
||||||
const icons: Record<Toast["kind"], string> = {
|
|
||||||
success: "M9 12l2 2 4-4M12 2a10 10 0 1 0 0 20A10 10 0 0 0 12 2z",
|
|
||||||
error: "M12 9v4M12 17h.01M12 2a10 10 0 1 0 0 20A10 10 0 0 0 12 2z",
|
|
||||||
info: "M12 16v-4M12 8h.01M12 2a10 10 0 1 0 0 20A10 10 0 0 0 12 2z",
|
|
||||||
download: "M12 3v13M7 11l5 5 5-5M5 21h14",
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#if store.toasts.length}
|
|
||||||
<div class="toaster" aria-live="polite">
|
|
||||||
{#each store.toasts as t (t.id)}
|
|
||||||
<div class="toast toast-{t.kind}" role="alert">
|
|
||||||
<span class="icon">
|
|
||||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none"
|
|
||||||
stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
|
||||||
<path d={icons[t.kind]} />
|
|
||||||
</svg>
|
|
||||||
</span>
|
|
||||||
<div class="body">
|
|
||||||
<p class="title">{t.title}</p>
|
|
||||||
{#if t.body}<p class="sub">{t.body}</p>{/if}
|
|
||||||
</div>
|
|
||||||
<button class="close" onclick={() => dismissToast(t.id)} title="Dismiss">
|
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none"
|
|
||||||
stroke="currentColor" stroke-width="1.5" stroke-linecap="round">
|
|
||||||
<path d="M18 6L6 18M6 6l12 12" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.toaster {
|
|
||||||
position: fixed; bottom: var(--sp-5); right: var(--sp-5);
|
|
||||||
z-index: 9999; display: flex; flex-direction: column;
|
|
||||||
gap: var(--sp-2); pointer-events: none; max-width: 320px;
|
|
||||||
}
|
|
||||||
.toast {
|
|
||||||
display: flex; align-items: flex-start; gap: var(--sp-2);
|
|
||||||
padding: var(--sp-2) var(--sp-3);
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
border: 1px solid var(--border-base);
|
|
||||||
background: var(--bg-raised);
|
|
||||||
box-shadow: 0 4px 24px rgba(0,0,0,0.45), 0 0 0 1px rgba(0,0,0,0.08);
|
|
||||||
pointer-events: all; min-width: 220px;
|
|
||||||
animation: toastIn 0.18s cubic-bezier(0.16,1,0.3,1) both;
|
|
||||||
}
|
|
||||||
@keyframes toastIn {
|
|
||||||
from { opacity: 0; transform: translateX(24px) scale(0.96); }
|
|
||||||
to { opacity: 1; transform: translateX(0) scale(1); }
|
|
||||||
}
|
|
||||||
.toast-success { border-color: var(--accent-dim); }
|
|
||||||
.toast-success .icon { color: var(--accent-fg); }
|
|
||||||
.toast-error { border-color: var(--color-error); }
|
|
||||||
.toast-error .icon { color: var(--color-error); }
|
|
||||||
.toast-download .icon, .toast-info .icon { color: var(--accent-fg); }
|
|
||||||
.icon { flex-shrink: 0; margin-top: 2px; color: var(--text-faint); }
|
|
||||||
.body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
|
|
||||||
.title { font-size: var(--text-sm); color: var(--text-secondary); font-weight: var(--weight-medium); line-height: 1.3; }
|
|
||||||
.sub {
|
|
||||||
font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint);
|
|
||||||
letter-spacing: var(--tracking-wide); white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
.close {
|
|
||||||
display: flex; align-items: center; justify-content: center;
|
|
||||||
width: 18px; height: 18px; border-radius: var(--radius-sm);
|
|
||||||
color: var(--text-faint); flex-shrink: 0; margin-top: 1px;
|
|
||||||
transition: color var(--t-base), background var(--t-base);
|
|
||||||
}
|
|
||||||
.close:hover { color: var(--text-muted); background: var(--bg-overlay); }
|
|
||||||
</style>
|
|
||||||
@@ -1,22 +1,22 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onDestroy } from "svelte";
|
import { onDestroy } from "svelte";
|
||||||
import { BookmarkSimple, FolderSimplePlus, Folder, Sparkle, ArrowsClockwise } from "phosphor-svelte";
|
import { BookmarkSimple, FolderSimplePlus, Folder, Sparkle, ArrowsClockwise } from "phosphor-svelte";
|
||||||
import { gql, thumbUrl } from "../../lib/client";
|
import { gql } from "../../lib/client";
|
||||||
import { GET_SOURCES, FETCH_SOURCE_MANGA, UPDATE_MANGA, GET_CATEGORIES, CREATE_CATEGORY, UPDATE_MANGA_CATEGORIES } from "../../lib/queries";
|
import { GET_SOURCES, FETCH_SOURCE_MANGA, UPDATE_MANGA, GET_CATEGORIES, CREATE_CATEGORY, UPDATE_MANGA_CATEGORIES } from "../../lib/queries";
|
||||||
import { cache, CACHE_KEYS } from "../../lib/cache";
|
import { cache, CACHE_KEYS } from "../../lib/cache";
|
||||||
import { dedupeSources, dedupeMangaByTitle, dedupeMangaById, isNsfwManga } from "../../lib/util";
|
import { dedupeSources, dedupeMangaByTitle, dedupeMangaById, shouldHideNsfw, shouldHideSource } from "../../lib/util";
|
||||||
import { store, setPreviewManga, clearDiscoverCache } from "../../store/state.svelte";
|
import { store, setPreviewManga, clearDiscoverCache } from "../../store/state.svelte";
|
||||||
import type { Manga, Source, Category } from "../../lib/types";
|
import type { Manga, Source, Category } from "../../lib/types";
|
||||||
import ContextMenu from "../shared/ContextMenu.svelte";
|
import ContextMenu from "../shared/ContextMenu.svelte";
|
||||||
import type { MenuEntry } from "../shared/ContextMenu.svelte";
|
import type { MenuEntry } from "../shared/ContextMenu.svelte";
|
||||||
import SourceBrowse from "../shared/SourceBrowse.svelte";
|
import SourceBrowse from "../shared/SourceBrowse.svelte";
|
||||||
|
import Thumbnail from "../shared/Thumbnail.svelte";
|
||||||
|
|
||||||
// ── Constants ─────────────────────────────────────────────────────────────
|
|
||||||
const GENRE_TABS = ["All", "Action", "Romance", "Fantasy", "Comedy", "Drama", "Horror", "Sci-Fi", "Adventure", "Thriller"];
|
const GENRE_TABS = ["All", "Action", "Romance", "Fantasy", "Comedy", "Drama", "Horror", "Sci-Fi", "Adventure", "Thriller"];
|
||||||
const GRID_LIMIT = 200;
|
const GRID_LIMIT = 200;
|
||||||
const CONCURRENCY = 6;
|
const CONCURRENCY = 6;
|
||||||
const PAGES_INIT = 3; // pages per source on All tab
|
const PAGES_INIT = 3;
|
||||||
const PAGES_GENRE = 2; // pages per source on genre tabs
|
const PAGES_GENRE = 2;
|
||||||
|
|
||||||
const EXPLORE_ALL_MANGA = `
|
const EXPLORE_ALL_MANGA = `
|
||||||
query ExploreAllManga {
|
query ExploreAllManga {
|
||||||
@@ -37,7 +37,6 @@
|
|||||||
return `${srcId}|${type}|${genre}:p${page}`;
|
return `${srcId}|${type}|${genre}:p${page}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Local component state ─────────────────────────────────────────────────
|
|
||||||
let allSources: Source[] = $state([]);
|
let allSources: Source[] = $state([]);
|
||||||
let loadingLib = $state(true);
|
let loadingLib = $state(true);
|
||||||
let loadError = $state(false);
|
let loadError = $state(false);
|
||||||
@@ -54,7 +53,6 @@
|
|||||||
const isLoading = $derived(genreLoading || refreshing || (currentGenre === "All" && loadingLib));
|
const isLoading = $derived(genreLoading || refreshing || (currentGenre === "All" && loadingLib));
|
||||||
const visibleGrid = $derived(genreResults.get(currentGenre) ?? []);
|
const visibleGrid = $derived(genreResults.get(currentGenre) ?? []);
|
||||||
|
|
||||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
|
||||||
function dedup(items: Manga[]): Manga[] {
|
function dedup(items: Manga[]): Manga[] {
|
||||||
return dedupeMangaByTitle(dedupeMangaById(items), store.settings.mangaLinks);
|
return dedupeMangaByTitle(dedupeMangaById(items), store.settings.mangaLinks);
|
||||||
}
|
}
|
||||||
@@ -62,14 +60,15 @@
|
|||||||
function filterOut(mangas: Manga[]): Manga[] {
|
function filterOut(mangas: Manga[]): Manga[] {
|
||||||
return dedup(mangas.filter(m => {
|
return dedup(mangas.filter(m => {
|
||||||
if (m.inLibrary || store.discoverLibraryIds.has(m.id)) return false;
|
if (m.inLibrary || store.discoverLibraryIds.has(m.id)) return false;
|
||||||
if (!store.settings.showNsfw && isNsfwManga(m)) return false;
|
if (shouldHideNsfw(m, store.settings)) return false;
|
||||||
return true;
|
return true;
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
function rotatedSources(): Source[] {
|
function rotatedSources(): Source[] {
|
||||||
const lang = store.settings.preferredExtensionLang || "en";
|
const lang = store.settings.preferredExtensionLang || "en";
|
||||||
const srcs = dedupeSources(allSources.filter(s => s.id !== "0"), lang);
|
const eligible = allSources.filter(s => s.id !== "0" && !shouldHideSource(s, store.settings));
|
||||||
|
const srcs = dedupeSources(eligible, lang);
|
||||||
if (!srcs.length) return [];
|
if (!srcs.length) return [];
|
||||||
const off = store.discoverSrcOffset % srcs.length;
|
const off = store.discoverSrcOffset % srcs.length;
|
||||||
return [...srcs.slice(off), ...srcs.slice(0, off)];
|
return [...srcs.slice(off), ...srcs.slice(0, off)];
|
||||||
@@ -86,7 +85,6 @@
|
|||||||
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, items.length) }, worker));
|
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, items.length) }, worker));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Push results into the reactive grid immediately — no batch delay.
|
|
||||||
function pushToGrid(genre: string, incoming: Manga[]) {
|
function pushToGrid(genre: string, incoming: Manga[]) {
|
||||||
const filtered = filterOut(incoming);
|
const filtered = filterOut(incoming);
|
||||||
if (!filtered.length) return;
|
if (!filtered.length) return;
|
||||||
@@ -95,7 +93,6 @@
|
|||||||
genreResults = new Map(genreResults);
|
genreResults = new Map(genreResults);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Source fan-out ────────────────────────────────────────────────────────
|
|
||||||
async function fanOut(genre: string, ctrl: AbortController) {
|
async function fanOut(genre: string, ctrl: AbortController) {
|
||||||
const srcs = rotatedSources();
|
const srcs = rotatedSources();
|
||||||
if (!srcs.length) return;
|
if (!srcs.length) return;
|
||||||
@@ -114,7 +111,6 @@
|
|||||||
let hasNextPage = false;
|
let hasNextPage = false;
|
||||||
|
|
||||||
if (store.discoverCache.has(key)) {
|
if (store.discoverCache.has(key)) {
|
||||||
// Cache hit — no network call needed
|
|
||||||
mangas = store.discoverCache.get(key)!;
|
mangas = store.discoverCache.get(key)!;
|
||||||
} else {
|
} else {
|
||||||
const result = await gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>(
|
const result = await gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>(
|
||||||
@@ -140,13 +136,11 @@
|
|||||||
pushToGrid(genre, matching.length ? matching : mangas);
|
pushToGrid(genre, matching.length ? matching : mangas);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stop paging early if source is exhausted
|
|
||||||
if (!hasNextPage) return;
|
if (!hasNextPage) return;
|
||||||
}
|
}
|
||||||
}, ctrl.signal);
|
}, ctrl.signal);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Tab switch ────────────────────────────────────────────────────────────
|
|
||||||
async function switchGenre(genre: string) {
|
async function switchGenre(genre: string) {
|
||||||
if (currentGenre === genre) return;
|
if (currentGenre === genre) return;
|
||||||
|
|
||||||
@@ -157,7 +151,6 @@
|
|||||||
activeCtrl = ctrl;
|
activeCtrl = ctrl;
|
||||||
|
|
||||||
if (genre === "All") {
|
if (genre === "All") {
|
||||||
// Already have results from this session — show instantly, re-fan in background
|
|
||||||
if ((genreResults.get("All") ?? []).length > 0) {
|
if ((genreResults.get("All") ?? []).length > 0) {
|
||||||
genreLoading = false;
|
genreLoading = false;
|
||||||
fanOut("All", ctrl).catch(() => {});
|
fanOut("All", ctrl).catch(() => {});
|
||||||
@@ -171,7 +164,6 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Genre tab: serve cached local results instantly, always fan out too
|
|
||||||
const localKey = `local|${genre}`;
|
const localKey = `local|${genre}`;
|
||||||
if (store.discoverCache.has(localKey)) {
|
if (store.discoverCache.has(localKey)) {
|
||||||
genreResults.set(genre, store.discoverCache.get(localKey)!);
|
genreResults.set(genre, store.discoverCache.get(localKey)!);
|
||||||
@@ -187,9 +179,7 @@
|
|||||||
);
|
);
|
||||||
if (ctrl.signal.aborted) return;
|
if (ctrl.signal.aborted) return;
|
||||||
|
|
||||||
const local = dedup(
|
const local = dedup(d.mangas.nodes.filter(m => !shouldHideNsfw(m, store.settings)));
|
||||||
d.mangas.nodes.filter(m => store.settings.showNsfw || !isNsfwManga(m))
|
|
||||||
);
|
|
||||||
store.discoverCache.set(localKey, local);
|
store.discoverCache.set(localKey, local);
|
||||||
genreResults.set(genre, local.slice(0, GRID_LIMIT));
|
genreResults.set(genre, local.slice(0, GRID_LIMIT));
|
||||||
genreResults = new Map(genreResults);
|
genreResults = new Map(genreResults);
|
||||||
@@ -202,10 +192,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Refresh ───────────────────────────────────────────────────────────────
|
|
||||||
async function refresh() {
|
async function refresh() {
|
||||||
activeCtrl?.abort();
|
activeCtrl?.abort();
|
||||||
clearDiscoverCache(); // wipes store.discoverCache + bumps discoverSrcOffset
|
clearDiscoverCache();
|
||||||
genreResults = new Map();
|
genreResults = new Map();
|
||||||
refreshing = true;
|
refreshing = true;
|
||||||
genreLoading = true;
|
genreLoading = true;
|
||||||
@@ -216,17 +205,14 @@
|
|||||||
refreshing = false;
|
refreshing = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Initial load ──────────────────────────────────────────────────────────
|
|
||||||
function loadAll() {
|
function loadAll() {
|
||||||
loadingLib = true;
|
loadingLib = true;
|
||||||
loadError = false;
|
loadError = false;
|
||||||
|
|
||||||
// Already have a session grid — show it immediately
|
|
||||||
if ((genreResults.get("All") ?? []).length > 0) {
|
if ((genreResults.get("All") ?? []).length > 0) {
|
||||||
loadingLib = false;
|
loadingLib = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refresh library ID set so newly-added manga get filtered out
|
|
||||||
cache.get(CACHE_KEYS.DISCOVER, () =>
|
cache.get(CACHE_KEYS.DISCOVER, () =>
|
||||||
gql<{ mangas: { nodes: Manga[] } }>(EXPLORE_ALL_MANGA).then(d => d.mangas.nodes)
|
gql<{ mangas: { nodes: Manga[] } }>(EXPLORE_ALL_MANGA).then(d => d.mangas.nodes)
|
||||||
).then(m => {
|
).then(m => {
|
||||||
@@ -236,7 +222,6 @@
|
|||||||
}).catch(e => { console.error(e); loadError = true; })
|
}).catch(e => { console.error(e); loadError = true; })
|
||||||
.finally(() => { loadingLib = false; });
|
.finally(() => { loadingLib = false; });
|
||||||
|
|
||||||
// Load sources then kick off All tab fan-out (only if grid is empty)
|
|
||||||
gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
|
gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
|
||||||
.then(d => {
|
.then(d => {
|
||||||
allSources = d.sources.nodes;
|
allSources = d.sources.nodes;
|
||||||
@@ -257,7 +242,6 @@
|
|||||||
|
|
||||||
loadAll();
|
loadAll();
|
||||||
|
|
||||||
// ── Context menu ──────────────────────────────────────────────────────────
|
|
||||||
function openCtx(e: MouseEvent, m: Manga) {
|
function openCtx(e: MouseEvent, m: Manga) {
|
||||||
e.preventDefault(); e.stopPropagation();
|
e.preventDefault(); e.stopPropagation();
|
||||||
ctx = { x: e.clientX, y: e.clientY, manga: m };
|
ctx = { x: e.clientX, y: e.clientY, manga: m };
|
||||||
@@ -315,11 +299,7 @@
|
|||||||
<span class="heading">Discover</span>
|
<span class="heading">Discover</span>
|
||||||
<div class="tab-strip">
|
<div class="tab-strip">
|
||||||
{#each GENRE_TABS as tab (tab)}
|
{#each GENRE_TABS as tab (tab)}
|
||||||
<button
|
<button class="genre-tab" class:active={currentGenre === tab} onclick={() => switchGenre(tab)}>
|
||||||
class="genre-tab"
|
|
||||||
class:active={currentGenre === tab}
|
|
||||||
onclick={() => switchGenre(tab)}
|
|
||||||
>
|
|
||||||
{#if tab === "All"}<Sparkle size={10} weight="fill" />{/if}
|
{#if tab === "All"}<Sparkle size={10} weight="fill" />{/if}
|
||||||
{tab}
|
{tab}
|
||||||
</button>
|
</button>
|
||||||
@@ -350,13 +330,9 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<div class="manga-grid">
|
<div class="manga-grid">
|
||||||
{#each visibleGrid as m (m.id)}
|
{#each visibleGrid as m (m.id)}
|
||||||
<button
|
<button class="manga-card" onclick={() => setPreviewManga(m)} oncontextmenu={(e) => openCtx(e, m)}>
|
||||||
class="manga-card"
|
|
||||||
onclick={() => setPreviewManga(m)}
|
|
||||||
oncontextmenu={(e) => openCtx(e, m)}
|
|
||||||
>
|
|
||||||
<div class="cover-wrap">
|
<div class="cover-wrap">
|
||||||
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} class="cover" loading="lazy" decoding="async" />
|
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="cover" />
|
||||||
<div class="cover-gradient"></div>
|
<div class="cover-gradient"></div>
|
||||||
{#if m.inLibrary}<span class="lib-badge">Saved</span>{/if}
|
{#if m.inLibrary}<span class="lib-badge">Saved</span>{/if}
|
||||||
<div class="card-footer">
|
<div class="card-footer">
|
||||||
@@ -391,11 +367,11 @@
|
|||||||
.body { flex: 1; overflow-y: auto; padding: var(--sp-4) var(--sp-5) var(--sp-6); will-change: scroll-position; -webkit-overflow-scrolling: touch; contain: layout style; }
|
.body { flex: 1; overflow-y: auto; padding: var(--sp-4) var(--sp-5) var(--sp-6); will-change: scroll-position; -webkit-overflow-scrolling: touch; contain: layout style; }
|
||||||
.manga-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(clamp(90px, 11vw, 130px), 1fr)); gap: var(--sp-2); align-content: start; contain: layout style; }
|
.manga-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(clamp(90px, 11vw, 130px), 1fr)); gap: var(--sp-2); align-content: start; contain: layout style; }
|
||||||
.manga-card { background: none; border: none; padding: 0; cursor: pointer; text-align: left; }
|
.manga-card { background: none; border: none; padding: 0; cursor: pointer; text-align: left; }
|
||||||
.manga-card:hover .cover { filter: brightness(1.08) saturate(1.05); transform: scale(1.02); }
|
.manga-card:hover :global(.cover) { filter: brightness(1.08) saturate(1.05); transform: scale(1.02); }
|
||||||
.manga-card:hover .card-title { color: #fff; }
|
.manga-card:hover .card-title { color: #fff; }
|
||||||
.manga-card:hover { will-change: transform; }
|
.manga-card:hover { will-change: transform; }
|
||||||
.cover-wrap { position: relative; aspect-ratio: 2/3; overflow: hidden; border-radius: var(--radius-md); background: var(--bg-raised); border: 1px solid var(--border-dim); box-shadow: 0 2px 8px rgba(0,0,0,0.25); }
|
.cover-wrap { position: relative; aspect-ratio: 2/3; overflow: hidden; border-radius: var(--radius-md); background: var(--bg-raised); border: 1px solid var(--border-dim); box-shadow: 0 2px 8px rgba(0,0,0,0.25); }
|
||||||
.cover { width: 100%; height: 100%; object-fit: cover; display: block; transition: filter 0.15s ease, transform 0.15s ease; }
|
:global(.cover) { width: 100%; height: 100%; object-fit: cover; display: block; transition: filter 0.15s ease, transform 0.15s ease; }
|
||||||
.cover-gradient { position: absolute; inset: 0; background: linear-gradient(to top, rgba(0,0,0,0.82) 0%, rgba(0,0,0,0.15) 50%, transparent 72%); pointer-events: none; }
|
.cover-gradient { position: absolute; inset: 0; background: linear-gradient(to top, rgba(0,0,0,0.82) 0%, rgba(0,0,0,0.15) 50%, transparent 72%); pointer-events: none; }
|
||||||
.lib-badge { position: absolute; top: var(--sp-1); right: var(--sp-1); font-family: var(--font-ui); font-size: 9px; letter-spacing: var(--tracking-wide); text-transform: uppercase; background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim); padding: 1px 5px; border-radius: var(--radius-sm); }
|
.lib-badge { position: absolute; top: var(--sp-1); right: var(--sp-1); font-family: var(--font-ui); font-size: 9px; letter-spacing: var(--tracking-wide); text-transform: uppercase; background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim); padding: 1px 5px; border-radius: var(--radius-sm); }
|
||||||
.card-footer { position: absolute; bottom: 0; left: 0; right: 0; padding: var(--sp-2); pointer-events: none; }
|
.card-footer { position: absolute; bottom: 0; left: 0; right: 0; padding: var(--sp-2); pointer-events: none; }
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Play, Pause, Trash, CircleNotch, X } from "phosphor-svelte";
|
import { Play, Pause, Trash, CircleNotch, X } from "phosphor-svelte";
|
||||||
import { gql, thumbUrl } from "../../lib/client";
|
import { gql } from "../../lib/client";
|
||||||
|
import Thumbnail from "../shared/Thumbnail.svelte";
|
||||||
import { GET_DOWNLOAD_STATUS, START_DOWNLOADER, STOP_DOWNLOADER, CLEAR_DOWNLOADER, DEQUEUE_DOWNLOAD } from "../../lib/queries";
|
import { GET_DOWNLOAD_STATUS, START_DOWNLOADER, STOP_DOWNLOADER, CLEAR_DOWNLOADER, DEQUEUE_DOWNLOAD } from "../../lib/queries";
|
||||||
import { store, setActiveDownloads } from "../../store/state.svelte";
|
import { store, setActiveDownloads } from "../../store/state.svelte";
|
||||||
import type { DownloadStatus } from "../../lib/types";
|
import type { DownloadStatus } from "../../lib/types";
|
||||||
@@ -114,7 +115,7 @@
|
|||||||
<div class="row" class:row-active={isActive} class:row-removing={isRemoving}>
|
<div class="row" class:row-active={isActive} class:row-removing={isRemoving}>
|
||||||
{#if manga?.thumbnailUrl}
|
{#if manga?.thumbnailUrl}
|
||||||
<div class="thumb">
|
<div class="thumb">
|
||||||
<img src={thumbUrl(manga.thumbnailUrl)} alt={manga?.title} class="thumb-img" loading="lazy" decoding="async" />
|
<Thumbnail src={manga.thumbnailUrl} alt={manga?.title} class="thumb-img" />
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="info">
|
<div class="info">
|
||||||
@@ -165,7 +166,7 @@
|
|||||||
.row.row-active { border-color: var(--accent-dim); }
|
.row.row-active { border-color: var(--accent-dim); }
|
||||||
.row.row-removing { opacity: 0.4; pointer-events: none; }
|
.row.row-removing { opacity: 0.4; pointer-events: none; }
|
||||||
.thumb { width: 36px; height: 54px; border-radius: var(--radius-sm); overflow: hidden; background: var(--bg-overlay); flex-shrink: 0; border: 1px solid var(--border-dim); }
|
.thumb { width: 36px; height: 54px; border-radius: var(--radius-sm); overflow: hidden; background: var(--bg-overlay); flex-shrink: 0; border: 1px solid var(--border-dim); }
|
||||||
.thumb-img { width: 100%; height: 100%; object-fit: cover; }
|
:global(.thumb-img) { width: 100%; height: 100%; object-fit: cover; }
|
||||||
.info { flex: 1; display: flex; flex-direction: column; gap: 3px; overflow: hidden; min-width: 0; }
|
.info { flex: 1; display: flex; flex-direction: column; gap: 3px; overflow: hidden; min-width: 0; }
|
||||||
.manga-title { font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
.manga-title { font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
.chapter-name { font-size: var(--text-xs); color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
.chapter-name { font-size: var(--text-xs); color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { untrack } from "svelte";
|
import { untrack } from "svelte";
|
||||||
import { MagnifyingGlass, ArrowsClockwise, Plus, CircleNotch, CaretRight, CaretDown, X, Check, GitBranch } from "phosphor-svelte";
|
import { MagnifyingGlass, ArrowsClockwise, Plus, CircleNotch, CaretRight, CaretDown, X, Check, GitBranch } from "phosphor-svelte";
|
||||||
import { gql, thumbUrl } from "../../lib/client";
|
import { gql } from "../../lib/client";
|
||||||
|
import Thumbnail from "../shared/Thumbnail.svelte";
|
||||||
import { GET_EXTENSIONS, FETCH_EXTENSIONS, UPDATE_EXTENSION, INSTALL_EXTERNAL_EXTENSION, GET_SETTINGS, SET_EXTENSION_REPOS } from "../../lib/queries";
|
import { GET_EXTENSIONS, FETCH_EXTENSIONS, UPDATE_EXTENSION, INSTALL_EXTERNAL_EXTENSION, GET_SETTINGS, SET_EXTENSION_REPOS } from "../../lib/queries";
|
||||||
import { store } from "../../store/state.svelte";
|
import { store } from "../../store/state.svelte";
|
||||||
import type { Extension } from "../../lib/types";
|
import type { Extension } from "../../lib/types";
|
||||||
@@ -225,7 +226,7 @@
|
|||||||
{@const hasVariants = variants.length > 0}
|
{@const hasVariants = variants.length > 0}
|
||||||
<div class="group">
|
<div class="group">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<img src={thumbUrl(primary.iconUrl)} alt={primary.name} class="icon" onerror={(e) => (e.target as HTMLImageElement).style.display = "none"} />
|
<Thumbnail src={primary.iconUrl} alt={primary.name} class="icon" onerror={(e) => (e.target as HTMLImageElement).style.display = "none"} />
|
||||||
<div class="info">
|
<div class="info">
|
||||||
<span class="name">{base}</span>
|
<span class="name">{base}</span>
|
||||||
<span class="meta"><span class="lang-tag">{primary.lang.toUpperCase()}</span> v{primary.versionName}</span>
|
<span class="meta"><span class="lang-tag">{primary.lang.toUpperCase()}</span> v{primary.versionName}</span>
|
||||||
@@ -284,7 +285,7 @@
|
|||||||
.header { display: flex; align-items: center; gap: var(--sp-4); padding: var(--sp-4) var(--sp-6); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
|
.header { display: flex; align-items: center; gap: var(--sp-4); padding: var(--sp-4) var(--sp-6); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
|
||||||
.header-right { display: flex; align-items: center; gap: var(--sp-2); margin-left: auto; }
|
.header-right { display: flex; align-items: center; gap: var(--sp-2); margin-left: auto; }
|
||||||
.heading { font-family: var(--font-ui); font-size: var(--text-xs); font-weight: var(--weight-normal); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; }
|
.heading { font-family: var(--font-ui); font-size: var(--text-xs); font-weight: var(--weight-normal); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; }
|
||||||
.header-actions { display: flex; gap: var(--sp-1); }
|
|
||||||
.icon-btn { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: var(--radius-md); color: var(--text-muted); transition: color var(--t-base), background var(--t-base); }
|
.icon-btn { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: var(--radius-md); color: var(--text-muted); transition: color var(--t-base), background var(--t-base); }
|
||||||
.icon-btn:hover:not(:disabled) { color: var(--text-primary); background: var(--bg-raised); }
|
.icon-btn:hover:not(:disabled) { color: var(--text-primary); background: var(--bg-raised); }
|
||||||
.icon-btn:disabled { opacity: 0.4; }
|
.icon-btn:disabled { opacity: 0.4; }
|
||||||
@@ -323,7 +324,7 @@
|
|||||||
.group { display: flex; flex-direction: column; }
|
.group { display: flex; flex-direction: column; }
|
||||||
.row { display: flex; align-items: center; gap: var(--sp-3); padding: 8px var(--sp-3); border-radius: var(--radius-md); border: 1px solid transparent; transition: background var(--t-fast), border-color var(--t-fast); }
|
.row { display: flex; align-items: center; gap: var(--sp-3); padding: 8px var(--sp-3); border-radius: var(--radius-md); border: 1px solid transparent; transition: background var(--t-fast), border-color var(--t-fast); }
|
||||||
.row:hover { background: var(--bg-raised); border-color: var(--border-dim); }
|
.row:hover { background: var(--bg-raised); border-color: var(--border-dim); }
|
||||||
.icon { width: 32px; height: 32px; border-radius: var(--radius-md); object-fit: cover; flex-shrink: 0; background: var(--bg-raised); }
|
:global(.icon) { width: 32px; height: 32px; border-radius: var(--radius-md); object-fit: cover; flex-shrink: 0; background: var(--bg-raised); }
|
||||||
.info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; min-width: 0; }
|
.info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; min-width: 0; }
|
||||||
.name { font-size: var(--text-base); font-weight: var(--weight-medium); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
.name { font-size: var(--text-base); font-weight: var(--weight-medium); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
.meta { display: flex; align-items: center; gap: var(--sp-2); font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
.meta { display: flex; align-items: center; gap: var(--sp-2); font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { ArrowLeft, BookmarkSimple, FolderSimplePlus, Folder, CircleNotch } from "phosphor-svelte";
|
import { ArrowLeft, BookmarkSimple, FolderSimplePlus, Folder, CircleNotch } from "phosphor-svelte";
|
||||||
import { untrack } from "svelte";
|
import { untrack } from "svelte";
|
||||||
import { gql, thumbUrl } from "../../lib/client";
|
import { gql } from "../../lib/client";
|
||||||
|
import Thumbnail from "../shared/Thumbnail.svelte";
|
||||||
import { GET_ALL_MANGA, GET_LIBRARY, GET_SOURCES, FETCH_SOURCE_MANGA, UPDATE_MANGA, GET_CATEGORIES, CREATE_CATEGORY, UPDATE_MANGA_CATEGORIES } from "../../lib/queries";
|
import { GET_ALL_MANGA, GET_LIBRARY, GET_SOURCES, FETCH_SOURCE_MANGA, UPDATE_MANGA, GET_CATEGORIES, CREATE_CATEGORY, UPDATE_MANGA_CATEGORIES } from "../../lib/queries";
|
||||||
import { cache, CACHE_KEYS, getPageSet } from "../../lib/cache";
|
import { cache, CACHE_KEYS, getPageSet } from "../../lib/cache";
|
||||||
import { dedupeSources, dedupeMangaById } from "../../lib/util";
|
import { dedupeSources, dedupeMangaById, shouldHideNsfw } from "../../lib/util";
|
||||||
import { store, setGenreFilter, setPreviewManga, setNavPage } from "../../store/state.svelte";
|
import { store, setGenreFilter, setPreviewManga, setNavPage } from "../../store/state.svelte";
|
||||||
import type { Manga, Source, Category } from "../../lib/types";
|
import type { Manga, Source, Category } from "../../lib/types";
|
||||||
import ContextMenu, { type MenuEntry } from "../shared/ContextMenu.svelte";
|
import ContextMenu, { type MenuEntry } from "../shared/ContextMenu.svelte";
|
||||||
@@ -47,9 +48,9 @@
|
|||||||
let abortCtrl: AbortController | null = null;
|
let abortCtrl: AbortController | null = null;
|
||||||
|
|
||||||
const filtered = $derived.by(() => {
|
const filtered = $derived.by(() => {
|
||||||
const libMatches = libraryManga.filter((m) => matchesAllTags(m, tags));
|
const libMatches = libraryManga.filter((m) => matchesAllTags(m, tags) && !shouldHideNsfw(m, store.settings));
|
||||||
const libIds = new Set(libMatches.map((m) => m.id));
|
const libIds = new Set(libMatches.map((m) => m.id));
|
||||||
return dedupeMangaById([...libMatches, ...sourceManga.filter((m) => !libIds.has(m.id))]);
|
return dedupeMangaById([...libMatches, ...sourceManga.filter((m) => !libIds.has(m.id) && !shouldHideNsfw(m, store.settings))]);
|
||||||
});
|
});
|
||||||
const visibleItems = $derived(filtered.slice(0, visibleCount));
|
const visibleItems = $derived(filtered.slice(0, visibleCount));
|
||||||
const hasMoreVisible = $derived(visibleCount < filtered.length);
|
const hasMoreVisible = $derived(visibleCount < filtered.length);
|
||||||
@@ -217,7 +218,7 @@
|
|||||||
{#each visibleItems as m (m.id)}
|
{#each visibleItems as m (m.id)}
|
||||||
<button class="card" onclick={() => setPreviewManga(m)} oncontextmenu={(e) => { e.stopPropagation(); openCtx(e, m); }}>
|
<button class="card" onclick={() => setPreviewManga(m)} oncontextmenu={(e) => { e.stopPropagation(); openCtx(e, m); }}>
|
||||||
<div class="cover-wrap">
|
<div class="cover-wrap">
|
||||||
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} class="cover" loading="lazy" decoding="async" />
|
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="cover" />
|
||||||
{#if m.inLibrary}<span class="in-library-badge">Saved</span>{/if}
|
{#if m.inLibrary}<span class="in-library-badge">Saved</span>{/if}
|
||||||
</div>
|
</div>
|
||||||
<p class="card-title">{m.title}</p>
|
<p class="card-title">{m.title}</p>
|
||||||
@@ -247,10 +248,10 @@
|
|||||||
.result-count, .loading-hint { margin-left: auto; font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
.result-count, .loading-hint { margin-left: auto; font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||||
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(clamp(100px,13vw,140px),1fr)); gap: var(--sp-4); padding: var(--sp-5) var(--sp-6) var(--sp-6); overflow-y: auto; flex: 1; align-content: start; will-change: scroll-position; -webkit-overflow-scrolling: touch; contain: layout style; }
|
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(clamp(100px,13vw,140px),1fr)); gap: var(--sp-4); padding: var(--sp-5) var(--sp-6) var(--sp-6); overflow-y: auto; flex: 1; align-content: start; will-change: scroll-position; -webkit-overflow-scrolling: touch; contain: layout style; }
|
||||||
.card { background: none; border: none; padding: 0; cursor: pointer; text-align: left; }
|
.card { background: none; border: none; padding: 0; cursor: pointer; text-align: left; }
|
||||||
.card:hover .cover { filter: brightness(1.06); }
|
.card:hover :global(.cover) { filter: brightness(1.06); }
|
||||||
.card:hover .card-title { color: var(--text-primary); }
|
.card:hover .card-title { color: var(--text-primary); }
|
||||||
.cover-wrap { position: relative; aspect-ratio: 2/3; overflow: hidden; border-radius: var(--radius-md); background: var(--bg-raised); border: 1px solid var(--border-dim); transform: translateZ(0); }
|
.cover-wrap { position: relative; aspect-ratio: 2/3; overflow: hidden; border-radius: var(--radius-md); background: var(--bg-raised); border: 1px solid var(--border-dim); transform: translateZ(0); }
|
||||||
.cover { width: 100%; height: 100%; object-fit: cover; transition: filter var(--t-base); will-change: filter; }
|
:global(.cover) { width: 100%; height: 100%; object-fit: cover; transition: filter var(--t-base); will-change: filter; }
|
||||||
.in-library-badge { position: absolute; bottom: var(--sp-1); left: var(--sp-1); font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); text-transform: uppercase; background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim); padding: 2px 5px; border-radius: var(--radius-sm); }
|
.in-library-badge { position: absolute; bottom: var(--sp-1); left: var(--sp-1); font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); text-transform: uppercase; background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim); padding: 2px 5px; border-radius: var(--radius-sm); }
|
||||||
.card-title { margin-top: var(--sp-2); font-size: var(--text-sm); color: var(--text-secondary); line-height: var(--leading-snug); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; transition: color var(--t-base); }
|
.card-title { margin-top: var(--sp-2); font-size: var(--text-sm); color: var(--text-secondary); line-height: var(--leading-snug); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; transition: color var(--t-base); }
|
||||||
.card-skeleton { padding: 0; }
|
.card-skeleton { padding: 0; }
|
||||||
|
|||||||
@@ -2,11 +2,14 @@
|
|||||||
import { onMount, untrack } from "svelte";
|
import { onMount, untrack } from "svelte";
|
||||||
import { Play, ArrowRight, ArrowLeft, BookOpen, Clock, Fire, TrendUp, CalendarBlank, CheckCircle, PushPin, X as XIcon, MagnifyingGlass, ListBullets } from "phosphor-svelte";
|
import { Play, ArrowRight, ArrowLeft, BookOpen, Clock, Fire, TrendUp, CalendarBlank, CheckCircle, PushPin, X as XIcon, MagnifyingGlass, ListBullets } from "phosphor-svelte";
|
||||||
import { gql, thumbUrl } from "../../lib/client";
|
import { gql, thumbUrl } from "../../lib/client";
|
||||||
|
import { getBlobUrl } from "../../lib/imageCache";
|
||||||
|
import Thumbnail from "../shared/Thumbnail.svelte";
|
||||||
import { GET_LIBRARY, GET_CHAPTERS, GET_MANGA, GET_CATEGORIES } from "../../lib/queries";
|
import { GET_LIBRARY, GET_CHAPTERS, GET_MANGA, GET_CATEGORIES } from "../../lib/queries";
|
||||||
import { cache, CACHE_KEYS } from "../../lib/cache";
|
import { cache, CACHE_KEYS } from "../../lib/cache";
|
||||||
import { store, openReader, setHeroSlot, setActiveManga, setPreviewManga, setNavPage, setGenreFilter, setLibraryFilter } from "../../store/state.svelte";
|
import { store, openReader, setHeroSlot, setActiveManga, setPreviewManga, setNavPage, setGenreFilter, setLibraryFilter } from "../../store/state.svelte";
|
||||||
import type { HistoryEntry } from "../../store/state.svelte";
|
import type { HistoryEntry } from "../../store/state.svelte";
|
||||||
import type { Manga, Chapter, Category } from "../../lib/types";
|
import type { Manga, Chapter, Category } from "../../lib/types";
|
||||||
|
import { buildReaderChapterList } from "../../lib/chapterList";
|
||||||
|
|
||||||
function timeAgo(ts: number): string {
|
function timeAgo(ts: number): string {
|
||||||
const diff = Date.now() - ts, m = Math.floor(diff / 60000);
|
const diff = Date.now() - ts, m = Math.floor(diff / 60000);
|
||||||
@@ -57,17 +60,23 @@
|
|||||||
.finally(() => loadingLibrary = false);
|
.finally(() => loadingLibrary = false);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-fetch library and reset hero chapters whenever the reader closes,
|
function resetAndReload() {
|
||||||
// so the hero reflects the latest-read chapter immediately.
|
|
||||||
$effect(() => {
|
|
||||||
const sessionId = store.readerSessionId;
|
|
||||||
if (sessionId === 0) return; // skip initial mount — onMount handles that
|
|
||||||
cache.clear(CACHE_KEYS.LIBRARY);
|
cache.clear(CACHE_KEYS.LIBRARY);
|
||||||
loadingLibrary = true;
|
loadingLibrary = true;
|
||||||
heroChapters = [];
|
heroChapters = [];
|
||||||
heroAllChapters = [];
|
heroAllChapters = [];
|
||||||
heroChaptersFor = null;
|
heroChaptersFor = null;
|
||||||
loadLibrary();
|
loadLibrary();
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (store.navPage === "home") untrack(() => resetAndReload());
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const sessionId = store.readerSessionId;
|
||||||
|
if (sessionId === 0) return;
|
||||||
|
untrack(() => resetAndReload());
|
||||||
});
|
});
|
||||||
|
|
||||||
async function fetchExtraCompleted(library: Manga[], completed: Category | null) {
|
async function fetchExtraCompleted(library: Manga[], completed: Category | null) {
|
||||||
@@ -114,7 +123,21 @@
|
|||||||
|
|
||||||
let activeIdx = $state(0);
|
let activeIdx = $state(0);
|
||||||
const activeSlot = $derived(resolvedSlots[activeIdx]);
|
const activeSlot = $derived(resolvedSlots[activeIdx]);
|
||||||
const heroThumb = $derived(activeSlot?.kind === "pinned" ? thumbUrl(activeSlot.manga?.thumbnailUrl ?? "") : activeSlot?.kind === "continue" ? thumbUrl(activeSlot.entry?.thumbnailUrl ?? "") : "");
|
const heroThumbSrc = $derived(
|
||||||
|
activeSlot?.kind === "pinned" ? (activeSlot.manga?.thumbnailUrl ?? "") :
|
||||||
|
activeSlot?.kind === "continue" ? (activeSlot.entry?.thumbnailUrl ?? "") : ""
|
||||||
|
);
|
||||||
|
let heroThumb = $state("");
|
||||||
|
$effect(() => {
|
||||||
|
const path = heroThumbSrc;
|
||||||
|
const mode = store.settings.serverAuthMode ?? "NONE";
|
||||||
|
if (!path) { heroThumb = ""; return; }
|
||||||
|
if (mode !== "BASIC_AUTH") { heroThumb = thumbUrl(path); return; }
|
||||||
|
// Use tauri-plugin-http backed getBlobUrl which handles auth and bypasses CORS
|
||||||
|
getBlobUrl(thumbUrl(path))
|
||||||
|
.then(url => { heroThumb = url; })
|
||||||
|
.catch(() => { heroThumb = ""; });
|
||||||
|
});
|
||||||
const heroTitle = $derived(activeSlot?.kind === "pinned" ? (activeSlot.manga?.title ?? "") : activeSlot?.kind === "continue" ? (activeSlot.entry?.mangaTitle ?? "") : "");
|
const heroTitle = $derived(activeSlot?.kind === "pinned" ? (activeSlot.manga?.title ?? "") : activeSlot?.kind === "continue" ? (activeSlot.entry?.mangaTitle ?? "") : "");
|
||||||
const heroManga = $derived(activeSlot?.kind === "pinned" ? activeSlot.manga : activeSlot?.kind === "continue" ? libraryManga.find(m => m.id === activeSlot.entry?.mangaId) : null);
|
const heroManga = $derived(activeSlot?.kind === "pinned" ? activeSlot.manga : activeSlot?.kind === "continue" ? libraryManga.find(m => m.id === activeSlot.entry?.mangaId) : null);
|
||||||
const heroEntry = $derived(activeSlot?.kind === "continue" ? activeSlot.entry : null);
|
const heroEntry = $derived(activeSlot?.kind === "continue" ? activeSlot.entry : null);
|
||||||
@@ -142,7 +165,8 @@
|
|||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const id = heroMangaId;
|
const id = heroMangaId;
|
||||||
if (id && id !== heroChaptersFor) untrack(() => loadHeroChapters(id));
|
void store.settings.mangaPrefs?.[id!];
|
||||||
|
if (id) untrack(() => loadHeroChapters(id));
|
||||||
});
|
});
|
||||||
|
|
||||||
async function loadHeroChapters(mangaId: number) {
|
async function loadHeroChapters(mangaId: number) {
|
||||||
@@ -155,9 +179,10 @@
|
|||||||
if (heroChaptersFor !== mangaId) return;
|
if (heroChaptersFor !== mangaId) return;
|
||||||
const all = [...d.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder);
|
const all = [...d.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder);
|
||||||
heroAllChapters = all;
|
heroAllChapters = all;
|
||||||
const lastReadIdx = heroEntry ? all.findIndex(c => c.id === heroEntry!.chapterId) : all.findLastIndex(c => c.isRead);
|
const filtered = buildReaderChapterList(all, store.settings.mangaPrefs?.[mangaId]);
|
||||||
|
const lastReadIdx = heroEntry ? filtered.findIndex(c => c.id === heroEntry!.chapterId) : filtered.findLastIndex(c => c.isRead);
|
||||||
const startIdx = Math.max(0, lastReadIdx);
|
const startIdx = Math.max(0, lastReadIdx);
|
||||||
heroChapters = all.slice(startIdx, startIdx + 5);
|
heroChapters = filtered.slice(startIdx, startIdx + 5);
|
||||||
} catch { heroChapters = []; heroAllChapters = []; }
|
} catch { heroChapters = []; heroAllChapters = []; }
|
||||||
finally { loadingHeroChapters = false; }
|
finally { loadingHeroChapters = false; }
|
||||||
}
|
}
|
||||||
@@ -173,7 +198,13 @@
|
|||||||
const d = await gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId: heroMangaId });
|
const d = await gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId: heroMangaId });
|
||||||
all = [...d.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder);
|
all = [...d.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder);
|
||||||
}
|
}
|
||||||
openReader(chapter, all);
|
if (all.length) {
|
||||||
|
const manga = heroManga ?? { id: heroMangaId, title: heroTitle, thumbnailUrl: heroManga?.thumbnailUrl ?? "" } as any;
|
||||||
|
store.activeManga = manga;
|
||||||
|
const list = buildReaderChapterList(all, store.settings.mangaPrefs?.[heroMangaId]);
|
||||||
|
const target = list.find(c => c.id === chapter.id) ?? list[0];
|
||||||
|
if (target) openReader(target, list);
|
||||||
|
}
|
||||||
} catch { store.activeManga = { id: heroMangaId, title: heroTitle, thumbnailUrl: heroManga?.thumbnailUrl ?? "" } as any; }
|
} catch { store.activeManga = { id: heroMangaId, title: heroTitle, thumbnailUrl: heroManga?.thumbnailUrl ?? "" } as any; }
|
||||||
finally { resuming = false; }
|
finally { resuming = false; }
|
||||||
}
|
}
|
||||||
@@ -186,10 +217,13 @@
|
|||||||
resuming = true;
|
resuming = true;
|
||||||
try {
|
try {
|
||||||
const d = await gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId: heroEntry.mangaId });
|
const d = await gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId: heroEntry.mangaId });
|
||||||
const chapters = [...d.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder);
|
const raw = [...d.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder);
|
||||||
const ch = chapters.find(c => c.id === heroEntry!.chapterId) ?? chapters[0];
|
const list = buildReaderChapterList(raw, store.settings.mangaPrefs?.[heroEntry.mangaId]);
|
||||||
if (ch) openReader(ch, chapters);
|
const ch = list.find(c => c.id === heroEntry!.chapterId) ?? list[0];
|
||||||
else store.activeManga = { id: heroEntry.mangaId, title: heroEntry.mangaTitle, thumbnailUrl: heroEntry.thumbnailUrl } as any;
|
if (ch) {
|
||||||
|
store.activeManga = heroManga ?? { id: heroEntry.mangaId, title: heroEntry.mangaTitle, thumbnailUrl: heroEntry.thumbnailUrl } as any;
|
||||||
|
openReader(ch, list);
|
||||||
|
}
|
||||||
} catch { store.activeManga = { id: heroEntry.mangaId, title: heroEntry.mangaTitle, thumbnailUrl: heroEntry.thumbnailUrl } as any; }
|
} catch { store.activeManga = { id: heroEntry.mangaId, title: heroEntry.mangaTitle, thumbnailUrl: heroEntry.thumbnailUrl } as any; }
|
||||||
finally { resuming = false; }
|
finally { resuming = false; }
|
||||||
}
|
}
|
||||||
@@ -197,10 +231,13 @@
|
|||||||
async function resumeEntry(entry: HistoryEntry) {
|
async function resumeEntry(entry: HistoryEntry) {
|
||||||
try {
|
try {
|
||||||
const d = await gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId: entry.mangaId });
|
const d = await gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId: entry.mangaId });
|
||||||
const chapters = [...d.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder);
|
const raw = [...d.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder);
|
||||||
const ch = chapters.find(c => c.id === entry.chapterId) ?? chapters[0];
|
const list = buildReaderChapterList(raw, store.settings.mangaPrefs?.[entry.mangaId]);
|
||||||
if (ch) openReader(ch, chapters);
|
const ch = list.find(c => c.id === entry.chapterId) ?? list[0];
|
||||||
else store.activeManga = { id: entry.mangaId, title: entry.mangaTitle, thumbnailUrl: entry.thumbnailUrl } as any;
|
if (ch) {
|
||||||
|
store.activeManga = { id: entry.mangaId, title: entry.mangaTitle, thumbnailUrl: entry.thumbnailUrl } as any;
|
||||||
|
openReader(ch, list);
|
||||||
|
} else store.activeManga = { id: entry.mangaId, title: entry.mangaTitle, thumbnailUrl: entry.thumbnailUrl } as any;
|
||||||
} catch { store.activeManga = { id: entry.mangaId, title: entry.mangaTitle, thumbnailUrl: entry.thumbnailUrl } as any; }
|
} catch { store.activeManga = { id: entry.mangaId, title: entry.mangaTitle, thumbnailUrl: entry.thumbnailUrl } as any; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -379,7 +416,7 @@
|
|||||||
{#if recentHistory.length > 0}
|
{#if recentHistory.length > 0}
|
||||||
{#each recentHistory as entry (entry.chapterId)}
|
{#each recentHistory as entry (entry.chapterId)}
|
||||||
<button class="activity-row" onclick={() => resumeEntry(entry)}>
|
<button class="activity-row" onclick={() => resumeEntry(entry)}>
|
||||||
<img src={thumbUrl(entry.thumbnailUrl)} alt={entry.mangaTitle} class="activity-thumb" loading="lazy" decoding="async" />
|
<Thumbnail src={entry.thumbnailUrl} alt={entry.mangaTitle} class="activity-thumb" />
|
||||||
<div class="activity-info">
|
<div class="activity-info">
|
||||||
<span class="activity-title">{entry.mangaTitle}</span>
|
<span class="activity-title">{entry.mangaTitle}</span>
|
||||||
<span class="activity-sub">{entry.chapterName}{entry.pageNumber > 1 ? ` · p.${entry.pageNumber}` : ""}</span>
|
<span class="activity-sub">{entry.chapterName}{entry.pageNumber > 1 ? ` · p.${entry.pageNumber}` : ""}</span>
|
||||||
@@ -423,7 +460,7 @@
|
|||||||
{#each completedManga as m (m.id)}
|
{#each completedManga as m (m.id)}
|
||||||
<button class="mini-card" onclick={() => store.previewManga = m}>
|
<button class="mini-card" onclick={() => store.previewManga = m}>
|
||||||
<div class="mini-cover-wrap">
|
<div class="mini-cover-wrap">
|
||||||
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} class="mini-cover" loading="lazy" decoding="async" />
|
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="mini-cover" />
|
||||||
<div class="mini-gradient"></div>
|
<div class="mini-gradient"></div>
|
||||||
<div class="mini-footer">
|
<div class="mini-footer">
|
||||||
<p class="mini-card-title">{m.title}</p>
|
<p class="mini-card-title">{m.title}</p>
|
||||||
@@ -479,7 +516,7 @@
|
|||||||
{:else}
|
{:else}
|
||||||
{#each pickerResults as m (m.id)}
|
{#each pickerResults as m (m.id)}
|
||||||
<button class="picker-row" onclick={() => pinManga(m)}>
|
<button class="picker-row" onclick={() => pinManga(m)}>
|
||||||
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} class="picker-thumb" loading="lazy" />
|
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="picker-thumb" />
|
||||||
<div class="picker-info">
|
<div class="picker-info">
|
||||||
<span class="picker-manga-title">{m.title}</span>
|
<span class="picker-manga-title">{m.title}</span>
|
||||||
{#if m.source?.displayName}<span class="picker-source">{m.source.displayName}</span>{/if}
|
{#if m.source?.displayName}<span class="picker-source">{m.source.displayName}</span>{/if}
|
||||||
@@ -569,7 +606,7 @@
|
|||||||
.activity-row { display: flex; align-items: center; gap: var(--sp-3); padding: 7px var(--sp-2); border-radius: var(--radius-md); border: 1px solid transparent; background: none; text-align: left; cursor: pointer; width: 100%; transition: background var(--t-fast), border-color var(--t-fast); }
|
.activity-row { display: flex; align-items: center; gap: var(--sp-3); padding: 7px var(--sp-2); border-radius: var(--radius-md); border: 1px solid transparent; background: none; text-align: left; cursor: pointer; width: 100%; transition: background var(--t-fast), border-color var(--t-fast); }
|
||||||
.activity-row:hover { background: var(--bg-raised); border-color: var(--border-dim); }
|
.activity-row:hover { background: var(--bg-raised); border-color: var(--border-dim); }
|
||||||
.activity-row:hover .activity-play { opacity: 1; }
|
.activity-row:hover .activity-play { opacity: 1; }
|
||||||
.activity-thumb { width: 33px; height: 48px; border-radius: var(--radius-sm); object-fit: cover; flex-shrink: 0; border: 1px solid var(--border-dim); }
|
:global(.activity-thumb) { width: 33px; height: 48px; border-radius: var(--radius-sm); object-fit: cover; flex-shrink: 0; border: 1px solid var(--border-dim); }
|
||||||
.activity-info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; min-width: 0; }
|
.activity-info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; min-width: 0; }
|
||||||
.activity-title { font-size: var(--text-base); font-weight: var(--weight-medium); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
.activity-title { font-size: var(--text-base); font-weight: var(--weight-medium); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
.activity-sub { font-family: var(--font-ui); font-size: var(--text-sm); color: var(--text-muted); letter-spacing: var(--tracking-wide); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
.activity-sub { font-family: var(--font-ui); font-size: var(--text-sm); color: var(--text-muted); letter-spacing: var(--tracking-wide); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
@@ -586,10 +623,10 @@
|
|||||||
.mini-row::-webkit-scrollbar { display: none; }
|
.mini-row::-webkit-scrollbar { display: none; }
|
||||||
|
|
||||||
.mini-card { flex: 0 0 120px; width: 120px; background: none; border: none; padding: 0; cursor: pointer; text-align: left; }
|
.mini-card { flex: 0 0 120px; width: 120px; background: none; border: none; padding: 0; cursor: pointer; text-align: left; }
|
||||||
.mini-card:hover .mini-cover { filter: brightness(1.08) saturate(1.05); transform: scale(1.02); }
|
.mini-card:hover :global(.mini-cover) { filter: brightness(1.08) saturate(1.05); transform: scale(1.02); }
|
||||||
.mini-card:hover { will-change: transform; }
|
.mini-card:hover { will-change: transform; }
|
||||||
.mini-cover-wrap { position: relative; aspect-ratio: 2/3; overflow: hidden; border-radius: var(--radius-md); background: var(--bg-raised); border: 1px solid var(--border-dim); box-shadow: 0 2px 12px rgba(0,0,0,0.35); }
|
.mini-cover-wrap { position: relative; aspect-ratio: 2/3; overflow: hidden; border-radius: var(--radius-md); background: var(--bg-raised); border: 1px solid var(--border-dim); box-shadow: 0 2px 12px rgba(0,0,0,0.35); }
|
||||||
.mini-cover { width: 100%; height: 100%; object-fit: cover; display: block; transition: filter 0.15s ease, transform 0.15s ease; }
|
:global(.mini-cover) { width: 100%; height: 100%; object-fit: cover; display: block; transition: filter 0.15s ease, transform 0.15s ease; }
|
||||||
.mini-gradient { position: absolute; inset: 0; background: linear-gradient(to top, rgba(0,0,0,0.85) 0%, rgba(0,0,0,0.1) 55%, transparent 75%); pointer-events: none; }
|
.mini-gradient { position: absolute; inset: 0; background: linear-gradient(to top, rgba(0,0,0,0.85) 0%, rgba(0,0,0,0.1) 55%, transparent 75%); pointer-events: none; }
|
||||||
.mini-footer { position: absolute; bottom: 0; left: 0; right: 0; padding: var(--sp-2); pointer-events: none; }
|
.mini-footer { position: absolute; bottom: 0; left: 0; right: 0; padding: var(--sp-2); pointer-events: none; }
|
||||||
.mini-card-title { font-size: var(--text-xs); font-weight: var(--weight-medium); color: rgba(255,255,255,0.92); line-height: var(--leading-snug); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; text-shadow: 0 1px 4px rgba(0,0,0,0.7); }
|
.mini-card-title { font-size: var(--text-xs); font-weight: var(--weight-medium); color: rgba(255,255,255,0.92); line-height: var(--leading-snug); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; text-shadow: 0 1px 4px rgba(0,0,0,0.7); }
|
||||||
@@ -628,7 +665,7 @@
|
|||||||
.picker-empty { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); padding: var(--sp-4) var(--sp-3); text-align: center; }
|
.picker-empty { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); padding: var(--sp-4) var(--sp-3); text-align: center; }
|
||||||
.picker-row { display: flex; align-items: center; gap: var(--sp-3); width: 100%; padding: 8px var(--sp-3); border-radius: var(--radius-md); border: none; background: none; text-align: left; cursor: pointer; transition: background var(--t-fast); }
|
.picker-row { display: flex; align-items: center; gap: var(--sp-3); width: 100%; padding: 8px var(--sp-3); border-radius: var(--radius-md); border: none; background: none; text-align: left; cursor: pointer; transition: background var(--t-fast); }
|
||||||
.picker-row:hover { background: var(--bg-raised); }
|
.picker-row:hover { background: var(--bg-raised); }
|
||||||
.picker-thumb { height: 50px; width: 35px; aspect-ratio: 1 / 1.42; border-radius: var(--radius-sm); object-fit: cover; flex-shrink: 0; border: 1px solid var(--border-dim); background: var(--bg-raised); display: block; }
|
:global(.picker-thumb) { height: 50px; width: 35px; aspect-ratio: 1 / 1.42; border-radius: var(--radius-sm); object-fit: cover; flex-shrink: 0; border: 1px solid var(--border-dim); background: var(--bg-raised); display: block; }
|
||||||
.picker-info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; min-width: 0; }
|
.picker-info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; min-width: 0; }
|
||||||
.picker-manga-title { font-size: var(--text-sm); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
.picker-manga-title { font-size: var(--text-sm); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
.picker-source { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
.picker-source { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount, untrack } from "svelte";
|
import { onMount, untrack } from "svelte";
|
||||||
import { MagnifyingGlass, Books, DownloadSimple, Folder, FolderSimplePlus, Trash, Star, CheckSquare, X, ArrowSquareOut, SortAscending, Funnel, CaretUp, CaretDown } from "phosphor-svelte";
|
import { MagnifyingGlass, Books, DownloadSimple, Folder, FolderSimplePlus, Trash, Star, CheckSquare, X, ArrowSquareOut, SortAscending, Funnel, CaretUp, CaretDown, Check } from "phosphor-svelte";
|
||||||
import { gql, thumbUrl } from "../../lib/client";
|
import { gql, thumbUrl } from "../../lib/client";
|
||||||
import { GET_CATEGORIES, GET_LIBRARY, UPDATE_MANGA, GET_CHAPTERS, DELETE_DOWNLOADED_CHAPTERS, DEQUEUE_DOWNLOAD, CREATE_CATEGORY, UPDATE_MANGA_CATEGORIES, UPDATE_CATEGORY_ORDER } from "../../lib/queries";
|
import { GET_CATEGORIES, GET_LIBRARY, UPDATE_MANGA, GET_CHAPTERS, DELETE_DOWNLOADED_CHAPTERS, DEQUEUE_DOWNLOAD, CREATE_CATEGORY, UPDATE_MANGA_CATEGORIES, UPDATE_CATEGORY_ORDER } from "../../lib/queries";
|
||||||
import { cache, CACHE_KEYS, CACHE_GROUPS, DEFAULT_TTL_MS } from "../../lib/cache";
|
import { cache, CACHE_KEYS, CACHE_GROUPS, DEFAULT_TTL_MS } from "../../lib/cache";
|
||||||
import { dedupeMangaById, dedupeMangaByTitle, isNsfwManga } from "../../lib/util";
|
import { dedupeMangaById, dedupeMangaByTitle, shouldHideNsfw } from "../../lib/util";
|
||||||
import { store, setLibraryFilter, checkAndMarkCompleted as storeCheckAndMarkCompleted, updateSettings, setCategories } from "../../store/state.svelte";
|
import { store, setLibraryFilter, checkAndMarkCompleted as storeCheckAndMarkCompleted, updateSettings, setCategories } from "../../store/state.svelte";
|
||||||
import type { LibrarySortMode, LibrarySortDir, LibraryStatusFilter } from "../../store/state.svelte";
|
import type { LibrarySortMode, LibrarySortDir, LibraryStatusFilter, LibraryContentFilter } from "../../store/state.svelte";
|
||||||
import type { Manga, Category, Chapter } from "../../lib/types";
|
import type { Manga, Category, Chapter } from "../../lib/types";
|
||||||
import ContextMenu, { type MenuEntry } from "../shared/ContextMenu.svelte";
|
import ContextMenu, { type MenuEntry } from "../shared/ContextMenu.svelte";
|
||||||
|
import Thumbnail from "../shared/Thumbnail.svelte";
|
||||||
|
|
||||||
const CARD_MIN_W = 130;
|
const CARD_MIN_W = 130;
|
||||||
const CARD_GAP = 16;
|
const CARD_GAP = 16;
|
||||||
@@ -73,6 +74,12 @@
|
|||||||
const tabStatus = $derived(
|
const tabStatus = $derived(
|
||||||
store.settings.libraryTabStatus[store.libraryFilter] ?? "ALL" as LibraryStatusFilter
|
store.settings.libraryTabStatus[store.libraryFilter] ?? "ALL" as LibraryStatusFilter
|
||||||
);
|
);
|
||||||
|
const tabFilters = $derived(
|
||||||
|
store.settings.libraryTabFilters?.[store.libraryFilter] ?? {} as Partial<Record<LibraryContentFilter, boolean>>
|
||||||
|
);
|
||||||
|
const hasActiveFilters = $derived(
|
||||||
|
tabStatus !== "ALL" || Object.values(tabFilters).some(Boolean)
|
||||||
|
);
|
||||||
|
|
||||||
function setTabSort(mode: LibrarySortMode, dir?: LibrarySortDir) {
|
function setTabSort(mode: LibrarySortMode, dir?: LibrarySortDir) {
|
||||||
const prev = store.settings.libraryTabSort[store.libraryFilter];
|
const prev = store.settings.libraryTabSort[store.libraryFilter];
|
||||||
@@ -96,7 +103,32 @@
|
|||||||
[store.libraryFilter]: status,
|
[store.libraryFilter]: status,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
filterPanelOpen = false;
|
}
|
||||||
|
|
||||||
|
function toggleTabFilter(filter: LibraryContentFilter) {
|
||||||
|
const current = store.settings.libraryTabFilters?.[store.libraryFilter] ?? {};
|
||||||
|
updateSettings({
|
||||||
|
libraryTabFilters: {
|
||||||
|
...(store.settings.libraryTabFilters ?? {}),
|
||||||
|
[store.libraryFilter]: {
|
||||||
|
...current,
|
||||||
|
[filter]: !current[filter],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearAllFilters() {
|
||||||
|
updateSettings({
|
||||||
|
libraryTabStatus: {
|
||||||
|
...store.settings.libraryTabStatus,
|
||||||
|
[store.libraryFilter]: "ALL",
|
||||||
|
},
|
||||||
|
libraryTabFilters: {
|
||||||
|
...(store.settings.libraryTabFilters ?? {}),
|
||||||
|
[store.libraryFilter]: {},
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let allManga: Manga[] = $state([]);
|
let allManga: Manga[] = $state([]);
|
||||||
@@ -320,9 +352,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 2. NSFW filter — always applied before text search or sort
|
// 2. NSFW filter — always applied before text search or sort
|
||||||
if (!store.settings.showNsfw) {
|
items = items.filter(m => !shouldHideNsfw(m, store.settings));
|
||||||
items = items.filter(m => !isNsfwManga(m));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Text search
|
// 3. Text search
|
||||||
if (q) items = items.filter(m => m.title.toLowerCase().includes(q));
|
if (q) items = items.filter(m => m.title.toLowerCase().includes(q));
|
||||||
@@ -335,6 +365,13 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 4b. Content filters (additive — each active filter further narrows the list)
|
||||||
|
const filters = store.settings.libraryTabFilters?.[store.libraryFilter] ?? {};
|
||||||
|
if (filters.unread) items = items.filter(m => (m.unreadCount ?? 0) > 0);
|
||||||
|
if (filters.started) items = items.filter(m => (m.unreadCount ?? 0) > 0 && (m.chapterCount ?? 0) > (m.unreadCount ?? 0));
|
||||||
|
if (filters.downloaded) items = items.filter(m => (m.downloadCount ?? 0) > 0);
|
||||||
|
if (filters.bookmarked) items = items.filter(m => !!(m as any).hasBookmark);
|
||||||
|
|
||||||
// 5. Sort
|
// 5. Sort
|
||||||
const recentlyReadMap = new Map<number, number>();
|
const recentlyReadMap = new Map<number, number>();
|
||||||
if (mode === "recentlyRead") {
|
if (mode === "recentlyRead") {
|
||||||
@@ -714,7 +751,11 @@
|
|||||||
</button>
|
</button>
|
||||||
{#if sortPanelOpen}
|
{#if sortPanelOpen}
|
||||||
<div class="dropdown-panel sort-panel" role="menu">
|
<div class="dropdown-panel sort-panel" role="menu">
|
||||||
<p class="panel-label">Sort by</p>
|
<div class="panel-header">
|
||||||
|
<span class="panel-heading">Sort</span>
|
||||||
|
</div>
|
||||||
|
<div class="panel-divider"></div>
|
||||||
|
<p class="panel-label">Order by</p>
|
||||||
{#each ALL_SORT_MODES as m}
|
{#each ALL_SORT_MODES as m}
|
||||||
<button
|
<button
|
||||||
class="panel-item"
|
class="panel-item"
|
||||||
@@ -752,22 +793,47 @@
|
|||||||
<div class="filter-panel-wrap">
|
<div class="filter-panel-wrap">
|
||||||
<button
|
<button
|
||||||
class="icon-btn"
|
class="icon-btn"
|
||||||
class:icon-btn-active={tabStatus !== "ALL"}
|
class:icon-btn-active={hasActiveFilters}
|
||||||
title="Filter by status"
|
title="Filter"
|
||||||
onclick={openFilterPanel}
|
onclick={openFilterPanel}
|
||||||
>
|
>
|
||||||
<Funnel size={15} weight={tabStatus !== "ALL" ? "fill" : "bold"} />
|
<Funnel size={15} weight={hasActiveFilters ? "fill" : "bold"} />
|
||||||
</button>
|
</button>
|
||||||
{#if filterPanelOpen}
|
{#if filterPanelOpen}
|
||||||
<div class="dropdown-panel filter-panel" role="menu">
|
<div class="dropdown-panel filter-panel" role="menu">
|
||||||
<p class="panel-label">Filter by status</p>
|
<div class="panel-header">
|
||||||
{#each ALL_STATUS_FILTERS as s}
|
<span class="panel-heading">Filter</span>
|
||||||
|
{#if hasActiveFilters}
|
||||||
|
<button class="panel-clear-btn" onclick={clearAllFilters}>Clear all</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="panel-divider"></div>
|
||||||
|
<p class="panel-label">Content</p>
|
||||||
|
{#each ([["unread","Unread"],["started","Started"],["downloaded","Downloaded"],["bookmarked","Bookmarked"]] as const) as [f, label]}
|
||||||
<button
|
<button
|
||||||
class="panel-item"
|
class="panel-item panel-item-check"
|
||||||
|
class:panel-item-active={tabFilters[f]}
|
||||||
|
role="menuitem"
|
||||||
|
onclick={() => toggleTabFilter(f)}
|
||||||
|
>
|
||||||
|
<span class="panel-check" class:panel-check-on={tabFilters[f]}>
|
||||||
|
{#if tabFilters[f]}<Check size={9} weight="bold" />{/if}
|
||||||
|
</span>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
<div class="panel-divider"></div>
|
||||||
|
<p class="panel-label">Status</p>
|
||||||
|
{#each ALL_STATUS_FILTERS.filter(s => s !== "ALL") as s}
|
||||||
|
<button
|
||||||
|
class="panel-item panel-item-check"
|
||||||
class:panel-item-active={tabStatus === s}
|
class:panel-item-active={tabStatus === s}
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
onclick={() => setTabStatus(s)}
|
onclick={() => setTabStatus(tabStatus === s ? "ALL" : s)}
|
||||||
>
|
>
|
||||||
|
<span class="panel-check" class:panel-check-on={tabStatus === s}>
|
||||||
|
{#if tabStatus === s}<Check size={9} weight="bold" />{/if}
|
||||||
|
</span>
|
||||||
{STATUS_LABELS[s]}
|
{STATUS_LABELS[s]}
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -855,7 +921,7 @@
|
|||||||
onpointerleave={onCardPointerLeave}
|
onpointerleave={onCardPointerLeave}
|
||||||
>
|
>
|
||||||
<div class="cover-wrap">
|
<div class="cover-wrap">
|
||||||
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} class="cover" style="object-fit:{store.settings.libraryCropCovers ? 'cover' : 'contain'}" loading="lazy" decoding="async" draggable="false" />
|
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="cover" style="object-fit:{store.settings.libraryCropCovers ? 'cover' : 'contain'}" draggable="false" />
|
||||||
{#if m.downloadCount}<span class="badge-dl">{m.downloadCount}</span>{/if}
|
{#if m.downloadCount}<span class="badge-dl">{m.downloadCount}</span>{/if}
|
||||||
{#if m.unreadCount}<span class="badge-unread">{m.unreadCount}</span>{/if}
|
{#if m.unreadCount}<span class="badge-unread">{m.unreadCount}</span>{/if}
|
||||||
{#if selectMode}
|
{#if selectMode}
|
||||||
@@ -929,15 +995,24 @@
|
|||||||
/* ── Dropdown panels (shared) ───────────────────────────────────────────── */
|
/* ── Dropdown panels (shared) ───────────────────────────────────────────── */
|
||||||
.sort-panel-wrap,
|
.sort-panel-wrap,
|
||||||
.filter-panel-wrap { position: relative; }
|
.filter-panel-wrap { position: relative; }
|
||||||
.dropdown-panel { position: absolute; top: calc(100% + 6px); right: 0; z-index: 9999; min-width: 220px; background: var(--bg-overlay, #1a1a1a); border: 1px solid var(--border-base); border-radius: var(--radius-md); padding: 6px; box-shadow: 0 12px 36px rgba(0,0,0,0.55), 0 2px 8px rgba(0,0,0,0.3); animation: fadeIn 0.1s ease both; }
|
.dropdown-panel { position: absolute; top: calc(100% + 6px); right: 0; z-index: 9999; min-width: 220px; background: var(--bg-raised); border: 1px solid var(--border-base); border-radius: var(--radius-lg); padding: var(--sp-1); box-shadow: 0 8px 32px rgba(0,0,0,0.5); animation: fadeIn 0.1s ease both; }
|
||||||
.panel-label { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wider); text-transform: uppercase; color: var(--text-faint); padding: 4px 8px 8px; }
|
.panel-label { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wider); text-transform: uppercase; color: var(--text-faint); padding: 4px 8px 8px; }
|
||||||
.panel-item { display: flex; align-items: center; justify-content: space-between; width: 100%; padding: 7px 10px; border-radius: var(--radius-sm); border: none; background: transparent; color: var(--text-muted); font-family: var(--font-ui); font-size: var(--text-xs); cursor: pointer; text-align: left; transition: background var(--t-base), color var(--t-base); gap: var(--sp-2); }
|
.panel-item { display: flex; align-items: center; justify-content: space-between; width: 100%; padding: 7px 10px; border-radius: var(--radius-sm); border: none; background: transparent; color: var(--text-muted); font-family: var(--font-ui); font-size: var(--text-xs); cursor: pointer; text-align: left; transition: background var(--t-base), color var(--t-base); gap: var(--sp-2); }
|
||||||
.panel-item:hover { background: var(--bg-subtle, #202020); color: var(--text-primary); }
|
.panel-item:hover { background: var(--bg-overlay); color: var(--text-primary); }
|
||||||
.panel-item-active { color: var(--accent-fg); background: var(--accent-muted); font-weight: var(--weight-medium, 500); }
|
.panel-item-active { color: var(--accent-fg); background: var(--accent-muted); font-weight: var(--weight-medium, 500); }
|
||||||
.panel-item-active:hover { background: var(--accent-dim); }
|
.panel-item-active:hover { background: var(--accent-dim); }
|
||||||
.panel-divider { height: 1px; background: var(--border-dim); margin: 4px 2px; }
|
.panel-divider { height: 1px; background: var(--border-dim); margin: 4px 2px; }
|
||||||
|
/* Panel header row — shared by sort + filter panels */
|
||||||
|
.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); }
|
||||||
|
/* Check items */
|
||||||
|
.panel-item-check { justify-content: flex-start; gap: var(--sp-2); }
|
||||||
|
.panel-check { width: 13px; height: 13px; border-radius: 2px; border: 1px solid var(--border-strong); background: transparent; flex-shrink: 0; transition: background var(--t-base), border-color var(--t-base); display: flex; align-items: center; justify-content: center; color: var(--bg-base); }
|
||||||
|
.panel-check-on { background: var(--accent); border-color: var(--accent); }
|
||||||
.dir-toggle { color: var(--text-secondary); justify-content: flex-start; gap: var(--sp-2); border-top: 1px solid var(--border-dim); border-radius: 0 0 var(--radius-sm) var(--radius-sm); margin-top: 2px; padding-top: 9px; }
|
.dir-toggle { color: var(--text-secondary); justify-content: flex-start; gap: var(--sp-2); border-top: 1px solid var(--border-dim); border-radius: 0 0 var(--radius-sm) var(--radius-sm); margin-top: 2px; padding-top: 9px; }
|
||||||
.sort-caret { flex-shrink: 0; }
|
:global(.sort-caret) { flex-shrink: 0; }
|
||||||
|
|
||||||
/* ── Selection toolbar ──────────────────────────────────────────────────── */
|
/* ── Selection toolbar ──────────────────────────────────────────────────── */
|
||||||
.select-bar { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-3); padding: var(--sp-2) var(--sp-6); background: var(--bg-raised); border-bottom: 1px solid var(--accent-dim); flex-shrink: 0; animation: fadeIn 0.1s ease both; }
|
.select-bar { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-3); padding: var(--sp-2) var(--sp-6); background: var(--bg-raised); border-bottom: 1px solid var(--accent-dim); flex-shrink: 0; animation: fadeIn 0.1s ease both; }
|
||||||
|
|||||||
+510
-1249
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+685
-336
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,271 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { X } from "phosphor-svelte";
|
||||||
|
import { store, updateSettings } from "../../store/state.svelte";
|
||||||
|
import { DEFAULT_MANGA_PREFS } from "../../store/state.svelte";
|
||||||
|
import type { MangaPrefs } from "../../store/state.svelte";
|
||||||
|
let { mangaId, onClose }: {
|
||||||
|
mangaId: number;
|
||||||
|
onClose: () => void;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const mangaPrefs = $derived(
|
||||||
|
(store.settings.mangaPrefs?.[mangaId] ?? {}) as Partial<MangaPrefs>
|
||||||
|
);
|
||||||
|
|
||||||
|
function getPref<K extends keyof MangaPrefs>(key: K): MangaPrefs[K] {
|
||||||
|
return (mangaPrefs[key] ?? DEFAULT_MANGA_PREFS[key]) as MangaPrefs[K];
|
||||||
|
}
|
||||||
|
|
||||||
|
function setPref<K extends keyof MangaPrefs>(key: K, value: MangaPrefs[K]) {
|
||||||
|
updateSettings({
|
||||||
|
mangaPrefs: {
|
||||||
|
...store.settings.mangaPrefs,
|
||||||
|
[mangaId]: { ...(store.settings.mangaPrefs?.[mangaId] ?? {}), [key]: value },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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: "global", label: "Default" },
|
||||||
|
{ value: "daily", label: "Daily" },
|
||||||
|
{ value: "weekly", label: "Weekly" },
|
||||||
|
{ value: "manual", label: "Manual" },
|
||||||
|
];
|
||||||
|
|
||||||
|
function onBackdrop(e: MouseEvent) {
|
||||||
|
if (e.target === e.currentTarget) onClose();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="backdrop" role="presentation" tabindex="-1" onmousedown={onBackdrop}>
|
||||||
|
<div class="modal" role="dialog" aria-modal="true" aria-label="Automation">
|
||||||
|
|
||||||
|
<div class="modal-header">
|
||||||
|
<div class="header-left">
|
||||||
|
<span class="modal-title">Automation</span>
|
||||||
|
<span class="modal-subtitle">Per-series rules</span>
|
||||||
|
</div>
|
||||||
|
<button class="close-btn" onclick={onClose} aria-label="Close"><X size={16} weight="light" /></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-body">
|
||||||
|
|
||||||
|
<p class="section-label">Downloads</p>
|
||||||
|
|
||||||
|
<div class="auto-row">
|
||||||
|
<div class="auto-info">
|
||||||
|
<span class="auto-label">Auto-download new chapters</span>
|
||||||
|
<span class="auto-desc">Queue new chapters when this series refreshes</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
role="switch"
|
||||||
|
aria-checked={getPref("autoDownload")}
|
||||||
|
aria-label="Auto-download new chapters"
|
||||||
|
class="auto-toggle"
|
||||||
|
class:auto-toggle-on={getPref("autoDownload")}
|
||||||
|
onclick={() => setPref("autoDownload", !getPref("autoDownload"))}
|
||||||
|
><span class="auto-toggle-thumb"></span></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="auto-row">
|
||||||
|
<div class="auto-info">
|
||||||
|
<span class="auto-label">Download ahead</span>
|
||||||
|
<span class="auto-desc">Pre-fetch chapters while reading</span>
|
||||||
|
</div>
|
||||||
|
<div class="auto-chip-group">
|
||||||
|
{#each DOWNLOAD_AHEAD_OPTIONS as opt}
|
||||||
|
<button
|
||||||
|
class="auto-chip"
|
||||||
|
class:auto-chip-on={getPref("downloadAhead") === opt.value}
|
||||||
|
onclick={() => setPref("downloadAhead", opt.value)}
|
||||||
|
>{opt.label}</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="auto-row">
|
||||||
|
<div class="auto-info">
|
||||||
|
<span class="auto-label">Max chapters to keep</span>
|
||||||
|
<span class="auto-desc">Delete oldest downloads when limit is exceeded</span>
|
||||||
|
</div>
|
||||||
|
<div class="auto-chip-group">
|
||||||
|
{#each MAX_KEEP_OPTIONS as opt}
|
||||||
|
<button
|
||||||
|
class="auto-chip"
|
||||||
|
class:auto-chip-on={getPref("maxKeepChapters") === opt.value}
|
||||||
|
onclick={() => setPref("maxKeepChapters", opt.value)}
|
||||||
|
>{opt.label}</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="divider"></div>
|
||||||
|
|
||||||
|
<p class="section-label">On Read</p>
|
||||||
|
|
||||||
|
<div class="auto-row">
|
||||||
|
<div class="auto-info">
|
||||||
|
<span class="auto-label">Delete after reading</span>
|
||||||
|
<span class="auto-desc">Remove download when chapter is marked read</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
role="switch"
|
||||||
|
aria-checked={getPref("deleteOnRead")}
|
||||||
|
aria-label="Delete after reading"
|
||||||
|
class="auto-toggle"
|
||||||
|
class:auto-toggle-on={getPref("deleteOnRead")}
|
||||||
|
onclick={() => setPref("deleteOnRead", !getPref("deleteOnRead"))}
|
||||||
|
><span class="auto-toggle-thumb"></span></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if getPref("deleteOnRead")}
|
||||||
|
<div class="auto-row auto-row-sub">
|
||||||
|
<span class="auto-label">Delete delay</span>
|
||||||
|
<div class="auto-chip-group">
|
||||||
|
{#each DELETE_DELAY_OPTIONS as opt}
|
||||||
|
<button
|
||||||
|
class="auto-chip"
|
||||||
|
class:auto-chip-on={getPref("deleteDelayHours") === opt.value}
|
||||||
|
onclick={() => setPref("deleteDelayHours", opt.value)}
|
||||||
|
>{opt.label}</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="divider"></div>
|
||||||
|
|
||||||
|
<p class="section-label">Updates</p>
|
||||||
|
|
||||||
|
<div class="auto-row">
|
||||||
|
<div class="auto-info">
|
||||||
|
<span class="auto-label">Pause updates</span>
|
||||||
|
<span class="auto-desc">Skip this series during global refresh</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
role="switch"
|
||||||
|
aria-checked={getPref("pauseUpdates")}
|
||||||
|
aria-label="Pause updates"
|
||||||
|
class="auto-toggle"
|
||||||
|
class:auto-toggle-on={getPref("pauseUpdates")}
|
||||||
|
onclick={() => setPref("pauseUpdates", !getPref("pauseUpdates"))}
|
||||||
|
><span class="auto-toggle-thumb"></span></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="auto-row">
|
||||||
|
<div class="auto-info">
|
||||||
|
<span class="auto-label">Refresh interval</span>
|
||||||
|
<span class="auto-desc">How often to check for new chapters</span>
|
||||||
|
</div>
|
||||||
|
<div class="auto-chip-group">
|
||||||
|
{#each REFRESH_INTERVAL_OPTIONS as opt}
|
||||||
|
<button
|
||||||
|
class="auto-chip"
|
||||||
|
class:auto-chip-on={getPref("refreshInterval") === opt.value}
|
||||||
|
onclick={() => setPref("refreshInterval", opt.value as MangaPrefs["refreshInterval"])}
|
||||||
|
>{opt.label}</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.backdrop {
|
||||||
|
position: fixed; inset: 0; z-index: 300;
|
||||||
|
background: rgba(0,0,0,0.6);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
animation: fadeIn 0.1s ease both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
width: 420px; max-width: calc(100vw - var(--sp-6));
|
||||||
|
max-height: 80vh;
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
background: var(--bg-surface); border: 1px solid var(--border-base);
|
||||||
|
border-radius: var(--radius-xl); overflow: hidden;
|
||||||
|
box-shadow: 0 0 0 1px var(--border-dim), 0 24px 64px rgba(0,0,0,0.6);
|
||||||
|
animation: scaleIn 0.15s ease both;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header */
|
||||||
|
.modal-header {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
padding: var(--sp-4) var(--sp-5); border-bottom: 1px solid var(--border-dim); flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.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); }
|
||||||
|
|
||||||
|
/* Body */
|
||||||
|
.modal-body {
|
||||||
|
flex: 1; overflow-y: auto; scrollbar-width: none;
|
||||||
|
display: flex; flex-direction: column; gap: var(--sp-3);
|
||||||
|
padding: var(--sp-4) var(--sp-5);
|
||||||
|
}
|
||||||
|
.modal-body::-webkit-scrollbar { display: none; }
|
||||||
|
|
||||||
|
/* Section labels */
|
||||||
|
.section-label {
|
||||||
|
font-family: var(--font-ui); font-size: var(--text-2xs);
|
||||||
|
letter-spacing: var(--tracking-widest); color: var(--text-faint);
|
||||||
|
text-transform: uppercase; margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.divider { height: 1px; background: var(--border-dim); margin: var(--sp-1) 0; }
|
||||||
|
|
||||||
|
/* Rows — mirrors SeriesDetail auto-row */
|
||||||
|
.auto-row {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
gap: var(--sp-3);
|
||||||
|
}
|
||||||
|
.auto-row-align-start { align-items: flex-start; }
|
||||||
|
.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-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); }
|
||||||
|
|
||||||
|
/* Toggle */
|
||||||
|
.auto-toggle { width: 28px; height: 16px; border-radius: var(--radius-full); border: 1px solid var(--border-strong); background: var(--bg-overlay); cursor: pointer; padding: 0; flex-shrink: 0; position: relative; transition: background var(--t-base), border-color var(--t-base); }
|
||||||
|
.auto-toggle-on { background: var(--accent); border-color: var(--accent); }
|
||||||
|
.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); }
|
||||||
|
|
||||||
|
/* Chips */
|
||||||
|
.auto-chip-group { display: flex; flex-direction: row; gap: 4px; flex-shrink: 0; flex-wrap: wrap; justify-content: flex-end; }
|
||||||
|
.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-on { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
|
||||||
|
|
||||||
|
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
|
||||||
|
@keyframes scaleIn { from { opacity: 0; transform: scale(0.97) } to { opacity: 1; transform: scale(1) } }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { X, MapPin, Trash, PencilSimple, Check } from "phosphor-svelte";
|
||||||
|
import { store, removeMarker, updateMarker, openReader } from "../../store/state.svelte";
|
||||||
|
import type { MarkerEntry, MarkerColor } from "../../store/state.svelte";
|
||||||
|
import type { Chapter } from "../../lib/types";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
mangaId: number;
|
||||||
|
chapters: Chapter[];
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { mangaId, chapters, onClose }: Props = $props();
|
||||||
|
|
||||||
|
const COLOR_HEX: Record<MarkerColor, string> = {
|
||||||
|
yellow: "#c4a94a",
|
||||||
|
red: "#c47a7a",
|
||||||
|
blue: "#7a9ec4",
|
||||||
|
green: "#7aab7a",
|
||||||
|
purple: "#a07ac4",
|
||||||
|
};
|
||||||
|
|
||||||
|
const markers = $derived(store.getMarkersForManga(mangaId));
|
||||||
|
|
||||||
|
const grouped = $derived.by(() => {
|
||||||
|
const map = new Map<number, MarkerEntry[]>();
|
||||||
|
for (const m of markers) {
|
||||||
|
if (!map.has(m.chapterId)) map.set(m.chapterId, []);
|
||||||
|
map.get(m.chapterId)!.push(m);
|
||||||
|
}
|
||||||
|
const entries = [...map.entries()].map(([chapterId, items]) => ({
|
||||||
|
chapterId,
|
||||||
|
chapterName: items[0].chapterName,
|
||||||
|
items: [...items].sort((a, b) => a.pageNumber - b.pageNumber),
|
||||||
|
}));
|
||||||
|
const chapterOrder = new Map(chapters.map((c, i) => [c.id, i]));
|
||||||
|
entries.sort((a, b) => (chapterOrder.get(a.chapterId) ?? 9999) - (chapterOrder.get(b.chapterId) ?? 9999));
|
||||||
|
return entries;
|
||||||
|
});
|
||||||
|
|
||||||
|
let editingId: string = $state("");
|
||||||
|
let editNote: string = $state("");
|
||||||
|
let editColor: MarkerColor = $state("yellow");
|
||||||
|
|
||||||
|
function startEdit(m: MarkerEntry) {
|
||||||
|
editingId = m.id;
|
||||||
|
editNote = m.note;
|
||||||
|
editColor = m.color;
|
||||||
|
}
|
||||||
|
|
||||||
|
function commitEdit() {
|
||||||
|
if (!editingId) return;
|
||||||
|
updateMarker(editingId, { note: editNote.trim(), color: editColor });
|
||||||
|
editingId = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function jumpToMarker(m: MarkerEntry) {
|
||||||
|
const chapter = chapters.find(c => c.id === m.chapterId);
|
||||||
|
if (!chapter) return;
|
||||||
|
const chaptersAsc = [...chapters].sort((a, b) => a.sourceOrder - b.sourceOrder);
|
||||||
|
openReader(chapter, chaptersAsc);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(ts: number): string {
|
||||||
|
return new Date(ts).toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<div class="panel-title">
|
||||||
|
<MapPin size={13} weight="fill" />
|
||||||
|
<span>Markers</span>
|
||||||
|
{#if markers.length > 0}
|
||||||
|
<span class="count">{markers.length}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<button class="close-btn" onclick={onClose}><X size={14} weight="light" /></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel-body">
|
||||||
|
{#if grouped.length === 0}
|
||||||
|
<div class="empty">
|
||||||
|
<MapPin size={22} weight="light" style="color:var(--text-faint);opacity:0.4" />
|
||||||
|
<p>No markers yet</p>
|
||||||
|
<p class="empty-sub">Mark pages while reading with the marker button or keybind</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
{#each grouped as group}
|
||||||
|
<div class="group">
|
||||||
|
<div class="group-header">
|
||||||
|
<span class="group-name">{group.chapterName}</span>
|
||||||
|
<span class="group-count">{group.items.length}</span>
|
||||||
|
</div>
|
||||||
|
{#each group.items as m (m.id)}
|
||||||
|
<div class="marker-row" class:editing={editingId === m.id}>
|
||||||
|
<div class="marker-dot" style="background:{COLOR_HEX[m.color]}"></div>
|
||||||
|
<div class="marker-body">
|
||||||
|
{#if editingId === m.id}
|
||||||
|
<div class="edit-wrap">
|
||||||
|
<div class="color-row">
|
||||||
|
{#each Object.entries(COLOR_HEX) as [c, hex]}
|
||||||
|
<button
|
||||||
|
class="color-swatch"
|
||||||
|
class:color-active={editColor === c}
|
||||||
|
style="background:{hex}"
|
||||||
|
onclick={() => editColor = c as MarkerColor}
|
||||||
|
title={c}
|
||||||
|
></button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
class="edit-input"
|
||||||
|
rows={3}
|
||||||
|
bind:value={editNote}
|
||||||
|
placeholder="Add a note…"
|
||||||
|
onkeydown={(e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); commitEdit(); } if (e.key === "Escape") editingId = ""; }}
|
||||||
|
></textarea>
|
||||||
|
<div class="edit-actions">
|
||||||
|
<button class="edit-save" onclick={commitEdit}><Check size={12} weight="bold" /> Save</button>
|
||||||
|
<button class="edit-cancel" onclick={() => editingId = ""}>Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<button class="marker-jump" onclick={() => jumpToMarker(m)}>
|
||||||
|
<span class="page-label">p.{m.pageNumber}</span>
|
||||||
|
{#if m.note}
|
||||||
|
<span class="marker-note">{m.note}</span>
|
||||||
|
{:else}
|
||||||
|
<span class="marker-note marker-note-empty">No note</span>
|
||||||
|
{/if}
|
||||||
|
<span class="marker-date">{formatDate(m.updatedAt ?? m.createdAt)}</span>
|
||||||
|
</button>
|
||||||
|
<div class="marker-actions">
|
||||||
|
<button class="marker-action-btn" onclick={() => startEdit(m)} title="Edit"><PencilSimple size={11} weight="light" /></button>
|
||||||
|
<button class="marker-action-btn danger" onclick={() => removeMarker(m.id)} title="Delete"><Trash size={11} weight="light" /></button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.panel { display: flex; flex-direction: column; height: 100%; overflow: hidden; }
|
||||||
|
|
||||||
|
.panel-header { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-3) var(--sp-4); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
|
||||||
|
.panel-title { display: flex; align-items: center; gap: var(--sp-2); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); color: var(--text-muted); }
|
||||||
|
.count { background: var(--bg-overlay); border: 1px solid var(--border-dim); border-radius: var(--radius-full); font-size: var(--text-2xs); padding: 0 5px; color: var(--text-faint); }
|
||||||
|
.close-btn { display: flex; align-items: center; justify-content: center; width: 24px; height: 24px; border-radius: var(--radius-sm); color: var(--text-faint); transition: color var(--t-base), background var(--t-base); }
|
||||||
|
.close-btn:hover { color: var(--text-muted); background: var(--bg-raised); }
|
||||||
|
|
||||||
|
.panel-body { flex: 1; overflow-y: auto; padding: var(--sp-2); display: flex; flex-direction: column; gap: var(--sp-1); }
|
||||||
|
|
||||||
|
.empty { display: flex; flex-direction: column; align-items: center; gap: var(--sp-2); padding: var(--sp-8) var(--sp-4); text-align: center; }
|
||||||
|
.empty p { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||||
|
.empty-sub { font-size: var(--text-2xs) !important; opacity: 0.7; max-width: 180px; line-height: var(--leading-snug); }
|
||||||
|
|
||||||
|
.group { display: flex; flex-direction: column; gap: 2px; }
|
||||||
|
.group-header { display: flex; align-items: center; justify-content: space-between; padding: 6px var(--sp-2) 4px; }
|
||||||
|
.group-name { 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; }
|
||||||
|
.group-count { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); opacity: 0.6; flex-shrink: 0; }
|
||||||
|
|
||||||
|
.marker-row { display: flex; align-items: flex-start; gap: var(--sp-2); padding: 5px var(--sp-2); border-radius: var(--radius-md); transition: background var(--t-fast); }
|
||||||
|
.marker-row:hover { background: var(--bg-raised); }
|
||||||
|
.marker-row.editing { background: var(--bg-raised); }
|
||||||
|
.marker-dot { width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0; margin-top: 5px; }
|
||||||
|
|
||||||
|
.marker-body { flex: 1; min-width: 0; display: flex; align-items: flex-start; gap: var(--sp-1); }
|
||||||
|
.marker-jump { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; text-align: left; background: none; border: none; padding: 0; cursor: pointer; }
|
||||||
|
.page-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--accent-fg); letter-spacing: var(--tracking-wide); }
|
||||||
|
.marker-note { font-size: var(--text-xs); color: var(--text-secondary); line-height: var(--leading-snug); white-space: pre-wrap; word-break: break-word; }
|
||||||
|
.marker-note-empty { color: var(--text-faint); font-style: italic; }
|
||||||
|
.marker-date { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||||
|
|
||||||
|
.marker-actions { display: flex; flex-direction: column; gap: 2px; flex-shrink: 0; opacity: 0; transition: opacity var(--t-fast); }
|
||||||
|
.marker-row:hover .marker-actions { opacity: 1; }
|
||||||
|
.marker-action-btn { display: flex; align-items: center; justify-content: center; width: 20px; height: 20px; border-radius: var(--radius-sm); color: var(--text-faint); transition: color var(--t-base), background var(--t-base); }
|
||||||
|
.marker-action-btn:hover { color: var(--text-muted); background: var(--bg-overlay); }
|
||||||
|
.marker-action-btn.danger:hover { color: var(--color-error); background: var(--color-error-bg); }
|
||||||
|
|
||||||
|
.edit-wrap { flex: 1; display: flex; flex-direction: column; gap: var(--sp-2); }
|
||||||
|
.color-row { display: flex; gap: 5px; }
|
||||||
|
.color-swatch { width: 14px; height: 14px; border-radius: 50%; border: 2px solid transparent; cursor: pointer; transition: border-color var(--t-fast), transform var(--t-fast); flex-shrink: 0; }
|
||||||
|
.color-swatch:hover { transform: scale(1.15); }
|
||||||
|
.color-active { border-color: var(--text-primary) !important; }
|
||||||
|
.edit-input { width: 100%; background: var(--bg-overlay); border: 1px solid var(--border-strong); border-radius: var(--radius-sm); padding: 6px 8px; font-size: var(--text-xs); color: var(--text-secondary); outline: none; resize: none; font-family: inherit; line-height: var(--leading-snug); transition: border-color var(--t-base); }
|
||||||
|
.edit-input:focus { border-color: var(--border-focus); }
|
||||||
|
.edit-actions { display: flex; align-items: center; gap: var(--sp-2); }
|
||||||
|
.edit-save { display: flex; align-items: center; gap: 4px; padding: 4px 10px; border-radius: var(--radius-sm); border: 1px solid var(--accent-dim); background: var(--accent-muted); color: var(--accent-fg); font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); cursor: pointer; transition: filter var(--t-fast); }
|
||||||
|
.edit-save:hover { filter: brightness(1.15); }
|
||||||
|
.edit-cancel { padding: 4px 8px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); cursor: pointer; transition: color var(--t-base), border-color var(--t-base); }
|
||||||
|
.edit-cancel:hover { color: var(--text-muted); border-color: var(--border-strong); }
|
||||||
|
</style>
|
||||||
+96
-23
@@ -1,8 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { X, MagnifyingGlass, CircleNotch, ArrowRight, Check, Warning, Sparkle } from "phosphor-svelte";
|
import { X, MagnifyingGlass, CircleNotch, ArrowRight, Check, Warning, Sparkle } from "phosphor-svelte";
|
||||||
import { untrack } from "svelte";
|
import { untrack } from "svelte";
|
||||||
import { gql, thumbUrl } from "../../lib/client";
|
import { gql } from "../../lib/client";
|
||||||
|
import Thumbnail from "../shared/Thumbnail.svelte";
|
||||||
import { GET_SOURCES, FETCH_SOURCE_MANGA, FETCH_CHAPTERS, UPDATE_MANGA, UPDATE_CHAPTERS_PROGRESS } from "../../lib/queries";
|
import { GET_SOURCES, FETCH_SOURCE_MANGA, FETCH_CHAPTERS, UPDATE_MANGA, UPDATE_CHAPTERS_PROGRESS } from "../../lib/queries";
|
||||||
|
import { store } from "../../store/state.svelte";
|
||||||
import type { Manga, Source, Chapter } from "../../lib/types";
|
import type { Manga, Source, Chapter } from "../../lib/types";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -37,6 +39,46 @@
|
|||||||
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);
|
||||||
|
|
||||||
|
// Lang filter: "en" first, then alphabetical
|
||||||
|
let selectedLang: string = $state("all");
|
||||||
|
let langStripEl: HTMLDivElement | undefined = $state();
|
||||||
|
const availableLangs = $derived.by(() => {
|
||||||
|
const langs = Array.from(new Set<string>(sources.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);
|
||||||
|
|
||||||
|
function scrollLangStrip(dir: -1 | 1) {
|
||||||
|
if (!langStripEl) return;
|
||||||
|
const strip = langStripEl;
|
||||||
|
const chips = Array.from(strip.children) as HTMLElement[];
|
||||||
|
const scrollLeft = strip.scrollLeft;
|
||||||
|
const viewEnd = scrollLeft + strip.clientWidth;
|
||||||
|
|
||||||
|
if (dir === 1) {
|
||||||
|
// Find first chip that is cut off or fully outside the right edge, scroll it flush left
|
||||||
|
const next = chips.find(c => c.offsetLeft + c.offsetWidth > viewEnd + 2);
|
||||||
|
if (next) strip.scrollTo({ left: next.offsetLeft, behavior: "smooth" });
|
||||||
|
} else {
|
||||||
|
// Find last chip that is cut off or fully outside the left edge, scroll it flush right
|
||||||
|
const prev = [...chips].reverse().find(c => c.offsetLeft < scrollLeft - 2);
|
||||||
|
if (prev) strip.scrollTo({ left: prev.offsetLeft + prev.offsetWidth - strip.clientWidth, behavior: "smooth" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const visibleSources = $derived.by(() => {
|
||||||
|
if (selectedLang !== "all") return sources.filter(s => s.lang === selectedLang);
|
||||||
|
const map = new Map<string, Source>();
|
||||||
|
for (const s of sources) {
|
||||||
|
const existing = map.get(s.name);
|
||||||
|
if (!existing) { map.set(s.name, s); continue; }
|
||||||
|
if (s.lang < existing.lang) map.set(s.name, s);
|
||||||
|
}
|
||||||
|
return Array.from(map.values());
|
||||||
|
});
|
||||||
|
|
||||||
let query = $state(untrack(() => manga.title));
|
let query = $state(untrack(() => manga.title));
|
||||||
let results: { manga: Manga; similarity: number }[] = $state([]);
|
let results: { manga: Manga; similarity: number }[] = $state([]);
|
||||||
let searching = $state(false);
|
let searching = $state(false);
|
||||||
@@ -52,7 +94,14 @@
|
|||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
|
gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
|
||||||
.then((d) => { sources = d.sources.nodes.filter((s) => s.id !== "0" && s.id !== manga.source?.id); })
|
.then((d) => {
|
||||||
|
const filtered = d.sources.nodes.filter((s) => s.id !== "0" && s.id !== manga.source?.id);
|
||||||
|
sources = filtered;
|
||||||
|
// Pre-select preferred lang if available and there are multiple
|
||||||
|
const prefLang = store?.settings?.preferredExtensionLang ?? "";
|
||||||
|
const langs = new Set(filtered.map(s => s.lang));
|
||||||
|
if (prefLang && langs.has(prefLang) && langs.size > 1) selectedLang = prefLang;
|
||||||
|
})
|
||||||
.catch(console.error)
|
.catch(console.error)
|
||||||
.finally(() => { loadingSources = false; });
|
.finally(() => { loadingSources = false; });
|
||||||
|
|
||||||
@@ -178,21 +227,34 @@
|
|||||||
|
|
||||||
<!-- Step 1: Pick source -->
|
<!-- Step 1: Pick source -->
|
||||||
{#if step === "source"}
|
{#if step === "source"}
|
||||||
<div class="source-list">
|
{#if loadingSources}
|
||||||
{#if loadingSources}
|
<div class="centered">
|
||||||
<div class="centered">
|
<CircleNotch size={16} weight="light" class="anim-spin" style="color:var(--text-faint)" />
|
||||||
<CircleNotch size={16} weight="light" class="anim-spin" style="color:var(--text-faint)" />
|
</div>
|
||||||
|
{:else if sources.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>
|
</div>
|
||||||
{:else if sources.length === 0}
|
{/if}
|
||||||
<div class="centered"><span class="hint">No other sources installed.</span></div>
|
<div class="source-list">
|
||||||
{:else}
|
{#each visibleSources as src}
|
||||||
{#each sources as src}
|
|
||||||
<button
|
<button
|
||||||
class="source-row"
|
class="source-row"
|
||||||
class:source-row-active={selectedSource?.id === src.id}
|
class:source-row-active={selectedSource?.id === src.id}
|
||||||
onclick={() => pickSource(src)}>
|
onclick={() => pickSource(src)}>
|
||||||
<img src={thumbUrl(src.iconUrl)} alt={src.name} class="source-icon"
|
<Thumbnail src={src.iconUrl} alt={src.name} class="source-icon" onerror={(e) => (e.target as HTMLImageElement).style.display = "none"} />
|
||||||
onerror={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
|
|
||||||
<div class="source-info">
|
<div class="source-info">
|
||||||
<span class="source-name">{src.displayName}</span>
|
<span class="source-name">{src.displayName}</span>
|
||||||
<span class="source-meta">{src.lang.toUpperCase()}{src.isNsfw ? " · NSFW" : ""}</span>
|
<span class="source-meta">{src.lang.toUpperCase()}{src.isNsfw ? " · NSFW" : ""}</span>
|
||||||
@@ -200,8 +262,8 @@
|
|||||||
<ArrowRight size={13} weight="light" class="source-arrow" />
|
<ArrowRight size={13} weight="light" class="source-arrow" />
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
{/if}
|
</div>
|
||||||
</div>
|
{/if}
|
||||||
|
|
||||||
<!-- Step 2: Search & pick match -->
|
<!-- Step 2: Search & pick match -->
|
||||||
{:else if step === "search"}
|
{:else if step === "search"}
|
||||||
@@ -210,8 +272,7 @@
|
|||||||
<!-- Source context pill -->
|
<!-- Source context pill -->
|
||||||
{#if selectedSource}
|
{#if selectedSource}
|
||||||
<div class="search-context">
|
<div class="search-context">
|
||||||
<img src={thumbUrl(selectedSource.iconUrl)} alt="" class="search-context-icon"
|
<Thumbnail src={selectedSource.iconUrl} alt="" class="search-context-icon" onerror={(e) => (e.target as HTMLImageElement).style.display = "none"} />
|
||||||
onerror={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
|
|
||||||
<span class="search-context-name">{selectedSource.displayName}</span>
|
<span class="search-context-name">{selectedSource.displayName}</span>
|
||||||
<button class="search-context-change" onclick={() => { step = "source"; results = []; }}>Change</button>
|
<button class="search-context-change" onclick={() => { step = "source"; results = []; }}>Change</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -254,7 +315,7 @@
|
|||||||
onclick={() => selectMatch(m, similarity)}
|
onclick={() => selectMatch(m, similarity)}
|
||||||
disabled={loadingMatchId !== null}>
|
disabled={loadingMatchId !== null}>
|
||||||
<div class="result-cover-wrap">
|
<div class="result-cover-wrap">
|
||||||
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} class="result-cover" />
|
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="result-cover" />
|
||||||
</div>
|
</div>
|
||||||
<div class="result-info">
|
<div class="result-info">
|
||||||
<span class="result-title">{m.title}</span>
|
<span class="result-title">{m.title}</span>
|
||||||
@@ -288,7 +349,7 @@
|
|||||||
<div class="confirm-row">
|
<div class="confirm-row">
|
||||||
<div class="confirm-manga">
|
<div class="confirm-manga">
|
||||||
<div class="confirm-cover-wrap">
|
<div class="confirm-cover-wrap">
|
||||||
<img src={thumbUrl(manga.thumbnailUrl)} alt={manga.title} class="confirm-cover" />
|
<Thumbnail src={manga.thumbnailUrl} alt={manga.title} class="confirm-cover" />
|
||||||
</div>
|
</div>
|
||||||
<p class="confirm-title">{manga.title}</p>
|
<p class="confirm-title">{manga.title}</p>
|
||||||
<p class="confirm-source">{manga.source?.displayName ?? "Unknown"}</p>
|
<p class="confirm-source">{manga.source?.displayName ?? "Unknown"}</p>
|
||||||
@@ -301,7 +362,7 @@
|
|||||||
|
|
||||||
<div class="confirm-manga">
|
<div class="confirm-manga">
|
||||||
<div class="confirm-cover-wrap">
|
<div class="confirm-cover-wrap">
|
||||||
<img src={thumbUrl(selectedMatch.manga.thumbnailUrl)} alt={selectedMatch.manga.title} class="confirm-cover" />
|
<Thumbnail src={selectedMatch.manga.thumbnailUrl} alt={selectedMatch.manga.title} class="confirm-cover" />
|
||||||
</div>
|
</div>
|
||||||
<p class="confirm-title">{selectedMatch.manga.title}</p>
|
<p class="confirm-title">{selectedMatch.manga.title}</p>
|
||||||
<p class="confirm-source">{selectedSource?.displayName ?? "Unknown"}</p>
|
<p class="confirm-source">{selectedSource?.displayName ?? "Unknown"}</p>
|
||||||
@@ -393,17 +454,29 @@
|
|||||||
.source-row { display: flex; align-items: center; gap: var(--sp-3); padding: 9px var(--sp-3); border-radius: var(--radius-md); border: 1px solid transparent; background: none; text-align: left; width: 100%; cursor: pointer; transition: background var(--t-fast), border-color var(--t-fast); }
|
.source-row { display: flex; align-items: center; gap: var(--sp-3); padding: 9px var(--sp-3); border-radius: var(--radius-md); border: 1px solid transparent; background: none; text-align: left; width: 100%; cursor: pointer; transition: background var(--t-fast), border-color var(--t-fast); }
|
||||||
.source-row:hover { background: var(--bg-raised); border-color: var(--border-dim); }
|
.source-row:hover { background: var(--bg-raised); border-color: var(--border-dim); }
|
||||||
.source-row-active { background: var(--accent-muted); border-color: var(--accent-dim); }
|
.source-row-active { background: var(--accent-muted); border-color: var(--accent-dim); }
|
||||||
.source-icon { width: 28px; height: 28px; border-radius: var(--radius-md); object-fit: cover; flex-shrink: 0; background: var(--bg-raised); }
|
:global(.source-icon) { width: 28px; height: 28px; border-radius: var(--radius-md); object-fit: cover; flex-shrink: 0; background: var(--bg-raised); }
|
||||||
.source-info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; }
|
.source-info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; }
|
||||||
.source-name { font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
.source-name { font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
.source-meta { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
.source-meta { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||||
: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); }
|
||||||
.source-row:hover :global(.source-arrow) { opacity: 1; }
|
.source-row:hover :global(.source-arrow) { opacity: 1; }
|
||||||
|
|
||||||
|
/* Lang filter bar */
|
||||||
|
.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-chip:last-child { margin-right: var(--sp-1); }
|
||||||
|
.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); }
|
||||||
|
.src-lang-chip-active:hover { color: var(--accent-fg); border-color: var(--accent); }
|
||||||
|
|
||||||
/* Search step */
|
/* Search step */
|
||||||
.search-step { flex: 1; overflow: hidden; display: flex; flex-direction: column; gap: var(--sp-3); padding: var(--sp-3) var(--sp-4); }
|
.search-step { flex: 1; overflow: hidden; display: flex; flex-direction: column; gap: var(--sp-3); padding: var(--sp-3) var(--sp-4); }
|
||||||
.search-context { display: flex; align-items: center; gap: var(--sp-2); padding: var(--sp-2) var(--sp-3); background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); flex-shrink: 0; }
|
.search-context { display: flex; align-items: center; gap: var(--sp-2); padding: var(--sp-2) var(--sp-3); background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); flex-shrink: 0; }
|
||||||
.search-context-icon { width: 18px; height: 18px; border-radius: var(--radius-sm); object-fit: cover; flex-shrink: 0; }
|
:global(.search-context-icon) { width: 18px; height: 18px; border-radius: var(--radius-sm); object-fit: cover; flex-shrink: 0; }
|
||||||
.search-context-name { flex: 1; font-size: var(--text-sm); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
.search-context-name { flex: 1; font-size: var(--text-sm); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
.search-context-change { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--accent-fg); background: none; border: none; cursor: pointer; padding: 0; flex-shrink: 0; transition: opacity var(--t-base); }
|
.search-context-change { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--accent-fg); background: none; border: none; cursor: pointer; padding: 0; flex-shrink: 0; transition: opacity var(--t-base); }
|
||||||
.search-context-change:hover { opacity: 0.75; }
|
.search-context-change:hover { opacity: 0.75; }
|
||||||
@@ -421,7 +494,7 @@
|
|||||||
.result-row:hover:not(:disabled) { background: var(--bg-raised); }
|
.result-row:hover:not(:disabled) { background: var(--bg-raised); }
|
||||||
.result-row:disabled { opacity: 0.5; cursor: default; }
|
.result-row:disabled { opacity: 0.5; cursor: default; }
|
||||||
.result-cover-wrap { width: 36px; height: 54px; border-radius: var(--radius-sm); overflow: hidden; background: var(--bg-raised); border: 1px solid var(--border-dim); flex-shrink: 0; }
|
.result-cover-wrap { width: 36px; height: 54px; border-radius: var(--radius-sm); overflow: hidden; background: var(--bg-raised); border: 1px solid var(--border-dim); flex-shrink: 0; }
|
||||||
.result-cover { width: 100%; height: 100%; object-fit: cover; }
|
:global(.result-cover) { width: 100%; height: 100%; object-fit: cover; }
|
||||||
.result-info { flex: 1; display: flex; flex-direction: column; gap: 4px; overflow: hidden; min-width: 0; }
|
.result-info { flex: 1; display: flex; flex-direction: column; gap: 4px; overflow: hidden; min-width: 0; }
|
||||||
.result-title { font-size: var(--text-sm); color: var(--text-secondary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.result-title { font-size: var(--text-sm); color: var(--text-secondary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.result-meta { display: flex; align-items: center; gap: var(--sp-2); }
|
.result-meta { display: flex; align-items: center; gap: var(--sp-2); }
|
||||||
@@ -441,7 +514,7 @@
|
|||||||
.confirm-row { display: flex; align-items: center; justify-content: center; gap: var(--sp-4); }
|
.confirm-row { display: flex; align-items: center; justify-content: center; gap: var(--sp-4); }
|
||||||
.confirm-manga { display: flex; flex-direction: column; align-items: center; gap: var(--sp-2); flex: 1; max-width: 160px; }
|
.confirm-manga { display: flex; flex-direction: column; align-items: center; gap: var(--sp-2); flex: 1; max-width: 160px; }
|
||||||
.confirm-cover-wrap { width: 100%; aspect-ratio: 2/3; border-radius: var(--radius-md); overflow: hidden; background: var(--bg-raised); border: 1px solid var(--border-dim); }
|
.confirm-cover-wrap { width: 100%; aspect-ratio: 2/3; border-radius: var(--radius-md); overflow: hidden; background: var(--bg-raised); border: 1px solid var(--border-dim); }
|
||||||
.confirm-cover { width: 100%; height: 100%; object-fit: cover; }
|
:global(.confirm-cover) { width: 100%; height: 100%; object-fit: cover; }
|
||||||
.confirm-title { font-size: var(--text-xs); color: var(--text-secondary); text-align: center; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; line-height: var(--leading-snug); }
|
.confirm-title { font-size: var(--text-xs); color: var(--text-secondary); text-align: center; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; line-height: var(--leading-snug); }
|
||||||
.confirm-source { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); text-align: center; }
|
.confirm-source { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); text-align: center; }
|
||||||
.confirm-divider { display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
|
.confirm-divider { display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
|
||||||
+485
-218
@@ -1,15 +1,20 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount, untrack } from "svelte";
|
import { onMount, untrack } from "svelte";
|
||||||
import { ArrowLeft, BookmarkSimple, Download, CheckCircle, Circle, ArrowSquareOut, CircleNotch, Play, SortAscending, SortDescending, CaretDown, ArrowsClockwise, List, SquaresFour, FolderSimplePlus, Trash, DownloadSimple, X, LinkSimpleHorizontalBreak, ChartLineUp } from "phosphor-svelte";
|
import { ArrowLeft, BookmarkSimple, Download, CheckCircle, Circle, ArrowSquareOut, CircleNotch, Play, SortAscending, SortDescending, CaretDown, ArrowsClockwise, List, SquaresFour, FolderSimplePlus, Trash, DownloadSimple, X, LinkSimpleHorizontalBreak, ChartLineUp, MagnifyingGlass, Gear, Eye, MapPin, Funnel, Check } from "phosphor-svelte";
|
||||||
import { gql, thumbUrl } from "../../lib/client";
|
import { gql } from "../../lib/client";
|
||||||
|
import Thumbnail from "../shared/Thumbnail.svelte";
|
||||||
import { GET_MANGA, GET_CHAPTERS, FETCH_CHAPTERS, ENQUEUE_DOWNLOAD, UPDATE_MANGA, MARK_CHAPTER_READ, MARK_CHAPTERS_READ, DELETE_DOWNLOADED_CHAPTERS, ENQUEUE_CHAPTERS_DOWNLOAD, GET_ALL_MANGA, GET_CATEGORIES, CREATE_CATEGORY, UPDATE_MANGA_CATEGORIES } from "../../lib/queries";
|
import { GET_MANGA, GET_CHAPTERS, FETCH_CHAPTERS, ENQUEUE_DOWNLOAD, UPDATE_MANGA, MARK_CHAPTER_READ, MARK_CHAPTERS_READ, DELETE_DOWNLOADED_CHAPTERS, ENQUEUE_CHAPTERS_DOWNLOAD, GET_ALL_MANGA, GET_CATEGORIES, CREATE_CATEGORY, UPDATE_MANGA_CATEGORIES } from "../../lib/queries";
|
||||||
import { cache, CACHE_KEYS, recordSourceAccess } from "../../lib/cache";
|
import { cache, CACHE_KEYS, recordSourceAccess } from "../../lib/cache";
|
||||||
import { dedupeMangaById, dedupeMangaByTitle } from "../../lib/util";
|
import { dedupeMangaById, dedupeMangaByTitle } from "../../lib/util";
|
||||||
import { store, addToast, updateSettings, openReader, setActiveManga, setGenreFilter, setNavPage, linkManga, unlinkManga, checkAndMarkCompleted as storeCheckAndMarkCompleted } from "../../store/state.svelte";
|
import { store, addToast, updateSettings, openReader, setActiveManga, setGenreFilter, setNavPage, linkManga, unlinkManga, setPreviewManga, checkAndMarkCompleted as storeCheckAndMarkCompleted, clearMarkersForManga } from "../../store/state.svelte";
|
||||||
|
import type { MangaPrefs } from "../../store/state.svelte";
|
||||||
|
import { DEFAULT_MANGA_PREFS } from "../../store/state.svelte";
|
||||||
import type { Manga, Chapter, Category } from "../../lib/types";
|
import type { Manga, Chapter, Category } from "../../lib/types";
|
||||||
import ContextMenu, { type MenuEntry } from "../shared/ContextMenu.svelte";
|
import ContextMenu, { type MenuEntry } from "../shared/ContextMenu.svelte";
|
||||||
import MigrateModal from "./MigrateModal.svelte";
|
import MigrateModal from "./MigrateModal.svelte";
|
||||||
import TrackingPanel from "../shared/TrackingPanel.svelte";
|
import TrackingPanel from "./TrackingPanel.svelte";
|
||||||
|
import AutomationPanel from "./AutomationPanel.svelte";
|
||||||
|
import MarkersPanel from "./MarkersPanel.svelte";
|
||||||
|
|
||||||
const CHAPTERS_PER_PAGE = 25;
|
const CHAPTERS_PER_PAGE = 25;
|
||||||
const MANGA_TTL_MS = 5 * 60 * 1000;
|
const MANGA_TTL_MS = 5 * 60 * 1000;
|
||||||
@@ -18,47 +23,188 @@
|
|||||||
const mangaStore: Map<number, { data: Manga; fetchedAt: number }> = new Map();
|
const mangaStore: Map<number, { data: Manga; fetchedAt: number }> = new Map();
|
||||||
const chapterStore: Map<number, { data: Chapter[]; fetchedAt: number }> = new Map();
|
const chapterStore: Map<number, { data: Chapter[]; fetchedAt: number }> = new Map();
|
||||||
|
|
||||||
let manga: Manga | null = $state(null);
|
let manga: Manga | null = $state(null);
|
||||||
let chapters: Chapter[] = $state([]);
|
let chapters: Chapter[] = $state([]);
|
||||||
let loadingManga: boolean = $state(false);
|
let loadingManga: boolean = $state(false);
|
||||||
let loadingChapters: boolean = $state(true);
|
let loadingChapters: boolean = $state(true);
|
||||||
let enqueueing: Set<number> = $state(new Set());
|
let enqueueing: Set<number> = $state(new Set());
|
||||||
let dlOpen: boolean = $state(false);
|
let dlOpen: boolean = $state(false);
|
||||||
let detailsOpen: boolean = $state(false);
|
let manageOpen: boolean = $state(false);
|
||||||
let togglingLibrary: boolean = $state(false);
|
let togglingLibrary: boolean = $state(false);
|
||||||
let chapterPage: number = $state(1);
|
let chapterPage: number = $state(1);
|
||||||
let ctx: { x: number; y: number; chapter: Chapter; idx: number } | null = $state(null);
|
let ctx: { x: number; y: number; chapter: Chapter; idx: number } | null = $state(null);
|
||||||
let jumpOpen: boolean = $state(false);
|
let jumpOpen: boolean = $state(false);
|
||||||
let jumpInput: string = $state("");
|
let jumpInput: string = $state("");
|
||||||
let viewMode: "list" | "grid" = $state("list");
|
let viewMode: "list" | "grid" = $state("list");
|
||||||
let deletingAll: boolean = $state(false);
|
let deletingAll: boolean = $state(false);
|
||||||
let refreshing: boolean = $state(false);
|
let refreshing: boolean = $state(false);
|
||||||
let genresExpanded: boolean = $state(false);
|
let genresExpanded: boolean = $state(false);
|
||||||
let folderPickerOpen: boolean = $state(false);
|
let folderPickerOpen: boolean = $state(false);
|
||||||
let folderCreating: boolean = $state(false);
|
let folderCreating: boolean = $state(false);
|
||||||
let folderNewName: string = $state("");
|
let folderNewName: string = $state("");
|
||||||
let mangaCategories: Category[] = $state([]);
|
let mangaCategories: Category[] = $state([]);
|
||||||
let allCategories: Category[] = $state([]);
|
let allCategories: Category[] = $state([]);
|
||||||
let catsLoading: boolean = $state(false);
|
let catsLoading: boolean = $state(false);
|
||||||
let rangeFrom: string = $state("");
|
let rangeFrom: string = $state("");
|
||||||
let rangeTo: string = $state("");
|
let rangeTo: string = $state("");
|
||||||
let showRange: boolean = $state(false);
|
let showRange: boolean = $state(false);
|
||||||
let migrateOpen: boolean = $state(false);
|
let migrateOpen: boolean = $state(false);
|
||||||
let dlDropRef: HTMLDivElement | undefined = $state();
|
let autoOpen: boolean = $state(false);
|
||||||
let folderPickerRef: HTMLDivElement | undefined = $state();
|
let trackingOpen: boolean = $state(false);
|
||||||
|
let markersOpen: boolean = $state(false);
|
||||||
|
let linkPickerOpen: boolean = $state(false);
|
||||||
|
let linkSearch: string = $state("");
|
||||||
|
let allMangaForLink: Manga[] = $state([]);
|
||||||
|
let loadingLinkList: boolean = $state(false);
|
||||||
|
let selectedIds: Set<number> = $state(new Set());
|
||||||
|
let sortMenuOpen: boolean = $state(false);
|
||||||
|
let scanFilterOpen: boolean = $state(false);
|
||||||
|
let dlDropRef: HTMLDivElement | undefined = $state();
|
||||||
|
let folderPickerRef: HTMLDivElement | undefined = $state();
|
||||||
|
let mangaAbort: AbortController | null = null;
|
||||||
|
let chapterAbort: AbortController | null = null;
|
||||||
|
let loadingFor: number | null = null;
|
||||||
|
let _prevChapterIds: Set<number> = new Set();
|
||||||
|
|
||||||
// Series link state
|
function focusOnMount(node: HTMLElement) { node.focus(); }
|
||||||
let linkPickerOpen: boolean = $state(false);
|
|
||||||
let linkSearch: string = $state("");
|
|
||||||
let allMangaForLink: Manga[] = $state([]);
|
|
||||||
let loadingLinkList: boolean = $state(false);
|
|
||||||
|
|
||||||
// Tracking modal
|
const mangaPrefs = $derived.by((): Partial<MangaPrefs> => {
|
||||||
let trackingOpen: boolean = $state(false);
|
if (!store.activeManga) return {};
|
||||||
|
return store.settings.mangaPrefs?.[store.activeManga.id] ?? {};
|
||||||
|
});
|
||||||
|
|
||||||
let mangaAbort: AbortController | null = null;
|
function getPref<K extends keyof MangaPrefs>(key: K): MangaPrefs[K] {
|
||||||
let chapterAbort: AbortController | null = null;
|
return (mangaPrefs[key] ?? DEFAULT_MANGA_PREFS[key]) as MangaPrefs[K];
|
||||||
let loadingFor: number | null = null;
|
}
|
||||||
|
|
||||||
|
function setPref<K extends keyof MangaPrefs>(key: K, value: MangaPrefs[K]) {
|
||||||
|
const id = store.activeManga?.id;
|
||||||
|
if (!id) return;
|
||||||
|
updateSettings({
|
||||||
|
mangaPrefs: {
|
||||||
|
...store.settings.mangaPrefs,
|
||||||
|
[id]: { ...(store.settings.mangaPrefs?.[id] ?? {}), [key]: value },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasSelection = $derived(selectedIds.size > 0);
|
||||||
|
|
||||||
|
const sortDir = $derived(store.settings.chapterSortDir);
|
||||||
|
const sortMode = $derived(store.settings.chapterSortMode ?? "source");
|
||||||
|
|
||||||
|
const availableScanlators = $derived(
|
||||||
|
[...new Set(chapters.map(c => c.scanlator).filter((s): s is string => !!s?.trim()))]
|
||||||
|
.sort((a, b) => a.localeCompare(b))
|
||||||
|
);
|
||||||
|
|
||||||
|
const scanlatorFilter = $derived((getPref("scanlatorFilter") ?? []) as string[]);
|
||||||
|
|
||||||
|
const sortedChapters = $derived.by(() => {
|
||||||
|
let base = [...chapters];
|
||||||
|
|
||||||
|
if (sortMode === "chapterNumber") base.sort((a, b) => a.chapterNumber - b.chapterNumber);
|
||||||
|
else if (sortMode === "uploadDate") base.sort((a, b) => Number(a.uploadDate ?? 0) - Number(b.uploadDate ?? 0));
|
||||||
|
else base.sort((a, b) => a.sourceOrder - b.sourceOrder);
|
||||||
|
|
||||||
|
const preferred = getPref("preferredScanlator");
|
||||||
|
if (preferred) {
|
||||||
|
const pref: Chapter[] = [], rest: Chapter[] = [];
|
||||||
|
for (const c of base) (c.scanlator === preferred ? pref : rest).push(c);
|
||||||
|
base = [...pref, ...rest];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scanlatorFilter.length > 0) {
|
||||||
|
const seen = new Map<number, Chapter>();
|
||||||
|
for (const ch of base) {
|
||||||
|
const existing = seen.get(ch.chapterNumber);
|
||||||
|
if (!existing) {
|
||||||
|
seen.set(ch.chapterNumber, ch);
|
||||||
|
} else {
|
||||||
|
const np = scanlatorFilter.indexOf(ch.scanlator ?? "");
|
||||||
|
const op = scanlatorFilter.indexOf(existing.scanlator ?? "");
|
||||||
|
if (np !== -1 && (op === -1 || np < op)) seen.set(ch.chapterNumber, ch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
base = [...seen.values()];
|
||||||
|
if (sortMode === "chapterNumber") base.sort((a, b) => a.chapterNumber - b.chapterNumber);
|
||||||
|
else if (sortMode === "uploadDate") base.sort((a, b) => Number(a.uploadDate ?? 0) - Number(b.uploadDate ?? 0));
|
||||||
|
else base.sort((a, b) => a.sourceOrder - b.sourceOrder);
|
||||||
|
}
|
||||||
|
|
||||||
|
return sortDir === "desc" ? base.reverse() : base;
|
||||||
|
});
|
||||||
|
|
||||||
|
const chaptersAsc = $derived([...chapters].sort((a, b) => a.sourceOrder - b.sourceOrder));
|
||||||
|
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 readCount = $derived(chapters.filter(c => c.isRead).length);
|
||||||
|
const totalCount = $derived(chapters.length);
|
||||||
|
const progressPct = $derived(totalCount > 0 ? (readCount / totalCount) * 100 : 0);
|
||||||
|
const downloadedCount = $derived(chapters.filter(c => c.isDownloaded).length);
|
||||||
|
const statusLabel = $derived(manga?.status ? manga.status.charAt(0) + manga.status.slice(1).toLowerCase() : null);
|
||||||
|
const assignedFolders = $derived(mangaCategories.filter(c => c.id !== 0));
|
||||||
|
const hasFolders = $derived(assignedFolders.length > 0);
|
||||||
|
|
||||||
|
const continueChapter = $derived((() => {
|
||||||
|
if (!sortedChapters.length) return null;
|
||||||
|
const asc = [...sortedChapters].sort((a, b) => a.sourceOrder - b.sourceOrder);
|
||||||
|
const anyRead = asc.some(c => c.isRead);
|
||||||
|
const inProgress = asc.find(c => !c.isRead && (c.lastPageRead ?? 0) > 0);
|
||||||
|
if (inProgress) return { chapter: inProgress, type: "continue" as const };
|
||||||
|
const firstUnread = asc.find(c => !c.isRead);
|
||||||
|
if (firstUnread) return { chapter: firstUnread, type: (anyRead ? "continue" : "start") as const };
|
||||||
|
return { chapter: asc[0], type: "reread" as const };
|
||||||
|
})());
|
||||||
|
|
||||||
|
const jumpChapter = $derived.by(() => {
|
||||||
|
const q = jumpInput.trim().toLowerCase();
|
||||||
|
if (!q) return null;
|
||||||
|
const num = parseFloat(q);
|
||||||
|
if (!isNaN(num)) return sortedChapters.find(c => c.chapterNumber === num) ?? null;
|
||||||
|
return sortedChapters.find(c => c.name.toLowerCase().includes(q)) ?? null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const hasAnyAutomation = $derived(
|
||||||
|
getPref("autoDownload") ||
|
||||||
|
getPref("downloadAhead") > 0 ||
|
||||||
|
getPref("maxKeepChapters") > 0 ||
|
||||||
|
getPref("deleteOnRead") ||
|
||||||
|
getPref("pauseUpdates") ||
|
||||||
|
getPref("refreshInterval") !== "global" ||
|
||||||
|
!!getPref("preferredScanlator")
|
||||||
|
);
|
||||||
|
|
||||||
|
const linkedIds = $derived(
|
||||||
|
store.activeManga ? (store.settings.mangaLinks?.[store.activeManga.id] ?? []) : []
|
||||||
|
);
|
||||||
|
|
||||||
|
const linkPickerResults = $derived.by(() => {
|
||||||
|
const id = store.activeManga?.id;
|
||||||
|
const others = allMangaForLink.filter(m => m.id !== id);
|
||||||
|
const q = linkSearch.trim().toLowerCase();
|
||||||
|
const filtered = q ? others.filter(m => m.title.toLowerCase().includes(q)) : others;
|
||||||
|
const linked = filtered.filter(m => linkedIds.includes(m.id));
|
||||||
|
const rest = filtered.filter(m => !linkedIds.includes(m.id)).slice(0, 30);
|
||||||
|
return [...linked, ...rest];
|
||||||
|
});
|
||||||
|
|
||||||
|
function doJump() {
|
||||||
|
if (!jumpChapter) return;
|
||||||
|
const pageIdx = sortedChapters.indexOf(jumpChapter);
|
||||||
|
if (pageIdx >= 0) chapterPage = Math.floor(pageIdx / CHAPTERS_PER_PAGE) + 1;
|
||||||
|
jumpOpen = false;
|
||||||
|
jumpInput = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSelect(id: number, e: MouseEvent | KeyboardEvent) {
|
||||||
|
e.stopPropagation();
|
||||||
|
const next = new Set(selectedIds);
|
||||||
|
if (next.has(id)) next.delete(id); else next.add(id);
|
||||||
|
selectedIds = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSelection() { selectedIds = new Set(); }
|
||||||
|
|
||||||
function formatDate(ts: string | null | undefined): string {
|
function formatDate(ts: string | null | undefined): string {
|
||||||
if (!ts) return "";
|
if (!ts) return "";
|
||||||
@@ -68,56 +214,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function applyChapters(nodes: Chapter[]) {
|
function applyChapters(nodes: Chapter[]) {
|
||||||
|
if (getPref("autoDownload") && _prevChapterIds.size > 0) {
|
||||||
|
const newChapters = nodes.filter(c => !_prevChapterIds.has(c.id) && !c.isDownloaded);
|
||||||
|
if (newChapters.length) enqueueMultiple(newChapters.map(c => c.id));
|
||||||
|
}
|
||||||
|
_prevChapterIds = new Set(nodes.map(c => c.id));
|
||||||
chapters = nodes;
|
chapters = nodes;
|
||||||
if (store.activeManga && nodes.length > 0) checkAndMarkCompleted(store.activeManga.id, nodes);
|
if (store.activeManga && nodes.length > 0) checkAndMarkCompleted(store.activeManga.id, nodes);
|
||||||
}
|
}
|
||||||
|
|
||||||
const sortDir = $derived(store.settings.chapterSortDir);
|
|
||||||
const sortMode = $derived(store.settings.chapterSortMode ?? "source");
|
|
||||||
let sortMenuOpen = $state(false);
|
|
||||||
|
|
||||||
const sortedChapters = $derived.by(() => {
|
|
||||||
const base = [...chapters];
|
|
||||||
if (sortMode === "chapterNumber") {
|
|
||||||
base.sort((a, b) => a.chapterNumber - b.chapterNumber);
|
|
||||||
} else if (sortMode === "uploadDate") {
|
|
||||||
base.sort((a, b) => Number(a.uploadDate ?? 0) - Number(b.uploadDate ?? 0));
|
|
||||||
} else {
|
|
||||||
base.sort((a, b) => a.sourceOrder - b.sourceOrder);
|
|
||||||
}
|
|
||||||
return sortDir === "desc" ? base.reverse() : base;
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Chapter list in canonical reading order (ch1 -> ch2 -> ch3).
|
|
||||||
* Always passed to openReader so the Reader's idx-based prev/next
|
|
||||||
* navigation is direction-independent of the user's display sort.
|
|
||||||
*/
|
|
||||||
const chaptersAsc = $derived(
|
|
||||||
[...chapters].sort((a, b) => a.sourceOrder - b.sourceOrder)
|
|
||||||
);
|
|
||||||
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 readCount = $derived(chapters.filter(c => c.isRead).length);
|
|
||||||
const totalCount = $derived(chapters.length);
|
|
||||||
const progressPct = $derived(totalCount > 0 ? (readCount / totalCount) * 100 : 0);
|
|
||||||
const downloadedCount = $derived(chapters.filter(c => c.isDownloaded).length);
|
|
||||||
|
|
||||||
const continueChapter = $derived((() => {
|
|
||||||
if (!chapters.length) return null;
|
|
||||||
const asc = [...chapters].sort((a, b) => a.sourceOrder - b.sourceOrder);
|
|
||||||
const anyRead = asc.some(c => c.isRead);
|
|
||||||
const inProgress = asc.find(c => !c.isRead && (c.lastPageRead ?? 0) > 0);
|
|
||||||
if (inProgress) return { chapter: inProgress, type: "continue" as const };
|
|
||||||
const firstUnread = asc.find(c => !c.isRead);
|
|
||||||
if (firstUnread) return { chapter: firstUnread, type: (anyRead ? "continue" : "start") as const };
|
|
||||||
return { chapter: asc[0], type: "reread" as const };
|
|
||||||
})());
|
|
||||||
|
|
||||||
const statusLabel = $derived(manga?.status ? manga.status.charAt(0) + manga.status.slice(1).toLowerCase() : null);
|
|
||||||
const assignedFolders = $derived(mangaCategories.filter(c => c.id !== 0));
|
|
||||||
const hasFolders = $derived(assignedFolders.length > 0);
|
|
||||||
|
|
||||||
function loadCategories(mangaId: number) {
|
function loadCategories(mangaId: number) {
|
||||||
catsLoading = true;
|
catsLoading = true;
|
||||||
gql<{ categories: { nodes: Category[] } }>(GET_CATEGORIES)
|
gql<{ categories: { nodes: Category[] } }>(GET_CATEGORIES)
|
||||||
@@ -131,7 +236,6 @@
|
|||||||
|
|
||||||
async function checkAndMarkCompleted(mangaId: number, chaps: Chapter[]) {
|
async function checkAndMarkCompleted(mangaId: number, chaps: Chapter[]) {
|
||||||
await storeCheckAndMarkCompleted(mangaId, chaps, allCategories, gql, UPDATE_MANGA_CATEGORIES, UPDATE_MANGA);
|
await storeCheckAndMarkCompleted(mangaId, chaps, allCategories, gql, UPDATE_MANGA_CATEGORIES, UPDATE_MANGA);
|
||||||
// Sync local mangaCategories state after the mutation
|
|
||||||
if (chaps.length) {
|
if (chaps.length) {
|
||||||
const allRead = chaps.every(c => c.isRead);
|
const allRead = chaps.every(c => c.isRead);
|
||||||
const completed = allCategories.find(c => c.name === "Completed");
|
const completed = allCategories.find(c => c.name === "Completed");
|
||||||
@@ -215,6 +319,27 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (dlOpen) { setTimeout(() => document.addEventListener("mousedown", handleDlOutside, true), 0); }
|
||||||
|
else document.removeEventListener("mousedown", handleDlOutside, true);
|
||||||
|
});
|
||||||
|
$effect(() => {
|
||||||
|
if (folderPickerOpen) { setTimeout(() => document.addEventListener("mousedown", handleFolderOutside, true), 0); }
|
||||||
|
else document.removeEventListener("mousedown", handleFolderOutside, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleDlOutside(e: MouseEvent) { if (dlDropRef && !dlDropRef.contains(e.target as Node)) dlOpen = false; }
|
||||||
|
function handleFolderOutside(e: MouseEvent) { if (folderPickerRef && !folderPickerRef.contains(e.target as Node)) { folderPickerOpen = false; folderCreating = false; folderNewName = ""; } }
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!scanFilterOpen) return;
|
||||||
|
function onOutside(e: MouseEvent) {
|
||||||
|
if (!(e.target as HTMLElement).closest(".scan-filter-wrap")) scanFilterOpen = false;
|
||||||
|
}
|
||||||
|
setTimeout(() => document.addEventListener("mousedown", onOutside, true), 0);
|
||||||
|
return () => document.removeEventListener("mousedown", onOutside, true);
|
||||||
|
});
|
||||||
|
|
||||||
async function toggleLibrary() {
|
async function toggleLibrary() {
|
||||||
if (!manga) return;
|
if (!manga) return;
|
||||||
togglingLibrary = true;
|
togglingLibrary = true;
|
||||||
@@ -252,6 +377,24 @@
|
|||||||
await gql(MARK_CHAPTER_READ, { id: chapterId, isRead }).catch(console.error);
|
await gql(MARK_CHAPTER_READ, { id: chapterId, isRead }).catch(console.error);
|
||||||
chapters = chapters.map(c => c.id === chapterId ? { ...c, isRead } : c);
|
chapters = chapters.map(c => c.id === chapterId ? { ...c, isRead } : c);
|
||||||
if (store.activeManga) { chapterStore.set(store.activeManga.id, { data: chapters, fetchedAt: Date.now() }); checkAndMarkCompleted(store.activeManga.id, chapters); }
|
if (store.activeManga) { chapterStore.set(store.activeManga.id, { data: chapters, fetchedAt: Date.now() }); checkAndMarkCompleted(store.activeManga.id, chapters); }
|
||||||
|
if (isRead) {
|
||||||
|
if (getPref("deleteOnRead")) {
|
||||||
|
const ch = chapters.find(c => c.id === chapterId);
|
||||||
|
if (ch?.isDownloaded) {
|
||||||
|
const delayMs = getPref("deleteDelayHours") * 60 * 60 * 1000;
|
||||||
|
if (delayMs === 0) deleteDownloaded(chapterId);
|
||||||
|
else setTimeout(() => deleteDownloaded(chapterId), delayMs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const ahead = getPref("downloadAhead");
|
||||||
|
if (ahead > 0) {
|
||||||
|
const idx = sortedChapters.findIndex(c => c.id === chapterId);
|
||||||
|
if (idx >= 0) {
|
||||||
|
const toQueue = sortedChapters.slice(idx + 1, idx + 1 + ahead).filter(c => !c.isDownloaded).map(c => c.id);
|
||||||
|
if (toQueue.length) enqueueMultiple(toQueue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function markBulk(ids: number[], isRead: boolean) {
|
async function markBulk(ids: number[], isRead: boolean) {
|
||||||
@@ -260,6 +403,40 @@
|
|||||||
const idSet = new Set(ids);
|
const idSet = new Set(ids);
|
||||||
chapters = chapters.map(c => idSet.has(c.id) ? { ...c, isRead } : c);
|
chapters = chapters.map(c => idSet.has(c.id) ? { ...c, isRead } : c);
|
||||||
if (store.activeManga) { chapterStore.set(store.activeManga.id, { data: chapters, fetchedAt: Date.now() }); checkAndMarkCompleted(store.activeManga.id, chapters); }
|
if (store.activeManga) { chapterStore.set(store.activeManga.id, { data: chapters, fetchedAt: Date.now() }); checkAndMarkCompleted(store.activeManga.id, chapters); }
|
||||||
|
if (isRead && getPref("deleteOnRead")) {
|
||||||
|
const toDelete = ids.filter(id => chapters.find(c => c.id === id)?.isDownloaded);
|
||||||
|
if (toDelete.length) {
|
||||||
|
const delayMs = getPref("deleteDelayHours") * 60 * 60 * 1000;
|
||||||
|
const doDelete = async () => {
|
||||||
|
await gql(DELETE_DOWNLOADED_CHAPTERS, { ids: toDelete }).catch(console.error);
|
||||||
|
chapters = chapters.map(c => toDelete.includes(c.id) ? { ...c, isDownloaded: false } : c);
|
||||||
|
if (store.activeManga) chapterStore.set(store.activeManga.id, { data: chapters, fetchedAt: Date.now() });
|
||||||
|
};
|
||||||
|
if (delayMs === 0) doDelete();
|
||||||
|
else setTimeout(doDelete, delayMs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteSelected() {
|
||||||
|
const ids = [...selectedIds].filter(id => chapters.find(c => c.id === id)?.isDownloaded);
|
||||||
|
if (ids.length) {
|
||||||
|
await gql(DELETE_DOWNLOADED_CHAPTERS, { ids }).catch(console.error);
|
||||||
|
chapters = chapters.map(c => ids.includes(c.id) ? { ...c, isDownloaded: false } : c);
|
||||||
|
if (store.activeManga) chapterStore.set(store.activeManga.id, { data: chapters, fetchedAt: Date.now() });
|
||||||
|
}
|
||||||
|
clearSelection();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadSelected() {
|
||||||
|
const ids = [...selectedIds].filter(id => !chapters.find(c => c.id === id)?.isDownloaded);
|
||||||
|
await enqueueMultiple(ids);
|
||||||
|
clearSelection();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function markSelectedRead(isRead: boolean) {
|
||||||
|
await markBulk([...selectedIds], isRead);
|
||||||
|
clearSelection();
|
||||||
}
|
}
|
||||||
|
|
||||||
const markAboveRead = (i: number) => markBulk(sortedChapters.slice(0, i + 1).filter(c => !c.isRead).map(c => c.id), true);
|
const markAboveRead = (i: number) => markBulk(sortedChapters.slice(0, i + 1).filter(c => !c.isRead).map(c => c.id), true);
|
||||||
@@ -312,18 +489,6 @@
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleDlOutside(e: MouseEvent) { if (dlDropRef && !dlDropRef.contains(e.target as Node)) dlOpen = false; }
|
|
||||||
function handleFolderOutside(e: MouseEvent) { if (folderPickerRef && !folderPickerRef.contains(e.target as Node)) { folderPickerOpen = false; folderCreating = false; folderNewName = ""; } }
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (dlOpen) { setTimeout(() => document.addEventListener("mousedown", handleDlOutside, true), 0); }
|
|
||||||
else document.removeEventListener("mousedown", handleDlOutside, true);
|
|
||||||
});
|
|
||||||
$effect(() => {
|
|
||||||
if (folderPickerOpen) { setTimeout(() => document.addEventListener("mousedown", handleFolderOutside, true), 0); }
|
|
||||||
else document.removeEventListener("mousedown", handleFolderOutside, true);
|
|
||||||
});
|
|
||||||
|
|
||||||
function enqueueNext(n: number) {
|
function enqueueNext(n: number) {
|
||||||
if (!continueChapter) return;
|
if (!continueChapter) return;
|
||||||
const idx = sortedChapters.indexOf(continueChapter.chapter);
|
const idx = sortedChapters.indexOf(continueChapter.chapter);
|
||||||
@@ -360,29 +525,21 @@
|
|||||||
addTo: inCat ? [] : [cat.id],
|
addTo: inCat ? [] : [cat.id],
|
||||||
removeFrom: inCat ? [cat.id] : [],
|
removeFrom: inCat ? [cat.id] : [],
|
||||||
});
|
});
|
||||||
mangaCategories = inCat
|
mangaCategories = inCat ? mangaCategories.filter(c => c.id !== cat.id) : [...mangaCategories, cat];
|
||||||
? mangaCategories.filter(c => c.id !== cat.id)
|
|
||||||
: [...mangaCategories, cat];
|
|
||||||
} catch (e) { console.error(e); }
|
} catch (e) { console.error(e); }
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => () => { mangaAbort?.abort(); chapterAbort?.abort(); });
|
function openReaderWithAhead(ch: Chapter, list: Chapter[]) {
|
||||||
|
const ahead = getPref("downloadAhead");
|
||||||
// ── Series link ────────────────────────────────────────────────────────────
|
if (ahead > 0) {
|
||||||
|
const idx = list.indexOf(ch);
|
||||||
const linkedIds = $derived(
|
if (idx >= 0) {
|
||||||
store.activeManga ? (store.settings.mangaLinks?.[store.activeManga.id] ?? []) : []
|
const toQueue = list.slice(idx + 1, idx + 1 + ahead).filter(c => !c.isDownloaded).map(c => c.id);
|
||||||
);
|
if (toQueue.length) enqueueMultiple(toQueue);
|
||||||
|
}
|
||||||
const linkPickerResults = $derived.by(() => {
|
}
|
||||||
const id = store.activeManga?.id;
|
openReader(ch, list);
|
||||||
const others = allMangaForLink.filter(m => m.id !== id);
|
}
|
||||||
const q = linkSearch.trim().toLowerCase();
|
|
||||||
const filtered = q ? others.filter(m => m.title.toLowerCase().includes(q)) : others;
|
|
||||||
const linked = filtered.filter(m => linkedIds.includes(m.id));
|
|
||||||
const rest = filtered.filter(m => !linkedIds.includes(m.id)).slice(0, 30);
|
|
||||||
return [...linked, ...rest];
|
|
||||||
});
|
|
||||||
|
|
||||||
async function openLinkPicker() {
|
async function openLinkPicker() {
|
||||||
linkPickerOpen = true; linkSearch = "";
|
linkPickerOpen = true; linkSearch = "";
|
||||||
@@ -401,23 +558,22 @@
|
|||||||
if (linkedIds.includes(other.id)) unlinkManga(store.activeManga.id, other.id);
|
if (linkedIds.includes(other.id)) unlinkManga(store.activeManga.id, other.id);
|
||||||
else linkManga(store.activeManga.id, other.id);
|
else linkManga(store.activeManga.id, other.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onMount(() => () => { mangaAbort?.abort(); chapterAbort?.abort(); });
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if store.activeManga}
|
{#if store.activeManga}
|
||||||
<div class="root" role="presentation" oncontextmenu={(e) => e.preventDefault()}>
|
<div class="root" role="presentation" oncontextmenu={(e) => e.preventDefault()}>
|
||||||
|
|
||||||
<!-- ── Sidebar ────────────────────────────────────────────────────────── -->
|
|
||||||
<div class="sidebar">
|
<div class="sidebar">
|
||||||
<button class="back" onclick={() => setActiveManga(null)}>
|
<button class="back" onclick={() => setActiveManga(null)}>
|
||||||
<ArrowLeft size={13} weight="light" /> Back
|
<ArrowLeft size={13} weight="light" /> Back
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Zone 1: Cover -->
|
|
||||||
<div class="cover-wrap">
|
<div class="cover-wrap">
|
||||||
<img src={thumbUrl(store.activeManga.thumbnailUrl)} alt={store.activeManga.title} class="cover" />
|
<Thumbnail src={store.activeManga.thumbnailUrl} alt={store.activeManga.title} class="cover" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Zone 2: Meta -->
|
|
||||||
{#if loadingManga}
|
{#if loadingManga}
|
||||||
<div class="meta-skeleton">
|
<div class="meta-skeleton">
|
||||||
<div class="skeleton sk-line" style="width:90%;height:14px"></div>
|
<div class="skeleton sk-line" style="width:90%;height:14px"></div>
|
||||||
@@ -450,10 +606,9 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Zone 3: Primary CTA + library action -->
|
|
||||||
<div class="cta-section">
|
<div class="cta-section">
|
||||||
{#if continueChapter}
|
{#if continueChapter}
|
||||||
<button class="read-btn" onclick={() => openReader(continueChapter!.chapter, chaptersAsc)}>
|
<button class="read-btn" onclick={() => openReaderWithAhead(continueChapter!.chapter, sortedChapters)}>
|
||||||
<Play size={12} weight="fill" />
|
<Play size={12} weight="fill" />
|
||||||
{continueChapter.type === "continue"
|
{continueChapter.type === "continue"
|
||||||
? `Continue · Ch.${continueChapter.chapter.chapterNumber}${(continueChapter.chapter.lastPageRead ?? 0) > 0 ? ` p.${continueChapter.chapter.lastPageRead}` : ""}`
|
? `Continue · Ch.${continueChapter.chapter.chapterNumber}${(continueChapter.chapter.lastPageRead ?? 0) > 0 ? ` p.${continueChapter.chapter.lastPageRead}` : ""}`
|
||||||
@@ -473,7 +628,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Zone 4: Progress -->
|
|
||||||
{#if totalCount > 0}
|
{#if totalCount > 0}
|
||||||
<div class="progress-section">
|
<div class="progress-section">
|
||||||
<div class="progress-header">
|
<div class="progress-header">
|
||||||
@@ -484,38 +638,37 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Zone 5: Details accordion (source info + all secondary actions) -->
|
{#if !loadingManga && manga}
|
||||||
{#if !loadingManga && manga?.source}
|
|
||||||
<div class="details-section">
|
<div class="details-section">
|
||||||
<button class="details-toggle" onclick={() => detailsOpen = !detailsOpen}>
|
<button class="details-toggle" onclick={() => manageOpen = !manageOpen}>
|
||||||
<span>Details</span>
|
<span>Manage</span>
|
||||||
<CaretDown size={11} weight="light" style="transform:{detailsOpen ? 'rotate(180deg)' : 'rotate(0)'};transition:transform 0.15s ease" />
|
<CaretDown size={11} weight="light" style="transform:{manageOpen ? 'rotate(180deg)' : 'rotate(0)'};transition:transform 0.15s ease" />
|
||||||
</button>
|
</button>
|
||||||
{#if detailsOpen}
|
{#if manageOpen}
|
||||||
<div class="details-body">
|
<div class="details-body">
|
||||||
<div class="detail-row"><span class="detail-key">Source</span><span class="detail-val">{manga.source.displayName}</span></div>
|
|
||||||
{#if manga.status}<div class="detail-row"><span class="detail-key">Status</span><span class="detail-val">{statusLabel}</span></div>{/if}
|
|
||||||
{#if manga.author}<div class="detail-row"><span class="detail-key">Author</span><span class="detail-val">{manga.author}</span></div>{/if}
|
|
||||||
{#if manga.artist && manga.artist !== manga.author}<div class="detail-row"><span class="detail-key">Artist</span><span class="detail-val">{manga.artist}</span></div>{/if}
|
|
||||||
|
|
||||||
<div class="detail-actions">
|
<div class="detail-actions">
|
||||||
|
<button class="detail-action-btn" onclick={() => setPreviewManga(manga)}>
|
||||||
|
<Eye size={12} weight="light" /> Preview
|
||||||
|
</button>
|
||||||
<button class="detail-action-btn" onclick={() => migrateOpen = true}>
|
<button class="detail-action-btn" onclick={() => migrateOpen = true}>
|
||||||
<ArrowsClockwise size={12} weight="light" /> Switch Source
|
<ArrowsClockwise size={12} weight="light" /> Switch Source
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button class="detail-action-btn" class:detail-action-active={linkedIds.length > 0} onclick={openLinkPicker}>
|
||||||
class="detail-action-btn"
|
|
||||||
class:detail-action-active={linkedIds.length > 0}
|
|
||||||
onclick={openLinkPicker}
|
|
||||||
>
|
|
||||||
<LinkSimpleHorizontalBreak size={12} weight={linkedIds.length > 0 ? "fill" : "light"} />
|
<LinkSimpleHorizontalBreak size={12} weight={linkedIds.length > 0 ? "fill" : "light"} />
|
||||||
Series Link{linkedIds.length > 0 ? ` (${linkedIds.length})` : ""}
|
Series Link{linkedIds.length > 0 ? ` (${linkedIds.length})` : ""}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button class="detail-action-btn" onclick={() => trackingOpen = true}>
|
||||||
class="detail-action-btn"
|
|
||||||
onclick={() => trackingOpen = true}
|
|
||||||
>
|
|
||||||
<ChartLineUp size={12} weight="light" /> Tracking
|
<ChartLineUp size={12} weight="light" /> Tracking
|
||||||
</button>
|
</button>
|
||||||
|
<button class="detail-action-btn" class:detail-action-active={markersOpen} onclick={() => markersOpen = !markersOpen}>
|
||||||
|
<MapPin size={12} weight={markersOpen ? "fill" : "light"} />
|
||||||
|
Markers{store.activeManga && store.getMarkersForManga(store.activeManga.id).length > 0 ? ` (${store.getMarkersForManga(store.activeManga.id).length})` : ""}
|
||||||
|
</button>
|
||||||
|
{#if manga?.inLibrary}
|
||||||
|
<button class="detail-action-btn" class:detail-action-active={hasAnyAutomation} onclick={() => autoOpen = true}>
|
||||||
|
<Gear size={12} weight={hasAnyAutomation ? "fill" : "light"} /> Automation
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
{#if downloadedCount > 0}
|
{#if downloadedCount > 0}
|
||||||
<button class="detail-action-btn detail-action-danger" onclick={deleteAllDownloads} disabled={deletingAll}>
|
<button class="detail-action-btn detail-action-danger" onclick={deleteAllDownloads} disabled={deletingAll}>
|
||||||
<Trash size={12} weight="light" /> {deletingAll ? "Deleting…" : `Delete Downloads (${downloadedCount})`}
|
<Trash size={12} weight="light" /> {deletingAll ? "Deleting…" : `Delete Downloads (${downloadedCount})`}
|
||||||
@@ -528,42 +681,100 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ── Chapter list ───────────────────────────────────────────────────── -->
|
|
||||||
<div class="list-wrap">
|
<div class="list-wrap">
|
||||||
<div class="list-header">
|
<div class="list-header">
|
||||||
<div class="list-header-left">
|
<div class="list-header-left">
|
||||||
<div class="sort-wrap">
|
{#if hasSelection}
|
||||||
<button class="sort-btn" onclick={() => sortMenuOpen = !sortMenuOpen}>
|
<span class="sel-count">{selectedIds.size} selected</span>
|
||||||
{#if sortDir === "desc"}<SortDescending size={14} weight="light" />{:else}<SortAscending size={14} weight="light" />{/if}
|
<button class="sel-action-btn" onclick={downloadSelected} title="Download selected"><Download size={13} weight="light" /></button>
|
||||||
{{ source: "Source order", chapterNumber: "Ch. number", uploadDate: "Upload date" }[sortMode]}
|
<button class="sel-action-btn sel-action-danger" onclick={deleteSelected} title="Delete selected downloads"><Trash size={13} weight="light" /></button>
|
||||||
<CaretDown size={10} weight="light" />
|
<button class="sel-action-btn" onclick={() => markSelectedRead(true)} title="Mark selected as read"><CheckCircle size={13} weight="light" /></button>
|
||||||
</button>
|
<button class="sel-action-btn" onclick={() => markSelectedRead(false)} title="Mark selected as unread"><Circle size={13} weight="light" /></button>
|
||||||
{#if sortMenuOpen}
|
<button class="sel-action-btn" onclick={clearSelection} title="Clear selection"><X size={13} weight="light" /></button>
|
||||||
<div class="sort-menu" role="presentation" onmouseleave={() => sortMenuOpen = false}>
|
{:else}
|
||||||
{#each [["source","Source order"],["chapterNumber","Chapter number"],["uploadDate","Upload date"]] as [val, label]}
|
<div class="sort-wrap">
|
||||||
<button class="sort-option" class:active={sortMode === val}
|
<button class="sort-btn" onclick={() => sortMenuOpen = !sortMenuOpen}>
|
||||||
onclick={() => { updateSettings({ chapterSortMode: val as any }); chapterPage = 1; sortMenuOpen = false; }}>
|
{#if sortDir === "desc"}<SortDescending size={14} weight="light" />{:else}<SortAscending size={14} weight="light" />{/if}
|
||||||
{label}
|
{{ source: "Source order", chapterNumber: "Ch. number", uploadDate: "Upload date" }[sortMode]}
|
||||||
|
<CaretDown size={10} weight="light" />
|
||||||
|
</button>
|
||||||
|
{#if sortMenuOpen}
|
||||||
|
<div class="sort-menu" role="presentation" onmouseleave={() => sortMenuOpen = false}>
|
||||||
|
{#each [["source","Source order"],["chapterNumber","Chapter number"],["uploadDate","Upload date"]] as [val, label]}
|
||||||
|
<button class="sort-option" class:active={sortMode === val}
|
||||||
|
onclick={() => { updateSettings({ chapterSortMode: val as any }); chapterPage = 1; sortMenuOpen = false; }}>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
<div class="sort-divider"></div>
|
||||||
|
<button class="sort-option" onclick={() => { updateSettings({ chapterSortDir: sortDir === "desc" ? "asc" : "desc" }); chapterPage = 1; sortMenuOpen = false; }}>
|
||||||
|
{sortDir === "desc" ? "↑ Ascending" : "↓ Descending"}
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
</div>
|
||||||
<div class="sort-divider"></div>
|
{/if}
|
||||||
<button class="sort-option" onclick={() => { updateSettings({ chapterSortDir: sortDir === "desc" ? "asc" : "desc" }); chapterPage = 1; sortMenuOpen = false; }}>
|
</div>
|
||||||
{sortDir === "desc" ? "↑ Ascending" : "↓ Descending"}
|
|
||||||
</button>
|
<button class="icon-btn" class:active={viewMode === "grid"} onclick={() => viewMode = viewMode === "list" ? "grid" : "list"} title={viewMode === "list" ? "Grid view" : "List view"}>
|
||||||
|
{#if viewMode === "list"}<SquaresFour size={14} weight="light" />{:else}<List size={14} weight="light" />{/if}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="list-header-right">
|
||||||
|
<div class="jump-wrap">
|
||||||
|
<button class="icon-btn" class:active={jumpOpen} onclick={() => { jumpOpen = !jumpOpen; jumpInput = ""; }} title="Jump to chapter">
|
||||||
|
<MagnifyingGlass size={14} weight="light" />
|
||||||
|
</button>
|
||||||
|
{#if jumpOpen}
|
||||||
|
<div class="jump-popover">
|
||||||
|
<input class="jump-input" placeholder="Chapter # or name…" bind:value={jumpInput} use:focusOnMount
|
||||||
|
onkeydown={(e) => { if (e.key === "Enter") doJump(); if (e.key === "Escape") { jumpOpen = false; jumpInput = ""; } }} />
|
||||||
|
{#if jumpChapter}
|
||||||
|
<button class="jump-go" onclick={doJump}>Go · {jumpChapter.name}</button>
|
||||||
|
{:else if jumpInput.trim()}
|
||||||
|
<p class="jump-none">No match</p>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<!-- View toggle: icon reflects current state -->
|
|
||||||
<button class="icon-btn" class:active={viewMode === "grid"} onclick={() => viewMode = viewMode === "list" ? "grid" : "list"} title={viewMode === "list" ? "Grid view" : "List view"}>
|
{#if availableScanlators.length > 1}
|
||||||
{#if viewMode === "list"}<SquaresFour size={14} weight="light" />{:else}<List size={14} weight="light" />{/if}
|
<div class="scan-filter-wrap">
|
||||||
</button>
|
<button class="icon-btn" class:active={scanlatorFilter.length > 0} onclick={() => scanFilterOpen = !scanFilterOpen} title="Filter by scanlator">
|
||||||
</div>
|
<Funnel size={14} weight={scanlatorFilter.length > 0 ? "fill" : "light"} />
|
||||||
<div class="list-header-right">
|
</button>
|
||||||
|
{#if scanFilterOpen}
|
||||||
|
<div class="scan-filter-panel" role="menu">
|
||||||
|
<div class="scan-filter-header">
|
||||||
|
<span class="scan-filter-heading">Scanlators</span>
|
||||||
|
{#if scanlatorFilter.length > 0}
|
||||||
|
<button class="scan-filter-clear" onclick={() => { setPref("scanlatorFilter", []); chapterPage = 1; }}>Clear</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="scan-filter-divider"></div>
|
||||||
|
{#each availableScanlators as s}
|
||||||
|
<button class="scan-filter-item" class:scan-filter-item-active={scanlatorFilter.includes(s)} role="menuitem"
|
||||||
|
onclick={() => {
|
||||||
|
const next = scanlatorFilter.includes(s)
|
||||||
|
? scanlatorFilter.filter(x => x !== s)
|
||||||
|
: [...scanlatorFilter, s];
|
||||||
|
setPref("scanlatorFilter", next);
|
||||||
|
chapterPage = 1;
|
||||||
|
}}>
|
||||||
|
<span class="scan-filter-check" class:scan-filter-check-on={scanlatorFilter.includes(s)}>
|
||||||
|
{#if scanlatorFilter.includes(s)}<Check size={9} weight="bold" />{/if}
|
||||||
|
</span>
|
||||||
|
{s}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<button class="icon-btn" onclick={refreshChapters} disabled={refreshing}>
|
<button class="icon-btn" onclick={refreshChapters} disabled={refreshing}>
|
||||||
<ArrowsClockwise size={14} weight="light" class={refreshing ? "anim-spin" : ""} />
|
<ArrowsClockwise size={14} weight="light" class={refreshing ? "anim-spin" : ""} />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Category picker -->
|
|
||||||
<div class="fp-wrap" bind:this={folderPickerRef}>
|
<div class="fp-wrap" bind:this={folderPickerRef}>
|
||||||
<button class="icon-btn" class:active={hasFolders} onclick={() => folderPickerOpen = !folderPickerOpen}>
|
<button class="icon-btn" class:active={hasFolders} onclick={() => folderPickerOpen = !folderPickerOpen}>
|
||||||
<FolderSimplePlus size={14} weight={hasFolders ? "fill" : "light"} />
|
<FolderSimplePlus size={14} weight={hasFolders ? "fill" : "light"} />
|
||||||
@@ -584,12 +795,10 @@
|
|||||||
<div class="fp-div"></div>
|
<div class="fp-div"></div>
|
||||||
{#if folderCreating}
|
{#if folderCreating}
|
||||||
<div class="fp-create">
|
<div class="fp-create">
|
||||||
<input class="fp-input" placeholder="Folder name…" bind:value={folderNewName}
|
<input class="fp-input" placeholder="Folder name…" bind:value={folderNewName} use:focusOnMount
|
||||||
onkeydown={(e) => { if (e.key === "Enter") createCategory(); if (e.key === "Escape") { folderCreating = false; folderNewName = ""; } }} use:focusOnMount />
|
onkeydown={(e) => { if (e.key === "Enter") createCategory(); if (e.key === "Escape") { folderCreating = false; folderNewName = ""; } }} />
|
||||||
<button class="fp-confirm" onclick={createCategory} disabled={!folderNewName.trim()}>Add</button>
|
<button class="fp-confirm" onclick={createCategory} disabled={!folderNewName.trim()}>Add</button>
|
||||||
<button class="fp-cancel" onclick={() => { folderCreating = false; folderNewName = ""; }}>
|
<button class="fp-cancel" onclick={() => { folderCreating = false; folderNewName = ""; }}><X size={12} weight="light" /></button>
|
||||||
<X size={12} weight="light" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<button class="fp-new" onclick={() => folderCreating = true}>+ New folder</button>
|
<button class="fp-new" onclick={() => folderCreating = true}>+ New folder</button>
|
||||||
@@ -598,14 +807,18 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Download dropdown -->
|
|
||||||
{#if chapters.length > 0}
|
{#if chapters.length > 0}
|
||||||
<div class="dl-wrap" bind:this={dlDropRef}>
|
<div class="dl-wrap" bind:this={dlDropRef}>
|
||||||
<button class="icon-btn" onclick={() => dlOpen = !dlOpen}>
|
<button class="icon-btn dl-unified-btn" class:active={dlOpen} class:dl-has-count={downloadedCount > 0} onclick={() => dlOpen = !dlOpen} title="Download options">
|
||||||
<Download size={13} weight="light" />
|
<Download size={13} weight={downloadedCount > 0 ? "fill" : "light"} />
|
||||||
|
{#if downloadedCount > 0}<span class="dl-unified-count">{downloadedCount}</span>{/if}
|
||||||
</button>
|
</button>
|
||||||
{#if dlOpen}
|
{#if dlOpen}
|
||||||
<div class="dl-dropdown">
|
<div class="dl-dropdown">
|
||||||
|
{#if downloadedCount > 0}
|
||||||
|
<p class="dl-section-label">{downloadedCount} / {totalCount} downloaded</p>
|
||||||
|
<div class="dl-divider"></div>
|
||||||
|
{/if}
|
||||||
{#if continueChapter}
|
{#if continueChapter}
|
||||||
{@const contIdx = sortedChapters.indexOf(continueChapter.chapter)}
|
{@const contIdx = sortedChapters.indexOf(continueChapter.chapter)}
|
||||||
{#if contIdx >= 0}
|
{#if contIdx >= 0}
|
||||||
@@ -673,11 +886,13 @@
|
|||||||
{:else if viewMode === "grid"}
|
{:else if viewMode === "grid"}
|
||||||
{#each sortedChapters as ch, i}
|
{#each sortedChapters as ch, i}
|
||||||
{@const inProgress = !ch.isRead && (ch.lastPageRead ?? 0) > 0}
|
{@const inProgress = !ch.isRead && (ch.lastPageRead ?? 0) > 0}
|
||||||
<button class="grid-cell" class:read={ch.isRead} class:in-progress={inProgress}
|
{@const isGridSelected = selectedIds.has(ch.id)}
|
||||||
onclick={() => openReader(ch, chaptersAsc)}
|
<button class="grid-cell" class:read={ch.isRead} class:in-progress={inProgress} class:grid-selected={isGridSelected}
|
||||||
|
onclick={(e) => hasSelection ? toggleSelect(ch.id, e) : openReaderWithAhead(ch, sortedChapters)}
|
||||||
oncontextmenu={(e) => { e.preventDefault(); ctx = { x: e.clientX, y: e.clientY, chapter: ch, idx: i }; }}
|
oncontextmenu={(e) => { e.preventDefault(); ctx = { x: e.clientX, y: e.clientY, chapter: ch, idx: i }; }}
|
||||||
title={ch.name}>
|
title={ch.name}>
|
||||||
<span class="grid-cell-num">{ch.chapterNumber % 1 === 0 ? ch.chapterNumber.toFixed(0) : ch.chapterNumber}</span>
|
<span class="grid-cell-num">{ch.chapterNumber % 1 === 0 ? ch.chapterNumber.toFixed(0) : ch.chapterNumber}</span>
|
||||||
|
{#if ch.isDownloaded}<span class="grid-cell-dl" title="Downloaded"></span>{/if}
|
||||||
{#if ch.isRead}<span class="grid-cell-dot"></span>{/if}
|
{#if ch.isRead}<span class="grid-cell-dot"></span>{/if}
|
||||||
{#if enqueueing.has(ch.id)}<span class="grid-cell-spinner"><CircleNotch size={10} weight="light" class="anim-spin" /></span>{/if}
|
{#if enqueueing.has(ch.id)}<span class="grid-cell-spinner"><CircleNotch size={10} weight="light" class="anim-spin" /></span>{/if}
|
||||||
</button>
|
</button>
|
||||||
@@ -685,10 +900,14 @@
|
|||||||
{:else}
|
{:else}
|
||||||
{#each pageChapters as ch}
|
{#each pageChapters as ch}
|
||||||
{@const idxInSorted = sortedChapters.indexOf(ch)}
|
{@const idxInSorted = sortedChapters.indexOf(ch)}
|
||||||
<div role="button" tabindex="0" class="ch-row" class:read={ch.isRead}
|
{@const isSelected = selectedIds.has(ch.id)}
|
||||||
onclick={() => openReader(ch, chaptersAsc)}
|
<div role="button" tabindex="0" class="ch-row" class:read={ch.isRead} class:ch-selected={isSelected}
|
||||||
onkeydown={(e) => e.key === "Enter" && openReader(ch, chaptersAsc)}
|
onclick={(e) => hasSelection ? toggleSelect(ch.id, e) : openReaderWithAhead(ch, sortedChapters)}
|
||||||
|
onkeydown={(e) => e.key === "Enter" && (hasSelection ? toggleSelect(ch.id, e) : openReaderWithAhead(ch, sortedChapters))}
|
||||||
oncontextmenu={(e) => { e.preventDefault(); ctx = { x: e.clientX, y: e.clientY, chapter: ch, idx: idxInSorted }; }}>
|
oncontextmenu={(e) => { e.preventDefault(); ctx = { x: e.clientX, y: e.clientY, chapter: ch, idx: idxInSorted }; }}>
|
||||||
|
<button class="ch-check" class:ch-check-visible={hasSelection} onclick={(e) => toggleSelect(ch.id, e)} title="Select">
|
||||||
|
{#if isSelected}<CheckCircle size={15} weight="fill" />{:else}<Circle size={15} weight="light" />{/if}
|
||||||
|
</button>
|
||||||
<div class="ch-left">
|
<div class="ch-left">
|
||||||
<span class="ch-name">{ch.name}</span>
|
<span class="ch-name">{ch.name}</span>
|
||||||
<div class="ch-meta">
|
<div class="ch-meta">
|
||||||
@@ -700,11 +919,14 @@
|
|||||||
<div class="ch-right">
|
<div class="ch-right">
|
||||||
{#if ch.isRead}<CheckCircle size={14} weight="light" class="read-icon" />{/if}
|
{#if ch.isRead}<CheckCircle size={14} weight="light" class="read-icon" />{/if}
|
||||||
{#if ch.isDownloaded}
|
{#if ch.isDownloaded}
|
||||||
<button class="dl-btn" onclick={(e) => { e.stopPropagation(); deleteDownloaded(ch.id); }}><Trash size={13} weight="light" /></button>
|
<div class="ch-dl-wrap">
|
||||||
|
<Download size={13} weight="fill" class="ch-dl-icon" />
|
||||||
|
<button class="dl-btn dl-btn-delete" onclick={(e) => { e.stopPropagation(); deleteDownloaded(ch.id); }} title="Delete download"><Trash size={13} weight="light" /></button>
|
||||||
|
</div>
|
||||||
{:else if enqueueing.has(ch.id)}
|
{:else if enqueueing.has(ch.id)}
|
||||||
<CircleNotch size={14} weight="light" class="anim-spin enqueue-icon" />
|
<CircleNotch size={14} weight="light" class="anim-spin enqueue-icon" />
|
||||||
{:else}
|
{:else}
|
||||||
<button class="dl-btn" onclick={(e) => { e.stopPropagation(); enqueue(ch, e); }}><Download size={13} weight="light" /></button>
|
<button class="dl-btn" onclick={(e) => { e.stopPropagation(); enqueue(ch, e); }} title="Download"><Download size={13} weight="light" /></button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -736,20 +958,25 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if trackingOpen && store.activeManga}
|
{#if trackingOpen && store.activeManga}
|
||||||
<TrackingPanel
|
<TrackingPanel mangaId={store.activeManga.id} mangaTitle={store.activeManga.title} onClose={() => trackingOpen = false} />
|
||||||
mangaId={store.activeManga.id}
|
{/if}
|
||||||
mangaTitle={store.activeManga.title}
|
|
||||||
onClose={() => trackingOpen = false}
|
{#if autoOpen && store.activeManga}
|
||||||
/>
|
<AutomationPanel mangaId={store.activeManga.id} {chapters} onClose={() => autoOpen = false} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if markersOpen && store.activeManga}
|
||||||
|
<div class="markers-panel-overlay" role="presentation" onclick={(e) => { if (e.target === e.currentTarget) markersOpen = false; }}>
|
||||||
|
<div class="markers-panel-drawer">
|
||||||
|
<MarkersPanel mangaId={store.activeManga.id} {chapters} onClose={() => markersOpen = false} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if linkPickerOpen}
|
{#if linkPickerOpen}
|
||||||
<div
|
<div class="link-backdrop" role="presentation"
|
||||||
class="link-backdrop"
|
|
||||||
role="presentation"
|
|
||||||
onclick={(e) => { if (e.target === e.currentTarget) closeLinkPicker(); }}
|
onclick={(e) => { if (e.target === e.currentTarget) closeLinkPicker(); }}
|
||||||
onkeydown={(e) => e.key === "Escape" && closeLinkPicker()}
|
onkeydown={(e) => e.key === "Escape" && closeLinkPicker()}>
|
||||||
>
|
|
||||||
<div class="link-modal">
|
<div class="link-modal">
|
||||||
<div class="link-header">
|
<div class="link-header">
|
||||||
<span class="link-title">Link as same series</span>
|
<span class="link-title">Link as same series</span>
|
||||||
@@ -768,7 +995,7 @@
|
|||||||
{#each linkPickerResults as m (m.id)}
|
{#each linkPickerResults as m (m.id)}
|
||||||
{@const isLinked = linkedIds.includes(m.id)}
|
{@const isLinked = linkedIds.includes(m.id)}
|
||||||
<button class="link-row" class:link-row-linked={isLinked} onclick={() => handleLink(m)}>
|
<button class="link-row" class:link-row-linked={isLinked} onclick={() => handleLink(m)}>
|
||||||
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} class="link-thumb" loading="lazy" decoding="async" />
|
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="link-thumb" />
|
||||||
<div class="link-info">
|
<div class="link-info">
|
||||||
<span class="link-manga-title">{m.title}</span>
|
<span class="link-manga-title">{m.title}</span>
|
||||||
{#if m.source?.displayName}<span class="link-source">{m.source.displayName}</span>{/if}
|
{#if m.source?.displayName}<span class="link-source">{m.source.displayName}</span>{/if}
|
||||||
@@ -787,16 +1014,13 @@
|
|||||||
<style>
|
<style>
|
||||||
.root { display: flex; height: 100%; overflow: hidden; animation: fadeIn 0.14s ease both; }
|
.root { display: flex; height: 100%; overflow: hidden; animation: fadeIn 0.14s ease both; }
|
||||||
|
|
||||||
/* ── Sidebar ─────────────────────────────────────────────────────────────── */
|
|
||||||
.sidebar { width: 240px; flex-shrink: 0; padding: var(--sp-5); border-right: 1px solid var(--border-dim); overflow-y: auto; display: flex; flex-direction: column; gap: var(--sp-4); background: var(--bg-base); }
|
.sidebar { width: 240px; flex-shrink: 0; padding: var(--sp-5); border-right: 1px solid var(--border-dim); overflow-y: auto; display: flex; flex-direction: column; gap: var(--sp-4); background: var(--bg-base); }
|
||||||
.back { display: flex; align-items: center; gap: var(--sp-2); color: var(--text-muted); font-size: var(--text-xs); font-family: var(--font-ui); letter-spacing: var(--tracking-wide); text-transform: uppercase; transition: color var(--t-base); }
|
.back { display: flex; align-items: center; gap: var(--sp-2); color: var(--text-muted); font-size: var(--text-xs); font-family: var(--font-ui); letter-spacing: var(--tracking-wide); text-transform: uppercase; transition: color var(--t-base); }
|
||||||
.back:hover { color: var(--text-secondary); }
|
.back:hover { color: var(--text-secondary); }
|
||||||
|
|
||||||
/* Zone 1: Cover */
|
|
||||||
.cover-wrap { width: 100%; aspect-ratio: 2/3; border-radius: var(--radius-md); overflow: hidden; background: var(--bg-raised); border: 1px solid var(--border-dim); flex-shrink: 0; }
|
.cover-wrap { width: 100%; aspect-ratio: 2/3; border-radius: var(--radius-md); overflow: hidden; background: var(--bg-raised); border: 1px solid var(--border-dim); flex-shrink: 0; }
|
||||||
.cover { width: 100%; height: 100%; object-fit: cover; }
|
:global(.cover) { width: 100%; height: 100%; object-fit: cover; }
|
||||||
|
|
||||||
/* Zone 2: Meta */
|
|
||||||
.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); }
|
||||||
@@ -810,10 +1034,8 @@
|
|||||||
.genre:hover { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
|
.genre:hover { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
|
||||||
.genre-toggle { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-sm); padding: 1px 6px; letter-spacing: var(--tracking-wide); cursor: pointer; transition: color var(--t-base), border-color var(--t-base); }
|
.genre-toggle { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-sm); padding: 1px 6px; letter-spacing: var(--tracking-wide); cursor: pointer; transition: color var(--t-base), border-color var(--t-base); }
|
||||||
.genre-toggle:hover { color: var(--accent-fg); border-color: var(--accent-dim); }
|
.genre-toggle:hover { color: var(--accent-fg); border-color: var(--accent-dim); }
|
||||||
/* Description clamped — no expand in 240px sidebar */
|
|
||||||
.desc { 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; }
|
.desc { 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; }
|
||||||
|
|
||||||
/* Zone 3: CTA */
|
|
||||||
.cta-section { display: flex; flex-direction: column; gap: var(--sp-2); }
|
.cta-section { display: flex; flex-direction: column; gap: var(--sp-2); }
|
||||||
.read-btn { display: flex; align-items: center; justify-content: center; gap: var(--sp-2); width: 100%; padding: 9px var(--sp-3); border-radius: var(--radius-md); background: var(--accent); border: 1px solid var(--accent); color: var(--accent-contrast, #fff); font-size: var(--text-xs); font-family: var(--font-ui); letter-spacing: var(--tracking-wide); cursor: pointer; transition: opacity var(--t-base); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
.read-btn { display: flex; align-items: center; justify-content: center; gap: var(--sp-2); width: 100%; padding: 9px var(--sp-3); border-radius: var(--radius-md); background: var(--accent); border: 1px solid var(--accent); color: var(--accent-contrast, #fff); font-size: var(--text-xs); font-family: var(--font-ui); letter-spacing: var(--tracking-wide); cursor: pointer; transition: opacity var(--t-base); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
.read-btn:hover { opacity: 0.88; }
|
.read-btn:hover { opacity: 0.88; }
|
||||||
@@ -825,7 +1047,6 @@
|
|||||||
.external-link { 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-faint); flex-shrink: 0; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
|
.external-link { 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-faint); flex-shrink: 0; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
|
||||||
.external-link:hover { color: var(--text-muted); border-color: var(--border-strong); }
|
.external-link:hover { color: var(--text-muted); border-color: var(--border-strong); }
|
||||||
|
|
||||||
/* Zone 4: Progress */
|
|
||||||
.progress-section { display: flex; flex-direction: column; gap: var(--sp-1); }
|
.progress-section { display: flex; flex-direction: column; gap: var(--sp-1); }
|
||||||
.progress-header { display: flex; justify-content: space-between; align-items: center; }
|
.progress-header { display: flex; justify-content: space-between; align-items: center; }
|
||||||
.progress-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
.progress-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||||
@@ -833,14 +1054,10 @@
|
|||||||
.progress-track { height: 3px; background: var(--border-base); border-radius: var(--radius-full); overflow: hidden; }
|
.progress-track { height: 3px; background: var(--border-base); border-radius: var(--radius-full); overflow: hidden; }
|
||||||
.progress-fill { height: 100%; background: var(--accent); border-radius: var(--radius-full); transition: width 0.4s ease; }
|
.progress-fill { height: 100%; background: var(--accent); border-radius: var(--radius-full); transition: width 0.4s ease; }
|
||||||
|
|
||||||
/* Zone 5: Details accordion */
|
|
||||||
.details-section { display: flex; flex-direction: column; gap: 2px; }
|
.details-section { display: flex; flex-direction: column; gap: 2px; }
|
||||||
.details-toggle { display: flex; align-items: center; justify-content: space-between; font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); padding: 4px 0; transition: color var(--t-base); }
|
.details-toggle { display: flex; align-items: center; justify-content: space-between; font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); padding: 4px 0; transition: color var(--t-base); }
|
||||||
.details-toggle:hover { color: var(--text-muted); }
|
.details-toggle:hover { color: var(--text-muted); }
|
||||||
.details-body { display: flex; flex-direction: column; gap: var(--sp-2); padding-top: var(--sp-2); }
|
.details-body { display: flex; flex-direction: column; gap: var(--sp-2); padding-top: var(--sp-2); }
|
||||||
.detail-row { display: flex; justify-content: space-between; align-items: baseline; gap: var(--sp-2); }
|
|
||||||
.detail-key { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
|
||||||
.detail-val { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-secondary); letter-spacing: var(--tracking-wide); text-align: right; }
|
|
||||||
.detail-actions { display: flex; flex-direction: column; gap: var(--sp-1); padding-top: var(--sp-1); }
|
.detail-actions { display: flex; flex-direction: column; gap: var(--sp-1); padding-top: var(--sp-1); }
|
||||||
.detail-action-btn { display: flex; align-items: center; gap: var(--sp-2); width: 100%; padding: 6px var(--sp-2); border-radius: var(--radius-md); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); color: var(--text-faint); background: none; border: 1px solid var(--border-dim); cursor: pointer; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
|
.detail-action-btn { display: flex; align-items: center; gap: var(--sp-2); width: 100%; padding: 6px var(--sp-2); border-radius: var(--radius-md); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); color: var(--text-faint); background: none; border: 1px solid var(--border-dim); cursor: pointer; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
|
||||||
.detail-action-btn:hover { color: var(--text-muted); border-color: var(--border-strong); background: var(--bg-raised); }
|
.detail-action-btn:hover { color: var(--text-muted); border-color: var(--border-strong); background: var(--bg-raised); }
|
||||||
@@ -850,7 +1067,6 @@
|
|||||||
.detail-action-danger:hover:not(:disabled) { background: var(--color-error-bg); border-color: var(--color-error); color: var(--color-error); }
|
.detail-action-danger:hover:not(:disabled) { background: var(--color-error-bg); border-color: var(--color-error); color: var(--color-error); }
|
||||||
.detail-action-danger:disabled { opacity: 0.4; cursor: default; }
|
.detail-action-danger:disabled { opacity: 0.4; cursor: default; }
|
||||||
|
|
||||||
/* ── Series link modal ───────────────────────────────────────────────────── */
|
|
||||||
.link-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.72); z-index: var(--z-settings); display: flex; align-items: center; justify-content: center; backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px); animation: fadeIn 0.12s ease both; }
|
.link-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.72); z-index: var(--z-settings); display: flex; align-items: center; justify-content: center; backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px); animation: fadeIn 0.12s ease both; }
|
||||||
.link-modal { width: min(460px, calc(100vw - 48px)); max-height: 70vh; display: flex; flex-direction: column; background: var(--bg-surface); border: 1px solid var(--border-base); border-radius: var(--radius-xl); overflow: hidden; box-shadow: 0 24px 64px rgba(0,0,0,0.6); animation: scaleIn 0.14s ease both; }
|
.link-modal { width: min(460px, calc(100vw - 48px)); max-height: 70vh; display: flex; flex-direction: column; background: var(--bg-surface); border: 1px solid var(--border-base); border-radius: var(--radius-xl); overflow: hidden; box-shadow: 0 24px 64px rgba(0,0,0,0.6); animation: scaleIn 0.14s ease both; }
|
||||||
.link-header { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-4) var(--sp-5); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
|
.link-header { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-4) var(--sp-5); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
|
||||||
@@ -867,14 +1083,13 @@
|
|||||||
.link-row { display: flex; align-items: center; gap: var(--sp-3); width: 100%; padding: 8px var(--sp-3); border-radius: var(--radius-md); border: none; background: none; text-align: left; cursor: pointer; transition: background var(--t-fast); }
|
.link-row { display: flex; align-items: center; gap: var(--sp-3); width: 100%; padding: 8px var(--sp-3); border-radius: var(--radius-md); border: none; background: none; text-align: left; cursor: pointer; transition: background var(--t-fast); }
|
||||||
.link-row:hover { background: var(--bg-raised); }
|
.link-row:hover { background: var(--bg-raised); }
|
||||||
.link-row-linked { background: var(--accent-muted) !important; }
|
.link-row-linked { background: var(--accent-muted) !important; }
|
||||||
.link-thumb { width: 34px; height: 48px; border-radius: var(--radius-sm); object-fit: cover; flex-shrink: 0; border: 1px solid var(--border-dim); }
|
:global(.link-thumb) { width: 34px; height: 48px; border-radius: var(--radius-sm); object-fit: cover; flex-shrink: 0; border: 1px solid var(--border-dim); }
|
||||||
.link-info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; min-width: 0; }
|
.link-info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; min-width: 0; }
|
||||||
.link-manga-title { font-size: var(--text-sm); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
.link-manga-title { font-size: var(--text-sm); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
.link-source { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
.link-source { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||||
.link-status { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--text-faint); flex-shrink: 0; padding: 2px 8px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); }
|
.link-status { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--text-faint); flex-shrink: 0; padding: 2px 8px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); }
|
||||||
.link-row-linked .link-status { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
|
.link-row-linked .link-status { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
|
||||||
|
|
||||||
/* ── Chapter list ────────────────────────────────────────────────────────── */
|
|
||||||
.list-wrap { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
|
.list-wrap { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
|
||||||
.list-header { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-3) var(--sp-4); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; gap: var(--sp-2); flex-wrap: wrap; }
|
.list-header { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-3) var(--sp-4); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; gap: var(--sp-2); flex-wrap: wrap; }
|
||||||
.list-header-left, .list-header-right { display: flex; align-items: center; gap: var(--sp-1); }
|
.list-header-left, .list-header-right { display: flex; align-items: center; gap: var(--sp-1); }
|
||||||
@@ -891,7 +1106,14 @@
|
|||||||
.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); }
|
||||||
.icon-btn:disabled { opacity: 0.3; cursor: default; }
|
.icon-btn:disabled { opacity: 0.3; cursor: default; }
|
||||||
|
|
||||||
/* ── Folder picker ───────────────────────────────────────────────────────── */
|
.jump-wrap { position: relative; }
|
||||||
|
.jump-popover { position: absolute; top: calc(100% + 4px); right: 0; width: 220px; background: var(--bg-raised); border: 1px solid var(--border-base); border-radius: var(--radius-md); padding: var(--sp-2); z-index: 200; box-shadow: 0 8px 24px rgba(0,0,0,0.5); animation: scaleIn 0.1s ease both; transform-origin: top right; display: flex; flex-direction: column; gap: var(--sp-1); }
|
||||||
|
.jump-input { width: 100%; background: var(--bg-overlay); border: 1px solid var(--border-strong); border-radius: var(--radius-sm); padding: 5px 9px; font-size: var(--text-xs); color: var(--text-secondary); outline: none; transition: border-color var(--t-base); }
|
||||||
|
.jump-input:focus { border-color: var(--border-focus); }
|
||||||
|
.jump-go { width: 100%; padding: 6px var(--sp-2); border-radius: var(--radius-sm); background: var(--accent-muted); border: 1px solid var(--accent-dim); color: var(--accent-fg); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); cursor: pointer; text-align: left; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; transition: background var(--t-fast), border-color var(--t-fast); }
|
||||||
|
.jump-go:hover { background: var(--accent); border-color: var(--accent); color: var(--accent-contrast, #fff); }
|
||||||
|
.jump-none { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); padding: 4px var(--sp-1); letter-spacing: var(--tracking-wide); }
|
||||||
|
|
||||||
.fp-wrap { position: relative; }
|
.fp-wrap { position: relative; }
|
||||||
.fp-menu { position: absolute; top: calc(100% + 4px); right: 0; min-width: 180px; background: var(--bg-raised); border: 1px solid var(--border-base); border-radius: var(--radius-md); padding: var(--sp-1); z-index: 200; box-shadow: 0 8px 24px rgba(0,0,0,0.5); animation: scaleIn 0.1s ease both; transform-origin: top right; }
|
.fp-menu { position: absolute; top: calc(100% + 4px); right: 0; min-width: 180px; background: var(--bg-raised); border: 1px solid var(--border-base); border-radius: var(--radius-md); padding: var(--sp-1); z-index: 200; box-shadow: 0 8px 24px rgba(0,0,0,0.5); animation: scaleIn 0.1s ease both; transform-origin: top right; }
|
||||||
.fp-empty { padding: var(--sp-2) var(--sp-3); font-size: var(--text-xs); color: var(--text-faint); }
|
.fp-empty { padding: var(--sp-2) var(--sp-3); font-size: var(--text-xs); color: var(--text-faint); }
|
||||||
@@ -910,7 +1132,6 @@
|
|||||||
.fp-new { width: 100%; padding: 6px var(--sp-3); border-radius: var(--radius-sm); font-size: var(--text-xs); color: var(--text-faint); background: none; border: none; cursor: pointer; text-align: left; transition: color var(--t-fast), background var(--t-fast); }
|
.fp-new { width: 100%; padding: 6px var(--sp-3); border-radius: var(--radius-sm); font-size: var(--text-xs); color: var(--text-faint); background: none; border: none; cursor: pointer; text-align: left; transition: color var(--t-fast), background var(--t-fast); }
|
||||||
.fp-new:hover { color: var(--text-secondary); background: var(--bg-overlay); }
|
.fp-new:hover { color: var(--text-secondary); background: var(--bg-overlay); }
|
||||||
|
|
||||||
/* ── Download dropdown ───────────────────────────────────────────────────── */
|
|
||||||
.dl-wrap { position: relative; }
|
.dl-wrap { position: relative; }
|
||||||
.dl-dropdown { position: absolute; top: calc(100% + 4px); right: 0; min-width: 220px; background: var(--bg-raised); border: 1px solid var(--border-base); border-radius: var(--radius-lg); padding: var(--sp-1); z-index: 200; box-shadow: 0 8px 32px rgba(0,0,0,0.5); animation: scaleIn 0.1s ease both; transform-origin: top right; }
|
.dl-dropdown { position: absolute; top: calc(100% + 4px); right: 0; min-width: 220px; background: var(--bg-raised); border: 1px solid var(--border-base); border-radius: var(--radius-lg); padding: var(--sp-1); z-index: 200; box-shadow: 0 8px 32px rgba(0,0,0,0.5); animation: scaleIn 0.1s ease both; transform-origin: top right; }
|
||||||
.dl-section-label { padding: 6px var(--sp-3) 4px; font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; }
|
.dl-section-label { padding: 6px var(--sp-3) 4px; font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; }
|
||||||
@@ -935,7 +1156,6 @@
|
|||||||
.dl-range-go { padding: 4px 10px; border-radius: var(--radius-sm); border: 1px solid var(--accent-dim); background: var(--accent-muted); color: var(--accent-fg); font-family: var(--font-ui); font-size: var(--text-xs); cursor: pointer; }
|
.dl-range-go { padding: 4px 10px; border-radius: var(--radius-sm); border: 1px solid var(--accent-dim); background: var(--accent-muted); color: var(--accent-fg); font-family: var(--font-ui); font-size: var(--text-xs); cursor: pointer; }
|
||||||
.dl-range-go:disabled { opacity: 0.3; cursor: default; }
|
.dl-range-go:disabled { opacity: 0.3; cursor: default; }
|
||||||
|
|
||||||
/* ── Pagination ──────────────────────────────────────────────────────────── */
|
|
||||||
.pagination, .pagination-bottom { display: flex; align-items: center; gap: var(--sp-2); }
|
.pagination, .pagination-bottom { display: flex; align-items: center; gap: var(--sp-2); }
|
||||||
.pagination-bottom { justify-content: center; padding: var(--sp-3); border-top: 1px solid var(--border-dim); flex-shrink: 0; }
|
.pagination-bottom { justify-content: center; padding: var(--sp-3); border-top: 1px solid var(--border-dim); flex-shrink: 0; }
|
||||||
.page-btn { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 4px 10px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); color: var(--text-faint); background: none; cursor: pointer; transition: color var(--t-base), border-color var(--t-base); }
|
.page-btn { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 4px 10px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); color: var(--text-faint); background: none; cursor: pointer; transition: color var(--t-base), border-color var(--t-base); }
|
||||||
@@ -943,7 +1163,6 @@
|
|||||||
.page-btn:disabled { opacity: 0.3; cursor: default; }
|
.page-btn:disabled { opacity: 0.3; cursor: default; }
|
||||||
.page-num { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
.page-num { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||||
|
|
||||||
/* ── Chapter rows ────────────────────────────────────────────────────────── */
|
|
||||||
.ch-list { flex: 1; overflow-y: auto; }
|
.ch-list { flex: 1; overflow-y: auto; }
|
||||||
.ch-grid { flex: 1; overflow-y: auto; display: grid; grid-template-columns: repeat(auto-fill, minmax(42px, 1fr)); gap: 4px; padding: var(--sp-3); align-content: start; }
|
.ch-grid { flex: 1; overflow-y: auto; display: grid; grid-template-columns: repeat(auto-fill, minmax(42px, 1fr)); gap: 4px; padding: var(--sp-3); align-content: start; }
|
||||||
.ch-row { display: flex; align-items: center; padding: 10px var(--sp-4); border-bottom: 1px solid var(--border-dim); cursor: pointer; transition: background var(--t-fast); gap: var(--sp-3); }
|
.ch-row { display: flex; align-items: center; padding: 10px var(--sp-4); border-bottom: 1px solid var(--border-dim); cursor: pointer; transition: background var(--t-fast); gap: var(--sp-3); }
|
||||||
@@ -969,10 +1188,58 @@
|
|||||||
.grid-cell-spinner { position: absolute; top: 2px; right: 2px; }
|
.grid-cell-spinner { position: absolute; top: 2px; right: 2px; }
|
||||||
.grid-cell-skeleton { aspect-ratio: 1; border-radius: var(--radius-sm); }
|
.grid-cell-skeleton { aspect-ratio: 1; border-radius: var(--radius-sm); }
|
||||||
|
|
||||||
|
.sel-count { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); letter-spacing: var(--tracking-wide); padding: 0 var(--sp-1); }
|
||||||
|
.sel-action-btn { display: flex; align-items: center; justify-content: center; width: 26px; height: 26px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: none; color: var(--text-muted); cursor: pointer; transition: color var(--t-base), background var(--t-base), border-color var(--t-base); }
|
||||||
|
.sel-action-btn:hover { color: var(--text-primary); background: var(--bg-raised); border-color: var(--border-strong); }
|
||||||
|
.sel-action-danger { color: var(--color-error) !important; }
|
||||||
|
.sel-action-danger:hover { background: var(--color-error-bg) !important; border-color: var(--color-error) !important; }
|
||||||
|
|
||||||
|
.dl-unified-btn { gap: 5px; padding: 0 8px; width: auto; min-width: 28px; }
|
||||||
|
.dl-unified-count { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--text-faint); transition: color var(--t-base); }
|
||||||
|
.dl-unified-btn:hover .dl-unified-count,
|
||||||
|
.dl-unified-btn.active .dl-unified-count { color: var(--text-secondary); }
|
||||||
|
.dl-unified-btn.dl-has-count { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
|
||||||
|
.dl-unified-btn.dl-has-count .dl-unified-count { color: var(--accent-fg); opacity: 0.8; }
|
||||||
|
.dl-unified-btn.dl-has-count:hover { background: var(--accent-muted); border-color: var(--accent); opacity: 0.9; }
|
||||||
|
.dl-unified-btn.active { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
|
||||||
|
|
||||||
|
.ch-check { display: flex; align-items: center; justify-content: center; width: 20px; height: 20px; flex-shrink: 0; border-radius: var(--radius-sm); border: none; background: none; color: var(--text-faint); cursor: pointer; opacity: 0; transition: opacity var(--t-fast), color var(--t-fast); padding: 0; }
|
||||||
|
.ch-row:hover .ch-check { opacity: 1; }
|
||||||
|
.ch-check-visible { opacity: 1 !important; }
|
||||||
|
.ch-selected { background: color-mix(in srgb, var(--accent) 8%, transparent) !important; }
|
||||||
|
.ch-selected .ch-check { color: var(--accent-fg); opacity: 1; }
|
||||||
|
|
||||||
|
.dl-btn-delete { color: var(--color-error) !important; opacity: 0; }
|
||||||
|
.ch-row:hover .dl-btn-delete { opacity: 1; }
|
||||||
|
.dl-btn-delete:hover { background: var(--color-error-bg) !important; }
|
||||||
|
|
||||||
|
.ch-dl-wrap { position: relative; display: flex; align-items: center; justify-content: center; width: 24px; height: 24px; }
|
||||||
|
:global(.ch-dl-icon) { color: var(--text-faint); transition: opacity var(--t-fast); }
|
||||||
|
.ch-row:hover .ch-dl-wrap :global(.ch-dl-icon) { opacity: 0; }
|
||||||
|
.ch-dl-wrap .dl-btn-delete { position: absolute; inset: 0; opacity: 0; }
|
||||||
|
.ch-row:hover .ch-dl-wrap .dl-btn-delete { opacity: 1; }
|
||||||
|
|
||||||
|
.grid-selected { background: var(--accent-muted) !important; border-color: var(--accent-dim) !important; }
|
||||||
|
.grid-cell-dl { position: absolute; top: 3px; left: 3px; width: 4px; height: 4px; border-radius: 50%; background: var(--accent-fg); }
|
||||||
|
|
||||||
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
|
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
|
||||||
@keyframes scaleIn { from { opacity: 0; transform: scale(0.97) } to { opacity: 1; transform: scale(1) } }
|
@keyframes scaleIn { from { opacity: 0; transform: scale(0.97) } to { opacity: 1; transform: scale(1) } }
|
||||||
</style>
|
|
||||||
|
|
||||||
<script module>
|
.markers-panel-overlay { position: fixed; inset: 0; z-index: var(--z-settings); display: flex; align-items: stretch; justify-content: flex-start; animation: fadeIn 0.12s ease both; }
|
||||||
function focusOnMount(node: HTMLElement) { node.focus(); }
|
.markers-panel-drawer { width: 280px; max-width: 90vw; background: var(--bg-surface); border-right: 1px solid var(--border-base); box-shadow: 4px 0 24px rgba(0,0,0,0.4); display: flex; flex-direction: column; animation: drawerIn 0.18s cubic-bezier(0.16,1,0.3,1) both; }
|
||||||
</script>
|
@keyframes drawerIn { from { opacity: 0; transform: translateX(-12px); } to { opacity: 1; transform: translateX(0); } }
|
||||||
|
|
||||||
|
.scan-filter-wrap { position: relative; }
|
||||||
|
.scan-filter-panel { position: absolute; top: calc(100% + 6px); right: 0; z-index: 200; min-width: 200px; background: var(--bg-raised); border: 1px solid var(--border-base); border-radius: var(--radius-lg); padding: var(--sp-1); box-shadow: 0 8px 32px rgba(0,0,0,0.5); animation: scaleIn 0.1s ease both; transform-origin: top right; }
|
||||||
|
.scan-filter-header { display: flex; align-items: center; justify-content: space-between; padding: 4px 8px 6px; }
|
||||||
|
.scan-filter-heading { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); color: var(--text-secondary); font-weight: var(--weight-medium); }
|
||||||
|
.scan-filter-clear { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--text-faint); background: none; border: none; cursor: pointer; padding: 0; transition: color var(--t-base); }
|
||||||
|
.scan-filter-clear:hover { color: var(--color-error); }
|
||||||
|
.scan-filter-divider { height: 1px; background: var(--border-dim); margin: 0 2px 4px; }
|
||||||
|
.scan-filter-item { display: flex; align-items: center; gap: var(--sp-2); width: 100%; padding: 7px 10px; border-radius: var(--radius-sm); border: none; background: transparent; color: var(--text-muted); font-family: var(--font-ui); font-size: var(--text-xs); cursor: pointer; text-align: left; transition: background var(--t-base), color var(--t-base); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.scan-filter-item:hover { background: var(--bg-overlay); color: var(--text-primary); }
|
||||||
|
.scan-filter-item-active { color: var(--accent-fg); background: var(--accent-muted); }
|
||||||
|
.scan-filter-item-active:hover { background: var(--accent-dim); }
|
||||||
|
.scan-filter-check { width: 13px; height: 13px; border-radius: 2px; border: 1px solid var(--border-strong); background: transparent; flex-shrink: 0; display: flex; align-items: center; justify-content: center; color: var(--bg-base); transition: background var(--t-base), border-color var(--t-base); }
|
||||||
|
.scan-filter-check-on { background: var(--accent); border-color: var(--accent); }
|
||||||
|
</style>
|
||||||
+153
-160
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { CircleNotch, X, MagnifyingGlass, ArrowSquareOut, Lock, LockOpen, ArrowsClockwise } from "phosphor-svelte";
|
import { CircleNotch, X, MagnifyingGlass, ArrowSquareOut, Lock, LockOpen, ArrowsClockwise } from "phosphor-svelte";
|
||||||
import { gql, thumbUrl } from "../../lib/client";
|
import { gql } from "../../lib/client";
|
||||||
|
import Thumbnail from "../shared/Thumbnail.svelte";
|
||||||
import {
|
import {
|
||||||
GET_TRACKERS,
|
GET_TRACKERS,
|
||||||
GET_MANGA_TRACK_RECORDS,
|
GET_MANGA_TRACK_RECORDS,
|
||||||
@@ -19,8 +20,6 @@
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
// ── State ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
type TabId = "records" | number;
|
type TabId = "records" | number;
|
||||||
|
|
||||||
let trackers: Tracker[] = $state([]);
|
let trackers: Tracker[] = $state([]);
|
||||||
@@ -39,8 +38,6 @@
|
|||||||
let editingChapter: number | null = $state(null);
|
let editingChapter: number | null = $state(null);
|
||||||
let chapterDraft: number = $state(0);
|
let chapterDraft: number = $state(0);
|
||||||
|
|
||||||
// ── Load ───────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
loading = true;
|
loading = true;
|
||||||
try {
|
try {
|
||||||
@@ -61,7 +58,6 @@
|
|||||||
|
|
||||||
$effect(() => { load(); });
|
$effect(() => { load(); });
|
||||||
|
|
||||||
// Auto-search with manga title when switching to a tracker tab
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const tab = activeTab;
|
const tab = activeTab;
|
||||||
if (typeof tab !== "number") return;
|
if (typeof tab !== "number") return;
|
||||||
@@ -71,14 +67,10 @@
|
|||||||
doSearch(tab, mangaTitle);
|
doSearch(tab, mangaTitle);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function trackerFor(id: number) { return trackers.find(t => t.id === id); }
|
function trackerFor(id: number) { return trackers.find(t => t.id === id); }
|
||||||
function recordFor(trackerId: number){ return records.find(r => r.trackerId === trackerId); }
|
function recordFor(trackerId: number){ return records.find(r => r.trackerId === trackerId); }
|
||||||
const loggedInTrackers = $derived(trackers.filter(t => t.isLoggedIn));
|
const loggedInTrackers = $derived(trackers.filter(t => t.isLoggedIn));
|
||||||
|
|
||||||
// ── Search ─────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
let searchTimer: ReturnType<typeof setTimeout>;
|
let searchTimer: ReturnType<typeof setTimeout>;
|
||||||
|
|
||||||
function onSearchInput() {
|
function onSearchInput() {
|
||||||
@@ -105,8 +97,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Bind / Unbind ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async function bind(result: TrackSearch) {
|
async function bind(result: TrackSearch) {
|
||||||
if (typeof activeTab !== "number") return;
|
if (typeof activeTab !== "number") return;
|
||||||
binding = true;
|
binding = true;
|
||||||
@@ -137,8 +127,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Update ─────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async function updateStatus(record: TrackRecord, status: number) {
|
async function updateStatus(record: TrackRecord, status: number) {
|
||||||
updatingRecord = record.id;
|
updatingRecord = record.id;
|
||||||
try {
|
try {
|
||||||
@@ -234,7 +222,6 @@
|
|||||||
>
|
>
|
||||||
<div class="modal" role="dialog" aria-label="Tracking">
|
<div class="modal" role="dialog" aria-label="Tracking">
|
||||||
|
|
||||||
<!-- ── Header ─────────────────────────────────────────────────────────── -->
|
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<div class="header-left">
|
<div class="header-left">
|
||||||
<span class="modal-title">Tracking</span>
|
<span class="modal-title">Tracking</span>
|
||||||
@@ -256,7 +243,6 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{:else}
|
{:else}
|
||||||
<!-- ── Tabs ──────────────────────────────────────────────────────────── -->
|
|
||||||
<div class="tabs">
|
<div class="tabs">
|
||||||
<button
|
<button
|
||||||
class="tab"
|
class="tab"
|
||||||
@@ -275,14 +261,13 @@
|
|||||||
class:tab-active={activeTab === t.id}
|
class:tab-active={activeTab === t.id}
|
||||||
onclick={() => { activeTab = t.id; searchResults = []; }}
|
onclick={() => { activeTab = t.id; searchResults = []; }}
|
||||||
>
|
>
|
||||||
<img src={thumbUrl(t.icon)} alt={t.name} class="tab-icon" />
|
<Thumbnail src={t.icon} alt={t.name} class="tab-icon" />
|
||||||
{t.name}
|
{t.name}
|
||||||
{#if rec}<span class="tab-dot"></span>{/if}
|
{#if rec}<span class="tab-dot"></span>{/if}
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ── My List tab ───────────────────────────────────────────────────── -->
|
|
||||||
{#if activeTab === "records"}
|
{#if activeTab === "records"}
|
||||||
<div class="tab-body">
|
<div class="tab-body">
|
||||||
{#if records.length === 0}
|
{#if records.length === 0}
|
||||||
@@ -294,25 +279,50 @@
|
|||||||
{#each records as record (record.id)}
|
{#each records as record (record.id)}
|
||||||
{@const tracker = trackerFor(record.trackerId)}
|
{@const tracker = trackerFor(record.trackerId)}
|
||||||
{@const isBusy = updatingRecord === record.id}
|
{@const isBusy = updatingRecord === record.id}
|
||||||
<div class="record-row" class:record-busy={isBusy}>
|
<div class="record-card" class:record-busy={isBusy}>
|
||||||
|
|
||||||
<div class="record-identity">
|
<!-- Title row -->
|
||||||
{#if tracker}
|
<div class="record-head">
|
||||||
<img src={thumbUrl(tracker.icon)} alt={tracker.name} class="record-tracker-icon" />
|
<div class="record-source">
|
||||||
{/if}
|
{#if tracker}
|
||||||
{#if record.remoteUrl}
|
<Thumbnail src={tracker.icon} alt={tracker.name} class="record-tracker-icon" />
|
||||||
<a href={record.remoteUrl} target="_blank" rel="noreferrer" class="record-title">
|
{/if}
|
||||||
{record.title}
|
<span class="record-source-name">{tracker?.name ?? "Tracker"}</span>
|
||||||
<ArrowSquareOut size={10} weight="light" />
|
</div>
|
||||||
</a>
|
<div class="record-head-actions">
|
||||||
{:else}
|
{#if tracker?.supportsPrivateTracking}
|
||||||
<span class="record-title-plain">{record.title}</span>
|
<button
|
||||||
{/if}
|
class="record-icon-btn"
|
||||||
|
class:icon-active={record.private}
|
||||||
|
title={record.private ? "Private — click to make public" : "Public"}
|
||||||
|
disabled={isBusy}
|
||||||
|
onclick={() => togglePrivate(record)}
|
||||||
|
>
|
||||||
|
{#if record.private}<Lock size={11} weight="fill" />{:else}<LockOpen size={11} weight="light" />{/if}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
<button class="record-icon-btn" title="Sync from tracker" disabled={syncing === record.id} onclick={() => syncRecord(record)}>
|
||||||
|
<ArrowsClockwise size={11} weight="light" class={syncing === record.id ? "anim-spin" : ""} />
|
||||||
|
</button>
|
||||||
|
<button class="record-icon-btn icon-danger" title="Unlink" disabled={isBusy} onclick={() => unbind(record)}>
|
||||||
|
<X size={11} weight="bold" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="record-controls">
|
<!-- Linked title -->
|
||||||
|
{#if record.remoteUrl}
|
||||||
|
<a href={record.remoteUrl} target="_blank" rel="noreferrer" class="record-title">
|
||||||
|
{record.title} <ArrowSquareOut size={10} weight="light" />
|
||||||
|
</a>
|
||||||
|
{:else}
|
||||||
|
<span class="record-title-plain">{record.title}</span>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Status + score row -->
|
||||||
|
<div class="record-selects">
|
||||||
<select
|
<select
|
||||||
class="record-select"
|
class="record-select record-select-status"
|
||||||
value={record.status}
|
value={record.status}
|
||||||
disabled={isBusy}
|
disabled={isBusy}
|
||||||
onchange={(e) => updateStatus(record, parseInt((e.target as HTMLSelectElement).value))}
|
onchange={(e) => updateStatus(record, parseInt((e.target as HTMLSelectElement).value))}
|
||||||
@@ -321,7 +331,6 @@
|
|||||||
<option value={s.value}>{s.name}</option>
|
<option value={s.value}>{s.name}</option>
|
||||||
{/each}
|
{/each}
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<select
|
<select
|
||||||
class="record-select record-select-score"
|
class="record-select record-select-score"
|
||||||
value={record.displayScore}
|
value={record.displayScore}
|
||||||
@@ -332,58 +341,19 @@
|
|||||||
<option value={s}>★ {s}</option>
|
<option value={s}>★ {s}</option>
|
||||||
{/each}
|
{/each}
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
{#if tracker?.supportsPrivateTracking}
|
|
||||||
<button
|
|
||||||
class="record-icon-btn"
|
|
||||||
class:icon-active={record.private}
|
|
||||||
title={record.private ? "Private — click to make public" : "Public — click to make private"}
|
|
||||||
disabled={isBusy}
|
|
||||||
onclick={() => togglePrivate(record)}
|
|
||||||
>
|
|
||||||
{#if record.private}
|
|
||||||
<Lock size={12} weight="fill" />
|
|
||||||
{:else}
|
|
||||||
<LockOpen size={12} weight="light" />
|
|
||||||
{/if}
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<button
|
|
||||||
class="record-icon-btn"
|
|
||||||
title="Sync from tracker"
|
|
||||||
disabled={syncing === record.id}
|
|
||||||
onclick={() => syncRecord(record)}
|
|
||||||
>
|
|
||||||
<ArrowsClockwise size={12} weight="light" class={syncing === record.id ? "anim-spin" : ""} />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
class="record-icon-btn icon-danger"
|
|
||||||
title="Unlink"
|
|
||||||
disabled={isBusy}
|
|
||||||
onclick={() => unbind(record)}
|
|
||||||
>
|
|
||||||
<X size={12} weight="bold" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Chapter progress -->
|
||||||
{#if editingChapter === record.id}
|
{#if editingChapter === record.id}
|
||||||
<div class="chapter-editor">
|
<div class="chapter-editor">
|
||||||
<div class="chapter-editor-top">
|
<div class="chapter-editor-top">
|
||||||
<span class="chapter-editor-label">Chapter read</span>
|
<span class="chapter-editor-label">Chapter read</span>
|
||||||
<div class="chapter-input-wrap">
|
<div class="chapter-input-wrap">
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number" class="chapter-input"
|
||||||
class="chapter-input"
|
min="0" max={record.totalChapters > 0 ? record.totalChapters : undefined}
|
||||||
min="0"
|
step="0.5" bind:value={chapterDraft}
|
||||||
max={record.totalChapters > 0 ? record.totalChapters : undefined}
|
onkeydown={(e) => { if (e.key === "Enter") submitChapter(record); if (e.key === "Escape") cancelChapterEditor(); }}
|
||||||
step="0.5"
|
|
||||||
bind:value={chapterDraft}
|
|
||||||
onkeydown={(e) => {
|
|
||||||
if (e.key === "Enter") submitChapter(record);
|
|
||||||
if (e.key === "Escape") cancelChapterEditor();
|
|
||||||
}}
|
|
||||||
use:autoFocus
|
use:autoFocus
|
||||||
/>
|
/>
|
||||||
{#if record.totalChapters > 0}
|
{#if record.totalChapters > 0}
|
||||||
@@ -392,40 +362,36 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{#if record.totalChapters > 0}
|
{#if record.totalChapters > 0}
|
||||||
<input
|
<input type="range" class="chapter-slider" min="0" max={record.totalChapters} step="1" bind:value={chapterDraft} />
|
||||||
type="range"
|
|
||||||
class="chapter-slider"
|
|
||||||
min="0"
|
|
||||||
max={record.totalChapters}
|
|
||||||
step="1"
|
|
||||||
bind:value={chapterDraft}
|
|
||||||
/>
|
|
||||||
{/if}
|
{/if}
|
||||||
<div class="chapter-editor-actions">
|
<div class="chapter-editor-actions">
|
||||||
<button class="chapter-save-btn" onclick={() => submitChapter(record)}>Save</button>
|
|
||||||
<button class="chapter-cancel-btn" onclick={cancelChapterEditor}>Cancel</button>
|
<button class="chapter-cancel-btn" onclick={cancelChapterEditor}>Cancel</button>
|
||||||
</div>
|
<button class="chapter-save-btn" onclick={() => submitChapter(record)}>Save</button>
|
||||||
</div>
|
|
||||||
{:else if record.totalChapters > 0}
|
|
||||||
<div class="record-progress clickable" role="button" tabindex="0"
|
|
||||||
onclick={() => openChapterEditor(record)}
|
|
||||||
onkeydown={(e) => e.key === "Enter" && openChapterEditor(record)}
|
|
||||||
title="Click to edit"
|
|
||||||
>
|
|
||||||
<span class="record-progress-label">Ch. {record.lastChapterRead} / {record.totalChapters} <span class="edit-hint">✎</span></span>
|
|
||||||
<div class="record-progress-track">
|
|
||||||
<div class="record-progress-fill" style="width:{Math.min(100,(record.lastChapterRead/record.totalChapters)*100)}%"></div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="record-progress clickable" role="button" tabindex="0"
|
<div class="record-progress clickable" role="button" tabindex="0"
|
||||||
onclick={() => openChapterEditor(record)}
|
onclick={() => openChapterEditor(record)}
|
||||||
onkeydown={(e) => e.key === "Enter" && openChapterEditor(record)}
|
onkeydown={(e) => e.key === "Enter" && openChapterEditor(record)}
|
||||||
title="Click to set chapter"
|
title="Click to edit"
|
||||||
>
|
>
|
||||||
<span class="record-progress-label">
|
<div class="record-progress-header">
|
||||||
{record.lastChapterRead > 0 ? `Ch. ${record.lastChapterRead} read` : "Set chapter…"} <span class="edit-hint">✎</span>
|
<span class="record-progress-label">
|
||||||
</span>
|
{#if record.totalChapters > 0}
|
||||||
|
Ch. {record.lastChapterRead} / {record.totalChapters}
|
||||||
|
{:else if record.lastChapterRead > 0}
|
||||||
|
Ch. {record.lastChapterRead} read
|
||||||
|
{:else}
|
||||||
|
Set chapter…
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
<span class="edit-hint">Edit</span>
|
||||||
|
</div>
|
||||||
|
{#if record.totalChapters > 0}
|
||||||
|
<div class="record-progress-track">
|
||||||
|
<div class="record-progress-fill" style="width:{Math.min(100,(record.lastChapterRead/record.totalChapters)*100)}%"></div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@@ -434,7 +400,6 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ── Tracker search tab ─────────────────────────────────────────────── -->
|
|
||||||
{:else}
|
{:else}
|
||||||
{@const tracker = trackerFor(activeTab as number)}
|
{@const tracker = trackerFor(activeTab as number)}
|
||||||
{@const boundRecord = recordFor(activeTab as number)}
|
{@const boundRecord = recordFor(activeTab as number)}
|
||||||
@@ -517,8 +482,8 @@
|
|||||||
animation: fadeIn 0.12s ease both;
|
animation: fadeIn 0.12s ease both;
|
||||||
}
|
}
|
||||||
.modal {
|
.modal {
|
||||||
width: min(580px, calc(100vw - 48px));
|
width: min(560px, calc(100vw - 48px));
|
||||||
max-height: min(680px, calc(100vh - 80px));
|
max-height: min(660px, calc(100vh - 80px));
|
||||||
display: flex; flex-direction: column;
|
display: flex; flex-direction: column;
|
||||||
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;
|
||||||
@@ -532,9 +497,9 @@
|
|||||||
padding: var(--sp-4) var(--sp-5); border-bottom: 1px solid var(--border-dim); flex-shrink: 0;
|
padding: var(--sp-4) var(--sp-5); border-bottom: 1px solid var(--border-dim); flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.header-left { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
|
.header-left { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
|
||||||
.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-sm); 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); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.modal-subtitle { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.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: 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); 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); }
|
||||||
|
|
||||||
/* States */
|
/* States */
|
||||||
@@ -544,71 +509,100 @@
|
|||||||
.state-hint { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); text-align: center; }
|
.state-hint { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); text-align: center; }
|
||||||
|
|
||||||
/* Tabs */
|
/* Tabs */
|
||||||
.tabs { display: flex; align-items: center; gap: 2px; padding: var(--sp-2) var(--sp-3); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; overflow-x: auto; scrollbar-width: none; }
|
.tabs { display: flex; align-items: center; gap: 1px; padding: 0 var(--sp-4); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; overflow-x: auto; scrollbar-width: none; }
|
||||||
.tabs::-webkit-scrollbar { display: none; }
|
.tabs::-webkit-scrollbar { display: none; }
|
||||||
.tab { display: flex; align-items: center; gap: var(--sp-2); position: relative; font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 5px 10px; border-radius: var(--radius-sm); color: var(--text-faint); background: none; border: none; cursor: pointer; white-space: nowrap; transition: color var(--t-base), background var(--t-base); }
|
.tab {
|
||||||
.tab:hover { color: var(--text-muted); background: var(--bg-raised); }
|
display: flex; align-items: center; gap: var(--sp-2); position: relative;
|
||||||
.tab-active { color: var(--text-secondary); background: var(--bg-raised); }
|
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
|
||||||
.tab-icon { width: 14px; height: 14px; border-radius: 2px; object-fit: contain; }
|
padding: 10px 10px 9px; color: var(--text-faint);
|
||||||
.tab-badge { font-size: 10px; padding: 0 5px; border-radius: var(--radius-full); background: var(--accent-dim); color: var(--accent-fg); min-width: 16px; text-align: center; }
|
background: none; border: none; border-bottom: 2px solid transparent;
|
||||||
.tab-dot { position: absolute; top: 4px; right: 4px; width: 5px; height: 5px; border-radius: 50%; background: var(--accent); }
|
cursor: pointer; white-space: nowrap;
|
||||||
|
transition: color var(--t-base), border-color var(--t-base);
|
||||||
|
margin-bottom: -1px;
|
||||||
|
}
|
||||||
|
.tab:hover { color: var(--text-muted); }
|
||||||
|
.tab-active { color: var(--text-secondary); border-bottom-color: var(--accent); }
|
||||||
|
:global(.tab-icon) { width: 13px; height: 13px; border-radius: 2px; object-fit: contain; }
|
||||||
|
.tab-badge { font-size: 10px; padding: 0 4px; border-radius: var(--radius-full); background: var(--bg-overlay); color: var(--text-faint); min-width: 16px; text-align: center; line-height: 16px; }
|
||||||
|
.tab-active .tab-badge { background: var(--accent-muted); color: var(--accent-fg); }
|
||||||
|
.tab-dot { width: 5px; height: 5px; border-radius: 50%; background: var(--accent); flex-shrink: 0; }
|
||||||
|
|
||||||
/* Records */
|
/* Records */
|
||||||
.tab-body { flex: 1; overflow-y: auto; padding: var(--sp-3); scrollbar-width: none; display: flex; flex-direction: column; gap: var(--sp-2); }
|
.tab-body { flex: 1; overflow-y: auto; padding: var(--sp-3); scrollbar-width: none; display: flex; flex-direction: column; gap: var(--sp-2); }
|
||||||
.tab-body::-webkit-scrollbar { display: none; }
|
.tab-body::-webkit-scrollbar { display: none; }
|
||||||
.record-row { display: flex; flex-direction: column; gap: var(--sp-2); padding: var(--sp-4); border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: var(--bg-raised); transition: opacity var(--t-base); }
|
|
||||||
.record-busy { opacity: 0.5; pointer-events: none; }
|
.record-card {
|
||||||
.record-identity { display: flex; align-items: center; gap: var(--sp-2); min-width: 0; }
|
display: flex; flex-direction: column; gap: var(--sp-3);
|
||||||
.record-tracker-icon { width: 16px; height: 16px; border-radius: 3px; flex-shrink: 0; object-fit: contain; }
|
padding: var(--sp-4);
|
||||||
.record-title { display: inline-flex; align-items: center; gap: 3px; font-size: var(--text-sm); color: var(--accent-fg); text-decoration: none; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; transition: opacity var(--t-base); }
|
border-radius: var(--radius-lg);
|
||||||
.record-title:hover { opacity: 0.75; }
|
border: 1px solid var(--border-dim);
|
||||||
.record-title-plain { font-size: var(--text-sm); color: var(--text-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; }
|
background: var(--bg-raised);
|
||||||
.record-controls { display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap; }
|
transition: opacity var(--t-base), border-color var(--t-base);
|
||||||
.record-select {
|
|
||||||
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
|
|
||||||
padding: 4px 28px 4px 8px; border-radius: var(--radius-sm);
|
|
||||||
border: 1px solid var(--border-strong); background: var(--bg-overlay);
|
|
||||||
color: var(--text-secondary); outline: none; cursor: pointer;
|
|
||||||
appearance: none; -webkit-appearance: none;
|
|
||||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%23888' stroke-width='1.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E");
|
|
||||||
background-repeat: no-repeat; background-position: right 8px center;
|
|
||||||
transition: border-color var(--t-base);
|
|
||||||
}
|
}
|
||||||
.record-select:hover:not(:disabled) { border-color: var(--accent-dim); }
|
.record-card:hover { border-color: var(--border-strong); }
|
||||||
.record-select:focus { border-color: var(--accent); outline: none; }
|
.record-busy { opacity: 0.4; pointer-events: none; }
|
||||||
.record-select:disabled { opacity: 0.4; cursor: default; }
|
|
||||||
|
.record-head { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-2); }
|
||||||
|
.record-source { display: flex; align-items: center; gap: var(--sp-2); }
|
||||||
|
:global(.record-tracker-icon) { width: 14px; height: 14px; border-radius: 2px; flex-shrink: 0; object-fit: contain; opacity: 0.75; }
|
||||||
|
.record-source-name { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; }
|
||||||
|
.record-head-actions { display: flex; align-items: center; gap: 2px; }
|
||||||
|
|
||||||
|
.record-title { display: inline-flex; align-items: center; gap: 4px; font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-secondary); text-decoration: none; line-height: var(--leading-snug); transition: color var(--t-base); }
|
||||||
|
.record-title:hover { color: var(--accent-fg); }
|
||||||
|
.record-title-plain { font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-secondary); line-height: var(--leading-snug); }
|
||||||
|
|
||||||
|
.record-selects { display: flex; gap: var(--sp-2); flex-wrap: wrap; }
|
||||||
|
.record-select {
|
||||||
|
font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide);
|
||||||
|
padding: 5px 24px 5px 10px; border-radius: var(--radius-md);
|
||||||
|
border: 1px solid var(--border-dim); background: var(--bg-surface);
|
||||||
|
color: var(--text-muted); outline: none; cursor: pointer; flex: 1; min-width: 0;
|
||||||
|
appearance: none; -webkit-appearance: none;
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%23555' stroke-width='1.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E");
|
||||||
|
background-repeat: no-repeat; background-position: right 8px center;
|
||||||
|
transition: border-color var(--t-base), color var(--t-base);
|
||||||
|
}
|
||||||
|
.record-select:hover:not(:disabled) { border-color: var(--border-strong); color: var(--text-secondary); }
|
||||||
|
.record-select:focus { border-color: var(--accent-dim); color: var(--text-secondary); }
|
||||||
|
.record-select:disabled { opacity: 0.35; cursor: default; }
|
||||||
.record-select option { background: var(--bg-surface); color: var(--text-secondary); }
|
.record-select option { background: var(--bg-surface); color: var(--text-secondary); }
|
||||||
.record-select-score { max-width: 100px; }
|
.record-select-score { flex: 0 0 auto; min-width: 80px; }
|
||||||
.record-icon-btn { display: flex; align-items: center; justify-content: center; width: 26px; height: 26px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); cursor: pointer; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); flex-shrink: 0; }
|
.record-select-status { flex: 1; }
|
||||||
.record-icon-btn:hover:not(:disabled) { color: var(--text-muted); border-color: var(--border-strong); background: var(--bg-overlay); }
|
|
||||||
.record-icon-btn.icon-active { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
|
.record-icon-btn { display: flex; align-items: center; justify-content: center; width: 22px; height: 22px; border-radius: var(--radius-sm); border: none; background: none; color: var(--text-faint); cursor: pointer; transition: color var(--t-base), background var(--t-base); flex-shrink: 0; }
|
||||||
.record-icon-btn.icon-danger:hover:not(:disabled) { color: var(--color-error); border-color: var(--color-error); background: var(--color-error-bg); }
|
.record-icon-btn:hover:not(:disabled) { color: var(--text-muted); background: var(--bg-overlay); }
|
||||||
|
.record-icon-btn.icon-active { color: var(--accent-fg); }
|
||||||
|
.record-icon-btn.icon-danger:hover:not(:disabled) { color: var(--color-error); background: var(--color-error-bg); }
|
||||||
.record-icon-btn:disabled { opacity: 0.3; cursor: default; }
|
.record-icon-btn:disabled { opacity: 0.3; cursor: default; }
|
||||||
.record-progress { display: flex; flex-direction: column; gap: 4px; }
|
|
||||||
.record-progress.clickable { cursor: pointer; border-radius: var(--radius-sm); padding: 3px 4px; margin: -3px -4px; transition: background var(--t-fast); }
|
.record-progress { display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.record-progress.clickable { cursor: pointer; border-radius: var(--radius-sm); padding: 4px 6px; margin: -4px -6px; transition: background var(--t-fast); }
|
||||||
.record-progress.clickable:hover { background: var(--bg-overlay); }
|
.record-progress.clickable:hover { background: var(--bg-overlay); }
|
||||||
|
.record-progress-header { display: flex; align-items: center; justify-content: space-between; }
|
||||||
.record-progress-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
.record-progress-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||||
.edit-hint { opacity: 0; font-size: 10px; transition: opacity var(--t-fast); }
|
.edit-hint { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); opacity: 0; transition: opacity var(--t-fast); }
|
||||||
.record-progress.clickable:hover .edit-hint { opacity: 1; }
|
.record-progress.clickable:hover .edit-hint { opacity: 0.6; }
|
||||||
.record-progress-track { height: 2px; background: var(--border-base); border-radius: var(--radius-full); overflow: hidden; }
|
.record-progress-track { height: 2px; background: var(--border-strong); border-radius: var(--radius-full); overflow: hidden; }
|
||||||
.record-progress-fill { height: 100%; background: var(--accent); border-radius: var(--radius-full); transition: width 0.3s ease; }
|
.record-progress-fill { height: 100%; background: var(--accent); border-radius: var(--radius-full); transition: width 0.3s ease; }
|
||||||
|
|
||||||
/* Chapter editor */
|
/* Chapter editor */
|
||||||
.chapter-editor { display: flex; flex-direction: column; gap: var(--sp-2); padding: var(--sp-3); border-radius: var(--radius-md); border: 1px solid var(--accent-dim); background: var(--bg-overlay); }
|
.chapter-editor { display: flex; flex-direction: column; gap: var(--sp-2); padding: var(--sp-3); border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: var(--bg-surface); }
|
||||||
.chapter-editor-top { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-3); }
|
.chapter-editor-top { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-3); }
|
||||||
.chapter-editor-label { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
.chapter-editor-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||||
.chapter-input-wrap { display: flex; align-items: center; gap: var(--sp-2); }
|
.chapter-input-wrap { display: flex; align-items: center; gap: var(--sp-2); }
|
||||||
.chapter-input { width: 68px; background: var(--bg-raised); border: 1px solid var(--accent-dim); border-radius: var(--radius-sm); padding: 3px 6px; font-family: var(--font-ui); font-size: var(--text-sm); color: var(--text-primary); outline: none; text-align: center; }
|
.chapter-input { width: 64px; background: var(--bg-raised); border: 1px solid var(--border-strong); border-radius: var(--radius-sm); padding: 3px 6px; font-family: var(--font-ui); font-size: var(--text-sm); color: var(--text-primary); outline: none; text-align: center; }
|
||||||
.chapter-input:focus { border-color: var(--accent); }
|
.chapter-input:focus { border-color: var(--accent); }
|
||||||
.chapter-total { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); }
|
.chapter-total { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||||
.chapter-slider { width: 100%; accent-color: var(--accent); cursor: pointer; height: 4px; }
|
.chapter-slider { width: 100%; accent-color: var(--accent); cursor: pointer; height: 3px; }
|
||||||
.chapter-editor-actions { display: flex; gap: var(--sp-2); justify-content: flex-end; }
|
.chapter-editor-actions { display: flex; gap: var(--sp-2); justify-content: flex-end; }
|
||||||
.chapter-save-btn { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 3px 12px; border-radius: var(--radius-sm); border: 1px solid var(--accent); background: var(--accent-muted); color: var(--accent-fg); cursor: pointer; transition: filter var(--t-base); }
|
.chapter-save-btn { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 4px 12px; border-radius: var(--radius-sm); border: 1px solid var(--accent-dim); background: var(--accent-muted); color: var(--accent-fg); cursor: pointer; transition: filter var(--t-base); }
|
||||||
.chapter-save-btn:hover { filter: brightness(1.15); }
|
.chapter-save-btn:hover { filter: brightness(1.15); }
|
||||||
.chapter-cancel-btn { font-family: var(--font-ui); font-size: var(--text-xs); 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; transition: color var(--t-base), border-color var(--t-base); }
|
.chapter-cancel-btn { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 4px 8px; border-radius: var(--radius-sm); border: none; background: none; color: var(--text-faint); cursor: pointer; transition: color var(--t-base); }
|
||||||
.chapter-cancel-btn:hover { color: var(--text-muted); border-color: var(--border-strong); }
|
.chapter-cancel-btn:hover { color: var(--text-muted); }
|
||||||
|
|
||||||
/* Search */
|
/* Search */
|
||||||
.search-bar { display: flex; align-items: center; gap: var(--sp-2); padding: var(--sp-3) var(--sp-4); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
|
.search-bar { display: flex; align-items: center; gap: var(--sp-2); padding: var(--sp-3) var(--sp-4); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; background: var(--bg-surface); }
|
||||||
:global(.search-icon) { color: var(--text-faint); flex-shrink: 0; }
|
:global(.search-icon) { color: var(--text-faint); flex-shrink: 0; }
|
||||||
.search-input { flex: 1; background: none; border: none; outline: none; font-size: var(--text-sm); color: var(--text-primary); }
|
.search-input { flex: 1; background: none; border: none; outline: none; font-size: var(--text-sm); color: var(--text-primary); }
|
||||||
.search-input::placeholder { color: var(--text-faint); }
|
.search-input::placeholder { color: var(--text-faint); }
|
||||||
@@ -618,17 +612,16 @@
|
|||||||
/* Results */
|
/* Results */
|
||||||
.result-row { display: flex; align-items: flex-start; gap: var(--sp-3); width: 100%; padding: var(--sp-3); border-radius: var(--radius-md); border: none; background: none; text-align: left; cursor: pointer; transition: background var(--t-fast); }
|
.result-row { display: flex; align-items: flex-start; gap: var(--sp-3); width: 100%; padding: var(--sp-3); border-radius: var(--radius-md); border: none; background: none; text-align: left; 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.4; cursor: default; }
|
||||||
.result-bound { background: var(--accent-muted) !important; }
|
.result-bound { background: color-mix(in srgb, var(--accent) 8%, transparent) !important; }
|
||||||
.result-cover { width: 46px; height: 66px; object-fit: cover; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); flex-shrink: 0; }
|
.result-cover { width: 44px; height: 62px; object-fit: cover; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); flex-shrink: 0; }
|
||||||
.result-cover-empty { background: var(--bg-raised); }
|
.result-cover-empty { background: var(--bg-raised); }
|
||||||
.hidden { display: none; }
|
|
||||||
.result-info { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: var(--sp-1); padding-top: 2px; }
|
.result-info { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: var(--sp-1); padding-top: 2px; }
|
||||||
.result-title { font-size: var(--text-sm); color: var(--text-secondary); line-height: var(--leading-snug); text-align: left; }
|
.result-title { font-size: var(--text-sm); color: var(--text-secondary); line-height: var(--leading-snug); text-align: left; }
|
||||||
.result-meta { display: flex; flex-wrap: wrap; gap: var(--sp-1); }
|
.result-meta { display: flex; flex-wrap: wrap; gap: var(--sp-1); }
|
||||||
.result-tag { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 1px 6px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: var(--bg-raised); color: var(--text-faint); }
|
.result-tag { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 1px 5px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: var(--bg-raised); color: var(--text-faint); }
|
||||||
.result-summary { font-size: var(--text-xs); color: var(--text-faint); line-height: var(--leading-snug); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; text-align: left; }
|
.result-summary { font-size: var(--text-xs); color: var(--text-faint); line-height: var(--leading-snug); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; text-align: left; }
|
||||||
.result-action { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 4px 12px; border-radius: var(--radius-sm); border: 1px solid var(--border-strong); background: none; color: var(--text-faint); flex-shrink: 0; align-self: center; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
|
.result-action { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 3px 10px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); flex-shrink: 0; align-self: center; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
|
||||||
.result-row:hover:not(:disabled) .result-action { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
|
.result-row:hover:not(:disabled) .result-action { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
|
||||||
.result-action-on { color: var(--accent-fg) !important; border-color: var(--accent-dim) !important; background: var(--accent-muted) !important; }
|
.result-action-on { color: var(--accent-fg) !important; border-color: var(--accent-dim) !important; background: var(--accent-muted) !important; }
|
||||||
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -12,8 +12,6 @@
|
|||||||
|
|
||||||
let { editingId = $bindable(null), onClose }: Props = $props();
|
let { editingId = $bindable(null), onClose }: Props = $props();
|
||||||
|
|
||||||
// ── Token group definitions ───────────────────────────────────────────────
|
|
||||||
|
|
||||||
const TOKEN_GROUPS: { label: string; tokens: (keyof ThemeTokens)[] }[] = [
|
const TOKEN_GROUPS: { label: string; tokens: (keyof ThemeTokens)[] }[] = [
|
||||||
{
|
{
|
||||||
label: "Backgrounds",
|
label: "Backgrounds",
|
||||||
@@ -65,8 +63,6 @@
|
|||||||
"color-info-bg": "Info background",
|
"color-info-bg": "Info background",
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── State ─────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function loadInitial(): { name: string; tokens: ThemeTokens } {
|
function loadInitial(): { name: string; tokens: ThemeTokens } {
|
||||||
if (editingId) {
|
if (editingId) {
|
||||||
const existing = store.settings.customThemes.find(t => t.id === editingId);
|
const existing = store.settings.customThemes.find(t => t.id === editingId);
|
||||||
@@ -81,13 +77,10 @@
|
|||||||
let saveStatus: "idle" | "saved" = $state("idle");
|
let saveStatus: "idle" | "saved" = $state("idle");
|
||||||
let importError: string | null = $state(null);
|
let importError: string | null = $state(null);
|
||||||
|
|
||||||
// ── CSS vars helper ───────────────────────────────────────────────────────
|
|
||||||
function toCssVars(t: ThemeTokens): string {
|
function toCssVars(t: ThemeTokens): string {
|
||||||
return Object.entries(t).map(([k, v]) => `--${k}: ${v};`).join(" ");
|
return Object.entries(t).map(([k, v]) => `--${k}: ${v};`).join(" ");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Actions ───────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function handleSave() {
|
function handleSave() {
|
||||||
const name = themeName.trim() || "Untitled Theme";
|
const name = themeName.trim() || "Untitled Theme";
|
||||||
const id = editingId ?? `custom:${Math.random().toString(36).slice(2, 10)}`;
|
const id = editingId ?? `custom:${Math.random().toString(36).slice(2, 10)}`;
|
||||||
@@ -154,17 +147,15 @@
|
|||||||
|
|
||||||
<svelte:window onkeydown={onKey} />
|
<svelte:window onkeydown={onKey} />
|
||||||
|
|
||||||
<!-- ── Main editor ────────────────────────────────────────────────────────────── -->
|
<div class="te-backdrop" role="presentation" onclick={onClose} onkeydown={(e) => e.key === "Escape" && onClose()}>
|
||||||
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
|
||||||
<div class="te-backdrop" onclick={onClose} role="presentation">
|
|
||||||
<div
|
<div
|
||||||
class="te-shell"
|
class="te-shell"
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-label="Theme editor"
|
aria-label="Theme editor"
|
||||||
|
tabindex="0"
|
||||||
onclick={(e) => e.stopPropagation()}
|
onclick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
|
|
||||||
<!-- ── Header ──────────────────────────────────────────────────────── -->
|
|
||||||
<header class="te-header">
|
<header class="te-header">
|
||||||
<div class="te-header-left">
|
<div class="te-header-left">
|
||||||
<button class="te-icon-btn" onclick={onClose} title="Close editor">
|
<button class="te-icon-btn" onclick={onClose} title="Close editor">
|
||||||
@@ -209,25 +200,17 @@
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<!-- ── Body ───────────────────────────────────────────────────────── -->
|
|
||||||
<div class="te-body">
|
<div class="te-body">
|
||||||
|
|
||||||
<!-- Left: live preview -->
|
|
||||||
<aside class="te-preview-pane">
|
<aside class="te-preview-pane">
|
||||||
<div class="te-pane-label">Live Preview</div>
|
<div class="te-pane-label">Live Preview</div>
|
||||||
|
|
||||||
<!--
|
|
||||||
FIX 1: toCssVars scoped only to this element, so only the
|
|
||||||
preview UI sees the draft tokens — not the editor shell.
|
|
||||||
-->
|
|
||||||
<div class="te-preview-ui" style={toCssVars(tokens)}>
|
<div class="te-preview-ui" style={toCssVars(tokens)}>
|
||||||
<!-- Sidebar -->
|
|
||||||
<div class="prv-sidebar">
|
<div class="prv-sidebar">
|
||||||
{#each [true, false, false, false] as active}
|
{#each [true, false, false, false] as active}
|
||||||
<div class="prv-sb-dot" class:active></div>
|
<div class="prv-sb-dot" class:active></div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
<!-- Main -->
|
|
||||||
<div class="prv-main">
|
<div class="prv-main">
|
||||||
<div class="prv-titlebar">
|
<div class="prv-titlebar">
|
||||||
<div class="prv-win-dots">
|
<div class="prv-win-dots">
|
||||||
@@ -262,7 +245,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Swatch strip — scoped to draft tokens too -->
|
|
||||||
<div class="te-swatches" style={toCssVars(tokens)}>
|
<div class="te-swatches" style={toCssVars(tokens)}>
|
||||||
{#each [
|
{#each [
|
||||||
["bg-base","bg-base"],["bg-surface","bg-surface"],
|
["bg-base","bg-base"],["bg-surface","bg-surface"],
|
||||||
@@ -279,7 +261,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<!-- Right: token editor -->
|
|
||||||
<div class="te-editor-pane">
|
<div class="te-editor-pane">
|
||||||
{#each TOKEN_GROUPS as group}
|
{#each TOKEN_GROUPS as group}
|
||||||
<div class="te-group">
|
<div class="te-group">
|
||||||
@@ -287,7 +268,14 @@
|
|||||||
<div class="te-token-list">
|
<div class="te-token-list">
|
||||||
{#each group.tokens as token}
|
{#each group.tokens as token}
|
||||||
<div class="te-token-row">
|
<div class="te-token-row">
|
||||||
<span class="te-color-swatch" style="background: {tokens[token]}"></span>
|
<label class="te-color-swatch" style="background: {tokens[token]}" title="Pick colour">
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
class="te-color-picker"
|
||||||
|
value={tokens[token].length === 7 ? tokens[token] : tokens[token].slice(0,7)}
|
||||||
|
oninput={(e) => { tokens = { ...tokens, [token]: (e.target as HTMLInputElement).value }; }}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
<span class="te-token-name">{TOKEN_LABELS[token]}</span>
|
<span class="te-token-name">{TOKEN_LABELS[token]}</span>
|
||||||
<span class="te-token-key">{token}</span>
|
<span class="te-token-key">{token}</span>
|
||||||
<input
|
<input
|
||||||
@@ -318,20 +306,16 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
/* ── Backdrop ─────────────────────────────────────────────────────────────── */
|
|
||||||
.te-backdrop {
|
.te-backdrop {
|
||||||
position: fixed; inset: 0;
|
position: fixed; inset: 0;
|
||||||
background: rgba(0, 0, 0, 0.72);
|
background: rgba(0, 0, 0, 0.72);
|
||||||
z-index: 200;
|
z-index: 200;
|
||||||
/* FIX 2: center the modal instead of stretch */
|
|
||||||
display: flex; align-items: center; justify-content: center;
|
display: flex; align-items: center; justify-content: center;
|
||||||
animation: teBackdropIn 0.14s ease both;
|
animation: teBackdropIn 0.14s ease both;
|
||||||
}
|
}
|
||||||
@keyframes teBackdropIn { from { opacity: 0; } to { opacity: 1; } }
|
@keyframes teBackdropIn { from { opacity: 0; } to { opacity: 1; } }
|
||||||
|
|
||||||
/* ── Shell ────────────────────────────────────────────────────────────────── */
|
|
||||||
.te-shell {
|
.te-shell {
|
||||||
/* FIX 2: constrained dimensions so it doesn't fill the screen */
|
|
||||||
width: calc(100% - 48px);
|
width: calc(100% - 48px);
|
||||||
max-width: 1100px;
|
max-width: 1100px;
|
||||||
height: calc(100% - 48px);
|
height: calc(100% - 48px);
|
||||||
@@ -348,7 +332,6 @@
|
|||||||
to { transform: translateY(0) scale(1); opacity: 1; }
|
to { transform: translateY(0) scale(1); opacity: 1; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Header ───────────────────────────────────────────────────────────────── */
|
|
||||||
.te-header {
|
.te-header {
|
||||||
display: flex; align-items: center; justify-content: space-between;
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
gap: 12px; padding: 0 16px; height: 46px;
|
gap: 12px; padding: 0 16px; height: 46px;
|
||||||
@@ -420,10 +403,8 @@
|
|||||||
.te-save-btn:hover { filter: brightness(1.12); }
|
.te-save-btn:hover { filter: brightness(1.12); }
|
||||||
.te-save-btn.saved { background: var(--accent-dim); border-color: var(--accent); }
|
.te-save-btn.saved { background: var(--accent-dim); border-color: var(--accent); }
|
||||||
|
|
||||||
/* ── Body ─────────────────────────────────────────────────────────────────── */
|
|
||||||
.te-body { flex: 1; overflow: hidden; display: flex; min-height: 0; }
|
.te-body { flex: 1; overflow: hidden; display: flex; min-height: 0; }
|
||||||
|
|
||||||
/* ── Preview pane ─────────────────────────────────────────────────────────── */
|
|
||||||
.te-preview-pane {
|
.te-preview-pane {
|
||||||
width: 260px; flex-shrink: 0;
|
width: 260px; flex-shrink: 0;
|
||||||
border-right: 1px solid var(--border-dim);
|
border-right: 1px solid var(--border-dim);
|
||||||
@@ -439,7 +420,6 @@
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* te-preview-ui receives draft CSS vars via inline style */
|
|
||||||
.te-preview-ui {
|
.te-preview-ui {
|
||||||
flex: 1; min-height: 0;
|
flex: 1; min-height: 0;
|
||||||
border-radius: 8px; overflow: hidden;
|
border-radius: 8px; overflow: hidden;
|
||||||
@@ -447,7 +427,6 @@
|
|||||||
display: flex; background: var(--bg-void);
|
display: flex; background: var(--bg-void);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Sidebar strip */
|
|
||||||
.prv-sidebar {
|
.prv-sidebar {
|
||||||
width: 34px; flex-shrink: 0;
|
width: 34px; flex-shrink: 0;
|
||||||
background: var(--bg-surface);
|
background: var(--bg-surface);
|
||||||
@@ -512,7 +491,6 @@
|
|||||||
.prv-toast-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--accent); flex-shrink: 0; }
|
.prv-toast-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--accent); flex-shrink: 0; }
|
||||||
.prv-toast-lines { flex: 1; }
|
.prv-toast-lines { flex: 1; }
|
||||||
|
|
||||||
/* Swatch strip */
|
|
||||||
.te-swatches { display: flex; gap: 5px; flex-wrap: wrap; flex-shrink: 0; }
|
.te-swatches { display: flex; gap: 5px; flex-wrap: wrap; flex-shrink: 0; }
|
||||||
.te-swatch {
|
.te-swatch {
|
||||||
width: 22px; height: 22px; border-radius: 4px;
|
width: 22px; height: 22px; border-radius: 4px;
|
||||||
@@ -520,7 +498,6 @@
|
|||||||
flex-shrink: 0; cursor: default;
|
flex-shrink: 0; cursor: default;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Editor pane ──────────────────────────────────────────────────────────── */
|
|
||||||
.te-editor-pane {
|
.te-editor-pane {
|
||||||
flex: 1; overflow-y: auto;
|
flex: 1; overflow-y: auto;
|
||||||
padding: 16px 20px;
|
padding: 16px 20px;
|
||||||
@@ -552,10 +529,23 @@
|
|||||||
.te-token-row:hover { background: var(--bg-raised); }
|
.te-token-row:hover { background: var(--bg-raised); }
|
||||||
|
|
||||||
.te-color-swatch {
|
.te-color-swatch {
|
||||||
width: 16px; height: 16px; border-radius: 4px;
|
width: 36px; height: 18px; border-radius: 5px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
border: 1px solid rgba(255,255,255,0.12);
|
border: 1px solid rgba(255,255,255,0.12);
|
||||||
box-shadow: 0 0 0 1px rgba(0,0,0,0.2);
|
box-shadow: 0 0 0 1px rgba(0,0,0,0.2);
|
||||||
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.te-color-swatch:hover { box-shadow: 0 0 0 2px var(--border-focus); }
|
||||||
|
|
||||||
|
.te-color-picker {
|
||||||
|
position: absolute; inset: 0;
|
||||||
|
width: 100%; height: 100%;
|
||||||
|
opacity: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0; border: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.te-token-name {
|
.te-token-name {
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount, onDestroy } from "svelte";
|
import { onMount, onDestroy } from "svelte";
|
||||||
import { X, BookmarkSimple, ArrowSquareOut, Play, CircleNotch, Books, CaretDown, FolderSimplePlus, Folder, LinkSimpleHorizontalBreak } from "phosphor-svelte";
|
import { X, BookmarkSimple, ArrowSquareOut, Play, CircleNotch, Books, CaretDown, FolderSimplePlus, Folder, LinkSimpleHorizontalBreak } from "phosphor-svelte";
|
||||||
import { gql, thumbUrl } from "../../lib/client";
|
import { gql } from "../../lib/client";
|
||||||
|
import Thumbnail from "../shared/Thumbnail.svelte";
|
||||||
import { GET_MANGA, GET_CHAPTERS, FETCH_MANGA, FETCH_CHAPTERS, UPDATE_MANGA, ENQUEUE_CHAPTERS_DOWNLOAD, GET_CATEGORIES, CREATE_CATEGORY, UPDATE_MANGA_CATEGORIES } from "../../lib/queries";
|
import { GET_MANGA, GET_CHAPTERS, FETCH_MANGA, FETCH_CHAPTERS, UPDATE_MANGA, ENQUEUE_CHAPTERS_DOWNLOAD, GET_CATEGORIES, CREATE_CATEGORY, UPDATE_MANGA_CATEGORIES } from "../../lib/queries";
|
||||||
import { GET_ALL_MANGA } from "../../lib/queries";
|
import { GET_ALL_MANGA } from "../../lib/queries";
|
||||||
import { cache, CACHE_KEYS } from "../../lib/cache";
|
import { cache, CACHE_KEYS } from "../../lib/cache";
|
||||||
@@ -248,7 +249,7 @@
|
|||||||
|
|
||||||
<div class="cover-col">
|
<div class="cover-col">
|
||||||
<div class="cover-wrap">
|
<div class="cover-wrap">
|
||||||
<img src={thumbUrl(store.previewManga.thumbnailUrl)} alt={displayManga?.title} class="cover" />
|
<Thumbnail src={store.previewManga.thumbnailUrl} alt={displayManga?.title} class="cover" />
|
||||||
{#if loadingDetail}
|
{#if loadingDetail}
|
||||||
<div class="cover-spinner"><CircleNotch size={18} weight="light" class="anim-spin" /></div>
|
<div class="cover-spinner"><CircleNotch size={18} weight="light" class="anim-spin" /></div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -356,7 +357,7 @@
|
|||||||
<div class="progress-track"><div class="progress-fill" style="width:{(readCount / totalCount) * 100}%"></div></div>
|
<div class="progress-track"><div class="progress-fill" style="width:{(readCount / totalCount) * 100}%"></div></div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if continueChapter}
|
{#if continueChapter}
|
||||||
<button class="read-btn" onclick={() => { openReader(continueChapter!.ch, chapters); close(); }}>
|
<button class="read-btn" onclick={() => { openReader(continueChapter!.ch, chapters, displayManga); close(); }}>
|
||||||
<Play size={12} weight="fill" />{continueChapter.label}
|
<Play size={12} weight="fill" />{continueChapter.label}
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -393,19 +394,18 @@
|
|||||||
|
|
||||||
{#if !loadingDetail}
|
{#if !loadingDetail}
|
||||||
<div class="meta-table">
|
<div class="meta-table">
|
||||||
{#if displayManga?.author}<div class="meta-row"><span class="meta-key">Author</span><span class="meta-val">{displayManga.author}</span></div>{/if}
|
<div class="meta-grid">
|
||||||
{#if displayManga?.artist && displayManga.artist !== displayManga.author}<div class="meta-row"><span class="meta-key">Artist</span><span class="meta-val">{displayManga.artist}</span></div>{/if}
|
<div class="meta-col">
|
||||||
{#if statusLabel}<div class="meta-row"><span class="meta-key">Status</span><span class="meta-val">{statusLabel}</span></div>{/if}
|
<div class="meta-row"><span class="meta-key">Status</span><span class="meta-val">{statusLabel ?? "N/A"}</span></div>
|
||||||
{#if displayManga?.source}<div class="meta-row"><span class="meta-key">Source</span><span class="meta-val">{displayManga.source.displayName}</span></div>{/if}
|
<div class="meta-row"><span class="meta-key">Source</span><span class="meta-val">{displayManga?.source?.displayName ?? "N/A"}</span></div>
|
||||||
{#if !loadingChapters && scanlators.length > 0}<div class="meta-row"><span class="meta-key">{scanlators.length === 1 ? "Scanlator" : "Scanlators"}</span><span class="meta-val">{scanlators.join(", ")}</span></div>{/if}
|
<div class="meta-row"><span class="meta-key">Link</span>{#if displayManga?.realUrl}<a href={displayManga.realUrl} target="_blank" rel="noreferrer" class="meta-link">Open <ArrowSquareOut size={11} weight="light" /></a>{:else}<span class="meta-val">N/A</span>{/if}</div>
|
||||||
{#if !loadingChapters && firstUpload && lastUpload}
|
|
||||||
<div class="meta-row">
|
|
||||||
<span class="meta-key">Published</span>
|
|
||||||
<span class="meta-val">{firstUpload.getTime() === lastUpload.getTime() ? formatDate(firstUpload) : `${formatDate(firstUpload)} – ${formatDate(lastUpload)}`}</span>
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
<div class="meta-col">
|
||||||
{#if !loadingChapters && downloadedCount > 0}<div class="meta-row"><span class="meta-key">Downloaded</span><span class="meta-val">{downloadedCount} / {totalCount} chapters</span></div>{/if}
|
<div class="meta-row"><span class="meta-key">Author</span><span class="meta-val">{displayManga?.author ?? "N/A"}</span></div>
|
||||||
{#if displayManga?.realUrl}<div class="meta-row"><span class="meta-key">Link</span><a href={displayManga.realUrl} target="_blank" rel="noreferrer" class="meta-link">Open <ArrowSquareOut size={11} weight="light" /></a></div>{/if}
|
<div class="meta-row"><span class="meta-key">Artist</span><span class="meta-val">{displayManga?.artist && displayManga.artist !== displayManga.author ? displayManga.artist : (displayManga?.author ?? "N/A")}</span></div>
|
||||||
|
<div class="meta-row"><span class="meta-key">Scanlator</span><span class="meta-val">{!loadingChapters && scanlators.length > 0 ? scanlators[0] : "N/A"}</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -438,7 +438,7 @@
|
|||||||
{#each linkPickerResults as m (m.id)}
|
{#each linkPickerResults as m (m.id)}
|
||||||
{@const isLinked = linkedIds.includes(m.id)}
|
{@const isLinked = linkedIds.includes(m.id)}
|
||||||
<button class="link-row" class:link-row-linked={isLinked} onclick={() => handleLink(m)}>
|
<button class="link-row" class:link-row-linked={isLinked} onclick={() => handleLink(m)}>
|
||||||
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} class="link-thumb" loading="lazy" decoding="async" />
|
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="link-thumb" />
|
||||||
<div class="link-info">
|
<div class="link-info">
|
||||||
<span class="link-manga-title">{m.title}</span>
|
<span class="link-manga-title">{m.title}</span>
|
||||||
{#if m.source?.displayName}<span class="link-source">{m.source.displayName}</span>{/if}
|
{#if m.source?.displayName}<span class="link-source">{m.source.displayName}</span>{/if}
|
||||||
@@ -463,7 +463,7 @@
|
|||||||
.modal { width: min(800px, calc(100vw - 48px)); height: min(560px, calc(100vh - 80px)); display: flex; background: var(--bg-surface); border: 1px solid var(--border-base); border-radius: var(--radius-xl); overflow: hidden; animation: scaleIn 0.16s ease both; box-shadow: 0 0 0 1px var(--border-dim), 0 24px 64px rgba(0,0,0,0.6); }
|
.modal { width: min(800px, calc(100vw - 48px)); height: min(560px, calc(100vh - 80px)); display: flex; background: var(--bg-surface); border: 1px solid var(--border-base); border-radius: var(--radius-xl); overflow: hidden; animation: scaleIn 0.16s ease both; box-shadow: 0 0 0 1px var(--border-dim), 0 24px 64px rgba(0,0,0,0.6); }
|
||||||
.cover-col { width: 190px; flex-shrink: 0; background: var(--bg-raised); border-right: 1px solid var(--border-dim); display: flex; flex-direction: column; padding: var(--sp-5) var(--sp-4) var(--sp-4); gap: var(--sp-3); overflow: hidden; }
|
.cover-col { width: 190px; flex-shrink: 0; background: var(--bg-raised); border-right: 1px solid var(--border-dim); display: flex; flex-direction: column; padding: var(--sp-5) var(--sp-4) var(--sp-4); gap: var(--sp-3); overflow: hidden; }
|
||||||
.cover-wrap { position: relative; width: 100%; }
|
.cover-wrap { position: relative; width: 100%; }
|
||||||
.cover { width: 100%; aspect-ratio: 2/3; object-fit: cover; border-radius: var(--radius-md); border: 1px solid var(--border-dim); display: block; }
|
:global(.cover) { width: 100%; aspect-ratio: 2/3; object-fit: cover; border-radius: var(--radius-md); border: 1px solid var(--border-dim); display: block; }
|
||||||
.cover-spinner { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; background: rgba(0,0,0,0.35); border-radius: var(--radius-md); color: var(--text-faint); }
|
.cover-spinner { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; background: rgba(0,0,0,0.35); border-radius: var(--radius-md); color: var(--text-faint); }
|
||||||
.cover-actions { display: flex; flex-direction: column; gap: var(--sp-2); }
|
.cover-actions { display: flex; flex-direction: column; gap: var(--sp-2); }
|
||||||
.action-btn { display: flex; align-items: center; gap: var(--sp-2); width: 100%; padding: 7px var(--sp-3); border-radius: var(--radius-md); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); border: 1px solid var(--border-strong); background: none; color: var(--text-muted); cursor: pointer; text-align: left; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
|
.action-btn { display: flex; align-items: center; gap: var(--sp-2); width: 100%; padding: 7px var(--sp-3); border-radius: var(--radius-md); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); border: 1px solid var(--border-strong); background: none; color: var(--text-muted); cursor: pointer; text-align: left; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
|
||||||
@@ -528,9 +528,11 @@
|
|||||||
.genre-tag { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 3px 8px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: var(--bg-raised); color: var(--text-faint); cursor: pointer; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
|
.genre-tag { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 3px 8px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: var(--bg-raised); color: var(--text-faint); cursor: pointer; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
|
||||||
.genre-tag:hover { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
|
.genre-tag:hover { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
|
||||||
.meta-table { display: flex; flex-direction: column; gap: 1px; border-top: 1px solid var(--border-dim); padding-top: var(--sp-3); }
|
.meta-table { display: flex; flex-direction: column; gap: 1px; border-top: 1px solid var(--border-dim); padding-top: var(--sp-3); }
|
||||||
|
.meta-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0 var(--sp-4); }
|
||||||
|
.meta-col { display: flex; flex-direction: column; }
|
||||||
.meta-row { display: flex; align-items: baseline; gap: var(--sp-3); padding: 5px 0; }
|
.meta-row { display: flex; align-items: baseline; gap: var(--sp-3); padding: 5px 0; }
|
||||||
.meta-key { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); text-transform: uppercase; min-width: 56px; flex-shrink: 0; }
|
.meta-key { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); text-transform: uppercase; min-width: 56px; flex-shrink: 0; }
|
||||||
.meta-val { font-size: var(--text-sm); color: var(--text-secondary); line-height: var(--leading-snug); }
|
.meta-val { font-size: var(--text-sm); color: var(--text-secondary); line-height: var(--leading-snug); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.meta-link { display: inline-flex; align-items: center; gap: 4px; font-size: var(--text-sm); color: var(--accent-fg); text-decoration: none; transition: opacity var(--t-base); }
|
.meta-link { display: inline-flex; align-items: center; gap: 4px; font-size: var(--text-sm); color: var(--accent-fg); text-decoration: none; transition: opacity var(--t-base); }
|
||||||
.meta-link:hover { opacity: 0.75; }
|
.meta-link:hover { opacity: 0.75; }
|
||||||
.link-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.65); z-index: calc(var(--z-settings) + 1); display: flex; align-items: center; justify-content: center; backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px); animation: fadeIn 0.1s ease both; }
|
.link-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.65); z-index: calc(var(--z-settings) + 1); display: flex; align-items: center; justify-content: center; backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px); animation: fadeIn 0.1s ease both; }
|
||||||
@@ -547,7 +549,7 @@
|
|||||||
.link-row { display: flex; align-items: center; gap: var(--sp-3); width: 100%; padding: 8px var(--sp-3); border-radius: var(--radius-md); border: none; background: none; text-align: left; cursor: pointer; transition: background var(--t-fast); }
|
.link-row { display: flex; align-items: center; gap: var(--sp-3); width: 100%; padding: 8px var(--sp-3); border-radius: var(--radius-md); border: none; background: none; text-align: left; cursor: pointer; transition: background var(--t-fast); }
|
||||||
.link-row:hover { background: var(--bg-raised); }
|
.link-row:hover { background: var(--bg-raised); }
|
||||||
.link-row-linked { background: var(--accent-muted) !important; }
|
.link-row-linked { background: var(--accent-muted) !important; }
|
||||||
.link-thumb { width: 34px; height: 48px; border-radius: var(--radius-sm); object-fit: cover; flex-shrink: 0; border: 1px solid var(--border-dim); }
|
:global(.link-thumb) { width: 34px; height: 48px; border-radius: var(--radius-sm); object-fit: cover; flex-shrink: 0; border: 1px solid var(--border-dim); }
|
||||||
.link-info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; min-width: 0; }
|
.link-info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; min-width: 0; }
|
||||||
.link-manga-title { font-size: var(--text-sm); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
.link-manga-title { font-size: var(--text-sm); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
.link-source { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
.link-source { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { ArrowLeft, MagnifyingGlass, ArrowLeft as Prev, ArrowRight as Next, BookmarkSimple, FolderSimplePlus, Folder } from "phosphor-svelte";
|
import { ArrowLeft, MagnifyingGlass, ArrowLeft as Prev, ArrowRight as Next, BookmarkSimple, FolderSimplePlus, Folder } from "phosphor-svelte";
|
||||||
import { gql, thumbUrl } from "../../lib/client";
|
import { gql } from "../../lib/client";
|
||||||
|
import Thumbnail from "../shared/Thumbnail.svelte";
|
||||||
import { FETCH_SOURCE_MANGA, UPDATE_MANGA, GET_CATEGORIES, CREATE_CATEGORY, UPDATE_MANGA_CATEGORIES } from "../../lib/queries";
|
import { FETCH_SOURCE_MANGA, UPDATE_MANGA, GET_CATEGORIES, CREATE_CATEGORY, UPDATE_MANGA_CATEGORIES } from "../../lib/queries";
|
||||||
import { store, setActiveSource, setActiveManga, setNavPage } from "../../store/state.svelte";
|
import { store, setActiveSource, setActiveManga, setNavPage } from "../../store/state.svelte";
|
||||||
import type { Manga, Category } from "../../lib/types";
|
import type { Manga, Category } from "../../lib/types";
|
||||||
@@ -120,7 +121,7 @@
|
|||||||
<button class="card" onclick={() => { setActiveManga(m); setNavPage("library"); }}
|
<button class="card" onclick={() => { setActiveManga(m); setNavPage("library"); }}
|
||||||
oncontextmenu={(e) => openCtx(e, m)}>
|
oncontextmenu={(e) => openCtx(e, m)}>
|
||||||
<div class="cover-wrap">
|
<div class="cover-wrap">
|
||||||
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} class="cover" />
|
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="cover" />
|
||||||
{#if m.inLibrary}<span class="in-library-badge">In Library</span>{/if}
|
{#if m.inLibrary}<span class="in-library-badge">In Library</span>{/if}
|
||||||
</div>
|
</div>
|
||||||
<p class="title">{m.title}</p>
|
<p class="title">{m.title}</p>
|
||||||
@@ -165,10 +166,10 @@
|
|||||||
.search:focus { border-color: var(--border-strong); }
|
.search:focus { border-color: var(--border-strong); }
|
||||||
.grid, .loading-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(clamp(100px,14vw,140px),1fr)); gap: var(--sp-4); padding: var(--sp-5) var(--sp-6); overflow-y: auto; flex: 1; align-content: start; will-change: scroll-position; -webkit-overflow-scrolling: touch; contain: layout style; }
|
.grid, .loading-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(clamp(100px,14vw,140px),1fr)); gap: var(--sp-4); padding: var(--sp-5) var(--sp-6); overflow-y: auto; flex: 1; align-content: start; will-change: scroll-position; -webkit-overflow-scrolling: touch; contain: layout style; }
|
||||||
.card { background: none; border: none; padding: 0; cursor: pointer; text-align: left; }
|
.card { background: none; border: none; padding: 0; cursor: pointer; text-align: left; }
|
||||||
.card:hover .cover { filter: brightness(1.06); }
|
.card:hover :global(.cover) { filter: brightness(1.06); }
|
||||||
.card:hover .title { color: var(--text-primary); }
|
.card:hover .title { color: var(--text-primary); }
|
||||||
.cover-wrap { position: relative; aspect-ratio: 2/3; overflow: hidden; border-radius: var(--radius-md); background: var(--bg-raised); border: 1px solid var(--border-dim); transform: translateZ(0); }
|
.cover-wrap { position: relative; aspect-ratio: 2/3; overflow: hidden; border-radius: var(--radius-md); background: var(--bg-raised); border: 1px solid var(--border-dim); transform: translateZ(0); }
|
||||||
.cover { width: 100%; height: 100%; object-fit: cover; transition: filter var(--t-base); will-change: filter; }
|
:global(.cover) { width: 100%; height: 100%; object-fit: cover; transition: filter var(--t-base); will-change: filter; }
|
||||||
.in-library-badge { position: absolute; bottom: var(--sp-1); left: var(--sp-1); font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); text-transform: uppercase; background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim); padding: 2px 5px; border-radius: var(--radius-sm); }
|
.in-library-badge { position: absolute; bottom: var(--sp-1); left: var(--sp-1); font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); text-transform: uppercase; background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim); padding: 2px 5px; border-radius: var(--radius-sm); }
|
||||||
.title { margin-top: var(--sp-2); font-size: var(--text-sm); color: var(--text-secondary); line-height: var(--leading-snug); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; transition: color var(--t-base); }
|
.title { margin-top: var(--sp-2); font-size: var(--text-sm); color: var(--text-secondary); line-height: var(--leading-snug); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; transition: color var(--t-base); }
|
||||||
.card-skeleton { padding: 0; }
|
.card-skeleton { padding: 0; }
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { onMount } from "svelte";
|
||||||
import { MagnifyingGlass, CircleNotch, CaretDown, CaretRight } from "phosphor-svelte";
|
import { MagnifyingGlass, CircleNotch, CaretDown, CaretRight } from "phosphor-svelte";
|
||||||
import { gql, thumbUrl } from "../../lib/client";
|
import { gql } from "../../lib/client";
|
||||||
|
import Thumbnail from "../shared/Thumbnail.svelte";
|
||||||
import { GET_SOURCES } from "../../lib/queries";
|
import { GET_SOURCES } from "../../lib/queries";
|
||||||
import { store } from "../../store/state.svelte";
|
import { store } from "../../store/state.svelte";
|
||||||
import type { Source } from "../../lib/types";
|
import type { Source } from "../../lib/types";
|
||||||
@@ -11,7 +13,7 @@
|
|||||||
let search = $state("");
|
let search = $state("");
|
||||||
let expanded = $state(new Set<string>());
|
let expanded = $state(new Set<string>());
|
||||||
|
|
||||||
$effect(() => {
|
onMount(() => {
|
||||||
gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
|
gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
|
||||||
.then((d) => { sources = d.sources.nodes; })
|
.then((d) => { sources = d.sources.nodes; })
|
||||||
.catch(console.error)
|
.catch(console.error)
|
||||||
@@ -73,8 +75,7 @@
|
|||||||
{@const open = expanded.has(g.name)}
|
{@const open = expanded.has(g.name)}
|
||||||
<div>
|
<div>
|
||||||
<button class="row" onclick={() => single ? store.activeSource = g.sources[0] : toggleGroup(g.name)}>
|
<button class="row" onclick={() => single ? store.activeSource = g.sources[0] : toggleGroup(g.name)}>
|
||||||
<img src={thumbUrl(g.icon)} alt={g.name} class="icon"
|
<Thumbnail src={g.icon} alt={g.name} class="icon" onerror={(e) => ((e.target as HTMLImageElement).style.display = "none")} />
|
||||||
onerror={(e) => ((e.target as HTMLImageElement).style.display = "none")} />
|
|
||||||
<div class="info">
|
<div class="info">
|
||||||
<span class="name">{g.name}</span>
|
<span class="name">{g.name}</span>
|
||||||
<span class="meta">{single ? `${g.sources[0].lang.toUpperCase()}${g.sources[0].isNsfw ? " · NSFW" : ""}` : `${g.sources.length} languages`}</span>
|
<span class="meta">{single ? `${g.sources[0].lang.toUpperCase()}${g.sources[0].isNsfw ? " · NSFW" : ""}` : `${g.sources.length} languages`}</span>
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { thumbUrl, plainThumbUrl } from "../../lib/client";
|
||||||
|
import { store } from "../../store/state.svelte";
|
||||||
|
import { getBlobUrl } from "../../lib/imageCache";
|
||||||
|
|
||||||
|
let {
|
||||||
|
src,
|
||||||
|
alt = "",
|
||||||
|
class: cls = "",
|
||||||
|
loading = "lazy",
|
||||||
|
decoding = "async",
|
||||||
|
priority = 0,
|
||||||
|
onerror = undefined,
|
||||||
|
...rest
|
||||||
|
}: {
|
||||||
|
src: string;
|
||||||
|
alt?: string;
|
||||||
|
class?: string;
|
||||||
|
loading?: string;
|
||||||
|
decoding?: string;
|
||||||
|
priority?: number;
|
||||||
|
onerror?: ((e: Event) => void) | undefined;
|
||||||
|
[key: string]: any;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const isAuth = $derived(store.settings.serverAuthMode === "BASIC_AUTH");
|
||||||
|
|
||||||
|
let blobUrl = $state("");
|
||||||
|
$effect(() => {
|
||||||
|
if (!isAuth || !src) { blobUrl = ""; return; }
|
||||||
|
getBlobUrl(plainThumbUrl(src), priority)
|
||||||
|
.then(u => { blobUrl = u; })
|
||||||
|
.catch(() => { blobUrl = ""; });
|
||||||
|
});
|
||||||
|
|
||||||
|
const resolved = $derived(
|
||||||
|
isAuth
|
||||||
|
? (blobUrl || undefined)
|
||||||
|
: (src ? thumbUrl(src) : undefined)
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<img src={resolved} {alt} class={cls} {loading} {decoding} {onerror} {...rest} />
|
||||||
+102
@@ -0,0 +1,102 @@
|
|||||||
|
import { store, updateSettings } from "../store/state.svelte";
|
||||||
|
|
||||||
|
export type AuthMode = "NONE" | "BASIC_AUTH" | "SIMPLE_LOGIN" | "UI_LOGIN";
|
||||||
|
|
||||||
|
export const authSession = {
|
||||||
|
clearTokens() {},
|
||||||
|
hasSession(): boolean { return true; },
|
||||||
|
};
|
||||||
|
|
||||||
|
function getServerBase(): string {
|
||||||
|
const url = store.settings.serverUrl;
|
||||||
|
return typeof url === "string" && url.trim() ? url.replace(/\/$/, "") : "http://127.0.0.1:4567";
|
||||||
|
}
|
||||||
|
|
||||||
|
function basicHeader(user: string, pass: string): Record<string, string> {
|
||||||
|
return { Authorization: `Basic ${btoa(`${user}:${pass}`)}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchAuthenticated(
|
||||||
|
url: string,
|
||||||
|
init: RequestInit,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<Response> {
|
||||||
|
const mode = store.settings.serverAuthMode ?? "NONE";
|
||||||
|
|
||||||
|
if (mode === "BASIC_AUTH") {
|
||||||
|
const user = store.settings.serverAuthUser?.trim() ?? "";
|
||||||
|
const pass = store.settings.serverAuthPass?.trim() ?? "";
|
||||||
|
return fetch(url, {
|
||||||
|
...init,
|
||||||
|
signal,
|
||||||
|
credentials: "include",
|
||||||
|
headers: {
|
||||||
|
...(init.headers as Record<string, string> ?? {}),
|
||||||
|
...(user && pass ? basicHeader(user, pass) : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return fetch(url, { ...init, signal });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loginBasic(user: string, pass: string): Promise<void> {
|
||||||
|
const res = await fetch(`${getServerBase()}/api/graphql`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json", ...basicHeader(user, pass) },
|
||||||
|
body: JSON.stringify({ query: "{ __typename }" }),
|
||||||
|
signal: AbortSignal.timeout(5000),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`Authentication failed (${res.status})`);
|
||||||
|
updateSettings({ serverAuthMode: "BASIC_AUTH", serverAuthUser: user, serverAuthPass: pass });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function logout(): Promise<void> {
|
||||||
|
updateSettings({ serverAuthPass: "" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function probeServer(): Promise<"ok" | "auth_required" | "unsupported_mode" | "unreachable"> {
|
||||||
|
const base = getServerBase();
|
||||||
|
const mode = store.settings.serverAuthMode ?? "NONE";
|
||||||
|
const s = store.settings;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
||||||
|
|
||||||
|
if (mode === "BASIC_AUTH") {
|
||||||
|
const user = s.serverAuthUser?.trim() ?? "";
|
||||||
|
const pass = s.serverAuthPass?.trim() ?? "";
|
||||||
|
if (user && pass) Object.assign(headers, basicHeader(user, pass));
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(`${base}/api/graphql`, {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "include",
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({ query: "{ __typename }" }),
|
||||||
|
signal: AbortSignal.timeout(2000),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
return "ok";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.status === 401) {
|
||||||
|
const wwwAuth = res.headers.get("WWW-Authenticate") ?? "";
|
||||||
|
|
||||||
|
if (/basic/i.test(wwwAuth)) {
|
||||||
|
if (mode !== "BASIC_AUTH") updateSettings({ serverAuthMode: "BASIC_AUTH" });
|
||||||
|
return "auth_required";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/bearer/i.test(wwwAuth)) {
|
||||||
|
if (mode !== "UI_LOGIN") updateSettings({ serverAuthMode: "UI_LOGIN" });
|
||||||
|
} else if (mode === "NONE") {
|
||||||
|
updateSettings({ serverAuthMode: "SIMPLE_LOGIN" });
|
||||||
|
}
|
||||||
|
return "unsupported_mode";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "unreachable";
|
||||||
|
} catch { return "unreachable"; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import type { Chapter } from "./types";
|
||||||
|
|
||||||
|
export function buildReaderChapterList(
|
||||||
|
chapters: Chapter[],
|
||||||
|
mangaPrefs: { preferredScanlator?: string; scanlatorFilter?: string[] } | undefined,
|
||||||
|
): Chapter[] {
|
||||||
|
const preferred = mangaPrefs?.preferredScanlator ?? "";
|
||||||
|
const filter = mangaPrefs?.scanlatorFilter ?? [];
|
||||||
|
|
||||||
|
let base = [...chapters].sort((a, b) => a.sourceOrder - b.sourceOrder);
|
||||||
|
|
||||||
|
if (preferred) {
|
||||||
|
const pref: Chapter[] = [], rest: Chapter[] = [];
|
||||||
|
for (const c of base) (c.scanlator === preferred ? pref : rest).push(c);
|
||||||
|
base = [...pref, ...rest];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.length > 0) {
|
||||||
|
const seen = new Map<number, Chapter>();
|
||||||
|
for (const ch of base) {
|
||||||
|
const existing = seen.get(ch.chapterNumber);
|
||||||
|
if (!existing) {
|
||||||
|
seen.set(ch.chapterNumber, ch);
|
||||||
|
} else {
|
||||||
|
const np = filter.indexOf(ch.scanlator ?? "");
|
||||||
|
const op = filter.indexOf(existing.scanlator ?? "");
|
||||||
|
if (np !== -1 && (op === -1 || np < op)) seen.set(ch.chapterNumber, ch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
base = [...seen.values()].sort((a, b) => a.sourceOrder - b.sourceOrder);
|
||||||
|
}
|
||||||
|
|
||||||
|
return base;
|
||||||
|
}
|
||||||
+18
-22
@@ -1,4 +1,5 @@
|
|||||||
import { store } from "../store/state.svelte";
|
import { store } from "../store/state.svelte";
|
||||||
|
import { fetchAuthenticated } from "./auth";
|
||||||
|
|
||||||
const DEFAULT_URL = "http://127.0.0.1:4567";
|
const DEFAULT_URL = "http://127.0.0.1:4567";
|
||||||
|
|
||||||
@@ -7,25 +8,20 @@ function getServerUrl(): string {
|
|||||||
return typeof url === "string" && url.trim() ? url.replace(/\/$/, "") : DEFAULT_URL;
|
return typeof url === "string" && url.trim() ? url.replace(/\/$/, "") : DEFAULT_URL;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getAuthHeader(): Record<string, string> {
|
|
||||||
const s = store.settings;
|
|
||||||
if (!s.serverAuthEnabled) return {};
|
|
||||||
const user = typeof s.serverAuthUser === "string" ? s.serverAuthUser.trim() : "";
|
|
||||||
const pass = typeof s.serverAuthPass === "string" ? s.serverAuthPass.trim() : "";
|
|
||||||
if (user && pass) return { Authorization: `Basic ${btoa(`${user}:${pass}`)}` };
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
function gqlUrl(): string { return `${getServerUrl()}/api/graphql`; }
|
function gqlUrl(): string { return `${getServerUrl()}/api/graphql`; }
|
||||||
|
|
||||||
export function thumbUrl(path: string): string {
|
export function plainThumbUrl(path: string): string {
|
||||||
if (!path) return "";
|
if (!path) return "";
|
||||||
if (path.startsWith("http")) return path;
|
if (path.startsWith("http")) return path;
|
||||||
return `${getServerUrl()}${path}`;
|
return `${getServerUrl()}${path}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function thumbUrl(path: string): string {
|
||||||
|
return plainThumbUrl(path);
|
||||||
|
}
|
||||||
|
|
||||||
interface GQLResponse<T> {
|
interface GQLResponse<T> {
|
||||||
data: T;
|
data: T;
|
||||||
errors?: { message: string }[];
|
errors?: { message: string }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,22 +37,22 @@ function abortableSleep(ms: number, signal?: AbortSignal): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function fetchWithRetry(
|
async function fetchWithRetry(
|
||||||
url: string,
|
url: string,
|
||||||
init: RequestInit,
|
init: RequestInit,
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
retries = 3,
|
retries = 3,
|
||||||
delayMs = 300,
|
delayMs = 300,
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
||||||
|
|
||||||
for (let i = 0; i < retries; i++) {
|
for (let i = 0; i < retries; i++) {
|
||||||
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(url, { ...init, signal });
|
const res = await fetchAuthenticated(url, init, signal);
|
||||||
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
||||||
return res;
|
return res;
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
|
if (e?.authRequired) throw e;
|
||||||
const isAbort = e?.name === "AbortError" || signal?.aborted;
|
const isAbort = e?.name === "AbortError" || signal?.aborted;
|
||||||
if (isAbort) throw new DOMException("Aborted", "AbortError");
|
if (isAbort) throw new DOMException("Aborted", "AbortError");
|
||||||
if (i === retries - 1) throw e;
|
if (i === retries - 1) throw e;
|
||||||
@@ -67,14 +63,14 @@ async function fetchWithRetry(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function gql<T>(
|
export async function gql<T>(
|
||||||
query: string,
|
query: string,
|
||||||
variables?: Record<string, unknown>,
|
variables?: Record<string, unknown>,
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const res = await fetchWithRetry(gqlUrl(), {
|
const res = await fetchWithRetry(gqlUrl(), {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json", ...getAuthHeader() },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ query, variables }),
|
body: JSON.stringify({ query, variables }),
|
||||||
}, signal);
|
}, signal);
|
||||||
|
|
||||||
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
||||||
|
|||||||
+49
-51
@@ -1,81 +1,79 @@
|
|||||||
import { start, stop, setActivity, clearActivity } from "tauri-plugin-drpc";
|
import { connect, disconnect, setActivity, clearActivity } from "tauri-plugin-discord-rpc-api";
|
||||||
import { Activity, Assets, Button, Timestamps } from "tauri-plugin-drpc/activity";
|
import { listen } from '@tauri-apps/api/event'
|
||||||
import type { Manga, Chapter } from "./types";
|
import type { Manga, Chapter } from './types'
|
||||||
|
|
||||||
const APP_ID = "1487894643613106298";
|
const APP_ID = '1487894643613106298'
|
||||||
const FALLBACK_IMAGE = "moku_logo";
|
const FALLBACK_IMAGE = 'moku_logo'
|
||||||
|
|
||||||
|
let sessionStart: number | null = null
|
||||||
|
let unlisten: (() => void) | null = null
|
||||||
|
|
||||||
function isPublicUrl(url: string | null | undefined): boolean {
|
function isPublicUrl(url: string | null | undefined): boolean {
|
||||||
return typeof url === "string" && url.startsWith("https://");
|
return typeof url === 'string' && url.startsWith('https://')
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveCoverImage(manga: Manga): string {
|
function resolveCoverImage(manga: Manga): string {
|
||||||
return isPublicUrl(manga.thumbnailUrl) ? manga.thumbnailUrl : FALLBACK_IMAGE;
|
return isPublicUrl(manga.thumbnailUrl) ? manga.thumbnailUrl : FALLBACK_IMAGE
|
||||||
}
|
}
|
||||||
|
|
||||||
function trunc(s: string, max = 128): string {
|
function trunc(s: string, max = 128): string {
|
||||||
return s.length <= max ? s : `${s.slice(0, max - 1)}…`;
|
return s.length <= max ? s : `${s.slice(0, max - 1)}…`
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatChapter(chapter: Chapter): string {
|
function formatChapter(chapter: Chapter): string {
|
||||||
const n = chapter.chapterNumber;
|
const n = chapter.chapterNumber
|
||||||
return `Chapter ${Number.isInteger(n) ? n : n.toFixed(1)}`;
|
return `Chapter ${Number.isInteger(n) ? n : n.toFixed(1)}`
|
||||||
}
|
}
|
||||||
|
|
||||||
const BUTTONS = [
|
const BUTTONS = [
|
||||||
new Button("GitHub", "https://github.com/Youwes09/Moku"),
|
{ label: 'GitHub', url: 'https://github.com/Youwes09/Moku' },
|
||||||
new Button("Discord", "https://discord.gg/Jq3pwuNqPp"),
|
{ label: 'Discord', url: 'https://discord.gg/Jq3pwuNqPp' },
|
||||||
];
|
]
|
||||||
|
|
||||||
export async function initRpc(): Promise<void> {
|
export async function initRpc(): Promise<void> {
|
||||||
await start(APP_ID)
|
sessionStart = Date.now()
|
||||||
.then(() => console.log("[discord] RPC started"))
|
|
||||||
.catch((e) => console.error("[discord] initRpc failed:", e));
|
unlisten = await listen('discord-rpc://running', ({ payload }) => {
|
||||||
|
if (payload) setIdle().catch(() => {})
|
||||||
|
})
|
||||||
|
|
||||||
|
await connect(APP_ID).catch(() => {})
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function setReading(manga: Manga, chapter: Chapter): Promise<void> {
|
export async function setReading(manga: Manga, chapter: Chapter): Promise<void> {
|
||||||
const assets = new Assets()
|
await setActivity({
|
||||||
.setLargeImage(resolveCoverImage(manga))
|
details: trunc(manga.title),
|
||||||
.setLargeText(trunc(manga.title))
|
state: `${formatChapter(chapter)} · Reading`,
|
||||||
.setSmallImage(FALLBACK_IMAGE)
|
timestamps: { start: sessionStart ?? Date.now() },
|
||||||
.setSmallText("Moku");
|
assets: {
|
||||||
|
largeImage: resolveCoverImage(manga),
|
||||||
const activity = new Activity()
|
largeText: trunc(manga.title),
|
||||||
.setDetails(trunc(manga.title))
|
smallImage: FALLBACK_IMAGE,
|
||||||
.setState(`${formatChapter(chapter)} · Reading`)
|
smallText: 'Moku',
|
||||||
.setAssets(assets)
|
},
|
||||||
.setTimestamps(new Timestamps(Date.now()));
|
buttons: BUTTONS,
|
||||||
activity.setButton(BUTTONS);
|
}).catch(() => {})
|
||||||
|
|
||||||
await setActivity(activity)
|
|
||||||
.then(() => console.log("[discord] reading →", manga.title, formatChapter(chapter)))
|
|
||||||
.catch((e) => console.error("[discord] setActivity failed:", e));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function setIdle(): Promise<void> {
|
export async function setIdle(): Promise<void> {
|
||||||
const assets = new Assets()
|
await setActivity({
|
||||||
.setLargeImage(FALLBACK_IMAGE)
|
details: 'Browsing',
|
||||||
.setLargeText("Moku");
|
timestamps: { start: sessionStart ?? Date.now() },
|
||||||
|
assets: {
|
||||||
const activity = new Activity()
|
largeImage: FALLBACK_IMAGE,
|
||||||
.setDetails("Browsing")
|
largeText: 'Moku',
|
||||||
.setAssets(assets)
|
},
|
||||||
.setTimestamps(new Timestamps(Date.now()));
|
buttons: BUTTONS,
|
||||||
activity.setButton(BUTTONS);
|
}).catch(() => {})
|
||||||
|
|
||||||
await setActivity(activity)
|
|
||||||
.then(() => console.log("[discord] idle"))
|
|
||||||
.catch((e) => console.error("[discord] setActivity failed (idle):", e));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function clearReading(): Promise<void> {
|
export async function clearReading(): Promise<void> {
|
||||||
await clearActivity()
|
await clearActivity().catch(() => {})
|
||||||
.then(() => console.log("[discord] activity cleared"))
|
|
||||||
.catch((e) => console.error("[discord] clearActivity failed:", e));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function destroyRpc(): Promise<void> {
|
export async function destroyRpc(): Promise<void> {
|
||||||
await stop()
|
unlisten?.()
|
||||||
.then(() => console.log("[discord] RPC stopped"))
|
unlisten = null
|
||||||
.catch((e) => console.error("[discord] destroyRpc failed:", e));
|
sessionStart = null
|
||||||
|
await disconnect().catch(() => {})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { fetch as tauriFetch } from "@tauri-apps/plugin-http";
|
||||||
|
import { store } from "../store/state.svelte";
|
||||||
|
|
||||||
|
const cache = new Map<string, string>();
|
||||||
|
const inflight = new Map<string, Promise<string>>();
|
||||||
|
|
||||||
|
const MAX_CONCURRENT = 14;
|
||||||
|
let active = 0;
|
||||||
|
|
||||||
|
interface QueueEntry {
|
||||||
|
url: string;
|
||||||
|
priority: number;
|
||||||
|
resolve: (v: string) => void;
|
||||||
|
reject: (e: unknown) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const queue: QueueEntry[] = [];
|
||||||
|
|
||||||
|
function getAuthHeaders(): Record<string, string> {
|
||||||
|
const user = store.settings.serverAuthUser?.trim() ?? "";
|
||||||
|
const pass = store.settings.serverAuthPass?.trim() ?? "";
|
||||||
|
return user && pass ? { Authorization: `Basic ${btoa(`${user}:${pass}`)}` } : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doFetch(url: string): Promise<string> {
|
||||||
|
const res = await tauriFetch(url, { method: "GET", headers: getAuthHeaders() });
|
||||||
|
if (!res.ok) throw new Error(`${res.status}`);
|
||||||
|
const blobUrl = URL.createObjectURL(await res.blob());
|
||||||
|
cache.set(url, blobUrl);
|
||||||
|
return blobUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
function drain() {
|
||||||
|
while (active < MAX_CONCURRENT && queue.length > 0) {
|
||||||
|
queue.sort((a, b) => b.priority - a.priority);
|
||||||
|
const entry = queue.shift()!;
|
||||||
|
active++;
|
||||||
|
doFetch(entry.url)
|
||||||
|
.then(entry.resolve, entry.reject)
|
||||||
|
.finally(() => {
|
||||||
|
inflight.delete(entry.url);
|
||||||
|
active--;
|
||||||
|
drain();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function enqueue(url: string, priority: number): Promise<string> {
|
||||||
|
const promise = new Promise<string>((resolve, reject) => {
|
||||||
|
queue.push({ url, priority, resolve, reject });
|
||||||
|
});
|
||||||
|
inflight.set(url, promise);
|
||||||
|
drain();
|
||||||
|
return promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBlobUrl(url: string, priority = 0): Promise<string> {
|
||||||
|
if (!url) return Promise.resolve("");
|
||||||
|
|
||||||
|
const cached = cache.get(url);
|
||||||
|
if (cached) return Promise.resolve(cached);
|
||||||
|
|
||||||
|
const existing = inflight.get(url);
|
||||||
|
if (existing) {
|
||||||
|
const entry = queue.find(e => e.url === url);
|
||||||
|
if (entry && priority > entry.priority) entry.priority = priority;
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
return enqueue(url, priority);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function preloadBlobUrls(urls: string[], basePriority = 0): void {
|
||||||
|
urls.forEach((url, i) => {
|
||||||
|
if (!url || cache.has(url) || inflight.has(url)) return;
|
||||||
|
enqueue(url, basePriority - i);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function revokeBlobUrl(url: string): void {
|
||||||
|
const blob = cache.get(url);
|
||||||
|
if (blob) {
|
||||||
|
URL.revokeObjectURL(blob);
|
||||||
|
cache.delete(url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearBlobCache(): void {
|
||||||
|
cache.forEach(blob => URL.revokeObjectURL(blob));
|
||||||
|
cache.clear();
|
||||||
|
}
|
||||||
@@ -12,6 +12,8 @@ export interface Keybinds {
|
|||||||
togglePageStyle: string;
|
togglePageStyle: string;
|
||||||
toggleFullscreen: string;
|
toggleFullscreen: string;
|
||||||
openSettings: string;
|
openSettings: string;
|
||||||
|
toggleBookmark: string;
|
||||||
|
toggleMarker: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_KEYBINDS: Keybinds = {
|
export const DEFAULT_KEYBINDS: Keybinds = {
|
||||||
@@ -26,6 +28,8 @@ export const DEFAULT_KEYBINDS: Keybinds = {
|
|||||||
togglePageStyle: "q",
|
togglePageStyle: "q",
|
||||||
toggleFullscreen: "f",
|
toggleFullscreen: "f",
|
||||||
openSettings: "o",
|
openSettings: "o",
|
||||||
|
toggleBookmark: "m",
|
||||||
|
toggleMarker: "n",
|
||||||
};
|
};
|
||||||
|
|
||||||
export const KEYBIND_LABELS: Record<keyof Keybinds, string> = {
|
export const KEYBIND_LABELS: Record<keyof Keybinds, string> = {
|
||||||
@@ -40,6 +44,8 @@ export const KEYBIND_LABELS: Record<keyof Keybinds, string> = {
|
|||||||
togglePageStyle: "Toggle page style",
|
togglePageStyle: "Toggle page style",
|
||||||
toggleFullscreen: "Toggle fullscreen",
|
toggleFullscreen: "Toggle fullscreen",
|
||||||
openSettings: "Open settings",
|
openSettings: "Open settings",
|
||||||
|
toggleBookmark: "Toggle bookmark",
|
||||||
|
toggleMarker: "Toggle marker",
|
||||||
};
|
};
|
||||||
|
|
||||||
export function eventToKeybind(e: KeyboardEvent): string {
|
export function eventToKeybind(e: KeyboardEvent): string {
|
||||||
|
|||||||
+36
-2
@@ -187,6 +187,23 @@ export const GET_DOWNLOADS_PATH = `
|
|||||||
query GetDownloadsPath {
|
query GetDownloadsPath {
|
||||||
settings {
|
settings {
|
||||||
downloadsPath
|
downloadsPath
|
||||||
|
localSourcePath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const SET_DOWNLOADS_PATH = `
|
||||||
|
mutation SetDownloadsPath($path: String!) {
|
||||||
|
setSettings(input: { settings: { downloadsPath: $path } }) {
|
||||||
|
settings { downloadsPath }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const SET_LOCAL_SOURCE_PATH = `
|
||||||
|
mutation SetLocalSourcePath($path: String!) {
|
||||||
|
setSettings(input: { settings: { localSourcePath: $path } }) {
|
||||||
|
settings { localSourcePath }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
@@ -424,8 +441,8 @@ export const GET_SOURCES = `
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
export const FETCH_SOURCE_MANGA = `
|
export const FETCH_SOURCE_MANGA = `
|
||||||
mutation FetchSourceManga($source: LongString!, $type: FetchSourceMangaType!, $page: Int!, $query: String) {
|
mutation FetchSourceManga($source: LongString!, $type: FetchSourceMangaType!, $page: Int!, $query: String, $filters: [FilterChangeInput!]) {
|
||||||
fetchSourceManga(input: { source: $source, type: $type, page: $page, query: $query }) {
|
fetchSourceManga(input: { source: $source, type: $type, page: $page, query: $query, filters: $filters }) {
|
||||||
mangas {
|
mangas {
|
||||||
id
|
id
|
||||||
title
|
title
|
||||||
@@ -871,3 +888,20 @@ export const LOGOUT_TRACKER = `
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
export const LOGIN_USER = `
|
||||||
|
mutation Login($username: String!, $password: String!) {
|
||||||
|
login(input: { username: $username, password: $password }) {
|
||||||
|
accessToken
|
||||||
|
refreshToken
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const REFRESH_TOKEN = `
|
||||||
|
mutation RefreshToken {
|
||||||
|
refreshToken {
|
||||||
|
accessToken
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|||||||
+82
-11
@@ -8,29 +8,100 @@ export function cn(...inputs: ClassValue[]) {
|
|||||||
// ── NSFW genre filtering ──────────────────────────────────────────────────────
|
// ── NSFW genre filtering ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Genre tags that indicate adult/mature content.
|
* Default substrings used when no user-configured list is available.
|
||||||
* Checked case-insensitively against each manga's genre array.
|
* The Settings > Content tab lets users add/remove entries from this list,
|
||||||
* Extend this set if additional tags need to be covered.
|
* which is stored as settings.nsfwFilteredTags.
|
||||||
*/
|
*/
|
||||||
const NSFW_GENRE_TAGS = new Set([
|
export const DEFAULT_NSFW_TAGS = [
|
||||||
"adult",
|
"adult",
|
||||||
"mature",
|
"mature",
|
||||||
"hentai",
|
"hentai",
|
||||||
"ecchi",
|
"ecchi",
|
||||||
"erotica",
|
"erotic", // catches "erotica", "erotic content", "erotic manga"
|
||||||
"pornographic",
|
"pornograph", // catches "pornographic", "pornography"
|
||||||
"18+",
|
"18+",
|
||||||
"smut",
|
"smut",
|
||||||
"lemon",
|
"lemon",
|
||||||
"explicit",
|
"explicit",
|
||||||
]);
|
"sexual violence",
|
||||||
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns true if the manga carries at least one genre tag that is considered
|
* Returns true if the manga carries at least one genre tag matching any of
|
||||||
* adult/mature. Used to enforce the `showNsfw` setting across all views.
|
* the provided substrings (case-insensitive). Pass settings.nsfwFilteredTags
|
||||||
|
* as the tag list; falls back to DEFAULT_NSFW_TAGS if omitted.
|
||||||
*/
|
*/
|
||||||
export function isNsfwManga(manga: { genre?: string[] | null }): boolean {
|
export function isNsfwManga(
|
||||||
return (manga.genre ?? []).some((g) => NSFW_GENRE_TAGS.has(g.toLowerCase().trim()));
|
manga: { genre?: string[] | null },
|
||||||
|
tags: string[] = DEFAULT_NSFW_TAGS,
|
||||||
|
): boolean {
|
||||||
|
return (manga.genre ?? []).some((g) => {
|
||||||
|
const normalized = g.toLowerCase().trim();
|
||||||
|
return tags.some((sub) => normalized.includes(sub));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single authoritative NSFW gate used by all views.
|
||||||
|
*
|
||||||
|
* Returns true when the manga should be HIDDEN. Checks in order:
|
||||||
|
* 1. showNsfw disabled globally → skip everything, hide by source flag or genre match.
|
||||||
|
* 2. Source is in blockedSourceIds → always hide regardless of showNsfw.
|
||||||
|
* 3. Source is in allowedSourceIds → always show (bypasses isNsfw flag only, genre tags still apply).
|
||||||
|
* 4. Source isNsfw flag → hide unless source is allowed.
|
||||||
|
* 5. Genre tag match → hide.
|
||||||
|
*
|
||||||
|
* Usage: items.filter(m => !shouldHideNsfw(m, settings))
|
||||||
|
*/
|
||||||
|
export function shouldHideNsfw(
|
||||||
|
manga: {
|
||||||
|
genre?: string[] | null;
|
||||||
|
source?: { id?: string; isNsfw?: boolean } | null;
|
||||||
|
},
|
||||||
|
settings: {
|
||||||
|
showNsfw: boolean;
|
||||||
|
nsfwFilteredTags: string[];
|
||||||
|
nsfwAllowedSourceIds: string[];
|
||||||
|
nsfwBlockedSourceIds: string[];
|
||||||
|
},
|
||||||
|
): boolean {
|
||||||
|
const srcId = manga.source?.id;
|
||||||
|
|
||||||
|
// Explicit block always wins, even when showNsfw is on
|
||||||
|
if (srcId && settings.nsfwBlockedSourceIds.includes(srcId)) return true;
|
||||||
|
|
||||||
|
// If NSFW is globally allowed, only explicit blocks apply
|
||||||
|
if (settings.showNsfw) return false;
|
||||||
|
|
||||||
|
// Source is explicitly allowed — skip the isNsfw flag check, but still filter genres
|
||||||
|
const sourceAllowed = !!(srcId && settings.nsfwAllowedSourceIds.includes(srcId));
|
||||||
|
|
||||||
|
if (!sourceAllowed && manga.source?.isNsfw) return true;
|
||||||
|
|
||||||
|
return isNsfwManga(manga, settings.nsfwFilteredTags);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gate for Source objects — parallel to shouldHideNsfw for manga.
|
||||||
|
*
|
||||||
|
* Priority:
|
||||||
|
* 1. Blocked list → always hidden, even when showNsfw is on.
|
||||||
|
* 2. Allowed list → always shown, even if isNsfw is true.
|
||||||
|
* 3. Fallback → hide when showNsfw is off and source.isNsfw is true.
|
||||||
|
*
|
||||||
|
* Usage: sources.filter(s => !shouldHideSource(s, settings))
|
||||||
|
*/
|
||||||
|
export function shouldHideSource(
|
||||||
|
source: { id: string; isNsfw: boolean },
|
||||||
|
settings: {
|
||||||
|
showNsfw: boolean;
|
||||||
|
nsfwAllowedSourceIds: string[];
|
||||||
|
nsfwBlockedSourceIds: string[];
|
||||||
|
},
|
||||||
|
): boolean {
|
||||||
|
if (settings.nsfwBlockedSourceIds.includes(source.id)) return true;
|
||||||
|
if (settings.nsfwAllowedSourceIds.includes(source.id)) return false;
|
||||||
|
return !settings.showNsfw && source.isNsfw;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Source deduplication ──────────────────────────────────────────────────────
|
// ── Source deduplication ──────────────────────────────────────────────────────
|
||||||
|
|||||||
+273
-240
@@ -27,35 +27,38 @@ export type LibraryStatusFilter =
|
|||||||
| "CANCELLED"
|
| "CANCELLED"
|
||||||
| "HIATUS"
|
| "HIATUS"
|
||||||
| "UNKNOWN";
|
| "UNKNOWN";
|
||||||
export type BuiltinTheme = "dark" | "high-contrast" | "light" | "light-contrast" | "midnight" | "warm";
|
|
||||||
export type Theme = BuiltinTheme | string; // custom themes have string IDs like "custom:abc123"
|
export type LibraryContentFilter =
|
||||||
|
| "unread"
|
||||||
|
| "started"
|
||||||
|
| "downloaded"
|
||||||
|
| "bookmarked"
|
||||||
|
| "marked";
|
||||||
|
|
||||||
|
export type BuiltinTheme = "dark" | "high-contrast" | "light" | "light-contrast" | "midnight" | "warm";
|
||||||
|
export type Theme = BuiltinTheme | string;
|
||||||
|
|
||||||
export interface ThemeTokens {
|
export interface ThemeTokens {
|
||||||
/* Backgrounds */
|
|
||||||
"bg-void": string;
|
"bg-void": string;
|
||||||
"bg-base": string;
|
"bg-base": string;
|
||||||
"bg-surface": string;
|
"bg-surface": string;
|
||||||
"bg-raised": string;
|
"bg-raised": string;
|
||||||
"bg-overlay": string;
|
"bg-overlay": string;
|
||||||
"bg-subtle": string;
|
"bg-subtle": string;
|
||||||
/* Borders */
|
|
||||||
"border-dim": string;
|
"border-dim": string;
|
||||||
"border-base": string;
|
"border-base": string;
|
||||||
"border-strong": string;
|
"border-strong": string;
|
||||||
"border-focus": string;
|
"border-focus": string;
|
||||||
/* Text */
|
|
||||||
"text-primary": string;
|
"text-primary": string;
|
||||||
"text-secondary": string;
|
"text-secondary": string;
|
||||||
"text-muted": string;
|
"text-muted": string;
|
||||||
"text-faint": string;
|
"text-faint": string;
|
||||||
"text-disabled": string;
|
"text-disabled": string;
|
||||||
/* Accent */
|
|
||||||
"accent": string;
|
"accent": string;
|
||||||
"accent-dim": string;
|
"accent-dim": string;
|
||||||
"accent-muted": string;
|
"accent-muted": string;
|
||||||
"accent-fg": string;
|
"accent-fg": string;
|
||||||
"accent-bright": string;
|
"accent-bright": string;
|
||||||
/* Semantic */
|
|
||||||
"color-error": string;
|
"color-error": string;
|
||||||
"color-error-bg": string;
|
"color-error-bg": string;
|
||||||
"color-success": string;
|
"color-success": string;
|
||||||
@@ -64,7 +67,7 @@ export interface ThemeTokens {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface CustomTheme {
|
export interface CustomTheme {
|
||||||
id: string; // "custom:abc123"
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
tokens: ThemeTokens;
|
tokens: ThemeTokens;
|
||||||
}
|
}
|
||||||
@@ -97,28 +100,46 @@ export const DEFAULT_THEME_TOKENS: ThemeTokens = {
|
|||||||
"color-info-bg": "#121a1f",
|
"color-info-bg": "#121a1f",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
export interface HistoryEntry {
|
export interface HistoryEntry {
|
||||||
mangaId: number;
|
mangaId: number;
|
||||||
mangaTitle: string;
|
mangaTitle: string;
|
||||||
thumbnailUrl: string;
|
thumbnailUrl: string;
|
||||||
chapterId: number;
|
chapterId: number;
|
||||||
chapterName: string;
|
chapterName: string;
|
||||||
pageNumber: number;
|
|
||||||
readAt: number;
|
readAt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export interface BookmarkEntry {
|
||||||
* ReadLogEntry — append-only record of every chapter-completion event.
|
mangaId: number;
|
||||||
* Unlike HistoryEntry (which dedupes per chapter for the "continue" UI),
|
mangaTitle: string;
|
||||||
* this log never overwrites existing entries. It is the source of truth
|
thumbnailUrl: string;
|
||||||
* for all reading stats.
|
chapterId: number;
|
||||||
*/
|
chapterName: string;
|
||||||
|
pageNumber: number;
|
||||||
|
savedAt: number;
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MarkerColor = "yellow" | "red" | "blue" | "green" | "purple";
|
||||||
|
|
||||||
|
export interface MarkerEntry {
|
||||||
|
id: string;
|
||||||
|
mangaId: number;
|
||||||
|
mangaTitle: string;
|
||||||
|
thumbnailUrl: string;
|
||||||
|
chapterId: number;
|
||||||
|
chapterName: string;
|
||||||
|
pageNumber: number;
|
||||||
|
note: string;
|
||||||
|
color: MarkerColor;
|
||||||
|
createdAt: number;
|
||||||
|
updatedAt?: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ReadLogEntry {
|
export interface ReadLogEntry {
|
||||||
mangaId: number;
|
mangaId: number;
|
||||||
chapterId: number;
|
chapterId: number;
|
||||||
readAt: number;
|
readAt: number;
|
||||||
/** Minutes spent on this chapter (estimated from page count or default). */
|
|
||||||
minutes: number;
|
minutes: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,7 +154,7 @@ export interface ReadingStats {
|
|||||||
lastStreakDate: string;
|
lastStreakDate: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const AVG_MIN_PER_CHAPTER = 5; // fallback when no page count is available
|
const AVG_MIN_PER_CHAPTER = 5;
|
||||||
|
|
||||||
export const DEFAULT_READING_STATS: ReadingStats = {
|
export const DEFAULT_READING_STATS: ReadingStats = {
|
||||||
totalChaptersRead: 0,
|
totalChaptersRead: 0,
|
||||||
@@ -160,16 +181,34 @@ export interface ActiveDownload {
|
|||||||
progress: number;
|
progress: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MangaPrefs {
|
||||||
|
autoDownload: boolean;
|
||||||
|
downloadAhead: number;
|
||||||
|
deleteOnRead: boolean;
|
||||||
|
deleteDelayHours: number;
|
||||||
|
maxKeepChapters: number;
|
||||||
|
pauseUpdates: boolean;
|
||||||
|
refreshInterval: "global" | "daily" | "weekly" | "manual";
|
||||||
|
preferredScanlator: string;
|
||||||
|
scanlatorFilter: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_MANGA_PREFS: MangaPrefs = {
|
||||||
|
autoDownload: false,
|
||||||
|
downloadAhead: 0,
|
||||||
|
deleteOnRead: false,
|
||||||
|
deleteDelayHours: 0,
|
||||||
|
maxKeepChapters: 0,
|
||||||
|
pauseUpdates: false,
|
||||||
|
refreshInterval: "global",
|
||||||
|
preferredScanlator: "",
|
||||||
|
scanlatorFilter: [],
|
||||||
|
};
|
||||||
|
|
||||||
export interface Settings {
|
export interface Settings {
|
||||||
pageStyle: PageStyle;
|
pageStyle: PageStyle;
|
||||||
readingDirection: ReadingDirection;
|
readingDirection: ReadingDirection;
|
||||||
fitMode: FitMode;
|
fitMode: FitMode;
|
||||||
/**
|
|
||||||
* Reader zoom level — unitless float multiplier relative to the viewer
|
|
||||||
* container width. 1.0 = image fills the viewer, 1.5 = 150%, 0.8 = 80%.
|
|
||||||
* Replaces the old `maxPageWidth` pixel value.
|
|
||||||
*/
|
|
||||||
readerZoom: number;
|
readerZoom: number;
|
||||||
pageGap: boolean;
|
pageGap: boolean;
|
||||||
optimizeContrast: boolean;
|
optimizeContrast: boolean;
|
||||||
@@ -184,11 +223,6 @@ export interface Settings {
|
|||||||
chapterSortDir: ChapterSortDir;
|
chapterSortDir: ChapterSortDir;
|
||||||
chapterSortMode: ChapterSortMode;
|
chapterSortMode: ChapterSortMode;
|
||||||
chapterPageSize: number;
|
chapterPageSize: number;
|
||||||
/**
|
|
||||||
* UI zoom level — unitless float multiplier applied on top of the
|
|
||||||
* platform scale factor from the OS/monitor. 1.0 = no user adjustment.
|
|
||||||
* Replaces the old `uiScale` percentage integer.
|
|
||||||
*/
|
|
||||||
uiZoom: number;
|
uiZoom: number;
|
||||||
compactSidebar: boolean;
|
compactSidebar: boolean;
|
||||||
gpuAcceleration: boolean;
|
gpuAcceleration: boolean;
|
||||||
@@ -202,14 +236,16 @@ export interface Settings {
|
|||||||
storageLimitGb: number | null;
|
storageLimitGb: number | null;
|
||||||
markReadOnNext: boolean;
|
markReadOnNext: boolean;
|
||||||
readerDebounceMs: number;
|
readerDebounceMs: number;
|
||||||
|
autoBookmark: boolean;
|
||||||
theme: Theme;
|
theme: Theme;
|
||||||
libraryBranches: boolean;
|
libraryBranches: boolean;
|
||||||
renderLimit: number;
|
renderLimit: number;
|
||||||
heroSlots: (number | null)[];
|
heroSlots: (number | null)[];
|
||||||
mangaLinks: Record<number, number[]>;
|
mangaLinks: Record<number, number[]>;
|
||||||
|
mangaPrefs: Record<number, Partial<MangaPrefs>>;
|
||||||
serverAuthUser: string;
|
serverAuthUser: string;
|
||||||
serverAuthPass: string;
|
serverAuthPass: string;
|
||||||
serverAuthEnabled: boolean;
|
serverAuthMode: "NONE" | "BASIC_AUTH" | "SIMPLE_LOGIN" | "UI_LOGIN";
|
||||||
socksProxyEnabled: boolean;
|
socksProxyEnabled: boolean;
|
||||||
socksProxyHost: string;
|
socksProxyHost: string;
|
||||||
socksProxyPort: string;
|
socksProxyPort: string;
|
||||||
@@ -226,19 +262,20 @@ export interface Settings {
|
|||||||
appLockPin: string;
|
appLockPin: string;
|
||||||
customThemes: CustomTheme[];
|
customThemes: CustomTheme[];
|
||||||
hiddenCategoryIds: number[];
|
hiddenCategoryIds: number[];
|
||||||
/** Category ID that opens by default when the Library tab is first visited. null = no default (shows Saved). */
|
|
||||||
defaultLibraryCategoryId: number | null;
|
defaultLibraryCategoryId: number | null;
|
||||||
/** Per-tab sort/filter state — keyed by libraryFilter value (e.g. "library", "downloaded", "42"). */
|
nsfwFilteredTags: string[];
|
||||||
libraryTabSort: Record<string, { mode: LibrarySortMode; dir: LibrarySortDir }>;
|
nsfwAllowedSourceIds: string[];
|
||||||
libraryTabStatus: Record<string, LibraryStatusFilter>;
|
nsfwBlockedSourceIds: string[];
|
||||||
// Legacy fields kept for migration reads only — never written after v3.
|
libraryTabSort: Record<string, { mode: LibrarySortMode; dir: LibrarySortDir }>;
|
||||||
/** @deprecated use readerZoom */
|
libraryTabStatus: Record<string, LibraryStatusFilter>;
|
||||||
|
libraryTabFilters: Record<string, Partial<Record<LibraryContentFilter, boolean>>>;
|
||||||
maxPageWidth?: number;
|
maxPageWidth?: number;
|
||||||
/** @deprecated use uiZoom */
|
|
||||||
uiScale?: number;
|
uiScale?: number;
|
||||||
|
extraScanDirs: string[];
|
||||||
|
serverDownloadsPath: string;
|
||||||
|
serverLocalSourcePath: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export const DEFAULT_SETTINGS: Settings = {
|
export const DEFAULT_SETTINGS: Settings = {
|
||||||
pageStyle: "longstrip",
|
pageStyle: "longstrip",
|
||||||
readingDirection: "ltr",
|
readingDirection: "ltr",
|
||||||
@@ -270,14 +307,16 @@ export const DEFAULT_SETTINGS: Settings = {
|
|||||||
storageLimitGb: null,
|
storageLimitGb: null,
|
||||||
markReadOnNext: true,
|
markReadOnNext: true,
|
||||||
readerDebounceMs: 120,
|
readerDebounceMs: 120,
|
||||||
|
autoBookmark: true,
|
||||||
theme: "dark",
|
theme: "dark",
|
||||||
libraryBranches: true,
|
libraryBranches: true,
|
||||||
renderLimit: 48,
|
renderLimit: 48,
|
||||||
heroSlots: [null, null, null, null],
|
heroSlots: [null, null, null, null],
|
||||||
mangaLinks: {},
|
mangaLinks: {},
|
||||||
|
mangaPrefs: {},
|
||||||
serverAuthUser: "",
|
serverAuthUser: "",
|
||||||
serverAuthPass: "",
|
serverAuthPass: "",
|
||||||
serverAuthEnabled: false,
|
serverAuthMode: "NONE",
|
||||||
socksProxyEnabled: false,
|
socksProxyEnabled: false,
|
||||||
socksProxyHost: "",
|
socksProxyHost: "",
|
||||||
socksProxyPort: "1080",
|
socksProxyPort: "1080",
|
||||||
@@ -295,16 +334,19 @@ export const DEFAULT_SETTINGS: Settings = {
|
|||||||
customThemes: [],
|
customThemes: [],
|
||||||
hiddenCategoryIds: [],
|
hiddenCategoryIds: [],
|
||||||
defaultLibraryCategoryId: null,
|
defaultLibraryCategoryId: null,
|
||||||
|
nsfwFilteredTags: ["adult", "mature", "hentai", "ecchi", "erotic", "pornograph", "18+", "smut", "lemon", "explicit", "sexual violence"],
|
||||||
|
nsfwAllowedSourceIds: [],
|
||||||
|
nsfwBlockedSourceIds: [],
|
||||||
libraryTabSort: {},
|
libraryTabSort: {},
|
||||||
libraryTabStatus: {},
|
libraryTabStatus: {},
|
||||||
|
libraryTabFilters: {},
|
||||||
|
extraScanDirs: [],
|
||||||
|
serverDownloadsPath: "",
|
||||||
|
serverLocalSourcePath: "",
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Persistence ───────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const STORE_VERSION = 3;
|
const STORE_VERSION = 3;
|
||||||
|
|
||||||
// Fields reset to their DEFAULT_SETTINGS value on each version bump.
|
|
||||||
// Add a key here whenever its default changes meaning between releases.
|
|
||||||
const RESET_ON_UPGRADE: (keyof Settings)[] = [
|
const RESET_ON_UPGRADE: (keyof Settings)[] = [
|
||||||
"serverBinary",
|
"serverBinary",
|
||||||
"readerZoom",
|
"readerZoom",
|
||||||
@@ -352,176 +394,167 @@ function mergeSettings(saved: any): Settings {
|
|||||||
return {
|
return {
|
||||||
...DEFAULT_SETTINGS,
|
...DEFAULT_SETTINGS,
|
||||||
...saved?.settings,
|
...saved?.settings,
|
||||||
keybinds: { ...DEFAULT_KEYBINDS, ...saved?.settings?.keybinds },
|
keybinds: { ...DEFAULT_KEYBINDS, ...saved?.settings?.keybinds },
|
||||||
heroSlots: saved?.settings?.heroSlots ?? [null, null, null, null],
|
heroSlots: saved?.settings?.heroSlots ?? [null, null, null, null],
|
||||||
mangaLinks: saved?.settings?.mangaLinks ?? {},
|
mangaLinks: saved?.settings?.mangaLinks ?? {},
|
||||||
customThemes: saved?.settings?.customThemes ?? [],
|
mangaPrefs: saved?.settings?.mangaPrefs ?? {},
|
||||||
|
customThemes: saved?.settings?.customThemes ?? [],
|
||||||
hiddenCategoryIds: saved?.settings?.hiddenCategoryIds ?? [],
|
hiddenCategoryIds: saved?.settings?.hiddenCategoryIds ?? [],
|
||||||
|
nsfwFilteredTags: saved?.settings?.nsfwFilteredTags ?? DEFAULT_SETTINGS.nsfwFilteredTags,
|
||||||
|
nsfwAllowedSourceIds: saved?.settings?.nsfwAllowedSourceIds ?? [],
|
||||||
|
nsfwBlockedSourceIds: saved?.settings?.nsfwBlockedSourceIds ?? [],
|
||||||
libraryTabSort: saved?.settings?.libraryTabSort ?? {},
|
libraryTabSort: saved?.settings?.libraryTabSort ?? {},
|
||||||
libraryTabStatus: saved?.settings?.libraryTabStatus ?? {},
|
libraryTabStatus: saved?.settings?.libraryTabStatus ?? {},
|
||||||
|
libraryTabFilters: saved?.settings?.libraryTabFilters ?? {},
|
||||||
|
extraScanDirs: saved?.settings?.extraScanDirs ?? [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function mergeStats(saved: any): ReadingStats {
|
|
||||||
return { ...DEFAULT_READING_STATS, ...saved?.readingStats };
|
|
||||||
}
|
|
||||||
|
|
||||||
function todayStr(): string {
|
|
||||||
const d = new Date();
|
|
||||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const genId = () => Math.random().toString(36).slice(2, 10);
|
|
||||||
|
|
||||||
// ── Store ─────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
class Store {
|
class Store {
|
||||||
navPage: NavPage = $state(saved?.navPage ?? "home");
|
settings: Settings = $state(mergeSettings(saved));
|
||||||
libraryFilter: LibraryFilter = $state("library");
|
activeManga: Manga | null = $state(null);
|
||||||
history: HistoryEntry[] = $state(saved?.history ?? []);
|
previewManga: Manga | null = $state(null);
|
||||||
/**
|
activeChapter: Chapter | null = $state(null);
|
||||||
* readLog — append-only, never deduped. Every chapter completion/progress
|
activeChapterList: Chapter[] = $state([]);
|
||||||
* event lands here. This is the authoritative source for all reading stats.
|
pageUrls: string[] = $state([]);
|
||||||
* Capped at 5 000 entries; oldest are trimmed first.
|
pageNumber: number = $state(1);
|
||||||
*/
|
navPage: NavPage = $state("home");
|
||||||
readLog: ReadLogEntry[] = $state(saved?.readLog ?? []);
|
libraryFilter: LibraryFilter = $state("all");
|
||||||
readingStats: ReadingStats = $state(mergeStats(saved));
|
genreFilter: string = $state("");
|
||||||
settings: Settings = $state(mergeSettings(saved));
|
searchPrefill: string = $state("");
|
||||||
|
toasts: Toast[] = $state([]);
|
||||||
/**
|
categories: Category[] = $state([]);
|
||||||
* Bumped each time the reader closes. Home.svelte watches this to know
|
activeDownloads: ActiveDownload[] = $state([]);
|
||||||
* when to re-fetch library data and refresh the hero section.
|
activeSource: Source | null = $state(null);
|
||||||
*/
|
libraryTagFilter: string[] = $state([]);
|
||||||
readerSessionId: number = $state(0);
|
settingsOpen: boolean = $state(false);
|
||||||
|
history: HistoryEntry[] = $state(saved?.history ?? []);
|
||||||
genreFilter: string = $state("");
|
bookmarks: BookmarkEntry[] = $state(saved?.bookmarks ?? []);
|
||||||
searchPrefill: string = $state("");
|
markers: MarkerEntry[] = $state(saved?.markers ?? []);
|
||||||
activeManga: Manga | null = $state(null);
|
readLog: ReadLogEntry[] = $state(saved?.readLog ?? []);
|
||||||
previewManga: Manga | null = $state(null);
|
readingStats: ReadingStats = $state(saved?.readingStats ?? { ...DEFAULT_READING_STATS });
|
||||||
activeSource: Source | null = $state(null);
|
discoverCache: Map<string, any> = $state(new Map());
|
||||||
pageUrls: string[] = $state([]);
|
discoverLibraryIds: Set<number> = $state(new Set());
|
||||||
pageNumber: number = $state(1);
|
discoverSrcOffset: number = $state(0);
|
||||||
libraryTagFilter: string[] = $state([]);
|
|
||||||
settingsOpen: boolean = $state(false);
|
|
||||||
activeDownloads: ActiveDownload[] = $state([]);
|
|
||||||
toasts: Toast[] = $state([]);
|
|
||||||
activeChapter: Chapter | null = $state(null);
|
|
||||||
activeChapterList: Chapter[] = $state([]);
|
|
||||||
// UI-only: synced from Tauri window events in App.svelte. Not persisted.
|
|
||||||
isFullscreen: boolean = $state(false);
|
|
||||||
|
|
||||||
// ── Shared category list ──────────────────────────────────────────────────
|
|
||||||
// Single source of truth for the category list, shared between Library and
|
|
||||||
// Settings. Library owns fetching; Settings reads and mutates in-place.
|
|
||||||
// No pub/sub or guard flags needed — both components share this $state ref.
|
|
||||||
categories: Category[] = $state([]);
|
|
||||||
|
|
||||||
// ── Discover session cache ────────────────────────────────────────────────
|
|
||||||
// Survives navigation within a session but is never persisted to localStorage.
|
|
||||||
// Key format: "<sourceId>|<type>|<genre>" or "local|<genre>"
|
|
||||||
discoverCache: Map<string, Manga[]> = $state(new Map());
|
|
||||||
discoverLibraryIds: Set<number> = $state(new Set());
|
|
||||||
discoverSrcOffset: number = $state(0);
|
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
$effect.root(() => {
|
$effect.root(() => {
|
||||||
$effect(() => { persist({ storeVersion: STORE_VERSION }); });
|
$effect(() => {
|
||||||
$effect(() => { persist({ navPage: this.navPage }); });
|
persist({
|
||||||
$effect(() => { persist({ libraryFilter: this.libraryFilter }); });
|
settings: this.settings,
|
||||||
$effect(() => { persist({ history: this.history }); });
|
history: this.history,
|
||||||
$effect(() => { persist({ readLog: this.readLog }); });
|
bookmarks: this.bookmarks,
|
||||||
$effect(() => { persist({ readingStats: this.readingStats }); });
|
markers: this.markers,
|
||||||
$effect(() => { persist({ settings: this.settings }); });
|
readLog: this.readLog,
|
||||||
|
readingStats: this.readingStats,
|
||||||
|
storeVersion: STORE_VERSION,
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
openReader(chapter: Chapter, chapterList: Chapter[]) {
|
openReader(chapter: Chapter, chapterList: Chapter[], manga?: Manga | null) {
|
||||||
this.activeChapter = chapter;
|
this.activeChapter = chapter;
|
||||||
this.activeChapterList = chapterList;
|
this.activeChapterList = chapterList;
|
||||||
this.pageUrls = [];
|
if (manga !== undefined) this.activeManga = manga;
|
||||||
this.pageNumber = 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
closeReader() {
|
closeReader() {
|
||||||
// Null activeChapter FIRST so the history $effect in Reader can't fire
|
|
||||||
// one last time with stale chapter + pageNumber=1, overwriting the real
|
|
||||||
// last-read position with page 1.
|
|
||||||
this.activeChapter = null;
|
this.activeChapter = null;
|
||||||
this.activeChapterList = [];
|
this.activeChapterList = [];
|
||||||
this.pageUrls = [];
|
this.pageUrls = [];
|
||||||
this.pageNumber = 1;
|
this.pageNumber = 1;
|
||||||
this.readerSessionId += 1; // signals Home to refresh
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
addHistory(entry: HistoryEntry, completed = false, minutes?: number) {
|
||||||
* Record a reading event.
|
const filtered = this.history.filter(h => h.chapterId !== entry.chapterId);
|
||||||
*
|
this.history = [entry, ...filtered].slice(0, 500);
|
||||||
* @param entry - The history entry for the "continue reading" UI.
|
|
||||||
* @param completed - True when the chapter was fully read (triggers stat
|
|
||||||
* accrual). False for mid-chapter progress updates.
|
|
||||||
* @param minutes - Actual minutes to credit; defaults to AVG_MIN_PER_CHAPTER.
|
|
||||||
*/
|
|
||||||
addHistory(entry: HistoryEntry, completed = false, minutes = AVG_MIN_PER_CHAPTER) {
|
|
||||||
// ── 1. Update the deduped "continue reading" history ──────────────────
|
|
||||||
// Always keep the latest position for each chapter at the top.
|
|
||||||
if (this.history[0]?.chapterId === entry.chapterId) {
|
|
||||||
this.history[0] = { ...this.history[0], pageNumber: entry.pageNumber, readAt: entry.readAt };
|
|
||||||
} else {
|
|
||||||
this.history = [entry, ...this.history.filter(x => x.chapterId !== entry.chapterId)].slice(0, 300);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 2. Append to the read log (only on completion) ────────────────────
|
|
||||||
// This is append-only — every completed chapter read lands here,
|
|
||||||
// including re-reads. We cap at 5 000 to keep storage bounded.
|
|
||||||
if (completed) {
|
if (completed) {
|
||||||
const logEntry: ReadLogEntry = {
|
const existing = this.readLog.find(e => e.chapterId === entry.chapterId);
|
||||||
mangaId: entry.mangaId,
|
if (!existing) {
|
||||||
chapterId: entry.chapterId,
|
const mins = minutes ?? AVG_MIN_PER_CHAPTER;
|
||||||
readAt: entry.readAt,
|
this.readLog = [...this.readLog, { mangaId: entry.mangaId, chapterId: entry.chapterId, readAt: entry.readAt, minutes: mins }];
|
||||||
minutes,
|
const uniqueChapters = new Set(this.readLog.map(e => e.chapterId));
|
||||||
};
|
const uniqueManga = new Set(this.readLog.map(e => e.mangaId));
|
||||||
this.readLog = [...this.readLog, logEntry].slice(-5000);
|
const totalMinutes = this.readLog.reduce((sum, e) => sum + e.minutes, 0);
|
||||||
|
const now = new Date();
|
||||||
|
const todayStr = now.toISOString().slice(0, 10);
|
||||||
|
const lastDate = this.readingStats.lastStreakDate;
|
||||||
|
const yesterday = new Date(now); yesterday.setDate(yesterday.getDate() - 1);
|
||||||
|
const yesterdayStr = yesterday.toISOString().slice(0, 10);
|
||||||
|
let streak = this.readingStats.currentStreakDays;
|
||||||
|
if (lastDate === todayStr) {
|
||||||
|
} else if (lastDate === yesterdayStr) {
|
||||||
|
streak++;
|
||||||
|
} else {
|
||||||
|
streak = 1;
|
||||||
|
}
|
||||||
|
const longest = Math.max(this.readingStats.longestStreakDays, streak);
|
||||||
|
this.readingStats = {
|
||||||
|
totalChaptersRead: uniqueChapters.size,
|
||||||
|
totalMangaRead: uniqueManga.size,
|
||||||
|
totalMinutesRead: totalMinutes,
|
||||||
|
firstReadAt: this.readingStats.firstReadAt || entry.readAt,
|
||||||
|
lastReadAt: entry.readAt,
|
||||||
|
currentStreakDays: streak,
|
||||||
|
longestStreakDays: longest,
|
||||||
|
lastStreakDate: todayStr,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 3. Recompute stats from the read log ──────────────────────────────
|
|
||||||
// Use the log as ground truth so stats are always accurate even after
|
|
||||||
// history is cleared or entries are back-filled.
|
|
||||||
const log = completed
|
|
||||||
? [...this.readLog] // already updated above
|
|
||||||
: this.readLog;
|
|
||||||
|
|
||||||
const uniqueChapters = new Set(log.map(e => e.chapterId));
|
|
||||||
const uniqueManga = new Set(log.map(e => e.mangaId));
|
|
||||||
const totalMinutes = log.reduce((sum, e) => sum + e.minutes, 0);
|
|
||||||
|
|
||||||
const today = todayStr();
|
|
||||||
let { currentStreakDays, longestStreakDays, lastStreakDate } = this.readingStats;
|
|
||||||
if (lastStreakDate !== today) {
|
|
||||||
const yesterday = new Date();
|
|
||||||
yesterday.setDate(yesterday.getDate() - 1);
|
|
||||||
const yStr = `${yesterday.getFullYear()}-${String(yesterday.getMonth() + 1).padStart(2, "0")}-${String(yesterday.getDate()).padStart(2, "0")}`;
|
|
||||||
currentStreakDays = lastStreakDate === yStr ? currentStreakDays + 1 : 1;
|
|
||||||
longestStreakDays = Math.max(longestStreakDays, currentStreakDays);
|
|
||||||
lastStreakDate = today;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.readingStats = {
|
|
||||||
totalChaptersRead: uniqueChapters.size,
|
|
||||||
totalMangaRead: uniqueManga.size,
|
|
||||||
totalMinutesRead: totalMinutes,
|
|
||||||
firstReadAt: this.readingStats.firstReadAt === 0 ? entry.readAt : this.readingStats.firstReadAt,
|
|
||||||
lastReadAt: entry.readAt,
|
|
||||||
currentStreakDays,
|
|
||||||
longestStreakDays,
|
|
||||||
lastStreakDate,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
clearHistory() { this.history = []; this.readLog = []; }
|
addBookmark(entry: Omit<BookmarkEntry, "savedAt">, label?: string) {
|
||||||
|
const filtered = this.bookmarks.filter(b => b.chapterId !== entry.chapterId);
|
||||||
|
this.bookmarks = [{ ...entry, savedAt: Date.now(), label }, ...filtered].slice(0, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
removeBookmark(chapterId: number) {
|
||||||
|
this.bookmarks = this.bookmarks.filter(b => b.chapterId !== chapterId);
|
||||||
|
}
|
||||||
|
|
||||||
|
clearBookmarks() { this.bookmarks = []; }
|
||||||
|
|
||||||
|
getBookmark(chapterId: number): BookmarkEntry | undefined {
|
||||||
|
return this.bookmarks.find(b => b.chapterId === chapterId);
|
||||||
|
}
|
||||||
|
|
||||||
|
addMarker(entry: Omit<MarkerEntry, "id" | "createdAt">): string {
|
||||||
|
const id = Math.random().toString(36).slice(2);
|
||||||
|
this.markers = [...this.markers, { ...entry, id, createdAt: Date.now() }];
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateMarker(id: string, patch: Partial<Pick<MarkerEntry, "note" | "color">>) {
|
||||||
|
this.markers = this.markers.map(m => m.id === id ? { ...m, ...patch, updatedAt: Date.now() } : m);
|
||||||
|
}
|
||||||
|
|
||||||
|
removeMarker(id: string) {
|
||||||
|
this.markers = this.markers.filter(m => m.id !== id);
|
||||||
|
}
|
||||||
|
|
||||||
|
getMarkersForPage(chapterId: number, page: number): MarkerEntry[] {
|
||||||
|
return this.markers.filter(m => m.chapterId === chapterId && m.pageNumber === page);
|
||||||
|
}
|
||||||
|
|
||||||
|
getMarkersForChapter(chapterId: number): MarkerEntry[] {
|
||||||
|
return this.markers.filter(m => m.chapterId === chapterId);
|
||||||
|
}
|
||||||
|
|
||||||
|
getMarkersForManga(mangaId: number): MarkerEntry[] {
|
||||||
|
return this.markers.filter(m => m.mangaId === mangaId);
|
||||||
|
}
|
||||||
|
|
||||||
|
clearMarkersForManga(mangaId: number) {
|
||||||
|
this.markers = this.markers.filter(m => m.mangaId !== mangaId);
|
||||||
|
}
|
||||||
|
|
||||||
|
clearHistory() { this.history = []; this.readLog = []; }
|
||||||
|
|
||||||
clearHistoryForManga(mangaId: number) {
|
clearHistoryForManga(mangaId: number) {
|
||||||
this.history = this.history.filter(x => x.mangaId !== mangaId);
|
this.history = this.history.filter(x => x.mangaId !== mangaId);
|
||||||
this.readLog = this.readLog.filter(x => x.mangaId !== mangaId);
|
this.readLog = this.readLog.filter(x => x.mangaId !== mangaId);
|
||||||
// Recompute stats after removal
|
|
||||||
const uniqueChapters = new Set(this.readLog.map(e => e.chapterId));
|
const uniqueChapters = new Set(this.readLog.map(e => e.chapterId));
|
||||||
const uniqueManga = new Set(this.readLog.map(e => e.mangaId));
|
const uniqueManga = new Set(this.readLog.map(e => e.mangaId));
|
||||||
const totalMinutes = this.readLog.reduce((sum, e) => sum + e.minutes, 0);
|
const totalMinutes = this.readLog.reduce((sum, e) => sum + e.minutes, 0);
|
||||||
@@ -536,11 +569,11 @@ class Store {
|
|||||||
wipeAllData() {
|
wipeAllData() {
|
||||||
this.history = [];
|
this.history = [];
|
||||||
this.readLog = [];
|
this.readLog = [];
|
||||||
|
this.markers = [];
|
||||||
this.readingStats = { ...DEFAULT_READING_STATS };
|
this.readingStats = { ...DEFAULT_READING_STATS };
|
||||||
this.settings = { ...this.settings, heroSlots: [null, null, null, null], mangaLinks: {} };
|
this.settings = { ...this.settings, heroSlots: [null, null, null, null], mangaLinks: {} };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
linkManga(idA: number, idB: number) {
|
linkManga(idA: number, idB: number) {
|
||||||
if (idA === idB) return;
|
if (idA === idB) return;
|
||||||
const links = { ...this.settings.mangaLinks };
|
const links = { ...this.settings.mangaLinks };
|
||||||
@@ -558,7 +591,7 @@ class Store {
|
|||||||
this.settings = { ...this.settings, mangaLinks: links };
|
this.settings = { ...this.settings, mangaLinks: links };
|
||||||
}
|
}
|
||||||
|
|
||||||
getLinkedMangaIds(mangaId: number): number[] { return this.settings.mangaLinks[mangaId] ?? []; }
|
getLinkedMangaIds(mangaId: number): number[] { return this.settings.mangaLinks[mangaId] ?? []; }
|
||||||
|
|
||||||
setHeroSlot(index: 1 | 2 | 3, mangaId: number | null) {
|
setHeroSlot(index: 1 | 2 | 3, mangaId: number | null) {
|
||||||
const slots = [...(this.settings.heroSlots ?? [null, null, null, null])];
|
const slots = [...(this.settings.heroSlots ?? [null, null, null, null])];
|
||||||
@@ -570,23 +603,22 @@ class Store {
|
|||||||
this.toasts = [...this.toasts, { ...toast, id: Math.random().toString(36).slice(2) }].slice(-5);
|
this.toasts = [...this.toasts, { ...toast, id: Math.random().toString(36).slice(2) }].slice(-5);
|
||||||
}
|
}
|
||||||
|
|
||||||
dismissToast(id: string) { this.toasts = this.toasts.filter(x => x.id !== id); }
|
dismissToast(id: string) { this.toasts = this.toasts.filter(x => x.id !== id); }
|
||||||
setCategories(cats: Category[]) { this.categories = cats; }
|
setCategories(cats: Category[]) { this.categories = cats; }
|
||||||
setActiveDownloads(next: ActiveDownload[]) { this.activeDownloads = next; }
|
setActiveDownloads(next: ActiveDownload[]) { this.activeDownloads = next; }
|
||||||
setNavPage(next: NavPage) { this.navPage = next; }
|
setNavPage(next: NavPage) { this.navPage = next; }
|
||||||
setLibraryFilter(next: LibraryFilter) { this.libraryFilter = next; }
|
setLibraryFilter(next: LibraryFilter) { this.libraryFilter = next; }
|
||||||
setGenreFilter(next: string) { this.genreFilter = next; }
|
setGenreFilter(next: string) { this.genreFilter = next; }
|
||||||
setSearchPrefill(next: string) { this.searchPrefill = next; }
|
setSearchPrefill(next: string) { this.searchPrefill = next; }
|
||||||
setActiveManga(next: Manga | null) { this.activeManga = next; }
|
setActiveManga(next: Manga | null) { this.activeManga = next; }
|
||||||
setPreviewManga(next: Manga | null) { this.previewManga = next; }
|
setPreviewManga(next: Manga | null) { this.previewManga = next; }
|
||||||
setActiveSource(next: Source | null) { this.activeSource = next; }
|
setActiveSource(next: Source | null) { this.activeSource = next; }
|
||||||
setPageUrls(next: string[]) { this.pageUrls = next; }
|
setPageUrls(next: string[]) { this.pageUrls = next; }
|
||||||
setPageNumber(next: number) { this.pageNumber = next; }
|
setPageNumber(next: number) { this.pageNumber = next; }
|
||||||
setLibraryTagFilter(next: string[]) { this.libraryTagFilter = next; }
|
setLibraryTagFilter(next: string[]) { this.libraryTagFilter = next; }
|
||||||
setSettingsOpen(next: boolean) { this.settingsOpen = next; }
|
setSettingsOpen(next: boolean) { this.settingsOpen = next; }
|
||||||
updateSettings(patch: Partial<Settings>) { this.settings = { ...this.settings, ...patch }; }
|
updateSettings(patch: Partial<Settings>) { this.settings = { ...this.settings, ...patch }; }
|
||||||
resetKeybinds() { this.settings = { ...this.settings, keybinds: DEFAULT_KEYBINDS }; }
|
resetKeybinds() { this.settings = { ...this.settings, keybinds: DEFAULT_KEYBINDS }; }
|
||||||
|
|
||||||
|
|
||||||
saveCustomTheme(theme: CustomTheme) {
|
saveCustomTheme(theme: CustomTheme) {
|
||||||
const existing = this.settings.customThemes.findIndex(t => t.id === theme.id);
|
const existing = this.settings.customThemes.findIndex(t => t.id === theme.id);
|
||||||
@@ -602,13 +634,6 @@ class Store {
|
|||||||
this.settings = { ...this.settings, customThemes: next, theme: wasActive ? "dark" : this.settings.theme };
|
this.settings = { ...this.settings, customThemes: next, theme: wasActive ? "dark" : this.settings.theme };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Auto-assign or remove the "Completed" category for a manga based on
|
|
||||||
* whether all chapters are read. Pass the `gql` executor to avoid a
|
|
||||||
* circular import between state.svelte.ts and client.ts.
|
|
||||||
*
|
|
||||||
* Call after any batch mark-read/unread operation.
|
|
||||||
*/
|
|
||||||
async checkAndMarkCompleted(
|
async checkAndMarkCompleted(
|
||||||
mangaId: number,
|
mangaId: number,
|
||||||
chaps: Chapter[],
|
chaps: Chapter[],
|
||||||
@@ -623,7 +648,6 @@ class Store {
|
|||||||
if (!completed) return;
|
if (!completed) return;
|
||||||
if (allRead) {
|
if (allRead) {
|
||||||
await gqlFn(UPDATE_MANGA_CATEGORIES, { mangaId, addTo: [completed.id], removeFrom: [] }).catch(console.error);
|
await gqlFn(UPDATE_MANGA_CATEGORIES, { mangaId, addTo: [completed.id], removeFrom: [] }).catch(console.error);
|
||||||
// Ensure the manga is in the library so it shows up in the Saved tab
|
|
||||||
if (UPDATE_MANGA) {
|
if (UPDATE_MANGA) {
|
||||||
await gqlFn(UPDATE_MANGA, { id: mangaId, inLibrary: true }).catch(console.error);
|
await gqlFn(UPDATE_MANGA, { id: mangaId, inLibrary: true }).catch(console.error);
|
||||||
}
|
}
|
||||||
@@ -647,39 +671,48 @@ class Store {
|
|||||||
|
|
||||||
export const store = new Store();
|
export const store = new Store();
|
||||||
|
|
||||||
// ── Function re-exports — zero call-site changes for actions ──────────────────
|
export function openReader(chapter: Chapter, chapterList: Chapter[], manga?: Manga | null) { store.openReader(chapter, chapterList, manga); }
|
||||||
|
export function closeReader() { store.closeReader(); }
|
||||||
export function openReader(chapter: Chapter, chapterList: Chapter[]) { store.openReader(chapter, chapterList); }
|
|
||||||
export function closeReader() { store.closeReader(); }
|
|
||||||
export function addHistory(entry: HistoryEntry, completed?: boolean, minutes?: number) { store.addHistory(entry, completed, minutes); }
|
export function addHistory(entry: HistoryEntry, completed?: boolean, minutes?: number) { store.addHistory(entry, completed, minutes); }
|
||||||
export function clearHistory() { store.clearHistory(); }
|
export function clearHistory() { store.clearHistory(); }
|
||||||
export function clearHistoryForManga(mangaId: number) { store.clearHistoryForManga(mangaId); }
|
export function clearHistoryForManga(mangaId: number) { store.clearHistoryForManga(mangaId); }
|
||||||
export function wipeAllData() { store.wipeAllData(); }
|
export function wipeAllData() { store.wipeAllData(); }
|
||||||
export function linkManga(idA: number, idB: number) { store.linkManga(idA, idB); }
|
export function linkManga(idA: number, idB: number) { store.linkManga(idA, idB); }
|
||||||
export function unlinkManga(idA: number, idB: number) { store.unlinkManga(idA, idB); }
|
export function unlinkManga(idA: number, idB: number) { store.unlinkManga(idA, idB); }
|
||||||
export function getLinkedMangaIds(mangaId: number) { return store.getLinkedMangaIds(mangaId); }
|
export function getLinkedMangaIds(mangaId: number) { return store.getLinkedMangaIds(mangaId); }
|
||||||
export function setHeroSlot(i: 1|2|3, mangaId: number | null) { store.setHeroSlot(i, mangaId); }
|
export function setHeroSlot(i: 1|2|3, mangaId: number | null) { store.setHeroSlot(i, mangaId); }
|
||||||
export function addToast(toast: Omit<Toast, "id">) { store.addToast(toast); }
|
export function addToast(toast: Omit<Toast, "id">) { store.addToast(toast); }
|
||||||
export function dismissToast(id: string) { store.dismissToast(id); }
|
export function dismissToast(id: string) { store.dismissToast(id); }
|
||||||
export function setCategories(cats: Category[]) { store.setCategories(cats); }
|
export function setCategories(cats: Category[]) { store.setCategories(cats); }
|
||||||
export function setActiveDownloads(next: ActiveDownload[]) { store.setActiveDownloads(next); }
|
export function setActiveDownloads(next: ActiveDownload[]) { store.setActiveDownloads(next); }
|
||||||
export function setNavPage(next: NavPage) { store.setNavPage(next); }
|
export function setNavPage(next: NavPage) { store.setNavPage(next); }
|
||||||
export function setLibraryFilter(next: LibraryFilter) { store.setLibraryFilter(next); }
|
export function setLibraryFilter(next: LibraryFilter) { store.setLibraryFilter(next); }
|
||||||
export function setGenreFilter(next: string) { store.setGenreFilter(next); }
|
export function setGenreFilter(next: string) { store.setGenreFilter(next); }
|
||||||
export function setSearchPrefill(next: string) { store.setSearchPrefill(next); }
|
export function setSearchPrefill(next: string) { store.setSearchPrefill(next); }
|
||||||
export function setActiveManga(next: Manga | null) { store.setActiveManga(next); }
|
export function setActiveManga(next: Manga | null) { store.setActiveManga(next); }
|
||||||
export function setPreviewManga(next: Manga | null) { store.setPreviewManga(next); }
|
export function setPreviewManga(next: Manga | null) { store.setPreviewManga(next); }
|
||||||
export function setActiveSource(next: Source | null) { store.setActiveSource(next); }
|
export function setActiveSource(next: Source | null) { store.setActiveSource(next); }
|
||||||
export function setPageUrls(next: string[]) { store.setPageUrls(next); }
|
export function setPageUrls(next: string[]) { store.setPageUrls(next); }
|
||||||
export function setPageNumber(next: number) { store.setPageNumber(next); }
|
export function setPageNumber(next: number) { store.setPageNumber(next); }
|
||||||
export function setLibraryTagFilter(next: string[]) { store.setLibraryTagFilter(next); }
|
export function setLibraryTagFilter(next: string[]) { store.setLibraryTagFilter(next); }
|
||||||
export function setSettingsOpen(next: boolean) { store.setSettingsOpen(next); }
|
export function setSettingsOpen(next: boolean) { store.setSettingsOpen(next); }
|
||||||
export function updateSettings(patch: Partial<Settings>) { store.updateSettings(patch); }
|
export function updateSettings(patch: Partial<Settings>) { store.updateSettings(patch); }
|
||||||
export function resetKeybinds() { store.resetKeybinds(); }
|
export function resetKeybinds() { store.resetKeybinds(); }
|
||||||
export function clearDiscoverCache() { store.clearDiscoverCache(); }
|
export function clearDiscoverCache() { store.clearDiscoverCache(); }
|
||||||
export function toggleHiddenCategory(id: number) { store.toggleHiddenCategory(id); }
|
export function addBookmark(entry: Omit<BookmarkEntry, "savedAt">, label?: string) { store.addBookmark(entry, label); }
|
||||||
export function saveCustomTheme(theme: CustomTheme) { store.saveCustomTheme(theme); }
|
export function removeBookmark(chapterId: number) { store.removeBookmark(chapterId); }
|
||||||
export function deleteCustomTheme(id: string) { store.deleteCustomTheme(id); }
|
export function clearBookmarks() { store.clearBookmarks(); }
|
||||||
|
export function getBookmark(chapterId: number) { return store.getBookmark(chapterId); }
|
||||||
|
export function addMarker(entry: Omit<MarkerEntry, "id" | "createdAt">): string { return store.addMarker(entry); }
|
||||||
|
export function updateMarker(id: string, patch: Partial<Pick<MarkerEntry, "note" | "color">>) { store.updateMarker(id, patch); }
|
||||||
|
export function removeMarker(id: string) { store.removeMarker(id); }
|
||||||
|
export function getMarkersForPage(chapterId: number, page: number) { return store.getMarkersForPage(chapterId, page); }
|
||||||
|
export function getMarkersForChapter(chapterId: number) { return store.getMarkersForChapter(chapterId); }
|
||||||
|
export function getMarkersForManga(mangaId: number) { return store.getMarkersForManga(mangaId); }
|
||||||
|
export function clearMarkersForManga(mangaId: number) { store.clearMarkersForManga(mangaId); }
|
||||||
|
export function toggleHiddenCategory(id: number) { store.toggleHiddenCategory(id); }
|
||||||
|
export function saveCustomTheme(theme: CustomTheme) { store.saveCustomTheme(theme); }
|
||||||
|
export function deleteCustomTheme(id: string) { store.deleteCustomTheme(id); }
|
||||||
export async function checkAndMarkCompleted(
|
export async function checkAndMarkCompleted(
|
||||||
mangaId: number,
|
mangaId: number,
|
||||||
chaps: Chapter[],
|
chaps: Chapter[],
|
||||||
|
|||||||
Reference in New Issue
Block a user