mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 17:29:55 -05:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 023b23288b | |||
| 67a9f0b944 | |||
| 56392e2427 | |||
| 843e205072 | |||
| ee708d85d0 | |||
| 8005c82654 | |||
| d989b2d67e | |||
| 6446a19b2d | |||
| 5cd96abc0c |
+1
-1
@@ -181,7 +181,7 @@ modules:
|
||||
path: .
|
||||
- type: file
|
||||
path: packaging/frontend-dist.tar.gz
|
||||
sha256: 43b7274bdab884aacbc3dad6f0f7c043d8e3d82b7bf7398e1df9f516ed553152
|
||||
sha256: d3ebde4d39e3de61420b78a9506df1a5c77c14d705e42662a45a2179bc96030e
|
||||
- packaging/cargo-sources.json
|
||||
- type: inline
|
||||
dest: src-tauri/.cargo
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
|
||||
perSystem = { system, lib, ... }:
|
||||
let
|
||||
version = "0.7.0";
|
||||
version = "0.7.1";
|
||||
|
||||
pkgs = import inputs.nixpkgs {
|
||||
inherit system;
|
||||
@@ -71,7 +71,7 @@
|
||||
inherit version;
|
||||
src = frontendSrc;
|
||||
fetcherVersion = 1;
|
||||
hash = "sha256-ezlckHhfSIe/Hs30tbiN0a/EuvGxhO5L020aup23Ozg=";
|
||||
hash = "sha256-nlhm3NYn4x+JlKcCgj1lAX43muB3QRKGDzaxfQNfJwc=";
|
||||
};
|
||||
|
||||
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
|
||||
{
|
||||
apps = {
|
||||
@@ -272,6 +281,7 @@ EOF
|
||||
bump = { type = "app"; program = "${bumpScript}/bin/moku-bump"; };
|
||||
flatpak = { type = "app"; program = "${flatpakScript}/bin/moku-flatpak"; };
|
||||
pkgbuild-bump = { type = "app"; program = "${pkgbuildBumpScript}/bin/moku-pkgbuild-bump"; };
|
||||
tunnel = { type = "app"; program = "${tunnelScript}/bin/moku-tunnel"; };
|
||||
};
|
||||
|
||||
packages = {
|
||||
@@ -288,6 +298,7 @@ EOF
|
||||
nodejs_22
|
||||
pnpm
|
||||
suwayomi-server
|
||||
cloudflared
|
||||
xdg-utils
|
||||
];
|
||||
shellHook = ''
|
||||
@@ -301,6 +312,7 @@ EOF
|
||||
echo " nix run .#bump -- <ver> bump versions only"
|
||||
echo " nix run .#flatpak -- <ver> full flatpak build"
|
||||
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": {
|
||||
"@tauri-apps/api": "^2.0.0",
|
||||
"@tauri-apps/plugin-http": "^2.5.8",
|
||||
"@tauri-apps/plugin-os": "^2.3.2",
|
||||
"@tauri-apps/plugin-shell": "^2.3.5",
|
||||
"clsx": "^2.1.1",
|
||||
"phosphor-svelte": "^3.1.0",
|
||||
"svelte-spa-router": "^4.0.1",
|
||||
"tauri-plugin-discord-rpc-api": "github:Youwes09/tauri-plugin-discord-rpc",
|
||||
"tauri-plugin-drpc": "^1.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
+115
-115
@@ -2030,92 +2030,92 @@
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
"url": "https://static.crates.io/crates/icu_collections/icu_collections-2.1.1.crate",
|
||||
"sha256": "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43",
|
||||
"dest": "cargo/vendor/icu_collections-2.1.1"
|
||||
"url": "https://static.crates.io/crates/icu_collections/icu_collections-2.2.0.crate",
|
||||
"sha256": "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c",
|
||||
"dest": "cargo/vendor/icu_collections-2.2.0"
|
||||
},
|
||||
{
|
||||
"type": "inline",
|
||||
"contents": "{\"package\": \"4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/icu_collections-2.1.1",
|
||||
"contents": "{\"package\": \"2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/icu_collections-2.2.0",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
"url": "https://static.crates.io/crates/icu_locale_core/icu_locale_core-2.1.1.crate",
|
||||
"sha256": "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6",
|
||||
"dest": "cargo/vendor/icu_locale_core-2.1.1"
|
||||
"url": "https://static.crates.io/crates/icu_locale_core/icu_locale_core-2.2.0.crate",
|
||||
"sha256": "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29",
|
||||
"dest": "cargo/vendor/icu_locale_core-2.2.0"
|
||||
},
|
||||
{
|
||||
"type": "inline",
|
||||
"contents": "{\"package\": \"edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/icu_locale_core-2.1.1",
|
||||
"contents": "{\"package\": \"92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/icu_locale_core-2.2.0",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
"url": "https://static.crates.io/crates/icu_normalizer/icu_normalizer-2.1.1.crate",
|
||||
"sha256": "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599",
|
||||
"dest": "cargo/vendor/icu_normalizer-2.1.1"
|
||||
"url": "https://static.crates.io/crates/icu_normalizer/icu_normalizer-2.2.0.crate",
|
||||
"sha256": "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4",
|
||||
"dest": "cargo/vendor/icu_normalizer-2.2.0"
|
||||
},
|
||||
{
|
||||
"type": "inline",
|
||||
"contents": "{\"package\": \"5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/icu_normalizer-2.1.1",
|
||||
"contents": "{\"package\": \"c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/icu_normalizer-2.2.0",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
"url": "https://static.crates.io/crates/icu_normalizer_data/icu_normalizer_data-2.1.1.crate",
|
||||
"sha256": "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a",
|
||||
"dest": "cargo/vendor/icu_normalizer_data-2.1.1"
|
||||
"url": "https://static.crates.io/crates/icu_normalizer_data/icu_normalizer_data-2.2.0.crate",
|
||||
"sha256": "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38",
|
||||
"dest": "cargo/vendor/icu_normalizer_data-2.2.0"
|
||||
},
|
||||
{
|
||||
"type": "inline",
|
||||
"contents": "{\"package\": \"7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/icu_normalizer_data-2.1.1",
|
||||
"contents": "{\"package\": \"da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/icu_normalizer_data-2.2.0",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
"url": "https://static.crates.io/crates/icu_properties/icu_properties-2.1.2.crate",
|
||||
"sha256": "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec",
|
||||
"dest": "cargo/vendor/icu_properties-2.1.2"
|
||||
"url": "https://static.crates.io/crates/icu_properties/icu_properties-2.2.0.crate",
|
||||
"sha256": "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de",
|
||||
"dest": "cargo/vendor/icu_properties-2.2.0"
|
||||
},
|
||||
{
|
||||
"type": "inline",
|
||||
"contents": "{\"package\": \"020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/icu_properties-2.1.2",
|
||||
"contents": "{\"package\": \"bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/icu_properties-2.2.0",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
"url": "https://static.crates.io/crates/icu_properties_data/icu_properties_data-2.1.2.crate",
|
||||
"sha256": "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af",
|
||||
"dest": "cargo/vendor/icu_properties_data-2.1.2"
|
||||
"url": "https://static.crates.io/crates/icu_properties_data/icu_properties_data-2.2.0.crate",
|
||||
"sha256": "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14",
|
||||
"dest": "cargo/vendor/icu_properties_data-2.2.0"
|
||||
},
|
||||
{
|
||||
"type": "inline",
|
||||
"contents": "{\"package\": \"616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/icu_properties_data-2.1.2",
|
||||
"contents": "{\"package\": \"8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/icu_properties_data-2.2.0",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
"url": "https://static.crates.io/crates/icu_provider/icu_provider-2.1.1.crate",
|
||||
"sha256": "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614",
|
||||
"dest": "cargo/vendor/icu_provider-2.1.1"
|
||||
"url": "https://static.crates.io/crates/icu_provider/icu_provider-2.2.0.crate",
|
||||
"sha256": "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421",
|
||||
"dest": "cargo/vendor/icu_provider-2.2.0"
|
||||
},
|
||||
{
|
||||
"type": "inline",
|
||||
"contents": "{\"package\": \"85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/icu_provider-2.1.1",
|
||||
"contents": "{\"package\": \"139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/icu_provider-2.2.0",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
@@ -2186,14 +2186,14 @@
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
"url": "https://static.crates.io/crates/indexmap/indexmap-2.13.0.crate",
|
||||
"sha256": "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017",
|
||||
"dest": "cargo/vendor/indexmap-2.13.0"
|
||||
"url": "https://static.crates.io/crates/indexmap/indexmap-2.13.1.crate",
|
||||
"sha256": "45a8a2b9cb3e0b0c1803dbb0758ffac5de2f425b23c28f518faabd9d805342ff",
|
||||
"dest": "cargo/vendor/indexmap-2.13.1"
|
||||
},
|
||||
{
|
||||
"type": "inline",
|
||||
"contents": "{\"package\": \"7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/indexmap-2.13.0",
|
||||
"contents": "{\"package\": \"45a8a2b9cb3e0b0c1803dbb0758ffac5de2f425b23c28f518faabd9d805342ff\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/indexmap-2.13.1",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
@@ -2459,14 +2459,14 @@
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
"url": "https://static.crates.io/crates/libc/libc-0.2.183.crate",
|
||||
"sha256": "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d",
|
||||
"dest": "cargo/vendor/libc-0.2.183"
|
||||
"url": "https://static.crates.io/crates/libc/libc-0.2.184.crate",
|
||||
"sha256": "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af",
|
||||
"dest": "cargo/vendor/libc-0.2.184"
|
||||
},
|
||||
{
|
||||
"type": "inline",
|
||||
"contents": "{\"package\": \"b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/libc-0.2.183",
|
||||
"contents": "{\"package\": \"48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/libc-0.2.184",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
@@ -2511,14 +2511,14 @@
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
"url": "https://static.crates.io/crates/litemap/litemap-0.8.1.crate",
|
||||
"sha256": "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77",
|
||||
"dest": "cargo/vendor/litemap-0.8.1"
|
||||
"url": "https://static.crates.io/crates/litemap/litemap-0.8.2.crate",
|
||||
"sha256": "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0",
|
||||
"dest": "cargo/vendor/litemap-0.8.2"
|
||||
},
|
||||
{
|
||||
"type": "inline",
|
||||
"contents": "{\"package\": \"6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/litemap-0.8.1",
|
||||
"contents": "{\"package\": \"92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/litemap-0.8.2",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
@@ -2719,14 +2719,14 @@
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
"url": "https://static.crates.io/crates/muda/muda-0.17.1.crate",
|
||||
"sha256": "01c1738382f66ed56b3b9c8119e794a2e23148ac8ea214eda86622d4cb9d415a",
|
||||
"dest": "cargo/vendor/muda-0.17.1"
|
||||
"url": "https://static.crates.io/crates/muda/muda-0.17.2.crate",
|
||||
"sha256": "7c9fec5a4e89860383d778d10563a605838f8f0b2f9303868937e5ff32e86177",
|
||||
"dest": "cargo/vendor/muda-0.17.2"
|
||||
},
|
||||
{
|
||||
"type": "inline",
|
||||
"contents": "{\"package\": \"01c1738382f66ed56b3b9c8119e794a2e23148ac8ea214eda86622d4cb9d415a\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/muda-0.17.1",
|
||||
"contents": "{\"package\": \"7c9fec5a4e89860383d778d10563a605838f8f0b2f9303868937e5ff32e86177\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/muda-0.17.2",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
@@ -3577,14 +3577,14 @@
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
"url": "https://static.crates.io/crates/potential_utf/potential_utf-0.1.4.crate",
|
||||
"sha256": "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77",
|
||||
"dest": "cargo/vendor/potential_utf-0.1.4"
|
||||
"url": "https://static.crates.io/crates/potential_utf/potential_utf-0.1.5.crate",
|
||||
"sha256": "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564",
|
||||
"dest": "cargo/vendor/potential_utf-0.1.5"
|
||||
},
|
||||
{
|
||||
"type": "inline",
|
||||
"contents": "{\"package\": \"b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/potential_utf-0.1.4",
|
||||
"contents": "{\"package\": \"0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/potential_utf-0.1.5",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
@@ -5514,14 +5514,14 @@
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
"url": "https://static.crates.io/crates/tinystr/tinystr-0.8.2.crate",
|
||||
"sha256": "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869",
|
||||
"dest": "cargo/vendor/tinystr-0.8.2"
|
||||
"url": "https://static.crates.io/crates/tinystr/tinystr-0.8.3.crate",
|
||||
"sha256": "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d",
|
||||
"dest": "cargo/vendor/tinystr-0.8.3"
|
||||
},
|
||||
{
|
||||
"type": "inline",
|
||||
"contents": "{\"package\": \"42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/tinystr-0.8.2",
|
||||
"contents": "{\"package\": \"c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/tinystr-0.8.3",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
@@ -5696,27 +5696,27 @@
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
"url": "https://static.crates.io/crates/toml_edit/toml_edit-0.25.9+spec-1.1.0.crate",
|
||||
"sha256": "da053d28fe57e2c9d21b48261e14e7b4c8b670b54d2c684847b91feaf4c7dac5",
|
||||
"dest": "cargo/vendor/toml_edit-0.25.9+spec-1.1.0"
|
||||
"url": "https://static.crates.io/crates/toml_edit/toml_edit-0.25.10+spec-1.1.0.crate",
|
||||
"sha256": "a82418ca169e235e6c399a84e395ab6debeb3bc90edc959bf0f48647c6a32d1b",
|
||||
"dest": "cargo/vendor/toml_edit-0.25.10+spec-1.1.0"
|
||||
},
|
||||
{
|
||||
"type": "inline",
|
||||
"contents": "{\"package\": \"da053d28fe57e2c9d21b48261e14e7b4c8b670b54d2c684847b91feaf4c7dac5\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/toml_edit-0.25.9+spec-1.1.0",
|
||||
"contents": "{\"package\": \"a82418ca169e235e6c399a84e395ab6debeb3bc90edc959bf0f48647c6a32d1b\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/toml_edit-0.25.10+spec-1.1.0",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
"url": "https://static.crates.io/crates/toml_parser/toml_parser-1.1.1+spec-1.1.0.crate",
|
||||
"sha256": "39ca317ebc49f06bd748bfba29533eac9485569dc9bf80b849024b025e814fb9",
|
||||
"dest": "cargo/vendor/toml_parser-1.1.1+spec-1.1.0"
|
||||
"url": "https://static.crates.io/crates/toml_parser/toml_parser-1.1.2+spec-1.1.0.crate",
|
||||
"sha256": "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526",
|
||||
"dest": "cargo/vendor/toml_parser-1.1.2+spec-1.1.0"
|
||||
},
|
||||
{
|
||||
"type": "inline",
|
||||
"contents": "{\"package\": \"39ca317ebc49f06bd748bfba29533eac9485569dc9bf80b849024b025e814fb9\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/toml_parser-1.1.1+spec-1.1.0",
|
||||
"contents": "{\"package\": \"a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/toml_parser-1.1.2+spec-1.1.0",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
@@ -7438,14 +7438,14 @@
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
"url": "https://static.crates.io/crates/writeable/writeable-0.6.2.crate",
|
||||
"sha256": "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9",
|
||||
"dest": "cargo/vendor/writeable-0.6.2"
|
||||
"url": "https://static.crates.io/crates/writeable/writeable-0.6.3.crate",
|
||||
"sha256": "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4",
|
||||
"dest": "cargo/vendor/writeable-0.6.3"
|
||||
},
|
||||
{
|
||||
"type": "inline",
|
||||
"contents": "{\"package\": \"9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/writeable-0.6.2",
|
||||
"contents": "{\"package\": \"1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/writeable-0.6.3",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
@@ -7503,27 +7503,27 @@
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
"url": "https://static.crates.io/crates/yoke/yoke-0.8.1.crate",
|
||||
"sha256": "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954",
|
||||
"dest": "cargo/vendor/yoke-0.8.1"
|
||||
"url": "https://static.crates.io/crates/yoke/yoke-0.8.2.crate",
|
||||
"sha256": "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca",
|
||||
"dest": "cargo/vendor/yoke-0.8.2"
|
||||
},
|
||||
{
|
||||
"type": "inline",
|
||||
"contents": "{\"package\": \"72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/yoke-0.8.1",
|
||||
"contents": "{\"package\": \"abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/yoke-0.8.2",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
"url": "https://static.crates.io/crates/yoke-derive/yoke-derive-0.8.1.crate",
|
||||
"sha256": "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d",
|
||||
"dest": "cargo/vendor/yoke-derive-0.8.1"
|
||||
"url": "https://static.crates.io/crates/yoke-derive/yoke-derive-0.8.2.crate",
|
||||
"sha256": "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e",
|
||||
"dest": "cargo/vendor/yoke-derive-0.8.2"
|
||||
},
|
||||
{
|
||||
"type": "inline",
|
||||
"contents": "{\"package\": \"b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/yoke-derive-0.8.1",
|
||||
"contents": "{\"package\": \"de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/yoke-derive-0.8.2",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
@@ -7555,27 +7555,27 @@
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
"url": "https://static.crates.io/crates/zerofrom/zerofrom-0.1.6.crate",
|
||||
"sha256": "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5",
|
||||
"dest": "cargo/vendor/zerofrom-0.1.6"
|
||||
"url": "https://static.crates.io/crates/zerofrom/zerofrom-0.1.7.crate",
|
||||
"sha256": "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df",
|
||||
"dest": "cargo/vendor/zerofrom-0.1.7"
|
||||
},
|
||||
{
|
||||
"type": "inline",
|
||||
"contents": "{\"package\": \"50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/zerofrom-0.1.6",
|
||||
"contents": "{\"package\": \"69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/zerofrom-0.1.7",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
"url": "https://static.crates.io/crates/zerofrom-derive/zerofrom-derive-0.1.6.crate",
|
||||
"sha256": "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502",
|
||||
"dest": "cargo/vendor/zerofrom-derive-0.1.6"
|
||||
"url": "https://static.crates.io/crates/zerofrom-derive/zerofrom-derive-0.1.7.crate",
|
||||
"sha256": "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1",
|
||||
"dest": "cargo/vendor/zerofrom-derive-0.1.7"
|
||||
},
|
||||
{
|
||||
"type": "inline",
|
||||
"contents": "{\"package\": \"d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/zerofrom-derive-0.1.6",
|
||||
"contents": "{\"package\": \"11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/zerofrom-derive-0.1.7",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
@@ -7594,40 +7594,40 @@
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
"url": "https://static.crates.io/crates/zerotrie/zerotrie-0.2.3.crate",
|
||||
"sha256": "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851",
|
||||
"dest": "cargo/vendor/zerotrie-0.2.3"
|
||||
"url": "https://static.crates.io/crates/zerotrie/zerotrie-0.2.4.crate",
|
||||
"sha256": "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf",
|
||||
"dest": "cargo/vendor/zerotrie-0.2.4"
|
||||
},
|
||||
{
|
||||
"type": "inline",
|
||||
"contents": "{\"package\": \"2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/zerotrie-0.2.3",
|
||||
"contents": "{\"package\": \"0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/zerotrie-0.2.4",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
"url": "https://static.crates.io/crates/zerovec/zerovec-0.11.5.crate",
|
||||
"sha256": "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002",
|
||||
"dest": "cargo/vendor/zerovec-0.11.5"
|
||||
"url": "https://static.crates.io/crates/zerovec/zerovec-0.11.6.crate",
|
||||
"sha256": "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239",
|
||||
"dest": "cargo/vendor/zerovec-0.11.6"
|
||||
},
|
||||
{
|
||||
"type": "inline",
|
||||
"contents": "{\"package\": \"6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/zerovec-0.11.5",
|
||||
"contents": "{\"package\": \"90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/zerovec-0.11.6",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
"url": "https://static.crates.io/crates/zerovec-derive/zerovec-derive-0.11.2.crate",
|
||||
"sha256": "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3",
|
||||
"dest": "cargo/vendor/zerovec-derive-0.11.2"
|
||||
"url": "https://static.crates.io/crates/zerovec-derive/zerovec-derive-0.11.3.crate",
|
||||
"sha256": "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555",
|
||||
"dest": "cargo/vendor/zerovec-derive-0.11.3"
|
||||
},
|
||||
{
|
||||
"type": "inline",
|
||||
"contents": "{\"package\": \"eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/zerovec-derive-0.11.2",
|
||||
"contents": "{\"package\": \"625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/zerovec-derive-0.11.3",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
|
||||
Generated
+21
@@ -11,6 +11,9 @@ importers:
|
||||
'@tauri-apps/api':
|
||||
specifier: ^2.0.0
|
||||
version: 2.10.1
|
||||
'@tauri-apps/plugin-http':
|
||||
specifier: ^2.5.8
|
||||
version: 2.5.8
|
||||
'@tauri-apps/plugin-os':
|
||||
specifier: ^2.3.2
|
||||
version: 2.3.2
|
||||
@@ -26,6 +29,9 @@ importers:
|
||||
svelte-spa-router:
|
||||
specifier: ^4.0.1
|
||||
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:
|
||||
specifier: ^1.0.3
|
||||
version: 1.0.3(typescript@5.9.3)
|
||||
@@ -442,6 +448,9 @@ packages:
|
||||
engines: {node: '>= 10'}
|
||||
hasBin: true
|
||||
|
||||
'@tauri-apps/plugin-http@2.5.8':
|
||||
resolution: {integrity: sha512-oxd7oypzQeu8kAfFCrw534Kq7Cw+NzozcnCY21O4rz3A+veJiIiuSCMIprgGcZOcLAXFP9GmDhKUbhuKWcunRw==}
|
||||
|
||||
'@tauri-apps/plugin-os@2.3.2':
|
||||
resolution: {integrity: sha512-n+nXWeuSeF9wcEsSPmRnBEGrRgOy6jjkSU+UVCOV8YUGKb2erhDOxis7IqRXiRVHhY8XMKks00BJ0OAdkpf6+A==}
|
||||
|
||||
@@ -747,6 +756,10 @@ packages:
|
||||
resolution: {integrity: sha512-TTDxwYnHkova6Wsyj1PGt9TByuWqvMoeY1bQiuAf2DM/JeDSMw7FjRKzk8K/5mJ99vGOKhbCqTDpyAKwjp4igg==}
|
||||
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:
|
||||
resolution: {integrity: sha512-vl5dXhjKbl7+Nf9veW12usdmIUtZXwEf91SzxQPZlbRRJ/sjizbbQlnkUTtx6baJuGzz0KXXgP9xUhF39BdiXQ==}
|
||||
peerDependencies:
|
||||
@@ -1046,6 +1059,10 @@ snapshots:
|
||||
'@tauri-apps/cli-win32-ia32-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':
|
||||
dependencies:
|
||||
'@tauri-apps/api': 2.10.1
|
||||
@@ -1372,6 +1389,10 @@ snapshots:
|
||||
magic-string: 0.30.21
|
||||
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):
|
||||
dependencies:
|
||||
typescript: 5.9.3
|
||||
|
||||
Generated
+187
-59
@@ -268,9 +268,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.2.58"
|
||||
version = "1.2.59"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e1e928d4b69e3077709075a938a05ffbedfa53a84c8f766efbf8220bb1ff60e1"
|
||||
checksum = "b7a4d3ec6524d28a329fc53654bbadc9bdd7b0431f5d65f1a56ffb28a1ee5283"
|
||||
dependencies = [
|
||||
"find-msvc-tools",
|
||||
"shlex",
|
||||
@@ -354,24 +354,6 @@ dependencies = [
|
||||
"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]]
|
||||
name = "cookie_store"
|
||||
version = "0.22.1"
|
||||
@@ -425,7 +407,7 @@ dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"core-foundation 0.10.1",
|
||||
"core-graphics-types",
|
||||
"foreign-types",
|
||||
"foreign-types 0.5.0",
|
||||
"libc",
|
||||
]
|
||||
|
||||
@@ -699,6 +681,21 @@ dependencies = [
|
||||
"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]]
|
||||
name = "dispatch2"
|
||||
version = "0.3.1"
|
||||
@@ -869,9 +866,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "fastrand"
|
||||
version = "2.3.0"
|
||||
version = "2.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
|
||||
checksum = "a043dc74da1e37d6afe657061213aa6f425f855399a11d3463c6ecccc4dfda1f"
|
||||
|
||||
[[package]]
|
||||
name = "fdeflate"
|
||||
@@ -937,6 +934,15 @@ version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "foreign-types"
|
||||
version = "0.5.0"
|
||||
@@ -944,7 +950,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965"
|
||||
dependencies = [
|
||||
"foreign-types-macros",
|
||||
"foreign-types-shared",
|
||||
"foreign-types-shared 0.3.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -958,6 +964,12 @@ dependencies = [
|
||||
"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]]
|
||||
name = "foreign-types-shared"
|
||||
version = "0.3.1"
|
||||
@@ -990,6 +1002,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1538,6 +1551,22 @@ dependencies = [
|
||||
"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]]
|
||||
name = "hyper-util"
|
||||
version = "0.1.20"
|
||||
@@ -2104,20 +2133,23 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "moku"
|
||||
version = "0.7.0"
|
||||
version = "0.7.1"
|
||||
dependencies = [
|
||||
"dirs 5.0.1",
|
||||
"reqwest 0.12.28",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sysinfo",
|
||||
"sysinfo 0.32.1",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-drpc",
|
||||
"tauri-plugin-discord-rpc",
|
||||
"tauri-plugin-http",
|
||||
"tauri-plugin-os",
|
||||
"tauri-plugin-process",
|
||||
"tauri-plugin-shell",
|
||||
"tauri-plugin-updater",
|
||||
"tokio",
|
||||
"urlencoding",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
@@ -2142,6 +2174,23 @@ dependencies = [
|
||||
"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]]
|
||||
name = "ndk"
|
||||
version = "0.9.0"
|
||||
@@ -2370,6 +2419,16 @@ dependencies = [
|
||||
"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]]
|
||||
name = "objc2-io-surface"
|
||||
version = "0.3.2"
|
||||
@@ -2468,12 +2527,50 @@ dependencies = [
|
||||
"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]]
|
||||
name = "openssl-probe"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "option-ext"
|
||||
version = "0.2.0"
|
||||
@@ -3254,19 +3351,23 @@ dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"cookie",
|
||||
"cookie_store 0.22.1",
|
||||
"cookie_store",
|
||||
"encoding_rs",
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"h2",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-rustls",
|
||||
"hyper-tls",
|
||||
"hyper-util",
|
||||
"js-sys",
|
||||
"log",
|
||||
"mime",
|
||||
"native-tls",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"quinn",
|
||||
@@ -3277,6 +3378,7 @@ dependencies = [
|
||||
"serde_urlencoded",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-native-tls",
|
||||
"tokio-rustls",
|
||||
"tower",
|
||||
"tower-http",
|
||||
@@ -3341,19 +3443,6 @@ dependencies = [
|
||||
"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]]
|
||||
name = "rustc-hash"
|
||||
version = "2.1.2"
|
||||
@@ -3605,9 +3694,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "semver"
|
||||
version = "1.0.27"
|
||||
version = "1.0.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2"
|
||||
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_core",
|
||||
@@ -4082,6 +4171,20 @@ dependencies = [
|
||||
"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]]
|
||||
name = "system-configuration"
|
||||
version = "0.7.0"
|
||||
@@ -4314,15 +4417,16 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-drpc"
|
||||
version = "0.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7b291669b7dbc05471fba380eeecf31e3f733ae6013aaa5216a43ca376027e5a"
|
||||
name = "tauri-plugin-discord-rpc"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/Youwes09/tauri-plugin-discord-rpc#d2fd312945d0573153e0e7e2d2dfb131acecc52c"
|
||||
dependencies = [
|
||||
"discord-rich-presence",
|
||||
"libc",
|
||||
"log",
|
||||
"rpcdiscord",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sysinfo 0.36.1",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"thiserror 2.0.18",
|
||||
@@ -4331,13 +4435,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-fs"
|
||||
version = "2.4.5"
|
||||
version = "2.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed390cc669f937afeb8b28032ce837bac8ea023d975a2e207375ec05afaf1804"
|
||||
checksum = "36e1ec28b79f3d0683f4507e1615c36292c0ea6716668770d4396b9b39871ed8"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"dunce",
|
||||
"glob",
|
||||
"log",
|
||||
"objc2-foundation",
|
||||
"percent-encoding",
|
||||
"schemars 0.8.22",
|
||||
"serde",
|
||||
@@ -4353,12 +4459,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-http"
|
||||
version = "2.5.7"
|
||||
version = "2.5.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d8f069451c4e87e7e2636b7f065a4c52866c4ce5e60e2d53fa1038edb6d184dc"
|
||||
checksum = "cfba7d4ec72763f9d1fdf73c217747f01e2c84b08b87a8cacd2f94f35853f84d"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"cookie_store 0.21.1",
|
||||
"cookie_store",
|
||||
"data-url",
|
||||
"http",
|
||||
"regex",
|
||||
@@ -4426,9 +4532,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-updater"
|
||||
version = "2.10.0"
|
||||
version = "2.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3fe8e9bebd88fc222938ffdfbdcfa0307081423bd01e3252fc337d8bde81fc61"
|
||||
checksum = "806d9dac662c2e4594ff03c647a552f2c9bd544e7d0f683ec58f872f952ce4af"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"dirs 6.0.0",
|
||||
@@ -4689,9 +4795,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
|
||||
|
||||
[[package]]
|
||||
name = "tokio"
|
||||
version = "1.50.0"
|
||||
version = "1.51.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d"
|
||||
checksum = "2bd1c4c0fc4a7ab90fc15ef6daaa3ec3b893f004f915f2392557ed23237820cd"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"libc",
|
||||
@@ -4704,15 +4810,25 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tokio-macros"
|
||||
version = "2.6.1"
|
||||
version = "2.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c"
|
||||
checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"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]]
|
||||
name = "tokio-rustls"
|
||||
version = "0.26.4"
|
||||
@@ -5023,6 +5139,12 @@ dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "urlencoding"
|
||||
version = "2.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
|
||||
|
||||
[[package]]
|
||||
name = "urlpattern"
|
||||
version = "0.3.0"
|
||||
@@ -5068,6 +5190,12 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vcpkg"
|
||||
version = "0.2.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
|
||||
|
||||
[[package]]
|
||||
name = "version-compare"
|
||||
version = "0.2.1"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "moku"
|
||||
version = "0.7.0"
|
||||
version = "0.7.1"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
@@ -20,13 +20,16 @@ tauri-plugin-shell = "2"
|
||||
tauri-plugin-updater = "2"
|
||||
tauri-plugin-process = "2"
|
||||
tauri-plugin-http = "2"
|
||||
tauri-plugin-os = "2.3.2"
|
||||
tauri-plugin-discord-rpc = { git = "https://github.com/Youwes09/tauri-plugin-discord-rpc" }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
walkdir = "2"
|
||||
sysinfo = "0.32"
|
||||
dirs = "5"
|
||||
tauri-plugin-os = "2.3.2"
|
||||
tauri-plugin-drpc = "0.1.6"
|
||||
urlencoding = "2"
|
||||
tokio = { version = "1", features = ["rt-multi-thread"] }
|
||||
reqwest = { version = "0.12", features = ["blocking"] }
|
||||
|
||||
[profile.release]
|
||||
codegen-units = 1
|
||||
|
||||
@@ -33,11 +33,11 @@
|
||||
"process:allow-restart",
|
||||
"http:default",
|
||||
"http:allow-fetch",
|
||||
"drpc:default",
|
||||
"drpc:allow-is-running",
|
||||
"drpc:allow-spawn-thread",
|
||||
"drpc:allow-destroy-thread",
|
||||
"drpc:allow-set-activity",
|
||||
"drpc:allow-clear-activity"
|
||||
"discord-rpc:default",
|
||||
"discord-rpc:allow-connect",
|
||||
"discord-rpc:allow-disconnect",
|
||||
"discord-rpc:allow-set-activity",
|
||||
"discord-rpc: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://*/*" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
+36
-185
@@ -26,9 +26,6 @@ pub enum SpawnError {
|
||||
SpawnFailed(String),
|
||||
}
|
||||
|
||||
// ── Update types ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// A single GitHub release returned to the frontend.
|
||||
#[derive(Serialize, Clone)]
|
||||
pub struct ReleaseInfo {
|
||||
pub tag_name: String,
|
||||
@@ -38,7 +35,6 @@ pub struct ReleaseInfo {
|
||||
pub html_url: String,
|
||||
}
|
||||
|
||||
/// Progress event emitted during download — matches what the frontend listens for.
|
||||
#[derive(Clone, serde::Serialize)]
|
||||
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||||
struct UpdateProgress {
|
||||
@@ -46,8 +42,6 @@ struct UpdateProgress {
|
||||
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 {
|
||||
let s = path.to_string_lossy();
|
||||
if let Some(stripped) = s.strip_prefix(r"\\?\") {
|
||||
@@ -61,10 +55,6 @@ fn resolve_downloads_path(downloads_path: &str) -> PathBuf {
|
||||
if !downloads_path.trim().is_empty() {
|
||||
return PathBuf::from(downloads_path);
|
||||
}
|
||||
// Mirror Suwayomi-Server's own default: <data_dir>/Tachidesk/downloads
|
||||
// Windows: %LOCALAPPDATA%\Tachidesk\downloads
|
||||
// macOS: ~/Library/Application Support/Tachidesk/downloads
|
||||
// Linux: $XDG_DATA_HOME/Tachidesk/downloads (~/.local/share/Tachidesk/downloads)
|
||||
let base = std::env::var("XDG_DATA_HOME")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|_| dirs::data_dir().unwrap_or_else(|| PathBuf::from("/")));
|
||||
@@ -108,34 +98,23 @@ fn get_storage_info(downloads_path: String) -> Result<StorageInfo, String> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns the resolved default downloads path for the current platform.
|
||||
/// This mirrors resolve_downloads_path("") so the frontend can display it.
|
||||
#[tauri::command]
|
||||
fn get_default_downloads_path() -> String {
|
||||
resolve_downloads_path("").to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
/// Returns true if the given path exists and is a directory.
|
||||
#[tauri::command]
|
||||
fn check_path_exists(path: String) -> bool {
|
||||
std::path::Path::new(path.trim()).is_dir()
|
||||
}
|
||||
|
||||
/// Creates a directory and all missing parent directories.
|
||||
#[tauri::command]
|
||||
fn create_directory(path: String) -> Result<(), String> {
|
||||
std::fs::create_dir_all(path.trim()).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Moves all content from `src` into `dst`, then removes `src`.
|
||||
/// Emits `migrate_progress` events: `{ done, total, current }`.
|
||||
/// Only deletes the source tree after every file is confirmed copied.
|
||||
#[tauri::command]
|
||||
async fn migrate_downloads(
|
||||
app: tauri::AppHandle,
|
||||
src: String,
|
||||
dst: String,
|
||||
) -> Result<(), String> {
|
||||
async fn migrate_downloads(app: tauri::AppHandle, src: String, dst: String) -> Result<(), String> {
|
||||
use tauri::Emitter;
|
||||
use std::fs;
|
||||
|
||||
@@ -143,19 +122,16 @@ async fn migrate_downloads(
|
||||
let dst_path = std::path::PathBuf::from(dst.trim());
|
||||
|
||||
if !src_path.is_dir() {
|
||||
return Ok(()); // nothing to migrate
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Count files first so the frontend can show accurate progress
|
||||
let total: u64 = WalkDir::new(&src_path)
|
||||
.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 _ = app.emit("migrate_progress", serde_json::json!({ "done": 0u64, "total": total, "current": "" }));
|
||||
|
||||
let mut done: u64 = 0;
|
||||
|
||||
@@ -172,23 +148,15 @@ async fn migrate_downloads(
|
||||
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()
|
||||
"done": done, "total": total, "current": rel.to_string_lossy()
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// Only remove source after all files are confirmed copied
|
||||
fs::remove_dir_all(&src_path).map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Returns the OS/monitor DPI scale factor for the window's current monitor.
|
||||
/// This is the real hardware scale — 1.0 on standard displays, 2.0 on HiDPI/4K,
|
||||
/// 1.25–1.5 on Windows displays with OS-level scaling applied.
|
||||
/// The frontend multiplies this by the user's uiZoom preference to get the
|
||||
/// final effective zoom applied to document.documentElement.
|
||||
#[tauri::command]
|
||||
fn get_platform_ui_scale(window: tauri::Window) -> f64 {
|
||||
window.scale_factor().unwrap_or(1.0)
|
||||
@@ -210,8 +178,6 @@ fn kill_tachidesk(app: &tauri::AppHandle) {
|
||||
.creation_flags(CREATE_NO_WINDOW)
|
||||
.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 {
|
||||
let still_running = std::process::Command::new("tasklist")
|
||||
.args(["/FI", "IMAGENAME eq java.exe", "/NH"])
|
||||
@@ -220,20 +186,15 @@ fn kill_tachidesk(app: &tauri::AppHandle) {
|
||||
.map(|o| String::from_utf8_lossy(&o.stdout).contains("java.exe"))
|
||||
.unwrap_or(false);
|
||||
|
||||
if !still_running {
|
||||
break;
|
||||
}
|
||||
if !still_running { break; }
|
||||
std::thread::sleep(std::time::Duration::from_millis(100));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
let _ = std::process::Command::new("pkill")
|
||||
.args(["-f", "tachidesk"])
|
||||
.status();
|
||||
let _ = std::process::Command::new("pkill").args(["-f", "tachidesk"]).status();
|
||||
}
|
||||
|
||||
|
||||
const DEFAULT_SERVER_CONF: &str = r#"server.ip = "127.0.0.1"
|
||||
server.port = 4567
|
||||
server.webUIEnabled = false
|
||||
@@ -328,23 +289,14 @@ struct ServerInvocation {
|
||||
working_dir: Option<PathBuf>,
|
||||
}
|
||||
|
||||
/// Locate the `java` / `java.exe` binary inside a bundled JRE directory.
|
||||
///
|
||||
/// Expected layout (Windows and Linux):
|
||||
/// <bundle_dir>/jre/bin/java[.exe]
|
||||
///
|
||||
/// On Windows `strip_unc` is applied so Java doesn't choke on `\\?\` prefixes.
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
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] checking path: {:?}", java));
|
||||
do_log(log, &format!("[find_java] exists: {}", java.exists()));
|
||||
|
||||
do_log(log, &format!("[find_java] path: {:?} exists: {}", java, java.exists()));
|
||||
if java.exists() { Some(java) } else { None }
|
||||
}
|
||||
|
||||
@@ -360,12 +312,8 @@ fn resolve_server_binary(
|
||||
app: &tauri::AppHandle,
|
||||
log: &mut Option<std::fs::File>,
|
||||
) -> Result<ServerInvocation, SpawnError> {
|
||||
do_log(log, &format!("[resolve] binary arg = {:?}", binary));
|
||||
do_log(log, &format!("[resolve] binary = {:?}", binary));
|
||||
|
||||
// ── 1. User-specified binary path ─────────────────────────────────────────
|
||||
// Primary: honour the path as-is (doc-2 behaviour — trust the user).
|
||||
// Fallback: if the path doesn't exist after stripping UNC, log a warning
|
||||
// and continue so the bundled detection still has a chance.
|
||||
if !binary.trim().is_empty() {
|
||||
let path = strip_unc(PathBuf::from(binary.trim()));
|
||||
do_log(log, &format!("[resolve] user path: {:?} exists={}", path, path.exists()));
|
||||
@@ -376,62 +324,44 @@ fn resolve_server_binary(
|
||||
working_dir: path.parent().map(|p| p.to_path_buf()),
|
||||
});
|
||||
}
|
||||
// Fallback: path was set but file is missing — warn and keep trying.
|
||||
do_log(log, "[resolve] WARNING: user-supplied path not found, falling through to bundled detection");
|
||||
do_log(log, "[resolve] user path not found, falling through");
|
||||
}
|
||||
|
||||
// Resolve and UNC-strip resource_dir once; used by all non-macOS branches.
|
||||
#[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));
|
||||
do_log(log, &format!("[resolve] resource_dir = {:?}", stripped));
|
||||
stripped
|
||||
};
|
||||
|
||||
// ── 2. Bundled JRE + JAR (Windows / Linux — specific layout) ─────────────
|
||||
// Primary path from doc-2: binaries/suwayomi-bundle/bin/Suwayomi-Server.jar
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
{
|
||||
let bundle_dir = resource_dir.join("binaries").join("suwayomi-bundle");
|
||||
let jar = bundle_dir.join("bin").join("Suwayomi-Server.jar");
|
||||
|
||||
do_log(log, &format!("[resolve] bundle_dir = {:?}", bundle_dir));
|
||||
do_log(log, &format!("[resolve] bundle_dir exists: {}", bundle_dir.exists()));
|
||||
do_log(log, &format!("[resolve] jar = {:?}", jar));
|
||||
do_log(log, &format!("[resolve] jar exists: {}", jar.exists()));
|
||||
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) => {
|
||||
do_log(log, &format!("[resolve] java found: {:?}", java));
|
||||
if jar.exists() {
|
||||
do_log(log, "[resolve] both java and jar found — using bundled JRE");
|
||||
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] java found but jar MISSING — falling through");
|
||||
}
|
||||
None => {
|
||||
do_log(log, "[resolve] java NOT found in bundle — falling through");
|
||||
}
|
||||
_ => do_log(log, "[resolve] bundled JRE/jar not found, falling through"),
|
||||
}
|
||||
}
|
||||
|
||||
// ── 2b. Bundled launcher scripts / native sidecars (Windows / Linux) ──────
|
||||
// Fallback for older bundle layouts that ship a wrapper script instead of a
|
||||
// bare JRE + JAR. Also scans for any *.jar in resource_dir as a last resort.
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
{
|
||||
// Named launcher scripts.
|
||||
let script_candidates = ["suwayomi-launcher", "suwayomi-launcher.sh", "tachidesk-server"];
|
||||
for name in &script_candidates {
|
||||
for name in &["suwayomi-launcher", "suwayomi-launcher.sh", "tachidesk-server"] {
|
||||
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() {
|
||||
do_log(log, &format!("[resolve] using sidecar: {:?}", p));
|
||||
return Ok(ServerInvocation {
|
||||
bin: p.to_string_lossy().into_owned(),
|
||||
args: vec![],
|
||||
@@ -440,50 +370,31 @@ fn resolve_server_binary(
|
||||
}
|
||||
}
|
||||
|
||||
// Generic JRE at resource_dir root + any *.jar alongside it.
|
||||
do_log(log, "[resolve] no named sidecar found, trying generic JRE + any jar in resource_dir");
|
||||
if let Some(java) = find_java_in_bundle(&resource_dir, log) {
|
||||
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)
|
||||
})
|
||||
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())
|
||||
});
|
||||
|
||||
do_log(log, &format!("[resolve] generic jar candidate: {:?}", jar));
|
||||
|
||||
if let Some(jar_path) = jar {
|
||||
do_log(log, &format!("[resolve] using generic JRE java={:?} jar={:?}", java, jar_path));
|
||||
do_log(log, &format!("[resolve] generic JRE java={:?} jar={:?}", java, jar_path));
|
||||
return Ok(ServerInvocation {
|
||||
bin: java.to_string_lossy().into_owned(),
|
||||
args: vec!["-jar".to_string(), jar_path.to_string_lossy().into_owned()],
|
||||
working_dir: Some(resource_dir),
|
||||
});
|
||||
}
|
||||
do_log(log, "[resolve] generic JRE found but no .jar — falling through");
|
||||
}
|
||||
}
|
||||
|
||||
// ── 3. macOS app bundle — MacOS/ then Resources/ ──────────────────────────
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let resource_dir = app.path().resource_dir().unwrap_or_default();
|
||||
let macos_dir = resource_dir
|
||||
.parent()
|
||||
.map(|p| p.join("MacOS"))
|
||||
.unwrap_or_default();
|
||||
let macos_dir = resource_dir.parent().map(|p| p.join("MacOS")).unwrap_or_default();
|
||||
|
||||
do_log(log, &format!("[resolve] macOS macos_dir = {:?}", macos_dir));
|
||||
|
||||
// Tauri strips the target triple when installing externalBin sidecars into
|
||||
// Contents/MacOS/, so the binary is "suwayomi-server" at runtime.
|
||||
// Triple-suffixed names are kept as a belt-and-suspenders fallback for
|
||||
// dev / flat layouts.
|
||||
let candidates = [
|
||||
"suwayomi-server",
|
||||
"suwayomi-server-aarch64-apple-darwin",
|
||||
@@ -498,7 +409,6 @@ fn resolve_server_binary(
|
||||
let p = search_dir.join(name);
|
||||
do_log(log, &format!("[resolve] macOS candidate: {:?} exists={}", p, p.exists()));
|
||||
if p.exists() {
|
||||
do_log(log, &format!("[resolve] using macOS sidecar: {:?}", p));
|
||||
return Ok(ServerInvocation {
|
||||
bin: p.to_string_lossy().into_owned(),
|
||||
args: vec![],
|
||||
@@ -509,37 +419,17 @@ fn resolve_server_binary(
|
||||
}
|
||||
}
|
||||
|
||||
// ── 4. PATH fallback ──────────────────────────────────────────────────────
|
||||
// Use `where` on Windows, `which` everywhere else.
|
||||
do_log(log, "[resolve] trying PATH fallback");
|
||||
for name in &["suwayomi-server", "tachidesk-server"] {
|
||||
#[cfg(target_os = "windows")]
|
||||
let found = std::process::Command::new("where")
|
||||
.arg(name)
|
||||
.output()
|
||||
.map(|o| o.status.success())
|
||||
.unwrap_or(false);
|
||||
|
||||
let found = std::process::Command::new("where").arg(name).output().map(|o| o.status.success()).unwrap_or(false);
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
let found = std::process::Command::new("which")
|
||||
.arg(name)
|
||||
.output()
|
||||
.map(|o| o.status.success())
|
||||
.unwrap_or(false);
|
||||
|
||||
do_log(log, &format!("[resolve] PATH check {:?}: found={}", name, found));
|
||||
let found = std::process::Command::new("which").arg(name).output().map(|o| o.status.success()).unwrap_or(false);
|
||||
|
||||
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(
|
||||
"Server binary not found. Install Suwayomi-Server or set the path in Settings.".to_string(),
|
||||
))
|
||||
@@ -555,50 +445,28 @@ fn spawn_server(binary: String, app: tauri::AppHandle) -> Result<(), SpawnError>
|
||||
}
|
||||
|
||||
let data_dir = suwayomi_data_dir();
|
||||
|
||||
let log_path = data_dir.join("moku-spawn.log");
|
||||
let _ = std::fs::create_dir_all(&data_dir);
|
||||
let mut log = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&log_path)
|
||||
.ok();
|
||||
let mut log = std::fs::OpenOptions::new().create(true).append(true).open(&log_path).ok();
|
||||
|
||||
do_log(&mut log, "");
|
||||
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()));
|
||||
do_log(&mut log, &format!("[spawn_server] binary={:?} data_dir={:?}", binary, 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) {
|
||||
Ok(i) => i,
|
||||
Err(e) => {
|
||||
do_log(&mut log, &format!("[spawn_server] resolve FAILED: {:?}", e));
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
let mut invocation = resolve_server_binary(&binary, &app, &mut log).map_err(|e| {
|
||||
do_log(&mut log, &format!("[spawn_server] resolve failed: {:?}", e));
|
||||
e
|
||||
})?;
|
||||
|
||||
let bin_display = invocation.bin.clone();
|
||||
let rootdir_flag = format!(
|
||||
"-Dsuwayomi.tachidesk.config.server.rootDir={}",
|
||||
data_dir.to_string_lossy()
|
||||
);
|
||||
|
||||
invocation.args.insert(0, rootdir_flag);
|
||||
|
||||
let working_dir = invocation.working_dir
|
||||
.unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
|
||||
let working_dir = invocation.working_dir.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] args = {:?}", invocation.args));
|
||||
do_log(&mut log, &format!("[spawn_server] working_dir = {:?}", working_dir));
|
||||
do_log(&mut log, &format!("[spawn_server] bin={:?} args={:?} cwd={:?}", invocation.bin, invocation.args, working_dir));
|
||||
|
||||
let cmd = app.shell()
|
||||
.command(&invocation.bin)
|
||||
@@ -606,17 +474,13 @@ fn spawn_server(binary: String, app: tauri::AppHandle) -> Result<(), SpawnError>
|
||||
.args(&invocation.args)
|
||||
.current_dir(&working_dir);
|
||||
|
||||
do_log(&mut log, "[spawn_server] calling cmd.spawn()...");
|
||||
|
||||
match cmd.spawn() {
|
||||
Ok((_rx, child)) => {
|
||||
do_log(&mut log, &format!("[spawn_server] SUCCESS — spawned: {}", bin_display));
|
||||
*app.state::<ServerState>().0.lock().unwrap() = Some(child);
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => {
|
||||
do_log(&mut log, &format!("[spawn_server] SPAWN FAILED: {}", e));
|
||||
do_log(&mut log, &format!("[spawn_server] error kind: {:?}", e));
|
||||
do_log(&mut log, &format!("[spawn_server] spawn failed: {}", e));
|
||||
Err(SpawnError::SpawnFailed(e.to_string()))
|
||||
}
|
||||
}
|
||||
@@ -628,10 +492,6 @@ fn kill_server(app: tauri::AppHandle) -> Result<(), String> {
|
||||
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]
|
||||
async fn list_releases() -> Result<Vec<ReleaseInfo>, String> {
|
||||
use tauri_plugin_http::reqwest;
|
||||
@@ -663,22 +523,15 @@ async fn list_releases() -> Result<Vec<ReleaseInfo>, 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())?;
|
||||
|
||||
Ok(releases
|
||||
.into_iter()
|
||||
.map(|r| ReleaseInfo {
|
||||
Ok(releases.into_iter().map(|r| ReleaseInfo {
|
||||
tag_name: r.tag_name.clone(),
|
||||
name: r.name.unwrap_or_else(|| r.tag_name.clone()),
|
||||
body: r.body.unwrap_or_default(),
|
||||
published_at: r.published_at.unwrap_or_default(),
|
||||
html_url: r.html_url,
|
||||
})
|
||||
.collect())
|
||||
}).collect())
|
||||
}
|
||||
|
||||
/// Download and install the latest update using tauri-plugin-updater.
|
||||
/// Emits `update-progress` events with `{ downloaded, total }` while downloading.
|
||||
/// On Windows the installer runs in passive (silent) mode; the frontend prompts restart.
|
||||
/// On other platforms this command is a no-op — the frontend opens the GitHub page instead.
|
||||
#[tauri::command]
|
||||
#[allow(unused_variables)]
|
||||
async fn download_and_install_update(app: tauri::AppHandle) -> Result<(), String> {
|
||||
@@ -693,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 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();
|
||||
@@ -711,18 +564,16 @@ async fn download_and_install_update(app: tauri::AppHandle) -> Result<(), String
|
||||
}
|
||||
}
|
||||
|
||||
/// Restart the app after a successful update install.
|
||||
#[tauri::command]
|
||||
fn restart_app(app: tauri::AppHandle) {
|
||||
tauri::process::restart(&app.env());
|
||||
}
|
||||
|
||||
// ── App entry point ───────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_drpc::init())
|
||||
.plugin(tauri_plugin_discord_rpc::init())
|
||||
.plugin(tauri_plugin_os::init())
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.plugin(tauri_plugin_http::init())
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Moku",
|
||||
"version": "0.7.0",
|
||||
"version": "0.7.1",
|
||||
"identifier": "dev.moku.app",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
|
||||
+13
-4
@@ -11,13 +11,13 @@
|
||||
import { store, addToast, setActiveDownloads, setSettingsOpen } from "./store/state.svelte";
|
||||
import { initRpc, setIdle, clearReading, destroyRpc } from "./lib/discord";
|
||||
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 Settings from "./components/settings/Settings.svelte";
|
||||
import ThemeEditor from "./components/settings/ThemeEditor.svelte";
|
||||
import TitleBar from "./components/layout/TitleBar.svelte";
|
||||
import Toaster from "./components/layout/Toaster.svelte";
|
||||
import SplashScreen from "./components/layout/SplashScreen.svelte";
|
||||
import TitleBar from "./components/chrome/TitleBar.svelte";
|
||||
import Toaster from "./components/chrome/Toaster.svelte";
|
||||
import SplashScreen from "./components/chrome/SplashScreen.svelte";
|
||||
import MangaPreview from "./components/shared/MangaPreview.svelte";
|
||||
|
||||
let themeStyleEl: HTMLStyleElement | null = null;
|
||||
@@ -221,6 +221,15 @@
|
||||
|
||||
if (result === "auth_required") {
|
||||
serverProbeOk = true;
|
||||
const savedUser = store.settings.serverAuthUser?.trim() ?? "";
|
||||
const savedPass = store.settings.serverAuthPass?.trim() ?? "";
|
||||
if (savedUser && savedPass) {
|
||||
try {
|
||||
await loginBasic(savedUser, savedPass);
|
||||
loginRequired = false;
|
||||
return;
|
||||
} catch {}
|
||||
}
|
||||
loginRequired = true;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import Sidebar from "./Sidebar.svelte";
|
||||
import Home from "../pages/Home.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 Search from "../pages/Search.svelte";
|
||||
import Discover from "../pages/Discover.svelte";
|
||||
+3
-3
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
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 type { HistoryEntry } from "../../store/state.svelte";
|
||||
|
||||
@@ -192,7 +192,7 @@
|
||||
{#each items as session (session.latestChapterId)}
|
||||
<button class="session-row" onclick={() => resume(session)}>
|
||||
<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}
|
||||
<span class="session-count">{session.chapterCount}</span>
|
||||
{/if}
|
||||
@@ -290,7 +290,7 @@
|
||||
.session-row:hover .play-pill { opacity: 1; transform: translateX(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 {
|
||||
position: absolute; bottom: -4px; right: -6px;
|
||||
background: var(--accent-muted); border: 1px solid var(--accent-dim); color: var(--accent-fg);
|
||||
+86
-75
@@ -17,8 +17,11 @@
|
||||
onDismiss?: () => void;
|
||||
}
|
||||
|
||||
let { mode = "loading", ringFull = false, failed = false, notConfigured = false,
|
||||
showCards = true, showFps = false, onReady, onRetry, onBypass, onDismiss }: Props = $props();
|
||||
let {
|
||||
mode = "loading", ringFull = false, failed = false,
|
||||
notConfigured = false, showCards = true, showFps = false,
|
||||
onReady, onRetry, onBypass, onDismiss,
|
||||
}: Props = $props();
|
||||
|
||||
const lockEnabled = $derived(
|
||||
store.settings.appLockEnabled && (store.settings.appLockPin?.length ?? 0) >= 4
|
||||
@@ -28,6 +31,21 @@
|
||||
let pinShake = $state(false);
|
||||
let pinUnlocked = $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() {
|
||||
if (pinEntry === store.settings.appLockPin) {
|
||||
@@ -37,7 +55,7 @@
|
||||
} else {
|
||||
pinShake = true;
|
||||
pinEntry = "";
|
||||
setTimeout(() => pinShake = false, 500);
|
||||
setTimeout(() => (pinShake = false), 500);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,9 +68,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
function handleRetry() { onRetry?.(); }
|
||||
function handleBypass() { onBypass?.(); }
|
||||
|
||||
const EXIT_MS = 320;
|
||||
const PHASE1_TARGET = 0.85;
|
||||
const PHASE1_MS = 3000;
|
||||
@@ -64,8 +79,6 @@
|
||||
let exiting = $state(false);
|
||||
let exitLock = false;
|
||||
|
||||
let fpsEl = $state<HTMLSpanElement | undefined>(undefined);
|
||||
|
||||
function triggerExit(cb?: () => void) {
|
||||
if (exitLock) return;
|
||||
exitLock = true;
|
||||
@@ -81,18 +94,14 @@
|
||||
if (exitLock) return;
|
||||
if (animStart === null) animStart = ts;
|
||||
const elapsed = ts - animStart;
|
||||
|
||||
if (animPhase === 1) {
|
||||
const t = Math.min(elapsed / PHASE1_MS, 1);
|
||||
const eased = 1 - Math.pow(1 - t, 3);
|
||||
ringProg = 0.025 + eased * (PHASE1_TARGET - 0.025);
|
||||
ringProg = 0.025 + (1 - Math.pow(1 - t, 3)) * (PHASE1_TARGET - 0.025);
|
||||
if (t >= 1) { animPhase = 2; animStart = ts; }
|
||||
} else if (animPhase === 2) {
|
||||
} else {
|
||||
const t = Math.min(elapsed / PHASE2_MS, 1);
|
||||
const eased = 1 - Math.pow(1 - t, 4);
|
||||
ringProg = PHASE1_TARGET + eased * (PHASE2_TARGET - PHASE1_TARGET);
|
||||
ringProg = PHASE1_TARGET + (1 - Math.pow(1 - t, 4)) * (PHASE2_TARGET - PHASE1_TARGET);
|
||||
}
|
||||
|
||||
animFrame = requestAnimationFrame(animateRing);
|
||||
}
|
||||
|
||||
@@ -104,26 +113,39 @@
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (ringFull) {
|
||||
if (!ringFull) return;
|
||||
cancelAnimationFrame(animFrame);
|
||||
ringProg = 1;
|
||||
if (lockEnabled && !pinUnlocked) {
|
||||
setTimeout(() => { pinVisible = true; }, 400);
|
||||
setTimeout(() => (pinVisible = true), 400);
|
||||
} else {
|
||||
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(() => {
|
||||
dots = dots.length >= 3 ? "" : dots + ".";
|
||||
}, 420);
|
||||
|
||||
onMount(() => {
|
||||
onMount(async () => {
|
||||
const win = getCurrentWindow();
|
||||
uiScale = await win.scaleFactor();
|
||||
|
||||
if (mode === "idle" && onDismiss) {
|
||||
if (lockEnabled) {
|
||||
return () => clearInterval(dotsInterval);
|
||||
}
|
||||
if (lockEnabled) return () => clearInterval(dotsInterval);
|
||||
const handler = () => triggerExit(onDismiss);
|
||||
const t = setTimeout(() => {
|
||||
window.addEventListener("keydown", handler, { once: true });
|
||||
@@ -143,6 +165,7 @@
|
||||
|
||||
interface CardDef { cx: number; w: number; h: number; lines: number; alpha: number; speed: number; cycleSec: number; phase: number; travel: number; yStart: number; angleStart: number; tilt: number; }
|
||||
interface 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 = [
|
||||
{ wMin: 26, wMax: 40, speedMin: 30, speedMax: 50, alpha: 0.22 },
|
||||
@@ -159,7 +182,8 @@
|
||||
}
|
||||
|
||||
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++) {
|
||||
const cfg = LAYER_CFG[layer];
|
||||
for (let col = 0; col < COLS; col++) {
|
||||
@@ -170,10 +194,14 @@
|
||||
const travel = vh + h + BUF;
|
||||
cards.push({
|
||||
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,
|
||||
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,
|
||||
tilt: (hash(seed + 4) * 2 - 1) * 18,
|
||||
});
|
||||
@@ -205,11 +233,12 @@
|
||||
const ctx = oc.getContext("2d")!;
|
||||
ctx.scale(dpr, dpr);
|
||||
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;
|
||||
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.strokeStyle = "rgba(255,255,255,0.75)"; ctx.lineWidth = 1.2; rrect(ctx, x0, y0, c.w, c.h, 4); ctx.stroke();
|
||||
ctx.strokeStyle = "rgba(255,255,255,0.75)";
|
||||
ctx.lineWidth = 1.2; rrect(ctx, x0, y0, c.w, c.h, 4); ctx.stroke();
|
||||
ctx.fillStyle = "rgba(255,255,255,0.15)"; rrect(ctx, x0 + 3, y0 + 3, c.w - 6, coverH, 3); ctx.fill();
|
||||
ctx.fillStyle = "rgba(255,255,255,0.08)"; rrect(ctx, x0 + 3, y0 + 3, (c.w - 6) * 0.45, coverH, 3); ctx.fill();
|
||||
for (let li = 0; li < c.lines; li++) {
|
||||
@@ -221,12 +250,17 @@
|
||||
|
||||
function buildVignette(vw: number, vh: number, dpr: number): HTMLCanvasElement {
|
||||
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")!;
|
||||
ctx.scale(dpr, dpr);
|
||||
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)");
|
||||
ctx.fillStyle = g; ctx.fillRect(0, 0, vw, vh);
|
||||
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)");
|
||||
ctx.fillStyle = g;
|
||||
ctx.fillRect(0, 0, vw, vh);
|
||||
return oc;
|
||||
}
|
||||
|
||||
@@ -247,10 +281,11 @@
|
||||
const sin = tg.sinA * Math.cos(delta) + tg.cosA * Math.sin(delta);
|
||||
ctx.globalAlpha = alpha;
|
||||
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.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);
|
||||
}
|
||||
|
||||
@@ -259,7 +294,8 @@
|
||||
fpsFrames++;
|
||||
if (now - fpsLast >= 500) {
|
||||
fps = Math.round(fpsFrames / ((now - fpsLast) / 1000));
|
||||
fpsFrames = 0; fpsLast = now;
|
||||
fpsFrames = 0;
|
||||
fpsLast = now;
|
||||
if (fpsEl) fpsEl.textContent = `${fps} fps`;
|
||||
}
|
||||
}
|
||||
@@ -267,10 +303,6 @@
|
||||
function mountCanvas(el: HTMLCanvasElement) {
|
||||
const win = getCurrentWindow();
|
||||
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 lastLogW = 0, lastLogH = 0, lastScale = 0, buildGen = 0;
|
||||
|
||||
@@ -289,7 +321,8 @@
|
||||
}
|
||||
|
||||
const ro = new ResizeObserver(() => syncSize());
|
||||
ro.observe(el); syncSize();
|
||||
ro.observe(el);
|
||||
syncSize();
|
||||
|
||||
let raf = 0, t0 = -1;
|
||||
function frame(now: number) {
|
||||
@@ -303,30 +336,6 @@
|
||||
raf = requestAnimationFrame(frame);
|
||||
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>
|
||||
|
||||
<div class="splash" class:exiting style="cursor: {mode === 'idle' && !lockEnabled ? 'pointer' : 'default'}">
|
||||
@@ -339,9 +348,9 @@
|
||||
|
||||
{#if mode === "idle" && lockEnabled}
|
||||
<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>
|
||||
<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 class="pin-block">
|
||||
<div class="pin-dots" class:pin-shake={pinShake}>
|
||||
@@ -355,15 +364,15 @@
|
||||
|
||||
{:else if mode === "idle"}
|
||||
<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>
|
||||
<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>
|
||||
<p class="hint">press any key to continue</p>
|
||||
</div>
|
||||
|
||||
{: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}
|
||||
<svg width={ringSize} height={ringSize}
|
||||
class="loading-ring"
|
||||
@@ -377,7 +386,7 @@
|
||||
style="transition: stroke-dasharray 0.4s cubic-bezier(0.4,0,0.2,1)" />
|
||||
</svg>
|
||||
{/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>
|
||||
<p class="title-label">moku</p>
|
||||
|
||||
@@ -385,12 +394,10 @@
|
||||
<div class="status-slot" class:status-slot-hide={lockEnabled && pinVisible}>
|
||||
{#if failed || notConfigured}
|
||||
<div class="error-box">
|
||||
<p class="error-label">
|
||||
{failed ? "Could not reach server" : "Server not configured"}
|
||||
</p>
|
||||
<p class="error-label">{failed ? "Could not reach server" : "Server not configured"}</p>
|
||||
<div class="error-actions">
|
||||
<button class="err-btn" onclick={handleRetry}>Retry</button>
|
||||
<button class="err-btn err-btn--primary" onclick={handleBypass}>Enter app</button>
|
||||
<button class="err-btn" onclick={() => onRetry?.()}>Retry</button>
|
||||
<button class="err-btn err-btn--primary" onclick={() => onBypass?.()}>Enter app</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
@@ -415,16 +422,20 @@
|
||||
<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.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 logoBreathe { 0%,100% { transform:scale(1); filter:drop-shadow(0 0 0px rgba(255,255,255,0)) } 50% { transform:scale(1.04); filter:drop-shadow(0 0 18px rgba(255,255,255,0.12)) } }
|
||||
@keyframes hintFade { 0%,100% { opacity:0.35 } 50% { opacity:0.7 } }
|
||||
@keyframes 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; }
|
||||
.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; }
|
||||
|
||||
.error-box { display: flex; flex-direction: column; align-items: center; gap: 12px; padding: 16px 20px; border-radius: var(--radius-lg); background: var(--bg-surface); border: 1px solid var(--border-base); min-width: 200px; text-align: center; animation: errIn 0.25s cubic-bezier(0,0,0.2,1) both; }
|
||||
@keyframes errIn { from { opacity:0; transform:translateY(4px) } to { opacity:1; transform:translateY(0) } }
|
||||
.error-label { font-family: var(--font-ui); font-size: 11px; font-weight: 500; color: var(--text-muted); letter-spacing: 0.06em; margin: 0; }
|
||||
.error-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; }
|
||||
@@ -438,13 +449,13 @@
|
||||
.status-text { font-family: var(--font-ui); font-size: 10px; color: var(--text-faint); letter-spacing: 0.12em; margin: 0; min-width: 160px; text-align: center; }
|
||||
.loading-ring { transition: opacity 0.5s ease; }
|
||||
.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-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-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-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-submit-btn { opacity: 0; width: 1px; height: 1px; overflow: hidden; padding: 0; border: none; background: none; cursor: pointer; pointer-events: auto; position: absolute; }
|
||||
</style>
|
||||
@@ -2,13 +2,34 @@
|
||||
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(() => dismissToast(t.id), dur));
|
||||
timers.set(t.id, setTimeout(() => dismiss(t.id), dur));
|
||||
}
|
||||
|
||||
function dismiss(id: string) {
|
||||
if (leaving.has(id)) return;
|
||||
leaving.add(id);
|
||||
if (timers.has(id)) { clearTimeout(timers.get(id)!); timers.delete(id); }
|
||||
|
||||
const el = document.querySelector<HTMLElement>(`[data-toast-id="${id}"]`);
|
||||
if (!el) { finalize(id); return; }
|
||||
|
||||
const h = el.offsetHeight;
|
||||
el.style.setProperty("--exit-h", `${h}px`);
|
||||
el.classList.add("leaving");
|
||||
setTimeout(() => finalize(id), EXIT_MS);
|
||||
}
|
||||
|
||||
function finalize(id: string) {
|
||||
leaving.delete(id);
|
||||
dismissToast(id);
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
@@ -27,10 +48,11 @@
|
||||
{#if store.toasts.length}
|
||||
<div class="toaster" aria-live="polite">
|
||||
{#each store.toasts as t (t.id)}
|
||||
<button
|
||||
class="toast toast-{t.kind}"
|
||||
<div
|
||||
role="alert"
|
||||
onclick={() => dismissToast(t.id)}
|
||||
class="toast toast-{t.kind}"
|
||||
data-toast-id={t.id}
|
||||
onclick={() => dismiss(t.id)}
|
||||
>
|
||||
<div class="accent-bar"></div>
|
||||
<span class="icon">
|
||||
@@ -43,7 +65,7 @@
|
||||
<p class="title">{t.title}</p>
|
||||
{#if t.body}<p class="sub">{t.body}</p>{/if}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -74,22 +96,39 @@
|
||||
min-width: 200px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
animation: slideIn 0.2s cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||
transition: opacity 0.15s ease, transform 0.15s ease;
|
||||
font-family: inherit;
|
||||
font-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:hover { opacity: 0.85; transform: translateX(-2px); }
|
||||
.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(16px) scale(0.98); }
|
||||
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;
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy } from "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 { cache, CACHE_KEYS } from "../../lib/cache";
|
||||
import { dedupeSources, dedupeMangaByTitle, dedupeMangaById, shouldHideNsfw, shouldHideSource } from "../../lib/util";
|
||||
@@ -10,13 +10,13 @@
|
||||
import ContextMenu from "../shared/ContextMenu.svelte";
|
||||
import type { MenuEntry } from "../shared/ContextMenu.svelte";
|
||||
import SourceBrowse from "../shared/SourceBrowse.svelte";
|
||||
import Thumbnail from "../shared/Thumbnail.svelte";
|
||||
|
||||
// ── Constants ─────────────────────────────────────────────────────────────
|
||||
const GENRE_TABS = ["All", "Action", "Romance", "Fantasy", "Comedy", "Drama", "Horror", "Sci-Fi", "Adventure", "Thriller"];
|
||||
const GRID_LIMIT = 200;
|
||||
const CONCURRENCY = 6;
|
||||
const PAGES_INIT = 3; // pages per source on All tab
|
||||
const PAGES_GENRE = 2; // pages per source on genre tabs
|
||||
const PAGES_INIT = 3;
|
||||
const PAGES_GENRE = 2;
|
||||
|
||||
const EXPLORE_ALL_MANGA = `
|
||||
query ExploreAllManga {
|
||||
@@ -37,7 +37,6 @@
|
||||
return `${srcId}|${type}|${genre}:p${page}`;
|
||||
}
|
||||
|
||||
// ── Local component state ─────────────────────────────────────────────────
|
||||
let allSources: Source[] = $state([]);
|
||||
let loadingLib = $state(true);
|
||||
let loadError = $state(false);
|
||||
@@ -54,7 +53,6 @@
|
||||
const isLoading = $derived(genreLoading || refreshing || (currentGenre === "All" && loadingLib));
|
||||
const visibleGrid = $derived(genreResults.get(currentGenre) ?? []);
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
function dedup(items: Manga[]): Manga[] {
|
||||
return dedupeMangaByTitle(dedupeMangaById(items), store.settings.mangaLinks);
|
||||
}
|
||||
@@ -87,7 +85,6 @@
|
||||
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, items.length) }, worker));
|
||||
}
|
||||
|
||||
// Push results into the reactive grid immediately — no batch delay.
|
||||
function pushToGrid(genre: string, incoming: Manga[]) {
|
||||
const filtered = filterOut(incoming);
|
||||
if (!filtered.length) return;
|
||||
@@ -96,7 +93,6 @@
|
||||
genreResults = new Map(genreResults);
|
||||
}
|
||||
|
||||
// ── Source fan-out ────────────────────────────────────────────────────────
|
||||
async function fanOut(genre: string, ctrl: AbortController) {
|
||||
const srcs = rotatedSources();
|
||||
if (!srcs.length) return;
|
||||
@@ -115,7 +111,6 @@
|
||||
let hasNextPage = false;
|
||||
|
||||
if (store.discoverCache.has(key)) {
|
||||
// Cache hit — no network call needed
|
||||
mangas = store.discoverCache.get(key)!;
|
||||
} else {
|
||||
const result = await gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>(
|
||||
@@ -141,13 +136,11 @@
|
||||
pushToGrid(genre, matching.length ? matching : mangas);
|
||||
}
|
||||
|
||||
// Stop paging early if source is exhausted
|
||||
if (!hasNextPage) return;
|
||||
}
|
||||
}, ctrl.signal);
|
||||
}
|
||||
|
||||
// ── Tab switch ────────────────────────────────────────────────────────────
|
||||
async function switchGenre(genre: string) {
|
||||
if (currentGenre === genre) return;
|
||||
|
||||
@@ -158,7 +151,6 @@
|
||||
activeCtrl = ctrl;
|
||||
|
||||
if (genre === "All") {
|
||||
// Already have results from this session — show instantly, re-fan in background
|
||||
if ((genreResults.get("All") ?? []).length > 0) {
|
||||
genreLoading = false;
|
||||
fanOut("All", ctrl).catch(() => {});
|
||||
@@ -172,7 +164,6 @@
|
||||
return;
|
||||
}
|
||||
|
||||
// Genre tab: serve cached local results instantly, always fan out too
|
||||
const localKey = `local|${genre}`;
|
||||
if (store.discoverCache.has(localKey)) {
|
||||
genreResults.set(genre, store.discoverCache.get(localKey)!);
|
||||
@@ -188,9 +179,7 @@
|
||||
);
|
||||
if (ctrl.signal.aborted) return;
|
||||
|
||||
const local = dedup(
|
||||
d.mangas.nodes.filter(m => !shouldHideNsfw(m, store.settings))
|
||||
);
|
||||
const local = dedup(d.mangas.nodes.filter(m => !shouldHideNsfw(m, store.settings)));
|
||||
store.discoverCache.set(localKey, local);
|
||||
genreResults.set(genre, local.slice(0, GRID_LIMIT));
|
||||
genreResults = new Map(genreResults);
|
||||
@@ -203,10 +192,9 @@
|
||||
}
|
||||
}
|
||||
|
||||
// ── Refresh ───────────────────────────────────────────────────────────────
|
||||
async function refresh() {
|
||||
activeCtrl?.abort();
|
||||
clearDiscoverCache(); // wipes store.discoverCache + bumps discoverSrcOffset
|
||||
clearDiscoverCache();
|
||||
genreResults = new Map();
|
||||
refreshing = true;
|
||||
genreLoading = true;
|
||||
@@ -217,17 +205,14 @@
|
||||
refreshing = false;
|
||||
}
|
||||
|
||||
// ── Initial load ──────────────────────────────────────────────────────────
|
||||
function loadAll() {
|
||||
loadingLib = true;
|
||||
loadError = false;
|
||||
|
||||
// Already have a session grid — show it immediately
|
||||
if ((genreResults.get("All") ?? []).length > 0) {
|
||||
loadingLib = false;
|
||||
}
|
||||
|
||||
// Refresh library ID set so newly-added manga get filtered out
|
||||
cache.get(CACHE_KEYS.DISCOVER, () =>
|
||||
gql<{ mangas: { nodes: Manga[] } }>(EXPLORE_ALL_MANGA).then(d => d.mangas.nodes)
|
||||
).then(m => {
|
||||
@@ -237,7 +222,6 @@
|
||||
}).catch(e => { console.error(e); loadError = true; })
|
||||
.finally(() => { loadingLib = false; });
|
||||
|
||||
// Load sources then kick off All tab fan-out (only if grid is empty)
|
||||
gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
|
||||
.then(d => {
|
||||
allSources = d.sources.nodes;
|
||||
@@ -258,7 +242,6 @@
|
||||
|
||||
loadAll();
|
||||
|
||||
// ── Context menu ──────────────────────────────────────────────────────────
|
||||
function openCtx(e: MouseEvent, m: Manga) {
|
||||
e.preventDefault(); e.stopPropagation();
|
||||
ctx = { x: e.clientX, y: e.clientY, manga: m };
|
||||
@@ -316,11 +299,7 @@
|
||||
<span class="heading">Discover</span>
|
||||
<div class="tab-strip">
|
||||
{#each GENRE_TABS as tab (tab)}
|
||||
<button
|
||||
class="genre-tab"
|
||||
class:active={currentGenre === tab}
|
||||
onclick={() => switchGenre(tab)}
|
||||
>
|
||||
<button class="genre-tab" class:active={currentGenre === tab} onclick={() => switchGenre(tab)}>
|
||||
{#if tab === "All"}<Sparkle size={10} weight="fill" />{/if}
|
||||
{tab}
|
||||
</button>
|
||||
@@ -351,13 +330,9 @@
|
||||
{:else}
|
||||
<div class="manga-grid">
|
||||
{#each visibleGrid as m (m.id)}
|
||||
<button
|
||||
class="manga-card"
|
||||
onclick={() => setPreviewManga(m)}
|
||||
oncontextmenu={(e) => openCtx(e, m)}
|
||||
>
|
||||
<button class="manga-card" onclick={() => setPreviewManga(m)} oncontextmenu={(e) => openCtx(e, m)}>
|
||||
<div class="cover-wrap">
|
||||
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} class="cover" loading="lazy" decoding="async" />
|
||||
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="cover" />
|
||||
<div class="cover-gradient"></div>
|
||||
{#if m.inLibrary}<span class="lib-badge">Saved</span>{/if}
|
||||
<div class="card-footer">
|
||||
@@ -392,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; }
|
||||
.manga-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(clamp(90px, 11vw, 130px), 1fr)); gap: var(--sp-2); align-content: start; contain: layout style; }
|
||||
.manga-card { background: none; border: none; padding: 0; cursor: pointer; text-align: left; }
|
||||
.manga-card:hover .cover { filter: brightness(1.08) saturate(1.05); transform: scale(1.02); }
|
||||
.manga-card:hover :global(.cover) { filter: brightness(1.08) saturate(1.05); transform: scale(1.02); }
|
||||
.manga-card:hover .card-title { color: #fff; }
|
||||
.manga-card:hover { will-change: transform; }
|
||||
.cover-wrap { position: relative; aspect-ratio: 2/3; overflow: hidden; border-radius: var(--radius-md); background: var(--bg-raised); border: 1px solid var(--border-dim); box-shadow: 0 2px 8px rgba(0,0,0,0.25); }
|
||||
.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; }
|
||||
.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; }
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
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 { store, setActiveDownloads } from "../../store/state.svelte";
|
||||
import type { DownloadStatus } from "../../lib/types";
|
||||
@@ -114,7 +115,7 @@
|
||||
<div class="row" class:row-active={isActive} class:row-removing={isRemoving}>
|
||||
{#if manga?.thumbnailUrl}
|
||||
<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>
|
||||
{/if}
|
||||
<div class="info">
|
||||
@@ -165,7 +166,7 @@
|
||||
.row.row-active { border-color: var(--accent-dim); }
|
||||
.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-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; }
|
||||
.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; }
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from "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 { store } from "../../store/state.svelte";
|
||||
import type { Extension } from "../../lib/types";
|
||||
@@ -225,7 +226,7 @@
|
||||
{@const hasVariants = variants.length > 0}
|
||||
<div class="group">
|
||||
<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">
|
||||
<span class="name">{base}</span>
|
||||
<span class="meta"><span class="lang-tag">{primary.lang.toUpperCase()}</span> v{primary.versionName}</span>
|
||||
@@ -323,7 +324,7 @@
|
||||
.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: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; }
|
||||
.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); }
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { ArrowLeft, BookmarkSimple, FolderSimplePlus, Folder, CircleNotch } from "phosphor-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 { cache, CACHE_KEYS, getPageSet } from "../../lib/cache";
|
||||
import { dedupeSources, dedupeMangaById, shouldHideNsfw } from "../../lib/util";
|
||||
@@ -217,7 +218,7 @@
|
||||
{#each visibleItems as m (m.id)}
|
||||
<button class="card" onclick={() => setPreviewManga(m)} oncontextmenu={(e) => { e.stopPropagation(); openCtx(e, m); }}>
|
||||
<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}
|
||||
</div>
|
||||
<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); }
|
||||
.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:hover .cover { filter: brightness(1.06); }
|
||||
.card:hover :global(.cover) { filter: brightness(1.06); }
|
||||
.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 { 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); }
|
||||
.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; }
|
||||
|
||||
@@ -2,11 +2,14 @@
|
||||
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 { gql, thumbUrl } from "../../lib/client";
|
||||
import { getBlobUrl } from "../../lib/imageCache";
|
||||
import Thumbnail from "../shared/Thumbnail.svelte";
|
||||
import { GET_LIBRARY, GET_CHAPTERS, GET_MANGA, GET_CATEGORIES } from "../../lib/queries";
|
||||
import { cache, CACHE_KEYS } from "../../lib/cache";
|
||||
import { store, openReader, setHeroSlot, setActiveManga, setPreviewManga, setNavPage, setGenreFilter, setLibraryFilter } from "../../store/state.svelte";
|
||||
import type { HistoryEntry } from "../../store/state.svelte";
|
||||
import type { Manga, Chapter, Category } from "../../lib/types";
|
||||
import { buildReaderChapterList } from "../../lib/chapterList";
|
||||
|
||||
function timeAgo(ts: number): string {
|
||||
const diff = Date.now() - ts, m = Math.floor(diff / 60000);
|
||||
@@ -57,17 +60,23 @@
|
||||
.finally(() => loadingLibrary = false);
|
||||
}
|
||||
|
||||
// Re-fetch library and reset hero chapters whenever the reader closes,
|
||||
// so the hero reflects the latest-read chapter immediately.
|
||||
$effect(() => {
|
||||
const sessionId = store.readerSessionId;
|
||||
if (sessionId === 0) return; // skip initial mount — onMount handles that
|
||||
function resetAndReload() {
|
||||
cache.clear(CACHE_KEYS.LIBRARY);
|
||||
loadingLibrary = true;
|
||||
heroChapters = [];
|
||||
heroAllChapters = [];
|
||||
heroChaptersFor = null;
|
||||
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) {
|
||||
@@ -114,7 +123,21 @@
|
||||
|
||||
let activeIdx = $state(0);
|
||||
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 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);
|
||||
@@ -142,7 +165,8 @@
|
||||
|
||||
$effect(() => {
|
||||
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) {
|
||||
@@ -155,9 +179,10 @@
|
||||
if (heroChaptersFor !== mangaId) return;
|
||||
const all = [...d.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder);
|
||||
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);
|
||||
heroChapters = all.slice(startIdx, startIdx + 5);
|
||||
heroChapters = filtered.slice(startIdx, startIdx + 5);
|
||||
} catch { heroChapters = []; heroAllChapters = []; }
|
||||
finally { loadingHeroChapters = false; }
|
||||
}
|
||||
@@ -176,7 +201,9 @@
|
||||
if (all.length) {
|
||||
const manga = heroManga ?? { id: heroMangaId, title: heroTitle, thumbnailUrl: heroManga?.thumbnailUrl ?? "" } as any;
|
||||
store.activeManga = manga;
|
||||
openReader(chapter, all);
|
||||
const list = buildReaderChapterList(all, store.settings.mangaPrefs?.[heroMangaId]);
|
||||
const target = list.find(c => c.id === chapter.id) ?? list[0];
|
||||
if (target) openReader(target, list);
|
||||
}
|
||||
} catch { store.activeManga = { id: heroMangaId, title: heroTitle, thumbnailUrl: heroManga?.thumbnailUrl ?? "" } as any; }
|
||||
finally { resuming = false; }
|
||||
@@ -190,11 +217,12 @@
|
||||
resuming = true;
|
||||
try {
|
||||
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 ch = chapters.find(c => c.id === heroEntry!.chapterId) ?? chapters[0];
|
||||
const raw = [...d.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder);
|
||||
const list = buildReaderChapterList(raw, store.settings.mangaPrefs?.[heroEntry.mangaId]);
|
||||
const ch = list.find(c => c.id === heroEntry!.chapterId) ?? list[0];
|
||||
if (ch) {
|
||||
store.activeManga = heroManga ?? { id: heroEntry.mangaId, title: heroEntry.mangaTitle, thumbnailUrl: heroEntry.thumbnailUrl } as any;
|
||||
openReader(ch, chapters);
|
||||
openReader(ch, list);
|
||||
}
|
||||
} catch { store.activeManga = { id: heroEntry.mangaId, title: heroEntry.mangaTitle, thumbnailUrl: heroEntry.thumbnailUrl } as any; }
|
||||
finally { resuming = false; }
|
||||
@@ -203,11 +231,12 @@
|
||||
async function resumeEntry(entry: HistoryEntry) {
|
||||
try {
|
||||
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 ch = chapters.find(c => c.id === entry.chapterId) ?? chapters[0];
|
||||
const raw = [...d.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder);
|
||||
const list = buildReaderChapterList(raw, store.settings.mangaPrefs?.[entry.mangaId]);
|
||||
const ch = list.find(c => c.id === entry.chapterId) ?? list[0];
|
||||
if (ch) {
|
||||
store.activeManga = { id: entry.mangaId, title: entry.mangaTitle, thumbnailUrl: entry.thumbnailUrl } as any;
|
||||
openReader(ch, chapters);
|
||||
openReader(ch, list);
|
||||
} else store.activeManga = { id: entry.mangaId, title: entry.mangaTitle, thumbnailUrl: entry.thumbnailUrl } as any;
|
||||
} catch { store.activeManga = { id: entry.mangaId, title: entry.mangaTitle, thumbnailUrl: entry.thumbnailUrl } as any; }
|
||||
}
|
||||
@@ -387,7 +416,7 @@
|
||||
{#if recentHistory.length > 0}
|
||||
{#each recentHistory as entry (entry.chapterId)}
|
||||
<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">
|
||||
<span class="activity-title">{entry.mangaTitle}</span>
|
||||
<span class="activity-sub">{entry.chapterName}{entry.pageNumber > 1 ? ` · p.${entry.pageNumber}` : ""}</span>
|
||||
@@ -431,7 +460,7 @@
|
||||
{#each completedManga as m (m.id)}
|
||||
<button class="mini-card" onclick={() => store.previewManga = m}>
|
||||
<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-footer">
|
||||
<p class="mini-card-title">{m.title}</p>
|
||||
@@ -487,7 +516,7 @@
|
||||
{:else}
|
||||
{#each pickerResults as m (m.id)}
|
||||
<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">
|
||||
<span class="picker-manga-title">{m.title}</span>
|
||||
{#if m.source?.displayName}<span class="picker-source">{m.source.displayName}</span>{/if}
|
||||
@@ -577,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:hover { background: var(--bg-raised); border-color: var(--border-dim); }
|
||||
.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-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; }
|
||||
@@ -594,10 +623,10 @@
|
||||
.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: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-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-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); }
|
||||
@@ -636,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-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-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-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); }
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
import type { LibrarySortMode, LibrarySortDir, LibraryStatusFilter, LibraryContentFilter } from "../../store/state.svelte";
|
||||
import type { Manga, Category, Chapter } from "../../lib/types";
|
||||
import ContextMenu, { type MenuEntry } from "../shared/ContextMenu.svelte";
|
||||
import Thumbnail from "../shared/Thumbnail.svelte";
|
||||
|
||||
const CARD_MIN_W = 130;
|
||||
const CARD_GAP = 16;
|
||||
@@ -920,7 +921,7 @@
|
||||
onpointerleave={onCardPointerLeave}
|
||||
>
|
||||
<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.unreadCount}<span class="badge-unread">{m.unreadCount}</span>{/if}
|
||||
{#if selectMode}
|
||||
@@ -994,10 +995,10 @@
|
||||
/* ── Dropdown panels (shared) ───────────────────────────────────────────── */
|
||||
.sort-panel-wrap,
|
||||
.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-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:hover { background: var(--accent-dim); }
|
||||
.panel-divider { height: 1px; background: var(--border-dim); margin: 4px 2px; }
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy, untrack } from "svelte";
|
||||
import { gql, thumbUrl } from "../../lib/client";
|
||||
import { gql } from "../../lib/client";
|
||||
import Thumbnail from "../shared/Thumbnail.svelte";
|
||||
import { GET_SOURCES, FETCH_SOURCE_MANGA, FETCH_MANGA } from "../../lib/queries";
|
||||
import { cache, CACHE_KEYS, getPageSet } from "../../lib/cache";
|
||||
import { dedupeMangaById, dedupeMangaByTitle, shouldHideNsfw, shouldHideSource, normalizeTitle } from "../../lib/util";
|
||||
@@ -732,8 +733,7 @@
|
||||
{#each kw_results.filter((r) => r.mangas.length > 0 || r.loading || r.error) as { source, mangas, loading, error } (source.id)}
|
||||
<div class="sourceSection">
|
||||
<div class="sourceHeader">
|
||||
<img src={thumbUrl(source.iconUrl)} alt={source.displayName} class="sourceIcon"
|
||||
onerror={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
|
||||
<Thumbnail src={source.iconUrl} alt={source.displayName} class="sourceIcon" onerror={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
|
||||
<span class="sourceName">{source.displayName}</span>
|
||||
{#if hasMultipleLangs}<span class="sourceLang">{source.lang.toUpperCase()}</span>{/if}
|
||||
{#if loading}
|
||||
@@ -757,7 +757,7 @@
|
||||
{#each mangas.slice(0, (store.settings.renderLimit ?? 48)) as m (m.id)}
|
||||
<button class="card" onclick={() => setPreviewManga(m)}>
|
||||
<div class="coverWrap">
|
||||
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} class="cover" loading="lazy" decoding="async" />
|
||||
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="cover" />
|
||||
{#if m.inLibrary}<span class="inLibBadge">Saved</span>{/if}
|
||||
</div>
|
||||
<p class="cardTitle">{m.title}</p>
|
||||
@@ -902,7 +902,7 @@
|
||||
{#each tag_mergedResults as m (m.id)}
|
||||
<button class="card" onclick={() => setPreviewManga(m)}>
|
||||
<div class="coverWrap">
|
||||
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} class="cover" loading="lazy" decoding="async" />
|
||||
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="cover" />
|
||||
{#if m.inLibrary}<span class="inLibBadge">Saved</span>{/if}
|
||||
</div>
|
||||
<p class="cardTitle">{m.title}</p>
|
||||
@@ -961,8 +961,7 @@
|
||||
<div class="splitList">
|
||||
{#each src_visibleSources as src (src.id)}
|
||||
<button class="splitItem splitItemSource" class:splitItemActive={src_activeSource?.id === src.id} onclick={() => srcSelectSource(src)}>
|
||||
<img src={thumbUrl(src.iconUrl)} alt="" class="splitSourceIcon"
|
||||
onerror={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
|
||||
<Thumbnail src={src.iconUrl} alt="" class="splitSourceIcon" onerror={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
|
||||
<span class="splitItemLabel">{src.name}</span>
|
||||
{#if src_selectedLang === "all"}
|
||||
<span class="sourceLang" style="margin-left:auto;margin-right:4px">{src.lang.toUpperCase()}</span>
|
||||
@@ -989,8 +988,7 @@
|
||||
{:else}
|
||||
<div class="splitContentHeader">
|
||||
<div class="splitSourceTitle">
|
||||
<img src={thumbUrl(src_activeSource.iconUrl)} alt="" class="splitSourceIcon"
|
||||
onerror={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
|
||||
<Thumbnail src={src_activeSource.iconUrl} alt="" class="splitSourceIcon" onerror={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
|
||||
<span class="splitContentTitle">{src_activeSource.displayName}</span>
|
||||
{#if src_loadingBrowse}
|
||||
<svg width="13" height="13" viewBox="0 0 256 256" fill="currentColor" class="anim-spin" style="color:var(--text-faint)" aria-hidden="true">
|
||||
@@ -1030,7 +1028,7 @@
|
||||
{#each src_browseResults as m (m.id)}
|
||||
<button class="card" onclick={() => setPreviewManga(m)}>
|
||||
<div class="coverWrap">
|
||||
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} class="cover" loading="lazy" decoding="async" />
|
||||
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="cover" />
|
||||
{#if m.inLibrary}<span class="inLibBadge">Saved</span>{/if}
|
||||
</div>
|
||||
<p class="cardTitle">{m.title}</p>
|
||||
@@ -1117,7 +1115,7 @@
|
||||
.sourceSection { padding: var(--sp-1) var(--sp-4) var(--sp-3); border-bottom: 1px solid var(--border-dim); }
|
||||
.sourceSection:last-child { border-bottom: none; }
|
||||
.sourceHeader { display: flex; align-items: center; gap: var(--sp-2); padding: var(--sp-2) 0; }
|
||||
.sourceIcon { width: 18px; height: 18px; border-radius: var(--radius-sm); object-fit: cover; flex-shrink: 0; background: var(--bg-raised); }
|
||||
:global(.sourceIcon) { width: 20px; height: 20px; border-radius: var(--radius-sm); object-fit: cover; flex-shrink: 0; background: var(--bg-raised); }
|
||||
.sourceName { font-size: var(--text-base); font-weight: var(--weight-medium); color: var(--text-secondary); }
|
||||
.sourceLang { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-sm); padding: 1px 5px; }
|
||||
.resultCount { margin-left: auto; font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||
@@ -1125,10 +1123,10 @@
|
||||
.sourceRow { display: flex; gap: var(--sp-3); overflow-x: auto; padding-bottom: var(--sp-1); scrollbar-width: none; }
|
||||
.sourceRow::-webkit-scrollbar { display: none; }
|
||||
.card { display: flex; flex-direction: column; gap: var(--sp-2); cursor: pointer; flex-shrink: 0; width: 110px; text-align: left; background: none; border: none; padding: 0; }
|
||||
.card:hover .cover { filter: brightness(1.06); }
|
||||
.card:hover :global(.cover) { filter: brightness(1.06); }
|
||||
.card:hover .cardTitle { color: var(--text-primary); }
|
||||
.coverWrap { position: relative; width: 100%; aspect-ratio: 2 / 3; border-radius: var(--radius-md); overflow: hidden; background: var(--bg-raised); border: 1px solid var(--border-dim); transform: translateZ(0); }
|
||||
.cover { width: 100%; height: 100%; object-fit: cover; transition: filter var(--t-base); }
|
||||
:global(.cover) { width: 100%; height: 100%; object-fit: cover; transition: filter var(--t-base); }
|
||||
.inLibBadge { position: absolute; bottom: var(--sp-1); right: var(--sp-1); background: var(--accent-dim); color: var(--accent-fg); font-family: var(--font-ui); font-size: 9px; font-weight: var(--weight-medium); letter-spacing: var(--tracking-wide); padding: 1px 5px; border-radius: var(--radius-sm); border: 1px solid var(--accent-muted); }
|
||||
.cardTitle { font-size: var(--text-sm); color: var(--text-secondary); line-height: var(--leading-snug); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; transition: color var(--t-base); }
|
||||
.skCard { display: flex; flex-direction: column; gap: var(--sp-2); flex-shrink: 0; width: 110px; }
|
||||
@@ -1162,7 +1160,7 @@
|
||||
.splitSourceTitle { display: flex; align-items: center; gap: var(--sp-2); flex: 1; min-width: 0; }
|
||||
.splitContentTitle { font-size: var(--text-base); font-weight: var(--weight-medium); color: var(--text-secondary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; letter-spacing: var(--tracking-tight); }
|
||||
.splitResultCount { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); flex-shrink: 0; }
|
||||
.splitSourceIcon { width: 18px; height: 18px; border-radius: var(--radius-sm); object-fit: cover; flex-shrink: 0; background: var(--bg-raised); }
|
||||
:global(.splitSourceIcon) { width: 20px; height: 20px; border-radius: var(--radius-sm); object-fit: cover; flex-shrink: 0; background: var(--bg-raised); }
|
||||
.tagActiveBar { display: flex; align-items: flex-start; gap: var(--sp-2); padding: var(--sp-2) var(--sp-4); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; flex-wrap: wrap; }
|
||||
.tagPillRow { display: flex; flex-wrap: wrap; gap: var(--sp-1); flex: 1; min-width: 0; }
|
||||
.tagPill { display: inline-flex; align-items: center; gap: 4px; padding: 2px 7px; background: var(--accent-muted); border: 1px solid var(--accent-dim); border-radius: var(--radius-sm); font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--accent-fg); }
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { CircleNotch, ArrowSquareOut, ArrowsClockwise, X, Lock, MagnifyingGlass, Funnel } from "phosphor-svelte";
|
||||
import { gql, thumbUrl } from "../../lib/client";
|
||||
import { gql } from "../../lib/client";
|
||||
import Thumbnail from "../shared/Thumbnail.svelte";
|
||||
import {
|
||||
GET_ALL_TRACKER_RECORDS,
|
||||
UPDATE_TRACK,
|
||||
@@ -10,8 +11,6 @@
|
||||
import { addToast, setActiveManga, setNavPage } from "../../store/state.svelte";
|
||||
import type { Tracker, TrackRecord } from "../../lib/types";
|
||||
|
||||
// ── Types ──────────────────────────────────────────────────────────────────
|
||||
|
||||
interface TrackerWithRecords extends Tracker {
|
||||
trackRecords: { nodes: TrackRecord[] };
|
||||
}
|
||||
@@ -20,26 +19,20 @@
|
||||
tracker: Tracker;
|
||||
}
|
||||
|
||||
// ── State ──────────────────────────────────────────────────────────────────
|
||||
|
||||
let trackers: TrackerWithRecords[] = $state([]);
|
||||
let loading: boolean = $state(true);
|
||||
let error: string | null = $state(null);
|
||||
|
||||
// Filter/view state
|
||||
let activeTrackerId: number | "all" = $state("all");
|
||||
let statusFilter: number | "all" = $state("all");
|
||||
let searchQuery: string = $state("");
|
||||
let sortBy: "title" | "status" | "score" | "progress" = $state("title");
|
||||
|
||||
// Mutation state
|
||||
let updatingId: number | null = $state(null);
|
||||
let syncingId: number | null = $state(null);
|
||||
// Chapter editing: recordId → draft value
|
||||
let editingChapter: number | null = $state(null);
|
||||
let chapterDraft: number = $state(0);
|
||||
|
||||
// ── Load ───────────────────────────────────────────────────────────────────
|
||||
let confirmUnbindRecord: FlatRecord | null = $state(null);
|
||||
|
||||
async function load() {
|
||||
loading = true; error = null;
|
||||
@@ -55,15 +48,13 @@
|
||||
|
||||
$effect(() => { load(); });
|
||||
|
||||
// ── Derived data ───────────────────────────────────────────────────────────
|
||||
|
||||
const loggedInTrackers = $derived(trackers.filter(t => t.isLoggedIn));
|
||||
|
||||
const allRecords: FlatRecord[] = $derived(
|
||||
loggedInTrackers.flatMap(t =>
|
||||
t.trackRecords.nodes.map(r => ({
|
||||
...r,
|
||||
trackerId: r.trackerId ?? t.id, // fallback in case field is missing
|
||||
trackerId: r.trackerId ?? t.id,
|
||||
tracker: t as Tracker,
|
||||
}))
|
||||
)
|
||||
@@ -71,14 +62,11 @@
|
||||
|
||||
const totalCount = $derived(allRecords.length);
|
||||
|
||||
// Status options across active tracker
|
||||
const statusOptions = $derived.by(() => {
|
||||
if (activeTrackerId === "all") {
|
||||
// Merge all statuses, dedupe by value+name
|
||||
const seen = new Map<string, { value: number; name: string }>();
|
||||
for (const t of loggedInTrackers) {
|
||||
for (const t of loggedInTrackers)
|
||||
for (const s of t.statuses ?? []) seen.set(`${s.value}:${s.name}`, s);
|
||||
}
|
||||
return [...seen.values()];
|
||||
}
|
||||
return loggedInTrackers.find(t => t.id === activeTrackerId)?.statuses ?? [];
|
||||
@@ -111,8 +99,6 @@
|
||||
});
|
||||
});
|
||||
|
||||
// ── Mutations ──────────────────────────────────────────────────────────────
|
||||
|
||||
async function updateStatus(record: FlatRecord, status: number) {
|
||||
updatingId = record.id;
|
||||
try {
|
||||
@@ -122,9 +108,7 @@
|
||||
patchRecord(record.trackerId, res.updateTrack.trackRecord);
|
||||
} catch (e: any) {
|
||||
addToast({ kind: "error", title: "Update failed", body: e?.message });
|
||||
} finally {
|
||||
updatingId = null;
|
||||
}
|
||||
} finally { updatingId = null; }
|
||||
}
|
||||
|
||||
async function updateScore(record: FlatRecord, scoreString: string) {
|
||||
@@ -136,9 +120,7 @@
|
||||
patchRecord(record.trackerId, res.updateTrack.trackRecord);
|
||||
} catch (e: any) {
|
||||
addToast({ kind: "error", title: "Update failed", body: e?.message });
|
||||
} finally {
|
||||
updatingId = null;
|
||||
}
|
||||
} finally { updatingId = null; }
|
||||
}
|
||||
|
||||
async function syncRecord(record: FlatRecord) {
|
||||
@@ -151,9 +133,7 @@
|
||||
addToast({ kind: "success", title: "Synced from tracker" });
|
||||
} catch (e: any) {
|
||||
addToast({ kind: "error", title: "Sync failed", body: e?.message });
|
||||
} finally {
|
||||
syncingId = null;
|
||||
}
|
||||
} finally { syncingId = null; }
|
||||
}
|
||||
|
||||
async function unbind(record: FlatRecord) {
|
||||
@@ -169,9 +149,7 @@
|
||||
addToast({ kind: "info", title: "Unlinked from " + record.tracker.name });
|
||||
} catch (e: any) {
|
||||
addToast({ kind: "error", title: "Unbind failed", body: e?.message });
|
||||
} finally {
|
||||
updatingId = null;
|
||||
}
|
||||
} finally { updatingId = null; }
|
||||
}
|
||||
|
||||
function patchRecord(trackerId: number, updated: Partial<TrackRecord> & { id: number }) {
|
||||
@@ -196,9 +174,7 @@
|
||||
chapterDraft = record.lastChapterRead;
|
||||
}
|
||||
|
||||
function cancelChapterEditor() {
|
||||
editingChapter = null;
|
||||
}
|
||||
function cancelChapterEditor() { editingChapter = null; }
|
||||
|
||||
async function submitChapter(record: FlatRecord) {
|
||||
const val = Math.max(0, chapterDraft);
|
||||
@@ -212,15 +188,34 @@
|
||||
patchRecord(record.trackerId, res.updateTrack.trackRecord);
|
||||
} catch (e: any) {
|
||||
addToast({ kind: "error", title: "Update failed", body: e?.message });
|
||||
} finally {
|
||||
updatingId = null;
|
||||
} finally { updatingId = null; }
|
||||
}
|
||||
|
||||
function requestUnbind(record: FlatRecord) {
|
||||
confirmUnbindRecord = record;
|
||||
}
|
||||
|
||||
function cancelUnbind() {
|
||||
confirmUnbindRecord = null;
|
||||
}
|
||||
|
||||
async function confirmAndUnbind() {
|
||||
if (!confirmUnbindRecord) return;
|
||||
const record = confirmUnbindRecord;
|
||||
confirmUnbindRecord = null;
|
||||
await unbind(record);
|
||||
}
|
||||
|
||||
function scoreToStars(score: string | undefined, scores: string[] | undefined): number {
|
||||
if (!score || !scores || scores.length === 0) return 0;
|
||||
const idx = scores.indexOf(score);
|
||||
if (idx < 0) return 0;
|
||||
return Math.round((idx / (scores.length - 1)) * 5);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="page">
|
||||
|
||||
<!-- ── Header ──────────────────────────────────────────────────────────── -->
|
||||
<div class="header">
|
||||
<div class="header-top">
|
||||
<h1 class="heading">Tracking</h1>
|
||||
@@ -231,7 +226,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tracker filter tabs -->
|
||||
{#if !loading && loggedInTrackers.length > 0}
|
||||
<div class="tracker-tabs">
|
||||
<button
|
||||
@@ -249,14 +243,13 @@
|
||||
class:tab-active={activeTrackerId === t.id}
|
||||
onclick={() => { activeTrackerId = Number(t.id); statusFilter = "all"; }}
|
||||
>
|
||||
<img src={thumbUrl(t.icon)} alt={t.name} class="tab-tracker-icon" />
|
||||
<Thumbnail src={t.icon} alt={t.name} class="tab-tracker-icon" />
|
||||
{t.name}
|
||||
<span class="tab-count">{count}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Filter + sort bar -->
|
||||
<div class="filter-bar">
|
||||
<div class="search-wrap">
|
||||
<MagnifyingGlass size={12} weight="light" class="search-ico" />
|
||||
@@ -266,7 +259,6 @@
|
||||
bind:value={searchQuery}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="filter-right">
|
||||
<Funnel size={12} weight="light" style="color:var(--text-faint);flex-shrink:0" />
|
||||
<select class="filter-select" bind:value={statusFilter}
|
||||
@@ -279,25 +271,23 @@
|
||||
<option value={s.value}>{s.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
|
||||
<select class="filter-select" bind:value={sortBy}>
|
||||
<option value="title">Sort: Title</option>
|
||||
<option value="status">Sort: Status</option>
|
||||
<option value="score">Sort: Score</option>
|
||||
<option value="progress">Sort: Progress</option>
|
||||
<option value="title">Title</option>
|
||||
<option value="status">Status</option>
|
||||
<option value="score">Score</option>
|
||||
<option value="progress">Progress</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- ── Body ────────────────────────────────────────────────────────────── -->
|
||||
<div class="page-body">
|
||||
|
||||
{#if loading}
|
||||
<div class="state-center">
|
||||
<CircleNotch size={22} weight="light" class="anim-spin" style="color:var(--text-faint)" />
|
||||
<span class="state-label">Loading tracking data…</span>
|
||||
<CircleNotch size={20} weight="light" class="anim-spin" style="color:var(--text-faint)" />
|
||||
<span class="state-label">Loading…</span>
|
||||
</div>
|
||||
|
||||
{:else if error}
|
||||
@@ -309,19 +299,19 @@
|
||||
{:else if loggedInTrackers.length === 0}
|
||||
<div class="state-center">
|
||||
<p class="state-text">No trackers connected.</p>
|
||||
<p class="state-hint">Go to Settings → Tracking to log in to AniList, MAL, or others.</p>
|
||||
<p class="state-hint">Go to Settings → Tracking to connect AniList, MAL, or others.</p>
|
||||
</div>
|
||||
|
||||
{:else if filtered.length === 0}
|
||||
<div class="state-center">
|
||||
<p class="state-text">{searchQuery || statusFilter !== "all" ? "No results match your filters." : "Nothing tracked yet."}</p>
|
||||
<p class="state-text">{searchQuery || statusFilter !== "all" ? "No results." : "Nothing tracked yet."}</p>
|
||||
{#if searchQuery || statusFilter !== "all"}
|
||||
<button class="retry-btn" onclick={() => { searchQuery = ""; statusFilter = "all"; }}>Clear filters</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{:else}
|
||||
<div class="records-list">
|
||||
<div class="records-grid">
|
||||
{#each filtered as record (record.tracker.id + ":" + record.id)}
|
||||
{@const tracker = record.tracker}
|
||||
{@const isBusy = updatingId === record.id}
|
||||
@@ -329,64 +319,74 @@
|
||||
{@const progress = record.totalChapters > 0
|
||||
? Math.min(100, (record.lastChapterRead / record.totalChapters) * 100)
|
||||
: null}
|
||||
{@const stars = scoreToStars(record.displayScore, tracker.scores)}
|
||||
{@const statusName = (tracker.statuses ?? []).find(s => s.value === record.status)?.name ?? "—"}
|
||||
|
||||
<div class="record-card" class:record-busy={isBusy}>
|
||||
|
||||
<!-- Cover -->
|
||||
<div class="record-cover-wrap" role="button" tabindex="0"
|
||||
<div class="card-cover-wrap">
|
||||
<div class="card-cover-region"
|
||||
role="button" tabindex="0"
|
||||
onclick={() => openManga(record)}
|
||||
onkeydown={(e) => e.key === "Enter" && openManga(record)}
|
||||
title="Open in library"
|
||||
>
|
||||
{#if record.manga?.thumbnailUrl}
|
||||
<img src={thumbUrl(record.manga.thumbnailUrl)} alt={record.title} class="record-cover" loading="lazy" />
|
||||
<Thumbnail src={record.manga.thumbnailUrl} alt={record.title} class="card-cover-img" />
|
||||
{:else}
|
||||
<div class="record-cover record-cover-empty"></div>
|
||||
<div class="card-cover-empty"></div>
|
||||
{/if}
|
||||
<!-- Tracker badge -->
|
||||
<img src={thumbUrl(tracker.icon)} alt={tracker.name} class="record-tracker-badge" />
|
||||
</div>
|
||||
|
||||
<!-- Info -->
|
||||
<div class="record-body">
|
||||
<div class="record-top">
|
||||
<div class="record-titles" role="button" tabindex="0"
|
||||
onclick={() => openManga(record)}
|
||||
onkeydown={(e) => e.key === "Enter" && openManga(record)}
|
||||
>
|
||||
<span class="record-title">{record.title}</span>
|
||||
{#if record.manga?.title && record.manga.title !== record.title}
|
||||
<span class="record-local-title">{record.manga.title}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="record-header-actions">
|
||||
{#if activeTrackerId === "all"}
|
||||
<span class="record-tracker-label">
|
||||
<img src={thumbUrl(record.tracker.icon)} alt={record.tracker.name} class="record-tracker-label-icon" />
|
||||
{record.tracker.name}
|
||||
</span>
|
||||
<div class="card-top-actions">
|
||||
{#if record.private}
|
||||
<span class="card-badge-btn" title="Private"><Lock size={10} weight="fill" /></span>
|
||||
{/if}
|
||||
{#if isSyncing}
|
||||
<CircleNotch size={12} weight="light" class="anim-spin" style="color:var(--text-faint)" />
|
||||
<span class="card-badge-btn">
|
||||
<CircleNotch size={10} weight="light" class="anim-spin" />
|
||||
</span>
|
||||
{:else}
|
||||
<button class="card-icon-btn" title="Sync from tracker" onclick={() => syncRecord(record)}>
|
||||
<ArrowsClockwise size={12} weight="light" />
|
||||
<button class="card-badge-btn" title="Sync" onclick={() => syncRecord(record)}>
|
||||
<ArrowsClockwise size={10} weight="light" />
|
||||
</button>
|
||||
{/if}
|
||||
{#if record.remoteUrl}
|
||||
<a href={record.remoteUrl} target="_blank" rel="noreferrer" class="card-icon-btn" title="Open on {record.tracker.name}">
|
||||
<ArrowSquareOut size={12} weight="light" />
|
||||
<a href={record.remoteUrl} target="_blank" rel="noreferrer" class="card-badge-btn" title="Open on {tracker.name}">
|
||||
<ArrowSquareOut size={10} weight="light" />
|
||||
</a>
|
||||
{/if}
|
||||
<button class="card-icon-btn danger" title="Unlink" onclick={() => unbind(record)} disabled={isBusy}>
|
||||
<X size={12} weight="bold" />
|
||||
<button class="card-badge-btn danger" title="Unlink" onclick={() => requestUnbind(record)} disabled={isBusy}>
|
||||
<X size={10} weight="bold" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="card-tracker-badge">
|
||||
<Thumbnail src={tracker.icon} alt={tracker.name} class="tracker-badge-img" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Controls row -->
|
||||
<div class="record-controls">
|
||||
<div class="card-footer">
|
||||
<div class="card-stars">
|
||||
{#each Array(5) as _, i}
|
||||
<span class="star" class:star-filled={i < stars}>★</span>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="card-title-block"
|
||||
role="button" tabindex="0"
|
||||
onclick={() => openManga(record)}
|
||||
onkeydown={(e) => e.key === "Enter" && openManga(record)}
|
||||
>
|
||||
<span class="card-title">{record.title}</span>
|
||||
{#if record.manga?.title && record.manga.title !== record.title}
|
||||
<span class="card-local-title">{record.manga.title}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="card-meta-row">
|
||||
<select
|
||||
class="record-select"
|
||||
class="status-pill"
|
||||
value={record.status}
|
||||
disabled={isBusy}
|
||||
onchange={(e) => updateStatus(record, parseInt((e.target as HTMLSelectElement).value))}
|
||||
@@ -397,7 +397,7 @@
|
||||
</select>
|
||||
|
||||
<select
|
||||
class="record-select record-select-score"
|
||||
class="score-select"
|
||||
value={record.displayScore}
|
||||
disabled={isBusy}
|
||||
onchange={(e) => updateScore(record, (e.target as HTMLSelectElement).value)}
|
||||
@@ -406,29 +406,18 @@
|
||||
<option value={s}>★ {s}</option>
|
||||
{/each}
|
||||
</select>
|
||||
|
||||
{#if record.private}
|
||||
<span class="private-badge" title="Private"><Lock size={10} weight="fill" /></span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Progress / Chapter editor -->
|
||||
{#if editingChapter === record.id}
|
||||
<div class="chapter-editor">
|
||||
<div class="chapter-editor" role="presentation" onclick={(e) => e.stopPropagation()}>
|
||||
<div class="chapter-editor-top">
|
||||
<span class="chapter-editor-label">Chapter read</span>
|
||||
<span class="chapter-editor-label">Chapter</span>
|
||||
<div class="chapter-input-wrap">
|
||||
<input
|
||||
type="number"
|
||||
class="chapter-input"
|
||||
min="0"
|
||||
max={record.totalChapters > 0 ? record.totalChapters : undefined}
|
||||
step="0.5"
|
||||
bind:value={chapterDraft}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === "Enter") submitChapter(record);
|
||||
if (e.key === "Escape") cancelChapterEditor();
|
||||
}}
|
||||
type="number" class="chapter-input"
|
||||
min="0" max={record.totalChapters > 0 ? record.totalChapters : undefined}
|
||||
step="0.5" bind:value={chapterDraft}
|
||||
onkeydown={(e) => { if (e.key === "Enter") submitChapter(record); if (e.key === "Escape") cancelChapterEditor(); }}
|
||||
use:focusEl
|
||||
/>
|
||||
{#if record.totalChapters > 0}
|
||||
@@ -437,45 +426,39 @@
|
||||
</div>
|
||||
</div>
|
||||
{#if record.totalChapters > 0}
|
||||
<input
|
||||
type="range"
|
||||
class="chapter-slider"
|
||||
min="0"
|
||||
max={record.totalChapters}
|
||||
step="1"
|
||||
bind:value={chapterDraft}
|
||||
/>
|
||||
<input type="range" class="chapter-slider" min="0" max={record.totalChapters} step="1" bind:value={chapterDraft} />
|
||||
{/if}
|
||||
<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-save-btn" onclick={() => submitChapter(record)}>Save</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else if progress !== null}
|
||||
<div class="record-progress clickable" role="button" tabindex="0"
|
||||
onclick={() => openChapterEditor(record)}
|
||||
onkeydown={(e) => e.key === "Enter" && openChapterEditor(record)}
|
||||
title="Click to edit"
|
||||
>
|
||||
<div class="progress-track">
|
||||
<div class="progress-fill" style="width:{progress}%"></div>
|
||||
</div>
|
||||
<span class="progress-label">Ch. {record.lastChapterRead} / {record.totalChapters}</span>
|
||||
<span class="progress-edit-hint">✎</span>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="record-progress clickable no-total" role="button" tabindex="0"
|
||||
<div class="progress-block clickable"
|
||||
role="button" tabindex="0"
|
||||
onclick={() => openChapterEditor(record)}
|
||||
onkeydown={(e) => e.key === "Enter" && openChapterEditor(record)}
|
||||
title="Click to set chapter"
|
||||
title="Click to edit chapter"
|
||||
>
|
||||
<span class="progress-label">
|
||||
{record.lastChapterRead > 0 ? `Ch. ${record.lastChapterRead} read` : "Set chapter…"}
|
||||
<div class="progress-labels">
|
||||
<span class="progress-text">
|
||||
{#if progress !== null}
|
||||
Ch. {record.lastChapterRead} / {record.totalChapters}
|
||||
{:else if record.lastChapterRead > 0}
|
||||
Ch. {record.lastChapterRead} read
|
||||
{:else}
|
||||
Set chapter…
|
||||
{/if}
|
||||
</span>
|
||||
<span class="progress-edit-hint">✎</span>
|
||||
{#if progress !== null}
|
||||
<span class="progress-pct">{Math.round(progress)}%</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="progress-track">
|
||||
<div class="progress-fill" style="width:{progress ?? 0}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
@@ -485,151 +468,425 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.page { display: flex; flex-direction: column; height: 100%; overflow: hidden; animation: fadeIn 0.14s ease both; }
|
||||
{#if confirmUnbindRecord}
|
||||
{@const r = confirmUnbindRecord}
|
||||
<div class="modal-backdrop" role="presentation" onclick={cancelUnbind}>
|
||||
<div class="modal" role="dialog" aria-modal="true" onclick={(e) => e.stopPropagation()}>
|
||||
<div class="modal-icon">
|
||||
<X size={18} weight="bold" />
|
||||
</div>
|
||||
<p class="modal-title">Unlink from {r.tracker.name}?</p>
|
||||
<p class="modal-body">
|
||||
<strong>{r.title}</strong> will be removed from your tracking list. This won't affect your progress on {r.tracker.name} itself.
|
||||
</p>
|
||||
<div class="modal-actions">
|
||||
<button class="modal-cancel" onclick={cancelUnbind}>Cancel</button>
|
||||
<button class="modal-confirm" onclick={confirmAndUnbind}>Unlink</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
/* ── Header ─────────────────────────────────────────────────────────────── */
|
||||
.header { flex-shrink: 0; border-bottom: 1px solid var(--border-dim); background: var(--bg-base); }
|
||||
.header-top { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-4) var(--sp-6) var(--sp-3); }
|
||||
.heading { font-family: var(--font-ui); font-size: var(--text-xs); font-weight: var(--weight-normal); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; }
|
||||
<style>
|
||||
.page {
|
||||
display: flex; flex-direction: column; height: 100%; overflow: hidden;
|
||||
animation: fadeIn 0.16s ease both;
|
||||
}
|
||||
|
||||
.header {
|
||||
flex-shrink: 0;
|
||||
border-bottom: 1px solid var(--border-dim);
|
||||
background: var(--bg-base);
|
||||
}
|
||||
.header-top {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: var(--sp-4) var(--sp-6) var(--sp-3);
|
||||
}
|
||||
.heading {
|
||||
font-family: var(--font-ui); font-size: var(--text-xs);
|
||||
font-weight: var(--weight-normal); color: var(--text-faint);
|
||||
letter-spacing: var(--tracking-wider); text-transform: uppercase;
|
||||
}
|
||||
.header-actions { display: flex; align-items: center; gap: var(--sp-2); }
|
||||
.icon-btn { display: flex; align-items: center; justify-content: center; width: 26px; height: 26px; border-radius: var(--radius-sm); border: none; color: var(--text-faint); background: none; cursor: pointer; transition: color var(--t-base), background var(--t-base); }
|
||||
.icon-btn {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
width: 26px; height: 26px; border-radius: var(--radius-sm);
|
||||
border: none; color: var(--text-faint); background: none;
|
||||
cursor: pointer; transition: color var(--t-base), background var(--t-base);
|
||||
}
|
||||
.icon-btn:hover:not(:disabled) { color: var(--text-muted); background: var(--bg-raised); }
|
||||
.icon-btn:disabled { opacity: 0.3; cursor: default; }
|
||||
|
||||
/* ── Tracker tabs ───────────────────────────────────────────────────────── */
|
||||
.tracker-tabs { display: flex; align-items: center; gap: 1px; padding: 0 var(--sp-5); overflow-x: auto; scrollbar-width: none; }
|
||||
.tracker-tabs {
|
||||
display: flex; align-items: center; gap: 1px;
|
||||
padding: 0 var(--sp-5); overflow-x: auto; scrollbar-width: none;
|
||||
}
|
||||
.tracker-tabs::-webkit-scrollbar { display: none; }
|
||||
.tracker-tab {
|
||||
display: flex; align-items: center; gap: var(--sp-2);
|
||||
padding: 9px 10px 8px;
|
||||
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
|
||||
color: var(--text-faint); background: none; border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
border-radius: 0; cursor: pointer; white-space: nowrap;
|
||||
font-family: var(--font-ui); font-size: var(--text-xs);
|
||||
letter-spacing: var(--tracking-wide); color: var(--text-faint);
|
||||
background: none; border: none; border-bottom: 2px solid transparent;
|
||||
cursor: pointer; white-space: nowrap; margin-bottom: -1px;
|
||||
transition: color var(--t-base), border-color var(--t-base);
|
||||
margin-bottom: -1px;
|
||||
}
|
||||
.tracker-tab:hover { color: var(--text-muted); }
|
||||
.tab-active { color: var(--text-secondary) !important; border-bottom-color: var(--accent); }
|
||||
.tab-tracker-icon { width: 13px; height: 13px; border-radius: 2px; object-fit: contain; opacity: 0.85; }
|
||||
.tab-count { font-size: 10px; padding: 0 4px; border-radius: var(--radius-full); background: var(--bg-overlay); color: var(--text-faint); min-width: 16px; text-align: center; line-height: 16px; }
|
||||
:global(.tab-tracker-icon) { width: 13px; height: 13px; border-radius: 2px; object-fit: contain; opacity: 0.85; }
|
||||
.tab-count {
|
||||
font-size: 10px; padding: 0 4px; border-radius: var(--radius-full);
|
||||
background: var(--bg-overlay); color: var(--text-faint);
|
||||
min-width: 16px; text-align: center; line-height: 16px;
|
||||
}
|
||||
.tab-active .tab-count { background: var(--accent-muted); color: var(--accent-fg); }
|
||||
|
||||
/* ── Filter bar ─────────────────────────────────────────────────────────── */
|
||||
.filter-bar { display: flex; align-items: center; gap: var(--sp-3); padding: var(--sp-2) var(--sp-5); border-top: 1px solid var(--border-dim); }
|
||||
.search-wrap { display: flex; align-items: center; gap: var(--sp-2); flex: 1; background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 4px 10px; }
|
||||
.filter-bar {
|
||||
display: flex; align-items: center; gap: var(--sp-3);
|
||||
padding: var(--sp-2) var(--sp-5);
|
||||
border-top: 1px solid var(--border-dim);
|
||||
}
|
||||
.search-wrap {
|
||||
display: flex; align-items: center; gap: var(--sp-2); flex: 1;
|
||||
background: var(--bg-raised); border: 1px solid var(--border-dim);
|
||||
border-radius: var(--radius-md); padding: 4px 10px;
|
||||
}
|
||||
:global(.search-ico) { color: var(--text-faint); flex-shrink: 0; }
|
||||
.filter-search { flex: 1; background: none; border: none; outline: none; font-size: var(--text-sm); color: var(--text-primary); min-width: 0; }
|
||||
.filter-search {
|
||||
flex: 1; background: none; border: none; outline: none;
|
||||
font-size: var(--text-sm); color: var(--text-primary); min-width: 0;
|
||||
}
|
||||
.filter-search::placeholder { color: var(--text-faint); }
|
||||
.filter-right { display: flex; align-items: center; gap: var(--sp-2); flex-shrink: 0; }
|
||||
.filter-select {
|
||||
font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide);
|
||||
padding: 4px 24px 4px 8px; border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border-dim); background: var(--bg-raised);
|
||||
color: var(--text-faint); outline: none; cursor: pointer;
|
||||
appearance: none; -webkit-appearance: none;
|
||||
font-family: var(--font-ui); font-size: var(--text-2xs);
|
||||
letter-spacing: var(--tracking-wide); padding: 4px 22px 4px 8px;
|
||||
border-radius: var(--radius-sm); border: 1px solid var(--border-dim);
|
||||
background: var(--bg-raised); color: var(--text-faint);
|
||||
outline: none; cursor: pointer; appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%23555' stroke-width='1.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat; background-position: right 7px center;
|
||||
background-repeat: no-repeat; background-position: right 6px center;
|
||||
transition: border-color var(--t-base), color var(--t-base);
|
||||
}
|
||||
.filter-select:hover { border-color: var(--border-strong); color: var(--text-muted); }
|
||||
.filter-select option { background: var(--bg-surface); color: var(--text-secondary); }
|
||||
|
||||
/* ── Body ───────────────────────────────────────────────────────────────── */
|
||||
.page-body { flex: 1; overflow-y: auto; padding: var(--sp-3) var(--sp-5); scrollbar-width: thin; scrollbar-color: var(--border-strong) transparent; }
|
||||
.page-body {
|
||||
flex: 1; overflow-y: auto; padding: var(--sp-5);
|
||||
scrollbar-width: thin; scrollbar-color: var(--border-strong) transparent;
|
||||
}
|
||||
|
||||
/* ── States ─────────────────────────────────────────────────────────────── */
|
||||
.state-center { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: var(--sp-3); height: 100%; padding: var(--sp-10); text-align: center; }
|
||||
.state-center {
|
||||
display: flex; flex-direction: column; align-items: center;
|
||||
justify-content: center; gap: var(--sp-3); height: 100%;
|
||||
padding: var(--sp-10); text-align: center;
|
||||
}
|
||||
.state-label { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||
.state-text { font-size: var(--text-sm); color: var(--text-muted); }
|
||||
.state-hint { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||
.state-error { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--color-error); letter-spacing: var(--tracking-wide); }
|
||||
.retry-btn { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 5px 14px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); cursor: pointer; transition: color var(--t-base), border-color var(--t-base); }
|
||||
.retry-btn {
|
||||
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
|
||||
padding: 5px 14px; border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border-dim); background: none;
|
||||
color: var(--text-faint); cursor: pointer;
|
||||
transition: color var(--t-base), border-color var(--t-base);
|
||||
}
|
||||
.retry-btn:hover { color: var(--accent-fg); border-color: var(--accent-dim); }
|
||||
|
||||
/* ── Records list ───────────────────────────────────────────────────────── */
|
||||
.records-list { display: flex; flex-direction: column; gap: 2px; }
|
||||
.records-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
gap: var(--sp-4);
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.record-card {
|
||||
display: flex; align-items: flex-start; gap: var(--sp-4);
|
||||
padding: var(--sp-3) var(--sp-3);
|
||||
border-radius: var(--radius-md);
|
||||
background: none;
|
||||
transition: background var(--t-fast), opacity var(--t-base);
|
||||
display: flex; flex-direction: column;
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--border-dim);
|
||||
background: var(--bg-raised);
|
||||
overflow: hidden;
|
||||
transition: border-color var(--t-base), opacity var(--t-base), transform var(--t-base);
|
||||
}
|
||||
.record-card:hover { background: var(--bg-raised); }
|
||||
.record-busy { opacity: 0.4; pointer-events: none; }
|
||||
.record-card:hover {
|
||||
border-color: var(--border-strong);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
.record-busy { opacity: 0.35; pointer-events: none; }
|
||||
|
||||
/* Cover */
|
||||
.record-cover-wrap { position: relative; flex-shrink: 0; cursor: pointer; }
|
||||
.record-cover { width: 44px; height: 62px; object-fit: cover; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); display: block; transition: opacity var(--t-fast); }
|
||||
.record-cover-empty { background: var(--bg-overlay); }
|
||||
.record-cover-wrap:hover .record-cover { opacity: 0.75; }
|
||||
.record-tracker-badge { position: absolute; bottom: -3px; right: -3px; width: 14px; height: 14px; border-radius: 2px; border: 1px solid var(--bg-base); object-fit: contain; background: var(--bg-raised); }
|
||||
.card-cover-wrap {
|
||||
position: relative;
|
||||
aspect-ratio: 2 / 3;
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
background: var(--bg-overlay);
|
||||
}
|
||||
|
||||
/* Body */
|
||||
.record-body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: var(--sp-2); }
|
||||
.record-top { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--sp-3); }
|
||||
.record-titles { display: flex; flex-direction: column; gap: 2px; flex: 1; min-width: 0; cursor: pointer; }
|
||||
.record-title { font-size: var(--text-sm); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; transition: color var(--t-base); }
|
||||
.record-titles:hover .record-title { color: var(--accent-fg); }
|
||||
.record-local-title { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.record-header-actions { display: flex; align-items: center; gap: 1px; flex-shrink: 0; }
|
||||
.record-tracker-label { display: flex; align-items: center; gap: 4px; font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); padding: 2px 6px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: var(--bg-overlay); margin-right: var(--sp-1); }
|
||||
.record-tracker-label-icon { width: 11px; height: 11px; border-radius: 2px; object-fit: contain; }
|
||||
.card-icon-btn { display: flex; align-items: center; justify-content: center; width: 22px; height: 22px; border-radius: var(--radius-sm); color: var(--text-faint); background: none; border: none; cursor: pointer; text-decoration: none; transition: color var(--t-base), background var(--t-base); }
|
||||
.card-icon-btn:hover { color: var(--text-muted); background: var(--bg-overlay); }
|
||||
.card-icon-btn.danger:hover { color: var(--color-error); background: var(--color-error-bg); }
|
||||
.card-icon-btn:disabled { opacity: 0.3; cursor: default; }
|
||||
.card-cover-region {
|
||||
position: absolute; inset: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Controls */
|
||||
.record-controls { display: flex; align-items: center; 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: 3px 22px 3px 7px; border-radius: var(--radius-sm);
|
||||
border: 1px solid transparent; background: var(--bg-overlay);
|
||||
color: var(--text-faint); 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='%23555' stroke-width='1.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E");
|
||||
:global(.card-cover-img) {
|
||||
width: 100%; height: 100%;
|
||||
object-fit: cover; display: block;
|
||||
transition: transform 0.35s ease, opacity 0.2s ease;
|
||||
}
|
||||
.card-cover-wrap:hover :global(.card-cover-img) {
|
||||
transform: scale(1.04);
|
||||
opacity: 0.88;
|
||||
}
|
||||
.card-cover-empty { width: 100%; height: 100%; background: var(--bg-overlay); }
|
||||
|
||||
.card-stars {
|
||||
display: flex; gap: 3px; align-items: center;
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
.star {
|
||||
font-size: 15px; line-height: 1;
|
||||
color: var(--border-strong);
|
||||
transition: color var(--t-base);
|
||||
}
|
||||
.star-filled { color: #f5c518; }
|
||||
|
||||
.card-top-actions {
|
||||
position: absolute; top: 6px; right: 6px; z-index: 2;
|
||||
display: flex; gap: 2px;
|
||||
opacity: 0;
|
||||
transition: opacity var(--t-base);
|
||||
}
|
||||
.card-cover-wrap:hover .card-top-actions { opacity: 1; }
|
||||
|
||||
.card-badge-btn {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
width: 24px; height: 24px; border-radius: var(--radius-sm);
|
||||
background: rgba(0,0,0,0.6); backdrop-filter: blur(8px);
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
color: rgba(255,255,255,0.75); cursor: pointer;
|
||||
text-decoration: none;
|
||||
transition: background var(--t-base), color var(--t-base);
|
||||
}
|
||||
.card-badge-btn:hover { background: rgba(0,0,0,0.75); color: #fff; }
|
||||
.card-badge-btn.danger:hover { background: rgba(180,40,40,0.7); color: #fff; }
|
||||
.card-badge-btn:disabled { opacity: 0.3; cursor: default; }
|
||||
|
||||
.card-tracker-badge {
|
||||
position: absolute; bottom: 9px; right: 9px; z-index: 2;
|
||||
width: 22px; height: 22px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid rgba(0,0,0,0.35);
|
||||
background: var(--bg-raised);
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.55);
|
||||
overflow: hidden;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
:global(.tracker-badge-img) {
|
||||
width: 100%; height: 100%;
|
||||
object-fit: contain; display: block;
|
||||
}
|
||||
|
||||
/* ── Footer panel ───────────────────────────────────────────────────────── */
|
||||
.card-footer {
|
||||
display: flex; flex-direction: column; gap: 10px;
|
||||
padding: 13px 13px 13px;
|
||||
border-top: 1px solid var(--border-dim);
|
||||
}
|
||||
|
||||
/* Title */
|
||||
.card-title-block {
|
||||
display: flex; flex-direction: column; gap: 3px;
|
||||
cursor: pointer; min-width: 0;
|
||||
}
|
||||
.card-title {
|
||||
font-size: var(--text-sm); font-weight: var(--weight-medium);
|
||||
color: var(--text-secondary); line-height: 1.38;
|
||||
display: -webkit-box; -webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical; overflow: hidden;
|
||||
transition: color var(--t-base);
|
||||
}
|
||||
.card-title-block:hover .card-title { color: var(--accent-fg); }
|
||||
.card-local-title {
|
||||
font-family: var(--font-ui); font-size: 11px; color: var(--text-faint);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.card-meta-row {
|
||||
display: flex; align-items: center; gap: var(--sp-1);
|
||||
}
|
||||
|
||||
.status-pill {
|
||||
flex: 1; min-width: 0;
|
||||
font-family: var(--font-ui); font-size: var(--text-2xs);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
padding: 5px 20px 5px 9px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border-dim);
|
||||
background: var(--bg-overlay);
|
||||
color: var(--text-muted);
|
||||
outline: none; cursor: pointer;
|
||||
appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='5' viewBox='0 0 8 5'%3E%3Cpath d='M1 1l3 3 3-3' stroke='%23555' stroke-width='1.3' fill='none' stroke-linecap='round'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat; background-position: right 6px center;
|
||||
transition: border-color var(--t-base), color var(--t-base), background var(--t-base);
|
||||
transition: border-color var(--t-base), color var(--t-base);
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.record-select:hover:not(:disabled) { border-color: var(--border-dim); color: var(--text-secondary); background: var(--bg-raised); }
|
||||
.record-select:disabled { opacity: 0.35; cursor: default; }
|
||||
.record-select option { background: var(--bg-surface); color: var(--text-secondary); }
|
||||
.record-select-score { max-width: 86px; }
|
||||
.private-badge { display: flex; align-items: center; color: var(--text-faint); padding: 2px 4px; }
|
||||
.status-pill:hover:not(:disabled) { border-color: var(--border-strong); color: var(--text-secondary); }
|
||||
.status-pill:disabled { opacity: 0.35; cursor: default; }
|
||||
.status-pill option { background: var(--bg-surface); color: var(--text-secondary); }
|
||||
|
||||
/* Progress */
|
||||
.record-progress { display: flex; align-items: center; gap: var(--sp-3); }
|
||||
.progress-track { flex: 1; height: 2px; background: var(--border-strong); border-radius: var(--radius-full); overflow: hidden; max-width: 140px; }
|
||||
.progress-fill { height: 100%; background: var(--accent); border-radius: var(--radius-full); transition: width 0.3s ease; }
|
||||
.progress-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); white-space: nowrap; }
|
||||
.record-progress.clickable { cursor: pointer; border-radius: var(--radius-sm); padding: 2px 4px; margin: -2px -4px; transition: background var(--t-fast); }
|
||||
.record-progress.clickable:hover { background: var(--bg-overlay); }
|
||||
.record-progress.clickable:hover .progress-label { color: var(--text-muted); }
|
||||
.progress-edit-hint { font-size: 10px; color: var(--text-faint); opacity: 0; transition: opacity var(--t-fast); }
|
||||
.record-progress.clickable:hover .progress-edit-hint { opacity: 1; }
|
||||
.score-select {
|
||||
flex-shrink: 0; width: 58px;
|
||||
font-family: var(--font-ui); font-size: var(--text-2xs);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
padding: 5px 16px 5px 6px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border-dim);
|
||||
background: var(--bg-overlay);
|
||||
color: var(--text-faint);
|
||||
outline: none; cursor: pointer;
|
||||
appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='5' viewBox='0 0 8 5'%3E%3Cpath d='M1 1l3 3 3-3' stroke='%23555' stroke-width='1.3' fill='none' stroke-linecap='round'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat; background-position: right 4px center;
|
||||
transition: border-color var(--t-base), color var(--t-base);
|
||||
}
|
||||
.score-select:hover:not(:disabled) { border-color: var(--border-strong); color: var(--text-secondary); }
|
||||
.score-select:disabled { opacity: 0.35; cursor: default; }
|
||||
.score-select option { background: var(--bg-surface); color: var(--text-secondary); }
|
||||
|
||||
/* Chapter editor */
|
||||
.chapter-editor { display: flex; flex-direction: column; gap: var(--sp-2); padding: var(--sp-3); border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: var(--bg-raised); }
|
||||
.chapter-editor-top { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-3); }
|
||||
.chapter-editor-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||
.chapter-input-wrap { display: flex; align-items: center; gap: var(--sp-2); }
|
||||
.chapter-input { width: 72px; background: var(--bg-surface); border: 1px solid var(--border-strong); border-radius: var(--radius-sm); padding: 4px 8px; font-family: var(--font-ui); font-size: var(--text-sm); color: var(--text-primary); outline: none; text-align: center; appearance: none; -webkit-appearance: none; -moz-appearance: textfield; }
|
||||
.progress-block {
|
||||
display: flex; flex-direction: column; gap: 7px;
|
||||
}
|
||||
.progress-block.clickable {
|
||||
cursor: pointer; border-radius: var(--radius-sm);
|
||||
padding: 4px 5px;
|
||||
margin: 0 -5px;
|
||||
transition: background var(--t-fast);
|
||||
}
|
||||
.progress-block.clickable:hover { background: var(--bg-overlay); }
|
||||
.progress-labels {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
}
|
||||
.progress-text {
|
||||
font-family: var(--font-ui); font-size: var(--text-2xs);
|
||||
color: var(--text-muted); letter-spacing: var(--tracking-wide);
|
||||
}
|
||||
.progress-pct {
|
||||
font-family: var(--font-ui); font-size: var(--text-2xs);
|
||||
color: var(--text-faint); letter-spacing: var(--tracking-wide);
|
||||
}
|
||||
.progress-track {
|
||||
height: 3px; background: var(--border-strong);
|
||||
border-radius: var(--radius-full); overflow: hidden;
|
||||
}
|
||||
.progress-fill {
|
||||
height: 100%; background: var(--accent);
|
||||
border-radius: var(--radius-full); transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.chapter-editor {
|
||||
display: flex; flex-direction: column; gap: var(--sp-2);
|
||||
padding: var(--sp-2); border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border-dim); background: var(--bg-surface);
|
||||
}
|
||||
.chapter-editor-top { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-2); }
|
||||
.chapter-editor-label { font-family: var(--font-ui); font-size: 10px; color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||
.chapter-input-wrap { display: flex; align-items: center; gap: var(--sp-1); }
|
||||
.chapter-input {
|
||||
width: 58px; background: var(--bg-surface);
|
||||
border: 1px solid var(--border-strong); border-radius: var(--radius-sm);
|
||||
padding: 3px 6px; font-family: var(--font-ui); font-size: var(--text-xs);
|
||||
color: var(--text-primary); outline: none; text-align: center;
|
||||
appearance: none; -moz-appearance: textfield;
|
||||
}
|
||||
.chapter-input:focus { border-color: var(--accent); }
|
||||
.chapter-input::-webkit-outer-spin-button,
|
||||
.chapter-input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
|
||||
.chapter-total { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||
.chapter-total { font-family: var(--font-ui); font-size: 10px; color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||
.chapter-slider { width: 100%; accent-color: var(--accent); cursor: pointer; height: 3px; }
|
||||
.chapter-editor-actions { display: flex; align-items: center; gap: var(--sp-2); justify-content: flex-end; }
|
||||
.chapter-save-btn { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 4px 12px; border-radius: var(--radius-sm); border: 1px solid var(--accent-dim); background: var(--accent-muted); color: var(--accent-fg); cursor: pointer; transition: filter var(--t-base); }
|
||||
.chapter-save-btn {
|
||||
font-family: var(--font-ui); font-size: 10px; letter-spacing: var(--tracking-wide);
|
||||
padding: 3px 10px; border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--accent-dim); background: var(--accent-muted);
|
||||
color: var(--accent-fg); cursor: pointer; transition: filter var(--t-base);
|
||||
}
|
||||
.chapter-save-btn:hover { filter: brightness(1.15); }
|
||||
.chapter-cancel-btn { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 4px 8px; border-radius: var(--radius-sm); border: none; background: none; color: var(--text-faint); cursor: pointer; transition: color var(--t-base); }
|
||||
.chapter-cancel-btn {
|
||||
font-family: var(--font-ui); font-size: 10px; letter-spacing: var(--tracking-wide);
|
||||
padding: 3px 6px; border-radius: var(--radius-sm);
|
||||
border: none; background: none; color: var(--text-faint);
|
||||
cursor: pointer; transition: color var(--t-base);
|
||||
}
|
||||
.chapter-cancel-btn:hover { color: var(--text-muted); }
|
||||
|
||||
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
|
||||
.modal-backdrop {
|
||||
position: fixed; inset: 0; z-index: 200;
|
||||
background: rgba(0,0,0,0.55);
|
||||
backdrop-filter: blur(4px);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
animation: fadeIn 0.12s ease both;
|
||||
}
|
||||
.modal {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-dim);
|
||||
border-radius: var(--radius-xl, 14px);
|
||||
padding: var(--sp-6, 24px);
|
||||
width: 320px; max-width: calc(100vw - 32px);
|
||||
display: flex; flex-direction: column; align-items: center; gap: var(--sp-3);
|
||||
box-shadow: 0 24px 60px rgba(0,0,0,0.5);
|
||||
animation: modalIn 0.18s cubic-bezier(0.34,1.56,0.64,1) both;
|
||||
}
|
||||
.modal-icon {
|
||||
width: 40px; height: 40px; border-radius: 50%;
|
||||
background: var(--color-error-bg, rgba(200,50,50,0.12));
|
||||
border: 1px solid var(--color-error-dim, rgba(200,50,50,0.25));
|
||||
color: var(--color-error, #e05252);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.modal-title {
|
||||
font-size: var(--text-sm); font-weight: var(--weight-medium);
|
||||
color: var(--text-primary); text-align: center; margin: 0;
|
||||
}
|
||||
.modal-body {
|
||||
font-family: var(--font-ui); font-size: var(--text-xs);
|
||||
color: var(--text-muted); text-align: center; line-height: 1.5;
|
||||
margin: 0;
|
||||
}
|
||||
.modal-body strong { color: var(--text-secondary); font-weight: var(--weight-medium); }
|
||||
.modal-actions {
|
||||
display: flex; gap: var(--sp-2); width: 100%; margin-top: var(--sp-1);
|
||||
}
|
||||
.modal-cancel {
|
||||
flex: 1;
|
||||
font-family: var(--font-ui); font-size: var(--text-xs);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
padding: 8px 0; border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border-dim); background: none;
|
||||
color: var(--text-muted); cursor: pointer;
|
||||
transition: border-color var(--t-base), color var(--t-base);
|
||||
}
|
||||
.modal-cancel:hover { border-color: var(--border-strong); color: var(--text-primary); }
|
||||
.modal-confirm {
|
||||
flex: 1;
|
||||
font-family: var(--font-ui); font-size: var(--text-xs);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
padding: 8px 0; border-radius: var(--radius-md);
|
||||
border: 1px solid var(--color-error-dim, rgba(200,50,50,0.3));
|
||||
background: var(--color-error-bg, rgba(200,50,50,0.1));
|
||||
color: var(--color-error, #e05252); cursor: pointer;
|
||||
transition: filter var(--t-base), background var(--t-base);
|
||||
}
|
||||
.modal-confirm:hover { filter: brightness(1.2); background: var(--color-error-bg, rgba(200,50,50,0.18)); }
|
||||
|
||||
@keyframes modalIn {
|
||||
from { opacity: 0; transform: scale(0.92) translateY(8px); }
|
||||
to { opacity: 1; transform: none; }
|
||||
}
|
||||
|
||||
@keyframes fadeIn { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } }
|
||||
</style>
|
||||
|
||||
<script module>
|
||||
|
||||
@@ -6,9 +6,12 @@
|
||||
CircleNotch, MagnifyingGlassMinus, MagnifyingGlassPlus,
|
||||
Bookmark, BookOpen, MonitorPlay, MapPin, Check,
|
||||
} from "phosphor-svelte";
|
||||
import { gql, thumbUrl } from "../../lib/client";
|
||||
import { FETCH_CHAPTER_PAGES, MARK_CHAPTER_READ, ENQUEUE_DOWNLOAD, ENQUEUE_CHAPTERS_DOWNLOAD } from "../../lib/queries";
|
||||
import { gql, thumbUrl, plainThumbUrl } from "../../lib/client";
|
||||
import { getBlobUrl, preloadBlobUrls } from "../../lib/imageCache";
|
||||
import { store as appStore } from "../../store/state.svelte";
|
||||
import { FETCH_CHAPTER_PAGES, MARK_CHAPTER_READ, ENQUEUE_DOWNLOAD, ENQUEUE_CHAPTERS_DOWNLOAD, DELETE_DOWNLOADED_CHAPTERS } from "../../lib/queries";
|
||||
import { store, closeReader, openReader, addHistory, updateSettings, checkAndMarkCompleted, setSettingsOpen, addBookmark, removeBookmark, addMarker, removeMarker, updateMarker } from "../../store/state.svelte";
|
||||
import { DEFAULT_MANGA_PREFS } from "../../store/state.svelte";
|
||||
import { matchesKeybind, toggleFullscreen, DEFAULT_KEYBINDS } from "../../lib/keybinds";
|
||||
import { setReading } from "../../lib/discord";
|
||||
import type { FitMode, MarkerColor } from "../../store/state.svelte";
|
||||
@@ -34,20 +37,32 @@
|
||||
const pageCache = new Map<number, string[]>();
|
||||
const inflight = new Map<number, Promise<string[]>>();
|
||||
|
||||
function fetchPages(chapterId: number, signal?: AbortSignal): Promise<string[]> {
|
||||
const useBlob = $derived((appStore.settings.serverAuthMode ?? "NONE") === "BASIC_AUTH");
|
||||
|
||||
function resolveUrl(url: string, priority = 0): Promise<string> {
|
||||
return useBlob ? getBlobUrl(url, priority) : Promise.resolve(url);
|
||||
}
|
||||
|
||||
function fetchPages(chapterId: number, signal?: AbortSignal, priorityPage = 0): Promise<string[]> {
|
||||
const cached = pageCache.get(chapterId);
|
||||
if (cached) return Promise.resolve(cached);
|
||||
if (signal?.aborted) return Promise.reject(new DOMException("Aborted", "AbortError"));
|
||||
|
||||
if (!inflight.has(chapterId)) {
|
||||
const p = gql<{ fetchChapterPages: { pages: string[] } }>(FETCH_CHAPTER_PAGES, { chapterId })
|
||||
.then(d => {
|
||||
const urls = d.fetchChapterPages.pages.map(thumbUrl);
|
||||
const urls = d.fetchChapterPages.pages.map(p => plainThumbUrl(p));
|
||||
if (useBlob) {
|
||||
if (urls[priorityPage]) getBlobUrl(urls[priorityPage], urls.length + 999);
|
||||
preloadBlobUrls(urls.filter((_, i) => i !== priorityPage), urls.length);
|
||||
}
|
||||
pageCache.set(chapterId, urls);
|
||||
return urls;
|
||||
})
|
||||
.finally(() => inflight.delete(chapterId));
|
||||
inflight.set(chapterId, p);
|
||||
}
|
||||
|
||||
const base = inflight.get(chapterId)!;
|
||||
if (!signal) return base;
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -57,20 +72,19 @@
|
||||
}
|
||||
|
||||
const aspectCache = new Map<string, number>();
|
||||
function preloadImage(url: string) { new Image().src = url; }
|
||||
|
||||
function measureAspect(url: string): Promise<number> {
|
||||
if (aspectCache.has(url)) return Promise.resolve(aspectCache.get(url)!);
|
||||
return new Promise(res => {
|
||||
return resolveUrl(url).then(src => new Promise(res => {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const r = img.naturalHeight > 0 ? img.naturalWidth / img.naturalHeight : 0.67;
|
||||
aspectCache.set(url, r);
|
||||
res(r);
|
||||
};
|
||||
img.onload = () => { const r = img.naturalHeight > 0 ? img.naturalWidth / img.naturalHeight : 0.67; aspectCache.set(url, r); res(r); };
|
||||
img.onerror = () => res(0.67);
|
||||
img.src = url;
|
||||
});
|
||||
img.src = src;
|
||||
}));
|
||||
}
|
||||
|
||||
function preloadImage(url: string) {
|
||||
resolveUrl(url).then(src => { new Image().src = src; }).catch(() => {});
|
||||
}
|
||||
|
||||
interface StripChapter { chapterId: number; chapterName: string; urls: string[]; }
|
||||
@@ -275,12 +289,13 @@
|
||||
|
||||
store.pageNumber = 1;
|
||||
try {
|
||||
const urls = await fetchPages(id, ctrl.signal);
|
||||
const urls = await fetchPages(id, ctrl.signal, resumeTo > 1 ? resumeTo - 1 : 0);
|
||||
if (ctrl.signal.aborted) return;
|
||||
store.pageUrls = urls;
|
||||
if (resumeTo > 1) store.pageNumber = Math.min(resumeTo, urls.length || resumeTo);
|
||||
pageReady = true;
|
||||
loading = false;
|
||||
if (adjacent.next) fetchPages(adjacent.next.id).catch(() => {});
|
||||
} catch (e: any) {
|
||||
if (ctrl.signal.aborted) return;
|
||||
error = e instanceof Error ? e.message : String(e);
|
||||
@@ -323,7 +338,24 @@
|
||||
if (!chId || style !== "longstrip") return;
|
||||
if (chId === store.activeChapter?.id) return;
|
||||
const wasAppended = untrack(() => stripChapters.findIndex(c => c.chapterId === chId)) > 0;
|
||||
if (wasAppended) { untrack(() => { resumePage = 0; resumeVisible = false; }); return; }
|
||||
if (wasAppended) {
|
||||
untrack(() => {
|
||||
resumePage = 0; resumeVisible = false;
|
||||
const prefs = getMangaPrefs();
|
||||
if (prefs.downloadAhead > 0) {
|
||||
const list = store.activeChapterList;
|
||||
const idx = list.findIndex(c => c.id === chId);
|
||||
if (idx >= 0) {
|
||||
const toQueue = list
|
||||
.slice(idx + 1, idx + 1 + prefs.downloadAhead)
|
||||
.filter(c => !c.isDownloaded && !c.isRead)
|
||||
.map(c => c.id);
|
||||
if (toQueue.length) gql(ENQUEUE_CHAPTERS_DOWNLOAD, { chapterIds: toQueue }).catch(console.error);
|
||||
}
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
const bookmark = store.bookmarks.find(b => b.chapterId === chId);
|
||||
if (bookmark && bookmark.pageNumber > 1) {
|
||||
untrack(() => {
|
||||
@@ -450,9 +482,22 @@
|
||||
|
||||
$effect(() => {
|
||||
const ahead = store.settings.preloadPages ?? 3;
|
||||
for (let i = 1; i <= ahead; i++) { const url = store.pageUrls[store.pageNumber - 1 + i]; if (url) preloadImage(url); }
|
||||
const current = store.pageUrls[store.pageNumber - 1];
|
||||
if (!current) return;
|
||||
if (useBlob) {
|
||||
getBlobUrl(current, 999);
|
||||
const upcoming = Array.from({ length: ahead }, (_, i) => store.pageUrls[store.pageNumber + i]).filter(Boolean) as string[];
|
||||
const behind = store.pageUrls[store.pageNumber - 2];
|
||||
preloadBlobUrls(upcoming, ahead);
|
||||
if (behind) preloadBlobUrls([behind], 0);
|
||||
} else {
|
||||
for (let i = 1; i <= ahead; i++) {
|
||||
const url = store.pageUrls[store.pageNumber - 1 + i];
|
||||
if (url) preloadImage(url);
|
||||
}
|
||||
const behind = store.pageUrls[store.pageNumber - 2];
|
||||
if (behind) preloadImage(behind);
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
@@ -479,6 +524,12 @@
|
||||
}
|
||||
});
|
||||
|
||||
function getMangaPrefs() {
|
||||
const mangaId = store.activeManga?.id;
|
||||
if (!mangaId) return DEFAULT_MANGA_PREFS;
|
||||
return { ...DEFAULT_MANGA_PREFS, ...(appStore.settings.mangaPrefs?.[mangaId] ?? {}) };
|
||||
}
|
||||
|
||||
function markChapterRead(id: number) {
|
||||
if (markedRead.has(id)) return;
|
||||
markedRead.add(id);
|
||||
@@ -493,9 +544,42 @@
|
||||
}
|
||||
gql(MARK_CHAPTER_READ, { id, isRead: true })
|
||||
.then(() => {
|
||||
if (store.activeManga) {
|
||||
const mangaId = store.activeManga?.id;
|
||||
if (mangaId) {
|
||||
const updated = store.activeChapterList.map(c => c.id === id ? { ...c, isRead: true } : c);
|
||||
checkAndMarkCompleted(store.activeManga.id, updated);
|
||||
checkAndMarkCompleted(mangaId, updated);
|
||||
|
||||
const prefs = getMangaPrefs();
|
||||
|
||||
if (prefs.deleteOnRead) {
|
||||
const ch = store.activeChapterList.find(c => c.id === id);
|
||||
if (ch?.isDownloaded) {
|
||||
const delayMs = (prefs.deleteDelayHours ?? 0) * 60 * 60 * 1000;
|
||||
const doDelete = () => gql(DELETE_DOWNLOADED_CHAPTERS, { ids: [id] }).catch(console.error);
|
||||
if (delayMs === 0) doDelete();
|
||||
else setTimeout(doDelete, delayMs);
|
||||
}
|
||||
}
|
||||
|
||||
if (prefs.downloadAhead > 0) {
|
||||
const list = store.activeChapterList;
|
||||
const idx = list.findIndex(c => c.id === id);
|
||||
if (idx >= 0) {
|
||||
const toQueue = list
|
||||
.slice(idx + 1, idx + 1 + prefs.downloadAhead)
|
||||
.filter(c => !c.isDownloaded && !c.isRead)
|
||||
.map(c => c.id);
|
||||
if (toQueue.length) gql(ENQUEUE_CHAPTERS_DOWNLOAD, { chapterIds: toQueue }).catch(console.error);
|
||||
}
|
||||
}
|
||||
|
||||
if (prefs.maxKeepChapters > 0) {
|
||||
const downloaded = store.activeChapterList
|
||||
.filter(c => c.isDownloaded)
|
||||
.sort((a, b) => a.sourceOrder - b.sourceOrder);
|
||||
const excess = downloaded.slice(0, Math.max(0, downloaded.length - prefs.maxKeepChapters));
|
||||
if (excess.length) gql(DELETE_DOWNLOADED_CHAPTERS, { ids: excess.map(c => c.id) }).catch(console.error);
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(e => { markedRead.delete(id); console.error(e); });
|
||||
@@ -948,39 +1032,31 @@
|
||||
{#if style === "longstrip"}
|
||||
{#each stripToRender as chunk}
|
||||
{#each chunk.urls as url, i}
|
||||
<img
|
||||
src={url}
|
||||
alt="{chunk.chapterName} – Page {i + 1}"
|
||||
data-local-page={i + 1}
|
||||
data-chapter={chunk.chapterId}
|
||||
data-total={chunk.urls.length}
|
||||
class="{imgCls}{store.settings.pageGap ? ' strip-gap' : ''}"
|
||||
loading={i < 5 ? "eager" : "lazy"}
|
||||
decoding="async"
|
||||
/>
|
||||
{#await resolveUrl(url, chunk.urls.length - i)}
|
||||
<img src="" alt="{chunk.chapterName} – Page {i + 1}" data-local-page={i + 1} data-chapter={chunk.chapterId} data-total={chunk.urls.length} class="{imgCls}{store.settings.pageGap ? ' strip-gap' : ''}" loading={i < 5 ? "eager" : "lazy"} decoding="async" />
|
||||
{:then src}
|
||||
<img {src} alt="{chunk.chapterName} – Page {i + 1}" data-local-page={i + 1} data-chapter={chunk.chapterId} data-total={chunk.urls.length} class="{imgCls}{store.settings.pageGap ? ' strip-gap' : ''}" loading={i < 5 ? "eager" : "lazy"} decoding="async" />
|
||||
{/await}
|
||||
{/each}
|
||||
{/each}
|
||||
<div style="height:1px;flex-shrink:0"></div>
|
||||
|
||||
{:else if style === "fade" && pageReady}
|
||||
<img
|
||||
src={store.pageUrls[store.pageNumber - 1]}
|
||||
alt="Page {store.pageNumber}"
|
||||
class={imgCls}
|
||||
decoding="async"
|
||||
style="opacity: {fadingOut ? 0 : 1}; transition: opacity 0.1s ease;"
|
||||
/>
|
||||
{#await resolveUrl(store.pageUrls[store.pageNumber - 1], 999)}
|
||||
<img src="" alt="Page {store.pageNumber}" class={imgCls} decoding="async" style="opacity:0" />
|
||||
{:then src}
|
||||
<img {src} alt="Page {store.pageNumber}" class={imgCls} decoding="async" style="opacity: {fadingOut ? 0 : 1}; transition: opacity 0.1s ease;" />
|
||||
{/await}
|
||||
|
||||
{:else if style === "double" && pageReady}
|
||||
{#if pageGroups.length}
|
||||
<div class="double-wrap">
|
||||
{#each currentGroup as pg, i}
|
||||
<img
|
||||
src={store.pageUrls[pg - 1]}
|
||||
alt="Page {pg}"
|
||||
class="{imgCls} page-half {i === 0 ? 'gap-left' : 'gap-right'}"
|
||||
decoding="async"
|
||||
/>
|
||||
{#await resolveUrl(store.pageUrls[pg - 1], 999)}
|
||||
<img src="" alt="Page {pg}" class="{imgCls} page-half {i === 0 ? 'gap-left' : 'gap-right'}" decoding="async" />
|
||||
{:then src}
|
||||
<img {src} alt="Page {pg}" class="{imgCls} page-half {i === 0 ? 'gap-left' : 'gap-right'}" decoding="async" />
|
||||
{/await}
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
@@ -988,7 +1064,11 @@
|
||||
{/if}
|
||||
|
||||
{:else if pageReady}
|
||||
<img src={store.pageUrls[store.pageNumber - 1]} alt="Page {store.pageNumber}" class={imgCls} decoding="async" />
|
||||
{#await resolveUrl(store.pageUrls[store.pageNumber - 1], 999)}
|
||||
<img src="" alt="Page {store.pageNumber}" class={imgCls} decoding="async" />
|
||||
{:then src}
|
||||
<img {src} alt="Page {store.pageNumber}" class={imgCls} decoding="async" />
|
||||
{/await}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1137,13 +1217,6 @@
|
||||
.marker-popover { position: absolute; top: calc(100% + 8px); right: 0; width: 240px; background: var(--bg-surface); border: 1px solid var(--border-base); border-radius: var(--radius-lg); padding: var(--sp-3); display: flex; flex-direction: column; gap: var(--sp-3); box-shadow: 0 12px 32px rgba(0,0,0,0.6), 0 2px 8px rgba(0,0,0,0.4); z-index: 100; animation: scaleIn 0.1s ease both; transform-origin: top right; }
|
||||
.marker-pop-header { display: flex; align-items: center; justify-content: space-between; }
|
||||
.marker-pop-title { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; }
|
||||
.marker-delete-btn { display: flex; align-items: center; justify-content: center; width: 20px; height: 20px; border-radius: var(--radius-sm); color: var(--color-error); opacity: 0.55; transition: opacity var(--t-fast), background var(--t-fast); }
|
||||
.marker-delete-btn:hover { opacity: 1; background: var(--color-error-bg); }
|
||||
|
||||
.marker-color-row { display: flex; gap: 5px; }
|
||||
.marker-swatch { display: flex; flex-direction: column; align-items: center; gap: 4px; flex: 1; padding: 6px 4px 5px; border-radius: var(--radius-md); border: 1px solid transparent; background: none; cursor: pointer; transition: background var(--t-fast), border-color var(--t-fast); }
|
||||
.marker-swatch:hover { background: var(--bg-raised); }
|
||||
.marker-swatch-active { background: var(--bg-overlay); border-color: var(--border-strong); }
|
||||
.swatch-dot { width: 14px; height: 14px; border-radius: 50%; background: var(--swatch); box-shadow: 0 0 0 0 var(--swatch); transition: box-shadow var(--t-fast), transform var(--t-fast); flex-shrink: 0; }
|
||||
.marker-swatch:hover .swatch-dot { transform: scale(1.15); }
|
||||
.marker-swatch-active .swatch-dot { box-shadow: 0 0 0 3px color-mix(in srgb, var(--swatch) 30%, transparent); transform: scale(1.1); }
|
||||
|
||||
+3
-41
@@ -3,11 +3,8 @@
|
||||
import { store, updateSettings } from "../../store/state.svelte";
|
||||
import { DEFAULT_MANGA_PREFS } from "../../store/state.svelte";
|
||||
import type { MangaPrefs } from "../../store/state.svelte";
|
||||
import type { Chapter } from "../../lib/types";
|
||||
|
||||
let { mangaId, chapters, onClose }: {
|
||||
let { mangaId, onClose }: {
|
||||
mangaId: number;
|
||||
chapters: Chapter[];
|
||||
onClose: () => void;
|
||||
} = $props();
|
||||
|
||||
@@ -28,12 +25,7 @@
|
||||
});
|
||||
}
|
||||
|
||||
const scanlators = $derived(
|
||||
[...new Set(chapters.map(c => c.scanlator).filter((s): s is string => !!s?.trim()))]
|
||||
.sort((a, b) => a.localeCompare(b))
|
||||
);
|
||||
|
||||
const DOWNLOAD_AHEAD_OPTIONS = [
|
||||
const DOWNLOAD_AHEAD_OPTIONS = [
|
||||
{ value: 0, label: "Off" },
|
||||
{ value: 2, label: "2" },
|
||||
{ value: 5, label: "5" },
|
||||
@@ -196,33 +188,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if scanlators.length > 1}
|
||||
<div class="divider"></div>
|
||||
|
||||
<p class="section-label">Scanlator</p>
|
||||
|
||||
<div class="auto-row auto-row-align-start">
|
||||
<div class="auto-info">
|
||||
<span class="auto-label">Preferred scanlator</span>
|
||||
<span class="auto-desc">Prioritise this group's chapters in the list</span>
|
||||
</div>
|
||||
<div class="scanlator-list">
|
||||
<button
|
||||
class="auto-chip scanlator-chip"
|
||||
class:auto-chip-on={!getPref("preferredScanlator")}
|
||||
onclick={() => setPref("preferredScanlator", "")}
|
||||
>Any</button>
|
||||
{#each scanlators as s}
|
||||
<button
|
||||
class="auto-chip scanlator-chip"
|
||||
class:auto-chip-on={getPref("preferredScanlator") === s}
|
||||
onclick={() => setPref("preferredScanlator", getPref("preferredScanlator") === s ? "" : s)}
|
||||
title={s}
|
||||
>{s}</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
@@ -300,10 +266,6 @@
|
||||
.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); }
|
||||
|
||||
/* Scanlator list */
|
||||
.scanlator-list { display: flex; flex-direction: row; gap: 4px; flex-wrap: wrap; justify-content: flex-end; max-width: 220px; }
|
||||
.scanlator-chip { max-width: 160px; overflow: hidden; text-overflow: ellipsis; }
|
||||
|
||||
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
|
||||
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
|
||||
@keyframes scaleIn { from { opacity: 0; transform: scale(0.97) } to { opacity: 1; transform: scale(1) } }
|
||||
</style>
|
||||
+11
-12
@@ -1,7 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { X, MagnifyingGlass, CircleNotch, ArrowRight, Check, Warning, Sparkle } from "phosphor-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 { store } from "../../store/state.svelte";
|
||||
import type { Manga, Source, Chapter } from "../../lib/types";
|
||||
@@ -253,8 +254,7 @@
|
||||
class="source-row"
|
||||
class:source-row-active={selectedSource?.id === src.id}
|
||||
onclick={() => pickSource(src)}>
|
||||
<img src={thumbUrl(src.iconUrl)} alt={src.name} class="source-icon"
|
||||
onerror={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
|
||||
<Thumbnail src={src.iconUrl} alt={src.name} class="source-icon" onerror={(e) => (e.target as HTMLImageElement).style.display = "none"} />
|
||||
<div class="source-info">
|
||||
<span class="source-name">{src.displayName}</span>
|
||||
<span class="source-meta">{src.lang.toUpperCase()}{src.isNsfw ? " · NSFW" : ""}</span>
|
||||
@@ -272,8 +272,7 @@
|
||||
<!-- Source context pill -->
|
||||
{#if selectedSource}
|
||||
<div class="search-context">
|
||||
<img src={thumbUrl(selectedSource.iconUrl)} alt="" class="search-context-icon"
|
||||
onerror={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
|
||||
<Thumbnail src={selectedSource.iconUrl} alt="" class="search-context-icon" onerror={(e) => (e.target as HTMLImageElement).style.display = "none"} />
|
||||
<span class="search-context-name">{selectedSource.displayName}</span>
|
||||
<button class="search-context-change" onclick={() => { step = "source"; results = []; }}>Change</button>
|
||||
</div>
|
||||
@@ -316,7 +315,7 @@
|
||||
onclick={() => selectMatch(m, similarity)}
|
||||
disabled={loadingMatchId !== null}>
|
||||
<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 class="result-info">
|
||||
<span class="result-title">{m.title}</span>
|
||||
@@ -350,7 +349,7 @@
|
||||
<div class="confirm-row">
|
||||
<div class="confirm-manga">
|
||||
<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>
|
||||
<p class="confirm-title">{manga.title}</p>
|
||||
<p class="confirm-source">{manga.source?.displayName ?? "Unknown"}</p>
|
||||
@@ -363,7 +362,7 @@
|
||||
|
||||
<div class="confirm-manga">
|
||||
<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>
|
||||
<p class="confirm-title">{selectedMatch.manga.title}</p>
|
||||
<p class="confirm-source">{selectedSource?.displayName ?? "Unknown"}</p>
|
||||
@@ -455,7 +454,7 @@
|
||||
.source-row { display: flex; align-items: center; gap: var(--sp-3); padding: 9px var(--sp-3); border-radius: var(--radius-md); border: 1px solid transparent; background: none; text-align: left; width: 100%; cursor: pointer; transition: background var(--t-fast), border-color var(--t-fast); }
|
||||
.source-row:hover { background: var(--bg-raised); border-color: var(--border-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-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); }
|
||||
@@ -477,7 +476,7 @@
|
||||
/* 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-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-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; }
|
||||
@@ -495,7 +494,7 @@
|
||||
.result-row:hover:not(:disabled) { background: var(--bg-raised); }
|
||||
.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 { 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-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); }
|
||||
@@ -515,7 +514,7 @@
|
||||
.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-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-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; }
|
||||
+140
-20
@@ -1,7 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { onMount, untrack } from "svelte";
|
||||
import { ArrowLeft, BookmarkSimple, Download, CheckCircle, Circle, ArrowSquareOut, CircleNotch, Play, SortAscending, SortDescending, CaretDown, ArrowsClockwise, List, SquaresFour, FolderSimplePlus, Trash, DownloadSimple, X, LinkSimpleHorizontalBreak, ChartLineUp, MagnifyingGlass, Gear, Eye, MapPin } from "phosphor-svelte";
|
||||
import { gql, thumbUrl } from "../../lib/client";
|
||||
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 } 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 { cache, CACHE_KEYS, recordSourceAccess } from "../../lib/cache";
|
||||
import { dedupeMangaById, dedupeMangaByTitle } from "../../lib/util";
|
||||
@@ -11,9 +12,9 @@
|
||||
import type { Manga, Chapter, Category } from "../../lib/types";
|
||||
import ContextMenu, { type MenuEntry } from "../shared/ContextMenu.svelte";
|
||||
import MigrateModal from "./MigrateModal.svelte";
|
||||
import TrackingPanel from "../shared/TrackingPanel.svelte";
|
||||
import AutomationPanel from "../shared/AutomationPanel.svelte";
|
||||
import MarkersPanel from "../shared/MarkersPanel.svelte";
|
||||
import TrackingPanel from "./TrackingPanel.svelte";
|
||||
import AutomationPanel from "./AutomationPanel.svelte";
|
||||
import MarkersPanel from "./MarkersPanel.svelte";
|
||||
|
||||
const CHAPTERS_PER_PAGE = 25;
|
||||
const MANGA_TTL_MS = 5 * 60 * 1000;
|
||||
@@ -57,6 +58,7 @@
|
||||
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;
|
||||
@@ -75,16 +77,61 @@
|
||||
return (mangaPrefs[key] ?? DEFAULT_MANGA_PREFS[key]) as MangaPrefs[K];
|
||||
}
|
||||
|
||||
function setPref<K extends keyof MangaPrefs>(key: K, value: MangaPrefs[K]) {
|
||||
const id = store.activeManga?.id;
|
||||
if (!id) return;
|
||||
updateSettings({
|
||||
mangaPrefs: {
|
||||
...store.settings.mangaPrefs,
|
||||
[id]: { ...(store.settings.mangaPrefs?.[id] ?? {}), [key]: value },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const hasSelection = $derived(selectedIds.size > 0);
|
||||
|
||||
const 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(() => {
|
||||
const base = [...chapters];
|
||||
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;
|
||||
});
|
||||
|
||||
@@ -100,8 +147,8 @@
|
||||
const hasFolders = $derived(assignedFolders.length > 0);
|
||||
|
||||
const continueChapter = $derived((() => {
|
||||
if (!chapters.length) return null;
|
||||
const asc = [...chapters].sort((a, b) => a.sourceOrder - b.sourceOrder);
|
||||
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 };
|
||||
@@ -284,6 +331,15 @@
|
||||
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() {
|
||||
if (!manga) return;
|
||||
togglingLibrary = true;
|
||||
@@ -321,7 +377,8 @@
|
||||
await gql(MARK_CHAPTER_READ, { id: chapterId, isRead }).catch(console.error);
|
||||
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 (isRead && getPref("deleteOnRead")) {
|
||||
if (isRead) {
|
||||
if (getPref("deleteOnRead")) {
|
||||
const ch = chapters.find(c => c.id === chapterId);
|
||||
if (ch?.isDownloaded) {
|
||||
const delayMs = getPref("deleteDelayHours") * 60 * 60 * 1000;
|
||||
@@ -329,6 +386,15 @@
|
||||
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) {
|
||||
@@ -505,7 +571,7 @@
|
||||
</button>
|
||||
|
||||
<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>
|
||||
|
||||
{#if loadingManga}
|
||||
@@ -542,7 +608,7 @@
|
||||
|
||||
<div class="cta-section">
|
||||
{#if continueChapter}
|
||||
<button class="read-btn" onclick={() => openReaderWithAhead(continueChapter!.chapter, chaptersAsc)}>
|
||||
<button class="read-btn" onclick={() => openReaderWithAhead(continueChapter!.chapter, sortedChapters)}>
|
||||
<Play size={12} weight="fill" />
|
||||
{continueChapter.type === "continue"
|
||||
? `Continue · Ch.${continueChapter.chapter.chapterNumber}${(continueChapter.chapter.lastPageRead ?? 0) > 0 ? ` p.${continueChapter.chapter.lastPageRead}` : ""}`
|
||||
@@ -647,6 +713,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<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>
|
||||
@@ -670,6 +737,40 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if availableScanlators.length > 1}
|
||||
<div class="scan-filter-wrap">
|
||||
<button class="icon-btn" class:active={scanlatorFilter.length > 0} onclick={() => scanFilterOpen = !scanFilterOpen} title="Filter by scanlator">
|
||||
<Funnel size={14} weight={scanlatorFilter.length > 0 ? "fill" : "light"} />
|
||||
</button>
|
||||
{#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}>
|
||||
<ArrowsClockwise size={14} weight="light" class={refreshing ? "anim-spin" : ""} />
|
||||
</button>
|
||||
@@ -787,7 +888,7 @@
|
||||
{@const inProgress = !ch.isRead && (ch.lastPageRead ?? 0) > 0}
|
||||
{@const isGridSelected = selectedIds.has(ch.id)}
|
||||
<button class="grid-cell" class:read={ch.isRead} class:in-progress={inProgress} class:grid-selected={isGridSelected}
|
||||
onclick={(e) => hasSelection ? toggleSelect(ch.id, e) : openReaderWithAhead(ch, chaptersAsc)}
|
||||
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 }; }}
|
||||
title={ch.name}>
|
||||
<span class="grid-cell-num">{ch.chapterNumber % 1 === 0 ? ch.chapterNumber.toFixed(0) : ch.chapterNumber}</span>
|
||||
@@ -801,8 +902,8 @@
|
||||
{@const idxInSorted = sortedChapters.indexOf(ch)}
|
||||
{@const isSelected = selectedIds.has(ch.id)}
|
||||
<div role="button" tabindex="0" class="ch-row" class:read={ch.isRead} class:ch-selected={isSelected}
|
||||
onclick={(e) => hasSelection ? toggleSelect(ch.id, e) : openReaderWithAhead(ch, chaptersAsc)}
|
||||
onkeydown={(e) => e.key === "Enter" && (hasSelection ? toggleSelect(ch.id, e) : openReaderWithAhead(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 }; }}>
|
||||
<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}
|
||||
@@ -818,8 +919,10 @@
|
||||
<div class="ch-right">
|
||||
{#if ch.isRead}<CheckCircle size={14} weight="light" class="read-icon" />{/if}
|
||||
{#if ch.isDownloaded}
|
||||
<span class="ch-dl-dot" title="Downloaded"></span>
|
||||
<div class="ch-dl-wrap">
|
||||
<Download size={13} weight="fill" class="ch-dl-icon" />
|
||||
<button class="dl-btn dl-btn-delete" onclick={(e) => { e.stopPropagation(); deleteDownloaded(ch.id); }} title="Delete download"><Trash size={13} weight="light" /></button>
|
||||
</div>
|
||||
{:else if enqueueing.has(ch.id)}
|
||||
<CircleNotch size={14} weight="light" class="anim-spin enqueue-icon" />
|
||||
{:else}
|
||||
@@ -892,7 +995,7 @@
|
||||
{#each linkPickerResults as m (m.id)}
|
||||
{@const isLinked = linkedIds.includes(m.id)}
|
||||
<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">
|
||||
<span class="link-manga-title">{m.title}</span>
|
||||
{#if m.source?.displayName}<span class="link-source">{m.source.displayName}</span>{/if}
|
||||
@@ -916,7 +1019,7 @@
|
||||
.back:hover { color: var(--text-secondary); }
|
||||
|
||||
.cover-wrap { width: 100%; aspect-ratio: 2/3; border-radius: var(--radius-md); overflow: hidden; background: var(--bg-raised); border: 1px solid var(--border-dim); flex-shrink: 0; }
|
||||
.cover { width: 100%; height: 100%; object-fit: cover; }
|
||||
:global(.cover) { width: 100%; height: 100%; object-fit: cover; }
|
||||
|
||||
.meta-skeleton { display: flex; flex-direction: column; gap: var(--sp-2); }
|
||||
.sk-line { border-radius: var(--radius-sm); }
|
||||
@@ -980,7 +1083,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:hover { background: var(--bg-raised); }
|
||||
.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-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); }
|
||||
@@ -1110,8 +1213,11 @@
|
||||
.ch-row:hover .dl-btn-delete { opacity: 1; }
|
||||
.dl-btn-delete:hover { background: var(--color-error-bg) !important; }
|
||||
|
||||
.ch-dl-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--accent-fg); flex-shrink: 0; opacity: 0.7; transition: opacity var(--t-fast); }
|
||||
.ch-row:hover .ch-dl-dot { opacity: 0; }
|
||||
.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); }
|
||||
@@ -1122,4 +1228,18 @@
|
||||
.markers-panel-overlay { position: fixed; inset: 0; z-index: var(--z-settings); display: flex; align-items: stretch; justify-content: flex-start; animation: fadeIn 0.12s ease both; }
|
||||
.markers-panel-drawer { width: 280px; max-width: 90vw; background: var(--bg-surface); border-right: 1px solid var(--border-base); box-shadow: 4px 0 24px rgba(0,0,0,0.4); display: flex; flex-direction: column; animation: drawerIn 0.18s cubic-bezier(0.16,1,0.3,1) both; }
|
||||
@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>
|
||||
+94
-105
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
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 {
|
||||
GET_TRACKERS,
|
||||
GET_MANGA_TRACK_RECORDS,
|
||||
@@ -260,7 +261,7 @@
|
||||
class:tab-active={activeTab === t.id}
|
||||
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}
|
||||
{#if rec}<span class="tab-dot"></span>{/if}
|
||||
</button>
|
||||
@@ -278,25 +279,50 @@
|
||||
{#each records as record (record.id)}
|
||||
{@const tracker = trackerFor(record.trackerId)}
|
||||
{@const isBusy = updatingRecord === record.id}
|
||||
<div class="record-row" class:record-busy={isBusy}>
|
||||
<div class="record-card" class:record-busy={isBusy}>
|
||||
|
||||
<div class="record-identity">
|
||||
<!-- Title row -->
|
||||
<div class="record-head">
|
||||
<div class="record-source">
|
||||
{#if tracker}
|
||||
<img src={thumbUrl(tracker.icon)} alt={tracker.name} class="record-tracker-icon" />
|
||||
<Thumbnail src={tracker.icon} alt={tracker.name} class="record-tracker-icon" />
|
||||
{/if}
|
||||
<span class="record-source-name">{tracker?.name ?? "Tracker"}</span>
|
||||
</div>
|
||||
<div class="record-head-actions">
|
||||
{#if tracker?.supportsPrivateTracking}
|
||||
<button
|
||||
class="record-icon-btn"
|
||||
class:icon-active={record.private}
|
||||
title={record.private ? "Private — click to make public" : "Public"}
|
||||
disabled={isBusy}
|
||||
onclick={() => togglePrivate(record)}
|
||||
>
|
||||
{#if record.private}<Lock size={11} weight="fill" />{:else}<LockOpen size={11} weight="light" />{/if}
|
||||
</button>
|
||||
{/if}
|
||||
<button class="record-icon-btn" title="Sync from tracker" disabled={syncing === record.id} onclick={() => syncRecord(record)}>
|
||||
<ArrowsClockwise size={11} weight="light" class={syncing === record.id ? "anim-spin" : ""} />
|
||||
</button>
|
||||
<button class="record-icon-btn icon-danger" title="Unlink" disabled={isBusy} onclick={() => unbind(record)}>
|
||||
<X size={11} weight="bold" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Linked title -->
|
||||
{#if record.remoteUrl}
|
||||
<a href={record.remoteUrl} target="_blank" rel="noreferrer" class="record-title">
|
||||
{record.title}
|
||||
<ArrowSquareOut size={10} weight="light" />
|
||||
{record.title} <ArrowSquareOut size={10} weight="light" />
|
||||
</a>
|
||||
{:else}
|
||||
<span class="record-title-plain">{record.title}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="record-controls">
|
||||
<!-- Status + score row -->
|
||||
<div class="record-selects">
|
||||
<select
|
||||
class="record-select"
|
||||
class="record-select record-select-status"
|
||||
value={record.status}
|
||||
disabled={isBusy}
|
||||
onchange={(e) => updateStatus(record, parseInt((e.target as HTMLSelectElement).value))}
|
||||
@@ -305,7 +331,6 @@
|
||||
<option value={s.value}>{s.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
|
||||
<select
|
||||
class="record-select record-select-score"
|
||||
value={record.displayScore}
|
||||
@@ -316,58 +341,19 @@
|
||||
<option value={s}>★ {s}</option>
|
||||
{/each}
|
||||
</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>
|
||||
|
||||
<!-- Chapter progress -->
|
||||
{#if editingChapter === record.id}
|
||||
<div class="chapter-editor">
|
||||
<div class="chapter-editor-top">
|
||||
<span class="chapter-editor-label">Chapter read</span>
|
||||
<div class="chapter-input-wrap">
|
||||
<input
|
||||
type="number"
|
||||
class="chapter-input"
|
||||
min="0"
|
||||
max={record.totalChapters > 0 ? record.totalChapters : undefined}
|
||||
step="0.5"
|
||||
bind:value={chapterDraft}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === "Enter") submitChapter(record);
|
||||
if (e.key === "Escape") cancelChapterEditor();
|
||||
}}
|
||||
type="number" class="chapter-input"
|
||||
min="0" max={record.totalChapters > 0 ? record.totalChapters : undefined}
|
||||
step="0.5" bind:value={chapterDraft}
|
||||
onkeydown={(e) => { if (e.key === "Enter") submitChapter(record); if (e.key === "Escape") cancelChapterEditor(); }}
|
||||
use:autoFocus
|
||||
/>
|
||||
{#if record.totalChapters > 0}
|
||||
@@ -376,40 +362,36 @@
|
||||
</div>
|
||||
</div>
|
||||
{#if record.totalChapters > 0}
|
||||
<input
|
||||
type="range"
|
||||
class="chapter-slider"
|
||||
min="0"
|
||||
max={record.totalChapters}
|
||||
step="1"
|
||||
bind:value={chapterDraft}
|
||||
/>
|
||||
<input type="range" class="chapter-slider" min="0" max={record.totalChapters} step="1" bind:value={chapterDraft} />
|
||||
{/if}
|
||||
<div class="chapter-editor-actions">
|
||||
<button class="chapter-save-btn" onclick={() => submitChapter(record)}>Save</button>
|
||||
<button class="chapter-cancel-btn" onclick={cancelChapterEditor}>Cancel</button>
|
||||
</div>
|
||||
</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>
|
||||
<button class="chapter-save-btn" onclick={() => submitChapter(record)}>Save</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="record-progress clickable" role="button" tabindex="0"
|
||||
onclick={() => openChapterEditor(record)}
|
||||
onkeydown={(e) => e.key === "Enter" && openChapterEditor(record)}
|
||||
title="Click to set chapter"
|
||||
title="Click to edit"
|
||||
>
|
||||
<div class="record-progress-header">
|
||||
<span class="record-progress-label">
|
||||
{record.lastChapterRead > 0 ? `Ch. ${record.lastChapterRead} read` : "Set chapter…"} <span class="edit-hint">✎</span>
|
||||
{#if record.totalChapters > 0}
|
||||
Ch. {record.lastChapterRead} / {record.totalChapters}
|
||||
{:else if record.lastChapterRead > 0}
|
||||
Ch. {record.lastChapterRead} read
|
||||
{:else}
|
||||
Set chapter…
|
||||
{/if}
|
||||
</span>
|
||||
<span 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>
|
||||
{/if}
|
||||
|
||||
@@ -540,60 +522,67 @@
|
||||
}
|
||||
.tab:hover { color: var(--text-muted); }
|
||||
.tab-active { color: var(--text-secondary); border-bottom-color: var(--accent); }
|
||||
.tab-icon { width: 13px; height: 13px; border-radius: 2px; object-fit: contain; }
|
||||
:global(.tab-icon) { width: 13px; height: 13px; border-radius: 2px; object-fit: contain; }
|
||||
.tab-badge { font-size: 10px; padding: 0 4px; border-radius: var(--radius-full); background: var(--bg-overlay); color: var(--text-faint); min-width: 16px; text-align: center; line-height: 16px; }
|
||||
.tab-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 */
|
||||
.tab-body { flex: 1; overflow-y: auto; padding: var(--sp-3) var(--sp-4); scrollbar-width: none; display: flex; flex-direction: column; gap: 2px; }
|
||||
.tab-body { flex: 1; overflow-y: auto; padding: var(--sp-3); scrollbar-width: none; display: flex; flex-direction: column; gap: var(--sp-2); }
|
||||
.tab-body::-webkit-scrollbar { display: none; }
|
||||
|
||||
.record-row {
|
||||
.record-card {
|
||||
display: flex; flex-direction: column; gap: var(--sp-3);
|
||||
padding: var(--sp-3) var(--sp-4);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--sp-4);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--border-dim);
|
||||
background: var(--bg-raised);
|
||||
transition: opacity var(--t-base);
|
||||
transition: opacity var(--t-base), border-color var(--t-base);
|
||||
}
|
||||
.record-row:hover { background: var(--bg-overlay); }
|
||||
.record-busy { opacity: 0.45; pointer-events: none; }
|
||||
.record-card:hover { border-color: var(--border-strong); }
|
||||
.record-busy { opacity: 0.4; pointer-events: none; }
|
||||
|
||||
.record-identity { display: flex; align-items: center; gap: var(--sp-2); min-width: 0; }
|
||||
.record-tracker-icon { width: 14px; height: 14px; border-radius: 2px; flex-shrink: 0; object-fit: contain; opacity: 0.8; }
|
||||
.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); }
|
||||
.record-title:hover { opacity: 0.75; }
|
||||
.record-title-plain { font-size: var(--text-sm); color: var(--text-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; }
|
||||
.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-controls { display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap; }
|
||||
.record-title { display: inline-flex; align-items: center; gap: 4px; font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-secondary); text-decoration: none; line-height: var(--leading-snug); transition: color var(--t-base); }
|
||||
.record-title:hover { color: var(--accent-fg); }
|
||||
.record-title-plain { font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-secondary); line-height: var(--leading-snug); }
|
||||
|
||||
.record-selects { display: flex; gap: var(--sp-2); flex-wrap: wrap; }
|
||||
.record-select {
|
||||
font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide);
|
||||
padding: 3px 22px 3px 8px; border-radius: var(--radius-sm);
|
||||
border: 1px solid transparent; background: var(--bg-overlay);
|
||||
color: var(--text-faint); outline: none; cursor: pointer;
|
||||
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 6px center;
|
||||
transition: border-color var(--t-base), color var(--t-base), background var(--t-base);
|
||||
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-dim); color: var(--text-secondary); background: var(--bg-raised); }
|
||||
.record-select:focus { border-color: var(--border-strong); outline: none; color: var(--text-secondary); }
|
||||
.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-score { max-width: 90px; }
|
||||
.record-select-score { flex: 0 0 auto; min-width: 80px; }
|
||||
.record-select-status { flex: 1; }
|
||||
|
||||
.record-icon-btn { display: flex; align-items: center; justify-content: center; width: 24px; height: 24px; border-radius: var(--radius-sm); border: none; background: none; color: var(--text-faint); cursor: pointer; transition: color var(--t-base), background var(--t-base); flex-shrink: 0; }
|
||||
.record-icon-btn { display: flex; align-items: center; justify-content: center; width: 22px; height: 22px; border-radius: var(--radius-sm); border: none; background: none; color: var(--text-faint); cursor: pointer; transition: color var(--t-base), background var(--t-base); flex-shrink: 0; }
|
||||
.record-icon-btn:hover:not(:disabled) { color: var(--text-muted); background: var(--bg-overlay); }
|
||||
.record-icon-btn.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-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-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); display: flex; align-items: center; gap: var(--sp-1); }
|
||||
.edit-hint { opacity: 0; font-size: 10px; transition: opacity var(--t-fast); color: var(--text-faint); }
|
||||
.record-progress.clickable:hover .edit-hint { opacity: 1; }
|
||||
.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); }
|
||||
.edit-hint { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); opacity: 0; transition: opacity var(--t-fast); }
|
||||
.record-progress.clickable:hover .edit-hint { opacity: 0.6; }
|
||||
.record-progress-track { height: 2px; background: var(--border-strong); border-radius: var(--radius-full); overflow: hidden; }
|
||||
.record-progress-fill { height: 100%; background: var(--accent); border-radius: var(--radius-full); transition: width 0.3s ease; }
|
||||
|
||||
@@ -1759,6 +1759,13 @@
|
||||
|
||||
{/if}
|
||||
|
||||
{#if store.settings.serverAuthMode === "BASIC_AUTH"}
|
||||
<div class="step-row">
|
||||
<div class="toggle-info"></div>
|
||||
<p class="auth-perf-note">Images are proxied through Tauri when Basic Auth is active, which reduces loading speed.</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="step-row">
|
||||
<div class="toggle-info"></div>
|
||||
<div class="sec-btn-row">
|
||||
@@ -2455,6 +2462,7 @@
|
||||
.sec-eye-btn { position: absolute; right: 8px; top: 50%; transform: translateY(-50%); display: flex; align-items: center; justify-content: center; padding: 0; border: none; background: none; color: var(--text-faint); cursor: pointer; transition: color var(--t-base); }
|
||||
.sec-eye-btn:hover { color: var(--text-muted); }
|
||||
.sec-btn-row { display: flex; align-items: center; gap: var(--sp-2); flex-shrink: 0; }
|
||||
.auth-perf-note { font-size: var(--text-xs); color: var(--text-faint); max-width: 260px; line-height: var(--leading-snug); }
|
||||
.sec-action-btn { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 5px 14px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: none; color: var(--text-muted); cursor: pointer; flex-shrink: 0; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
|
||||
.sec-action-btn:hover:not(:disabled) { color: var(--text-secondary); border-color: var(--border-strong); }
|
||||
.sec-action-btn:disabled { opacity: 0.35; cursor: default; }
|
||||
|
||||
@@ -147,7 +147,7 @@
|
||||
|
||||
<svelte:window onkeydown={onKey} />
|
||||
|
||||
<div class="te-backdrop" tabindex="-1" onclick={onClose} onkeydown={(e) => e.key === "Escape" && onClose()}>
|
||||
<div class="te-backdrop" role="presentation" onclick={onClose} onkeydown={(e) => e.key === "Escape" && onClose()}>
|
||||
<div
|
||||
class="te-shell"
|
||||
role="dialog"
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from "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_ALL_MANGA } from "../../lib/queries";
|
||||
import { cache, CACHE_KEYS } from "../../lib/cache";
|
||||
@@ -248,7 +249,7 @@
|
||||
|
||||
<div class="cover-col">
|
||||
<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}
|
||||
<div class="cover-spinner"><CircleNotch size={18} weight="light" class="anim-spin" /></div>
|
||||
{/if}
|
||||
@@ -437,7 +438,7 @@
|
||||
{#each linkPickerResults as m (m.id)}
|
||||
{@const isLinked = linkedIds.includes(m.id)}
|
||||
<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">
|
||||
<span class="link-manga-title">{m.title}</span>
|
||||
{#if m.source?.displayName}<span class="link-source">{m.source.displayName}</span>{/if}
|
||||
@@ -462,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); }
|
||||
.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 { 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-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); }
|
||||
@@ -548,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:hover { background: var(--bg-raised); }
|
||||
.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-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); }
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
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 { store, setActiveSource, setActiveManga, setNavPage } from "../../store/state.svelte";
|
||||
import type { Manga, Category } from "../../lib/types";
|
||||
@@ -120,7 +121,7 @@
|
||||
<button class="card" onclick={() => { setActiveManga(m); setNavPage("library"); }}
|
||||
oncontextmenu={(e) => openCtx(e, m)}>
|
||||
<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}
|
||||
</div>
|
||||
<p class="title">{m.title}</p>
|
||||
@@ -165,10 +166,10 @@
|
||||
.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; }
|
||||
.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); }
|
||||
.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); }
|
||||
.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; }
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "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 { store } from "../../store/state.svelte";
|
||||
import type { Source } from "../../lib/types";
|
||||
@@ -74,8 +75,7 @@
|
||||
{@const open = expanded.has(g.name)}
|
||||
<div>
|
||||
<button class="row" onclick={() => single ? store.activeSource = g.sources[0] : toggleGroup(g.name)}>
|
||||
<img src={thumbUrl(g.icon)} alt={g.name} class="icon"
|
||||
onerror={(e) => ((e.target as HTMLImageElement).style.display = "none")} />
|
||||
<Thumbnail src={g.icon} alt={g.name} class="icon" onerror={(e) => ((e.target as HTMLImageElement).style.display = "none")} />
|
||||
<div class="info">
|
||||
<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>
|
||||
|
||||
@@ -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} />
|
||||
+12
-17
@@ -16,27 +16,25 @@ function basicHeader(user: string, pass: string): Record<string, string> {
|
||||
return { Authorization: `Basic ${btoa(`${user}:${pass}`)}` };
|
||||
}
|
||||
|
||||
function buildRequestInit(init: RequestInit, extraHeaders: Record<string, string> = {}): RequestInit {
|
||||
return {
|
||||
...init,
|
||||
credentials: "include",
|
||||
headers: { ...(init.headers as Record<string, string> ?? {}), ...extraHeaders },
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchAuthenticated(
|
||||
export function fetchAuthenticated(
|
||||
url: string,
|
||||
init: RequestInit,
|
||||
signal?: AbortSignal,
|
||||
): Promise<Response> {
|
||||
const mode = store.settings.serverAuthMode ?? "NONE";
|
||||
const s = store.settings;
|
||||
|
||||
if (mode === "BASIC_AUTH") {
|
||||
const user = s.serverAuthUser?.trim() ?? "";
|
||||
const pass = s.serverAuthPass?.trim() ?? "";
|
||||
const headers = user && pass ? basicHeader(user, pass) : {};
|
||||
return fetch(url, buildRequestInit({ ...init, signal }, headers));
|
||||
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 });
|
||||
@@ -80,9 +78,6 @@ export async function probeServer(): Promise<"ok" | "auth_required" | "unsupport
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
if (mode === "SIMPLE_LOGIN" || mode === "UI_LOGIN") {
|
||||
updateSettings({ serverAuthMode: "NONE" });
|
||||
}
|
||||
return "ok";
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
+5
-16
@@ -10,25 +10,14 @@ function getServerUrl(): string {
|
||||
|
||||
function gqlUrl(): string { return `${getServerUrl()}/api/graphql`; }
|
||||
|
||||
export function thumbUrl(path: string): string {
|
||||
export function plainThumbUrl(path: string): string {
|
||||
if (!path) return "";
|
||||
if (path.startsWith("http")) return path;
|
||||
return `${getServerUrl()}${path}`;
|
||||
}
|
||||
|
||||
const base = getServerUrl();
|
||||
const mode = store.settings.serverAuthMode;
|
||||
|
||||
if (mode === "BASIC_AUTH") {
|
||||
const user = store.settings.serverAuthUser?.trim() ?? "";
|
||||
const pass = store.settings.serverAuthPass?.trim() ?? "";
|
||||
if (user && pass) {
|
||||
const url = new URL(`${base}${path}`);
|
||||
url.username = user;
|
||||
url.password = pass;
|
||||
return url.toString();
|
||||
}
|
||||
}
|
||||
|
||||
return `${base}${path}`;
|
||||
export function thumbUrl(path: string): string {
|
||||
return plainThumbUrl(path);
|
||||
}
|
||||
|
||||
interface GQLResponse<T> {
|
||||
|
||||
+48
-48
@@ -1,79 +1,79 @@
|
||||
import { start, stop, setActivity, clearActivity } from "tauri-plugin-drpc";
|
||||
import { Activity, Assets, Button, Timestamps } from "tauri-plugin-drpc/activity";
|
||||
import type { Manga, Chapter } from "./types";
|
||||
import { connect, disconnect, setActivity, clearActivity } from "tauri-plugin-discord-rpc-api";
|
||||
import { listen } from '@tauri-apps/api/event'
|
||||
import type { Manga, Chapter } from './types'
|
||||
|
||||
const APP_ID = "1487894643613106298";
|
||||
const FALLBACK_IMAGE = "moku_logo";
|
||||
const APP_ID = '1487894643613106298'
|
||||
const FALLBACK_IMAGE = 'moku_logo'
|
||||
|
||||
let sessionStart: number | null = null;
|
||||
let sessionStart: number | null = null
|
||||
let unlisten: (() => void) | null = null
|
||||
|
||||
function isPublicUrl(url: string | null | undefined): boolean {
|
||||
return typeof url === "string" && url.startsWith("https://");
|
||||
return typeof url === 'string' && url.startsWith('https://')
|
||||
}
|
||||
|
||||
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 {
|
||||
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 {
|
||||
const n = chapter.chapterNumber;
|
||||
return `Chapter ${Number.isInteger(n) ? n : n.toFixed(1)}`;
|
||||
}
|
||||
|
||||
function getTimestamps(): Timestamps {
|
||||
return new Timestamps(sessionStart ?? Date.now());
|
||||
const n = chapter.chapterNumber
|
||||
return `Chapter ${Number.isInteger(n) ? n : n.toFixed(1)}`
|
||||
}
|
||||
|
||||
const BUTTONS = [
|
||||
new Button("GitHub", "https://github.com/Youwes09/Moku"),
|
||||
new Button("Discord", "https://discord.gg/Jq3pwuNqPp"),
|
||||
];
|
||||
{ label: 'GitHub', url: 'https://github.com/Youwes09/Moku' },
|
||||
{ label: 'Discord', url: 'https://discord.gg/Jq3pwuNqPp' },
|
||||
]
|
||||
|
||||
export async function initRpc(): Promise<void> {
|
||||
sessionStart = Date.now();
|
||||
await start(APP_ID).catch(() => {});
|
||||
sessionStart = Date.now()
|
||||
|
||||
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> {
|
||||
const assets = new Assets()
|
||||
.setLargeImage(resolveCoverImage(manga))
|
||||
.setLargeText(trunc(manga.title))
|
||||
.setSmallImage(FALLBACK_IMAGE)
|
||||
.setSmallText("Moku");
|
||||
|
||||
const activity = new Activity()
|
||||
.setDetails(trunc(manga.title))
|
||||
.setState(`${formatChapter(chapter)} · Reading`)
|
||||
.setAssets(assets)
|
||||
.setTimestamps(getTimestamps());
|
||||
activity.setButton(BUTTONS);
|
||||
|
||||
await setActivity(activity).catch(() => {});
|
||||
await setActivity({
|
||||
details: trunc(manga.title),
|
||||
state: `${formatChapter(chapter)} · Reading`,
|
||||
timestamps: { start: sessionStart ?? Date.now() },
|
||||
assets: {
|
||||
largeImage: resolveCoverImage(manga),
|
||||
largeText: trunc(manga.title),
|
||||
smallImage: FALLBACK_IMAGE,
|
||||
smallText: 'Moku',
|
||||
},
|
||||
buttons: BUTTONS,
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
export async function setIdle(): Promise<void> {
|
||||
const assets = new Assets()
|
||||
.setLargeImage(FALLBACK_IMAGE)
|
||||
.setLargeText("Moku");
|
||||
|
||||
const activity = new Activity()
|
||||
.setDetails("Browsing")
|
||||
.setAssets(assets)
|
||||
.setTimestamps(getTimestamps());
|
||||
activity.setButton(BUTTONS);
|
||||
|
||||
await setActivity(activity).catch(() => {});
|
||||
await setActivity({
|
||||
details: 'Browsing',
|
||||
timestamps: { start: sessionStart ?? Date.now() },
|
||||
assets: {
|
||||
largeImage: FALLBACK_IMAGE,
|
||||
largeText: 'Moku',
|
||||
},
|
||||
buttons: BUTTONS,
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
export async function clearReading(): Promise<void> {
|
||||
await clearActivity().catch(() => {});
|
||||
await clearActivity().catch(() => {})
|
||||
}
|
||||
|
||||
export async function destroyRpc(): Promise<void> {
|
||||
sessionStart = null;
|
||||
await stop().catch(() => {});
|
||||
unlisten?.()
|
||||
unlisten = null
|
||||
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();
|
||||
}
|
||||
+69
-95
@@ -190,6 +190,7 @@ export interface MangaPrefs {
|
||||
pauseUpdates: boolean;
|
||||
refreshInterval: "global" | "daily" | "weekly" | "manual";
|
||||
preferredScanlator: string;
|
||||
scanlatorFilter: string[];
|
||||
}
|
||||
|
||||
export const DEFAULT_MANGA_PREFS: MangaPrefs = {
|
||||
@@ -201,6 +202,7 @@ export const DEFAULT_MANGA_PREFS: MangaPrefs = {
|
||||
pauseUpdates: false,
|
||||
refreshInterval: "global",
|
||||
preferredScanlator: "",
|
||||
scanlatorFilter: [],
|
||||
};
|
||||
|
||||
export interface Settings {
|
||||
@@ -398,75 +400,63 @@ function mergeSettings(saved: any): Settings {
|
||||
mangaPrefs: saved?.settings?.mangaPrefs ?? {},
|
||||
customThemes: saved?.settings?.customThemes ?? [],
|
||||
hiddenCategoryIds: saved?.settings?.hiddenCategoryIds ?? [],
|
||||
nsfwFilteredTags: saved?.settings?.nsfwFilteredTags ?? DEFAULT_SETTINGS.nsfwFilteredTags,
|
||||
nsfwAllowedSourceIds: saved?.settings?.nsfwAllowedSourceIds ?? [],
|
||||
nsfwBlockedSourceIds: saved?.settings?.nsfwBlockedSourceIds ?? [],
|
||||
libraryTabSort: saved?.settings?.libraryTabSort ?? {},
|
||||
libraryTabStatus: saved?.settings?.libraryTabStatus ?? {},
|
||||
libraryTabFilters: saved?.settings?.libraryTabFilters ?? {},
|
||||
nsfwFilteredTags: saved?.settings?.nsfwFilteredTags ?? ["adult", "mature", "hentai", "ecchi", "erotic", "pornograph", "18+", "smut", "lemon", "explicit", "sexual violence"],
|
||||
nsfwAllowedSourceIds: saved?.settings?.nsfwAllowedSourceIds ?? [],
|
||||
nsfwBlockedSourceIds: saved?.settings?.nsfwBlockedSourceIds ?? [],
|
||||
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);
|
||||
|
||||
class Store {
|
||||
navPage: NavPage = $state(saved?.navPage ?? "home");
|
||||
libraryFilter: LibraryFilter = $state("library");
|
||||
history: HistoryEntry[] = $state(saved?.history ?? []);
|
||||
readLog: ReadLogEntry[] = $state(saved?.readLog ?? []);
|
||||
bookmarks: BookmarkEntry[] = $state(saved?.bookmarks ?? []);
|
||||
markers: MarkerEntry[] = $state(saved?.markers ?? []);
|
||||
readingStats: ReadingStats = $state(mergeStats(saved));
|
||||
settings: Settings = $state(mergeSettings(saved));
|
||||
readerSessionId: number = $state(0);
|
||||
genreFilter: string = $state("");
|
||||
searchPrefill: string = $state("");
|
||||
activeManga: Manga | null = $state(null);
|
||||
previewManga: Manga | null = $state(null);
|
||||
activeSource: Source | null = $state(null);
|
||||
pageUrls: string[] = $state([]);
|
||||
pageNumber: number = $state(1);
|
||||
libraryTagFilter: string[] = $state([]);
|
||||
settingsOpen: boolean = $state(false);
|
||||
activeDownloads: ActiveDownload[] = $state([]);
|
||||
toasts: Toast[] = $state([]);
|
||||
activeChapter: Chapter | null = $state(null);
|
||||
activeChapterList: Chapter[] = $state([]);
|
||||
isFullscreen: boolean = $state(false);
|
||||
pageUrls: string[] = $state([]);
|
||||
pageNumber: number = $state(1);
|
||||
navPage: NavPage = $state("home");
|
||||
libraryFilter: LibraryFilter = $state("all");
|
||||
genreFilter: string = $state("");
|
||||
searchPrefill: string = $state("");
|
||||
toasts: Toast[] = $state([]);
|
||||
categories: Category[] = $state([]);
|
||||
discoverCache: Map<string, Manga[]> = $state(new Map());
|
||||
activeDownloads: ActiveDownload[] = $state([]);
|
||||
activeSource: Source | null = $state(null);
|
||||
libraryTagFilter: string[] = $state([]);
|
||||
settingsOpen: boolean = $state(false);
|
||||
history: HistoryEntry[] = $state(saved?.history ?? []);
|
||||
bookmarks: BookmarkEntry[] = $state(saved?.bookmarks ?? []);
|
||||
markers: MarkerEntry[] = $state(saved?.markers ?? []);
|
||||
readLog: ReadLogEntry[] = $state(saved?.readLog ?? []);
|
||||
readingStats: ReadingStats = $state(saved?.readingStats ?? { ...DEFAULT_READING_STATS });
|
||||
discoverCache: Map<string, any> = $state(new Map());
|
||||
discoverLibraryIds: Set<number> = $state(new Set());
|
||||
discoverSrcOffset: number = $state(0);
|
||||
|
||||
constructor() {
|
||||
$effect.root(() => {
|
||||
$effect(() => { persist({ storeVersion: STORE_VERSION }); });
|
||||
$effect(() => { persist({ navPage: this.navPage }); });
|
||||
$effect(() => { persist({ libraryFilter: this.libraryFilter }); });
|
||||
$effect(() => { persist({ history: this.history }); });
|
||||
$effect(() => { persist({ readLog: this.readLog }); });
|
||||
$effect(() => { persist({ bookmarks: this.bookmarks }); });
|
||||
$effect(() => { persist({ markers: this.markers }); });
|
||||
$effect(() => { persist({ readingStats: this.readingStats }); });
|
||||
$effect(() => { persist({ settings: this.settings }); });
|
||||
$effect(() => {
|
||||
persist({
|
||||
settings: this.settings,
|
||||
history: this.history,
|
||||
bookmarks: this.bookmarks,
|
||||
markers: this.markers,
|
||||
readLog: this.readLog,
|
||||
readingStats: this.readingStats,
|
||||
storeVersion: STORE_VERSION,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
openReader(chapter: Chapter, chapterList: Chapter[], manga?: Manga | null) {
|
||||
if (manga) this.activeManga = manga;
|
||||
this.activeChapter = chapter;
|
||||
this.activeChapterList = chapterList;
|
||||
this.pageUrls = [];
|
||||
this.pageNumber = 1;
|
||||
if (manga !== undefined) this.activeManga = manga;
|
||||
}
|
||||
|
||||
closeReader() {
|
||||
@@ -474,86 +464,70 @@ class Store {
|
||||
this.activeChapterList = [];
|
||||
this.pageUrls = [];
|
||||
this.pageNumber = 1;
|
||||
this.readerSessionId += 1;
|
||||
}
|
||||
|
||||
addHistory(entry: HistoryEntry, completed = false, minutes = AVG_MIN_PER_CHAPTER) {
|
||||
if (this.history[0]?.chapterId === entry.chapterId) {
|
||||
this.history[0] = { ...this.history[0], readAt: entry.readAt };
|
||||
} else {
|
||||
this.history = [entry, ...this.history.filter(x => x.chapterId !== entry.chapterId)].slice(0, 300);
|
||||
}
|
||||
addHistory(entry: HistoryEntry, completed = false, minutes?: number) {
|
||||
const filtered = this.history.filter(h => h.chapterId !== entry.chapterId);
|
||||
this.history = [entry, ...filtered].slice(0, 500);
|
||||
|
||||
if (completed) {
|
||||
const logEntry: ReadLogEntry = {
|
||||
mangaId: entry.mangaId,
|
||||
chapterId: entry.chapterId,
|
||||
readAt: entry.readAt,
|
||||
minutes,
|
||||
};
|
||||
this.readLog = [...this.readLog, logEntry].slice(-5000);
|
||||
const existing = this.readLog.find(e => e.chapterId === entry.chapterId);
|
||||
if (!existing) {
|
||||
const mins = minutes ?? AVG_MIN_PER_CHAPTER;
|
||||
this.readLog = [...this.readLog, { mangaId: entry.mangaId, chapterId: entry.chapterId, readAt: entry.readAt, minutes: mins }];
|
||||
const uniqueChapters = new Set(this.readLog.map(e => e.chapterId));
|
||||
const uniqueManga = new Set(this.readLog.map(e => e.mangaId));
|
||||
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 log = completed ? [...this.readLog] : this.readLog;
|
||||
|
||||
const uniqueChapters = new Set(log.map(e => e.chapterId));
|
||||
const uniqueManga = new Set(log.map(e => e.mangaId));
|
||||
const totalMinutes = log.reduce((sum, e) => sum + e.minutes, 0);
|
||||
|
||||
const today = todayStr();
|
||||
let { currentStreakDays, longestStreakDays, lastStreakDate } = this.readingStats;
|
||||
if (lastStreakDate !== today) {
|
||||
const yesterday = new Date();
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
const yStr = `${yesterday.getFullYear()}-${String(yesterday.getMonth() + 1).padStart(2, "0")}-${String(yesterday.getDate()).padStart(2, "0")}`;
|
||||
currentStreakDays = lastStreakDate === yStr ? currentStreakDays + 1 : 1;
|
||||
longestStreakDays = Math.max(longestStreakDays, currentStreakDays);
|
||||
lastStreakDate = today;
|
||||
}
|
||||
|
||||
const longest = Math.max(this.readingStats.longestStreakDays, streak);
|
||||
this.readingStats = {
|
||||
totalChaptersRead: uniqueChapters.size,
|
||||
totalMangaRead: uniqueManga.size,
|
||||
totalMinutesRead: totalMinutes,
|
||||
firstReadAt: this.readingStats.firstReadAt === 0 ? entry.readAt : this.readingStats.firstReadAt,
|
||||
firstReadAt: this.readingStats.firstReadAt || entry.readAt,
|
||||
lastReadAt: entry.readAt,
|
||||
currentStreakDays,
|
||||
longestStreakDays,
|
||||
lastStreakDate,
|
||||
currentStreakDays: streak,
|
||||
longestStreakDays: longest,
|
||||
lastStreakDate: todayStr,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
addBookmark(entry: Omit<BookmarkEntry, "savedAt">, label?: string) {
|
||||
const bookmark: BookmarkEntry = { ...entry, savedAt: Date.now(), label };
|
||||
this.bookmarks = [
|
||||
bookmark,
|
||||
...this.bookmarks.filter(b => b.mangaId !== entry.mangaId),
|
||||
].slice(0, 200);
|
||||
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 = [];
|
||||
}
|
||||
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 = genId();
|
||||
const marker: MarkerEntry = { ...entry, id, createdAt: Date.now() };
|
||||
this.markers = [marker, ...this.markers].slice(0, 2000);
|
||||
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
|
||||
);
|
||||
this.markers = this.markers.map(m => m.id === id ? { ...m, ...patch, updatedAt: Date.now() } : m);
|
||||
}
|
||||
|
||||
removeMarker(id: string) {
|
||||
|
||||
Reference in New Issue
Block a user