mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 17:29:55 -05:00
Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8aa92e6b54 | |||
| b55dd16d0d | |||
| 7c6aeb8f4c | |||
| 3e4d322fb7 | |||
| db8a984270 | |||
| 18027baee1 | |||
| c5243ba30c | |||
| 13f2a483ca | |||
| 6de5207ce7 | |||
| 8c250021a0 | |||
| 584b917f98 | |||
| e9929747d2 | |||
| cbdf9e8be1 | |||
| d9a9427e3b | |||
| ae5d9748c7 | |||
| 6c39ef538f | |||
| 081becdd60 | |||
| c891cb349c | |||
| 8cef74bb98 | |||
| bf071dcfc7 | |||
| b0efb183e8 | |||
| 745b6993de | |||
| bd79169f71 | |||
| 6fccf02614 | |||
| fa7cfdc4e6 | |||
| 9c614b38f8 | |||
| 30e50b5a1b | |||
| 8ef0a14363 | |||
| 4e2ad6cae7 | |||
| 9e56b1176c | |||
| d025d07e07 | |||
| f988641446 |
+17
-10
@@ -1,30 +1,33 @@
|
|||||||
# --- Build Artifacts ---
|
|
||||||
node_modules/
|
node_modules/
|
||||||
suwayomi-raw/
|
suwayomi-raw/
|
||||||
suwayomi-windows.zip
|
suwayomi-windows.zip
|
||||||
suwayomi.zip
|
|
||||||
dist/
|
dist/
|
||||||
dist-tauri/
|
dist-tauri/
|
||||||
target/
|
target/
|
||||||
bin/
|
bin/
|
||||||
out/
|
out/
|
||||||
|
|
||||||
|
|
||||||
# --- Nix ---
|
|
||||||
.direnv/
|
.direnv/
|
||||||
result
|
result
|
||||||
result-*
|
result-*
|
||||||
|
|
||||||
# --- Logs ---
|
|
||||||
*.log
|
*.log
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
yarn-debug.log*
|
yarn-debug.log*
|
||||||
yarn-error.log*
|
|
||||||
.env
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
!.env.test
|
||||||
.env.local
|
.env.local
|
||||||
.env.*.local
|
.env.*.local
|
||||||
|
|
||||||
# --- IDEs & OS ---
|
.output
|
||||||
|
.vercel
|
||||||
|
.netlify
|
||||||
|
.wrangler
|
||||||
|
/.svelte-kit
|
||||||
|
/build
|
||||||
|
|
||||||
.vscode/
|
.vscode/
|
||||||
.idea/
|
.idea/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
@@ -34,15 +37,19 @@ yarn-error.log*
|
|||||||
*.sln
|
*.sln
|
||||||
*.swp
|
*.swp
|
||||||
|
|
||||||
# --- Tauri specific ---
|
|
||||||
src-tauri/target/
|
src-tauri/target/
|
||||||
src-tauri/binaries/
|
src-tauri/binaries/
|
||||||
src-tauri/gen/
|
src-tauri/gen/
|
||||||
|
|
||||||
# --- Flatpak build artifacts ---
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
vite.config.js.timestamp-*
|
||||||
|
vite.config.ts.timestamp-*
|
||||||
|
|
||||||
build-dir/
|
build-dir/
|
||||||
repo/
|
repo/
|
||||||
dist/
|
dist/
|
||||||
packaging/frontend-dist.tar.gz
|
packaging/frontend-dist.tar.gz
|
||||||
*.flatpak
|
*.flatpak
|
||||||
.flatpak-builder/\n# --- Staged sidecar binaries (platform-specific, never commit) ---\nsrc-tauri/binaries/
|
./flatpak-builder
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ Moku is a fast, minimal manga reader frontend for [Suwayomi-Server](https://gith
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
<a href="docs/screenshots" style="color: #a8c4a8;">View all screenshots →</a>
|
<a href="docs/screenshots">View all screenshots →</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -46,8 +46,8 @@ Moku is a fast, minimal manga reader frontend for [Suwayomi-Server](https://gith
|
|||||||
- **Markers** — pin color-coded notes to any page while reading; markers appear as dots on the progress bar and are browseable under Series Detail → Manage → Markers
|
- **Markers** — pin color-coded notes to any page while reading; markers appear as dots on the progress bar and are browseable under Series Detail → Manage → Markers
|
||||||
- **Extension support** — install and manage Suwayomi extensions directly from the app
|
- **Extension support** — install and manage Suwayomi extensions directly from the app
|
||||||
- **Download management** — queue and monitor chapter downloads with progress toasts
|
- **Download management** — queue and monitor chapter downloads with progress toasts
|
||||||
- **Automation** — pre-download titles automatically and optionally delete chapters after they're marked as read (accessible from Series Detail)
|
- **Automation** — pre-download titles automatically and optionally delete chapters after reading (accessible from Series Detail)
|
||||||
- **Discord Rich Presence** — shows the manga title, current chapter, and an elapsed timer in your Discord status; configurable in Settings → General
|
- **Discord Rich Presence** — shows manga title, current chapter, and elapsed timer in your Discord status; configurable in Settings → General
|
||||||
- **Auto-start server** — optionally launch Suwayomi in the background on startup
|
- **Auto-start server** — optionally launch Suwayomi in the background on startup
|
||||||
- **Multiple themes** — Dark, Light, Midnight, Warm, High Contrast, and more
|
- **Multiple themes** — Dark, Light, Midnight, Warm, High Contrast, and more
|
||||||
- **Auto-updates** — in-app update checker with silent background notifications
|
- **Auto-updates** — in-app update checker with silent background notifications
|
||||||
@@ -61,7 +61,7 @@ Moku is a fast, minimal manga reader frontend for [Suwayomi-Server](https://gith
|
|||||||
|
|
||||||

|

|
||||||

|

|
||||||

|

|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -148,9 +148,9 @@ pnpm tauri:dev
|
|||||||
| | |
|
| | |
|
||||||
|---|---|
|
|---|---|
|
||||||
| [Tauri v2](https://tauri.app) | Native app shell |
|
| [Tauri v2](https://tauri.app) | Native app shell |
|
||||||
| [Svelte 5](https://svelte.dev) + [TypeScript](https://www.typescriptlang.org) | UI |
|
| [Svelte 5](https://svelte.dev) + [SvelteKit 2](https://kit.svelte.dev) + [TypeScript](https://www.typescriptlang.org) | UI |
|
||||||
| [Vite](https://vitejs.dev) | Frontend bundler |
|
| [Vite 8](https://vitejs.dev) | Frontend bundler |
|
||||||
| [Crane](https://github.com/ipetkov/crane) | Nix Rust builds |
|
| [Nixpkgs stdenv](https://nixos.org/manual/nixpkgs/stable/) | Nix builds |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,39 +1,4 @@
|
|||||||
Major Revisions:
|
Revival of the TODO List!!!!!
|
||||||
- Moku-Share to Easily Migrate/Share Manga (Investigate Usecase/Feasibility)
|
|
||||||
- Moku-Share allows exporting of Manga
|
|
||||||
- Compressed Format (Storage)
|
|
||||||
- Import as Local-Source
|
|
||||||
- Takes existing Local-Source or Creates Own
|
|
||||||
|
|
||||||
Minor Revisions:
|
|
||||||
- Investigate feasibility of Multi-Page Screenshot (Reader)
|
|
||||||
- Revise Migration (https://github.com/Suwayomi/Suwayomi-WebUI/pull/1073)
|
|
||||||
|
|
||||||
Priority Bugs:
|
- Reminder to Completely Test Settings
|
||||||
- Fix Library-Refresh System (TESTING)
|
|
||||||
|
|
||||||
- Suwayomi RESET
|
|
||||||
- Allow User to Wipe Suwayomi (Scratch)
|
|
||||||
- If Possible, Component based Wipe (Library, Etc)
|
|
||||||
|
|
||||||
Pending/On-Hold:
|
|
||||||
- Enable Cloudflare Bypass (Suwayomi Config) (Requires Patching)
|
|
||||||
- Working on 3D Display Cards
|
|
||||||
- Add Flathub Support (Pending Video)
|
|
||||||
|
|
||||||
- Change Auto-Link Threshold
|
|
||||||
- Fix Auto-Link De-dupe for Images
|
|
||||||
- Optimize Auto-Link Latency (IP)
|
|
||||||
|
|
||||||
In-Progress:
|
|
||||||
- Fix Tracking Login
|
|
||||||
- Pasting OAuth URL is not User-Friendly, Look for Alternatives
|
|
||||||
|
|
||||||
- Apply Syer's Fix for Library on Backup Load (Manga Metadata)
|
|
||||||
- Note User's have to always install extensions manually
|
|
||||||
- Create "Missing Source" for Manga
|
|
||||||
|
|
||||||
- Wire Series-Detail Refresh to Fix Manga-Metadata (MAJOR)
|
|
||||||
|
|
||||||
- UI LOGIN DOES NOT WORK OFFLINE
|
|
||||||
Notes from last time:
|
|
||||||
|
|||||||
Generated
+12
-28
@@ -1,30 +1,15 @@
|
|||||||
{
|
{
|
||||||
"nodes": {
|
"nodes": {
|
||||||
"crane": {
|
|
||||||
"locked": {
|
|
||||||
"lastModified": 1773857772,
|
|
||||||
"narHash": "sha256-5xsK26KRHf0WytBtsBnQYC/lTWDhQuT57HJ7SzuqZcM=",
|
|
||||||
"owner": "ipetkov",
|
|
||||||
"repo": "crane",
|
|
||||||
"rev": "b556d7bbae5ff86e378451511873dfd07e4504cd",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
"original": {
|
|
||||||
"owner": "ipetkov",
|
|
||||||
"repo": "crane",
|
|
||||||
"type": "github"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"flake-parts": {
|
"flake-parts": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"nixpkgs-lib": "nixpkgs-lib"
|
"nixpkgs-lib": "nixpkgs-lib"
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1772408722,
|
"lastModified": 1778716662,
|
||||||
"narHash": "sha256-rHuJtdcOjK7rAHpHphUb1iCvgkU3GpfvicLMwwnfMT0=",
|
"narHash": "sha256-m1Yf0wZ8j1OHjTc2UwHwyQRSnNeSgLJOd7q5Y45hzi4=",
|
||||||
"owner": "hercules-ci",
|
"owner": "hercules-ci",
|
||||||
"repo": "flake-parts",
|
"repo": "flake-parts",
|
||||||
"rev": "f20dc5d9b8027381c474144ecabc9034d6a839a3",
|
"rev": "f7c1a2d347e4c52d5fb8d10cb4d94b5884e546fb",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -35,11 +20,11 @@
|
|||||||
},
|
},
|
||||||
"nixpkgs": {
|
"nixpkgs": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1773821835,
|
"lastModified": 1780243769,
|
||||||
"narHash": "sha256-TJ3lSQtW0E2JrznGVm8hOQGVpXjJyXY2guAxku2O9A4=",
|
"narHash": "sha256-x5UQuRsH3MqI0U9afaXSNqzTPSeZlRLvFAav2Ux1pNw=",
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "b40629efe5d6ec48dd1efba650c797ddbd39ace0",
|
"rev": "331800de5053fcebacf6813adb5db9c9dca22a0c",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -51,11 +36,11 @@
|
|||||||
},
|
},
|
||||||
"nixpkgs-lib": {
|
"nixpkgs-lib": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1772328832,
|
"lastModified": 1777168982,
|
||||||
"narHash": "sha256-e+/T/pmEkLP6BHhYjx6GmwP5ivonQQn0bJdH9YrRB+Q=",
|
"narHash": "sha256-GOkGPcboWE9BmGCRMLX3worL4EMnsnG8MyKmXNeYuhQ=",
|
||||||
"owner": "nix-community",
|
"owner": "nix-community",
|
||||||
"repo": "nixpkgs.lib",
|
"repo": "nixpkgs.lib",
|
||||||
"rev": "c185c7a5e5dd8f9add5b2f8ebeff00888b070742",
|
"rev": "f5901329dade4a6ea039af1433fb087bd9c1fe14",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -66,7 +51,6 @@
|
|||||||
},
|
},
|
||||||
"root": {
|
"root": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"crane": "crane",
|
|
||||||
"flake-parts": "flake-parts",
|
"flake-parts": "flake-parts",
|
||||||
"nixpkgs": "nixpkgs",
|
"nixpkgs": "nixpkgs",
|
||||||
"rust-overlay": "rust-overlay"
|
"rust-overlay": "rust-overlay"
|
||||||
@@ -79,11 +63,11 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1773975983,
|
"lastModified": 1780543271,
|
||||||
"narHash": "sha256-zrRVwdfhDdohANqEhzY/ydeza6EXEi8AG6cyMRNYT9Q=",
|
"narHash": "sha256-oPJ7eJN1sM37v92Rp/eyQL7/rUm0BOvXEBAoq/zN0cM=",
|
||||||
"owner": "oxalica",
|
"owner": "oxalica",
|
||||||
"repo": "rust-overlay",
|
"repo": "rust-overlay",
|
||||||
"rev": "cc80954a95f6f356c303ed9f08d0b63ca86216ac",
|
"rev": "c30ca201c5093540cf792f6982f81ba1aa0f3514",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|||||||
@@ -4,7 +4,6 @@
|
|||||||
inputs = {
|
inputs = {
|
||||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||||
flake-parts.url = "github:hercules-ci/flake-parts";
|
flake-parts.url = "github:hercules-ci/flake-parts";
|
||||||
crane.url = "github:ipetkov/crane";
|
|
||||||
rust-overlay = {
|
rust-overlay = {
|
||||||
url = "github:oxalica/rust-overlay";
|
url = "github:oxalica/rust-overlay";
|
||||||
inputs.nixpkgs.follows = "nixpkgs";
|
inputs.nixpkgs.follows = "nixpkgs";
|
||||||
@@ -12,7 +11,7 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
outputs =
|
outputs =
|
||||||
inputs@{ flake-parts, crane, rust-overlay, ... }:
|
inputs@{ flake-parts, rust-overlay, ... }:
|
||||||
flake-parts.lib.mkFlake { inherit inputs; } {
|
flake-parts.lib.mkFlake { inherit inputs; } {
|
||||||
systems = [
|
systems = [
|
||||||
"x86_64-linux"
|
"x86_64-linux"
|
||||||
@@ -22,7 +21,8 @@
|
|||||||
perSystem =
|
perSystem =
|
||||||
{ system, lib, ... }:
|
{ system, lib, ... }:
|
||||||
let
|
let
|
||||||
version = "0.9.4";
|
versions = import ./nix/versions.nix;
|
||||||
|
version = versions.moku;
|
||||||
|
|
||||||
pkgs = import inputs.nixpkgs {
|
pkgs = import inputs.nixpkgs {
|
||||||
inherit system;
|
inherit system;
|
||||||
@@ -36,8 +36,6 @@
|
|||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
|
||||||
craneLib = (crane.mkLib pkgs).overrideToolchain rustToolchain;
|
|
||||||
|
|
||||||
runtimeLibs = with pkgs; [
|
runtimeLibs = with pkgs; [
|
||||||
webkitgtk_4_1
|
webkitgtk_4_1
|
||||||
gtk3
|
gtk3
|
||||||
@@ -53,7 +51,7 @@
|
|||||||
gsettings-desktop-schemas
|
gsettings-desktop-schemas
|
||||||
];
|
];
|
||||||
|
|
||||||
frontendSrc = lib.cleanSourceWith {
|
src = lib.cleanSourceWith {
|
||||||
src = ./.;
|
src = ./.;
|
||||||
filter =
|
filter =
|
||||||
path: type:
|
path: type:
|
||||||
@@ -61,41 +59,36 @@
|
|||||||
base = builtins.baseNameOf path;
|
base = builtins.baseNameOf path;
|
||||||
in
|
in
|
||||||
(lib.hasInfix "/src" path)
|
(lib.hasInfix "/src" path)
|
||||||
|
|| (lib.hasInfix "/src-tauri/src" path)
|
||||||
|
|| (lib.hasInfix "/src-tauri/icons" path)
|
||||||
|
|| (lib.hasInfix "/src-tauri/capabilities" path)
|
||||||
|
|| (lib.hasInfix "/static" path)
|
||||||
|| base == "index.html"
|
|| base == "index.html"
|
||||||
|| base == "package.json"
|
|| base == "package.json"
|
||||||
|| base == "pnpm-lock.yaml"
|
|| base == "pnpm-lock.yaml"
|
||||||
|
|| base == "pnpm-workspace.yaml"
|
||||||
|| base == "tsconfig.json"
|
|| base == "tsconfig.json"
|
||||||
|| base == "vite.config.ts";
|
|| base == "vite.config.ts"
|
||||||
|
|| base == "svelte.config.js"
|
||||||
|
|| base == "Cargo.toml"
|
||||||
|
|| base == "Cargo.lock"
|
||||||
|
|| base == "build.rs"
|
||||||
|
|| base == "tauri.conf.json";
|
||||||
};
|
};
|
||||||
|
|
||||||
cargoSrc = lib.cleanSourceWith {
|
suwayomiServer = pkgs.callPackage ./nix/server.nix { inherit versions; };
|
||||||
src = ./src-tauri;
|
|
||||||
filter =
|
moku = pkgs.callPackage ./nix/moku.nix {
|
||||||
path: type:
|
inherit lib pkgs rustToolchain runtimeLibs suwayomiServer version src versions;
|
||||||
(craneLib.filterCargoSources path type)
|
appIcon = ./src/lib/assets/moku-icon.svg;
|
||||||
|| (lib.hasInfix "/icons/" path)
|
|
||||||
|| (lib.hasInfix "/capabilities/" path)
|
|
||||||
|| (builtins.baseNameOf path == "tauri.conf.json");
|
|
||||||
};
|
};
|
||||||
|
|
||||||
suwayomiServer = pkgs.callPackage ./nix/server.nix { };
|
scripts = import ./nix/scripts.nix { inherit pkgs rustToolchain version versions; };
|
||||||
|
|
||||||
frontend = pkgs.callPackage ./nix/frontend.nix {
|
|
||||||
inherit version;
|
|
||||||
src = frontendSrc;
|
|
||||||
};
|
|
||||||
|
|
||||||
moku = import ./nix/moku.nix {
|
|
||||||
inherit lib craneLib pkgs runtimeLibs frontend suwayomiServer version cargoSrc;
|
|
||||||
appIcon = ./src/assets/moku-icon.svg;
|
|
||||||
};
|
|
||||||
|
|
||||||
scripts = import ./nix/scripts.nix { inherit pkgs rustToolchain version; };
|
|
||||||
|
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
packages = {
|
packages = {
|
||||||
inherit moku frontend suwayomiServer;
|
inherit moku suwayomiServer;
|
||||||
default = moku;
|
default = moku;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -103,7 +96,7 @@
|
|||||||
default = { type = "app"; program = "${moku}/bin/moku"; };
|
default = { type = "app"; program = "${moku}/bin/moku"; };
|
||||||
moku = { type = "app"; program = "${moku}/bin/moku"; };
|
moku = { type = "app"; program = "${moku}/bin/moku"; };
|
||||||
bump = { type = "app"; program = "${scripts.bump}/bin/moku-bump"; };
|
bump = { type = "app"; program = "${scripts.bump}/bin/moku-bump"; };
|
||||||
post-tag-bump = { type = "app"; program = "${scripts.postTagBump}/bin/moku-post-tag-bump"; };
|
update = { type = "app"; program = "${scripts.update}/bin/moku-update"; };
|
||||||
flatpak = { type = "app"; program = "${scripts.flatpak}/bin/moku-flatpak"; };
|
flatpak = { type = "app"; program = "${scripts.flatpak}/bin/moku-flatpak"; };
|
||||||
tunnel = { type = "app"; program = "${scripts.tunnel}/bin/moku-tunnel"; };
|
tunnel = { type = "app"; program = "${scripts.tunnel}/bin/moku-tunnel"; };
|
||||||
};
|
};
|
||||||
@@ -132,11 +125,11 @@
|
|||||||
|
|
||||||
echo "Moku dev shell — pnpm install && pnpm tauri:dev"
|
echo "Moku dev shell — pnpm install && pnpm tauri:dev"
|
||||||
echo ""
|
echo ""
|
||||||
echo " nix run .#bump -- <ver>"
|
echo " nix run .#bump -- <ver> bump version + rebuild frontend"
|
||||||
echo " git commit && git tag && git push"
|
echo " git commit && git tag && git push"
|
||||||
echo " nix run .#post-tag-bump -- <ver>"
|
echo " nix run .#update -- <ver> fetch hashes + patch all configs"
|
||||||
echo " nix run .#flatpak -- <ver>"
|
echo " nix run .#flatpak build flatpak bundle"
|
||||||
echo " nix run .#tunnel -- [port]"
|
echo " nix run .#tunnel -- [port] expose local server via cloudflare"
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
{ lib, stdenv, nodejs_22, pnpm, pnpmConfigHook, fetchPnpmDeps, version, src }:
|
|
||||||
|
|
||||||
stdenv.mkDerivation {
|
|
||||||
pname = "moku-frontend";
|
|
||||||
inherit version src;
|
|
||||||
|
|
||||||
nativeBuildInputs = [ nodejs_22 pnpm pnpmConfigHook ];
|
|
||||||
|
|
||||||
pnpmDeps = fetchPnpmDeps {
|
|
||||||
pname = "moku-frontend";
|
|
||||||
inherit version src;
|
|
||||||
fetcherVersion = 1;
|
|
||||||
hash = "sha256-vM//1/qe9nKDwwlmFbqvBFqF8cCjIIdNKEtktyzBFB8=";
|
|
||||||
};
|
|
||||||
|
|
||||||
buildPhase = "pnpm build";
|
|
||||||
installPhase = "cp -r dist $out";
|
|
||||||
}
|
|
||||||
+50
-25
@@ -1,38 +1,61 @@
|
|||||||
{
|
{
|
||||||
lib,
|
lib,
|
||||||
craneLib,
|
|
||||||
pkgs,
|
pkgs,
|
||||||
|
rustToolchain,
|
||||||
runtimeLibs,
|
runtimeLibs,
|
||||||
frontend,
|
|
||||||
suwayomiServer,
|
suwayomiServer,
|
||||||
version,
|
version,
|
||||||
cargoSrc,
|
versions,
|
||||||
|
src,
|
||||||
appIcon,
|
appIcon,
|
||||||
}:
|
}:
|
||||||
|
|
||||||
let
|
pkgs.stdenv.mkDerivation {
|
||||||
commonArgs = {
|
|
||||||
src = cargoSrc;
|
|
||||||
pname = "moku";
|
pname = "moku";
|
||||||
inherit version;
|
inherit version src;
|
||||||
strictDeps = true;
|
|
||||||
|
nativeBuildInputs = with pkgs; [
|
||||||
|
rustToolchain
|
||||||
|
nodejs_22
|
||||||
|
pnpm
|
||||||
|
pnpmConfigHook
|
||||||
|
pkg-config
|
||||||
|
wrapGAppsHook3
|
||||||
|
rustPlatform.cargoSetupHook
|
||||||
|
];
|
||||||
|
|
||||||
buildInputs = runtimeLibs;
|
buildInputs = runtimeLibs;
|
||||||
nativeBuildInputs = with pkgs; [ pkg-config wrapGAppsHook3 ];
|
|
||||||
preBuild = ''
|
pnpmDeps = pkgs.fetchPnpmDeps {
|
||||||
cp -r ${frontend} ../dist
|
pname = "moku";
|
||||||
'';
|
inherit version src;
|
||||||
|
fetcherVersion = 3;
|
||||||
|
hash = versions.frontend.pnpmHash;
|
||||||
};
|
};
|
||||||
|
|
||||||
cargoArtifacts = craneLib.buildDepsOnly commonArgs;
|
cargoDeps = pkgs.rustPlatform.importCargoLock {
|
||||||
in
|
lockFile = ../src-tauri/Cargo.lock;
|
||||||
craneLib.buildPackage (commonArgs // {
|
outputHashes = {
|
||||||
inherit cargoArtifacts;
|
"tauri-plugin-discord-rpc-0.1.0" = versions.gitDeps.tauri-plugin-discord-rpc;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
meta.mainProgram = "moku";
|
env = {
|
||||||
|
PKG_CONFIG_PATH = "${pkgs.openssl.dev}/lib/pkgconfig";
|
||||||
|
TAURI_SKIP_DEVSERVER_CHECK = "true";
|
||||||
|
cargoRoot = "src-tauri";
|
||||||
|
};
|
||||||
|
|
||||||
|
buildPhase = ''
|
||||||
|
export HOME=$(mktemp -d)
|
||||||
|
pnpm tauri:build
|
||||||
|
'';
|
||||||
|
|
||||||
|
installPhase = ''
|
||||||
|
install -Dm755 src-tauri/target/release/moku $out/bin/moku
|
||||||
|
|
||||||
postInstall = ''
|
|
||||||
mkdir -p "$out/share/applications"
|
mkdir -p "$out/share/applications"
|
||||||
cat > "$out/share/applications/moku.desktop" << EOF
|
cat > "$out/share/applications/moku.desktop" << EOF2
|
||||||
[Desktop Entry]
|
[Desktop Entry]
|
||||||
Version=1.0
|
Version=1.0
|
||||||
Type=Application
|
Type=Application
|
||||||
@@ -44,17 +67,17 @@ Terminal=false
|
|||||||
Categories=Graphics;Viewer;
|
Categories=Graphics;Viewer;
|
||||||
Keywords=manga;comic;reader;suwayomi;
|
Keywords=manga;comic;reader;suwayomi;
|
||||||
StartupWMClass=moku
|
StartupWMClass=moku
|
||||||
EOF
|
EOF2
|
||||||
|
|
||||||
for size in 32x32 128x128 256x256 512x512; do
|
for size in 32x32 128x128 256x256 512x512; do
|
||||||
src="icons/$size.png"
|
f="src-tauri/icons/$size.png"
|
||||||
[ -f "$src" ] && install -Dm644 "$src" \
|
[ -f "$f" ] && install -Dm644 "$f" \
|
||||||
"$out/share/icons/hicolor/$size/apps/moku.png"
|
"$out/share/icons/hicolor/$size/apps/moku.png"
|
||||||
done
|
done
|
||||||
|
|
||||||
for size in 128x128 256x256; do
|
for size in 128x128 256x256; do
|
||||||
src="icons/''${size}@2x.png"
|
f="src-tauri/icons/''${size}@2x.png"
|
||||||
[ -f "$src" ] && install -Dm644 "$src" \
|
[ -f "$f" ] && install -Dm644 "$f" \
|
||||||
"$out/share/icons/hicolor/''${size}@2/apps/moku.png"
|
"$out/share/icons/hicolor/''${size}@2/apps/moku.png"
|
||||||
done
|
done
|
||||||
|
|
||||||
@@ -71,4 +94,6 @@ EOF
|
|||||||
--set GDK_BACKEND wayland \
|
--set GDK_BACKEND wayland \
|
||||||
--set WEBKIT_DISABLE_SANDBOX_THIS_IS_DANGEROUS 1
|
--set WEBKIT_DISABLE_SANDBOX_THIS_IS_DANGEROUS 1
|
||||||
'';
|
'';
|
||||||
})
|
|
||||||
|
meta.mainProgram = "moku";
|
||||||
|
}
|
||||||
|
|||||||
+57
-50
@@ -1,4 +1,4 @@
|
|||||||
{ pkgs, rustToolchain, version }:
|
{ pkgs, rustToolchain, version, versions }:
|
||||||
|
|
||||||
{
|
{
|
||||||
bump = pkgs.writeShellApplication {
|
bump = pkgs.writeShellApplication {
|
||||||
@@ -17,42 +17,28 @@
|
|||||||
VERSION="$1"
|
VERSION="$1"
|
||||||
REPO="$(git rev-parse --show-toplevel)"
|
REPO="$(git rev-parse --show-toplevel)"
|
||||||
|
|
||||||
echo "── Bumping version fields to $VERSION ──"
|
sed -i "s/moku = \"[^\"]*\"/moku = \"$VERSION\"/" "$REPO/nix/versions.nix"
|
||||||
sed -i "s/\"version\": \"[^\"]*\"/\"version\": \"$VERSION\"/" \
|
sed -i "s/\"version\": \"[^\"]*\"/\"version\": \"$VERSION\"/" "$REPO/src-tauri/tauri.conf.json"
|
||||||
"$REPO/src-tauri/tauri.conf.json"
|
sed -i "0,/^version = \"[^\"]*\"/s//version = \"$VERSION\"/" "$REPO/src-tauri/Cargo.toml"
|
||||||
sed -i "0,/^version = \"[^\"]*\"/s//version = \"$VERSION\"/" \
|
|
||||||
"$REPO/src-tauri/Cargo.toml"
|
|
||||||
sed -i "s/version = \"[^\"]*\";/version = \"$VERSION\";/g" \
|
|
||||||
"$REPO/flake.nix"
|
|
||||||
sed -i "s/^pkgver=.*/pkgver=$VERSION/" "$REPO/PKGBUILD"
|
sed -i "s/^pkgver=.*/pkgver=$VERSION/" "$REPO/PKGBUILD"
|
||||||
sed -i "s/^pkgrel=.*/pkgrel=1/" "$REPO/PKGBUILD"
|
sed -i "s/^pkgrel=.*/pkgrel=1/" "$REPO/PKGBUILD"
|
||||||
echo "Done"
|
|
||||||
|
|
||||||
echo "── Regenerating Cargo.lock ──"
|
|
||||||
(cd "$REPO/src-tauri" && cargo generate-lockfile)
|
(cd "$REPO/src-tauri" && cargo generate-lockfile)
|
||||||
echo "Done"
|
|
||||||
|
|
||||||
echo "── Building frontend ──"
|
|
||||||
cd "$REPO"
|
cd "$REPO"
|
||||||
pnpm install --frozen-lockfile
|
pnpm install --frozen-lockfile
|
||||||
pnpm build
|
pnpm build:static
|
||||||
echo "Done"
|
|
||||||
|
|
||||||
echo "── Repacking frontend-dist.tar.gz ──"
|
|
||||||
tar -czf "$REPO/packaging/frontend-dist.tar.gz" -C "$REPO" dist
|
tar -czf "$REPO/packaging/frontend-dist.tar.gz" -C "$REPO" dist
|
||||||
FRONTEND_SHA=$(sha256sum "$REPO/packaging/frontend-dist.tar.gz" | awk '{print $1}')
|
FRONTEND_SHA_HEX=$(sha256sum "$REPO/packaging/frontend-dist.tar.gz" | awk '{print $1}')
|
||||||
echo "sha256: $FRONTEND_SHA"
|
FRONTEND_SHA_SRI=$(echo "$FRONTEND_SHA_HEX" | xxd -r -p | base64 -w0 | sed 's/^/sha256-/')
|
||||||
|
|
||||||
echo "── Regenerating cargo-sources.json ──"
|
sed -i "s|distHash = \"[^\"]*\"|distHash = \"$FRONTEND_SHA_HEX\"|" "$REPO/nix/versions.nix"
|
||||||
python3 "$REPO/packaging/flatpak-cargo-generator.py" \
|
sed -i "s|distHashSri = \"[^\"]*\"|distHashSri = \"$FRONTEND_SHA_SRI\"|" "$REPO/nix/versions.nix"
|
||||||
"$REPO/src-tauri/Cargo.lock" \
|
|
||||||
-o "$REPO/packaging/cargo-sources.json"
|
|
||||||
echo "Done"
|
|
||||||
|
|
||||||
echo "── Patching flatpak manifest ──"
|
|
||||||
MANIFEST="$REPO/io.github.moku_project.Moku.yml"
|
MANIFEST="$REPO/io.github.moku_project.Moku.yml"
|
||||||
sed -i "s/tag: v[^[:space:]]*/tag: v$VERSION/" "$MANIFEST"
|
sed -i "s/tag: v[^[:space:]]*/tag: v$VERSION/" "$MANIFEST"
|
||||||
python3 - "$MANIFEST" "$FRONTEND_SHA" <<'PYEOF'
|
python3 - "$MANIFEST" "$FRONTEND_SHA_HEX" <<'PYEOF'
|
||||||
import re, sys
|
import re, sys
|
||||||
path, sha = sys.argv[1], sys.argv[2]
|
path, sha = sys.argv[1], sys.argv[2]
|
||||||
text = open(path).read()
|
text = open(path).read()
|
||||||
@@ -63,41 +49,66 @@
|
|||||||
sys.exit("ERROR: could not find frontend-dist sha256 in manifest")
|
sys.exit("ERROR: could not find frontend-dist sha256 in manifest")
|
||||||
open(path, 'w').write(updated)
|
open(path, 'w').write(updated)
|
||||||
PYEOF
|
PYEOF
|
||||||
echo "Done"
|
|
||||||
|
|
||||||
echo ""
|
python3 "$REPO/packaging/flatpak-cargo-generator.py" \
|
||||||
echo "Bumped to v$VERSION — commit, tag, push, then: nix run .#post-tag-bump -- $VERSION"
|
"$REPO/src-tauri/Cargo.lock" \
|
||||||
|
-o "$REPO/packaging/cargo-sources.json"
|
||||||
|
|
||||||
|
echo "Bumped to v$VERSION — commit, tag, push, then: nix run .#update -- $VERSION"
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
|
|
||||||
postTagBump = pkgs.writeShellApplication {
|
update = pkgs.writeShellApplication {
|
||||||
name = "moku-post-tag-bump";
|
name = "moku-update";
|
||||||
runtimeInputs = with pkgs; [ gnused coreutils git curl ];
|
runtimeInputs = with pkgs; [ gnused coreutils git curl nix xxd ];
|
||||||
text = ''
|
text = ''
|
||||||
[[ $# -lt 1 ]] && { echo "Usage: nix run .#post-tag-bump -- <version>"; exit 1; }
|
|
||||||
VERSION="$1"
|
|
||||||
REPO="$(git rev-parse --show-toplevel)"
|
REPO="$(git rev-parse --show-toplevel)"
|
||||||
|
VERSIONS="$REPO/nix/versions.nix"
|
||||||
MANIFEST="$REPO/io.github.moku_project.Moku.yml"
|
MANIFEST="$REPO/io.github.moku_project.Moku.yml"
|
||||||
PKGBUILD="$REPO/PKGBUILD"
|
PKGBUILD="$REPO/PKGBUILD"
|
||||||
|
|
||||||
echo "── Resolving commit for v$VERSION ──"
|
if [[ $# -ge 1 ]]; then
|
||||||
COMMIT=$(git ls-remote https://github.com/moku-project/Moku.git "refs/tags/v$VERSION" \
|
VERSION="$1"
|
||||||
| awk '{print $1}')
|
else
|
||||||
[[ -z "$COMMIT" ]] && { echo "ERROR: tag v$VERSION not found on remote"; exit 1; }
|
VERSION=$(grep 'moku = "' "$VERSIONS" | head -1 | sed 's/.*"\(.*\)".*/\1/')
|
||||||
echo "commit: $COMMIT"
|
fi
|
||||||
sed -i "s/commit: [0-9a-f]\{40\}/commit: $COMMIT/" "$MANIFEST"
|
|
||||||
echo "Done"
|
COMMIT=$(git ls-remote https://github.com/moku-project/Moku.git "refs/tags/v$VERSION" | awk '{print $1}')
|
||||||
|
[[ -z "$COMMIT" ]] && { echo "ERROR: tag v$VERSION not found on remote"; exit 1; }
|
||||||
|
sed -i "s/gitCommit = \"[^\"]*\"/gitCommit = \"$COMMIT\"/" "$VERSIONS"
|
||||||
|
sed -i "s/commit: [0-9a-f]\{40\}/commit: $COMMIT/" "$MANIFEST"
|
||||||
|
|
||||||
echo "── Fetching PKGBUILD tarball sha256 ──"
|
|
||||||
TARBALL_URL="https://github.com/moku-project/Moku/archive/refs/tags/v$VERSION.tar.gz"
|
TARBALL_URL="https://github.com/moku-project/Moku/archive/refs/tags/v$VERSION.tar.gz"
|
||||||
TARBALL_SHA=$(curl -fsSL "$TARBALL_URL" | sha256sum | awk '{print $1}')
|
TARBALL_SHA=$(curl -fsSL "$TARBALL_URL" | sha256sum | awk '{print $1}')
|
||||||
|
sed -i "s/tarballHash = \"[^\"]*\"/tarballHash = \"$TARBALL_SHA\"/" "$VERSIONS"
|
||||||
sed -i "/sha256sums=/,/)/{ 0,/'/s/'[^']*'/'$TARBALL_SHA'/ }" "$PKGBUILD"
|
sed -i "/sha256sums=/,/)/{ 0,/'/s/'[^']*'/'$TARBALL_SHA'/ }" "$PKGBUILD"
|
||||||
grep -q "$TARBALL_SHA" "$PKGBUILD" \
|
|
||||||
|| { echo "ERROR: PKGBUILD sha256 replacement failed"; exit 1; }
|
|
||||||
echo "Done"
|
|
||||||
|
|
||||||
echo ""
|
if [[ $# -ge 2 ]]; then
|
||||||
echo "post-tag-bump complete for v$VERSION"
|
SUWA_VER="$2"
|
||||||
|
JAR_URL="https://github.com/Suwayomi/Suwayomi-Server-preview/releases/download/v${SUWA_VER}/Suwayomi-Server-v${SUWA_VER}.jar"
|
||||||
|
|
||||||
|
SUWA_SHA_HEX=$(curl -fsSL "$JAR_URL" | sha256sum | awk '{print $1}')
|
||||||
|
SUWA_SHA_SRI=$(echo "$SUWA_SHA_HEX" | xxd -r -p | base64 -w0 | sed 's/^/sha256-/')
|
||||||
|
|
||||||
|
sed -i "s/version = \"[^\"]*\"/version = \"$SUWA_VER\"/" "$VERSIONS"
|
||||||
|
sed -i "s|hash = \"sha256-[^\"]*\"|hash = \"$SUWA_SHA_SRI\"|" "$VERSIONS"
|
||||||
|
|
||||||
|
sed -i "s|Suwayomi-Server-preview/releases/download/v[^/]*/|Suwayomi-Server-preview/releases/download/v${SUWA_VER}/|" "$MANIFEST"
|
||||||
|
sed -i "s|Suwayomi-Server-v[0-9.]*\.jar|Suwayomi-Server-v${SUWA_VER}.jar|g" "$MANIFEST"
|
||||||
|
python3 - "$MANIFEST" "$SUWA_SHA_HEX" <<'PYEOF'
|
||||||
|
import re, sys
|
||||||
|
path, sha = sys.argv[1], sys.argv[2]
|
||||||
|
text = open(path).read()
|
||||||
|
updated, n = re.subn(
|
||||||
|
r'(dest-filename:\s*Suwayomi-Server\.jar\s*\n\s*sha256:\s*)[0-9a-f]+',
|
||||||
|
r'\g<1>' + sha, text)
|
||||||
|
if n == 0:
|
||||||
|
sys.exit("ERROR: could not find Suwayomi jar sha256 in manifest")
|
||||||
|
open(path, 'w').write(updated)
|
||||||
|
PYEOF
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Done — versions.nix, flatpak manifest, and PKGBUILD patched for v$VERSION"
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -105,19 +116,15 @@
|
|||||||
name = "moku-flatpak";
|
name = "moku-flatpak";
|
||||||
runtimeInputs = with pkgs; [ coreutils git appstream flatpak-builder flatpak ];
|
runtimeInputs = with pkgs; [ coreutils git appstream flatpak-builder flatpak ];
|
||||||
text = ''
|
text = ''
|
||||||
[[ $# -lt 1 ]] && { echo "Usage: nix run .#flatpak -- <version>"; exit 1; }
|
|
||||||
REPO="$(git rev-parse --show-toplevel)"
|
REPO="$(git rev-parse --show-toplevel)"
|
||||||
MANIFEST="$REPO/io.github.moku_project.Moku.yml"
|
|
||||||
|
|
||||||
rm -rf "$REPO/build-dir" "$REPO/repo"
|
rm -rf "$REPO/build-dir" "$REPO/repo"
|
||||||
flatpak-builder \
|
flatpak-builder \
|
||||||
--repo="$REPO/repo" \
|
--repo="$REPO/repo" \
|
||||||
--force-clean \
|
--force-clean \
|
||||||
"$REPO/build-dir" \
|
"$REPO/build-dir" \
|
||||||
"$MANIFEST"
|
"$REPO/io.github.moku_project.Moku.yml"
|
||||||
flatpak build-bundle "$REPO/repo" "$REPO/moku.flatpak" io.github.moku_project.Moku
|
flatpak build-bundle "$REPO/repo" "$REPO/moku.flatpak" io.github.moku_project.Moku
|
||||||
rm -rf "$REPO/build-dir" "$REPO/repo"
|
rm -rf "$REPO/build-dir" "$REPO/repo"
|
||||||
|
|
||||||
echo "moku.flatpak created"
|
echo "moku.flatpak created"
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
|
|||||||
+8
-6
@@ -4,17 +4,19 @@
|
|||||||
fetchurl,
|
fetchurl,
|
||||||
makeWrapper,
|
makeWrapper,
|
||||||
jdk21_headless,
|
jdk21_headless,
|
||||||
|
versions,
|
||||||
}:
|
}:
|
||||||
let
|
let
|
||||||
jdk = jdk21_headless;
|
jdk = jdk21_headless;
|
||||||
|
ver = versions.suwayomi;
|
||||||
in
|
in
|
||||||
stdenvNoCC.mkDerivation (finalAttrs: {
|
stdenvNoCC.mkDerivation {
|
||||||
pname = "suwayomi-server";
|
pname = "suwayomi-server";
|
||||||
version = "2.1.2087";
|
version = ver.version;
|
||||||
|
|
||||||
src = fetchurl {
|
src = fetchurl {
|
||||||
url = "https://github.com/Suwayomi/Suwayomi-Server-preview/releases/download/v${finalAttrs.version}/Suwayomi-Server-v${finalAttrs.version}.jar";
|
url = "https://github.com/Suwayomi/Suwayomi-Server-preview/releases/download/v${ver.version}/Suwayomi-Server-v${ver.version}.jar";
|
||||||
hash = "sha256-9YmkImdCUjlME7KJqci+aRkFv1g++39NXxUBrl6R5rM=";
|
hash = ver.hash;
|
||||||
};
|
};
|
||||||
|
|
||||||
nativeBuildInputs = [ makeWrapper ];
|
nativeBuildInputs = [ makeWrapper ];
|
||||||
@@ -37,10 +39,10 @@ stdenvNoCC.mkDerivation (finalAttrs: {
|
|||||||
description = "Free and open source manga reader server that runs extensions built for Mihon (Tachiyomi)";
|
description = "Free and open source manga reader server that runs extensions built for Mihon (Tachiyomi)";
|
||||||
homepage = "https://github.com/Suwayomi/Suwayomi-Server";
|
homepage = "https://github.com/Suwayomi/Suwayomi-Server";
|
||||||
downloadPage = "https://github.com/Suwayomi/Suwayomi-Server-preview/releases";
|
downloadPage = "https://github.com/Suwayomi/Suwayomi-Server-preview/releases";
|
||||||
changelog = "https://github.com/Suwayomi/Suwayomi-Server-preview/releases/tag/v${finalAttrs.version}";
|
changelog = "https://github.com/Suwayomi/Suwayomi-Server-preview/releases/tag/v${ver.version}";
|
||||||
license = lib.licenses.mpl20;
|
license = lib.licenses.mpl20;
|
||||||
platforms = jdk.meta.platforms;
|
platforms = jdk.meta.platforms;
|
||||||
sourceProvenance = [ lib.sourceTypes.binaryBytecode ];
|
sourceProvenance = [ lib.sourceTypes.binaryBytecode ];
|
||||||
mainProgram = "suwayomi-server";
|
mainProgram = "suwayomi-server";
|
||||||
};
|
};
|
||||||
})
|
}
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
moku = "0.9.4";
|
||||||
|
|
||||||
|
suwayomi = {
|
||||||
|
version = "2.1.2087";
|
||||||
|
hash = "sha256-9YmkImdCUjlME7KJqci+aRkFv1g++39NXxUBrl6R5rM=";
|
||||||
|
};
|
||||||
|
|
||||||
|
frontend = {
|
||||||
|
pnpmHash = "sha256-18twdFhprV9v9hzvqxuVDHD6Tm4zHNDJs7s6l/7ClBo=";
|
||||||
|
distHash = "7db288b4b54277aa82b6ec5b21fc31a1e71f8246c50a74777500083b806c1fa5";
|
||||||
|
distHashSri = "sha256-fbiiu0tCd6qCtu+SIfw+aR8Yj2bFCnR3dQAIO4BvwfM=";
|
||||||
|
};
|
||||||
|
|
||||||
|
gitDeps = {
|
||||||
|
tauri-plugin-discord-rpc = "sha256-xq0qyK2NrwSAFDhXo0vbvcygRD2/7uqBaLpqfpfxkrc=";
|
||||||
|
};
|
||||||
|
|
||||||
|
gitCommit = "239960683b6c7f1347e1798b0e179a8a46628728";
|
||||||
|
tarballHash = "";
|
||||||
|
}
|
||||||
+33
-18
@@ -1,33 +1,48 @@
|
|||||||
{
|
{
|
||||||
"name": "moku",
|
"name": "moku",
|
||||||
"version": "0.5.0",
|
"private": true,
|
||||||
|
"version": "0.9.4",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite dev",
|
||||||
"build": "vite build",
|
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"tauri": "tauri",
|
"prepare": "svelte-kit sync || echo ''",
|
||||||
"tauri:dev": "tauri dev --config src-tauri/tauri.dev.conf.json"
|
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||||
|
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||||
|
"build:static": "MOKU_TARGET=static vite build",
|
||||||
|
"build:node": "MOKU_TARGET=node vite build",
|
||||||
|
"build:android": "MOKU_TARGET=static vite build",
|
||||||
|
"tauri:dev": "tauri dev --config src-tauri/tauri.dev.conf.json",
|
||||||
|
"tauri:build": "tauri build"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@sveltejs/adapter-node": "^5.5.4",
|
||||||
|
"@sveltejs/adapter-static": "^3.0.10",
|
||||||
|
"@sveltejs/kit": "^2.62.0",
|
||||||
|
"@sveltejs/vite-plugin-svelte": "^7.1.2",
|
||||||
|
"@tauri-apps/cli": "^2.11.2",
|
||||||
|
"svelte": "^5.56.1",
|
||||||
|
"svelte-check": "^4.5.0",
|
||||||
|
"typescript": "^6.0.3",
|
||||||
|
"vite": "^8.0.16"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@capacitor/app": "^8.1.0",
|
||||||
|
"@capacitor/browser": "^8.0.3",
|
||||||
|
"@capacitor/core": "^8.4.0",
|
||||||
|
"@capacitor/filesystem": "^8.1.2",
|
||||||
|
"@capacitor/preferences": "^8.0.1",
|
||||||
"@tauri-apps/api": "^2.11.0",
|
"@tauri-apps/api": "^2.11.0",
|
||||||
"@tauri-apps/plugin-http": "^2.5.8",
|
"@tauri-apps/plugin-dialog": "^2.7.1",
|
||||||
|
"@tauri-apps/plugin-fs": "^2.5.1",
|
||||||
|
"@tauri-apps/plugin-http": "^2.5.9",
|
||||||
"@tauri-apps/plugin-os": "^2.3.2",
|
"@tauri-apps/plugin-os": "^2.3.2",
|
||||||
"@tauri-apps/plugin-process": "^2.3.1",
|
"@tauri-apps/plugin-process": "^2.3.1",
|
||||||
"@tauri-apps/plugin-shell": "^2.3.5",
|
"@tauri-apps/plugin-shell": "^2.3.5",
|
||||||
"@tauri-apps/plugin-store": "~2.4.2",
|
"@tauri-apps/plugin-store": "^2.4.3",
|
||||||
|
"capacitor-native-biometric": "^4.2.2",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"phosphor-svelte": "^3.1.0",
|
"phosphor-svelte": "^3.1.0",
|
||||||
"svelte-spa-router": "^5.1.0",
|
"tauri-plugin-discord-rpc-api": "github:Youwes09/tauri-plugin-discord-rpc"
|
||||||
"tauri-plugin-discord-rpc-api": "github:Youwes09/tauri-plugin-discord-rpc",
|
|
||||||
"tauri-plugin-drpc": "^1.0.3"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
|
||||||
"@tauri-apps/cli": "^2.11.0",
|
|
||||||
"svelte": "^5.55.5",
|
|
||||||
"svelte-check": "^4.4.7",
|
|
||||||
"typescript": "^6.0.3",
|
|
||||||
"vite": "^8.0.10"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Generated
+813
-234
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,2 @@
|
|||||||
|
onlyBuiltDependencies:
|
||||||
|
- esbuild
|
||||||
Generated
+111
-111
@@ -78,9 +78,9 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "autocfg"
|
name = "autocfg"
|
||||||
version = "1.5.0"
|
version = "1.5.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
|
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "base64"
|
name = "base64"
|
||||||
@@ -117,9 +117,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bitflags"
|
name = "bitflags"
|
||||||
version = "2.11.1"
|
version = "2.12.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
|
checksum = "84d7ced0ae9557296835c32bf1b1e02b44c746701f898460fb000d7eaa84f00a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"serde_core",
|
"serde_core",
|
||||||
]
|
]
|
||||||
@@ -144,9 +144,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "brotli"
|
name = "brotli"
|
||||||
version = "8.0.2"
|
version = "8.0.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560"
|
checksum = "8119e4516436f5708bbc474a9d395bf12f1b5395e93a92a56e647ac3388c8610"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"alloc-no-stdlib",
|
"alloc-no-stdlib",
|
||||||
"alloc-stdlib",
|
"alloc-stdlib",
|
||||||
@@ -155,9 +155,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "brotli-decompressor"
|
name = "brotli-decompressor"
|
||||||
version = "5.0.0"
|
version = "5.0.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03"
|
checksum = "5962523e1b92ce1b5e793d9169b9943eece10d39f62550bc04bb605d75b94924"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"alloc-no-stdlib",
|
"alloc-no-stdlib",
|
||||||
"alloc-stdlib",
|
"alloc-stdlib",
|
||||||
@@ -174,9 +174,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bumpalo"
|
name = "bumpalo"
|
||||||
version = "3.20.2"
|
version = "3.20.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
|
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bytemuck"
|
name = "bytemuck"
|
||||||
@@ -205,7 +205,7 @@ version = "0.18.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2"
|
checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.11.1",
|
"bitflags 2.12.1",
|
||||||
"cairo-sys-rs",
|
"cairo-sys-rs",
|
||||||
"glib",
|
"glib",
|
||||||
"libc",
|
"libc",
|
||||||
@@ -268,9 +268,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cc"
|
name = "cc"
|
||||||
version = "1.2.62"
|
version = "1.2.63"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98"
|
checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"find-msvc-tools",
|
"find-msvc-tools",
|
||||||
"shlex",
|
"shlex",
|
||||||
@@ -290,7 +290,7 @@ checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"byteorder",
|
"byteorder",
|
||||||
"fnv",
|
"fnv",
|
||||||
"uuid 1.23.1",
|
"uuid 1.23.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -398,7 +398,7 @@ version = "0.25.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97"
|
checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.11.1",
|
"bitflags 2.12.1",
|
||||||
"core-foundation 0.10.1",
|
"core-foundation 0.10.1",
|
||||||
"core-graphics-types",
|
"core-graphics-types",
|
||||||
"foreign-types 0.5.0",
|
"foreign-types 0.5.0",
|
||||||
@@ -411,7 +411,7 @@ version = "0.2.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
|
checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.11.1",
|
"bitflags 2.12.1",
|
||||||
"core-foundation 0.10.1",
|
"core-foundation 0.10.1",
|
||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
@@ -672,7 +672,7 @@ version = "0.3.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
|
checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.11.1",
|
"bitflags 2.12.1",
|
||||||
"block2",
|
"block2",
|
||||||
"libc",
|
"libc",
|
||||||
"objc2",
|
"objc2",
|
||||||
@@ -680,9 +680,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "displaydoc"
|
name = "displaydoc"
|
||||||
version = "0.2.5"
|
version = "0.2.6"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0"
|
checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
@@ -789,9 +789,9 @@ checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "either"
|
name = "either"
|
||||||
version = "1.15.0"
|
version = "1.16.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
|
checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "embed-resource"
|
name = "embed-resource"
|
||||||
@@ -1228,7 +1228,7 @@ version = "0.18.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5"
|
checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.11.1",
|
"bitflags 2.12.1",
|
||||||
"futures-channel",
|
"futures-channel",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"futures-executor",
|
"futures-executor",
|
||||||
@@ -1408,9 +1408,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "http"
|
name = "http"
|
||||||
version = "1.4.0"
|
version = "1.4.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a"
|
checksum = "8be7462df143984c4598a256ef469b251d7d7f9e271135073e78fc535414f3d0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bytes",
|
"bytes",
|
||||||
"itoa",
|
"itoa",
|
||||||
@@ -1447,9 +1447,9 @@ checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hyper"
|
name = "hyper"
|
||||||
version = "1.9.0"
|
version = "1.10.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca"
|
checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"atomic-waker",
|
"atomic-waker",
|
||||||
"bytes",
|
"bytes",
|
||||||
@@ -1804,9 +1804,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "js-sys"
|
name = "js-sys"
|
||||||
version = "0.3.98"
|
version = "0.3.99"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08"
|
checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
@@ -1842,7 +1842,7 @@ version = "0.7.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a"
|
checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.11.1",
|
"bitflags 2.12.1",
|
||||||
"serde",
|
"serde",
|
||||||
"unicode-segmentation",
|
"unicode-segmentation",
|
||||||
]
|
]
|
||||||
@@ -1904,9 +1904,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libredox"
|
name = "libredox"
|
||||||
version = "0.1.16"
|
version = "0.1.17"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c"
|
checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
@@ -1940,9 +1940,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "log"
|
name = "log"
|
||||||
version = "0.4.29"
|
version = "0.4.31"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
checksum = "113b30b4cd05f7c06868fdb2854f66a7b9fece9a48425351cd532e810d74024f"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lru-slab"
|
name = "lru-slab"
|
||||||
@@ -1963,9 +1963,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "memchr"
|
name = "memchr"
|
||||||
version = "2.8.0"
|
version = "2.8.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
|
checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "memoffset"
|
name = "memoffset"
|
||||||
@@ -1994,9 +1994,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mio"
|
name = "mio"
|
||||||
version = "1.2.0"
|
version = "1.2.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1"
|
checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"wasi",
|
"wasi",
|
||||||
@@ -2029,9 +2029,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "muda"
|
name = "muda"
|
||||||
version = "0.19.1"
|
version = "0.19.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0ae8844f63b5b118e334e205585b8c5c17b984121dbdb179d44aeb087ffad3cb"
|
checksum = "47a2e3dff89cd322c66647942668faee0a2b1f88ea6cbb4d374b4a8d7e92528c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"crossbeam-channel",
|
"crossbeam-channel",
|
||||||
"dpi",
|
"dpi",
|
||||||
@@ -2071,7 +2071,7 @@ version = "0.9.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4"
|
checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.11.1",
|
"bitflags 2.12.1",
|
||||||
"jni-sys 0.3.1",
|
"jni-sys 0.3.1",
|
||||||
"log",
|
"log",
|
||||||
"ndk-sys",
|
"ndk-sys",
|
||||||
@@ -2097,11 +2097,11 @@ checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "nix"
|
name = "nix"
|
||||||
version = "0.30.1"
|
version = "0.31.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6"
|
checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.11.1",
|
"bitflags 2.12.1",
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"cfg_aliases",
|
"cfg_aliases",
|
||||||
"libc",
|
"libc",
|
||||||
@@ -2118,9 +2118,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "num-conv"
|
name = "num-conv"
|
||||||
version = "0.2.1"
|
version = "0.2.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967"
|
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "num-traits"
|
name = "num-traits"
|
||||||
@@ -2169,7 +2169,7 @@ version = "0.3.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c"
|
checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.11.1",
|
"bitflags 2.12.1",
|
||||||
"block2",
|
"block2",
|
||||||
"objc2",
|
"objc2",
|
||||||
"objc2-core-foundation",
|
"objc2-core-foundation",
|
||||||
@@ -2182,7 +2182,7 @@ version = "0.3.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c"
|
checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.11.1",
|
"bitflags 2.12.1",
|
||||||
"objc2",
|
"objc2",
|
||||||
"objc2-foundation",
|
"objc2-foundation",
|
||||||
]
|
]
|
||||||
@@ -2203,7 +2203,7 @@ version = "0.3.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
|
checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.11.1",
|
"bitflags 2.12.1",
|
||||||
"dispatch2",
|
"dispatch2",
|
||||||
"objc2",
|
"objc2",
|
||||||
]
|
]
|
||||||
@@ -2214,7 +2214,7 @@ version = "0.3.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807"
|
checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.11.1",
|
"bitflags 2.12.1",
|
||||||
"dispatch2",
|
"dispatch2",
|
||||||
"objc2",
|
"objc2",
|
||||||
"objc2-core-foundation",
|
"objc2-core-foundation",
|
||||||
@@ -2247,7 +2247,7 @@ version = "0.3.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d"
|
checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.11.1",
|
"bitflags 2.12.1",
|
||||||
"objc2",
|
"objc2",
|
||||||
"objc2-core-foundation",
|
"objc2-core-foundation",
|
||||||
"objc2-core-graphics",
|
"objc2-core-graphics",
|
||||||
@@ -2274,7 +2274,7 @@ version = "0.3.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
|
checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.11.1",
|
"bitflags 2.12.1",
|
||||||
"block2",
|
"block2",
|
||||||
"libc",
|
"libc",
|
||||||
"objc2",
|
"objc2",
|
||||||
@@ -2297,7 +2297,7 @@ version = "0.3.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d"
|
checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.11.1",
|
"bitflags 2.12.1",
|
||||||
"objc2",
|
"objc2",
|
||||||
"objc2-core-foundation",
|
"objc2-core-foundation",
|
||||||
]
|
]
|
||||||
@@ -2308,7 +2308,7 @@ version = "0.3.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f"
|
checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.11.1",
|
"bitflags 2.12.1",
|
||||||
"objc2",
|
"objc2",
|
||||||
"objc2-core-foundation",
|
"objc2-core-foundation",
|
||||||
"objc2-foundation",
|
"objc2-foundation",
|
||||||
@@ -2320,7 +2320,7 @@ version = "0.3.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22"
|
checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.11.1",
|
"bitflags 2.12.1",
|
||||||
"block2",
|
"block2",
|
||||||
"objc2",
|
"objc2",
|
||||||
"objc2-cloud-kit",
|
"objc2-cloud-kit",
|
||||||
@@ -2351,7 +2351,7 @@ version = "0.3.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f"
|
checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.11.1",
|
"bitflags 2.12.1",
|
||||||
"block2",
|
"block2",
|
||||||
"objc2",
|
"objc2",
|
||||||
"objc2-app-kit",
|
"objc2-app-kit",
|
||||||
@@ -2383,7 +2383,7 @@ version = "0.10.80"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967"
|
checksum = "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.11.1",
|
"bitflags 2.12.1",
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"foreign-types 0.3.2",
|
"foreign-types 0.3.2",
|
||||||
"libc",
|
"libc",
|
||||||
@@ -2428,9 +2428,9 @@ checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "os_info"
|
name = "os_info"
|
||||||
version = "3.14.0"
|
version = "3.15.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e4022a17595a00d6a369236fdae483f0de7f0a339960a53118b818238e132224"
|
checksum = "9cf20a545b305cf1da722b236b5155c9bb35f1d5ceb28c048bd96ca842f41b5b"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"android_system_properties",
|
"android_system_properties",
|
||||||
"log",
|
"log",
|
||||||
@@ -2609,7 +2609,7 @@ version = "0.18.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
|
checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.11.1",
|
"bitflags 2.12.1",
|
||||||
"crc32fast",
|
"crc32fast",
|
||||||
"fdeflate",
|
"fdeflate",
|
||||||
"flate2",
|
"flate2",
|
||||||
@@ -2682,7 +2682,7 @@ version = "3.5.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
|
checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"toml_edit 0.25.11+spec-1.1.0",
|
"toml_edit 0.25.12+spec-1.1.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2880,7 +2880,7 @@ version = "0.5.18"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
|
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.11.1",
|
"bitflags 2.12.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3004,9 +3004,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "reqwest"
|
name = "reqwest"
|
||||||
version = "0.13.3"
|
version = "0.13.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "62e0021ea2c22aed41653bc7e1419abb2c97e038ff2c33d0e1309e49a97deec0"
|
checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"bytes",
|
"bytes",
|
||||||
@@ -3095,7 +3095,7 @@ version = "1.1.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
|
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.11.1",
|
"bitflags 2.12.1",
|
||||||
"errno",
|
"errno",
|
||||||
"libc",
|
"libc",
|
||||||
"linux-raw-sys",
|
"linux-raw-sys",
|
||||||
@@ -3179,7 +3179,7 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"url",
|
"url",
|
||||||
"uuid 1.23.1",
|
"uuid 1.23.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3230,7 +3230,7 @@ version = "3.7.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
|
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.11.1",
|
"bitflags 2.12.1",
|
||||||
"core-foundation 0.10.1",
|
"core-foundation 0.10.1",
|
||||||
"core-foundation-sys",
|
"core-foundation-sys",
|
||||||
"libc",
|
"libc",
|
||||||
@@ -3253,7 +3253,7 @@ version = "0.36.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c"
|
checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.11.1",
|
"bitflags 2.12.1",
|
||||||
"cssparser",
|
"cssparser",
|
||||||
"derive_more",
|
"derive_more",
|
||||||
"log",
|
"log",
|
||||||
@@ -3331,9 +3331,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde_json"
|
name = "serde_json"
|
||||||
version = "1.0.149"
|
version = "1.0.150"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
|
checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"itoa",
|
"itoa",
|
||||||
"memchr",
|
"memchr",
|
||||||
@@ -3470,9 +3470,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "shlex"
|
name = "shlex"
|
||||||
version = "1.3.0"
|
version = "2.0.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
|
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sigchld"
|
name = "sigchld"
|
||||||
@@ -3531,9 +3531,9 @@ checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "socket2"
|
name = "socket2"
|
||||||
version = "0.6.3"
|
version = "0.6.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
|
checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
@@ -3724,7 +3724,7 @@ version = "0.7.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
|
checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.11.1",
|
"bitflags 2.12.1",
|
||||||
"core-foundation 0.9.4",
|
"core-foundation 0.9.4",
|
||||||
"system-configuration-sys",
|
"system-configuration-sys",
|
||||||
]
|
]
|
||||||
@@ -3754,11 +3754,11 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tao"
|
name = "tao"
|
||||||
version = "0.35.2"
|
version = "0.35.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a33f7f9e486ade65fcf1e45c440f9236c904f5c1002cdc7fc6ae582777345ce4"
|
checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.11.1",
|
"bitflags 2.12.1",
|
||||||
"block2",
|
"block2",
|
||||||
"core-foundation 0.10.1",
|
"core-foundation 0.10.1",
|
||||||
"core-graphics",
|
"core-graphics",
|
||||||
@@ -3839,7 +3839,7 @@ dependencies = [
|
|||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"plist",
|
"plist",
|
||||||
"raw-window-handle",
|
"raw-window-handle",
|
||||||
"reqwest 0.13.3",
|
"reqwest 0.13.4",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"serde_repr",
|
"serde_repr",
|
||||||
@@ -3904,7 +3904,7 @@ dependencies = [
|
|||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"time",
|
"time",
|
||||||
"url",
|
"url",
|
||||||
"uuid 1.23.1",
|
"uuid 1.23.2",
|
||||||
"walkdir",
|
"walkdir",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -4171,7 +4171,7 @@ dependencies = [
|
|||||||
"toml 1.1.2+spec-1.1.0",
|
"toml 1.1.2+spec-1.1.0",
|
||||||
"url",
|
"url",
|
||||||
"urlpattern",
|
"urlpattern",
|
||||||
"uuid 1.23.1",
|
"uuid 1.23.2",
|
||||||
"walkdir",
|
"walkdir",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -4459,9 +4459,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "toml_edit"
|
name = "toml_edit"
|
||||||
version = "0.25.11+spec-1.1.0"
|
version = "0.25.12+spec-1.1.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b"
|
checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"indexmap 2.14.0",
|
"indexmap 2.14.0",
|
||||||
"toml_datetime 1.1.1+spec-1.1.0",
|
"toml_datetime 1.1.1+spec-1.1.0",
|
||||||
@@ -4501,11 +4501,11 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tower-http"
|
name = "tower-http"
|
||||||
version = "0.6.10"
|
version = "0.6.11"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "68d6fdd9f81c2819c9a8b0e0cd91660e7746a8e6ea2ba7c6b2b057985f6bcb51"
|
checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.11.1",
|
"bitflags 2.12.1",
|
||||||
"bytes",
|
"bytes",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"http",
|
"http",
|
||||||
@@ -4596,9 +4596,9 @@ checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "typenum"
|
name = "typenum"
|
||||||
version = "1.20.0"
|
version = "1.20.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de"
|
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unic-char-property"
|
name = "unic-char-property"
|
||||||
@@ -4649,9 +4649,9 @@ checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicode-segmentation"
|
name = "unicode-segmentation"
|
||||||
version = "1.13.2"
|
version = "1.13.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c"
|
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicode-xid"
|
name = "unicode-xid"
|
||||||
@@ -4719,9 +4719,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "uuid"
|
name = "uuid"
|
||||||
version = "1.23.1"
|
version = "1.23.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76"
|
checksum = "d258b83ceec21034727ecee8c382cfa6c3e133699b0742c64571814fb420c9f7"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"getrandom 0.4.2",
|
"getrandom 0.4.2",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
@@ -4812,9 +4812,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasm-bindgen"
|
name = "wasm-bindgen"
|
||||||
version = "0.2.121"
|
version = "0.2.122"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790"
|
checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
@@ -4825,9 +4825,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasm-bindgen-futures"
|
name = "wasm-bindgen-futures"
|
||||||
version = "0.4.71"
|
version = "0.4.72"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8"
|
checksum = "9473dbd2991ae90b6291c3c32c30c6187ac49aa32f9905d1cce280ec1e110b0f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
@@ -4835,9 +4835,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasm-bindgen-macro"
|
name = "wasm-bindgen-macro"
|
||||||
version = "0.2.121"
|
version = "0.2.122"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578"
|
checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"quote",
|
"quote",
|
||||||
"wasm-bindgen-macro-support",
|
"wasm-bindgen-macro-support",
|
||||||
@@ -4845,9 +4845,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasm-bindgen-macro-support"
|
name = "wasm-bindgen-macro-support"
|
||||||
version = "0.2.121"
|
version = "0.2.122"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2"
|
checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bumpalo",
|
"bumpalo",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
@@ -4858,9 +4858,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasm-bindgen-shared"
|
name = "wasm-bindgen-shared"
|
||||||
version = "0.2.121"
|
version = "0.2.122"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441"
|
checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
]
|
]
|
||||||
@@ -4906,7 +4906,7 @@ version = "0.244.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
|
checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.11.1",
|
"bitflags 2.12.1",
|
||||||
"hashbrown 0.15.5",
|
"hashbrown 0.15.5",
|
||||||
"indexmap 2.14.0",
|
"indexmap 2.14.0",
|
||||||
"semver",
|
"semver",
|
||||||
@@ -4914,9 +4914,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "web-sys"
|
name = "web-sys"
|
||||||
version = "0.3.98"
|
version = "0.3.99"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa"
|
checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
@@ -5759,7 +5759,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
|
checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"bitflags 2.11.1",
|
"bitflags 2.12.1",
|
||||||
"indexmap 2.14.0",
|
"indexmap 2.14.0",
|
||||||
"log",
|
"log",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -5862,9 +5862,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "yoke"
|
name = "yoke"
|
||||||
version = "0.8.2"
|
version = "0.8.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca"
|
checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"stable_deref_trait",
|
"stable_deref_trait",
|
||||||
"yoke-derive",
|
"yoke-derive",
|
||||||
@@ -5885,18 +5885,18 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zerocopy"
|
name = "zerocopy"
|
||||||
version = "0.8.48"
|
version = "0.8.50"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9"
|
checksum = "3b065d4f0e55f82fae73202e189638116a87c55ab6b8e6c2721e13dd9d854ad1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"zerocopy-derive",
|
"zerocopy-derive",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zerocopy-derive"
|
name = "zerocopy-derive"
|
||||||
version = "0.8.48"
|
version = "0.8.50"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4"
|
checksum = "0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
|
|||||||
@@ -4,8 +4,9 @@
|
|||||||
"version": "0.9.4",
|
"version": "0.9.4",
|
||||||
"identifier": "io.github.MokuProject.Moku",
|
"identifier": "io.github.MokuProject.Moku",
|
||||||
"build": {
|
"build": {
|
||||||
|
"devUrl": "http://localhost:1420",
|
||||||
"frontendDist": "../dist",
|
"frontendDist": "../dist",
|
||||||
"beforeBuildCommand": "pnpm build"
|
"beforeBuildCommand": "pnpm build:static"
|
||||||
},
|
},
|
||||||
"app": {
|
"app": {
|
||||||
"windows": [
|
"windows": [
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
{
|
{
|
||||||
"build": {
|
"build": {
|
||||||
"devUrl": "http://localhost:1420",
|
|
||||||
"beforeDevCommand": "pnpm dev"
|
"beforeDevCommand": "pnpm dev"
|
||||||
},
|
},
|
||||||
"app": {
|
"app": {
|
||||||
|
|||||||
-364
@@ -1,364 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { onMount } from "svelte";
|
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
|
||||||
import { listen } from "@tauri-apps/api/event";
|
|
||||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
|
||||||
import { platform } from "@tauri-apps/plugin-os";
|
|
||||||
import { store, updateSettings, setActiveDownloads } from "@store/state.svelte";
|
|
||||||
import { downloadStore } from "@features/downloads/store/downloadState.svelte";
|
|
||||||
import { boot, initStore, startProbe, stopProbe, retryBoot, bypassBoot } from "@store/boot.svelte";
|
|
||||||
import { initRpc, setIdle, clearReading, destroyRpc } from "@store/discord";
|
|
||||||
import { applyTheme } from "@core/theme";
|
|
||||||
import { applyZoom, mountZoomKey, mountIdleDetection } from "@core/ui";
|
|
||||||
import { checkForUpdateSilently } from "@core/updater";
|
|
||||||
import Layout from "@shared/chrome/Layout.svelte";
|
|
||||||
import Reader from "@features/reader/components/Reader.svelte";
|
|
||||||
import Settings from "@features/settings/components/Settings.svelte";
|
|
||||||
import ThemeEditor from "@features/settings/components/ThemeEditor.svelte";
|
|
||||||
import TitleBar from "@shared/chrome/TitleBar.svelte";
|
|
||||||
import Toaster from "@shared/chrome/Toaster.svelte";
|
|
||||||
import SplashScreen from "@shared/chrome/SplashScreen.svelte";
|
|
||||||
import MangaPreview from "@shared/manga/MangaPreview.svelte";
|
|
||||||
import AuthGate from "@shared/chrome/AuthGate.svelte";
|
|
||||||
|
|
||||||
const win = getCurrentWindow();
|
|
||||||
void platform();
|
|
||||||
|
|
||||||
let appReady = $state(false);
|
|
||||||
let idle = $state(false);
|
|
||||||
let devSplash = $state(false);
|
|
||||||
|
|
||||||
let themeEditorOpen = $state(false);
|
|
||||||
let themeEditorEditId = $state<string | null>(null);
|
|
||||||
|
|
||||||
let closeDialogOpen = $state(false);
|
|
||||||
let closeRemember = $state(false);
|
|
||||||
|
|
||||||
function openThemeEditor(id?: string | null) {
|
|
||||||
themeEditorEditId = id ?? null;
|
|
||||||
themeEditorOpen = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeThemeEditor() {
|
|
||||||
themeEditorOpen = false;
|
|
||||||
themeEditorEditId = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function doQuit() {
|
|
||||||
if (store.settings.autoStartServer) {
|
|
||||||
await Promise.race([
|
|
||||||
invoke("kill_server").catch(() => {}),
|
|
||||||
new Promise(res => setTimeout(res, 2000)),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
await invoke("exit_app");
|
|
||||||
}
|
|
||||||
|
|
||||||
async function doHide() {
|
|
||||||
await win.hide();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleCloseRequested() {
|
|
||||||
const action = store.settings.closeAction ?? "ask";
|
|
||||||
if (action === "tray") { await doHide(); return; }
|
|
||||||
if (action === "quit") { await doQuit(); return; }
|
|
||||||
closeDialogOpen = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function confirmClose(choice: "tray" | "quit") {
|
|
||||||
closeDialogOpen = false;
|
|
||||||
if (closeRemember) updateSettings({ closeAction: choice });
|
|
||||||
closeRemember = false;
|
|
||||||
if (choice === "tray") await doHide();
|
|
||||||
else await doQuit();
|
|
||||||
}
|
|
||||||
|
|
||||||
$effect(() => { void store.settings.theme; applyTheme(); });
|
|
||||||
$effect(() => { void store.settings.uiZoom; applyZoom(); });
|
|
||||||
$effect(() => mountZoomKey());
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (!appReady) return;
|
|
||||||
return mountIdleDetection(
|
|
||||||
() => { idle = true; },
|
|
||||||
() => { if (idle) idle = false; },
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (!appReady) return;
|
|
||||||
const timer = setTimeout(checkForUpdateSilently, 5_000);
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
});
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (!appReady) return;
|
|
||||||
downloadStore.poll();
|
|
||||||
const dlInterval = setInterval(() => downloadStore.poll(), 2000);
|
|
||||||
return () => clearInterval(dlInterval);
|
|
||||||
});
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (store.settings.discordRpc) {
|
|
||||||
initRpc();
|
|
||||||
} else {
|
|
||||||
clearReading();
|
|
||||||
destroyRpc();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (!store.activeChapter && store.settings.discordRpc) setIdle();
|
|
||||||
});
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
const next = downloadStore.queue.slice();
|
|
||||||
downloadStore.detectTransitions(next);
|
|
||||||
});
|
|
||||||
|
|
||||||
onMount(async () => {
|
|
||||||
document.addEventListener("contextmenu", e => e.preventDefault());
|
|
||||||
(window as any).__mokuShowSplash = () => { devSplash = true; };
|
|
||||||
|
|
||||||
applyZoom();
|
|
||||||
|
|
||||||
store.isFullscreen = await win.isFullscreen();
|
|
||||||
|
|
||||||
const unlistenResize = await win.onResized(async () => {
|
|
||||||
store.isFullscreen = await win.isFullscreen();
|
|
||||||
});
|
|
||||||
|
|
||||||
const unlistenScale = await win.onScaleChanged(async () => {
|
|
||||||
applyZoom();
|
|
||||||
});
|
|
||||||
|
|
||||||
const unlistenClose = await win.listen("tauri://close-requested", handleCloseRequested);
|
|
||||||
|
|
||||||
await initStore();
|
|
||||||
startProbe();
|
|
||||||
|
|
||||||
if (store.settings.autoStartServer) {
|
|
||||||
invoke<void>("spawn_server", {
|
|
||||||
binary: store.settings.serverBinary,
|
|
||||||
webUiEnabled: store.settings.suwayomiWebUI ?? false,
|
|
||||||
}).catch((err: any) => {
|
|
||||||
if (err?.kind === "NotConfigured") boot.notConfigured = true;
|
|
||||||
else console.warn("Could not start server:", err);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const unlistenDownload = await listen<{ chapterId: number; mangaId: number; progress: number }[]>(
|
|
||||||
"download-progress",
|
|
||||||
e => setActiveDownloads(e.payload),
|
|
||||||
);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
stopProbe();
|
|
||||||
unlistenResize();
|
|
||||||
unlistenScale();
|
|
||||||
unlistenDownload();
|
|
||||||
unlistenClose();
|
|
||||||
destroyRpc();
|
|
||||||
delete (window as any).__mokuShowSplash;
|
|
||||||
};
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#if devSplash}
|
|
||||||
<SplashScreen mode="idle" showFps showCards={store.settings.splashCards ?? true}
|
|
||||||
onDismiss={() => setTimeout(() => devSplash = false, 340)} />
|
|
||||||
|
|
||||||
{:else if !appReady && !boot.loginRequired}
|
|
||||||
<SplashScreen mode="loading" ringFull={boot.serverProbeOk}
|
|
||||||
failed={boot.failed} notConfigured={boot.notConfigured}
|
|
||||||
showCards={store.settings.splashCards ?? true}
|
|
||||||
onReady={() => { appReady = true; }}
|
|
||||||
onRetry={retryBoot}
|
|
||||||
onBypass={() => bypassBoot(() => { appReady = true; })} />
|
|
||||||
|
|
||||||
{:else if boot.loginRequired}
|
|
||||||
<SplashScreen mode="loading" ringFull={true} showCards={store.settings.splashCards ?? true} />
|
|
||||||
<AuthGate onReady={() => { appReady = true; }} />
|
|
||||||
|
|
||||||
{:else}
|
|
||||||
{#if idle && !store.activeChapter}
|
|
||||||
<SplashScreen mode="idle" showCards={store.settings.splashCards ?? true}
|
|
||||||
onDismiss={() => { idle = false; }} />
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if boot.sessionExpired}
|
|
||||||
<SplashScreen mode="loading" ringFull={true} showCards={store.settings.splashCards ?? true} />
|
|
||||||
<AuthGate onReady={() => { boot.sessionExpired = false; }} />
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div id="app-shell" class="root">
|
|
||||||
{#if !store.activeChapter}<TitleBar onClose={handleCloseRequested} />{/if}
|
|
||||||
<div class="content">
|
|
||||||
{#if store.activeChapter}<Reader />{:else}<Layout />{/if}
|
|
||||||
</div>
|
|
||||||
{#if store.settingsOpen}<Settings onOpenThemeEditor={openThemeEditor} />{/if}
|
|
||||||
{#if themeEditorOpen}
|
|
||||||
<ThemeEditor bind:editingId={themeEditorEditId} onClose={closeThemeEditor} />
|
|
||||||
{/if}
|
|
||||||
<MangaPreview />
|
|
||||||
<Toaster />
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if closeDialogOpen}
|
|
||||||
<div class="close-backdrop" role="presentation" onclick={() => { closeDialogOpen = false; closeRemember = false; }}>
|
|
||||||
<div class="close-dialog" role="dialog" aria-modal="true" onclick={(e) => e.stopPropagation()}>
|
|
||||||
<div class="close-header">
|
|
||||||
<p class="close-title">Close Moku?</p>
|
|
||||||
<p class="close-sub">Choose how the app should exit.</p>
|
|
||||||
</div>
|
|
||||||
<div class="close-actions">
|
|
||||||
<button class="close-btn" onclick={() => confirmClose("tray")}>
|
|
||||||
<span class="close-btn-label">Minimize to Tray</span>
|
|
||||||
<span class="close-btn-desc">Keep running in the background</span>
|
|
||||||
</button>
|
|
||||||
<button class="close-btn close-btn-danger" onclick={() => confirmClose("quit")}>
|
|
||||||
<span class="close-btn-label">Quit</span>
|
|
||||||
<span class="close-btn-desc">Stop Moku entirely</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<button class="close-remember" onclick={() => closeRemember = !closeRemember}>
|
|
||||||
<span class="close-remember-toggle" class:on={closeRemember}><span class="close-remember-thumb"></span></span>
|
|
||||||
<span class="close-remember-label">Remember my choice</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.root { display: flex; flex-direction: column; height: 100%; overflow: hidden; }
|
|
||||||
.content { flex: 1; overflow: hidden; }
|
|
||||||
|
|
||||||
.close-backdrop {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
background: rgba(0, 0, 0, 0.55);
|
|
||||||
backdrop-filter: blur(6px);
|
|
||||||
-webkit-backdrop-filter: blur(6px);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
z-index: var(--z-modal);
|
|
||||||
}
|
|
||||||
|
|
||||||
.close-dialog {
|
|
||||||
background: var(--bg-surface);
|
|
||||||
border: 1px solid var(--border-base);
|
|
||||||
border-radius: var(--radius-2xl);
|
|
||||||
padding: var(--sp-5);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--sp-3);
|
|
||||||
width: 300px;
|
|
||||||
box-shadow:
|
|
||||||
0 0 0 1px rgba(255,255,255,0.04) inset,
|
|
||||||
0 20px 60px rgba(0,0,0,0.65),
|
|
||||||
0 6px 20px rgba(0,0,0,0.35);
|
|
||||||
}
|
|
||||||
|
|
||||||
.close-header { display: flex; flex-direction: column; gap: 3px; }
|
|
||||||
|
|
||||||
.close-title {
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-base);
|
|
||||||
font-weight: var(--weight-medium);
|
|
||||||
color: var(--text-primary);
|
|
||||||
letter-spacing: var(--tracking-tight);
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.close-sub {
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
color: var(--text-faint);
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.close-actions { display: flex; flex-direction: column; gap: var(--sp-1); }
|
|
||||||
|
|
||||||
.close-btn {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 2px;
|
|
||||||
width: 100%;
|
|
||||||
padding: 10px var(--sp-3);
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
border: 1px solid var(--border-dim);
|
|
||||||
background: var(--bg-raised);
|
|
||||||
cursor: pointer;
|
|
||||||
text-align: left;
|
|
||||||
transition: background var(--t-base), border-color var(--t-base);
|
|
||||||
}
|
|
||||||
.close-btn:hover { background: var(--bg-overlay); border-color: var(--border-strong); }
|
|
||||||
|
|
||||||
.close-btn-danger { border-color: color-mix(in srgb, var(--color-error) 30%, transparent); }
|
|
||||||
.close-btn-danger:hover { background: var(--color-error-bg); border-color: color-mix(in srgb, var(--color-error) 55%, transparent); }
|
|
||||||
.close-btn-danger .close-btn-label { color: var(--color-error); }
|
|
||||||
.close-btn-danger .close-btn-desc { color: color-mix(in srgb, var(--color-error) 55%, var(--text-faint)); }
|
|
||||||
|
|
||||||
.close-btn-label {
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-weight: var(--weight-medium);
|
|
||||||
}
|
|
||||||
|
|
||||||
.close-btn-desc {
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-2xs);
|
|
||||||
color: var(--text-faint);
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
}
|
|
||||||
|
|
||||||
.close-remember {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--sp-2);
|
|
||||||
padding: var(--sp-1) 0 0;
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.close-remember-toggle {
|
|
||||||
position: relative;
|
|
||||||
width: 28px;
|
|
||||||
height: 16px;
|
|
||||||
border-radius: var(--radius-full);
|
|
||||||
border: 1px solid var(--border-strong);
|
|
||||||
background: var(--bg-overlay);
|
|
||||||
flex-shrink: 0;
|
|
||||||
transition: background var(--t-base), border-color var(--t-base);
|
|
||||||
}
|
|
||||||
.close-remember-toggle.on { background: var(--accent); border-color: var(--accent); }
|
|
||||||
|
|
||||||
.close-remember-thumb {
|
|
||||||
position: absolute;
|
|
||||||
top: 1px;
|
|
||||||
left: 1px;
|
|
||||||
width: 12px;
|
|
||||||
height: 12px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: var(--text-faint);
|
|
||||||
transition: transform var(--t-base), background var(--t-base);
|
|
||||||
}
|
|
||||||
.close-remember-toggle.on .close-remember-thumb {
|
|
||||||
transform: translateX(12px);
|
|
||||||
background: var(--bg-void);
|
|
||||||
}
|
|
||||||
|
|
||||||
.close-remember-label {
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
color: var(--text-faint);
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,151 +0,0 @@
|
|||||||
import { store } from "@store/state.svelte";
|
|
||||||
import { fetchAuthenticated, AuthRequiredError, refreshUiAccessToken } from "../core/auth";
|
|
||||||
import { boot } from "@store/boot.svelte";
|
|
||||||
import { getBlobUrl } from "@core/cache/imageCache";
|
|
||||||
|
|
||||||
const DEFAULT_URL = "http://127.0.0.1:4567";
|
|
||||||
|
|
||||||
type ReauthResolver = () => void;
|
|
||||||
let _reauthQueue: ReauthResolver[] = [];
|
|
||||||
|
|
||||||
export function notifyReauthSuccess() {
|
|
||||||
const queue = _reauthQueue;
|
|
||||||
_reauthQueue = [];
|
|
||||||
queue.forEach(resolve => resolve());
|
|
||||||
}
|
|
||||||
|
|
||||||
function waitForReauth(): Promise<void> {
|
|
||||||
return new Promise(resolve => { _reauthQueue.push(resolve); });
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getServerUrl(): string {
|
|
||||||
const url = store.settings.serverUrl;
|
|
||||||
return typeof url === "string" && url.trim() ? url.replace(/\/$/, "") : DEFAULT_URL;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function plainThumbUrl(path: string): string {
|
|
||||||
if (!path) return "";
|
|
||||||
if (path.startsWith("http")) return path;
|
|
||||||
return `${getServerUrl()}${path}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function resolveImageUrl(path: string): Promise<string> {
|
|
||||||
if (!path) return "";
|
|
||||||
const url = path.startsWith("http") ? path : `${getServerUrl()}${path}`;
|
|
||||||
const mode = store.settings.serverAuthMode ?? "NONE";
|
|
||||||
if (mode === "NONE") return url;
|
|
||||||
return getBlobUrl(url);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const thumbUrl = plainThumbUrl;
|
|
||||||
|
|
||||||
interface GQLResponse<T> {
|
|
||||||
data: T;
|
|
||||||
errors?: { message: string }[];
|
|
||||||
}
|
|
||||||
|
|
||||||
function abortableSleep(ms: number, signal?: AbortSignal): Promise<void> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
if (signal?.aborted) { reject(new DOMException("Aborted", "AbortError")); return; }
|
|
||||||
const timer = setTimeout(resolve, ms);
|
|
||||||
signal?.addEventListener("abort", () => {
|
|
||||||
clearTimeout(timer);
|
|
||||||
reject(new DOMException("Aborted", "AbortError"));
|
|
||||||
}, { once: true });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchWithRetry(
|
|
||||||
url: string,
|
|
||||||
init: RequestInit,
|
|
||||||
signal?: AbortSignal,
|
|
||||||
retries = 3,
|
|
||||||
delayMs = 300,
|
|
||||||
): Promise<Response> {
|
|
||||||
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
|
||||||
for (let i = 0; i < retries; i++) {
|
|
||||||
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
|
||||||
try {
|
|
||||||
const res = await fetchAuthenticated(url, init, signal, boot.skipped);
|
|
||||||
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
|
||||||
return res;
|
|
||||||
} catch (e: any) {
|
|
||||||
if (e?.authRequired) throw e;
|
|
||||||
if (e?.name === "AbortError" || signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
|
||||||
if (e instanceof AuthRequiredError) throw e;
|
|
||||||
if (i === retries - 1) throw e;
|
|
||||||
await abortableSleep(delayMs * Math.pow(1.5, i), signal);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw new Error("unreachable");
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function fetchImage(
|
|
||||||
path: string,
|
|
||||||
signal?: AbortSignal,
|
|
||||||
): Promise<{ src: string; revoke: () => void }> {
|
|
||||||
if (!path) return { src: "", revoke: () => {} };
|
|
||||||
|
|
||||||
const url = path.startsWith("http") ? path : `${getServerUrl()}${path}`;
|
|
||||||
const mode = store.settings.serverAuthMode ?? "NONE";
|
|
||||||
|
|
||||||
if (mode === "NONE") return { src: url, revoke: () => {} };
|
|
||||||
|
|
||||||
const res = await fetchWithRetry(url, { method: "GET" }, signal);
|
|
||||||
if (!res.ok) throw new Error(`Image fetch failed: ${res.status}`);
|
|
||||||
|
|
||||||
const blob = await res.blob();
|
|
||||||
const src = URL.createObjectURL(blob);
|
|
||||||
return { src, revoke: () => URL.revokeObjectURL(src) };
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function gql<T>(
|
|
||||||
query: string,
|
|
||||||
variables?: Record<string, unknown>,
|
|
||||||
signal?: AbortSignal,
|
|
||||||
): Promise<T> {
|
|
||||||
const tryRefreshAndRetry = async (): Promise<T | null> => {
|
|
||||||
const mode = store.settings.serverAuthMode ?? "NONE";
|
|
||||||
if (mode !== "UI_LOGIN" || boot.skipped) return null;
|
|
||||||
const refreshed = await refreshUiAccessToken(true);
|
|
||||||
if (!refreshed) return null;
|
|
||||||
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
|
||||||
return attempt();
|
|
||||||
};
|
|
||||||
|
|
||||||
const attempt = async (): Promise<T> => {
|
|
||||||
const res = await fetchWithRetry(
|
|
||||||
`${getServerUrl()}/api/graphql`,
|
|
||||||
{ method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ query, variables }) },
|
|
||||||
signal,
|
|
||||||
);
|
|
||||||
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
|
||||||
if (!res.ok) {
|
|
||||||
if (res.status === 401 || res.status === 403) {
|
|
||||||
const retried = await tryRefreshAndRetry();
|
|
||||||
if (retried) return retried;
|
|
||||||
}
|
|
||||||
throw new Error(`Suwayomi HTTP ${res.status}`);
|
|
||||||
}
|
|
||||||
const json: GQLResponse<T> = await res.json();
|
|
||||||
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
|
||||||
if (json.errors?.length) {
|
|
||||||
const isAuthError = json.errors.some(e => /unauthorized|unauthenticated/i.test(e.message));
|
|
||||||
if (isAuthError && !boot.skipped) {
|
|
||||||
const retried = await tryRefreshAndRetry();
|
|
||||||
if (retried) return retried;
|
|
||||||
|
|
||||||
boot.sessionExpired = true;
|
|
||||||
boot.loginRequired = true;
|
|
||||||
boot.loginUser = store.settings.serverAuthUser ?? "";
|
|
||||||
await waitForReauth();
|
|
||||||
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
|
||||||
return attempt();
|
|
||||||
}
|
|
||||||
throw new Error(json.errors[0].message);
|
|
||||||
}
|
|
||||||
return json.data;
|
|
||||||
};
|
|
||||||
|
|
||||||
return attempt();
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
export * from "./client";
|
|
||||||
export * from "./queries/manga";
|
|
||||||
export * from "./queries/chapters";
|
|
||||||
export * from "./queries/downloads";
|
|
||||||
export * from "./queries/extensions";
|
|
||||||
export * from "./queries/tracking";
|
|
||||||
export * from "./mutations/manga";
|
|
||||||
export * from "./mutations/chapters";
|
|
||||||
export * from "./mutations/downloads";
|
|
||||||
export * from "./mutations/extensions";
|
|
||||||
export * from "./mutations/tracking";
|
|
||||||
@@ -1,215 +0,0 @@
|
|||||||
export const FETCH_EXTENSIONS = `
|
|
||||||
mutation FetchExtensions {
|
|
||||||
fetchExtensions(input: {}) {
|
|
||||||
extensions {
|
|
||||||
apkName pkgName name lang versionName
|
|
||||||
isInstalled isObsolete hasUpdate iconUrl
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const UPDATE_EXTENSION = `
|
|
||||||
mutation UpdateExtension($id: String!, $install: Boolean, $uninstall: Boolean, $update: Boolean) {
|
|
||||||
updateExtension(input: { id: $id, patch: { install: $install, uninstall: $uninstall, update: $update } }) {
|
|
||||||
extension { apkName pkgName name isInstalled hasUpdate }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const UPDATE_EXTENSIONS = `
|
|
||||||
mutation UpdateExtensions($ids: [String!]!, $install: Boolean, $uninstall: Boolean, $update: Boolean) {
|
|
||||||
updateExtensions(input: { ids: $ids, patch: { install: $install, uninstall: $uninstall, update: $update } }) {
|
|
||||||
extensions { apkName pkgName name isInstalled hasUpdate }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const INSTALL_EXTERNAL_EXTENSION = `
|
|
||||||
mutation InstallExternalExtension($url: String!) {
|
|
||||||
installExternalExtension(input: { extensionUrl: $url }) {
|
|
||||||
extension { apkName pkgName name isInstalled }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const UPDATE_SOURCE_PREFERENCE = `
|
|
||||||
mutation UpdateSourcePreference($source: LongString!, $change: SourcePreferenceChangeInput!) {
|
|
||||||
updateSourcePreference(input: { source: $source, change: $change }) {
|
|
||||||
source { id displayName }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const SET_SOURCE_METAS = `
|
|
||||||
mutation SetSourceMetas($input: SetSourceMetasInput!) {
|
|
||||||
setSourceMetas(input: $input) {
|
|
||||||
metas { sourceId key value }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const DELETE_SOURCE_METAS = `
|
|
||||||
mutation DeleteSourceMetas($input: DeleteSourceMetasInput!) {
|
|
||||||
deleteSourceMetas(input: $input) {
|
|
||||||
metas { sourceId key value }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const UPDATE_SOURCE_METADATA = `
|
|
||||||
mutation UpdateSourceMetadata(
|
|
||||||
$preUpdateDeleteInput: DeleteSourceMetasInput!
|
|
||||||
$hasPreUpdateDeletions: Boolean!
|
|
||||||
$updateInput: SetSourceMetasInput!
|
|
||||||
$hasUpdates: Boolean!
|
|
||||||
$postUpdateDeleteInput: DeleteSourceMetasInput!
|
|
||||||
$hasPostUpdateDeletions: Boolean!
|
|
||||||
$migrateInput: SetSourceMetasInput!
|
|
||||||
$isMigration: Boolean!
|
|
||||||
) {
|
|
||||||
preUpdateDeletedMeta: deleteSourceMetas(input: $preUpdateDeleteInput) @include(if: $hasPreUpdateDeletions) {
|
|
||||||
metas { sourceId key value }
|
|
||||||
}
|
|
||||||
updatedMeta: setSourceMetas(input: $updateInput) @include(if: $hasUpdates) {
|
|
||||||
metas { sourceId key value }
|
|
||||||
}
|
|
||||||
postUpdateDeletedMeta: deleteSourceMetas(input: $postUpdateDeleteInput) @include(if: $hasPostUpdateDeletions) {
|
|
||||||
metas { sourceId key value }
|
|
||||||
}
|
|
||||||
migrationMeta: setSourceMetas(input: $migrateInput) @include(if: $isMigration) {
|
|
||||||
metas { sourceId key value }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const SET_SOURCE_META = `
|
|
||||||
mutation SetSourceMeta($sourceId: LongString!, $key: String!, $value: String!) {
|
|
||||||
setSourceMeta(input: { meta: { sourceId: $sourceId, key: $key, value: $value } }) {
|
|
||||||
meta { key value }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const DELETE_SOURCE_META = `
|
|
||||||
mutation DeleteSourceMeta($sourceId: LongString!, $key: String!) {
|
|
||||||
deleteSourceMeta(input: { sourceId: $sourceId, key: $key }) {
|
|
||||||
meta { key value }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const SET_CATEGORY_META = `
|
|
||||||
mutation SetCategoryMeta($categoryId: Int!, $key: String!, $value: String!) {
|
|
||||||
setCategoryMeta(input: { meta: { categoryId: $categoryId, key: $key, value: $value } }) {
|
|
||||||
meta { key value }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const DELETE_CATEGORY_META = `
|
|
||||||
mutation DeleteCategoryMeta($categoryId: Int!, $key: String!) {
|
|
||||||
deleteCategoryMeta(input: { categoryId: $categoryId, key: $key }) {
|
|
||||||
meta { key value }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const SET_GLOBAL_META = `
|
|
||||||
mutation SetGlobalMeta($key: String!, $value: String!) {
|
|
||||||
setGlobalMeta(input: { meta: { key: $key, value: $value } }) {
|
|
||||||
meta { key value }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const DELETE_GLOBAL_META = `
|
|
||||||
mutation DeleteGlobalMeta($key: String!) {
|
|
||||||
deleteGlobalMeta(input: { key: $key }) {
|
|
||||||
meta { key value }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const CLEAR_CACHED_IMAGES = `
|
|
||||||
mutation ClearCachedImages($cachedPages: Boolean, $cachedThumbnails: Boolean, $downloadedThumbnails: Boolean) {
|
|
||||||
clearCachedImages(input: {
|
|
||||||
cachedPages: $cachedPages
|
|
||||||
cachedThumbnails: $cachedThumbnails
|
|
||||||
downloadedThumbnails: $downloadedThumbnails
|
|
||||||
}) {
|
|
||||||
cachedPages cachedThumbnails downloadedThumbnails
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const RESET_SETTINGS = `
|
|
||||||
mutation ResetSettings {
|
|
||||||
resetSettings(input: {}) {
|
|
||||||
settings { extensionRepos }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const SET_EXTENSION_REPOS = `
|
|
||||||
mutation SetExtensionRepos($repos: [String!]!) {
|
|
||||||
setSettings(input: { settings: { extensionRepos: $repos } }) {
|
|
||||||
settings { extensionRepos }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const SET_SERVER_AUTH = `
|
|
||||||
mutation SetServerAuth($authMode: AuthMode!, $authUsername: String!, $authPassword: String!) {
|
|
||||||
setSettings(input: { settings: { authMode: $authMode, authUsername: $authUsername, authPassword: $authPassword } }) {
|
|
||||||
settings { authMode authUsername }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const SET_SOCKS_PROXY = `
|
|
||||||
mutation SetSocksProxy(
|
|
||||||
$socksProxyEnabled: Boolean!
|
|
||||||
$socksProxyHost: String!
|
|
||||||
$socksProxyPort: String!
|
|
||||||
$socksProxyVersion: Int!
|
|
||||||
$socksProxyUsername: String!
|
|
||||||
$socksProxyPassword: String!
|
|
||||||
) {
|
|
||||||
setSettings(input: { settings: {
|
|
||||||
socksProxyEnabled: $socksProxyEnabled
|
|
||||||
socksProxyHost: $socksProxyHost
|
|
||||||
socksProxyPort: $socksProxyPort
|
|
||||||
socksProxyVersion: $socksProxyVersion
|
|
||||||
socksProxyUsername: $socksProxyUsername
|
|
||||||
socksProxyPassword: $socksProxyPassword
|
|
||||||
}}) {
|
|
||||||
settings { socksProxyEnabled socksProxyHost socksProxyPort socksProxyVersion socksProxyUsername }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const SET_FLARESOLVERR = `
|
|
||||||
mutation SetFlareSolverr(
|
|
||||||
$flareSolverrEnabled: Boolean!
|
|
||||||
$flareSolverrUrl: String!
|
|
||||||
$flareSolverrTimeout: Int!
|
|
||||||
$flareSolverrSessionName: String!
|
|
||||||
$flareSolverrSessionTtl: Int!
|
|
||||||
$flareSolverrAsResponseFallback: Boolean!
|
|
||||||
) {
|
|
||||||
setSettings(input: { settings: {
|
|
||||||
flareSolverrEnabled: $flareSolverrEnabled
|
|
||||||
flareSolverrUrl: $flareSolverrUrl
|
|
||||||
flareSolverrTimeout: $flareSolverrTimeout
|
|
||||||
flareSolverrSessionName: $flareSolverrSessionName
|
|
||||||
flareSolverrSessionTtl: $flareSolverrSessionTtl
|
|
||||||
flareSolverrAsResponseFallback: $flareSolverrAsResponseFallback
|
|
||||||
}}) {
|
|
||||||
settings {
|
|
||||||
flareSolverrEnabled flareSolverrUrl flareSolverrTimeout
|
|
||||||
flareSolverrSessionName flareSolverrSessionTtl flareSolverrAsResponseFallback
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
export * from "./manga";
|
|
||||||
export * from "./chapters";
|
|
||||||
export * from "./downloads";
|
|
||||||
export * from "./extensions";
|
|
||||||
export * from "./tracking";
|
|
||||||
@@ -1,130 +0,0 @@
|
|||||||
# Mutations
|
|
||||||
|
|
||||||
## Manga (`mutations/manga.ts`)
|
|
||||||
|
|
||||||
| Mutation | Variables | Description |
|
|
||||||
|----------|-----------|-------------|
|
|
||||||
| `FETCH_MANGA` | `id: Int!` | Fetch and refresh manga metadata from its source |
|
|
||||||
| `UPDATE_MANGA` | `id: Int!`, `inLibrary: Boolean` | Update a single manga's library membership |
|
|
||||||
| `UPDATE_MANGAS` | `ids: [Int!]!`, `inLibrary: Boolean` | Bulk-update library membership for multiple manga |
|
|
||||||
| `UPDATE_MANGA_CATEGORIES` | `mangaId: Int!`, `addTo: [Int!]!`, `removeFrom: [Int!]!` | Add or remove a single manga from categories |
|
|
||||||
| `UPDATE_MANGAS_CATEGORIES` | `ids: [Int!]!`, `addTo: [Int!]!`, `removeFrom: [Int!]!` | Bulk add/remove multiple manga from categories |
|
|
||||||
| `CREATE_CATEGORY` | `name: String!` | Create a new category |
|
|
||||||
| `UPDATE_CATEGORY` | `id: Int!`, `name: String` | Update a category's name |
|
|
||||||
| `UPDATE_CATEGORIES` | `ids: [Int!]!`, `patch: UpdateCategoryPatchInput!` | Bulk-update multiple categories |
|
|
||||||
| `DELETE_CATEGORY` | `id: Int!` | Delete a category |
|
|
||||||
| `UPDATE_CATEGORY_ORDER` | `id: Int!`, `position: Int!` | Move a category to a new position |
|
|
||||||
| `UPDATE_CATEGORY_MANGA` | `categoryId: Int!` | Trigger a metadata update for all manga in a category |
|
|
||||||
| `UPDATE_LIBRARY` | — | Trigger a full library metadata refresh |
|
|
||||||
| `UPDATE_LIBRARY_MANGA` | `mangaId: Int!` | Trigger a metadata update for a single manga |
|
|
||||||
| `UPDATE_STOP` | — | Stop the currently running library update job |
|
|
||||||
| `CREATE_BACKUP` | — | Create a backup and return its download URL |
|
|
||||||
| `RESTORE_BACKUP` | `backup: Upload!` | Restore a backup file and return the restore job status |
|
|
||||||
| `SET_MANGA_META` | `mangaId: Int!`, `key: String!`, `value: String!` | Set a key/value meta entry on a manga |
|
|
||||||
| `DELETE_MANGA_META` | `mangaId: Int!`, `key: String!` | Delete a key/value meta entry from a manga |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Chapters (`mutations/chapters.ts`)
|
|
||||||
|
|
||||||
| Mutation | Variables | Description |
|
|
||||||
|----------|-----------|-------------|
|
|
||||||
| `FETCH_CHAPTERS` | `mangaId: Int!` | Fetch/refresh the chapter list for a manga from its source |
|
|
||||||
| `FETCH_CHAPTER_PAGES` | `chapterId: Int!` | Fetch the page URLs for a specific chapter |
|
|
||||||
| `MARK_CHAPTER_READ` | `id: Int!`, `isRead: Boolean!` | Mark a single chapter read or unread |
|
|
||||||
| `MARK_CHAPTERS_READ` | `ids: [Int!]!`, `isRead: Boolean!` | Bulk mark chapters read or unread |
|
|
||||||
| `UPDATE_CHAPTERS_PROGRESS` | `ids: [Int!]!`, `isRead: Boolean`, `isBookmarked: Boolean`, `lastPageRead: Int` | Bulk update read state, bookmark state, and last page read |
|
|
||||||
| `DELETE_DOWNLOADED_CHAPTERS` | `ids: [Int!]!` | Delete downloaded chapter files |
|
|
||||||
| `SET_CHAPTER_META` | `chapterId: Int!`, `key: String!`, `value: String!` | Set a key/value meta entry on a chapter |
|
|
||||||
| `DELETE_CHAPTER_META` | `chapterId: Int!`, `key: String!` | Delete a key/value meta entry from a chapter |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Downloads (`mutations/downloads.ts`)
|
|
||||||
|
|
||||||
| Mutation | Variables | Description |
|
|
||||||
|----------|-----------|-------------|
|
|
||||||
| `ENQUEUE_DOWNLOAD` | `chapterId: Int!` | Add a single chapter to the download queue |
|
|
||||||
| `ENQUEUE_CHAPTERS_DOWNLOAD` | `chapterIds: [Int!]!` | Add multiple chapters to the download queue |
|
|
||||||
| `DEQUEUE_DOWNLOAD` | `chapterId: Int!` | Remove a single chapter from the download queue |
|
|
||||||
| `DEQUEUE_CHAPTERS_DOWNLOAD` | `chapterIds: [Int!]!` | Remove multiple chapters from the download queue |
|
|
||||||
| `REORDER_DOWNLOAD` | `chapterId: Int!`, `to: Int!` | Move a queued chapter to a new position |
|
|
||||||
| `START_DOWNLOADER` | — | Start the downloader |
|
|
||||||
| `STOP_DOWNLOADER` | — | Stop the downloader |
|
|
||||||
| `CLEAR_DOWNLOADER` | — | Clear all items from the download queue |
|
|
||||||
| `FETCH_SOURCE_MANGA` | `source: LongString!`, `type: FetchSourceMangaType!`, `page: Int!`, `query: String`, `filters: [FilterChangeInput!]` | Fetch manga from a source (browse/search) with pagination |
|
|
||||||
| `SET_DOWNLOADS_PATH` | `path: String!` | Set the downloads directory path |
|
|
||||||
| `SET_LOCAL_SOURCE_PATH` | `path: String!` | Set the local source directory path |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Extensions (`mutations/extensions.ts`)
|
|
||||||
|
|
||||||
| Mutation | Variables | Description |
|
|
||||||
|----------|-----------|-------------|
|
|
||||||
| `FETCH_EXTENSIONS` | — | Fetch the latest extension list from configured repos |
|
|
||||||
| `UPDATE_EXTENSION` | `id: String!`, `install: Boolean`, `uninstall: Boolean`, `update: Boolean` | Install, uninstall, or update a single extension |
|
|
||||||
| `UPDATE_EXTENSIONS` | `ids: [String!]!`, `install: Boolean`, `uninstall: Boolean`, `update: Boolean` | Bulk install, uninstall, or update multiple extensions |
|
|
||||||
| `INSTALL_EXTERNAL_EXTENSION` | `url: String!` | Install an extension from an external APK URL |
|
|
||||||
| `UPDATE_SOURCE_PREFERENCE` | `source: LongString!`, `change: SourcePreferenceChangeInput!` | Update a source-specific preference value |
|
|
||||||
| `SET_SOURCE_META` | `sourceId: LongString!`, `key: String!`, `value: String!` | Set a key/value meta entry on a source |
|
|
||||||
| `DELETE_SOURCE_META` | `sourceId: LongString!`, `key: String!` | Delete a key/value meta entry from a source |
|
|
||||||
| `SET_CATEGORY_META` | `categoryId: Int!`, `key: String!`, `value: String!` | Set a key/value meta entry on a category |
|
|
||||||
| `DELETE_CATEGORY_META` | `categoryId: Int!`, `key: String!` | Delete a key/value meta entry from a category |
|
|
||||||
| `SET_GLOBAL_META` | `key: String!`, `value: String!` | Set a global key/value meta entry |
|
|
||||||
| `DELETE_GLOBAL_META` | `key: String!` | Delete a global key/value meta entry |
|
|
||||||
| `CLEAR_CACHED_IMAGES` | `cachedPages: Boolean`, `cachedThumbnails: Boolean`, `downloadedThumbnails: Boolean` | Selectively clear cached page images, cached thumbnails, or downloaded thumbnails |
|
|
||||||
| `RESET_SETTINGS` | — | Reset all server settings to defaults |
|
|
||||||
| `UPDATE_WEBUI` | — | Trigger a WebUI update and return live status |
|
|
||||||
| `RESET_WEBUI_UPDATE_STATUS` | — | Reset the WebUI update status back to idle |
|
|
||||||
| `SET_EXTENSION_REPOS` | `repos: [String!]!` | Set the list of extension repository URLs |
|
|
||||||
| `SET_SERVER_AUTH` | `authMode: AuthMode!`, `authUsername: String!`, `authPassword: String!` | Configure server auth mode and credentials |
|
|
||||||
| `SET_SOCKS_PROXY` | `socksProxyEnabled: Boolean!`, `socksProxyHost: String!`, `socksProxyPort: String!`, `socksProxyVersion: Int!`, `socksProxyUsername: String!`, `socksProxyPassword: String!` | Configure SOCKS proxy settings |
|
|
||||||
| `SET_FLARESOLVERR` | `flareSolverrEnabled: Boolean!`, `flareSolverrUrl: String!`, `flareSolverrTimeout: Int!`, `flareSolverrSessionName: String!`, `flareSolverrSessionTtl: Int!`, `flareSolverrAsResponseFallback: Boolean!` | Configure FlareSolverr integration |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Tracking (`mutations/tracking.ts`)
|
|
||||||
|
|
||||||
| Mutation | Variables | Description |
|
|
||||||
|----------|-----------|-------------|
|
|
||||||
| `BIND_TRACK` | `mangaId: Int!`, `trackerId: Int!`, `remoteId: LongString!` | Bind a manga to a remote tracker entry |
|
|
||||||
| `UPDATE_TRACK` | `recordId: Int!`, `status: Int`, `lastChapterRead: Float`, `scoreString: String`, `startDate: LongString`, `finishDate: LongString`, `private: Boolean` | Update tracking progress, status, score, and dates |
|
|
||||||
| `UNBIND_TRACK` | `recordId: Int!` | Unbind a manga from a tracker record |
|
|
||||||
| `FETCH_TRACK` | `recordId: Int!` | Refresh a track record from the remote tracker |
|
|
||||||
| `TRACK_PROGRESS` | `mangaId: Int!` | Sync current reading progress to all bound trackers for a manga |
|
|
||||||
| `LOGIN_TRACKER_OAUTH` | `trackerId: Int!`, `callbackUrl: String!` | Initiate OAuth login for a tracker |
|
|
||||||
| `LOGIN_TRACKER_CREDENTIALS` | `trackerId: Int!`, `username: String!`, `password: String!` | Log into a tracker with username and password |
|
|
||||||
| `LOGOUT_TRACKER` | `trackerId: Int!` | Log out of a tracker |
|
|
||||||
| `CONNECT_KOSYNC` | `username: String!`, `password: String!`, `serverAddress: String!` | Connect a KOReader sync account |
|
|
||||||
| `LOGOUT_KOSYNC` | — | Disconnect the KOReader sync account |
|
|
||||||
| `PULL_KOSYNC_PROGRESS` | `chapterId: Int!` | Pull reading progress from KOReader sync for a chapter |
|
|
||||||
| `PUSH_KOSYNC_PROGRESS` | `chapterId: Int!` | Push reading progress to KOReader sync for a chapter |
|
|
||||||
| `LOGIN_USER` | `username: String!`, `password: String!` | Authenticate and return access + refresh tokens |
|
|
||||||
| `REFRESH_TOKEN` | — | Refresh the current access token |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## New in Preview
|
|
||||||
|
|
||||||
Mutations now available and not yet wired to any feature in Moku:
|
|
||||||
|
|
||||||
| Mutation | Potential Feature |
|
|
||||||
|----------|-------------------|
|
|
||||||
| `UPDATE_MANGAS_CATEGORIES` | Bulk category editor — move/assign multiple manga at once |
|
|
||||||
| `UPDATE_CATEGORIES` | Bulk category settings — toggle update/download flags for multiple categories at once |
|
|
||||||
| `UPDATE_CATEGORY_MANGA` | Per-category refresh button — update only one category's manga |
|
|
||||||
| `UPDATE_LIBRARY_MANGA` | Single manga refresh — trigger from series detail without a full library update |
|
|
||||||
| `UPDATE_STOP` | Cancel button for library update jobs |
|
|
||||||
| `UPDATE_EXTENSIONS` | Bulk extension updater — "update all" button in extensions page |
|
|
||||||
| `UPDATE_SOURCE_PREFERENCE` | Source settings page — persist source-specific preferences |
|
|
||||||
| `SET_SOURCE_META` / `DELETE_SOURCE_META` | Per-source client state — store browse position, last filter, etc. |
|
|
||||||
| `SET_CATEGORY_META` / `DELETE_CATEGORY_META` | Per-category client state — store sort/filter preferences per category |
|
|
||||||
| `SET_CHAPTER_META` / `DELETE_CHAPTER_META` | Per-chapter client state — annotations, custom notes |
|
|
||||||
| `SET_GLOBAL_META` / `DELETE_GLOBAL_META` | Server-synced app state — replace local persistence for settings that should roam |
|
|
||||||
| `CLEAR_CACHED_IMAGES` | Storage settings — granular cache clearing (pages, thumbnails, downloaded) |
|
|
||||||
| `RESET_SETTINGS` | Settings page — factory reset button |
|
|
||||||
| `UPDATE_WEBUI` / `RESET_WEBUI_UPDATE_STATUS` | WebUI update flow in settings — trigger and monitor update progress |
|
|
||||||
| `TRACK_PROGRESS` | One-tap sync — push current reading position to all trackers without opening tracking panel |
|
|
||||||
| `CONNECT_KOSYNC` / `LOGOUT_KOSYNC` | KOReader sync settings section — connect/disconnect account |
|
|
||||||
| `PULL_KOSYNC_PROGRESS` / `PUSH_KOSYNC_PROGRESS` | KOReader sync — manual pull/push per chapter, or auto-sync on chapter open/close |
|
|
||||||
@@ -1,127 +0,0 @@
|
|||||||
const TRACK_RECORD_FRAGMENT = `
|
|
||||||
id trackerId remoteId title status score displayScore
|
|
||||||
lastChapterRead totalChapters remoteUrl startDate finishDate private libraryId
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const BIND_TRACK = `
|
|
||||||
mutation BindTrack($mangaId: Int!, $trackerId: Int!, $remoteId: LongString!) {
|
|
||||||
bindTrack(input: { mangaId: $mangaId, trackerId: $trackerId, remoteId: $remoteId }) {
|
|
||||||
trackRecord { ${TRACK_RECORD_FRAGMENT} }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const UPDATE_TRACK = `
|
|
||||||
mutation UpdateTrack($recordId: Int!, $status: Int, $lastChapterRead: Float, $scoreString: String, $startDate: LongString, $finishDate: LongString, $private: Boolean) {
|
|
||||||
updateTrack(input: { recordId: $recordId, status: $status, lastChapterRead: $lastChapterRead, scoreString: $scoreString, startDate: $startDate, finishDate: $finishDate, private: $private }) {
|
|
||||||
trackRecord {
|
|
||||||
id trackerId status score displayScore lastChapterRead totalChapters startDate finishDate private libraryId
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const UNBIND_TRACK = `
|
|
||||||
mutation UnbindTrack($recordId: Int!) {
|
|
||||||
unbindTrack(input: { recordId: $recordId }) {
|
|
||||||
trackRecord { id }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const FETCH_TRACK = `
|
|
||||||
mutation FetchTrack($recordId: Int!) {
|
|
||||||
fetchTrack(input: { recordId: $recordId }) {
|
|
||||||
trackRecord {
|
|
||||||
id trackerId status score displayScore lastChapterRead totalChapters startDate finishDate libraryId
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const TRACK_PROGRESS = `
|
|
||||||
mutation TrackProgress($mangaId: Int!) {
|
|
||||||
trackProgress(input: { mangaId: $mangaId }) {
|
|
||||||
trackRecords {
|
|
||||||
id trackerId lastChapterRead status
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const LOGIN_TRACKER_OAUTH = `
|
|
||||||
mutation LoginTrackerOAuth($trackerId: Int!, $callbackUrl: String!) {
|
|
||||||
loginTrackerOAuth(input: { trackerId: $trackerId, callbackUrl: $callbackUrl }) {
|
|
||||||
isLoggedIn
|
|
||||||
tracker { id name isLoggedIn isTokenExpired authUrl }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const LOGIN_TRACKER_CREDENTIALS = `
|
|
||||||
mutation LoginTrackerCredentials($trackerId: Int!, $username: String!, $password: String!) {
|
|
||||||
loginTrackerCredentials(input: { trackerId: $trackerId, username: $username, password: $password }) {
|
|
||||||
isLoggedIn
|
|
||||||
tracker { id name isLoggedIn isTokenExpired authUrl }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const LOGOUT_TRACKER = `
|
|
||||||
mutation LogoutTracker($trackerId: Int!) {
|
|
||||||
logoutTracker(input: { trackerId: $trackerId }) {
|
|
||||||
tracker { id name isLoggedIn isTokenExpired authUrl }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const CONNECT_KOSYNC = `
|
|
||||||
mutation ConnectKoSync($username: String!, $password: String!, $serverAddress: String!) {
|
|
||||||
connectKoSyncAccount(input: { username: $username, password: $password, serverAddress: $serverAddress }) {
|
|
||||||
isConnected
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const LOGOUT_KOSYNC = `
|
|
||||||
mutation LogoutKoSync {
|
|
||||||
logoutKoSyncAccount(input: {}) {
|
|
||||||
isConnected
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const PULL_KOSYNC_PROGRESS = `
|
|
||||||
mutation PullKoSyncProgress($chapterId: Int!) {
|
|
||||||
pullKoSyncProgress(input: { chapterId: $chapterId }) {
|
|
||||||
chapter { id lastPageRead isRead }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const PUSH_KOSYNC_PROGRESS = `
|
|
||||||
mutation PushKoSyncProgress($chapterId: Int!) {
|
|
||||||
pushKoSyncProgress(input: { chapterId: $chapterId }) {
|
|
||||||
chapter { id lastPageRead isRead }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const LOGIN_USER = `
|
|
||||||
mutation Login($username: String!, $password: String!, $clientMutationId: String) {
|
|
||||||
login(input: { username: $username, password: $password, clientMutationId: $clientMutationId }) {
|
|
||||||
accessToken
|
|
||||||
refreshToken
|
|
||||||
clientMutationId
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const REFRESH_TOKEN = `
|
|
||||||
mutation RefreshToken($refreshToken: String!, $clientMutationId: String) {
|
|
||||||
refreshToken(input: { refreshToken: $refreshToken, clientMutationId: $clientMutationId }) {
|
|
||||||
accessToken
|
|
||||||
clientMutationId
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
export const GET_RECENTLY_UPDATED = `
|
|
||||||
query GetRecentlyUpdated {
|
|
||||||
chapters(orderBy: FETCHED_AT, orderByType: DESC, first: 300) {
|
|
||||||
nodes {
|
|
||||||
mangaId
|
|
||||||
fetchedAt
|
|
||||||
manga { id title thumbnailUrl inLibrary }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const GET_CHAPTERS = `
|
|
||||||
query GetChapters($mangaId: Int!) {
|
|
||||||
chapters(condition: { mangaId: $mangaId }) {
|
|
||||||
nodes {
|
|
||||||
id name chapterNumber sourceOrder isRead isDownloaded isBookmarked
|
|
||||||
pageCount mangaId uploadDate realUrl lastPageRead lastReadAt scanlator
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
export const GET_DOWNLOAD_STATUS = `
|
|
||||||
query GetDownloadStatus {
|
|
||||||
downloadStatus {
|
|
||||||
state
|
|
||||||
queue {
|
|
||||||
progress state tries
|
|
||||||
chapter {
|
|
||||||
id name pageCount mangaId
|
|
||||||
manga { id title thumbnailUrl }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
export const GET_LOCAL_MANGA = `
|
|
||||||
query GetLocalManga {
|
|
||||||
mangas(condition: { sourceId: "0" }) {
|
|
||||||
nodes { id title thumbnailUrl inLibrary }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const GET_EXTENSIONS = `
|
|
||||||
query GetExtensions {
|
|
||||||
extensions {
|
|
||||||
nodes {
|
|
||||||
apkName pkgName name lang versionName
|
|
||||||
isInstalled isObsolete hasUpdate iconUrl
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const GET_SOURCES = `
|
|
||||||
query GetSources {
|
|
||||||
sources {
|
|
||||||
nodes {
|
|
||||||
id name lang displayName iconUrl isNsfw
|
|
||||||
isConfigurable supportsLatest
|
|
||||||
extension { pkgName }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const GET_SOURCE_SETTINGS = `
|
|
||||||
query GetSourceSettings($id: LongString!) {
|
|
||||||
source(id: $id) {
|
|
||||||
id
|
|
||||||
displayName
|
|
||||||
preferences {
|
|
||||||
... on CheckBoxPreference {
|
|
||||||
type: __typename
|
|
||||||
CheckBoxTitle: title
|
|
||||||
CheckBoxSummary: summary
|
|
||||||
CheckBoxDefault: default
|
|
||||||
CheckBoxCurrentValue: currentValue
|
|
||||||
key
|
|
||||||
}
|
|
||||||
... on SwitchPreference {
|
|
||||||
type: __typename
|
|
||||||
SwitchPreferenceTitle: title
|
|
||||||
SwitchPreferenceSummary: summary
|
|
||||||
SwitchPreferenceDefault: default
|
|
||||||
SwitchPreferenceCurrentValue: currentValue
|
|
||||||
key
|
|
||||||
}
|
|
||||||
... on ListPreference {
|
|
||||||
type: __typename
|
|
||||||
ListPreferenceTitle: title
|
|
||||||
ListPreferenceSummary: summary
|
|
||||||
ListPreferenceDefault: default
|
|
||||||
ListPreferenceCurrentValue: currentValue
|
|
||||||
entries
|
|
||||||
entryValues
|
|
||||||
key
|
|
||||||
}
|
|
||||||
... on EditTextPreference {
|
|
||||||
type: __typename
|
|
||||||
EditTextPreferenceTitle: title
|
|
||||||
EditTextPreferenceSummary: summary
|
|
||||||
EditTextPreferenceDefault: default
|
|
||||||
EditTextPreferenceCurrentValue: currentValue
|
|
||||||
dialogTitle
|
|
||||||
dialogMessage
|
|
||||||
key
|
|
||||||
}
|
|
||||||
... on MultiSelectListPreference {
|
|
||||||
type: __typename
|
|
||||||
MultiSelectListPreferenceTitle: title
|
|
||||||
MultiSelectListPreferenceSummary: summary
|
|
||||||
MultiSelectListPreferenceDefault: default
|
|
||||||
MultiSelectListPreferenceCurrentValue: currentValue
|
|
||||||
entries
|
|
||||||
entryValues
|
|
||||||
key
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const GET_MIGRATABLE_SOURCES = `
|
|
||||||
query GetMigratableSources {
|
|
||||||
mangas(condition: { inLibrary: true }) {
|
|
||||||
nodes {
|
|
||||||
sourceId
|
|
||||||
source {
|
|
||||||
id name lang displayName iconUrl isNsfw isConfigurable supportsLatest
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const GET_SETTINGS = `
|
|
||||||
query GetSettings {
|
|
||||||
settings { extensionRepos }
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const GET_SERVER_SECURITY = `
|
|
||||||
query GetServerSecurity {
|
|
||||||
settings {
|
|
||||||
authMode authUsername
|
|
||||||
socksProxyEnabled socksProxyHost socksProxyPort socksProxyVersion socksProxyUsername
|
|
||||||
flareSolverrEnabled flareSolverrUrl flareSolverrTimeout
|
|
||||||
flareSolverrSessionName flareSolverrSessionTtl flareSolverrAsResponseFallback
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
export * from "./manga";
|
|
||||||
export * from "./chapters";
|
|
||||||
export * from "./downloads";
|
|
||||||
export * from "./extensions";
|
|
||||||
export * from "./tracking";
|
|
||||||
export * from "./updater";
|
|
||||||
export * from "./meta";
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
export const GET_LIBRARY = `
|
|
||||||
query GetLibrary {
|
|
||||||
mangas(condition: { inLibrary: true }) {
|
|
||||||
nodes {
|
|
||||||
id title thumbnailUrl inLibrary downloadCount unreadCount bookmarkCount
|
|
||||||
description status author artist genre
|
|
||||||
inLibraryAt lastFetchedAt chaptersLastFetchedAt thumbnailUrlLastFetched
|
|
||||||
source { id name displayName }
|
|
||||||
chapters { totalCount }
|
|
||||||
latestFetchedChapter { id uploadDate }
|
|
||||||
latestUploadedChapter { id uploadDate }
|
|
||||||
lastReadChapter { id chapterNumber }
|
|
||||||
firstUnreadChapter { id chapterNumber }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const GET_ALL_MANGA = `
|
|
||||||
query GetAllManga {
|
|
||||||
mangas {
|
|
||||||
nodes { id title thumbnailUrl inLibrary downloadCount }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const GET_MANGA = `
|
|
||||||
query GetManga($id: Int!) {
|
|
||||||
manga(id: $id) {
|
|
||||||
id title description thumbnailUrl status author artist genre inLibrary realUrl
|
|
||||||
inLibraryAt lastFetchedAt thumbnailUrlLastFetched updateStrategy
|
|
||||||
source { id name displayName }
|
|
||||||
lastReadChapter { id chapterNumber lastPageRead }
|
|
||||||
firstUnreadChapter { id chapterNumber }
|
|
||||||
highestNumberedChapter { id chapterNumber }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const GET_CATEGORIES = `
|
|
||||||
query GetCategories {
|
|
||||||
categories {
|
|
||||||
nodes {
|
|
||||||
id name order default includeInUpdate includeInDownload
|
|
||||||
mangas {
|
|
||||||
nodes { id title thumbnailUrl inLibrary downloadCount unreadCount }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const GET_DOWNLOADED_CHAPTERS_PAGES = `
|
|
||||||
query GetDownloadedChaptersPages {
|
|
||||||
chapters(condition: { isDownloaded: true }) {
|
|
||||||
nodes { pageCount }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const GET_DOWNLOADS_PATH = `
|
|
||||||
query GetDownloadsPath {
|
|
||||||
settings { downloadsPath localSourcePath }
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const LIBRARY_UPDATE_STATUS = `
|
|
||||||
query LibraryUpdateStatus {
|
|
||||||
libraryUpdateStatus {
|
|
||||||
jobsInfo {
|
|
||||||
isRunning finishedJobs totalJobs skippedMangasCount skippedCategoriesCount
|
|
||||||
}
|
|
||||||
mangaUpdates {
|
|
||||||
status
|
|
||||||
manga { id title thumbnailUrl unreadCount }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const GET_RESTORE_STATUS = `
|
|
||||||
query GetRestoreStatus($id: String!) {
|
|
||||||
restoreStatus(id: $id) { mangaProgress state totalManga }
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const VALIDATE_BACKUP = `
|
|
||||||
query ValidateBackup($backup: Upload!) {
|
|
||||||
validateBackup(input: { backup: $backup }) {
|
|
||||||
missingSources { id name }
|
|
||||||
missingTrackers { name }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const MANGAS_BY_GENRE = `
|
|
||||||
query MangasByGenre($filter: MangaFilterInput, $first: Int, $offset: Int) {
|
|
||||||
mangas(filter: $filter, first: $first, offset: $offset, orderBy: IN_LIBRARY_AT, orderByType: DESC) {
|
|
||||||
nodes {
|
|
||||||
id title thumbnailUrl inLibrary genre status
|
|
||||||
source { id displayName }
|
|
||||||
}
|
|
||||||
pageInfo { hasNextPage }
|
|
||||||
totalCount
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
export const GET_META = `
|
|
||||||
query GetMeta($key: String!) {
|
|
||||||
meta(key: $key) {
|
|
||||||
key value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const GET_METAS = `
|
|
||||||
query GetMetas {
|
|
||||||
metas {
|
|
||||||
nodes { key value }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
# Queries
|
|
||||||
|
|
||||||
## Manga (`queries/manga.ts`)
|
|
||||||
|
|
||||||
| Query | Variables | Description |
|
|
||||||
|-------|-----------|-------------|
|
|
||||||
| `GET_LIBRARY` | — | All in-library manga with metadata, source, chapter counts, download count, unread count, bookmark count, and read progress anchors (`lastReadChapter`, `firstUnreadChapter`) |
|
|
||||||
| `GET_ALL_MANGA` | — | Minimal manga list — id, title, thumbnail, library flag, download count |
|
|
||||||
| `GET_MANGA` | `id: Int!` | Full detail for a single manga — includes `updateStrategy`, `lastReadChapter`, `firstUnreadChapter`, `highestNumberedChapter` |
|
|
||||||
| `GET_CATEGORIES` | — | All categories with order/settings and their assigned manga (minimal fields) |
|
|
||||||
| `GET_DOWNLOADED_CHAPTERS_PAGES` | — | Page counts for all downloaded chapters — used for storage stats |
|
|
||||||
| `GET_DOWNLOADS_PATH` | — | `downloadsPath` and `localSourcePath` from settings |
|
|
||||||
| `LIBRARY_UPDATE_STATUS` | — | Current library update job — `jobsInfo` progress and `mangaUpdates` list with new chapters |
|
|
||||||
| `GET_RESTORE_STATUS` | `id: String!` | Backup restore job status by job ID — `mangaProgress`, `state`, `totalManga` |
|
|
||||||
| `VALIDATE_BACKUP` | `backup: Upload!` | Validate a backup file before restore — returns missing sources and trackers |
|
|
||||||
| `MANGAS_BY_GENRE` | `filter: MangaFilterInput`, `first: Int`, `offset: Int` | Paginated manga filtered by genre, ordered by `IN_LIBRARY_AT DESC` |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Chapters (`queries/chapters.ts`)
|
|
||||||
|
|
||||||
| Query | Variables | Description |
|
|
||||||
|-------|-----------|-------------|
|
|
||||||
| `GET_RECENTLY_UPDATED` | — | Latest 300 chapters ordered by `FETCHED_AT DESC` with parent manga info |
|
|
||||||
| `GET_CHAPTERS` | `mangaId: Int!` | All chapters for a manga — includes `lastReadAt`, `lastPageRead`, read/download/bookmark state, page count, scanlator |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Downloads (`queries/downloads.ts`)
|
|
||||||
|
|
||||||
| Query | Variables | Description |
|
|
||||||
|-------|-----------|-------------|
|
|
||||||
| `GET_DOWNLOAD_STATUS` | — | Downloader state (`DownloaderState` enum) and full queue with chapter and manga info |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Extensions (`queries/extensions.ts`)
|
|
||||||
|
|
||||||
| Query | Variables | Description |
|
|
||||||
|-------|-----------|-------------|
|
|
||||||
| `GET_LOCAL_MANGA` | — | Manga from the local source (`sourceId: "0"`) |
|
|
||||||
| `GET_EXTENSIONS` | — | All extensions — install status, update flag, obsolete flag, metadata |
|
|
||||||
| `GET_SOURCES` | — | All sources — id, name, lang, display name, icon, NSFW flag, `isConfigurable`, `supportsLatest`, `baseUrl` |
|
|
||||||
| `GET_SETTINGS` | — | `extensionRepos` from settings |
|
|
||||||
| `GET_SERVER_SECURITY` | — | Full security config — auth mode, SOCKS proxy settings, FlareSolverr settings |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Tracking (`queries/tracking.ts`)
|
|
||||||
|
|
||||||
| Query | Variables | Description |
|
|
||||||
|-------|-----------|-------------|
|
|
||||||
| `GET_TRACKERS` | — | All trackers with login state, token expiry, capability flags (`supportsPrivateTracking`, `supportsReadingDates`, `supportsTrackDeletion`), scores, and statuses |
|
|
||||||
| `GET_MANGA_TRACK_RECORDS` | `mangaId: Int!` | All track records for a specific manga — includes `libraryId`, score, dates, privacy flag |
|
|
||||||
| `SEARCH_TRACKER` | `trackerId: Int!`, `query: String!` | Search a tracker by query string — returns id, title, cover, summary, publishing info |
|
|
||||||
| `GET_ALL_TRACKER_RECORDS` | — | All trackers and their full record lists with associated manga — includes `isTokenExpired`, `libraryId` |
|
|
||||||
| `GET_TRACKER_RECORDS` | `trackerId: Int!` | Records for a specific tracker with associated manga |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Updater (`queries/updater.ts`)
|
|
||||||
|
|
||||||
| Query | Variables | Description |
|
|
||||||
|-------|-----------|-------------|
|
|
||||||
| `GET_ABOUT_SERVER` | — | Server name, version, build type, build time, GitHub and Discord links |
|
|
||||||
| `GET_ABOUT_WEBUI` | — | WebUI channel, tag, and last update timestamp |
|
|
||||||
| `CHECK_FOR_SERVER_UPDATES` | — | Available server updates — channel, tag, download URL |
|
|
||||||
| `CHECK_FOR_WEBUI_UPDATE` | — | Available WebUI updates — channel and tag |
|
|
||||||
| `GET_WEBUI_UPDATE_STATUS` | — | Live WebUI update state (`UpdateState` enum), progress percent, and info block |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Meta (`queries/meta.ts`)
|
|
||||||
|
|
||||||
| Query | Variables | Description |
|
|
||||||
|-------|-----------|-------------|
|
|
||||||
| `GET_META` | `key: String!` | Single server-side key/value meta entry |
|
|
||||||
| `GET_METAS` | — | All global meta entries as a node list |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## KoSync (`queries/kosync.ts`)
|
|
||||||
|
|
||||||
| Query | Variables | Description |
|
|
||||||
|-------|-----------|-------------|
|
|
||||||
| `GET_KOSYNC_STATUS` | — | KOReader sync connection status |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## New in Preview
|
|
||||||
|
|
||||||
Queries and fields now available but not yet wired to any feature in Moku:
|
|
||||||
|
|
||||||
| Query / Field | Potential Feature |
|
|
||||||
|---------------|-------------------|
|
|
||||||
| `GET_ABOUT_SERVER` | About page — server version, build info, links to GitHub and Discord |
|
|
||||||
| `GET_ABOUT_WEBUI` | About page — WebUI version and release channel |
|
|
||||||
| `CHECK_FOR_SERVER_UPDATES` | Update available banner or settings badge |
|
|
||||||
| `CHECK_FOR_WEBUI_UPDATE` | Update available banner or settings badge |
|
|
||||||
| `GET_WEBUI_UPDATE_STATUS` | Update progress indicator in settings |
|
|
||||||
| `GET_META` / `GET_METAS` | Server-side persistence — sync app state across clients without local storage |
|
|
||||||
| `GET_KOSYNC_STATUS` | KOReader sync settings section — show connection state |
|
|
||||||
| `trackRecords` (top-level) | Flat tracker record browser — filter by score, privacy, tracker |
|
|
||||||
| `category` (single by id) | Direct category detail without fetching all categories |
|
|
||||||
| `chapter` (single by id) | Direct chapter lookup without fetching full manga chapter list |
|
|
||||||
| `source` (single by id) | Source detail page — preferences, filters, browse |
|
|
||||||
| `tracker` (single by id) | Individual tracker detail — statuses, records |
|
|
||||||
| `trackRecord` (single by id) | Direct track record lookup for deep linking |
|
|
||||||
| `lastUpdateTimestamp` | Stale data detection — poll before refetching library |
|
|
||||||
| `MangaType.hasDuplicateChapters` | Library health view — flag manga with duplicate chapter numbers |
|
|
||||||
| `MangaType.age` / `chaptersAge` | Stale manga indicator — highlight series with no updates in N days |
|
|
||||||
| `MangaType.initialized` | Loading skeleton gating — skip detail render until manga is fully fetched |
|
|
||||||
| `SourceType.isConfigurable` | Source list — show gear icon only when source is configurable |
|
|
||||||
| `SourceType.supportsLatest` | Source browse UI — conditionally show Latest tab |
|
|
||||||
| `TrackerType.supportsTrackDeletion` | Tracking panel — show remove button only when tracker supports it |
|
|
||||||
| `TrackerType.supportsReadingDates` | Tracking panel — show date fields only when tracker supports them |
|
|
||||||
| `TrackerType.isTokenExpired` | Re-auth prompt — detect expired tokens before a request fails |
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
export const GET_TRACKERS = `
|
|
||||||
query GetTrackers {
|
|
||||||
trackers {
|
|
||||||
nodes {
|
|
||||||
id name icon isLoggedIn isTokenExpired authUrl
|
|
||||||
supportsPrivateTracking supportsReadingDates supportsTrackDeletion
|
|
||||||
scores
|
|
||||||
statuses { value name }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const GET_MANGA_TRACK_RECORDS = `
|
|
||||||
query GetMangaTrackRecords($mangaId: Int!) {
|
|
||||||
manga(id: $mangaId) {
|
|
||||||
trackRecords {
|
|
||||||
nodes {
|
|
||||||
id trackerId remoteId title status score displayScore
|
|
||||||
lastChapterRead totalChapters remoteUrl startDate finishDate private libraryId
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const SEARCH_TRACKER = `
|
|
||||||
query SearchTracker($trackerId: Int!, $query: String!) {
|
|
||||||
searchTracker(input: { trackerId: $trackerId, query: $query }) {
|
|
||||||
trackSearches {
|
|
||||||
id trackerId remoteId title coverUrl summary
|
|
||||||
publishingStatus publishingType startDate totalChapters trackingUrl
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const GET_ALL_TRACKER_RECORDS = `
|
|
||||||
query GetAllTrackerRecords {
|
|
||||||
trackers {
|
|
||||||
nodes {
|
|
||||||
id name icon isLoggedIn isTokenExpired scores
|
|
||||||
statuses { value name }
|
|
||||||
trackRecords {
|
|
||||||
nodes {
|
|
||||||
id trackerId title status displayScore lastChapterRead
|
|
||||||
totalChapters remoteUrl private libraryId
|
|
||||||
manga { id title thumbnailUrl inLibrary }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const GET_TRACKER_RECORDS = `
|
|
||||||
query GetTrackerRecords($trackerId: Int!) {
|
|
||||||
trackers(condition: { id: $trackerId }) {
|
|
||||||
nodes {
|
|
||||||
id name
|
|
||||||
statuses { value name }
|
|
||||||
trackRecords {
|
|
||||||
nodes {
|
|
||||||
id title status displayScore lastChapterRead totalChapters remoteUrl
|
|
||||||
manga { id title thumbnailUrl }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
+203
@@ -0,0 +1,203 @@
|
|||||||
|
@import '$lib/components/settings/Settings.css';
|
||||||
|
@import '$lib/styles/themes.css';
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg-void: #080808;
|
||||||
|
--bg-base: #0c0c0c;
|
||||||
|
--bg-surface: #101010;
|
||||||
|
--bg-raised: #151515;
|
||||||
|
--bg-overlay: #1a1a1a;
|
||||||
|
--bg-subtle: #202020;
|
||||||
|
|
||||||
|
--border-dim: #1c1c1c;
|
||||||
|
--border-base: #242424;
|
||||||
|
--border-strong: #2e2e2e;
|
||||||
|
--border-focus: #4a5c4a;
|
||||||
|
|
||||||
|
--text-primary: #f0efec;
|
||||||
|
--text-secondary: #c8c6c0;
|
||||||
|
--text-muted: #8a8880;
|
||||||
|
--text-faint: #4e4d4a;
|
||||||
|
--text-disabled: #2a2a28;
|
||||||
|
|
||||||
|
--accent: #6b8f6b;
|
||||||
|
--accent-dim: #2a3d2a;
|
||||||
|
--accent-muted: #1a251a;
|
||||||
|
--accent-fg: #a8c4a8;
|
||||||
|
--accent-bright: #8fb88f;
|
||||||
|
|
||||||
|
--color-error: #c47a7a;
|
||||||
|
--color-error-bg: #1f1212;
|
||||||
|
--color-success: #7aab7a;
|
||||||
|
--color-info: #7a9ec4;
|
||||||
|
--color-info-bg: #121a1f;
|
||||||
|
--color-read: #2e2e2c;
|
||||||
|
|
||||||
|
--dot-active: var(--accent);
|
||||||
|
--dot-inactive: var(--text-faint);
|
||||||
|
|
||||||
|
--t-fast: 0.08s ease;
|
||||||
|
--t-base: 0.14s ease;
|
||||||
|
--t-slow: 0.22s ease;
|
||||||
|
|
||||||
|
--radius-sm: 3px;
|
||||||
|
--radius-md: 5px;
|
||||||
|
--radius-lg: 7px;
|
||||||
|
--radius-xl: 10px;
|
||||||
|
--radius-2xl: 14px;
|
||||||
|
--radius-full: 9999px;
|
||||||
|
|
||||||
|
--sp-1: 4px;
|
||||||
|
--sp-2: 8px;
|
||||||
|
--sp-3: 12px;
|
||||||
|
--sp-4: 16px;
|
||||||
|
--sp-5: 20px;
|
||||||
|
--sp-6: 24px;
|
||||||
|
--sp-8: 32px;
|
||||||
|
--sp-10: 40px;
|
||||||
|
|
||||||
|
--sidebar-width: 52px;
|
||||||
|
--titlebar-height: 36px;
|
||||||
|
|
||||||
|
--font-ui: "DM Mono", "Fira Mono", ui-monospace, monospace;
|
||||||
|
--font-sans: "DM Sans", ui-sans-serif, system-ui, sans-serif;
|
||||||
|
|
||||||
|
--text-2xs: 10px;
|
||||||
|
--text-xs: 11px;
|
||||||
|
--text-sm: 12px;
|
||||||
|
--text-base: 13px;
|
||||||
|
--text-md: 14px;
|
||||||
|
--text-lg: 15px;
|
||||||
|
--text-xl: 17px;
|
||||||
|
--text-2xl: 20px;
|
||||||
|
--text-3xl: 24px;
|
||||||
|
|
||||||
|
--weight-normal: 400;
|
||||||
|
--weight-medium: 500;
|
||||||
|
--weight-semi: 600;
|
||||||
|
|
||||||
|
--leading-none: 1;
|
||||||
|
--leading-tight: 1.3;
|
||||||
|
--leading-snug: 1.45;
|
||||||
|
--leading-base: 1.6;
|
||||||
|
|
||||||
|
--tracking-tight: -0.02em;
|
||||||
|
--tracking-normal: 0;
|
||||||
|
--tracking-wide: 0.06em;
|
||||||
|
--tracking-wider: 0.1em;
|
||||||
|
|
||||||
|
--z-reader: 50;
|
||||||
|
--z-modal: 100;
|
||||||
|
--z-settings: 150;
|
||||||
|
}
|
||||||
|
|
||||||
|
*, *::before, *::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--bg-void);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
#svelte {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
color: inherit;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
input, textarea, select {
|
||||||
|
font: inherit;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul, ol { list-style: none; }
|
||||||
|
|
||||||
|
img, svg { display: block; max-width: 100%; }
|
||||||
|
|
||||||
|
p { margin: 0; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: var(--text-base);
|
||||||
|
font-weight: var(--weight-normal);
|
||||||
|
line-height: var(--leading-base);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: transparent transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
*::-webkit-scrollbar { width: 4px; height: 4px; }
|
||||||
|
*::-webkit-scrollbar-track { background: transparent; }
|
||||||
|
*::-webkit-scrollbar-thumb { background: transparent; border-radius: 99px; }
|
||||||
|
*::-webkit-scrollbar-thumb:hover { background: transparent; }
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeUp {
|
||||||
|
from { opacity: 0; transform: translateY(5px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeDown {
|
||||||
|
from { opacity: 0; transform: translateY(-5px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes scaleIn {
|
||||||
|
from { opacity: 0; transform: scale(0.97); }
|
||||||
|
to { opacity: 1; transform: scale(1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
from { transform: rotate(0deg); }
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.35; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shimmer {
|
||||||
|
from { background-position: -200% 0; }
|
||||||
|
to { background-position: 200% 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.anim-fade-in { animation: fadeIn 0.14s ease both; }
|
||||||
|
.anim-fade-up { animation: fadeUp 0.18s ease both; }
|
||||||
|
.anim-fade-down { animation: fadeDown 0.18s ease both; }
|
||||||
|
.anim-scale-in { animation: scaleIn 0.14s ease both; }
|
||||||
|
.anim-pulse { animation: pulse 1.6s ease infinite; }
|
||||||
|
.anim-spin { animation: spin 0.7s linear infinite; }
|
||||||
|
|
||||||
|
.skeleton {
|
||||||
|
background: linear-gradient(90deg, var(--bg-raised) 25%, var(--bg-overlay) 50%, var(--bg-raised) 75%);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: shimmer 1.4s ease infinite;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
Vendored
+5
@@ -0,0 +1,5 @@
|
|||||||
|
declare global {
|
||||||
|
namespace App {}
|
||||||
|
const __APP_VERSION__: string
|
||||||
|
}
|
||||||
|
export {}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<link rel="icon" href="%sveltekit.assets%/favicon.svg" />
|
||||||
|
%sveltekit.head%
|
||||||
|
</head>
|
||||||
|
<body data-theme="dark">
|
||||||
|
<div id="svelte">%sveltekit.body%</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -1 +0,0 @@
|
|||||||
export * from "./selectPortal";
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
import type { Attachment } from "svelte/attachments";
|
|
||||||
|
|
||||||
export function selectPortal(triggerEl: HTMLElement & { __selectMenuEl?: HTMLElement | null }): Attachment {
|
|
||||||
return (menuEl: HTMLElement) => {
|
|
||||||
function position() {
|
|
||||||
const zoom = parseFloat(document.documentElement.style.zoom) / 100 || 1;
|
|
||||||
const r = triggerEl.getBoundingClientRect();
|
|
||||||
|
|
||||||
const top = r.bottom / zoom + 4;
|
|
||||||
const right = r.right / zoom;
|
|
||||||
const width = menuEl.offsetWidth;
|
|
||||||
const left = Math.max(8, right - width);
|
|
||||||
|
|
||||||
menuEl.style.position = "fixed";
|
|
||||||
menuEl.style.top = `${top}px`;
|
|
||||||
menuEl.style.left = `${left}px`;
|
|
||||||
}
|
|
||||||
|
|
||||||
menuEl.style.visibility = "hidden";
|
|
||||||
document.body.appendChild(menuEl);
|
|
||||||
triggerEl.__selectMenuEl = menuEl;
|
|
||||||
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
position();
|
|
||||||
menuEl.style.visibility = "";
|
|
||||||
});
|
|
||||||
|
|
||||||
window.addEventListener("scroll", position, true);
|
|
||||||
window.addEventListener("resize", position);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener("scroll", position, true);
|
|
||||||
window.removeEventListener("resize", position);
|
|
||||||
triggerEl.__selectMenuEl = null;
|
|
||||||
menuEl.remove();
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
export * from './sort';
|
|
||||||
export * from './filter';
|
|
||||||
export * from './paginate';
|
|
||||||
export * from './search';
|
|
||||||
export * from './queue';
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
export interface AsyncQueue<T> {
|
|
||||||
enqueue(item: T): void;
|
|
||||||
drain(): void;
|
|
||||||
clear(): void;
|
|
||||||
size(): number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createAsyncQueue<T>(
|
|
||||||
worker: (item: T) => Promise<void>,
|
|
||||||
concurrency = 1,
|
|
||||||
): AsyncQueue<T> {
|
|
||||||
const queue: T[] = [];
|
|
||||||
let active = 0;
|
|
||||||
|
|
||||||
function next() {
|
|
||||||
while (active < concurrency && queue.length > 0) {
|
|
||||||
const item = queue.shift()!;
|
|
||||||
active++;
|
|
||||||
worker(item).finally(() => { active--; next(); });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
enqueue(item) { queue.push(item); next(); },
|
|
||||||
drain() { next(); },
|
|
||||||
clear() { queue.length = 0; },
|
|
||||||
size() { return queue.length; },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
export interface SearchResult<T> {
|
|
||||||
item: T;
|
|
||||||
score: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function searchItems<T>(
|
|
||||||
items: T[],
|
|
||||||
query: string,
|
|
||||||
getField: (item: T) => string,
|
|
||||||
): T[] {
|
|
||||||
const q = query.trim().toLowerCase();
|
|
||||||
if (!q) return items;
|
|
||||||
return items.filter(item => getField(item).toLowerCase().includes(q));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function searchWithScore<T>(
|
|
||||||
items: T[],
|
|
||||||
query: string,
|
|
||||||
getField: (item: T) => string,
|
|
||||||
): SearchResult<T>[] {
|
|
||||||
const q = query.trim().toLowerCase();
|
|
||||||
if (!q) return items.map(item => ({ item, score: 0 }));
|
|
||||||
|
|
||||||
return items
|
|
||||||
.map(item => {
|
|
||||||
const field = getField(item).toLowerCase();
|
|
||||||
if (!field.includes(q)) return null;
|
|
||||||
const score = field === q ? 2 : field.startsWith(q) ? 1 : 0;
|
|
||||||
return { item, score };
|
|
||||||
})
|
|
||||||
.filter((r): r is SearchResult<T> => r !== null)
|
|
||||||
.sort((a, b) => b.score - a.score);
|
|
||||||
}
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
/**
|
|
||||||
* Runs an async task over every item in `items`, with at most `concurrency`
|
|
||||||
* tasks in-flight at once. Respects the provided AbortSignal — each worker
|
|
||||||
* exits early if the signal fires. Errors thrown by individual tasks are
|
|
||||||
* swallowed so one failure does not cancel the whole batch.
|
|
||||||
*/
|
|
||||||
export async function runConcurrent<T>(
|
|
||||||
items: T[],
|
|
||||||
fn: (item: T) => Promise<void>,
|
|
||||||
signal: AbortSignal,
|
|
||||||
concurrency = 6,
|
|
||||||
): Promise<void> {
|
|
||||||
let i = 0;
|
|
||||||
async function worker() {
|
|
||||||
while (i < items.length) {
|
|
||||||
if (signal.aborted) return;
|
|
||||||
const item = items[i++];
|
|
||||||
await fn(item).catch(() => {});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await Promise.all(
|
|
||||||
Array.from({ length: Math.min(concurrency, items.length) }, worker),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Deduplicates in-flight async calls by key.
|
|
||||||
*
|
|
||||||
* Two call signatures are supported:
|
|
||||||
*
|
|
||||||
* 1. Direct call — supply a key and a zero-arg factory each time:
|
|
||||||
* dedupeRequest("my-key", () => fetchSomething())
|
|
||||||
* If a request with that key is already pending, the existing Promise is
|
|
||||||
* returned and the factory is not called again.
|
|
||||||
*
|
|
||||||
* 2. Curried wrapper — supply a key-based fetcher once, get back a
|
|
||||||
* single-arg function you can call repeatedly:
|
|
||||||
* const get = dedupeRequest((key) => fetchSomething(key))
|
|
||||||
* get("my-key")
|
|
||||||
*/
|
|
||||||
const _inflight = new Map<string, Promise<unknown>>();
|
|
||||||
|
|
||||||
export function dedupeRequest<T>(key: string, factory: () => Promise<T>): Promise<T>;
|
|
||||||
export function dedupeRequest<T>(fn: (key: string) => Promise<T>): (key: string) => Promise<T>;
|
|
||||||
export function dedupeRequest<T>(
|
|
||||||
keyOrFn: string | ((key: string) => Promise<T>),
|
|
||||||
factory?: () => Promise<T>,
|
|
||||||
): Promise<T> | ((key: string) => Promise<T>) {
|
|
||||||
// Curried wrapper form
|
|
||||||
if (typeof keyOrFn === 'function') {
|
|
||||||
const fn = keyOrFn;
|
|
||||||
return (key: string) => dedupeRequest(key, () => fn(key));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Direct call form
|
|
||||||
const key = keyOrFn;
|
|
||||||
if (_inflight.has(key)) return _inflight.get(key) as Promise<T>;
|
|
||||||
const p = factory!().finally(() => _inflight.delete(key));
|
|
||||||
_inflight.set(key, p);
|
|
||||||
return p;
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
export interface PaginatedQuery<T> {
|
|
||||||
fetchPage(page: number): Promise<T[]>;
|
|
||||||
reset(): void;
|
|
||||||
hasMore(): boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PaginatedQueryConfig<T> {
|
|
||||||
fetcher: (page: number) => Promise<{ items: T[]; hasNextPage: boolean }>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createPaginatedQuery<T>(
|
|
||||||
config: PaginatedQueryConfig<T>,
|
|
||||||
): PaginatedQuery<T> {
|
|
||||||
let _hasMore = true;
|
|
||||||
|
|
||||||
return {
|
|
||||||
async fetchPage(page) {
|
|
||||||
const { items, hasNextPage } = await config.fetcher(page);
|
|
||||||
_hasMore = hasNextPage;
|
|
||||||
return items;
|
|
||||||
},
|
|
||||||
reset() { _hasMore = true; },
|
|
||||||
hasMore() { return _hasMore; },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
export * from './fetchWithRetry';
|
|
||||||
export * from './batchRequests';
|
|
||||||
export * from './createPaginatedQuery';
|
|
||||||
@@ -1,629 +0,0 @@
|
|||||||
import { store, updateSettings } from "@store/state.svelte";
|
|
||||||
|
|
||||||
export type AuthMode = "NONE" | "BASIC_AUTH" | "UI_LOGIN";
|
|
||||||
|
|
||||||
export class AuthRequiredError extends Error {
|
|
||||||
constructor(msg = "Authentication required") {
|
|
||||||
super(msg);
|
|
||||||
this.name = "AuthRequiredError";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const TOKEN_KEY = "moku_access_token";
|
|
||||||
const UI_SESSION_KEY = "moku_ui_auth_session";
|
|
||||||
const TOKEN_REFRESH_SKEW_MS = 30_000;
|
|
||||||
const AUTH_DEBUG = Boolean((import.meta as ImportMeta & { env?: { DEV?: boolean } }).env?.DEV);
|
|
||||||
|
|
||||||
interface StoredAccessToken {
|
|
||||||
base: string;
|
|
||||||
token: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface StoredUiAuthSession {
|
|
||||||
base: string;
|
|
||||||
accessToken: string;
|
|
||||||
refreshToken?: string;
|
|
||||||
clientMutationId?: string;
|
|
||||||
accessExpiresAt?: number | null;
|
|
||||||
refreshExpiresAt?: number | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface JwtSettings {
|
|
||||||
jwtAudience?: string | null;
|
|
||||||
jwtRefreshExpiry?: string | null;
|
|
||||||
jwtTokenExpiry?: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UiAuthDebugStatus {
|
|
||||||
mode: AuthMode;
|
|
||||||
serverBase: string;
|
|
||||||
hasSession: boolean;
|
|
||||||
hasRefreshToken: boolean;
|
|
||||||
accessExpiresAt: number | null;
|
|
||||||
refreshExpiresAt: number | null;
|
|
||||||
accessExpiresInMs: number | null;
|
|
||||||
refreshExpiresInMs: number | null;
|
|
||||||
shouldRefreshSoon: boolean;
|
|
||||||
refreshInFlight: boolean;
|
|
||||||
skewMs: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
let _accessToken: string | null = null;
|
|
||||||
let _accessTokenBase: string | null = null;
|
|
||||||
let _uiSession: StoredUiAuthSession | null = null;
|
|
||||||
let _refreshPromise: Promise<string | null> | null = null;
|
|
||||||
let _jwtSettingsBase: string | null = null;
|
|
||||||
let _jwtSettings: JwtSettings | null = null;
|
|
||||||
let _jwtSettingsFetchedAt = 0;
|
|
||||||
|
|
||||||
function authDebug(event: string, fields?: Record<string, unknown>) {
|
|
||||||
if (!AUTH_DEBUG) return;
|
|
||||||
if (fields) {
|
|
||||||
console.debug(`[auth] ${event}`, fields);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
console.debug(`[auth] ${event}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseIsoDuration(duration: string): number | null {
|
|
||||||
try {
|
|
||||||
const match = duration.match(
|
|
||||||
/^P(?:(\d+)Y)?(?:(\d+)M)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:([\d.]+)S)?)?$/
|
|
||||||
);
|
|
||||||
if (!match) return null;
|
|
||||||
const [, years, months, days, hours, minutes, seconds] = match;
|
|
||||||
let ms = 0;
|
|
||||||
if (years) ms += parseInt(years) * 365.25 * 24 * 60 * 60 * 1000;
|
|
||||||
if (months) ms += parseInt(months) * 30.44 * 24 * 60 * 60 * 1000;
|
|
||||||
if (days) ms += parseInt(days) * 24 * 60 * 60 * 1000;
|
|
||||||
if (hours) ms += parseInt(hours) * 60 * 60 * 1000;
|
|
||||||
if (minutes) ms += parseInt(minutes) * 60 * 1000;
|
|
||||||
if (seconds) ms += parseFloat(seconds) * 1000;
|
|
||||||
return ms;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function decodeJwtExpiryMs(token: string): number | null {
|
|
||||||
try {
|
|
||||||
const payload = token.split(".")[1];
|
|
||||||
if (!payload) return null;
|
|
||||||
const normalized = payload.replace(/-/g, "+").replace(/_/g, "/");
|
|
||||||
const padded = normalized.padEnd(normalized.length + ((4 - (normalized.length % 4)) % 4), "=");
|
|
||||||
const decoded = atob(padded);
|
|
||||||
const json = JSON.parse(decoded) as { exp?: number };
|
|
||||||
return typeof json.exp === "number" ? json.exp * 1000 : null;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function isExpired(expiresAt?: number | null, skewMs = TOKEN_REFRESH_SKEW_MS): boolean {
|
|
||||||
if (!expiresAt || !Number.isFinite(expiresAt)) return false;
|
|
||||||
return Date.now() >= expiresAt - skewMs;
|
|
||||||
}
|
|
||||||
|
|
||||||
function withExpiryFromSettings(
|
|
||||||
accessToken: string,
|
|
||||||
jwt: JwtSettings | null,
|
|
||||||
): Pick<StoredUiAuthSession, "accessExpiresAt" | "refreshExpiresAt"> {
|
|
||||||
const now = Date.now();
|
|
||||||
const accessExpiresAt =
|
|
||||||
decodeJwtExpiryMs(accessToken)
|
|
||||||
?? (typeof jwt?.jwtTokenExpiry === "string" ? now + (parseIsoDuration(jwt.jwtTokenExpiry) ?? 0) : null);
|
|
||||||
const refreshExpiresAt =
|
|
||||||
typeof jwt?.jwtRefreshExpiry === "string" ? now + (parseIsoDuration(jwt.jwtRefreshExpiry) ?? 0) : null;
|
|
||||||
return { accessExpiresAt, refreshExpiresAt };
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchJwtSettings(base: string): Promise<JwtSettings | null> {
|
|
||||||
const res = await fetchAuthenticated(
|
|
||||||
`${base}/api/graphql`,
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: gqlBody(
|
|
||||||
`query GetJWTSettings {
|
|
||||||
settings {
|
|
||||||
jwtAudience
|
|
||||||
jwtRefreshExpiry
|
|
||||||
jwtTokenExpiry
|
|
||||||
}
|
|
||||||
}`,
|
|
||||||
),
|
|
||||||
},
|
|
||||||
timeoutSignal(5000),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
authDebug("JWT settings fetch failed", { status: res.status });
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const json = await res.json();
|
|
||||||
if (json?.errors?.length) {
|
|
||||||
authDebug("JWT settings query error", { errors: json.errors });
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const settings = json?.data?.settings;
|
|
||||||
if (!settings || typeof settings !== "object") {
|
|
||||||
authDebug("JWT settings missing or invalid", { settings });
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
authDebug("JWT settings fetched", {
|
|
||||||
hasAudience: !!settings.jwtAudience,
|
|
||||||
tokenExpiry: settings.jwtTokenExpiry,
|
|
||||||
refreshExpiry: settings.jwtRefreshExpiry,
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
jwtAudience: typeof settings.jwtAudience === "string" ? settings.jwtAudience : null,
|
|
||||||
jwtRefreshExpiry: typeof settings.jwtRefreshExpiry === "string" ? settings.jwtRefreshExpiry : null,
|
|
||||||
jwtTokenExpiry: typeof settings.jwtTokenExpiry === "string" ? settings.jwtTokenExpiry : null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getJwtSettings(force = false): Promise<JwtSettings | null> {
|
|
||||||
const base = getServerBase();
|
|
||||||
const freshEnough = Date.now() - _jwtSettingsFetchedAt < 60_000;
|
|
||||||
if (!force && _jwtSettingsBase === base && _jwtSettings && freshEnough) return _jwtSettings;
|
|
||||||
|
|
||||||
const jwt = await fetchJwtSettings(base);
|
|
||||||
_jwtSettingsBase = base;
|
|
||||||
_jwtSettings = jwt;
|
|
||||||
_jwtSettingsFetchedAt = Date.now();
|
|
||||||
return jwt;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const uiAuth = {
|
|
||||||
getSession: () => {
|
|
||||||
const base = getServerBase();
|
|
||||||
if (_uiSession && _uiSession.base === base) return _uiSession;
|
|
||||||
|
|
||||||
const stored = readStoredSession();
|
|
||||||
if (!stored) return null;
|
|
||||||
if (stored.base !== base) {
|
|
||||||
sessionStorage.removeItem(UI_SESSION_KEY);
|
|
||||||
sessionStorage.removeItem(TOKEN_KEY);
|
|
||||||
_uiSession = null;
|
|
||||||
_accessToken = null;
|
|
||||||
_accessTokenBase = null;
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
_uiSession = stored;
|
|
||||||
_accessToken = stored.accessToken;
|
|
||||||
_accessTokenBase = stored.base;
|
|
||||||
return _uiSession;
|
|
||||||
},
|
|
||||||
setSession: (session: Omit<StoredUiAuthSession, "base">) => {
|
|
||||||
const base = getServerBase();
|
|
||||||
_uiSession = { ...session, base };
|
|
||||||
_accessToken = session.accessToken;
|
|
||||||
_accessTokenBase = base;
|
|
||||||
sessionStorage.setItem(UI_SESSION_KEY, JSON.stringify(_uiSession));
|
|
||||||
sessionStorage.removeItem(TOKEN_KEY);
|
|
||||||
},
|
|
||||||
getToken: () => {
|
|
||||||
const session = uiAuth.getSession();
|
|
||||||
if (!session) return null;
|
|
||||||
|
|
||||||
if (isExpired(session.accessExpiresAt, 0)) return null;
|
|
||||||
|
|
||||||
const base = getServerBase();
|
|
||||||
if (_accessToken && _accessTokenBase === base) return _accessToken;
|
|
||||||
const stored = readStoredToken();
|
|
||||||
if (!stored) return null;
|
|
||||||
if (stored.base !== base) {
|
|
||||||
sessionStorage.removeItem(TOKEN_KEY);
|
|
||||||
sessionStorage.removeItem(UI_SESSION_KEY);
|
|
||||||
_accessToken = null;
|
|
||||||
_accessTokenBase = null;
|
|
||||||
_uiSession = null;
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
_accessToken = stored.token;
|
|
||||||
_accessTokenBase = stored.base;
|
|
||||||
return _accessToken;
|
|
||||||
},
|
|
||||||
setToken: (t: string) => {
|
|
||||||
const existing = uiAuth.getSession();
|
|
||||||
if (existing?.refreshToken) {
|
|
||||||
uiAuth.setSession({
|
|
||||||
...existing,
|
|
||||||
accessToken: t,
|
|
||||||
...withExpiryFromSettings(t, _jwtSettings),
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const base = getServerBase();
|
|
||||||
_accessToken = t;
|
|
||||||
_accessTokenBase = base;
|
|
||||||
sessionStorage.setItem(TOKEN_KEY, JSON.stringify({ base, token: t }));
|
|
||||||
},
|
|
||||||
setLoginSession: (payload: { accessToken: string; refreshToken: string; clientMutationId?: string }, jwt: JwtSettings | null) => {
|
|
||||||
uiAuth.setSession({
|
|
||||||
accessToken: payload.accessToken,
|
|
||||||
refreshToken: payload.refreshToken,
|
|
||||||
clientMutationId: payload.clientMutationId,
|
|
||||||
...withExpiryFromSettings(payload.accessToken, jwt),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
updateAccessToken: (payload: { accessToken: string; clientMutationId?: string }, jwt: JwtSettings | null) => {
|
|
||||||
const existing = uiAuth.getSession();
|
|
||||||
if (!existing?.refreshToken) {
|
|
||||||
uiAuth.setToken(payload.accessToken);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
uiAuth.setSession({
|
|
||||||
...existing,
|
|
||||||
accessToken: payload.accessToken,
|
|
||||||
clientMutationId: payload.clientMutationId ?? existing.clientMutationId,
|
|
||||||
...withExpiryFromSettings(payload.accessToken, jwt),
|
|
||||||
refreshToken: existing.refreshToken,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
clearToken: () => {
|
|
||||||
_accessToken = null;
|
|
||||||
_accessTokenBase = null;
|
|
||||||
_uiSession = null;
|
|
||||||
sessionStorage.removeItem(TOKEN_KEY);
|
|
||||||
sessionStorage.removeItem(UI_SESSION_KEY);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export const authSession = {
|
|
||||||
clearTokens() {
|
|
||||||
_refreshPromise = null;
|
|
||||||
_jwtSettings = null;
|
|
||||||
_jwtSettingsBase = null;
|
|
||||||
_jwtSettingsFetchedAt = 0;
|
|
||||||
uiAuth.clearToken();
|
|
||||||
},
|
|
||||||
hasSession(): boolean {
|
|
||||||
const mode = store.settings.serverAuthMode ?? "NONE";
|
|
||||||
if (mode === "UI_LOGIN") return uiAuth.getSession() !== null;
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
function getServerBase(): string {
|
|
||||||
const url = store.settings.serverUrl;
|
|
||||||
return typeof url === "string" && url.trim() ? url.replace(/\/$/, "") : "http://127.0.0.1:4567";
|
|
||||||
}
|
|
||||||
|
|
||||||
function readStoredToken(): StoredAccessToken | null {
|
|
||||||
const session = readStoredSession();
|
|
||||||
if (session) return { base: session.base, token: session.accessToken };
|
|
||||||
|
|
||||||
const raw = sessionStorage.getItem(TOKEN_KEY);
|
|
||||||
if (raw?.trim()) {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(raw);
|
|
||||||
if (typeof parsed?.base === "string" && typeof parsed?.token === "string")
|
|
||||||
return { base: parsed.base, token: parsed.token };
|
|
||||||
} catch {}
|
|
||||||
|
|
||||||
const migrated = { base: getServerBase(), token: raw.trim() };
|
|
||||||
sessionStorage.setItem(TOKEN_KEY, JSON.stringify(migrated));
|
|
||||||
return migrated;
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function readStoredSession(): StoredUiAuthSession | null {
|
|
||||||
const raw = sessionStorage.getItem(UI_SESSION_KEY);
|
|
||||||
if (raw?.trim()) {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(raw);
|
|
||||||
if (typeof parsed?.base === "string" && typeof parsed?.accessToken === "string") {
|
|
||||||
return {
|
|
||||||
base: parsed.base,
|
|
||||||
accessToken: parsed.accessToken,
|
|
||||||
refreshToken: typeof parsed.refreshToken === "string" ? parsed.refreshToken : undefined,
|
|
||||||
clientMutationId: typeof parsed.clientMutationId === "string" ? parsed.clientMutationId : undefined,
|
|
||||||
accessExpiresAt: typeof parsed.accessExpiresAt === "number" ? parsed.accessExpiresAt : null,
|
|
||||||
refreshExpiresAt: typeof parsed.refreshExpiresAt === "number" ? parsed.refreshExpiresAt : null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
|
|
||||||
const legacy = sessionStorage.getItem(TOKEN_KEY);
|
|
||||||
if (!legacy?.trim()) return null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(legacy);
|
|
||||||
if (typeof parsed?.base === "string" && typeof parsed?.token === "string") {
|
|
||||||
const migrated: StoredUiAuthSession = { base: parsed.base, accessToken: parsed.token };
|
|
||||||
sessionStorage.setItem(UI_SESSION_KEY, JSON.stringify(migrated));
|
|
||||||
return migrated;
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
|
|
||||||
const migrated: StoredUiAuthSession = { base: getServerBase(), accessToken: legacy.trim() };
|
|
||||||
sessionStorage.setItem(UI_SESSION_KEY, JSON.stringify(migrated));
|
|
||||||
return migrated;
|
|
||||||
}
|
|
||||||
|
|
||||||
function timeoutSignal(ms: number): AbortSignal {
|
|
||||||
const controller = new AbortController();
|
|
||||||
setTimeout(() => controller.abort(), ms);
|
|
||||||
return controller.signal;
|
|
||||||
}
|
|
||||||
|
|
||||||
function basicHeader(user: string, pass: string): Record<string, string> {
|
|
||||||
return { Authorization: `Basic ${btoa(`${user}:${pass}`)}` };
|
|
||||||
}
|
|
||||||
|
|
||||||
function bearerHeader(token: string): Record<string, string> {
|
|
||||||
return { Authorization: `Bearer ${token}` };
|
|
||||||
}
|
|
||||||
|
|
||||||
function gqlBody(query: string, variables?: Record<string, unknown>): string {
|
|
||||||
return JSON.stringify({ query, ...(variables ? { variables } : {}) });
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function fetchAuthenticated(
|
|
||||||
url: string,
|
|
||||||
init: RequestInit,
|
|
||||||
signal?: AbortSignal,
|
|
||||||
skipped = false,
|
|
||||||
): Promise<Response> {
|
|
||||||
const mode = store.settings.serverAuthMode ?? "NONE";
|
|
||||||
const baseHeaders = (init.headers ?? {}) as Record<string, string>;
|
|
||||||
|
|
||||||
if (mode === "BASIC_AUTH") {
|
|
||||||
const user = store.settings.serverAuthUser?.trim() ?? "";
|
|
||||||
const pass = store.settings.serverAuthPass?.trim() ?? "";
|
|
||||||
return fetch(url, {
|
|
||||||
...init, signal, credentials: "omit",
|
|
||||||
headers: { ...baseHeaders, ...(user && pass ? basicHeader(user, pass) : {}) },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (mode === "UI_LOGIN") {
|
|
||||||
const token = await getUIAccessToken();
|
|
||||||
if (!token) {
|
|
||||||
if (skipped) return fetch(url, { ...init, signal, credentials: "omit", headers: baseHeaders });
|
|
||||||
throw new AuthRequiredError();
|
|
||||||
}
|
|
||||||
|
|
||||||
let res = await fetch(url, {
|
|
||||||
...init, signal, credentials: "omit",
|
|
||||||
headers: { ...baseHeaders, ...bearerHeader(token) },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (res.status !== 401 || skipped) return res;
|
|
||||||
|
|
||||||
const refreshed = await refreshUiAccessToken(true);
|
|
||||||
if (!refreshed) return res;
|
|
||||||
|
|
||||||
res = await fetch(url, {
|
|
||||||
...init, signal, credentials: "omit",
|
|
||||||
headers: { ...baseHeaders, ...bearerHeader(refreshed) },
|
|
||||||
});
|
|
||||||
return res;
|
|
||||||
}
|
|
||||||
|
|
||||||
return fetch(url, { ...init, signal, credentials: "omit" });
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getUIAccessToken(forceRefresh = false): Promise<string | null> {
|
|
||||||
const session = uiAuth.getSession();
|
|
||||||
if (!session) return null;
|
|
||||||
if (forceRefresh || isExpired(session.accessExpiresAt)) {
|
|
||||||
return refreshUiAccessToken(true);
|
|
||||||
}
|
|
||||||
return session.accessToken;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function refreshUiAccessToken(force = false): Promise<string | null> {
|
|
||||||
const session = uiAuth.getSession();
|
|
||||||
if (!session) return null;
|
|
||||||
if (!session.refreshToken) {
|
|
||||||
if (force && isExpired(session.accessExpiresAt, 0)) return null;
|
|
||||||
return session.accessToken;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!force && !isExpired(session.accessExpiresAt)) return session.accessToken;
|
|
||||||
if (isExpired(session.refreshExpiresAt)) {
|
|
||||||
authDebug("refresh skipped: refresh token expired", {
|
|
||||||
force,
|
|
||||||
refreshExpiresAt: session.refreshExpiresAt ?? null,
|
|
||||||
});
|
|
||||||
uiAuth.clearToken();
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_refreshPromise) {
|
|
||||||
authDebug("refresh joined existing request");
|
|
||||||
return _refreshPromise;
|
|
||||||
}
|
|
||||||
|
|
||||||
authDebug("refresh start", {
|
|
||||||
force,
|
|
||||||
accessExpiresAt: session.accessExpiresAt ?? null,
|
|
||||||
refreshExpiresAt: session.refreshExpiresAt ?? null,
|
|
||||||
});
|
|
||||||
|
|
||||||
_refreshPromise = (async () => {
|
|
||||||
const base = getServerBase();
|
|
||||||
const jwt = await getJwtSettings().catch(() => null);
|
|
||||||
|
|
||||||
const res = await fetch(`${base}/api/graphql`, {
|
|
||||||
method: "POST",
|
|
||||||
credentials: "omit",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: gqlBody(
|
|
||||||
`mutation RefreshToken($refreshToken: String!, $clientMutationId: String) {
|
|
||||||
refreshToken(input: { refreshToken: $refreshToken, clientMutationId: $clientMutationId }) {
|
|
||||||
accessToken
|
|
||||||
clientMutationId
|
|
||||||
}
|
|
||||||
}`,
|
|
||||||
{ refreshToken: session.refreshToken, clientMutationId: session.clientMutationId ?? undefined },
|
|
||||||
),
|
|
||||||
signal: timeoutSignal(5000),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
if (res.status === 401 || res.status === 403) {
|
|
||||||
authDebug("refresh rejected by server", { status: res.status });
|
|
||||||
uiAuth.clearToken();
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
authDebug("refresh failed with HTTP error", { status: res.status });
|
|
||||||
throw new Error(`Token refresh failed (${res.status})`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const json = await res.json();
|
|
||||||
const refreshed = json?.data?.refreshToken;
|
|
||||||
const nextAccessToken: string | undefined = refreshed?.accessToken;
|
|
||||||
if (!nextAccessToken) {
|
|
||||||
const msg = json?.errors?.[0]?.message;
|
|
||||||
if (msg && /unauthorized|unauthenticated|forbidden/i.test(msg)) {
|
|
||||||
authDebug("refresh rejected by GraphQL error", { message: msg });
|
|
||||||
uiAuth.clearToken();
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
authDebug("refresh returned no access token", { message: msg ?? null });
|
|
||||||
throw new Error(msg ?? "Token refresh failed");
|
|
||||||
}
|
|
||||||
|
|
||||||
uiAuth.updateAccessToken(
|
|
||||||
{
|
|
||||||
accessToken: nextAccessToken,
|
|
||||||
clientMutationId: typeof refreshed?.clientMutationId === "string"
|
|
||||||
? refreshed.clientMutationId
|
|
||||||
: session.clientMutationId,
|
|
||||||
},
|
|
||||||
jwt,
|
|
||||||
);
|
|
||||||
authDebug("refresh success", {
|
|
||||||
nextAccessExpiresAt: uiAuth.getSession()?.accessExpiresAt ?? null,
|
|
||||||
});
|
|
||||||
return nextAccessToken;
|
|
||||||
})()
|
|
||||||
.catch((e: unknown) => {
|
|
||||||
authDebug("refresh threw error", {
|
|
||||||
message: e instanceof Error ? e.message : String(e),
|
|
||||||
});
|
|
||||||
throw e;
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
_refreshPromise = null;
|
|
||||||
});
|
|
||||||
|
|
||||||
return _refreshPromise;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getUiAuthDebugStatus(now = Date.now()): UiAuthDebugStatus {
|
|
||||||
const session = uiAuth.getSession();
|
|
||||||
const accessExpiresAt = session?.accessExpiresAt ?? null;
|
|
||||||
const refreshExpiresAt = session?.refreshExpiresAt ?? null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
mode: (store.settings.serverAuthMode ?? "NONE") as AuthMode,
|
|
||||||
serverBase: getServerBase(),
|
|
||||||
hasSession: !!session,
|
|
||||||
hasRefreshToken: !!session?.refreshToken,
|
|
||||||
accessExpiresAt,
|
|
||||||
refreshExpiresAt,
|
|
||||||
accessExpiresInMs: accessExpiresAt ? accessExpiresAt - now : null,
|
|
||||||
refreshExpiresInMs: refreshExpiresAt ? refreshExpiresAt - now : null,
|
|
||||||
shouldRefreshSoon: isExpired(accessExpiresAt),
|
|
||||||
refreshInFlight: _refreshPromise !== null,
|
|
||||||
skewMs: TOKEN_REFRESH_SKEW_MS,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function loginUI(user: string, pass: string): Promise<void> {
|
|
||||||
const res = await fetch(`${getServerBase()}/api/graphql`, {
|
|
||||||
method: "POST", credentials: "omit",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: gqlBody(
|
|
||||||
`mutation Login($username: String!, $password: String!) {
|
|
||||||
login(input: { username: $username, password: $password }) {
|
|
||||||
accessToken
|
|
||||||
refreshToken
|
|
||||||
clientMutationId
|
|
||||||
}
|
|
||||||
}`,
|
|
||||||
{ username: user, password: pass },
|
|
||||||
),
|
|
||||||
signal: timeoutSignal(8000),
|
|
||||||
});
|
|
||||||
if (!res.ok) throw new Error(`Login request failed (${res.status})`);
|
|
||||||
const json = await res.json();
|
|
||||||
const payload = json?.data?.login;
|
|
||||||
const accessToken: string | undefined = payload?.accessToken;
|
|
||||||
const refreshToken: string | undefined = payload?.refreshToken;
|
|
||||||
if (!accessToken || !refreshToken) throw new Error(json?.errors?.[0]?.message ?? "Login failed");
|
|
||||||
|
|
||||||
authDebug("login success", { user });
|
|
||||||
|
|
||||||
const preliminarySession = {
|
|
||||||
accessToken,
|
|
||||||
refreshToken,
|
|
||||||
clientMutationId: typeof payload?.clientMutationId === "string" ? payload.clientMutationId : undefined,
|
|
||||||
};
|
|
||||||
|
|
||||||
uiAuth.setLoginSession(preliminarySession, null);
|
|
||||||
updateSettings({ serverAuthMode: "UI_LOGIN", serverAuthUser: user, serverAuthPass: "" });
|
|
||||||
|
|
||||||
const jwt = await getJwtSettings(true).catch(() => null);
|
|
||||||
uiAuth.setLoginSession(preliminarySession, jwt);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function loginBasic(user: string, pass: string): Promise<void> {
|
|
||||||
const res = await fetch(`${getServerBase()}/api/graphql`, {
|
|
||||||
method: "POST", credentials: "omit",
|
|
||||||
headers: { "Content-Type": "application/json", ...basicHeader(user, pass) },
|
|
||||||
body: gqlBody("{ __typename }"),
|
|
||||||
signal: timeoutSignal(5000),
|
|
||||||
});
|
|
||||||
if (!res.ok) throw new Error(`Authentication failed (${res.status})`);
|
|
||||||
updateSettings({ serverAuthMode: "BASIC_AUTH", serverAuthUser: user, serverAuthPass: pass });
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function logout(): Promise<void> {
|
|
||||||
uiAuth.clearToken();
|
|
||||||
updateSettings({ serverAuthPass: "", serverAuthMode: "NONE" });
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function probeServer(): Promise<"ok" | "auth_required" | "unreachable"> {
|
|
||||||
const base = getServerBase();
|
|
||||||
const mode = store.settings.serverAuthMode ?? "NONE";
|
|
||||||
const s = store.settings;
|
|
||||||
const token = mode === "UI_LOGIN" ? await getUIAccessToken() : null;
|
|
||||||
|
|
||||||
if (mode === "UI_LOGIN" && !token) return "auth_required";
|
|
||||||
|
|
||||||
try {
|
|
||||||
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
|
||||||
if (mode === "BASIC_AUTH") {
|
|
||||||
const user = s.serverAuthUser?.trim() ?? "";
|
|
||||||
const pass = s.serverAuthPass?.trim() ?? "";
|
|
||||||
if (user && pass) Object.assign(headers, basicHeader(user, pass));
|
|
||||||
} else if (mode === "UI_LOGIN" && token) {
|
|
||||||
Object.assign(headers, bearerHeader(token));
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await fetch(`${base}/api/graphql`, {
|
|
||||||
method: "POST", credentials: "omit", headers,
|
|
||||||
body: gqlBody("{ __typename }"),
|
|
||||||
signal: timeoutSignal(5000),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (res.ok) return "ok";
|
|
||||||
if (res.status === 401) return "auth_required";
|
|
||||||
return "unreachable";
|
|
||||||
} catch {
|
|
||||||
return "unreachable";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Vendored
-4
@@ -1,4 +0,0 @@
|
|||||||
export * from './memoryCache';
|
|
||||||
export * from './pageCache';
|
|
||||||
export * from './imageCache';
|
|
||||||
export * from './queryCache';
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
import { store, linkManga } from "@store/state.svelte";
|
|
||||||
import type { Manga } from "@types";
|
|
||||||
|
|
||||||
export function autoLinkLibrary(focal: Manga, allManga: Manga[]): Promise<number> {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
const worker = new Worker(
|
|
||||||
new URL("./autoLinkWorker.ts", import.meta.url),
|
|
||||||
{ type: "module" },
|
|
||||||
);
|
|
||||||
|
|
||||||
worker.onmessage = (e: MessageEvent<number[]>) => {
|
|
||||||
const matches = e.data;
|
|
||||||
for (const id of matches) linkManga(focal.id, id);
|
|
||||||
worker.terminate();
|
|
||||||
resolve(matches.length);
|
|
||||||
};
|
|
||||||
|
|
||||||
worker.onerror = () => { worker.terminate(); resolve(0); };
|
|
||||||
|
|
||||||
worker.postMessage({
|
|
||||||
focalTitle: focal.title,
|
|
||||||
focalId: focal.id,
|
|
||||||
allManga: allManga.map(m => ({ id: m.id, title: m.title })),
|
|
||||||
linkedIds: store.settings.mangaLinks?.[focal.id] ?? [],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
interface WorkerMsg {
|
|
||||||
focalTitle: string;
|
|
||||||
focalId: number;
|
|
||||||
allManga: { id: number; title: string }[];
|
|
||||||
linkedIds: number[];
|
|
||||||
}
|
|
||||||
|
|
||||||
function titleSimilarity(a: string, b: string): number {
|
|
||||||
const norm = (s: string) =>
|
|
||||||
s.toLowerCase().replace(/[^a-z0-9\s]/g, "").split(/\s+/).filter(Boolean);
|
|
||||||
const wa = new Set(norm(a));
|
|
||||||
const wb = new Set(norm(b));
|
|
||||||
if (!wa.size || !wb.size) return 0;
|
|
||||||
const intersection = [...wa].filter(w => wb.has(w)).length;
|
|
||||||
return intersection / new Set([...wa, ...wb]).size;
|
|
||||||
}
|
|
||||||
|
|
||||||
self.onmessage = (e: MessageEvent<WorkerMsg>) => {
|
|
||||||
const { focalTitle, focalId, allManga, linkedIds } = e.data;
|
|
||||||
const matches: number[] = [];
|
|
||||||
|
|
||||||
for (const m of allManga) {
|
|
||||||
if (m.id === focalId) continue;
|
|
||||||
if (linkedIds.includes(m.id)) continue;
|
|
||||||
if (titleSimilarity(focalTitle, m.title) >= 0.65) matches.push(m.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
self.postMessage(matches);
|
|
||||||
};
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
const THUMB_SIZE = 16;
|
|
||||||
const DUPE_THRESH = 0.12;
|
|
||||||
|
|
||||||
const hashCache = new Map<string, Uint8ClampedArray>();
|
|
||||||
|
|
||||||
function toGray(data: Uint8ClampedArray, pixels: number): Uint8ClampedArray {
|
|
||||||
const gray = new Uint8ClampedArray(pixels);
|
|
||||||
for (let i = 0; i < pixels; i++) {
|
|
||||||
const o = i * 4;
|
|
||||||
gray[i] = (data[o] * 299 + data[o + 1] * 587 + data[o + 2] * 114) / 1000;
|
|
||||||
}
|
|
||||||
return gray;
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadThumb(url: string): Promise<Uint8ClampedArray> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const img = new Image();
|
|
||||||
img.crossOrigin = "anonymous";
|
|
||||||
img.onload = () => {
|
|
||||||
const canvas = document.createElement("canvas");
|
|
||||||
canvas.width = canvas.height = THUMB_SIZE;
|
|
||||||
const ctx = canvas.getContext("2d")!;
|
|
||||||
ctx.drawImage(img, 0, 0, THUMB_SIZE, THUMB_SIZE);
|
|
||||||
resolve(toGray(ctx.getImageData(0, 0, THUMB_SIZE, THUMB_SIZE).data, THUMB_SIZE * THUMB_SIZE));
|
|
||||||
};
|
|
||||||
img.onerror = reject;
|
|
||||||
img.src = url;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function similarity(a: Uint8ClampedArray, b: Uint8ClampedArray): number {
|
|
||||||
let diff = 0;
|
|
||||||
for (let i = 0; i < a.length; i++) diff += Math.abs(a[i] - b[i]);
|
|
||||||
return diff / (a.length * 255);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getHash(url: string): Promise<Uint8ClampedArray | null> {
|
|
||||||
if (hashCache.has(url)) return hashCache.get(url)!;
|
|
||||||
try {
|
|
||||||
const thumb = await loadThumb(url);
|
|
||||||
hashCache.set(url, thumb);
|
|
||||||
return thumb;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function areDuplicates(a: Uint8ClampedArray, b: Uint8ClampedArray): boolean {
|
|
||||||
return similarity(a, b) <= DUPE_THRESH;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function clearHashCache(): void {
|
|
||||||
hashCache.clear();
|
|
||||||
}
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
import { store } from "@store/state.svelte";
|
|
||||||
import { searchWithScore } from "@core/algorithms/search";
|
|
||||||
import { getHash, areDuplicates } from "@core/cover/coverHash";
|
|
||||||
|
|
||||||
type CoverManga = { id: number; thumbnailUrl: string; source?: { displayName: string } | null };
|
|
||||||
|
|
||||||
export type CoverCandidate = {
|
|
||||||
mangaId: number;
|
|
||||||
url: string;
|
|
||||||
label: string;
|
|
||||||
isActive: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
const FUZZY_SCORE_THRESHOLD = 0.65;
|
|
||||||
|
|
||||||
function normalizeUrl(url: string): string {
|
|
||||||
try {
|
|
||||||
const u = new URL(url);
|
|
||||||
u.search = "";
|
|
||||||
return u.href.toLowerCase();
|
|
||||||
} catch {
|
|
||||||
return url.toLowerCase();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resolvedCover(mangaId: number, ownUrl: string): string {
|
|
||||||
return store.settings.mangaPrefs?.[mangaId]?.coverUrl ?? ownUrl;
|
|
||||||
}
|
|
||||||
|
|
||||||
function fuzzyMatchIds(
|
|
||||||
mangaId: number,
|
|
||||||
title: string,
|
|
||||||
mangaById: Map<number, CoverManga & { title: string }>,
|
|
||||||
): number[] {
|
|
||||||
const results = searchWithScore(
|
|
||||||
[...mangaById.values()].filter(m => m.id !== mangaId),
|
|
||||||
title,
|
|
||||||
m => m.title,
|
|
||||||
);
|
|
||||||
return results
|
|
||||||
.filter(r => r.score >= FUZZY_SCORE_THRESHOLD)
|
|
||||||
.map(r => r.item.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function coverCandidatesSync(
|
|
||||||
mangaId: number,
|
|
||||||
title: string,
|
|
||||||
ownUrl: string,
|
|
||||||
mangaById: Map<number, CoverManga & { title: string }>,
|
|
||||||
): CoverCandidate[] {
|
|
||||||
const linkedIds = store.getLinkedMangaIds(mangaId);
|
|
||||||
const fuzzyIds = fuzzyMatchIds(mangaId, title, mangaById);
|
|
||||||
const current = store.settings.mangaPrefs?.[mangaId]?.coverUrl ?? ownUrl;
|
|
||||||
|
|
||||||
const allIds = Array.from(new Set([...linkedIds, ...fuzzyIds]));
|
|
||||||
|
|
||||||
const raw: { mangaId: number; url: string; label: string }[] = [
|
|
||||||
{ mangaId, url: ownUrl, label: "This source" },
|
|
||||||
...allIds.flatMap(id => {
|
|
||||||
const m = mangaById.get(id);
|
|
||||||
return m ? [{ mangaId: m.id, url: m.thumbnailUrl, label: m.source?.displayName ?? `ID ${m.id}` }] : [];
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
|
|
||||||
const seen = new Set<string>();
|
|
||||||
return raw
|
|
||||||
.filter(c => {
|
|
||||||
const key = normalizeUrl(c.url);
|
|
||||||
if (seen.has(key)) return false;
|
|
||||||
seen.add(key);
|
|
||||||
return true;
|
|
||||||
})
|
|
||||||
.map(c => ({ ...c, isActive: normalizeUrl(c.url) === normalizeUrl(current) }));
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function dedupeByImage(candidates: CoverCandidate[]): Promise<CoverCandidate[]> {
|
|
||||||
const hashes = await Promise.all(candidates.map(c => getHash(c.url)));
|
|
||||||
|
|
||||||
const groups: number[][] = [];
|
|
||||||
|
|
||||||
for (let i = 0; i < candidates.length; i++) {
|
|
||||||
const hi = hashes[i];
|
|
||||||
const existing = hi
|
|
||||||
? groups.find(g => { const hj = hashes[g[0]]; return hj ? areDuplicates(hi, hj) : false; })
|
|
||||||
: undefined;
|
|
||||||
if (existing) existing.push(i);
|
|
||||||
else groups.push([i]);
|
|
||||||
}
|
|
||||||
|
|
||||||
return groups.map(group => {
|
|
||||||
const active = group.find(i => candidates[i].isActive) ?? group[0];
|
|
||||||
const labels = [...new Set(group.map(i => candidates[i].label))];
|
|
||||||
return { ...candidates[active], label: labels.join(" · ") };
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
export { getHash, areDuplicates, clearHashCache } from "./coverHash";
|
|
||||||
export { resolvedCover, coverCandidatesSync, dedupeByImage } from "./coverResolver";
|
|
||||||
export type { CoverCandidate } from "./coverResolver";
|
|
||||||
export { autoLinkLibrary } from "./autoLink";
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
export { eventToKeybind, matchesKeybind, toggleFullscreen } from "./keybindEngine";
|
|
||||||
export { DEFAULT_KEYBINDS, KEYBIND_LABELS } from "./defaultBinds";
|
|
||||||
export type { Keybinds } from "./defaultBinds";
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
const VAULT_KEY = "moku-credential-vault";
|
|
||||||
const SALT_ITERATIONS = 200_000;
|
|
||||||
const KEY_USAGE: KeyUsage[] = ["encrypt", "decrypt"];
|
|
||||||
|
|
||||||
export interface VaultPayload {
|
|
||||||
refreshToken?: string;
|
|
||||||
basicUser?: string;
|
|
||||||
basicPass?: string;
|
|
||||||
authMode: "UI_LOGIN" | "BASIC_AUTH" | "NONE";
|
|
||||||
}
|
|
||||||
|
|
||||||
interface StoredVault {
|
|
||||||
salt: string;
|
|
||||||
iv: string;
|
|
||||||
data: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function toB64(buf: ArrayBuffer): string {
|
|
||||||
return btoa(String.fromCharCode(...new Uint8Array(buf)));
|
|
||||||
}
|
|
||||||
|
|
||||||
function fromB64(s: string): Uint8Array {
|
|
||||||
return Uint8Array.from(atob(s), (c) => c.charCodeAt(0));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deriveKey(pin: string, salt: Uint8Array): Promise<CryptoKey> {
|
|
||||||
const enc = new TextEncoder();
|
|
||||||
const keyMat = await crypto.subtle.importKey("raw", enc.encode(pin), "PBKDF2", false, ["deriveKey"]);
|
|
||||||
return crypto.subtle.deriveKey(
|
|
||||||
{ name: "PBKDF2", salt, iterations: SALT_ITERATIONS, hash: "SHA-256" },
|
|
||||||
keyMat,
|
|
||||||
{ name: "AES-GCM", length: 256 },
|
|
||||||
false,
|
|
||||||
KEY_USAGE,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function vaultExists(): boolean {
|
|
||||||
return !!localStorage.getItem(VAULT_KEY);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function lockVault(pin: string, payload: VaultPayload): Promise<void> {
|
|
||||||
const salt = crypto.getRandomValues(new Uint8Array(16));
|
|
||||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
||||||
const key = await deriveKey(pin, salt);
|
|
||||||
|
|
||||||
const enc = new TextEncoder();
|
|
||||||
const cipher = await crypto.subtle.encrypt(
|
|
||||||
{ name: "AES-GCM", iv },
|
|
||||||
key,
|
|
||||||
enc.encode(JSON.stringify(payload)),
|
|
||||||
);
|
|
||||||
|
|
||||||
localStorage.setItem(VAULT_KEY, JSON.stringify({
|
|
||||||
salt: toB64(salt),
|
|
||||||
iv: toB64(iv),
|
|
||||||
data: toB64(cipher),
|
|
||||||
} satisfies StoredVault));
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function unlockVault(pin: string): Promise<VaultPayload | null> {
|
|
||||||
const raw = localStorage.getItem(VAULT_KEY);
|
|
||||||
if (!raw) return null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const stored = JSON.parse(raw) as StoredVault;
|
|
||||||
const key = await deriveKey(pin, fromB64(stored.salt));
|
|
||||||
const plain = await crypto.subtle.decrypt(
|
|
||||||
{ name: "AES-GCM", iv: fromB64(stored.iv) },
|
|
||||||
key,
|
|
||||||
fromB64(stored.data),
|
|
||||||
);
|
|
||||||
return JSON.parse(new TextDecoder().decode(plain)) as VaultPayload;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function clearVault(): void {
|
|
||||||
localStorage.removeItem(VAULT_KEY);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function rekeyVault(oldPin: string, newPin: string): Promise<boolean> {
|
|
||||||
const payload = await unlockVault(oldPin);
|
|
||||||
if (!payload) return false;
|
|
||||||
await lockVault(newPin, payload);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
export { loadAllStores, persistSettings, persistLibrary, persistUpdates } from "./persist";
|
|
||||||
export type { PersistedData } from "./persist";
|
|
||||||
|
|
||||||
export { vaultExists, lockVault, unlockVault, clearVault, rekeyVault } from "./credentialVault";
|
|
||||||
export type { VaultPayload } from "./credentialVault";
|
|
||||||
@@ -1,166 +0,0 @@
|
|||||||
import { LazyStore } from "@tauri-apps/plugin-store";
|
|
||||||
|
|
||||||
const settingsStore = new LazyStore("settings.json", { autoSave: false });
|
|
||||||
const libraryStore = new LazyStore("library.json", { autoSave: false });
|
|
||||||
const updatesStore = new LazyStore("updates.json", { autoSave: false });
|
|
||||||
const backupsStore = new LazyStore("backups.json", { autoSave: false });
|
|
||||||
|
|
||||||
export interface PersistedData {
|
|
||||||
settings: any;
|
|
||||||
storeVersion: number | null;
|
|
||||||
history: any[];
|
|
||||||
bookmarks: any[];
|
|
||||||
markers: any[];
|
|
||||||
readLog: any[];
|
|
||||||
readingStats: any | null;
|
|
||||||
dailyReadCounts: Record<string, number>;
|
|
||||||
libraryUpdates: any[];
|
|
||||||
lastLibraryRefresh: number;
|
|
||||||
acknowledgedUpdateIds: number[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function loadAllStores(): Promise<PersistedData> {
|
|
||||||
const migrated = await migrateFromLocalStorage();
|
|
||||||
if (migrated) return migrated;
|
|
||||||
|
|
||||||
const [sv, s, hist, bk, mk, rl, rs, dc, lu, llr, au] = await Promise.all([
|
|
||||||
settingsStore.get<number>("storeVersion"),
|
|
||||||
settingsStore.get<any>("settings"),
|
|
||||||
libraryStore.get<any[]>("history"),
|
|
||||||
libraryStore.get<any[]>("bookmarks"),
|
|
||||||
libraryStore.get<any[]>("markers"),
|
|
||||||
libraryStore.get<any[]>("readLog"),
|
|
||||||
libraryStore.get<any>("readingStats"),
|
|
||||||
libraryStore.get<Record<string, number>>("dailyReadCounts"),
|
|
||||||
updatesStore.get<any[]>("libraryUpdates"),
|
|
||||||
updatesStore.get<number>("lastLibraryRefresh"),
|
|
||||||
updatesStore.get<number[]>("acknowledgedUpdateIds"),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
storeVersion: sv ?? null,
|
|
||||||
settings: s ?? null,
|
|
||||||
history: hist ?? [],
|
|
||||||
bookmarks: bk ?? [],
|
|
||||||
markers: mk ?? [],
|
|
||||||
readLog: rl ?? [],
|
|
||||||
readingStats: rs ?? null,
|
|
||||||
dailyReadCounts: dc ?? {},
|
|
||||||
libraryUpdates: lu ?? [],
|
|
||||||
lastLibraryRefresh: llr ?? 0,
|
|
||||||
acknowledgedUpdateIds: au ?? [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function migrateFromLocalStorage(): Promise<PersistedData | null> {
|
|
||||||
try {
|
|
||||||
const raw = localStorage.getItem("moku-store");
|
|
||||||
if (!raw) return null;
|
|
||||||
const data = JSON.parse(raw);
|
|
||||||
|
|
||||||
await Promise.all([
|
|
||||||
persistSettings({ settings: data.settings ?? null, storeVersion: data.storeVersion ?? 1 }),
|
|
||||||
persistLibrary({
|
|
||||||
history: data.history ?? [],
|
|
||||||
bookmarks: data.bookmarks ?? [],
|
|
||||||
markers: data.markers ?? [],
|
|
||||||
readLog: data.readLog ?? [],
|
|
||||||
readingStats: data.readingStats ?? null,
|
|
||||||
dailyReadCounts: data.dailyReadCounts ?? {},
|
|
||||||
}),
|
|
||||||
persistUpdates({
|
|
||||||
libraryUpdates: data.libraryUpdates ?? [],
|
|
||||||
lastLibraryRefresh: data.lastLibraryRefresh ?? 0,
|
|
||||||
acknowledgedUpdateIds: data.acknowledgedUpdateIds ?? [],
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
localStorage.removeItem("moku-store");
|
|
||||||
|
|
||||||
return {
|
|
||||||
storeVersion: data.storeVersion ?? null,
|
|
||||||
settings: data.settings ?? null,
|
|
||||||
history: data.history ?? [],
|
|
||||||
bookmarks: data.bookmarks ?? [],
|
|
||||||
markers: data.markers ?? [],
|
|
||||||
readLog: data.readLog ?? [],
|
|
||||||
readingStats: data.readingStats ?? null,
|
|
||||||
dailyReadCounts: data.dailyReadCounts ?? {},
|
|
||||||
libraryUpdates: data.libraryUpdates ?? [],
|
|
||||||
lastLibraryRefresh: data.lastLibraryRefresh ?? 0,
|
|
||||||
acknowledgedUpdateIds: data.acknowledgedUpdateIds ?? [],
|
|
||||||
};
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function persistSettings(data: { settings: any; storeVersion: number }) {
|
|
||||||
await Promise.all([
|
|
||||||
settingsStore.set("settings", data.settings),
|
|
||||||
settingsStore.set("storeVersion", data.storeVersion),
|
|
||||||
]);
|
|
||||||
await settingsStore.save();
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function persistLibrary(data: {
|
|
||||||
history: any[];
|
|
||||||
bookmarks: any[];
|
|
||||||
markers: any[];
|
|
||||||
readLog: any[];
|
|
||||||
readingStats: any;
|
|
||||||
dailyReadCounts: Record<string, number>;
|
|
||||||
}) {
|
|
||||||
await Promise.all([
|
|
||||||
libraryStore.set("history", data.history),
|
|
||||||
libraryStore.set("bookmarks", data.bookmarks),
|
|
||||||
libraryStore.set("markers", data.markers),
|
|
||||||
libraryStore.set("readLog", data.readLog),
|
|
||||||
libraryStore.set("readingStats", data.readingStats),
|
|
||||||
libraryStore.set("dailyReadCounts", data.dailyReadCounts),
|
|
||||||
]);
|
|
||||||
await libraryStore.save();
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function persistUpdates(data: {
|
|
||||||
libraryUpdates: any[];
|
|
||||||
lastLibraryRefresh: number;
|
|
||||||
acknowledgedUpdateIds: number[];
|
|
||||||
}) {
|
|
||||||
await Promise.all([
|
|
||||||
updatesStore.set("libraryUpdates", data.libraryUpdates),
|
|
||||||
updatesStore.set("lastLibraryRefresh", data.lastLibraryRefresh),
|
|
||||||
updatesStore.set("acknowledgedUpdateIds", data.acknowledgedUpdateIds),
|
|
||||||
]);
|
|
||||||
await updatesStore.save();
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BackupEntry { url: string; name: string; }
|
|
||||||
|
|
||||||
export async function loadBackups(): Promise<BackupEntry[]> {
|
|
||||||
const fromStore = await backupsStore.get<BackupEntry[]>("backupList");
|
|
||||||
if (fromStore) return fromStore;
|
|
||||||
try {
|
|
||||||
const raw = localStorage.getItem("moku_backups");
|
|
||||||
if (!raw) return [];
|
|
||||||
const migrated: BackupEntry[] = JSON.parse(raw);
|
|
||||||
await persistBackups(migrated);
|
|
||||||
localStorage.removeItem("moku_backups");
|
|
||||||
return migrated;
|
|
||||||
} catch { return []; }
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function persistBackups(list: BackupEntry[]): Promise<void> {
|
|
||||||
await backupsStore.set("backupList", list);
|
|
||||||
await backupsStore.save();
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function resetAuthSettings(): Promise<void> {
|
|
||||||
const current = await settingsStore.get<any>("settings") ?? {};
|
|
||||||
current.serverAuthMode = "NONE";
|
|
||||||
current.serverAuthUser = "";
|
|
||||||
current.serverAuthPass = "";
|
|
||||||
await settingsStore.set("settings", current);
|
|
||||||
await settingsStore.save();
|
|
||||||
localStorage.removeItem("moku-credential-vault");
|
|
||||||
}
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
import { store, updateSettings } from "@store/state.svelte";
|
|
||||||
|
|
||||||
let themeStyleEl: HTMLStyleElement | null = null;
|
|
||||||
let mediaQuery: MediaQueryList | null = null;
|
|
||||||
let mediaHandler: (() => void) | null = null;
|
|
||||||
|
|
||||||
export function applyTheme() {
|
|
||||||
const themeId = store.settings.theme ?? "dark";
|
|
||||||
const isCustom = themeId.startsWith("custom:");
|
|
||||||
|
|
||||||
if (!isCustom) {
|
|
||||||
themeStyleEl?.remove();
|
|
||||||
themeStyleEl = null;
|
|
||||||
document.documentElement.setAttribute("data-theme", themeId);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const custom = store.settings.customThemes?.find(t => t.id === themeId);
|
|
||||||
if (!custom) {
|
|
||||||
themeStyleEl?.remove();
|
|
||||||
themeStyleEl = null;
|
|
||||||
document.documentElement.setAttribute("data-theme", "dark");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const vars = Object.entries(custom.tokens)
|
|
||||||
.map(([k, v]) => ` --${k}: ${v};`)
|
|
||||||
.join("\n");
|
|
||||||
const css = `[data-theme="custom"] {\n${vars}\n}`;
|
|
||||||
|
|
||||||
if (!themeStyleEl) {
|
|
||||||
themeStyleEl = document.createElement("style");
|
|
||||||
themeStyleEl.id = "moku-custom-theme";
|
|
||||||
document.head.appendChild(themeStyleEl);
|
|
||||||
}
|
|
||||||
themeStyleEl.textContent = css;
|
|
||||||
document.documentElement.setAttribute("data-theme", "custom");
|
|
||||||
}
|
|
||||||
|
|
||||||
function applySystemTheme(dark: boolean) {
|
|
||||||
const themeId = dark
|
|
||||||
? (store.settings.systemThemeDark ?? "dark")
|
|
||||||
: (store.settings.systemThemeLight ?? "light");
|
|
||||||
updateSettings({ theme: themeId });
|
|
||||||
}
|
|
||||||
|
|
||||||
export function mountSystemThemeSync() {
|
|
||||||
if (mediaQuery && mediaHandler) {
|
|
||||||
mediaQuery.removeEventListener("change", mediaHandler);
|
|
||||||
mediaHandler = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!store.settings.systemThemeSync) return;
|
|
||||||
|
|
||||||
mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
|
||||||
mediaHandler = () => applySystemTheme(mediaQuery!.matches);
|
|
||||||
mediaQuery.addEventListener("change", mediaHandler);
|
|
||||||
applySystemTheme(mediaQuery.matches);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function unmountSystemThemeSync() {
|
|
||||||
if (mediaQuery && mediaHandler) {
|
|
||||||
mediaQuery.removeEventListener("change", mediaHandler);
|
|
||||||
mediaHandler = null;
|
|
||||||
mediaQuery = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
import { store } from "@store/state.svelte";
|
|
||||||
|
|
||||||
const IDLE_EVENTS = ["mousemove", "mousedown", "keydown", "touchstart", "wheel"] as const;
|
|
||||||
|
|
||||||
export function mountIdleDetection(onIdle: () => void, onActive: () => void): () => void {
|
|
||||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
||||||
|
|
||||||
function reset() {
|
|
||||||
if (timer) clearTimeout(timer);
|
|
||||||
const ms = (store.settings.idleTimeoutMin ?? 5) * 60 * 1000;
|
|
||||||
if (ms === 0) return;
|
|
||||||
timer = setTimeout(onIdle, ms);
|
|
||||||
onActive();
|
|
||||||
}
|
|
||||||
|
|
||||||
IDLE_EVENTS.forEach(e => window.addEventListener(e, reset, { passive: true }));
|
|
||||||
reset();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (timer) clearTimeout(timer);
|
|
||||||
IDLE_EVENTS.forEach(e => window.removeEventListener(e, reset));
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
export * from './idle';
|
|
||||||
export * from './zoom';
|
|
||||||
export * from './touchscreen';
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
import { invoke } from "@tauri-apps/api/core";
|
|
||||||
import { getVersion } from "@tauri-apps/api/app";
|
|
||||||
import { addToast } from "@store/state.svelte";
|
|
||||||
|
|
||||||
function parse(tag: string): number[] {
|
|
||||||
return tag.replace(/^v/, "").split(".").map(Number);
|
|
||||||
}
|
|
||||||
|
|
||||||
function compare(a: number[], b: number[]): number {
|
|
||||||
for (let i = 0; i < 3; i++) {
|
|
||||||
if ((a[i] ?? 0) !== (b[i] ?? 0)) return (b[i] ?? 0) - (a[i] ?? 0);
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function checkForUpdateSilently(): Promise<void> {
|
|
||||||
try {
|
|
||||||
const [currentVersion, releases] = await Promise.all([
|
|
||||||
getVersion(),
|
|
||||||
invoke<Array<{ tag_name: string; html_url: string }>>("list_releases"),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const valid = releases.filter(r => typeof r.tag_name === "string" && r.tag_name.trim());
|
|
||||||
if (!valid.length) return;
|
|
||||||
|
|
||||||
const latestTag = valid
|
|
||||||
.map(r => r.tag_name)
|
|
||||||
.sort((a, b) => compare(parse(a), parse(b)))[0]
|
|
||||||
.replace(/^v/, "");
|
|
||||||
|
|
||||||
if (compare(parse(latestTag), parse(currentVersion)) < 0) {
|
|
||||||
addToast({
|
|
||||||
kind: "info",
|
|
||||||
title: `Update available — v${latestTag}`,
|
|
||||||
body: "Open Settings → About to install.",
|
|
||||||
duration: 8000,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
@keyframes fadeIn {
|
|
||||||
from { opacity: 0; }
|
|
||||||
to { opacity: 1; }
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes fadeUp {
|
|
||||||
from { opacity: 0; transform: translateY(5px); }
|
|
||||||
to { opacity: 1; transform: translateY(0); }
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes fadeDown {
|
|
||||||
from { opacity: 0; transform: translateY(-5px); }
|
|
||||||
to { opacity: 1; transform: translateY(0); }
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes scaleIn {
|
|
||||||
from { opacity: 0; transform: scale(0.97); }
|
|
||||||
to { opacity: 1; transform: scale(1); }
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes spin {
|
|
||||||
from { transform: rotate(0deg); }
|
|
||||||
to { transform: rotate(360deg); }
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes pulse {
|
|
||||||
0%, 100% { opacity: 1; }
|
|
||||||
50% { opacity: 0.35; }
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes shimmer {
|
|
||||||
from { background-position: -200% 0; }
|
|
||||||
to { background-position: 200% 0; }
|
|
||||||
}
|
|
||||||
|
|
||||||
.anim-fade-in { animation: fadeIn 0.14s ease both; }
|
|
||||||
.anim-fade-up { animation: fadeUp 0.18s ease both; }
|
|
||||||
.anim-fade-down { animation: fadeDown 0.18s ease both; }
|
|
||||||
.anim-scale-in { animation: scaleIn 0.14s ease both; }
|
|
||||||
.anim-pulse { animation: pulse 1.6s ease infinite; }
|
|
||||||
.anim-spin { animation: spin 0.7s linear infinite; }
|
|
||||||
|
|
||||||
.skeleton {
|
|
||||||
background: linear-gradient(90deg, var(--bg-raised) 25%, var(--bg-overlay) 50%, var(--bg-raised) 75%);
|
|
||||||
background-size: 200% 100%;
|
|
||||||
animation: shimmer 1.4s ease infinite;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
}
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
@import "./reset.css";
|
|
||||||
@import "./animations.css";
|
|
||||||
@import "./scrollbars.css";
|
|
||||||
@import "./typography.css";
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
*, *::before, *::after {
|
|
||||||
box-sizing: border-box;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
html, body {
|
|
||||||
height: 100%;
|
|
||||||
overflow: hidden;
|
|
||||||
background: var(--bg-void);
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
#app {
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
button {
|
|
||||||
cursor: pointer;
|
|
||||||
font: inherit;
|
|
||||||
color: inherit;
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
input, textarea, select {
|
|
||||||
font: inherit;
|
|
||||||
color: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
a {
|
|
||||||
color: inherit;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
ul, ol { list-style: none; }
|
|
||||||
|
|
||||||
img, svg { display: block; max-width: 100%; }
|
|
||||||
|
|
||||||
p { margin: 0; }
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
* {
|
|
||||||
scrollbar-width: thin;
|
|
||||||
scrollbar-color: transparent transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
*::-webkit-scrollbar { width: 4px; height: 4px; }
|
|
||||||
*::-webkit-scrollbar-track { background: transparent; }
|
|
||||||
*::-webkit-scrollbar-thumb { background: transparent; border-radius: 99px; }
|
|
||||||
*::-webkit-scrollbar-thumb:hover { background: transparent; }
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
body {
|
|
||||||
font-family: var(--font-sans);
|
|
||||||
font-size: var(--text-base);
|
|
||||||
font-weight: var(--weight-normal);
|
|
||||||
line-height: var(--leading-base);
|
|
||||||
-webkit-font-smoothing: antialiased;
|
|
||||||
-moz-osx-font-smoothing: grayscale;
|
|
||||||
text-rendering: optimizeLegibility;
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
[data-theme="dark"] {
|
|
||||||
--bg-void: #000000;
|
|
||||||
--bg-base: #080808;
|
|
||||||
--bg-surface: #0d0d0d;
|
|
||||||
--bg-raised: #111111;
|
|
||||||
--bg-overlay: #171717;
|
|
||||||
--bg-subtle: #1e1e1e;
|
|
||||||
|
|
||||||
--border-dim: #252525;
|
|
||||||
--border-base: #303030;
|
|
||||||
--border-strong: #3e3e3e;
|
|
||||||
--border-focus: #5a7a5a;
|
|
||||||
|
|
||||||
--text-primary: #ffffff;
|
|
||||||
--text-secondary: #e8e6e0;
|
|
||||||
--text-muted: #b0aea8;
|
|
||||||
--text-faint: #6e6c68;
|
|
||||||
--text-disabled: #303030;
|
|
||||||
|
|
||||||
--accent: #7aaa7a;
|
|
||||||
--accent-dim: #2e4a2e;
|
|
||||||
--accent-muted: #1e2e1e;
|
|
||||||
--accent-fg: #bcd8bc;
|
|
||||||
--accent-bright: #9fcf9f;
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
@import "./original.css";
|
|
||||||
@import "./dark.css";
|
|
||||||
@import "./light.css";
|
|
||||||
@import "./midnight.css";
|
|
||||||
@import "./warm.css";
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
[data-theme="light"] {
|
|
||||||
--bg-void: #d8d4ce;
|
|
||||||
--bg-base: #e2deda;
|
|
||||||
--bg-surface: #ece8e2;
|
|
||||||
--bg-raised: #f5f2ec;
|
|
||||||
--bg-overlay: #ffffff;
|
|
||||||
--bg-subtle: #e4e0d8;
|
|
||||||
|
|
||||||
--border-dim: #c4c0b8;
|
|
||||||
--border-base: #b0aca4;
|
|
||||||
--border-strong: #989490;
|
|
||||||
--border-focus: #3a5a3a;
|
|
||||||
|
|
||||||
--text-primary: #080806;
|
|
||||||
--text-secondary: #181612;
|
|
||||||
--text-muted: #38342e;
|
|
||||||
--text-faint: #706c64;
|
|
||||||
--text-disabled: #b0aca4;
|
|
||||||
|
|
||||||
--accent: #2a5a2a;
|
|
||||||
--accent-dim: #b0ccb0;
|
|
||||||
--accent-muted: #c8dcc8;
|
|
||||||
--accent-fg: #183818;
|
|
||||||
--accent-bright: #1e4e1e;
|
|
||||||
|
|
||||||
--color-error: #8a1a1a;
|
|
||||||
--color-error-bg: #f8e0e0;
|
|
||||||
--color-read: #e0dcd4;
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
[data-theme="midnight"] {
|
|
||||||
--bg-void: #050810;
|
|
||||||
--bg-base: #080c18;
|
|
||||||
--bg-surface: #0c1020;
|
|
||||||
--bg-raised: #101428;
|
|
||||||
--bg-overlay: #151a30;
|
|
||||||
--bg-subtle: #1a2038;
|
|
||||||
|
|
||||||
--border-dim: #1a2035;
|
|
||||||
--border-base: #222840;
|
|
||||||
--border-strong: #2c3450;
|
|
||||||
--border-focus: #4a5c8a;
|
|
||||||
|
|
||||||
--text-primary: #eeeef8;
|
|
||||||
--text-secondary: #c0c4d8;
|
|
||||||
--text-muted: #808498;
|
|
||||||
--text-faint: #404860;
|
|
||||||
--text-disabled: #202840;
|
|
||||||
|
|
||||||
--accent: #6a7ab8;
|
|
||||||
--accent-dim: #252d50;
|
|
||||||
--accent-muted: #181e38;
|
|
||||||
--accent-fg: #a8b4e8;
|
|
||||||
--accent-bright: #8896d0;
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
[data-theme="original"] {
|
|
||||||
--bg-void: #080808;
|
|
||||||
--bg-base: #0c0c0c;
|
|
||||||
--bg-surface: #101010;
|
|
||||||
--bg-raised: #151515;
|
|
||||||
--bg-overlay: #1a1a1a;
|
|
||||||
--bg-subtle: #202020;
|
|
||||||
|
|
||||||
--border-dim: #1c1c1c;
|
|
||||||
--border-base: #242424;
|
|
||||||
--border-strong: #2e2e2e;
|
|
||||||
--border-focus: #4a5c4a;
|
|
||||||
|
|
||||||
--text-primary: #f0efec;
|
|
||||||
--text-secondary: #c8c6c0;
|
|
||||||
--text-muted: #8a8880;
|
|
||||||
--text-faint: #4e4d4a;
|
|
||||||
--text-disabled: #2a2a28;
|
|
||||||
|
|
||||||
--accent: #6b8f6b;
|
|
||||||
--accent-dim: #2a3d2a;
|
|
||||||
--accent-muted: #1a251a;
|
|
||||||
--accent-fg: #a8c4a8;
|
|
||||||
--accent-bright: #8fb88f;
|
|
||||||
|
|
||||||
--color-error: #c47a7a;
|
|
||||||
--color-error-bg: #1f1212;
|
|
||||||
--color-success: #7aab7a;
|
|
||||||
--color-info: #7a9ec4;
|
|
||||||
--color-info-bg: #121a1f;
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
[data-theme="warm"] {
|
|
||||||
--bg-void: #0c0a06;
|
|
||||||
--bg-base: #100e08;
|
|
||||||
--bg-surface: #16130c;
|
|
||||||
--bg-raised: #1c1810;
|
|
||||||
--bg-overlay: #221e14;
|
|
||||||
--bg-subtle: #28241a;
|
|
||||||
|
|
||||||
--border-dim: #201c10;
|
|
||||||
--border-base: #2c2818;
|
|
||||||
--border-strong: #3a3420;
|
|
||||||
--border-focus: #6a5a30;
|
|
||||||
|
|
||||||
--text-primary: #f5f0e0;
|
|
||||||
--text-secondary: #d8d0b0;
|
|
||||||
--text-muted: #988c60;
|
|
||||||
--text-faint: #584e30;
|
|
||||||
--text-disabled: #302a18;
|
|
||||||
|
|
||||||
--accent: #c0902a;
|
|
||||||
--accent-dim: #3a2c10;
|
|
||||||
--accent-muted: #261e0c;
|
|
||||||
--accent-fg: #e0b860;
|
|
||||||
--accent-bright: #d0a040;
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
:root {
|
|
||||||
--bg-void: #080808;
|
|
||||||
--bg-base: #0c0c0c;
|
|
||||||
--bg-surface: #101010;
|
|
||||||
--bg-raised: #151515;
|
|
||||||
--bg-overlay: #1a1a1a;
|
|
||||||
--bg-subtle: #202020;
|
|
||||||
|
|
||||||
--border-dim: #1c1c1c;
|
|
||||||
--border-base: #242424;
|
|
||||||
--border-strong: #2e2e2e;
|
|
||||||
--border-focus: #4a5c4a;
|
|
||||||
|
|
||||||
--text-primary: #f0efec;
|
|
||||||
--text-secondary: #c8c6c0;
|
|
||||||
--text-muted: #8a8880;
|
|
||||||
--text-faint: #4e4d4a;
|
|
||||||
--text-disabled: #2a2a28;
|
|
||||||
|
|
||||||
--accent: #6b8f6b;
|
|
||||||
--accent-dim: #2a3d2a;
|
|
||||||
--accent-muted: #1a251a;
|
|
||||||
--accent-fg: #a8c4a8;
|
|
||||||
--accent-bright: #8fb88f;
|
|
||||||
|
|
||||||
--color-error: #c47a7a;
|
|
||||||
--color-error-bg: #1f1212;
|
|
||||||
--color-success: #7aab7a;
|
|
||||||
--color-info: #7a9ec4;
|
|
||||||
--color-info-bg: #121a1f;
|
|
||||||
--color-read: #2e2e2c;
|
|
||||||
|
|
||||||
--dot-active: var(--accent);
|
|
||||||
--dot-inactive: var(--text-faint);
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
@import "./colors.css";
|
|
||||||
@import "./typography.css";
|
|
||||||
@import "./spacing.css";
|
|
||||||
@import "./radius.css";
|
|
||||||
@import "./motion.css";
|
|
||||||
@import "./shadows.css";
|
|
||||||
@import "./zindex.css";
|
|
||||||
@import "../themes/index.css";
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
:root {
|
|
||||||
--t-fast: 0.08s ease;
|
|
||||||
--t-base: 0.14s ease;
|
|
||||||
--t-slow: 0.22s ease;
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
:root {
|
|
||||||
--radius-sm: 3px;
|
|
||||||
--radius-md: 5px;
|
|
||||||
--radius-lg: 7px;
|
|
||||||
--radius-xl: 10px;
|
|
||||||
--radius-2xl: 14px;
|
|
||||||
--radius-full: 9999px;
|
|
||||||
}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
:root {
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
:root {
|
|
||||||
--sp-1: 4px;
|
|
||||||
--sp-2: 8px;
|
|
||||||
--sp-3: 12px;
|
|
||||||
--sp-4: 16px;
|
|
||||||
--sp-5: 20px;
|
|
||||||
--sp-6: 24px;
|
|
||||||
--sp-8: 32px;
|
|
||||||
--sp-10: 40px;
|
|
||||||
|
|
||||||
--sidebar-width: 52px;
|
|
||||||
--titlebar-height: 36px;
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
:root {
|
|
||||||
--font-ui: "DM Mono", "Fira Mono", ui-monospace, monospace;
|
|
||||||
--font-sans: "DM Sans", ui-sans-serif, system-ui, sans-serif;
|
|
||||||
|
|
||||||
--text-2xs: 10px;
|
|
||||||
--text-xs: 11px;
|
|
||||||
--text-sm: 12px;
|
|
||||||
--text-base: 13px;
|
|
||||||
--text-md: 14px;
|
|
||||||
--text-lg: 15px;
|
|
||||||
--text-xl: 17px;
|
|
||||||
--text-2xl: 20px;
|
|
||||||
--text-3xl: 24px;
|
|
||||||
|
|
||||||
--weight-normal: 400;
|
|
||||||
--weight-medium: 500;
|
|
||||||
--weight-semi: 600;
|
|
||||||
|
|
||||||
--leading-none: 1;
|
|
||||||
--leading-tight: 1.3;
|
|
||||||
--leading-snug: 1.45;
|
|
||||||
--leading-base: 1.6;
|
|
||||||
|
|
||||||
--tracking-tight: -0.02em;
|
|
||||||
--tracking-normal: 0;
|
|
||||||
--tracking-wide: 0.06em;
|
|
||||||
--tracking-wider: 0.1em;
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
:root {
|
|
||||||
--z-reader: 50;
|
|
||||||
--z-modal: 100;
|
|
||||||
--z-settings: 150;
|
|
||||||
}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
export { default as Search } from "./components/Search.svelte";
|
|
||||||
export * from "./lib/searchFilter";
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { CircleNotch } from "phosphor-svelte";
|
|
||||||
import DownloadItem from "./DownloadItem.svelte";
|
|
||||||
import type { DownloadQueueItem } from "@types/index";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
queue: DownloadQueueItem[];
|
|
||||||
loading: boolean;
|
|
||||||
isRunning: boolean;
|
|
||||||
dequeueing: Set<number>;
|
|
||||||
selected: Set<number>;
|
|
||||||
onRemove: (chapterId: number) => void;
|
|
||||||
onRetry: (chapterId: number) => void;
|
|
||||||
onReorder: (chapterId: number, dir: "up" | "down") => void;
|
|
||||||
onReorderEdge: (chapterId: number, edge: "top" | "bottom") => void;
|
|
||||||
onSelect: (chapterId: number, e: MouseEvent) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const {
|
|
||||||
queue, loading, isRunning, dequeueing, selected,
|
|
||||||
onRemove, onRetry, onReorder, onReorderEdge, onSelect,
|
|
||||||
}: Props = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#if loading}
|
|
||||||
<div class="empty">
|
|
||||||
<CircleNotch size={16} weight="light" class="anim-spin" style="color:var(--text-faint)" />
|
|
||||||
</div>
|
|
||||||
{:else if queue.length === 0}
|
|
||||||
<div class="empty">Queue is empty.</div>
|
|
||||||
{:else}
|
|
||||||
<div class="list">
|
|
||||||
{#each queue as item, i (item.chapter.id)}
|
|
||||||
<DownloadItem
|
|
||||||
{item}
|
|
||||||
isActive={i === 0 && isRunning}
|
|
||||||
isRemoving={dequeueing.has(item.chapter.id)}
|
|
||||||
isSelected={selected.has(item.chapter.id)}
|
|
||||||
{onRemove}
|
|
||||||
{onRetry}
|
|
||||||
{onReorder}
|
|
||||||
{onReorderEdge}
|
|
||||||
{onSelect}
|
|
||||||
/>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.list { display: flex; flex-direction: column; gap: var(--sp-2); }
|
|
||||||
.empty { display: flex; align-items: center; justify-content: center; height: 160px; color: var(--text-faint); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); }
|
|
||||||
</style>
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
export { downloadStore } from "./store/downloadState.svelte";
|
|
||||||
export { toActiveDownloads, optimisticRemove, isRunning, pageProgress } from "./lib/downloadQueue";
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
import type { DownloadQueueItem, ActiveDownload } from "@types/index";
|
|
||||||
|
|
||||||
export function toActiveDownloads(queue: DownloadQueueItem[]): ActiveDownload[] {
|
|
||||||
return queue.map((item) => ({
|
|
||||||
chapterId: item.chapter.id,
|
|
||||||
mangaId: item.chapter.mangaId,
|
|
||||||
progress: item.progress,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function optimisticRemove(queue: DownloadQueueItem[], chapterId: number): DownloadQueueItem[] {
|
|
||||||
return queue.filter((i) => i.chapter.id !== chapterId);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function optimisticRemoveMany(queue: DownloadQueueItem[], chapterIds: Set<number>): DownloadQueueItem[] {
|
|
||||||
return queue.filter((i) => !chapterIds.has(i.chapter.id));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isRunning(state: string | undefined): boolean {
|
|
||||||
return state === "STARTED";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getErrored(queue: DownloadQueueItem[]): DownloadQueueItem[] {
|
|
||||||
return queue.filter((i) => i.state === "ERROR");
|
|
||||||
}
|
|
||||||
|
|
||||||
export function pageProgress(progress: number, pageCount: number): { done: number; total: number } {
|
|
||||||
return { done: Math.round(progress * pageCount), total: pageCount };
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SpeedSample {
|
|
||||||
ts: number;
|
|
||||||
progress: number;
|
|
||||||
pages: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function calcSpeed(prev: SpeedSample | null, current: SpeedSample): number | null {
|
|
||||||
if (!prev) return null;
|
|
||||||
const dt = (current.ts - prev.ts) / 1000;
|
|
||||||
if (dt <= 0) return null;
|
|
||||||
const prevDone = Math.round(prev.progress * prev.pages);
|
|
||||||
const curDone = Math.round(current.progress * current.pages);
|
|
||||||
const delta = curDone - prevDone;
|
|
||||||
if (delta <= 0) return null;
|
|
||||||
return delta / dt;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function estimateEta(pagesPerSec: number, queue: DownloadQueueItem[]): number | null {
|
|
||||||
if (pagesPerSec <= 0 || queue.length === 0) return null;
|
|
||||||
let remaining = 0;
|
|
||||||
for (const item of queue) {
|
|
||||||
const pages = item.chapter.pageCount ?? 0;
|
|
||||||
remaining += pages - Math.round(item.progress * pages);
|
|
||||||
}
|
|
||||||
return remaining / pagesPerSec;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function reorderSelectedToEdge(
|
|
||||||
queue: DownloadQueueItem[],
|
|
||||||
selected: Set<number>,
|
|
||||||
edge: "top" | "bottom",
|
|
||||||
): DownloadQueueItem[] {
|
|
||||||
const pinned = queue.filter((i) => selected.has(i.chapter.id));
|
|
||||||
const rest = queue.filter((i) => !selected.has(i.chapter.id));
|
|
||||||
return edge === "top" ? [...pinned, ...rest] : [...rest, ...pinned];
|
|
||||||
}
|
|
||||||
|
|
||||||
const AVG_BYTES_PER_PAGE = 1_500_000;
|
|
||||||
|
|
||||||
export function estimateQueueBytes(queue: DownloadQueueItem[]): number {
|
|
||||||
let total = 0;
|
|
||||||
for (const item of queue) {
|
|
||||||
const pages = item.chapter.pageCount ?? 0;
|
|
||||||
const remaining = pages - Math.round(item.progress * pages);
|
|
||||||
total += remaining * AVG_BYTES_PER_PAGE;
|
|
||||||
}
|
|
||||||
return total;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatEta(seconds: number): string {
|
|
||||||
if (seconds < 60) return `~${Math.ceil(seconds)}s`;
|
|
||||||
if (seconds < 3600) return `~${Math.ceil(seconds / 60)}m`;
|
|
||||||
return `~${(seconds / 3600).toFixed(1)}h`;
|
|
||||||
}
|
|
||||||
@@ -1,407 +0,0 @@
|
|||||||
import { gql } from "@api/client";
|
|
||||||
import { GET_DOWNLOAD_STATUS } from "@api/queries";
|
|
||||||
import {
|
|
||||||
START_DOWNLOADER, STOP_DOWNLOADER, CLEAR_DOWNLOADER,
|
|
||||||
DEQUEUE_DOWNLOAD, DEQUEUE_CHAPTERS_DOWNLOAD,
|
|
||||||
ENQUEUE_DOWNLOAD, REORDER_DOWNLOAD,
|
|
||||||
} from "@api/mutations";
|
|
||||||
import { addToast, setActiveDownloads, store, updateSettings } from "@store/state.svelte";
|
|
||||||
import { boot } from "@store/boot.svelte";
|
|
||||||
import type { DownloadStatus, DownloadQueueItem } from "@types/index";
|
|
||||||
import {
|
|
||||||
toActiveDownloads, optimisticRemove, optimisticRemoveMany,
|
|
||||||
isRunning, getErrored, calcSpeed, estimateEta, estimateQueueBytes,
|
|
||||||
type SpeedSample,
|
|
||||||
} from "../lib/downloadQueue";
|
|
||||||
import { startAutoRetry, type AutoRetryHandle } from "../lib/autoRetry";
|
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
|
||||||
|
|
||||||
class DownloadStore {
|
|
||||||
status: DownloadStatus | null = $state(null);
|
|
||||||
loading = $state(true);
|
|
||||||
togglingPlay = $state(false);
|
|
||||||
clearing = $state(false);
|
|
||||||
dequeueing = $state(new Set<number>());
|
|
||||||
selected = $state(new Set<number>());
|
|
||||||
batchWorking = $state(false);
|
|
||||||
pagesPerSec: number | null = $state(null);
|
|
||||||
eta: number | null = $state(null);
|
|
||||||
storageWarning: boolean = $state(false);
|
|
||||||
|
|
||||||
private freeBytes: number | null = null;
|
|
||||||
|
|
||||||
get toastsEnabled() { return store.settings.downloadToastsEnabled ?? true; }
|
|
||||||
get autoRetryEnabled() { return store.settings.downloadAutoRetry ?? false; }
|
|
||||||
|
|
||||||
private lastSample: SpeedSample | null = null;
|
|
||||||
private prevQueue: DownloadQueueItem[] = [];
|
|
||||||
private autoRetryHnd: AutoRetryHandle | null = null;
|
|
||||||
|
|
||||||
get queue() { return this.status?.queue ?? []; }
|
|
||||||
get isRunning() { return isRunning(this.status?.state); }
|
|
||||||
get erroredIds() { return new Set(getErrored(this.queue).map((i) => i.chapter.id)); }
|
|
||||||
get hasErrored() { return this.erroredIds.size > 0; }
|
|
||||||
|
|
||||||
toggleToasts() {
|
|
||||||
const next = !this.toastsEnabled;
|
|
||||||
updateSettings({ downloadToastsEnabled: next });
|
|
||||||
addToast({ kind: "info", title: next ? "Notifications enabled" : "Notifications muted", body: next ? "You'll be notified when chapters finish downloading" : "Download notifications are silenced", duration: 2500 });
|
|
||||||
}
|
|
||||||
|
|
||||||
toggleAutoRetry() {
|
|
||||||
if (this.autoRetryEnabled) {
|
|
||||||
this.autoRetryHnd?.stop();
|
|
||||||
this.autoRetryHnd = null;
|
|
||||||
updateSettings({ downloadAutoRetry: false });
|
|
||||||
addToast({ kind: "info", title: "Auto-retry disabled", body: "Failed downloads will no longer retry automatically", duration: 2500 });
|
|
||||||
} else {
|
|
||||||
updateSettings({ downloadAutoRetry: true });
|
|
||||||
this.autoRetryHnd = startAutoRetry(
|
|
||||||
() => this.queue,
|
|
||||||
() => this.isRunning,
|
|
||||||
() => this.retryAllErrored(),
|
|
||||||
);
|
|
||||||
addToast({ kind: "info", title: "Auto-retry enabled", body: "Errored downloads will retry automatically", duration: 3000 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
detectTransitions(next: DownloadQueueItem[]) {
|
|
||||||
if (!this.toastsEnabled) return;
|
|
||||||
const nextMap = new Map(next.map(i => [i.chapter.id, i]));
|
|
||||||
for (const item of this.prevQueue) {
|
|
||||||
if (item.state !== "DOWNLOADING") continue;
|
|
||||||
const nextItem = nextMap.get(item.chapter.id);
|
|
||||||
const manga = item.chapter.manga;
|
|
||||||
const label = manga ? `${manga.title} — ${item.chapter.name}` : item.chapter.name;
|
|
||||||
if (!nextItem) {
|
|
||||||
addToast({ kind: "download", title: "Chapter downloaded", body: label, duration: 4000 });
|
|
||||||
} else if (nextItem.state === "ERROR") {
|
|
||||||
addToast({ kind: "error", title: "Download failed", body: label, duration: 5000 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.prevQueue = next.slice();
|
|
||||||
}
|
|
||||||
|
|
||||||
applyStatus(ds: DownloadStatus) {
|
|
||||||
this.status = ds;
|
|
||||||
setActiveDownloads(toActiveDownloads(ds.queue));
|
|
||||||
this.updateSpeed(ds);
|
|
||||||
this.fetchFreeBytes(ds);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async fetchFreeBytes(ds: DownloadStatus) {
|
|
||||||
const path = store.settings.serverDownloadsPath ?? "";
|
|
||||||
if (!path) return;
|
|
||||||
try {
|
|
||||||
const info = await invoke<{ free_bytes: number }>("get_storage_info", { downloadsPath: path });
|
|
||||||
this.freeBytes = info.free_bytes;
|
|
||||||
this.storageWarning = estimateQueueBytes(ds.queue) > info.free_bytes * 0.95;
|
|
||||||
} catch { }
|
|
||||||
}
|
|
||||||
|
|
||||||
private confirmStorageOverrun(): Promise<boolean> {
|
|
||||||
return new Promise(resolve => {
|
|
||||||
const backdrop = document.createElement("div");
|
|
||||||
backdrop.style.cssText = "position:fixed;inset:0;z-index:10000;background:rgba(0,0,0,0.5);display:flex;align-items:center;justify-content:center;animation:s-fade-in 0.15s ease both";
|
|
||||||
const panel = document.createElement("div");
|
|
||||||
panel.style.cssText = "background:var(--bg-surface);border:1px solid var(--border-base);border-radius:var(--radius-2xl);box-shadow:0 24px 80px rgba(0,0,0,0.7),0 0 0 1px rgba(255,255,255,0.04) inset;width:min(380px,calc(100vw - 40px));overflow:hidden;animation:s-scale-in 0.2s cubic-bezier(0.16,1,0.3,1) both";
|
|
||||||
panel.innerHTML = `
|
|
||||||
<div style="padding:var(--sp-4) var(--sp-5) var(--sp-3);border-bottom:1px solid var(--border-dim)">
|
|
||||||
<p style="margin:0;font-size:var(--text-sm);font-weight:var(--weight-medium);color:var(--text-primary);letter-spacing:0.01em">Low disk space</p>
|
|
||||||
</div>
|
|
||||||
<div style="padding:var(--sp-4) var(--sp-5);display:flex;flex-direction:column;gap:var(--sp-2)">
|
|
||||||
<p style="margin:0;font-family:var(--font-ui);font-size:var(--text-xs);color:var(--text-muted);letter-spacing:var(--tracking-wide);line-height:var(--leading-snug)">
|
|
||||||
The download queue is estimated to exceed 95% of your available storage. Download anyway?
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div style="padding:var(--sp-3) var(--sp-5);border-top:1px solid var(--border-dim);display:flex;justify-content:flex-end;gap:var(--sp-2)">
|
|
||||||
<button id="_moku-storage-cancel" style="font-family:var(--font-ui);font-size:var(--text-xs);letter-spacing:var(--tracking-wide);padding:5px var(--sp-3);border-radius:var(--radius-sm);border:1px solid var(--border-dim);background:none;color:var(--text-muted);cursor:pointer">Cancel</button>
|
|
||||||
<button id="_moku-storage-confirm" style="font-family:var(--font-ui);font-size:var(--text-xs);letter-spacing:var(--tracking-wide);padding:5px var(--sp-3);border-radius:var(--radius-sm);border:1px solid color-mix(in srgb,var(--color-error) 40%,transparent);background:color-mix(in srgb,var(--color-error) 10%,transparent);color:var(--color-error);cursor:pointer">Download anyway</button>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
backdrop.appendChild(panel);
|
|
||||||
document.body.appendChild(backdrop);
|
|
||||||
function finish(result: boolean) { backdrop.remove(); resolve(result); }
|
|
||||||
panel.querySelector("#_moku-storage-cancel")!.addEventListener("click", () => finish(false));
|
|
||||||
panel.querySelector("#_moku-storage-confirm")!.addEventListener("click", () => finish(true));
|
|
||||||
backdrop.addEventListener("click", (e) => { if (e.target === backdrop) finish(false); });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private async guardStorage(queueAfter: DownloadQueueItem[]): Promise<boolean> {
|
|
||||||
if (this.freeBytes === null) return true;
|
|
||||||
if (estimateQueueBytes(queueAfter) <= this.freeBytes * 0.95) return true;
|
|
||||||
return this.confirmStorageOverrun();
|
|
||||||
}
|
|
||||||
|
|
||||||
private updateSpeed(ds: DownloadStatus) {
|
|
||||||
const active = ds.queue[0];
|
|
||||||
if (!active || active.state !== "DOWNLOADING") {
|
|
||||||
this.lastSample = null;
|
|
||||||
this.pagesPerSec = null;
|
|
||||||
this.eta = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const sample: SpeedSample = {
|
|
||||||
ts: Date.now(),
|
|
||||||
progress: active.progress,
|
|
||||||
pages: active.chapter.pageCount ?? 0,
|
|
||||||
};
|
|
||||||
const speed = calcSpeed(this.lastSample, sample);
|
|
||||||
this.lastSample = sample;
|
|
||||||
if (speed !== null) {
|
|
||||||
this.pagesPerSec = speed;
|
|
||||||
this.eta = estimateEta(speed, ds.queue);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async poll() {
|
|
||||||
if (boot.sessionExpired) return;
|
|
||||||
gql<{ downloadStatus: DownloadStatus }>(GET_DOWNLOAD_STATUS)
|
|
||||||
.then((d) => this.applyStatus(d.downloadStatus))
|
|
||||||
.catch(console.error)
|
|
||||||
.finally(() => { this.loading = false; });
|
|
||||||
}
|
|
||||||
|
|
||||||
async togglePlay() {
|
|
||||||
if (this.togglingPlay) return;
|
|
||||||
this.togglingPlay = true;
|
|
||||||
const wasRunning = this.isRunning;
|
|
||||||
if (this.status) this.status = { ...this.status, state: wasRunning ? "STOPPED" : "STARTED" };
|
|
||||||
try {
|
|
||||||
if (wasRunning) {
|
|
||||||
const d = await gql<{ stopDownloader: { downloadStatus: DownloadStatus } }>(STOP_DOWNLOADER);
|
|
||||||
this.applyStatus(d.stopDownloader.downloadStatus);
|
|
||||||
} else {
|
|
||||||
const d = await gql<{ startDownloader: { downloadStatus: DownloadStatus } }>(START_DOWNLOADER);
|
|
||||||
this.applyStatus(d.startDownloader.downloadStatus);
|
|
||||||
}
|
|
||||||
} catch (e) { console.error(e); this.poll(); }
|
|
||||||
finally {
|
|
||||||
this.togglingPlay = false;
|
|
||||||
addToast({ kind: "info", title: wasRunning ? "Downloads paused" : "Downloads resumed", body: wasRunning ? "The download queue has been paused" : "The download queue is running", duration: 2500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async clear() {
|
|
||||||
if (this.clearing) return;
|
|
||||||
this.clearing = true;
|
|
||||||
this.selected = new Set();
|
|
||||||
if (this.status) this.status = { ...this.status, queue: [] };
|
|
||||||
setActiveDownloads([]);
|
|
||||||
try {
|
|
||||||
const d = await gql<{ clearDownloader: { downloadStatus: DownloadStatus } }>(CLEAR_DOWNLOADER);
|
|
||||||
this.applyStatus(d.clearDownloader.downloadStatus);
|
|
||||||
addToast({ kind: "info", title: "Queue cleared", body: "All pending downloads have been removed", duration: 2500 });
|
|
||||||
} catch (e) { console.error(e); this.poll(); }
|
|
||||||
finally { this.clearing = false; }
|
|
||||||
}
|
|
||||||
|
|
||||||
async dequeue(chapterId: number) {
|
|
||||||
if (this.dequeueing.has(chapterId)) return;
|
|
||||||
this.dequeueing = new Set(this.dequeueing).add(chapterId);
|
|
||||||
if (this.status) this.status = { ...this.status, queue: optimisticRemove(this.status.queue, chapterId) };
|
|
||||||
this.selected.delete(chapterId);
|
|
||||||
this.selected = new Set(this.selected);
|
|
||||||
try { await gql(DEQUEUE_DOWNLOAD, { chapterId }); this.poll(); }
|
|
||||||
catch (e) { console.error(e); this.poll(); }
|
|
||||||
finally { this.dequeueing.delete(chapterId); this.dequeueing = new Set(this.dequeueing); }
|
|
||||||
}
|
|
||||||
|
|
||||||
async dequeueSelected() {
|
|
||||||
if (this.batchWorking || this.selected.size === 0) return;
|
|
||||||
this.batchWorking = true;
|
|
||||||
const ids = [...this.selected];
|
|
||||||
if (this.status) this.status = { ...this.status, queue: optimisticRemoveMany(this.status.queue, this.selected) };
|
|
||||||
this.selected = new Set();
|
|
||||||
try {
|
|
||||||
await gql(DEQUEUE_CHAPTERS_DOWNLOAD, { chapterIds: ids });
|
|
||||||
this.poll();
|
|
||||||
addToast({ kind: "info", title: `Removed ${ids.length} download${ids.length !== 1 ? "s" : ""}`, body: "Selected items have been removed from the queue", duration: 2500 });
|
|
||||||
} catch (e) { console.error(e); this.poll(); }
|
|
||||||
finally { this.batchWorking = false; }
|
|
||||||
}
|
|
||||||
|
|
||||||
async enqueue(chapterId: number): Promise<boolean> {
|
|
||||||
const projected = [...this.queue, { chapter: { id: chapterId, pageCount: 0 }, progress: 0, state: "QUEUED" } as any];
|
|
||||||
if (!(await this.guardStorage(projected))) return false;
|
|
||||||
try { await gql(ENQUEUE_DOWNLOAD, { chapterId }); this.poll(); }
|
|
||||||
catch (e) { console.error(e); }
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
async retryOne(chapterId: number) {
|
|
||||||
if (this.dequeueing.has(chapterId)) return;
|
|
||||||
this.dequeueing = new Set(this.dequeueing).add(chapterId);
|
|
||||||
try {
|
|
||||||
await gql(DEQUEUE_DOWNLOAD, { chapterId });
|
|
||||||
const projected = this.queue.filter(i => i.chapter.id !== chapterId);
|
|
||||||
if (!(await this.guardStorage(projected))) { this.poll(); return; }
|
|
||||||
await gql(ENQUEUE_DOWNLOAD, { chapterId });
|
|
||||||
this.poll();
|
|
||||||
} catch (e) { console.error(e); this.poll(); }
|
|
||||||
finally { this.dequeueing.delete(chapterId); this.dequeueing = new Set(this.dequeueing); }
|
|
||||||
}
|
|
||||||
|
|
||||||
async retryAllErrored() {
|
|
||||||
if (this.batchWorking || !this.hasErrored) return;
|
|
||||||
this.batchWorking = true;
|
|
||||||
const ids = [...this.erroredIds];
|
|
||||||
try {
|
|
||||||
await gql(DEQUEUE_CHAPTERS_DOWNLOAD, { chapterIds: ids });
|
|
||||||
const projected = this.queue.filter(i => !this.erroredIds.has(i.chapter.id));
|
|
||||||
if (!(await this.guardStorage(projected))) { this.poll(); return; }
|
|
||||||
for (const id of ids) await gql(ENQUEUE_DOWNLOAD, { chapterId: id });
|
|
||||||
this.poll();
|
|
||||||
addToast({ kind: "info", title: `Retrying ${ids.length} failed download${ids.length !== 1 ? "s" : ""}`, duration: 3000 });
|
|
||||||
} catch (e) { console.error(e); this.poll(); }
|
|
||||||
finally { this.batchWorking = false; }
|
|
||||||
}
|
|
||||||
|
|
||||||
async retrySelected() {
|
|
||||||
if (this.batchWorking || this.selected.size === 0) return;
|
|
||||||
this.batchWorking = true;
|
|
||||||
const ids = [...this.selected].filter((id) => this.erroredIds.has(id));
|
|
||||||
this.selected = new Set();
|
|
||||||
try {
|
|
||||||
if (ids.length > 0) {
|
|
||||||
await gql(DEQUEUE_CHAPTERS_DOWNLOAD, { chapterIds: ids });
|
|
||||||
const projected = this.queue.filter(i => !new Set(ids).has(i.chapter.id));
|
|
||||||
if (!(await this.guardStorage(projected))) { this.poll(); return; }
|
|
||||||
for (const id of ids) await gql(ENQUEUE_DOWNLOAD, { chapterId: id });
|
|
||||||
addToast({ kind: "info", title: `Retrying ${ids.length} failed download${ids.length !== 1 ? "s" : ""}`, duration: 3000 });
|
|
||||||
}
|
|
||||||
this.poll();
|
|
||||||
} catch (e) { console.error(e); this.poll(); }
|
|
||||||
finally { this.batchWorking = false; }
|
|
||||||
}
|
|
||||||
|
|
||||||
async reorder(chapterId: number, direction: "up" | "down") {
|
|
||||||
const idx = this.queue.findIndex((i) => i.chapter.id === chapterId);
|
|
||||||
if (idx === -1) return;
|
|
||||||
const to = direction === "up" ? idx - 1 : idx + 1;
|
|
||||||
if (to < 0 || to >= this.queue.length) return;
|
|
||||||
const newQueue = [...this.queue];
|
|
||||||
[newQueue[idx], newQueue[to]] = [newQueue[to], newQueue[idx]];
|
|
||||||
if (this.status) this.status = { ...this.status, queue: newQueue };
|
|
||||||
try {
|
|
||||||
const d = await gql<{ reorderChapterDownload: { downloadStatus: DownloadStatus } }>(
|
|
||||||
REORDER_DOWNLOAD, { chapterId, to },
|
|
||||||
);
|
|
||||||
this.applyStatus(d.reorderChapterDownload.downloadStatus);
|
|
||||||
} catch (e) { console.error(e); this.poll(); }
|
|
||||||
}
|
|
||||||
|
|
||||||
async reorderSelected(direction: "up" | "down") {
|
|
||||||
if (this.batchWorking || this.selected.size === 0) return;
|
|
||||||
this.batchWorking = true;
|
|
||||||
|
|
||||||
const queue = [...this.queue];
|
|
||||||
const selectedIndices = queue
|
|
||||||
.map((item, i) => ({ id: item.chapter.id, i }))
|
|
||||||
.filter(({ id }) => this.selected.has(id))
|
|
||||||
.map(({ i }) => i)
|
|
||||||
.sort((a, b) => direction === "up" ? a - b : b - a);
|
|
||||||
|
|
||||||
if (direction === "up" && selectedIndices[0] === 0) { this.batchWorking = false; return; }
|
|
||||||
if (direction === "down" && selectedIndices[0] === queue.length - 1) { this.batchWorking = false; return; }
|
|
||||||
|
|
||||||
const newQueue = [...queue];
|
|
||||||
for (const idx of selectedIndices) {
|
|
||||||
const to = direction === "up" ? idx - 1 : idx + 1;
|
|
||||||
if (to < 0 || to >= newQueue.length) break;
|
|
||||||
[newQueue[idx], newQueue[to]] = [newQueue[to], newQueue[idx]];
|
|
||||||
}
|
|
||||||
if (this.status) this.status = { ...this.status, queue: newQueue };
|
|
||||||
|
|
||||||
try {
|
|
||||||
for (const idx of selectedIndices) {
|
|
||||||
const to = direction === "up" ? idx - 1 : idx + 1;
|
|
||||||
if (to < 0 || to >= queue.length) break;
|
|
||||||
const chapterId = queue[idx].chapter.id;
|
|
||||||
await gql<{ reorderChapterDownload: { downloadStatus: DownloadStatus } }>(
|
|
||||||
REORDER_DOWNLOAD, { chapterId, to },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
this.poll();
|
|
||||||
} catch (e) { console.error(e); this.poll(); }
|
|
||||||
finally { this.batchWorking = false; }
|
|
||||||
}
|
|
||||||
|
|
||||||
async reorderToEdge(chapterId: number, edge: "top" | "bottom") {
|
|
||||||
const idx = this.queue.findIndex((i) => i.chapter.id === chapterId);
|
|
||||||
if (idx === -1) return;
|
|
||||||
const first = this.isRunning ? 1 : 0;
|
|
||||||
const last = this.queue.length - 1;
|
|
||||||
const to = edge === "top" ? first : last;
|
|
||||||
if (idx === to) return;
|
|
||||||
const newQueue = [...this.queue];
|
|
||||||
newQueue.splice(idx, 1);
|
|
||||||
newQueue.splice(to, 0, this.queue[idx]);
|
|
||||||
if (this.status) this.status = { ...this.status, queue: newQueue };
|
|
||||||
try {
|
|
||||||
const d = await gql<{ reorderChapterDownload: { downloadStatus: DownloadStatus } }>(
|
|
||||||
REORDER_DOWNLOAD, { chapterId, to },
|
|
||||||
);
|
|
||||||
this.applyStatus(d.reorderChapterDownload.downloadStatus);
|
|
||||||
} catch (e) { console.error(e); this.poll(); }
|
|
||||||
}
|
|
||||||
|
|
||||||
async reorderSelectedToEdge(edge: "top" | "bottom") {
|
|
||||||
if (this.batchWorking || this.selected.size === 0) return;
|
|
||||||
this.batchWorking = true;
|
|
||||||
|
|
||||||
const first = this.isRunning ? 1 : 0;
|
|
||||||
const active = this.queue.slice(0, first);
|
|
||||||
const moveable = this.queue.slice(first);
|
|
||||||
const pinned = moveable.filter((i) => this.selected.has(i.chapter.id));
|
|
||||||
const rest = moveable.filter((i) => !this.selected.has(i.chapter.id));
|
|
||||||
const newQueue = edge === "top"
|
|
||||||
? [...active, ...pinned, ...rest]
|
|
||||||
: [...active, ...rest, ...pinned];
|
|
||||||
if (this.status) this.status = { ...this.status, queue: newQueue };
|
|
||||||
|
|
||||||
const last = this.queue.length - 1;
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (edge === "top") {
|
|
||||||
for (let i = 0; i < pinned.length; i++) {
|
|
||||||
await gql<{ reorderChapterDownload: { downloadStatus: DownloadStatus } }>(
|
|
||||||
REORDER_DOWNLOAD, { chapterId: pinned[i].chapter.id, to: first + i },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
for (let i = 0; i < pinned.length; i++) {
|
|
||||||
await gql<{ reorderChapterDownload: { downloadStatus: DownloadStatus } }>(
|
|
||||||
REORDER_DOWNLOAD, { chapterId: pinned[i].chapter.id, to: last - (pinned.length - 1 - i) },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.poll();
|
|
||||||
} catch (e) { console.error(e); this.poll(); }
|
|
||||||
finally { this.batchWorking = false; }
|
|
||||||
}
|
|
||||||
|
|
||||||
selectOnly(chapterId: number) { this.selected = new Set([chapterId]); }
|
|
||||||
toggleSelect(chapterId: number) {
|
|
||||||
const next = new Set(this.selected);
|
|
||||||
if (next.has(chapterId)) next.delete(chapterId);
|
|
||||||
else next.add(chapterId);
|
|
||||||
this.selected = next;
|
|
||||||
}
|
|
||||||
|
|
||||||
selectRange(fromId: number, toId: number) {
|
|
||||||
const ids = this.queue.map((i) => i.chapter.id);
|
|
||||||
const a = ids.indexOf(fromId), b = ids.indexOf(toId);
|
|
||||||
if (a === -1 || b === -1) return;
|
|
||||||
const [lo, hi] = a < b ? [a, b] : [b, a];
|
|
||||||
const next = new Set(this.selected);
|
|
||||||
for (let i = lo; i <= hi; i++) next.add(ids[i]);
|
|
||||||
this.selected = next;
|
|
||||||
}
|
|
||||||
|
|
||||||
selectAll() { this.selected = new Set(this.queue.map((i) => i.chapter.id)); }
|
|
||||||
clearSelection() { this.selected = new Set(); }
|
|
||||||
}
|
|
||||||
|
|
||||||
export const downloadStore = new DownloadStore();
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
export { default as Extensions } from "./components/Extensions.svelte";
|
|
||||||
export * from "./lib/extensionHelpers";
|
|
||||||
@@ -1,526 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { X, CircleNotch, CaretUpDown, Check, CaretLeft } from "phosphor-svelte";
|
|
||||||
import Thumbnail from "@shared/manga/Thumbnail.svelte";
|
|
||||||
import { gql } from "@api/client";
|
|
||||||
import { addToast } from "@store/state.svelte";
|
|
||||||
import { GET_SOURCE_SETTINGS } from "@api/queries";
|
|
||||||
import { UPDATE_SOURCE_PREFERENCE } from "@api/mutations";
|
|
||||||
|
|
||||||
interface Preference {
|
|
||||||
type: string;
|
|
||||||
key: string;
|
|
||||||
CheckBoxTitle?: string;
|
|
||||||
CheckBoxSummary?: string;
|
|
||||||
CheckBoxDefault?: boolean;
|
|
||||||
CheckBoxCurrentValue?: boolean;
|
|
||||||
SwitchPreferenceTitle?: string;
|
|
||||||
SwitchPreferenceSummary?: string;
|
|
||||||
SwitchPreferenceDefault?: boolean;
|
|
||||||
SwitchPreferenceCurrentValue?: boolean;
|
|
||||||
ListPreferenceTitle?: string;
|
|
||||||
ListPreferenceSummary?: string;
|
|
||||||
ListPreferenceDefault?: string;
|
|
||||||
ListPreferenceCurrentValue?: string;
|
|
||||||
entries?: string[];
|
|
||||||
entryValues?: string[];
|
|
||||||
EditTextPreferenceTitle?: string;
|
|
||||||
EditTextPreferenceSummary?: string;
|
|
||||||
EditTextPreferenceDefault?: string;
|
|
||||||
EditTextPreferenceCurrentValue?: string;
|
|
||||||
dialogTitle?: string;
|
|
||||||
dialogMessage?: string;
|
|
||||||
MultiSelectListPreferenceTitle?: string;
|
|
||||||
MultiSelectListPreferenceSummary?: string;
|
|
||||||
MultiSelectListPreferenceDefault?: string[];
|
|
||||||
MultiSelectListPreferenceCurrentValue?: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export type SourceEntry = { id: string; displayName: string };
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
extensionName: string;
|
|
||||||
iconUrl: string;
|
|
||||||
sources: SourceEntry[];
|
|
||||||
onClose: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { extensionName, iconUrl, sources, onClose }: Props = $props();
|
|
||||||
|
|
||||||
let phase = $state<"pick" | "settings">("pick");
|
|
||||||
let activeSource = $state<SourceEntry | null>(null);
|
|
||||||
let prefs = $state<Preference[]>([]);
|
|
||||||
let loading = $state(false);
|
|
||||||
let saving = $state<string | null>(null);
|
|
||||||
let editKey = $state<string | null>(null);
|
|
||||||
let editValue = $state("");
|
|
||||||
let listOpen = $state<string | null>(null);
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (sources.length === 1) openSource(sources[0]);
|
|
||||||
});
|
|
||||||
|
|
||||||
async function openSource(src: SourceEntry) {
|
|
||||||
activeSource = src;
|
|
||||||
phase = "settings";
|
|
||||||
loading = true;
|
|
||||||
prefs = [];
|
|
||||||
editKey = null;
|
|
||||||
listOpen = null;
|
|
||||||
try {
|
|
||||||
const d = await gql<{ source: { preferences: Preference[] } }>(
|
|
||||||
GET_SOURCE_SETTINGS,
|
|
||||||
{ id: String(src.id) },
|
|
||||||
);
|
|
||||||
prefs = d.source.preferences ?? [];
|
|
||||||
} catch (e: any) {
|
|
||||||
addToast({ kind: "error", title: "Failed to load settings", body: e?.message ?? "" });
|
|
||||||
} finally {
|
|
||||||
loading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function backToPicker() {
|
|
||||||
phase = "pick";
|
|
||||||
activeSource = null;
|
|
||||||
prefs = [];
|
|
||||||
editKey = null;
|
|
||||||
listOpen = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function save(position: number, changeType: string, value: unknown) {
|
|
||||||
if (!activeSource) return;
|
|
||||||
const pref = prefs[position];
|
|
||||||
saving = pref.key;
|
|
||||||
try {
|
|
||||||
await gql(UPDATE_SOURCE_PREFERENCE, {
|
|
||||||
source: String(activeSource.id),
|
|
||||||
change: { position, [changeType]: value },
|
|
||||||
});
|
|
||||||
const d = await gql<{ source: { preferences: Preference[] } }>(
|
|
||||||
GET_SOURCE_SETTINGS,
|
|
||||||
{ id: String(activeSource.id) },
|
|
||||||
);
|
|
||||||
prefs = d.source.preferences ?? [];
|
|
||||||
} catch (e: any) {
|
|
||||||
addToast({ kind: "error", title: "Failed to save", body: e?.message ?? "" });
|
|
||||||
} finally {
|
|
||||||
saving = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getTitle(p: Preference) {
|
|
||||||
return p.CheckBoxTitle ?? p.SwitchPreferenceTitle ?? p.ListPreferenceTitle
|
|
||||||
?? p.EditTextPreferenceTitle ?? p.MultiSelectListPreferenceTitle ?? p.key;
|
|
||||||
}
|
|
||||||
function getSummary(p: Preference) {
|
|
||||||
return p.CheckBoxSummary ?? p.SwitchPreferenceSummary ?? p.ListPreferenceSummary
|
|
||||||
?? p.EditTextPreferenceSummary ?? p.MultiSelectListPreferenceSummary ?? null;
|
|
||||||
}
|
|
||||||
function getBoolValue(p: Preference) {
|
|
||||||
if (p.type === "CheckBoxPreference")
|
|
||||||
return p.CheckBoxCurrentValue ?? p.CheckBoxDefault ?? false;
|
|
||||||
return p.SwitchPreferenceCurrentValue ?? p.SwitchPreferenceDefault ?? false;
|
|
||||||
}
|
|
||||||
function getListValue(p: Preference) {
|
|
||||||
return p.ListPreferenceCurrentValue ?? p.ListPreferenceDefault ?? "";
|
|
||||||
}
|
|
||||||
function getListLabel(p: Preference, val: string) {
|
|
||||||
const idx = p.entryValues?.indexOf(val) ?? -1;
|
|
||||||
return idx >= 0 ? (p.entries?.[idx] ?? val) : val;
|
|
||||||
}
|
|
||||||
function getMultiValue(p: Preference): string[] {
|
|
||||||
return p.MultiSelectListPreferenceCurrentValue ?? p.MultiSelectListPreferenceDefault ?? [];
|
|
||||||
}
|
|
||||||
function toggleMulti(position: number, p: Preference, val: string) {
|
|
||||||
const current = getMultiValue(p);
|
|
||||||
const next = current.includes(val) ? current.filter(v => v !== val) : [...current, val];
|
|
||||||
save(position, "multiSelectState", next);
|
|
||||||
}
|
|
||||||
function submitEdit(position: number) {
|
|
||||||
save(position, "editTextState", editValue);
|
|
||||||
editKey = null;
|
|
||||||
}
|
|
||||||
function openEdit(p: Preference) {
|
|
||||||
editKey = p.key;
|
|
||||||
editValue = p.EditTextPreferenceCurrentValue ?? p.EditTextPreferenceDefault ?? "";
|
|
||||||
}
|
|
||||||
|
|
||||||
function langTag(displayName: string) {
|
|
||||||
const m = displayName.match(/\(([^)]+)\)$/);
|
|
||||||
return m ? m[1].toUpperCase() : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function onBackdrop(e: MouseEvent) {
|
|
||||||
if (e.target === e.currentTarget) onClose();
|
|
||||||
}
|
|
||||||
function onKeydown(e: KeyboardEvent) {
|
|
||||||
if (e.key === "Escape") {
|
|
||||||
if (editKey) { editKey = null; return; }
|
|
||||||
if (listOpen) { listOpen = null; return; }
|
|
||||||
if (phase === "settings" && sources.length > 1) { backToPicker(); return; }
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<svelte:window onkeydown={onKeydown} />
|
|
||||||
|
|
||||||
<div class="backdrop" role="dialog" aria-modal="true" onmousedown={onBackdrop}>
|
|
||||||
<div class="modal">
|
|
||||||
|
|
||||||
<div class="modal-header">
|
|
||||||
<div class="modal-title-wrap">
|
|
||||||
{#if phase === "settings" && sources.length > 1}
|
|
||||||
<button class="icon-btn" onclick={backToPicker} title="Back">
|
|
||||||
<CaretLeft size={13} weight="bold" />
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
{#if iconUrl}
|
|
||||||
<Thumbnail src={iconUrl} alt={extensionName} class="modal-ext-icon" onerror={(e) => ((e.target as HTMLImageElement).style.display = "none")} />
|
|
||||||
{/if}
|
|
||||||
<div class="modal-titles">
|
|
||||||
<span class="modal-eyebrow">Extension Settings</span>
|
|
||||||
<span class="modal-title">
|
|
||||||
{phase === "pick" ? extensionName : (activeSource?.displayName ?? extensionName)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button class="icon-btn" onclick={onClose}>
|
|
||||||
<X size={14} weight="bold" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="modal-body">
|
|
||||||
|
|
||||||
{#if phase === "pick"}
|
|
||||||
<div class="source-list">
|
|
||||||
{#each sources as src}
|
|
||||||
{@const tag = langTag(src.displayName)}
|
|
||||||
{@const baseName = src.displayName.replace(/\s*\([^)]+\)$/, "")}
|
|
||||||
<button class="source-row" onclick={() => openSource(src)}>
|
|
||||||
<span class="source-name">{baseName}</span>
|
|
||||||
{#if tag}<span class="lang-badge">{tag}</span>{/if}
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{:else}
|
|
||||||
{#if loading}
|
|
||||||
<div class="center-state">
|
|
||||||
<CircleNotch size={16} weight="light" class="anim-spin" style="color:var(--text-faint)" />
|
|
||||||
</div>
|
|
||||||
{:else if prefs.length === 0}
|
|
||||||
<div class="center-state empty-state">No configurable settings.</div>
|
|
||||||
{:else}
|
|
||||||
<div class="pref-list">
|
|
||||||
{#each prefs as pref, i}
|
|
||||||
{@const title = getTitle(pref)}
|
|
||||||
{@const summary = getSummary(pref)}
|
|
||||||
{@const isSaving = saving === pref.key}
|
|
||||||
|
|
||||||
{#if pref.type === "CheckBoxPreference" || pref.type === "SwitchPreference"}
|
|
||||||
{@const checked = getBoolValue(pref)}
|
|
||||||
<div class="pref-row">
|
|
||||||
<div class="pref-text">
|
|
||||||
<span class="pref-title">{title}</span>
|
|
||||||
{#if summary}<span class="pref-summary">{summary}</span>{/if}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
class="toggle" class:toggle-on={checked}
|
|
||||||
disabled={isSaving}
|
|
||||||
onclick={() => save(i, pref.type === "CheckBoxPreference" ? "checkBoxState" : "switchState", !checked)}
|
|
||||||
>
|
|
||||||
{#if isSaving}
|
|
||||||
<CircleNotch size={10} weight="light" class="anim-spin" />
|
|
||||||
{:else}
|
|
||||||
<span class="toggle-thumb"></span>
|
|
||||||
{/if}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{:else if pref.type === "ListPreference"}
|
|
||||||
{@const current = getListValue(pref)}
|
|
||||||
<div class="pref-row pref-row-col">
|
|
||||||
<div class="pref-text">
|
|
||||||
<span class="pref-title">{title}</span>
|
|
||||||
{#if summary}<span class="pref-summary">{summary}</span>{/if}
|
|
||||||
</div>
|
|
||||||
<div class="select-wrap">
|
|
||||||
<button
|
|
||||||
class="select-btn" class:select-open={listOpen === pref.key}
|
|
||||||
disabled={isSaving}
|
|
||||||
onclick={() => listOpen = listOpen === pref.key ? null : pref.key}
|
|
||||||
>
|
|
||||||
<span class="select-val">{getListLabel(pref, current)}</span>
|
|
||||||
{#if isSaving}
|
|
||||||
<CircleNotch size={11} weight="light" class="anim-spin" />
|
|
||||||
{:else}
|
|
||||||
<CaretUpDown size={11} weight="bold" />
|
|
||||||
{/if}
|
|
||||||
</button>
|
|
||||||
{#if listOpen === pref.key}
|
|
||||||
<div class="dropdown">
|
|
||||||
{#each (pref.entries ?? []) as entry, j}
|
|
||||||
{@const val = pref.entryValues?.[j] ?? entry}
|
|
||||||
<button
|
|
||||||
class="dropdown-item" class:dropdown-item-active={val === current}
|
|
||||||
onclick={() => { save(i, "listState", val); listOpen = null; }}
|
|
||||||
>
|
|
||||||
{entry}
|
|
||||||
{#if val === current}<Check size={11} weight="bold" />{/if}
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{:else if pref.type === "EditTextPreference"}
|
|
||||||
{#if editKey === pref.key}
|
|
||||||
<div class="pref-row pref-row-col edit-active">
|
|
||||||
<div class="pref-text">
|
|
||||||
{#if pref.dialogTitle}<span class="pref-title">{pref.dialogTitle}</span>{/if}
|
|
||||||
{#if pref.dialogMessage}<span class="pref-summary">{pref.dialogMessage}</span>{/if}
|
|
||||||
</div>
|
|
||||||
<div class="edit-row">
|
|
||||||
<input
|
|
||||||
class="edit-input"
|
|
||||||
bind:value={editValue}
|
|
||||||
disabled={isSaving}
|
|
||||||
onkeydown={(e) => { if (e.key === "Enter") submitEdit(i); if (e.key === "Escape") editKey = null; }}
|
|
||||||
autofocus
|
|
||||||
/>
|
|
||||||
<button class="action-btn-dim" onclick={() => editKey = null}>Cancel</button>
|
|
||||||
<button class="action-btn" onclick={() => submitEdit(i)} disabled={isSaving}>
|
|
||||||
{#if isSaving}<CircleNotch size={11} weight="light" class="anim-spin" />{:else}Save{/if}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<button class="pref-row pref-row-btn" onclick={() => openEdit(pref)}>
|
|
||||||
<div class="pref-text">
|
|
||||||
<span class="pref-title">{title}</span>
|
|
||||||
{#if summary}<span class="pref-summary">{summary}</span>{/if}
|
|
||||||
</div>
|
|
||||||
<span class="pref-value-hint">
|
|
||||||
{pref.EditTextPreferenceCurrentValue ?? pref.EditTextPreferenceDefault ?? "—"}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{:else if pref.type === "MultiSelectListPreference"}
|
|
||||||
{@const selected = getMultiValue(pref)}
|
|
||||||
<div class="pref-row pref-row-col">
|
|
||||||
<div class="pref-text">
|
|
||||||
<span class="pref-title">{title}</span>
|
|
||||||
{#if summary}<span class="pref-summary">{summary}</span>{/if}
|
|
||||||
</div>
|
|
||||||
<div class="multi-list">
|
|
||||||
{#each (pref.entries ?? []) as entry, j}
|
|
||||||
{@const val = pref.entryValues?.[j] ?? entry}
|
|
||||||
{@const on = selected.includes(val)}
|
|
||||||
<button
|
|
||||||
class="multi-item" class:multi-item-on={on}
|
|
||||||
disabled={isSaving}
|
|
||||||
onclick={() => toggleMulti(i, pref, val)}
|
|
||||||
>
|
|
||||||
<span class="multi-check">{#if on}<Check size={10} weight="bold" />{/if}</span>
|
|
||||||
{entry}
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.backdrop {
|
|
||||||
position: fixed; inset: 0;
|
|
||||||
background: rgba(0,0,0,0.45);
|
|
||||||
backdrop-filter: blur(4px);
|
|
||||||
display: flex; align-items: center; justify-content: center;
|
|
||||||
z-index: var(--z-modal);
|
|
||||||
animation: fadeIn 0.15s ease both;
|
|
||||||
}
|
|
||||||
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
|
|
||||||
|
|
||||||
.modal {
|
|
||||||
display: flex; flex-direction: column;
|
|
||||||
width: 400px; max-width: calc(100vw - 32px); max-height: 78vh;
|
|
||||||
background: var(--bg-surface);
|
|
||||||
border: 1px solid var(--border-dim);
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
overflow: hidden;
|
|
||||||
animation: slideUp 0.18s cubic-bezier(0.16,1,0.3,1) both;
|
|
||||||
box-shadow: var(--shadow-lg);
|
|
||||||
}
|
|
||||||
@keyframes slideUp { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: translateY(0); } }
|
|
||||||
|
|
||||||
.modal-header {
|
|
||||||
display: flex; align-items: center; justify-content: space-between;
|
|
||||||
padding: var(--sp-3) var(--sp-3) var(--sp-3) var(--sp-4);
|
|
||||||
border-bottom: 1px solid var(--border-dim);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.modal-title-wrap { display: flex; align-items: center; gap: var(--sp-2); }
|
|
||||||
:global(.modal-ext-icon) { width: 22px; height: 22px; border-radius: var(--radius-sm); object-fit: cover; flex-shrink: 0; background: var(--bg-raised); }
|
|
||||||
.modal-titles { display: flex; flex-direction: column; gap: 1px; }
|
|
||||||
.modal-eyebrow {
|
|
||||||
font-family: var(--font-ui); font-size: var(--text-2xs);
|
|
||||||
color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase;
|
|
||||||
}
|
|
||||||
.modal-title { font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-primary); }
|
|
||||||
|
|
||||||
.icon-btn {
|
|
||||||
display: flex; align-items: center; justify-content: center;
|
|
||||||
width: 26px; height: 26px;
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
color: var(--text-faint); flex-shrink: 0;
|
|
||||||
transition: color var(--t-base), background var(--t-base);
|
|
||||||
}
|
|
||||||
.icon-btn:hover { color: var(--text-primary); background: var(--bg-overlay); }
|
|
||||||
|
|
||||||
.modal-body { overflow-y: auto; flex: 1; }
|
|
||||||
|
|
||||||
.source-list { display: flex; flex-direction: column; padding: var(--sp-2) 0; }
|
|
||||||
.source-row {
|
|
||||||
display: flex; align-items: center; justify-content: space-between;
|
|
||||||
padding: 10px var(--sp-4);
|
|
||||||
text-align: left;
|
|
||||||
transition: background var(--t-fast);
|
|
||||||
gap: var(--sp-3);
|
|
||||||
}
|
|
||||||
.source-row:hover { background: var(--bg-raised); }
|
|
||||||
.source-name { flex: 1; font-size: var(--text-sm); color: var(--text-secondary); font-weight: var(--weight-medium); }
|
|
||||||
.lang-badge {
|
|
||||||
font-family: var(--font-ui); font-size: 10px; letter-spacing: var(--tracking-wide);
|
|
||||||
color: var(--text-faint); background: var(--bg-overlay);
|
|
||||||
border: 1px solid var(--border-dim);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
padding: 1px 6px; flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.center-state { display: flex; align-items: center; justify-content: center; padding: var(--sp-8); }
|
|
||||||
.empty-state { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
|
||||||
|
|
||||||
.pref-list { display: flex; flex-direction: column; padding: var(--sp-2) 0; }
|
|
||||||
.pref-row {
|
|
||||||
display: flex; align-items: center; gap: var(--sp-3);
|
|
||||||
padding: 10px var(--sp-4);
|
|
||||||
border-bottom: 1px solid var(--border-dim);
|
|
||||||
}
|
|
||||||
.pref-row:last-child { border-bottom: none; }
|
|
||||||
.pref-row-col { flex-direction: column; align-items: stretch; gap: var(--sp-2); }
|
|
||||||
.pref-row-btn { width: 100%; text-align: left; transition: background var(--t-fast); }
|
|
||||||
.pref-row-btn:hover { background: var(--bg-raised); }
|
|
||||||
.edit-active { background: var(--bg-raised); }
|
|
||||||
|
|
||||||
.pref-text { display: flex; flex-direction: column; gap: 2px; flex: 1; min-width: 0; }
|
|
||||||
.pref-title { font-size: var(--text-sm); color: var(--text-secondary); font-weight: var(--weight-medium); }
|
|
||||||
.pref-summary {
|
|
||||||
font-family: var(--font-ui); font-size: var(--text-2xs);
|
|
||||||
color: var(--text-faint); letter-spacing: var(--tracking-wide); line-height: 1.5;
|
|
||||||
}
|
|
||||||
.pref-value-hint {
|
|
||||||
font-family: var(--font-ui); font-size: var(--text-2xs);
|
|
||||||
color: var(--text-muted); letter-spacing: var(--tracking-wide);
|
|
||||||
flex-shrink: 0; max-width: 120px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle {
|
|
||||||
position: relative; width: 32px; height: 18px; border-radius: 9px;
|
|
||||||
background: var(--bg-overlay); border: 1px solid var(--border-strong);
|
|
||||||
flex-shrink: 0; transition: background var(--t-base), border-color var(--t-base);
|
|
||||||
display: flex; align-items: center; justify-content: center;
|
|
||||||
}
|
|
||||||
.toggle-on { background: var(--accent-muted); border-color: var(--accent-dim); }
|
|
||||||
.toggle-thumb {
|
|
||||||
position: absolute; left: 2px; width: 12px; height: 12px;
|
|
||||||
border-radius: 50%; background: var(--text-faint);
|
|
||||||
transition: left var(--t-base), background var(--t-base); pointer-events: none;
|
|
||||||
}
|
|
||||||
.toggle-on .toggle-thumb { left: 16px; background: var(--accent-fg); }
|
|
||||||
.toggle:disabled { opacity: 0.4; cursor: default; }
|
|
||||||
|
|
||||||
.select-wrap { position: relative; }
|
|
||||||
.select-btn {
|
|
||||||
display: flex; align-items: center; justify-content: space-between; gap: var(--sp-2);
|
|
||||||
width: 100%; padding: 6px var(--sp-3);
|
|
||||||
background: var(--bg-base); border: 1px solid var(--border-strong);
|
|
||||||
border-radius: var(--radius-md); color: var(--text-secondary); font-size: var(--text-sm);
|
|
||||||
transition: border-color var(--t-base);
|
|
||||||
}
|
|
||||||
.select-btn:hover:not(:disabled) { border-color: var(--border-focus); }
|
|
||||||
.select-btn:disabled { opacity: 0.4; cursor: default; }
|
|
||||||
.select-open { border-color: var(--border-focus); }
|
|
||||||
.select-val { flex: 1; text-align: left; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
||||||
|
|
||||||
.dropdown {
|
|
||||||
position: absolute; top: calc(100% + 4px); left: 0; right: 0;
|
|
||||||
background: var(--bg-surface); border: 1px solid var(--border-strong);
|
|
||||||
border-radius: var(--radius-md); overflow: hidden;
|
|
||||||
box-shadow: var(--shadow-lg); z-index: 10;
|
|
||||||
animation: dropIn 0.12s cubic-bezier(0.16,1,0.3,1) both;
|
|
||||||
}
|
|
||||||
@keyframes dropIn { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: translateY(0); } }
|
|
||||||
.dropdown-item {
|
|
||||||
display: flex; align-items: center; justify-content: space-between;
|
|
||||||
width: 100%; padding: 7px var(--sp-3);
|
|
||||||
font-size: var(--text-sm); color: var(--text-secondary);
|
|
||||||
transition: background var(--t-fast);
|
|
||||||
}
|
|
||||||
.dropdown-item:hover { background: var(--bg-raised); }
|
|
||||||
.dropdown-item-active { color: var(--accent-fg); }
|
|
||||||
|
|
||||||
.edit-row { display: flex; gap: var(--sp-2); }
|
|
||||||
.edit-input {
|
|
||||||
flex: 1; background: var(--bg-base); border: 1px solid var(--border-strong);
|
|
||||||
border-radius: var(--radius-md); padding: 6px var(--sp-3);
|
|
||||||
color: var(--text-primary); font-size: var(--text-sm);
|
|
||||||
outline: none; transition: border-color var(--t-base);
|
|
||||||
}
|
|
||||||
.edit-input:focus { border-color: var(--border-focus); }
|
|
||||||
|
|
||||||
.action-btn {
|
|
||||||
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
|
|
||||||
padding: 5px 12px; border-radius: var(--radius-md);
|
|
||||||
background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim);
|
|
||||||
flex-shrink: 0; display: flex; align-items: center; gap: var(--sp-1);
|
|
||||||
transition: filter var(--t-base);
|
|
||||||
}
|
|
||||||
.action-btn:hover:not(:disabled) { filter: brightness(1.1); }
|
|
||||||
.action-btn:disabled { opacity: 0.4; cursor: default; }
|
|
||||||
|
|
||||||
.action-btn-dim {
|
|
||||||
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
|
|
||||||
padding: 5px 12px; border-radius: var(--radius-md);
|
|
||||||
background: none; color: var(--text-faint); border: 1px solid var(--border-dim);
|
|
||||||
flex-shrink: 0; transition: color var(--t-base), border-color var(--t-base);
|
|
||||||
}
|
|
||||||
.action-btn-dim:hover { color: var(--text-secondary); border-color: var(--border-strong); }
|
|
||||||
|
|
||||||
.multi-list { display: flex; flex-direction: column; gap: 1px; }
|
|
||||||
.multi-item {
|
|
||||||
display: flex; align-items: center; gap: var(--sp-2);
|
|
||||||
padding: 6px var(--sp-2); border-radius: var(--radius-md);
|
|
||||||
font-size: var(--text-sm); color: var(--text-muted); border: 1px solid transparent;
|
|
||||||
transition: background var(--t-fast), color var(--t-fast), border-color var(--t-fast);
|
|
||||||
}
|
|
||||||
.multi-item:hover:not(:disabled) { background: var(--bg-raised); color: var(--text-secondary); }
|
|
||||||
.multi-item-on { color: var(--accent-fg); background: var(--accent-muted); border-color: var(--accent-dim); }
|
|
||||||
.multi-check {
|
|
||||||
width: 14px; height: 14px; border-radius: var(--radius-sm);
|
|
||||||
border: 1px solid var(--border-strong); background: var(--bg-base);
|
|
||||||
display: flex; align-items: center; justify-content: center;
|
|
||||||
flex-shrink: 0; color: var(--accent-fg);
|
|
||||||
transition: background var(--t-fast), border-color var(--t-fast);
|
|
||||||
}
|
|
||||||
.multi-item-on .multi-check { background: var(--accent-muted); border-color: var(--accent-dim); }
|
|
||||||
</style>
|
|
||||||
@@ -1,285 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
let {
|
|
||||||
dailyReadCounts,
|
|
||||||
}: {
|
|
||||||
dailyReadCounts: Record<string, number>;
|
|
||||||
} = $props();
|
|
||||||
|
|
||||||
function intensity(count: number): 0 | 1 | 2 | 3 | 4 {
|
|
||||||
if (count === 0) return 0;
|
|
||||||
if (count === 1) return 1;
|
|
||||||
if (count <= 3) return 2;
|
|
||||||
if (count <= 6) return 3;
|
|
||||||
return 4;
|
|
||||||
}
|
|
||||||
|
|
||||||
let tip: { text: string; x: number; y: number } | null = $state(null);
|
|
||||||
|
|
||||||
function showTip(e: MouseEvent, cell: { dateStr: string; count: number }) {
|
|
||||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
|
||||||
const label = cell.count === 0
|
|
||||||
? `No chapters — ${fmtDate(cell.dateStr)}`
|
|
||||||
: `${cell.count} chapter${cell.count !== 1 ? "s" : ""} — ${fmtDate(cell.dateStr)}`;
|
|
||||||
tip = { text: label, x: rect.left + rect.width / 2, y: rect.top - 6 };
|
|
||||||
}
|
|
||||||
|
|
||||||
function hideTip() { tip = null; }
|
|
||||||
|
|
||||||
function fmtDate(d: string): string {
|
|
||||||
return new Date(d + "T00:00:00").toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
|
||||||
}
|
|
||||||
|
|
||||||
function localDateStr(d: Date): string {
|
|
||||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
let wrapEl: HTMLElement;
|
|
||||||
let cellSize = $state(12);
|
|
||||||
let numWeeks = $state(26);
|
|
||||||
|
|
||||||
const GAP = 3;
|
|
||||||
const DAY_GUTTER = 28;
|
|
||||||
const LEGEND_H = 20;
|
|
||||||
const MONTH_H = 14;
|
|
||||||
const ROWS = 7;
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (!wrapEl) return;
|
|
||||||
const obs = new ResizeObserver(() => {
|
|
||||||
const h = wrapEl.clientHeight;
|
|
||||||
const w = wrapEl.clientWidth;
|
|
||||||
const cs = Math.max(8, Math.floor((h - LEGEND_H - MONTH_H - 2 * GAP - (ROWS - 1) * GAP) / ROWS));
|
|
||||||
cellSize = cs;
|
|
||||||
numWeeks = Math.max(4, Math.floor((w - DAY_GUTTER - GAP * 3) / (cs + GAP)));
|
|
||||||
});
|
|
||||||
obs.observe(wrapEl);
|
|
||||||
return () => obs.disconnect();
|
|
||||||
});
|
|
||||||
|
|
||||||
const visibleWeeks = $derived((() => {
|
|
||||||
const today = new Date();
|
|
||||||
today.setHours(0, 0, 0, 0);
|
|
||||||
const todayStr = localDateStr(today);
|
|
||||||
const endDow = today.getDay(); // 0=Sun ... 6=Sat
|
|
||||||
const weekEnd = new Date(today);
|
|
||||||
weekEnd.setDate(weekEnd.getDate() + (6 - endDow)); // advance to Saturday
|
|
||||||
|
|
||||||
const weeks: { dateStr: string; count: number; isToday: boolean; isFuture: boolean }[][] = [];
|
|
||||||
for (let wi = numWeeks - 1; wi >= 0; wi--) {
|
|
||||||
const week: typeof weeks[0] = [];
|
|
||||||
for (let di = 0; di < 7; di++) {
|
|
||||||
const d = new Date(weekEnd);
|
|
||||||
d.setDate(d.getDate() - wi * 7 - (6 - di));
|
|
||||||
const dateStr = localDateStr(d);
|
|
||||||
week.push({ dateStr, count: dailyReadCounts[dateStr] ?? 0, isToday: dateStr === todayStr, isFuture: d > today });
|
|
||||||
}
|
|
||||||
weeks.push(week);
|
|
||||||
}
|
|
||||||
return weeks;
|
|
||||||
})());
|
|
||||||
|
|
||||||
const monthLabels = $derived((() => {
|
|
||||||
const labels: { label: string; colIndex: number }[] = [];
|
|
||||||
let lastMonth = -1;
|
|
||||||
visibleWeeks.forEach((week, ci) => {
|
|
||||||
const first = week[0];
|
|
||||||
if (!first) return;
|
|
||||||
const m = new Date(first.dateStr + "T00:00:00").getMonth();
|
|
||||||
if (m !== lastMonth) {
|
|
||||||
labels.push({ label: new Date(first.dateStr + "T00:00:00").toLocaleDateString("en-US", { month: "short" }), colIndex: ci });
|
|
||||||
lastMonth = m;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return labels;
|
|
||||||
})());
|
|
||||||
|
|
||||||
const DAY_LABELS = ["Sun", "", "Tue", "", "Thu", "", "Sat"];
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="heatmap-wrap" bind:this={wrapEl} style="--cell:{cellSize}px; --cols:{numWeeks};">
|
|
||||||
|
|
||||||
<div class="month-row">
|
|
||||||
<div class="day-gutter"></div>
|
|
||||||
<div class="month-cells">
|
|
||||||
{#each visibleWeeks as _week, ci}
|
|
||||||
{@const lbl = monthLabels.find(l => l.colIndex === ci)}
|
|
||||||
<div class="month-label">{lbl?.label ?? ""}</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid-row">
|
|
||||||
<div class="day-labels">
|
|
||||||
{#each DAY_LABELS as d}
|
|
||||||
<span class="day-label">{d}</span>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
<div class="cell-grid">
|
|
||||||
{#each visibleWeeks as week}
|
|
||||||
<div class="week-col">
|
|
||||||
{#each week as cell}
|
|
||||||
<!-- svelte-ignore a11y_mouse_events_have_key_events -->
|
|
||||||
<button
|
|
||||||
class="cell intensity-{intensity(cell.count)}"
|
|
||||||
class:cell-today={cell.isToday}
|
|
||||||
class:cell-future={cell.isFuture}
|
|
||||||
onmouseover={(e) => showTip(e, cell)}
|
|
||||||
onmouseleave={hideTip}
|
|
||||||
aria-label="{cell.count} chapters on {cell.dateStr}"
|
|
||||||
></button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="legend">
|
|
||||||
<span class="legend-label">Less</span>
|
|
||||||
{#each [0, 1, 2, 3, 4] as lvl}
|
|
||||||
<div class="legend-cell intensity-{lvl}"></div>
|
|
||||||
{/each}
|
|
||||||
<span class="legend-label">More</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if tip}
|
|
||||||
<div class="heatmap-tip" style="left:{tip.x}px; top:{tip.y}px;">{tip.text}</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.heatmap-wrap {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 4px;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
min-width: 0;
|
|
||||||
min-height: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
.month-row {
|
|
||||||
display: flex;
|
|
||||||
gap: 4px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.day-gutter { width: 28px; flex-shrink: 0; }
|
|
||||||
.month-cells {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(var(--cols), var(--cell));
|
|
||||||
gap: 3px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
.month-label {
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: 9px;
|
|
||||||
color: var(--text-faint);
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
padding-left: 1px;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.grid-row {
|
|
||||||
display: flex;
|
|
||||||
gap: 4px;
|
|
||||||
align-items: flex-start;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.day-labels {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 3px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
width: 28px;
|
|
||||||
}
|
|
||||||
.day-label {
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: 8px;
|
|
||||||
color: var(--text-faint);
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
height: var(--cell);
|
|
||||||
line-height: var(--cell);
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cell-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(var(--cols), var(--cell));
|
|
||||||
gap: 3px;
|
|
||||||
overflow: visible;
|
|
||||||
padding: 4px;
|
|
||||||
margin: -4px;
|
|
||||||
}
|
|
||||||
.week-col {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cell {
|
|
||||||
width: var(--cell);
|
|
||||||
height: var(--cell);
|
|
||||||
border-radius: 3px;
|
|
||||||
border: none;
|
|
||||||
padding: 0;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: filter var(--t-fast), transform var(--t-fast);
|
|
||||||
}
|
|
||||||
.cell:hover:not(.cell-future) {
|
|
||||||
filter: brightness(1.5);
|
|
||||||
transform: scale(1.2);
|
|
||||||
z-index: 1;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.intensity-0 { background: var(--bg-subtle); border: 1px solid var(--border-dim); }
|
|
||||||
.intensity-1 { background: var(--accent-muted); border: 1px solid var(--accent-dim); }
|
|
||||||
.intensity-2 { background: var(--accent-dim); border: 1px solid var(--accent); opacity: 0.7; }
|
|
||||||
.intensity-3 { background: var(--accent); border: 1px solid var(--accent-bright); opacity: 0.85; }
|
|
||||||
.intensity-4 { background: var(--accent-bright); border: 1px solid var(--accent-fg); }
|
|
||||||
|
|
||||||
.cell-today { outline: 1.5px solid var(--accent-fg); outline-offset: 1px; }
|
|
||||||
.cell-future { opacity: 0.2; cursor: default; pointer-events: none; }
|
|
||||||
|
|
||||||
.legend {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 3px;
|
|
||||||
justify-content: flex-end;
|
|
||||||
flex-shrink: 0;
|
|
||||||
padding-top: 2px;
|
|
||||||
}
|
|
||||||
.legend-cell {
|
|
||||||
width: 10px;
|
|
||||||
height: 10px;
|
|
||||||
border-radius: 3px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.legend-label {
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: 9px;
|
|
||||||
color: var(--text-faint);
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
}
|
|
||||||
|
|
||||||
.heatmap-tip {
|
|
||||||
position: fixed;
|
|
||||||
transform: translate(-50%, -100%);
|
|
||||||
background: var(--bg-overlay);
|
|
||||||
border: 1px solid var(--border-base);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
padding: 4px 8px;
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-2xs);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
white-space: nowrap;
|
|
||||||
pointer-events: none;
|
|
||||||
z-index: 9999;
|
|
||||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,194 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { MagnifyingGlass, X as XIcon } from "phosphor-svelte";
|
|
||||||
import Thumbnail from "@shared/manga/Thumbnail.svelte";
|
|
||||||
import type { Manga } from "@types";
|
|
||||||
|
|
||||||
let {
|
|
||||||
slotIndex,
|
|
||||||
libraryManga,
|
|
||||||
loading,
|
|
||||||
onpin,
|
|
||||||
onclose,
|
|
||||||
}: {
|
|
||||||
slotIndex: 1 | 2 | 3;
|
|
||||||
libraryManga: Manga[];
|
|
||||||
loading: boolean;
|
|
||||||
onpin: (m: Manga) => void;
|
|
||||||
onclose: () => void;
|
|
||||||
} = $props();
|
|
||||||
|
|
||||||
let search = $state("");
|
|
||||||
|
|
||||||
function focusEl(node: HTMLElement) { node.focus(); }
|
|
||||||
|
|
||||||
const results = $derived(
|
|
||||||
search.trim()
|
|
||||||
? libraryManga.filter(m => m.title.toLowerCase().includes(search.toLowerCase())).slice(0, 20)
|
|
||||||
: libraryManga.slice(0, 20)
|
|
||||||
);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="backdrop"
|
|
||||||
role="presentation"
|
|
||||||
onclick={(e) => { if (e.target === e.currentTarget) onclose(); }}
|
|
||||||
onkeydown={(e) => { if (e.key === "Escape") onclose(); }}
|
|
||||||
>
|
|
||||||
<div class="modal">
|
|
||||||
<div class="modal-header">
|
|
||||||
<span class="modal-title">Pin manga — slot {slotIndex + 1}</span>
|
|
||||||
<button class="modal-close" onclick={onclose}><XIcon size={13} weight="light" /></button>
|
|
||||||
</div>
|
|
||||||
<div class="search-wrap">
|
|
||||||
<MagnifyingGlass size={12} weight="light" style="color:var(--text-faint);flex-shrink:0" />
|
|
||||||
<input class="search-input" placeholder="Search library…" bind:value={search} use:focusEl />
|
|
||||||
</div>
|
|
||||||
<div class="list">
|
|
||||||
{#if loading}
|
|
||||||
<p class="empty-msg">Loading…</p>
|
|
||||||
{:else if results.length === 0}
|
|
||||||
<p class="empty-msg">No results</p>
|
|
||||||
{:else}
|
|
||||||
{#each results as m (m.id)}
|
|
||||||
<button class="list-row" onclick={() => onpin(m)}>
|
|
||||||
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="row-thumb" />
|
|
||||||
<div class="row-info">
|
|
||||||
<span class="row-title">{m.title}</span>
|
|
||||||
{#if m.source?.displayName}<span class="row-source">{m.source.displayName}</span>{/if}
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.backdrop {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
background: rgba(0, 0, 0, 0.62);
|
|
||||||
z-index: var(--z-settings);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
animation: fadeIn 0.1s ease both;
|
|
||||||
backdrop-filter: blur(4px);
|
|
||||||
-webkit-backdrop-filter: blur(4px);
|
|
||||||
}
|
|
||||||
.modal {
|
|
||||||
width: min(460px, calc(100vw - 48px));
|
|
||||||
max-height: 68vh;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
background: var(--bg-surface);
|
|
||||||
border: 1px solid var(--border-base);
|
|
||||||
border-radius: var(--radius-xl);
|
|
||||||
overflow: hidden;
|
|
||||||
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.6);
|
|
||||||
animation: scaleIn 0.14s ease both;
|
|
||||||
}
|
|
||||||
.modal-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: var(--sp-4) var(--sp-5);
|
|
||||||
border-bottom: 1px solid var(--border-dim);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.modal-title {
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
font-weight: var(--weight-medium);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
.modal-close {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 24px;
|
|
||||||
height: 24px;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
color: var(--text-faint);
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background var(--t-fast), color var(--t-fast);
|
|
||||||
}
|
|
||||||
.modal-close:hover { color: var(--text-muted); background: var(--bg-raised); }
|
|
||||||
|
|
||||||
.search-wrap {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--sp-2);
|
|
||||||
padding: var(--sp-3) var(--sp-4);
|
|
||||||
border-bottom: 1px solid var(--border-dim);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.search-input {
|
|
||||||
flex: 1;
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
outline: none;
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
}
|
|
||||||
.search-input::placeholder { color: var(--text-faint); }
|
|
||||||
|
|
||||||
.list {
|
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
padding: var(--sp-2);
|
|
||||||
scrollbar-width: none;
|
|
||||||
}
|
|
||||||
.list::-webkit-scrollbar { display: none; }
|
|
||||||
|
|
||||||
.empty-msg {
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
color: var(--text-faint);
|
|
||||||
padding: var(--sp-4) var(--sp-3);
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
.list-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);
|
|
||||||
}
|
|
||||||
.list-row:hover { background: var(--bg-raised); }
|
|
||||||
:global(.row-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;
|
|
||||||
}
|
|
||||||
.row-info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; min-width: 0; }
|
|
||||||
.row-title {
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
.row-source {
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-2xs);
|
|
||||||
color: var(--text-faint);
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
|
|
||||||
@keyframes scaleIn { from { opacity: 0; transform: scale(0.97) } to { opacity: 1; transform: scale(1) } }
|
|
||||||
</style>
|
|
||||||
@@ -1,589 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { Play, ArrowRight, ArrowLeft, BookOpen, Clock, ListBullets, PushPin, X as XIcon } from "phosphor-svelte";
|
|
||||||
import type { Manga, Chapter } from "@types";
|
|
||||||
import type { HistoryEntry } from "@store/state.svelte";
|
|
||||||
import { store, setGenreFilter, setNavPage } from "@store/state.svelte";
|
|
||||||
import { timeAgo } from "../lib/homeHelpers";
|
|
||||||
|
|
||||||
interface HeroSlot {
|
|
||||||
kind: "continue" | "pinned" | "empty";
|
|
||||||
entry?: HistoryEntry;
|
|
||||||
manga?: Manga;
|
|
||||||
slotIndex: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
let {
|
|
||||||
resolvedSlots,
|
|
||||||
activeIdx = $bindable(),
|
|
||||||
heroThumb,
|
|
||||||
heroTitle,
|
|
||||||
heroManga,
|
|
||||||
heroEntry,
|
|
||||||
heroMangaId,
|
|
||||||
heroChapters,
|
|
||||||
heroNewChapter,
|
|
||||||
loadingHeroChapters,
|
|
||||||
resuming,
|
|
||||||
onresume,
|
|
||||||
onopenchapter,
|
|
||||||
oncyclenext,
|
|
||||||
oncycleprev,
|
|
||||||
ongotoslot,
|
|
||||||
onopenpicker,
|
|
||||||
onunpin,
|
|
||||||
onviewall,
|
|
||||||
}: {
|
|
||||||
resolvedSlots: HeroSlot[];
|
|
||||||
activeIdx: number;
|
|
||||||
heroThumb: string;
|
|
||||||
heroTitle: string;
|
|
||||||
heroManga: Manga | null | undefined;
|
|
||||||
heroEntry: HistoryEntry | null;
|
|
||||||
heroMangaId: number | null;
|
|
||||||
heroChapters: Chapter[];
|
|
||||||
heroNewChapter: Chapter | null;
|
|
||||||
loadingHeroChapters: boolean;
|
|
||||||
resuming: boolean;
|
|
||||||
onresume: () => void;
|
|
||||||
onopenchapter: (ch: Chapter) => void;
|
|
||||||
oncyclenext: () => void;
|
|
||||||
oncycleprev: () => void;
|
|
||||||
ongotoslot: (i: number) => void;
|
|
||||||
onopenpicker: (i: 1 | 2 | 3) => void;
|
|
||||||
onunpin: (i: 1 | 2 | 3) => void;
|
|
||||||
onviewall: () => void;
|
|
||||||
} = $props();
|
|
||||||
|
|
||||||
const activeSlot = $derived(resolvedSlots[activeIdx]);
|
|
||||||
const TOTAL_SLOTS = 4;
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="hero-stage">
|
|
||||||
{#key heroThumb}
|
|
||||||
{#if heroThumb}
|
|
||||||
<div class="hero-backdrop" style="background-image:url({heroThumb})"></div>
|
|
||||||
{:else}
|
|
||||||
<div class="hero-backdrop hero-bd-empty"></div>
|
|
||||||
{/if}
|
|
||||||
{/key}
|
|
||||||
<div class="hero-scrim"></div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
class="hero-cover-col"
|
|
||||||
onclick={onresume}
|
|
||||||
disabled={resuming || activeSlot?.kind === "empty"}
|
|
||||||
aria-label={heroTitle ? `Resume ${heroTitle}` : "No manga selected"}
|
|
||||||
>
|
|
||||||
{#if heroThumb}
|
|
||||||
<img src={heroThumb} alt={heroTitle} class="hero-cover" loading="eager" decoding="async" />
|
|
||||||
{#if activeSlot?.kind === "continue"}
|
|
||||||
<div class="cover-resume-hint"><Play size={20} weight="fill" /></div>
|
|
||||||
{/if}
|
|
||||||
{:else}
|
|
||||||
<div class="hero-cover-empty"><BookOpen size={28} weight="light" /></div>
|
|
||||||
{/if}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div class="hero-details">
|
|
||||||
{#if activeSlot?.kind === "empty"}
|
|
||||||
<p class="hero-empty-title">Nothing here yet</p>
|
|
||||||
<p class="hero-empty-sub">
|
|
||||||
{activeSlot.slotIndex === 0
|
|
||||||
? "Read a manga to see it here"
|
|
||||||
: "Pin a manga or keep reading to fill this slot"}
|
|
||||||
</p>
|
|
||||||
{#if activeSlot.slotIndex !== 0}
|
|
||||||
<button class="hero-cta" onclick={() => onopenpicker(activeSlot.slotIndex as 1 | 2 | 3)}>
|
|
||||||
<PushPin size={11} weight="fill" /> Pin manga
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
{:else}
|
|
||||||
<div class="hero-tags">
|
|
||||||
{#if activeSlot?.kind === "continue"}
|
|
||||||
<span class="hero-tag hero-tag-reading"><Play size={8} weight="fill" /> Reading</span>
|
|
||||||
{:else}
|
|
||||||
<span class="hero-tag hero-tag-pinned"><PushPin size={8} weight="fill" /> Pinned</span>
|
|
||||||
{/if}
|
|
||||||
{#if heroNewChapter && !heroNewChapter.isRead}
|
|
||||||
<span class="hero-tag hero-tag-new">New ch.{Math.floor(heroNewChapter.chapterNumber)}</span>
|
|
||||||
{/if}
|
|
||||||
{#each (heroManga?.genre ?? []).slice(0, 3) as g}
|
|
||||||
<button
|
|
||||||
class="hero-tag hero-tag-genre"
|
|
||||||
onclick={() => { setGenreFilter(g); setNavPage("explore"); }}
|
|
||||||
>{g}</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h2 class="hero-title">{heroTitle}</h2>
|
|
||||||
{#if heroManga?.author}<p class="hero-author">{heroManga.author}</p>{/if}
|
|
||||||
|
|
||||||
{#if heroEntry}
|
|
||||||
<p class="hero-progress">
|
|
||||||
<Clock size={10} weight="light" />
|
|
||||||
{heroEntry.chapterName}
|
|
||||||
{#if heroEntry.pageNumber > 1}<span class="hero-prog-page"> · p.{heroEntry.pageNumber}</span>{/if}
|
|
||||||
<span class="hero-prog-time">{timeAgo(heroEntry.readAt)}</span>
|
|
||||||
</p>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if heroManga?.description}
|
|
||||||
<p class="hero-desc">{heroManga.description}</p>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="hero-actions">
|
|
||||||
{#if activeSlot?.kind === "continue"}
|
|
||||||
<button class="hero-cta" onclick={onresume} disabled={resuming}>
|
|
||||||
<Play size={11} weight="fill" />{resuming ? "Loading…" : "Resume"}
|
|
||||||
</button>
|
|
||||||
{:else if heroManga}
|
|
||||||
<button class="hero-cta" onclick={() => store.previewManga = heroManga!}>
|
|
||||||
<BookOpen size={11} weight="light" /> View manga
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
{#if activeSlot?.slotIndex !== 0}
|
|
||||||
{#if activeSlot?.kind === "pinned"}
|
|
||||||
<button class="hero-cta-ghost" onclick={() => onunpin(activeSlot.slotIndex as 1 | 2 | 3)}>
|
|
||||||
<XIcon size={10} weight="bold" /> Unpin
|
|
||||||
</button>
|
|
||||||
{:else}
|
|
||||||
<button class="hero-cta-ghost" onclick={() => onopenpicker(activeSlot!.slotIndex as 1 | 2 | 3)}>
|
|
||||||
<PushPin size={10} weight="light" /> Pin
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="hero-nav-row">
|
|
||||||
<button class="hero-nav-btn" onclick={oncycleprev} aria-label="Previous">
|
|
||||||
<ArrowLeft size={12} weight="bold" />
|
|
||||||
</button>
|
|
||||||
<div class="hero-dots">
|
|
||||||
{#each resolvedSlots as slot, i}
|
|
||||||
<button
|
|
||||||
class="hero-dot"
|
|
||||||
class:active={activeIdx === i}
|
|
||||||
class:pinned={slot.kind === "pinned"}
|
|
||||||
onclick={() => ongotoslot(i)}
|
|
||||||
aria-label="Slot {i + 1}"
|
|
||||||
></button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
<button class="hero-nav-btn" onclick={oncyclenext} aria-label="Next">
|
|
||||||
<ArrowRight size={12} weight="bold" />
|
|
||||||
</button>
|
|
||||||
<span class="hero-counter">{activeIdx + 1}/{TOTAL_SLOTS}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="hero-chapters">
|
|
||||||
<div class="hero-chapters-header">
|
|
||||||
<ListBullets size={11} weight="bold" /><span>Up Next</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if activeSlot?.kind === "empty"}
|
|
||||||
<p class="hero-chapters-empty">No chapters to show</p>
|
|
||||||
{:else if loadingHeroChapters}
|
|
||||||
{#each Array(4) as _}
|
|
||||||
<div class="chapter-row-sk">
|
|
||||||
<div class="sk sk-num"></div>
|
|
||||||
<div class="sk-info">
|
|
||||||
<div class="sk sk-name"></div>
|
|
||||||
<div class="sk sk-meta"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
{:else if heroChapters.length === 0}
|
|
||||||
<p class="hero-chapters-empty">No chapters available</p>
|
|
||||||
{:else}
|
|
||||||
{#each heroChapters as ch (ch.id)}
|
|
||||||
{@const isCurrent = heroEntry?.chapterId === ch.id}
|
|
||||||
<button
|
|
||||||
class="chapter-row"
|
|
||||||
class:chapter-row-current={isCurrent}
|
|
||||||
class:chapter-row-read={ch.isRead && !isCurrent}
|
|
||||||
onclick={() => onopenchapter(ch)}
|
|
||||||
>
|
|
||||||
<span class="ch-num">Ch.{ch.chapterNumber % 1 === 0 ? Math.floor(ch.chapterNumber) : ch.chapterNumber}</span>
|
|
||||||
<div class="ch-info">
|
|
||||||
<span class="ch-name">{ch.name}</span>
|
|
||||||
{#if isCurrent && heroEntry && heroEntry.pageNumber > 1}
|
|
||||||
<span class="ch-meta">p.{heroEntry.pageNumber} · in progress</span>
|
|
||||||
{:else if ch.isRead}
|
|
||||||
<span class="ch-meta ch-read">Read</span>
|
|
||||||
{:else if ch.uploadDate}
|
|
||||||
<span class="ch-meta">
|
|
||||||
{new Date(Number(ch.uploadDate) > 1e10 ? Number(ch.uploadDate) : Number(ch.uploadDate) * 1000)
|
|
||||||
.toLocaleDateString("en-US", { month: "short", day: "numeric" })}
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{#if isCurrent}<Play size={10} weight="fill" class="ch-play-icon" />{/if}
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
{#if heroManga}
|
|
||||||
<button class="ch-view-all" onclick={onviewall}>
|
|
||||||
All chapters <ArrowRight size={9} weight="bold" />
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.hero-stage {
|
|
||||||
position: relative;
|
|
||||||
display: flex;
|
|
||||||
align-items: stretch;
|
|
||||||
height: 374px;
|
|
||||||
overflow: hidden;
|
|
||||||
background: var(--bg-raised);
|
|
||||||
border-bottom: 1px solid var(--border-dim);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-backdrop {
|
|
||||||
position: absolute;
|
|
||||||
inset: -14px;
|
|
||||||
background-size: cover;
|
|
||||||
background-position: center 25%;
|
|
||||||
filter: blur(22px) saturate(2.4) brightness(0.4);
|
|
||||||
transform: scale(1.07);
|
|
||||||
pointer-events: none;
|
|
||||||
z-index: 0;
|
|
||||||
animation: backdropIn 0.5s ease both;
|
|
||||||
}
|
|
||||||
.hero-bd-empty { background: var(--bg-void); filter: none; }
|
|
||||||
|
|
||||||
.hero-scrim {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
z-index: 1;
|
|
||||||
pointer-events: none;
|
|
||||||
background: linear-gradient(110deg, rgba(0,0,0,0.0) 0%, rgba(0,0,0,0.6) 100%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-cover-col {
|
|
||||||
position: relative;
|
|
||||||
z-index: 2;
|
|
||||||
flex-shrink: 0;
|
|
||||||
width: 256px;
|
|
||||||
height: 374px;
|
|
||||||
overflow: hidden;
|
|
||||||
cursor: pointer;
|
|
||||||
background: var(--bg-raised);
|
|
||||||
padding: 0;
|
|
||||||
border: none;
|
|
||||||
border-right: 1px solid rgba(255, 255, 255, 0.07);
|
|
||||||
}
|
|
||||||
.hero-cover-col:hover .hero-cover { filter: brightness(1.1) saturate(1.05); }
|
|
||||||
.hero-cover-col:hover .cover-resume-hint { opacity: 1; }
|
|
||||||
.hero-cover { width: 100%; height: 100%; object-fit: cover; display: block; transition: filter 0.22s ease; }
|
|
||||||
.hero-cover-empty {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
background: var(--bg-overlay);
|
|
||||||
color: var(--text-faint);
|
|
||||||
}
|
|
||||||
.cover-resume-hint {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
color: #fff;
|
|
||||||
background: rgba(0, 0, 0, 0.38);
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity 0.18s ease;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-details {
|
|
||||||
position: relative;
|
|
||||||
z-index: 2;
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
padding: var(--sp-5) var(--sp-5) var(--sp-4);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--sp-2);
|
|
||||||
overflow: hidden;
|
|
||||||
border-right: 1px solid rgba(255, 255, 255, 0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-tags { display: flex; flex-wrap: wrap; gap: 5px; flex-shrink: 0; }
|
|
||||||
.hero-tag {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 5px;
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: 9px;
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
text-transform: uppercase;
|
|
||||||
padding: 3px 8px;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
background: rgba(255, 255, 255, 0.1);
|
|
||||||
color: rgba(255, 255, 255, 0.6);
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.13);
|
|
||||||
}
|
|
||||||
.hero-tag-reading { background: var(--accent-muted); color: var(--accent-fg); border-color: var(--accent-dim); }
|
|
||||||
.hero-tag-pinned { background: rgba(168, 132, 232, 0.18); color: #c4a8f0; border-color: rgba(168, 132, 232, 0.28); }
|
|
||||||
.hero-tag-new { background: rgba(74, 222, 128, 0.15); color: #86efac; border-color: rgba(74, 222, 128, 0.25); }
|
|
||||||
.hero-tag-genre { cursor: pointer; transition: background 0.15s, color 0.15s; }
|
|
||||||
.hero-tag-genre:hover { background: rgba(255, 255, 255, 0.18); color: rgba(255, 255, 255, 0.9); }
|
|
||||||
|
|
||||||
.hero-title {
|
|
||||||
font-size: var(--text-xl);
|
|
||||||
font-weight: var(--weight-semibold);
|
|
||||||
color: #fff;
|
|
||||||
line-height: var(--leading-tight);
|
|
||||||
margin: 0;
|
|
||||||
flex-shrink: 0;
|
|
||||||
display: -webkit-box;
|
|
||||||
-webkit-line-clamp: 2;
|
|
||||||
-webkit-box-orient: vertical;
|
|
||||||
overflow: hidden;
|
|
||||||
text-shadow: 0 2px 12px rgba(0, 0, 0, 0.55);
|
|
||||||
letter-spacing: -0.01em;
|
|
||||||
}
|
|
||||||
.hero-author {
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
color: rgba(255, 255, 255, 0.45);
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.hero-progress {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 5px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
color: rgba(255, 255, 255, 0.55);
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
}
|
|
||||||
.hero-prog-page { color: rgba(255, 255, 255, 0.35); }
|
|
||||||
.hero-prog-time { margin-left: auto; color: rgba(255, 255, 255, 0.3); }
|
|
||||||
|
|
||||||
.hero-desc {
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
color: rgba(255, 255, 255, 0.38);
|
|
||||||
line-height: 1.6;
|
|
||||||
display: -webkit-box;
|
|
||||||
-webkit-line-clamp: 3;
|
|
||||||
-webkit-box-orient: vertical;
|
|
||||||
overflow: hidden;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.hero-empty-title {
|
|
||||||
font-size: var(--text-base);
|
|
||||||
font-weight: var(--weight-medium);
|
|
||||||
color: rgba(255, 255, 255, 0.48);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.hero-empty-sub {
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
color: rgba(255, 255, 255, 0.26);
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
line-height: var(--leading-snug);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-actions { display: flex; gap: var(--sp-2); flex-shrink: 0; flex-wrap: wrap; margin-top: var(--sp-1); }
|
|
||||||
|
|
||||||
.hero-cta {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
padding: 7px 18px;
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
background: var(--accent-muted);
|
|
||||||
border: 1px solid var(--accent-dim);
|
|
||||||
color: var(--accent-fg);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: filter var(--t-base);
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
.hero-cta:hover:not(:disabled) { filter: brightness(1.18); }
|
|
||||||
.hero-cta:disabled { opacity: 0.5; cursor: default; }
|
|
||||||
|
|
||||||
.hero-cta-ghost {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
padding: 7px 14px;
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
background: rgba(255, 255, 255, 0.06);
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.11);
|
|
||||||
color: rgba(255, 255, 255, 0.48);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background var(--t-base), color var(--t-base);
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
.hero-cta-ghost:hover { background: rgba(255, 255, 255, 0.12); color: rgba(255, 255, 255, 0.82); }
|
|
||||||
|
|
||||||
.hero-nav-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--sp-2);
|
|
||||||
flex-shrink: 0;
|
|
||||||
margin-top: auto;
|
|
||||||
padding-top: var(--sp-3);
|
|
||||||
border-top: 1px solid rgba(255, 255, 255, 0.07);
|
|
||||||
}
|
|
||||||
.hero-nav-btn {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 22px;
|
|
||||||
height: 22px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: rgba(255, 255, 255, 0.08);
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.11);
|
|
||||||
color: rgba(255, 255, 255, 0.55);
|
|
||||||
cursor: pointer;
|
|
||||||
flex-shrink: 0;
|
|
||||||
transition: background var(--t-base), color var(--t-base);
|
|
||||||
}
|
|
||||||
.hero-nav-btn:hover { background: rgba(255, 255, 255, 0.18); color: #fff; }
|
|
||||||
.hero-dots { display: flex; gap: 5px; align-items: center; }
|
|
||||||
.hero-dot {
|
|
||||||
width: 5px;
|
|
||||||
height: 5px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: rgba(255, 255, 255, 0.2);
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 0;
|
|
||||||
transition: background var(--t-base), transform var(--t-base), width var(--t-base);
|
|
||||||
}
|
|
||||||
.hero-dot:hover { background: rgba(255, 255, 255, 0.48); }
|
|
||||||
.hero-dot.active { background: #fff; width: 14px; border-radius: 3px; }
|
|
||||||
.hero-dot.pinned { background: rgba(168, 132, 232, 0.5); }
|
|
||||||
.hero-dot.pinned.active { background: #c4a8f0; }
|
|
||||||
.hero-counter {
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: 10px;
|
|
||||||
color: rgba(255, 255, 255, 0.28);
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
margin-left: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-chapters {
|
|
||||||
position: relative;
|
|
||||||
z-index: 2;
|
|
||||||
width: clamp(180px, 30%, 232px);
|
|
||||||
flex-shrink: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
padding: var(--sp-4) var(--sp-3);
|
|
||||||
gap: 1px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
.hero-chapters-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--sp-2);
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-2xs);
|
|
||||||
color: rgba(255, 255, 255, 0.35);
|
|
||||||
letter-spacing: var(--tracking-wider);
|
|
||||||
text-transform: uppercase;
|
|
||||||
padding-bottom: var(--sp-2);
|
|
||||||
margin-bottom: var(--sp-1);
|
|
||||||
border-bottom: 1px solid rgba(255, 255, 255, 0.07);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.hero-chapters-empty {
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
color: rgba(255, 255, 255, 0.22);
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
padding: var(--sp-3) 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chapter-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--sp-2);
|
|
||||||
width: 100%;
|
|
||||||
padding: 7px var(--sp-2);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
text-align: left;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background var(--t-fast);
|
|
||||||
}
|
|
||||||
.chapter-row:hover { background: rgba(255, 255, 255, 0.07); }
|
|
||||||
.chapter-row-current { background: rgba(255, 255, 255, 0.1) !important; }
|
|
||||||
|
|
||||||
.ch-num {
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-2xs);
|
|
||||||
color: rgba(255, 255, 255, 0.32);
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
flex-shrink: 0;
|
|
||||||
min-width: 36px;
|
|
||||||
}
|
|
||||||
.chapter-row-current .ch-num { color: var(--accent-fg); }
|
|
||||||
|
|
||||||
.ch-info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; min-width: 0; }
|
|
||||||
.ch-name {
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
color: rgba(255, 255, 255, 0.72);
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
.chapter-row-read .ch-name { color: rgba(255, 255, 255, 0.32); }
|
|
||||||
.chapter-row-current .ch-name { color: #fff; font-weight: var(--weight-medium); }
|
|
||||||
.ch-meta {
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: 9px;
|
|
||||||
color: rgba(255, 255, 255, 0.26);
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
}
|
|
||||||
.ch-read { color: rgba(255, 255, 255, 0.18); }
|
|
||||||
:global(.ch-play-icon) { color: var(--accent-fg); flex-shrink: 0; }
|
|
||||||
|
|
||||||
.chapter-row-sk { display: flex; gap: var(--sp-2); padding: 7px var(--sp-2); align-items: center; }
|
|
||||||
.sk-info { flex: 1; display: flex; flex-direction: column; gap: 4px; }
|
|
||||||
.sk { background: rgba(255, 255, 255, 0.06); border-radius: var(--radius-sm); animation: pulse 1.6s ease-in-out infinite; }
|
|
||||||
.sk-num { width: 32px; height: 10px; flex-shrink: 0; }
|
|
||||||
.sk-name { height: 11px; width: 85%; }
|
|
||||||
.sk-meta { height: 9px; width: 50%; }
|
|
||||||
|
|
||||||
.ch-view-all {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
margin-top: auto;
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-2xs);
|
|
||||||
color: rgba(255, 255, 255, 0.28);
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: var(--sp-2) var(--sp-2) 0;
|
|
||||||
transition: color var(--t-base);
|
|
||||||
}
|
|
||||||
.ch-view-all:hover { color: var(--accent-fg); }
|
|
||||||
|
|
||||||
@keyframes backdropIn {
|
|
||||||
from { opacity: 0; }
|
|
||||||
to { opacity: 1; }
|
|
||||||
}
|
|
||||||
@keyframes pulse { 0%, 100% { opacity: 0.4 } 50% { opacity: 0.7 } }
|
|
||||||
</style>
|
|
||||||
@@ -1,376 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { onMount, untrack } from "svelte";
|
|
||||||
import { gql, resolveImageUrl } from "@api/client";
|
|
||||||
import { GET_CHAPTERS } from "@api/queries/chapters";
|
|
||||||
import { GET_LIBRARY } from "@api/queries/manga";
|
|
||||||
import { cache, CACHE_KEYS } from "@core/cache";
|
|
||||||
import { store, openReader, setHeroSlot, setNavPage, setLibraryFilter, clearLibraryUpdates } from "@store/state.svelte";
|
|
||||||
import type { HistoryEntry } from "@store/state.svelte";
|
|
||||||
import type { Manga, Chapter } from "@types";
|
|
||||||
import { buildReaderChapterList } from "@features/series/lib/chapterList";
|
|
||||||
import HeroStage from "./HeroStage.svelte";
|
|
||||||
import HeroSlotPicker from "./HeroSlotPicker.svelte";
|
|
||||||
import ActivityFeed from "./ActivityFeed.svelte";
|
|
||||||
import ActivityHeatmap from "./ActivityHeatmap.svelte";
|
|
||||||
import RecsRow from "./RecsRow.svelte";
|
|
||||||
import StatsGrid from "./StatsGrid.svelte";
|
|
||||||
|
|
||||||
let libraryManga: Manga[] = $state([]);
|
|
||||||
let loadingLibrary: boolean = $state(true);
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
loadLibrary();
|
|
||||||
window.addEventListener("keydown", onKey);
|
|
||||||
return () => window.removeEventListener("keydown", onKey);
|
|
||||||
});
|
|
||||||
|
|
||||||
function loadLibrary() {
|
|
||||||
cache.get(CACHE_KEYS.LIBRARY, () =>
|
|
||||||
gql<{ mangas: { nodes: Manga[] } }>(GET_LIBRARY).then(d => d.mangas.nodes)
|
|
||||||
)
|
|
||||||
.then(m => { libraryManga = m; })
|
|
||||||
.catch(console.error)
|
|
||||||
.finally(() => loadingLibrary = false);
|
|
||||||
}
|
|
||||||
|
|
||||||
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());
|
|
||||||
});
|
|
||||||
|
|
||||||
const continueReading = $derived((() => {
|
|
||||||
const seen = new Set<number>();
|
|
||||||
const out: HistoryEntry[] = [];
|
|
||||||
for (const e of store.history) {
|
|
||||||
if (seen.has(e.mangaId)) continue;
|
|
||||||
seen.add(e.mangaId);
|
|
||||||
out.push(e);
|
|
||||||
if (out.length >= 10) break;
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
})());
|
|
||||||
|
|
||||||
const TOTAL_SLOTS = 4;
|
|
||||||
interface HeroSlot { kind: "continue" | "pinned" | "empty"; entry?: HistoryEntry; manga?: Manga; slotIndex: number; }
|
|
||||||
|
|
||||||
const resolvedSlots = $derived((() => {
|
|
||||||
const pins = store.settings.heroSlots ?? [null, null, null, null];
|
|
||||||
const slots: HeroSlot[] = [];
|
|
||||||
const first = continueReading[0];
|
|
||||||
slots.push(first ? { kind: "continue", entry: first, slotIndex: 0 } : { kind: "empty", slotIndex: 0 });
|
|
||||||
let hi = 1;
|
|
||||||
for (let i = 1; i < TOTAL_SLOTS; i++) {
|
|
||||||
const pinId = pins[i];
|
|
||||||
if (pinId != null) {
|
|
||||||
const manga = libraryManga.find(m => m.id === pinId);
|
|
||||||
if (manga) { slots.push({ kind: "pinned", manga, slotIndex: i }); continue; }
|
|
||||||
}
|
|
||||||
const entry = continueReading[hi++];
|
|
||||||
slots.push(entry ? { kind: "continue", entry, slotIndex: i } : { kind: "empty", slotIndex: i });
|
|
||||||
}
|
|
||||||
return slots;
|
|
||||||
})());
|
|
||||||
|
|
||||||
let activeIdx = $state(0);
|
|
||||||
|
|
||||||
const activeSlot = $derived(resolvedSlots[activeIdx]);
|
|
||||||
|
|
||||||
const heroManga = $derived(
|
|
||||||
activeSlot?.kind === "pinned" ? activeSlot.manga :
|
|
||||||
activeSlot?.kind === "continue" ? libraryManga.find(m => m.id === activeSlot.entry?.mangaId) : null
|
|
||||||
);
|
|
||||||
const heroEntry = $derived(activeSlot?.kind === "continue" ? activeSlot.entry : null);
|
|
||||||
const heroMangaId = $derived(heroManga?.id ?? heroEntry?.mangaId ?? null);
|
|
||||||
const heroTitle = $derived(heroManga?.title ?? heroEntry?.mangaTitle ?? "");
|
|
||||||
|
|
||||||
const heroThumbSrc = $derived(
|
|
||||||
heroManga?.thumbnailUrl
|
|
||||||
?? (activeSlot?.kind === "continue" ? activeSlot.entry?.thumbnailUrl : undefined)
|
|
||||||
?? ""
|
|
||||||
);
|
|
||||||
|
|
||||||
let heroThumb = $state("");
|
|
||||||
$effect(() => {
|
|
||||||
const path = heroThumbSrc;
|
|
||||||
if (!path) { heroThumb = ""; return; }
|
|
||||||
resolveImageUrl(path)
|
|
||||||
.then(url => { heroThumb = url; })
|
|
||||||
.catch(() => { heroThumb = ""; });
|
|
||||||
});
|
|
||||||
|
|
||||||
const heroNewChapter = $derived(
|
|
||||||
heroManga ? (libraryManga.find(m => m.id === heroManga!.id) as any)?.latestUploadedChapter ?? null : null
|
|
||||||
);
|
|
||||||
|
|
||||||
function cycleNext() { activeIdx = (activeIdx + 1) % TOTAL_SLOTS; heroChapters = []; heroAllChapters = []; }
|
|
||||||
function cyclePrev() { activeIdx = (activeIdx - 1 + TOTAL_SLOTS) % TOTAL_SLOTS; heroChapters = []; heroAllChapters = []; }
|
|
||||||
function goToSlot(i: number) { if (i !== activeIdx) { activeIdx = i; heroChapters = []; heroAllChapters = []; } }
|
|
||||||
|
|
||||||
function onKey(e: KeyboardEvent) {
|
|
||||||
if (e.target !== document.body && !(e.target instanceof HTMLElement && e.target.closest(".hero-stage"))) return;
|
|
||||||
if (e.key === "ArrowRight") cycleNext();
|
|
||||||
if (e.key === "ArrowLeft") cyclePrev();
|
|
||||||
}
|
|
||||||
|
|
||||||
let heroChapters: Chapter[] = $state([]);
|
|
||||||
let heroAllChapters: Chapter[] = $state([]);
|
|
||||||
let loadingHeroChapters = $state(false);
|
|
||||||
let heroChaptersFor: number | null = null;
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
const id = heroMangaId;
|
|
||||||
void store.settings.mangaPrefs?.[id!];
|
|
||||||
if (id) untrack(() => loadHeroChapters(id));
|
|
||||||
});
|
|
||||||
|
|
||||||
async function loadHeroChapters(mangaId: number) {
|
|
||||||
heroChaptersFor = mangaId;
|
|
||||||
loadingHeroChapters = true;
|
|
||||||
heroChapters = [];
|
|
||||||
heroAllChapters = [];
|
|
||||||
try {
|
|
||||||
const d = await gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId });
|
|
||||||
if (heroChaptersFor !== mangaId) return;
|
|
||||||
const all = [...d.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder);
|
|
||||||
heroAllChapters = all;
|
|
||||||
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 = filtered.slice(startIdx, startIdx + 5);
|
|
||||||
} catch { heroChapters = []; heroAllChapters = []; }
|
|
||||||
finally { loadingHeroChapters = false; }
|
|
||||||
}
|
|
||||||
|
|
||||||
let resuming = $state(false);
|
|
||||||
|
|
||||||
function liveMangaStub(): Manga {
|
|
||||||
return heroManga ?? { id: heroMangaId!, title: heroTitle, thumbnailUrl: heroThumbSrc } as any;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function openChapter(chapter: Chapter) {
|
|
||||||
if (!heroMangaId) return;
|
|
||||||
resuming = true;
|
|
||||||
try {
|
|
||||||
let all = heroAllChapters;
|
|
||||||
if (!all.length) {
|
|
||||||
const d = await gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId: heroMangaId });
|
|
||||||
all = [...d.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder);
|
|
||||||
}
|
|
||||||
if (all.length) {
|
|
||||||
store.activeManga = liveMangaStub();
|
|
||||||
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 = liveMangaStub(); }
|
|
||||||
finally { resuming = false; }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function resumeActive() {
|
|
||||||
if (!heroEntry && heroManga) { store.activeManga = heroManga; return; }
|
|
||||||
if (!heroEntry) return;
|
|
||||||
const target = heroAllChapters.find(c => c.id === heroEntry!.chapterId) ?? heroAllChapters[0];
|
|
||||||
if (target && heroAllChapters.length) { await openChapter(target); return; }
|
|
||||||
resuming = true;
|
|
||||||
try {
|
|
||||||
const d = await gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId: heroEntry.mangaId });
|
|
||||||
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 = liveMangaStub();
|
|
||||||
openReader(ch, list);
|
|
||||||
}
|
|
||||||
} catch { store.activeManga = liveMangaStub(); }
|
|
||||||
finally { resuming = false; }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function resumeEntry(entry: HistoryEntry) {
|
|
||||||
const liveManga = libraryManga.find(m => m.id === entry.mangaId);
|
|
||||||
const stub = liveManga ?? { id: entry.mangaId, title: entry.mangaTitle, thumbnailUrl: liveManga?.thumbnailUrl ?? entry.thumbnailUrl } as any;
|
|
||||||
try {
|
|
||||||
const d = await gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId: entry.mangaId });
|
|
||||||
const raw = [...d.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder);
|
|
||||||
const list = buildReaderChapterList(raw, store.settings.mangaPrefs?.[entry.mangaId]);
|
|
||||||
const ch = list.find(c => c.id === entry.chapterId) ?? list[0];
|
|
||||||
store.activeManga = stub;
|
|
||||||
if (ch) openReader(ch, list);
|
|
||||||
} catch { store.activeManga = stub; }
|
|
||||||
}
|
|
||||||
|
|
||||||
let pickerOpen = $state(false);
|
|
||||||
let pickerSlotIndex: 1 | 2 | 3 | null = $state(null);
|
|
||||||
|
|
||||||
function openPicker(i: 1 | 2 | 3) { pickerSlotIndex = i; pickerOpen = true; }
|
|
||||||
function closePicker() { pickerOpen = false; pickerSlotIndex = null; }
|
|
||||||
function pinManga(m: Manga) { if (pickerSlotIndex !== null) { setHeroSlot(pickerSlotIndex, m.id); closePicker(); } }
|
|
||||||
function unpinSlot(i: 1 | 2 | 3) { setHeroSlot(i, null); }
|
|
||||||
|
|
||||||
const recentHistory = $derived(store.history.slice(0, 6));
|
|
||||||
const stats = $derived(store.readingStats);
|
|
||||||
const libraryUpdates = $derived(store.libraryUpdates.slice(0, 7));
|
|
||||||
const lastRefresh = $derived(store.lastLibraryRefresh);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="root">
|
|
||||||
<div class="body">
|
|
||||||
|
|
||||||
<div class="hero-shrink-guard">
|
|
||||||
<HeroStage
|
|
||||||
{resolvedSlots}
|
|
||||||
bind:activeIdx
|
|
||||||
{heroThumb}
|
|
||||||
{heroTitle}
|
|
||||||
{heroManga}
|
|
||||||
{heroEntry}
|
|
||||||
{heroMangaId}
|
|
||||||
{heroChapters}
|
|
||||||
{heroNewChapter}
|
|
||||||
{loadingHeroChapters}
|
|
||||||
{resuming}
|
|
||||||
onresume={resumeActive}
|
|
||||||
onopenchapter={openChapter}
|
|
||||||
oncyclenext={cycleNext}
|
|
||||||
oncycleprev={cyclePrev}
|
|
||||||
ongotoslot={goToSlot}
|
|
||||||
onopenpicker={openPicker}
|
|
||||||
onunpin={unpinSlot}
|
|
||||||
onviewall={() => { if (heroManga) store.activeManga = heroManga; }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="scroll-body">
|
|
||||||
<div class="mid-row">
|
|
||||||
<div class="mid-left">
|
|
||||||
<ActivityFeed
|
|
||||||
entries={recentHistory}
|
|
||||||
{libraryManga}
|
|
||||||
onresume={resumeEntry}
|
|
||||||
onviewhistory={() => setNavPage("history")}
|
|
||||||
onopenlibrary={() => setNavPage("library")}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="mid-divider"></div>
|
|
||||||
<div class="mid-right">
|
|
||||||
<RecsRow
|
|
||||||
{libraryManga}
|
|
||||||
history={store.history}
|
|
||||||
onopenrecommended={(m) => { store.previewManga = m; }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="bottom-row">
|
|
||||||
<div class="bottom-heatmap">
|
|
||||||
<span class="bottom-label">Activity</span>
|
|
||||||
<ActivityHeatmap dailyReadCounts={store.dailyReadCounts} />
|
|
||||||
</div>
|
|
||||||
<div class="bottom-divider"></div>
|
|
||||||
<div class="bottom-stats">
|
|
||||||
<StatsGrid {stats} updateCount={libraryUpdates.length} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if pickerOpen && pickerSlotIndex !== null}
|
|
||||||
<HeroSlotPicker
|
|
||||||
slotIndex={pickerSlotIndex}
|
|
||||||
{libraryManga}
|
|
||||||
loading={loadingLibrary}
|
|
||||||
onpin={pinManga}
|
|
||||||
onclose={closePicker}
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.root {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
height: 100%;
|
|
||||||
overflow: hidden;
|
|
||||||
animation: fadeIn 0.4s ease both;
|
|
||||||
}
|
|
||||||
.body {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
overflow: hidden;
|
|
||||||
min-height: 0;
|
|
||||||
}
|
|
||||||
.hero-shrink-guard { flex-shrink: 0; }
|
|
||||||
.scroll-body {
|
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
overflow-x: hidden;
|
|
||||||
min-height: 0;
|
|
||||||
scrollbar-width: none;
|
|
||||||
}
|
|
||||||
.scroll-body::-webkit-scrollbar { display: none; }
|
|
||||||
|
|
||||||
.mid-row {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 1px 1.4fr;
|
|
||||||
border-top: 1px solid var(--border-dim);
|
|
||||||
flex-shrink: 0;
|
|
||||||
min-height: 0;
|
|
||||||
}
|
|
||||||
.mid-left {
|
|
||||||
min-width: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
.mid-left :global(.section) { border-top: none; }
|
|
||||||
.mid-divider { background: var(--border-dim); align-self: stretch; }
|
|
||||||
.mid-right {
|
|
||||||
min-width: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
padding: var(--sp-3) var(--sp-4) var(--sp-4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bottom-row {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 1px 1fr;
|
|
||||||
border-top: 1px solid var(--border-dim);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.bottom-divider { background: var(--border-dim); align-self: stretch; }
|
|
||||||
.bottom-heatmap {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--sp-2);
|
|
||||||
padding: var(--sp-4) var(--sp-4) var(--sp-5);
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
.bottom-stats {
|
|
||||||
padding: var(--sp-4) var(--sp-4) var(--sp-5);
|
|
||||||
min-width: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
.bottom-label {
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-2xs);
|
|
||||||
color: var(--text-faint);
|
|
||||||
letter-spacing: var(--tracking-wider);
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes fadeIn {
|
|
||||||
from { opacity: 0; transform: translateY(6px); }
|
|
||||||
to { opacity: 1; transform: translateY(0); }
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
export function timeAgo(ts: number): string {
|
|
||||||
const diff = Date.now() - ts, m = Math.floor(diff / 60000);
|
|
||||||
if (m < 1) return "Just now";
|
|
||||||
if (m < 60) return `${m}m ago`;
|
|
||||||
const h = Math.floor(m / 60);
|
|
||||||
if (h < 24) return `${h}h ago`;
|
|
||||||
const d = Math.floor(h / 24);
|
|
||||||
if (d < 7) return `${d}d ago`;
|
|
||||||
return new Date(ts).toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatReadTime(mins: number): string {
|
|
||||||
if (mins < 1) return `${Math.round(mins * 60)}s`;
|
|
||||||
if (mins < 60) return `${Math.round(mins)}m`;
|
|
||||||
const h = Math.floor(mins / 60), r = Math.round(mins % 60);
|
|
||||||
if (h < 24) return r === 0 ? `${h}h` : `${h}h ${r}m`;
|
|
||||||
const d = Math.floor(h / 24), rh = h % 24;
|
|
||||||
return rh === 0 ? `${d}d` : `${d}d ${rh}h`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function timeAgoRefresh(ts: number): string {
|
|
||||||
if (!ts) return "";
|
|
||||||
const diff = Date.now() - ts, m = Math.floor(diff / 60000);
|
|
||||||
if (m < 1) return "just now";
|
|
||||||
if (m < 60) return `${m}m ago`;
|
|
||||||
const h = Math.floor(m / 60);
|
|
||||||
if (h < 24) return `${h}h ago`;
|
|
||||||
return `${Math.floor(h / 24)}d ago`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function handleRowWheel(e: WheelEvent) {
|
|
||||||
if (Math.abs(e.deltaY) <= Math.abs(e.deltaX)) return;
|
|
||||||
(e.currentTarget as HTMLElement).scrollLeft += e.deltaY;
|
|
||||||
e.stopPropagation();
|
|
||||||
}
|
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
import { gql } from "@api/client";
|
|
||||||
import { MANGAS_BY_GENRE } from "@api/queries/manga";
|
|
||||||
import { buildTagFilter } from "@features/discover/lib/searchFilter";
|
|
||||||
import type { Manga } from "@types";
|
|
||||||
import type { HistoryEntry } from "@store/state.svelte";
|
|
||||||
|
|
||||||
export interface RecommendedManga {
|
|
||||||
manga: Manga;
|
|
||||||
matchedGenres: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const TOP_GENRES = 6;
|
|
||||||
const PAGE_SIZE = 100;
|
|
||||||
const MAX_PAGES = 10;
|
|
||||||
const TARGET_PER_GENRE = 20;
|
|
||||||
const EXCLUDED_STATUSES = ["CANCELLED", "ABANDONED"];
|
|
||||||
|
|
||||||
export function topGenres(history: HistoryEntry[], libraryManga: Manga[]): string[] {
|
|
||||||
const byId = new Map(libraryManga.map(m => [m.id, m]));
|
|
||||||
const tally = new Map<string, { count: number; original: string }>();
|
|
||||||
|
|
||||||
for (const entry of history) {
|
|
||||||
const manga = byId.get(entry.mangaId);
|
|
||||||
if (!manga?.genre?.length) continue;
|
|
||||||
for (const g of manga.genre) {
|
|
||||||
const key = g.toLowerCase();
|
|
||||||
const existing = tally.get(key);
|
|
||||||
if (existing) { existing.count++; }
|
|
||||||
else { tally.set(key, { count: 1, original: g }); }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return [...tally.values()]
|
|
||||||
.sort((a, b) => b.count - a.count)
|
|
||||||
.slice(0, TOP_GENRES)
|
|
||||||
.map(e => e.original);
|
|
||||||
}
|
|
||||||
|
|
||||||
type Result = { mangas: { nodes: Manga[] } };
|
|
||||||
|
|
||||||
async function fetchGenrePages(
|
|
||||||
genre: string,
|
|
||||||
globalSeen: Set<number>,
|
|
||||||
signal?: AbortSignal,
|
|
||||||
): Promise<Manga[]> {
|
|
||||||
const filter = {
|
|
||||||
and: [
|
|
||||||
buildTagFilter([genre], "OR", []),
|
|
||||||
{ inLibrary: { equalTo: false } },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
const localSeen = new Set<number>();
|
|
||||||
const nodes: Manga[] = [];
|
|
||||||
|
|
||||||
for (let page = 0; page < MAX_PAGES; page++) {
|
|
||||||
if (signal?.aborted) break;
|
|
||||||
|
|
||||||
let batch: Manga[];
|
|
||||||
try {
|
|
||||||
const d = await gql<Result>(MANGAS_BY_GENRE, { filter, first: PAGE_SIZE, offset: page * PAGE_SIZE }, signal);
|
|
||||||
batch = d.mangas.nodes;
|
|
||||||
} catch {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!batch.length) break;
|
|
||||||
|
|
||||||
for (const m of batch) {
|
|
||||||
if (localSeen.has(m.id) || globalSeen.has(m.id)) continue;
|
|
||||||
if (EXCLUDED_STATUSES.includes(m.status ?? "")) continue;
|
|
||||||
localSeen.add(m.id);
|
|
||||||
nodes.push(m);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (nodes.length >= TARGET_PER_GENRE) break;
|
|
||||||
if (batch.length < PAGE_SIZE) break;
|
|
||||||
}
|
|
||||||
|
|
||||||
return nodes;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function fetchRecommendations(
|
|
||||||
history: HistoryEntry[],
|
|
||||||
libraryManga: Manga[],
|
|
||||||
signal?: AbortSignal,
|
|
||||||
): Promise<RecommendedManga[]> {
|
|
||||||
if (!history.length || !libraryManga.length) return [];
|
|
||||||
|
|
||||||
const genres = topGenres(history, libraryManga);
|
|
||||||
if (!genres.length) return [];
|
|
||||||
|
|
||||||
const globalSeen = new Set<number>();
|
|
||||||
const merged: Manga[] = [];
|
|
||||||
|
|
||||||
for (const genre of genres) {
|
|
||||||
const results = await fetchGenrePages(genre, globalSeen, signal);
|
|
||||||
for (const m of results) {
|
|
||||||
globalSeen.add(m.id);
|
|
||||||
merged.push(m);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return merged.map(m => ({
|
|
||||||
manga: m,
|
|
||||||
matchedGenres: (m.genre ?? []).filter(g =>
|
|
||||||
genres.some(tg => tg.toLowerCase() === g.toLowerCase())
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
@@ -1,753 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { onMount, untrack } from "svelte";
|
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
|
||||||
import { gql } from "@api/client";
|
|
||||||
import {
|
|
||||||
GET_CATEGORIES, GET_LIBRARY, UPDATE_MANGA, UPDATE_MANGAS,
|
|
||||||
GET_CHAPTERS, DELETE_DOWNLOADED_CHAPTERS, DEQUEUE_DOWNLOAD,
|
|
||||||
CREATE_CATEGORY, UPDATE_MANGA_CATEGORIES,
|
|
||||||
UPDATE_CATEGORY_ORDER, UPDATE_STOP, UPDATE_LIBRARY_MANGA, UPDATE_CATEGORY_MANGA,
|
|
||||||
} from "@api";
|
|
||||||
import { cache, CACHE_KEYS, CACHE_GROUPS, DEFAULT_TTL_MS } from "@core/cache/queryCache";
|
|
||||||
import { dedupeMangaById, dedupeMangaByTitle, shouldHideNsfw } from "@core/util";
|
|
||||||
import { sortLibrary } from "../lib/librarySort";
|
|
||||||
import { startLibraryUpdate } from "../lib/libraryUpdater";
|
|
||||||
import { createPaginator } from "@core/algorithms/paginate";
|
|
||||||
import { longPress } from "@core/ui/touchscreen";
|
|
||||||
import {
|
|
||||||
store, setCategories, setLibraryUpdates, addToast,
|
|
||||||
setTabSort, toggleTabSortDir, setTabStatus, toggleTabFilter, clearTabFilters,
|
|
||||||
} from "../store/libraryState.svelte";
|
|
||||||
import { saveScroll, getScroll } from "@store/state.svelte";
|
|
||||||
import type { LibrarySortMode, LibrarySortDir, LibraryStatusFilter, LibraryContentFilter, LibraryUpdateEntry } from "@store/state.svelte";
|
|
||||||
import type { Manga, Category, Chapter } from "@types";
|
|
||||||
import { checkAndMarkCompleted as storeCheckAndMarkCompleted, updateSettings } from "@store/state.svelte";
|
|
||||||
|
|
||||||
import LibraryToolbar from "./LibraryToolbar.svelte";
|
|
||||||
import LibraryGrid from "./LibraryGrid.svelte";
|
|
||||||
import LibraryFilters from "./LibraryFilters.svelte";
|
|
||||||
import BulkAutomationPanel from "../panels/BulkAutomationPanel.svelte";
|
|
||||||
import ContextMenu, { type MenuEntry } from "@shared/ui/ContextMenu.svelte";
|
|
||||||
|
|
||||||
import { Books, DownloadSimple, Folder, FolderSimple, FolderSimplePlus, Trash, Star, CheckSquare, ArrowSquareOut, ArrowsClockwise } from "phosphor-svelte";
|
|
||||||
|
|
||||||
const CARD_MIN_W = 130;
|
|
||||||
const CARD_GAP = 16;
|
|
||||||
const COMPLETED_NAME = "Completed";
|
|
||||||
const CTX_FOLDER_CAP = 4;
|
|
||||||
|
|
||||||
const paginator = createPaginator<Manga>(store.settings.renderLimit ?? 48);
|
|
||||||
|
|
||||||
let allManga: Manga[] = $state([]);
|
|
||||||
let loading: boolean = $state(true);
|
|
||||||
let error: string|null = $state(null);
|
|
||||||
let retryCount: number = $state(0);
|
|
||||||
let search: string = $state("");
|
|
||||||
let renderVisible: number = $state(store.settings.renderLimit ?? 48);
|
|
||||||
let scrollEl: HTMLDivElement;
|
|
||||||
let tabsEl = $state<HTMLDivElement>(null!);
|
|
||||||
let containerWidth: number = $state(800);
|
|
||||||
let ctx: { x: number; y: number; manga: Manga } | null = $state(null);
|
|
||||||
let emptyCtx: { x: number; y: number } | null = $state(null);
|
|
||||||
|
|
||||||
let tabIndicator: { left: number; width: number } = $state({ left: 0, width: 0 });
|
|
||||||
|
|
||||||
let selectedIds: Set<number> = $state(new Set());
|
|
||||||
let selectMode: boolean = $state(false);
|
|
||||||
let bulkWorking: boolean = $state(false);
|
|
||||||
let bulkAutomateOpen: boolean = $state(false);
|
|
||||||
|
|
||||||
let sortPanelOpen: boolean = $state(false);
|
|
||||||
let filterPanelOpen: boolean = $state(false);
|
|
||||||
|
|
||||||
let refreshing: boolean = $state(false);
|
|
||||||
let refreshProgress = $state({ finished: 0, total: 0 });
|
|
||||||
let cancelUpdate: (() => void) | null = null;
|
|
||||||
let refreshDone: boolean = $state(false);
|
|
||||||
let refreshDoneTimer: ReturnType<typeof setTimeout> | null = null;
|
|
||||||
|
|
||||||
let refreshingMangaId: number | null = $state(null);
|
|
||||||
let refreshingCatId: number | null = $state(null);
|
|
||||||
|
|
||||||
let activeDragKind: "tab" | null = $state(null);
|
|
||||||
let dragInsertIdx: number = $state(-1);
|
|
||||||
let dragTabId: string | null = $state(null);
|
|
||||||
let dragOverTabId: string | null = $state(null);
|
|
||||||
|
|
||||||
const DT_TAB = "application/x-moku-tab";
|
|
||||||
const anims = $derived(store.settings.qolAnimations ?? true);
|
|
||||||
|
|
||||||
const tab = $derived(store.libraryFilter);
|
|
||||||
const tabSortMode = $derived(store.settings.libraryTabSort[tab]?.mode ?? "az" as LibrarySortMode);
|
|
||||||
const tabSortDir = $derived(store.settings.libraryTabSort[tab]?.dir ?? "asc" as LibrarySortDir);
|
|
||||||
const tabStatus = $derived(store.settings.libraryTabStatus[tab] ?? "ALL" as LibraryStatusFilter);
|
|
||||||
const tabFilters = $derived(store.settings.libraryTabFilters?.[tab] ?? {} as Partial<Record<LibraryContentFilter, boolean>>);
|
|
||||||
const hasActiveFilters = $derived(tabStatus !== "ALL" || Object.values(tabFilters).some(Boolean));
|
|
||||||
const cols = $derived(Math.max(1, Math.floor((containerWidth + CARD_GAP) / (CARD_MIN_W + CARD_GAP))));
|
|
||||||
|
|
||||||
const BUILTIN_TABS = ["library", "downloaded"] as const;
|
|
||||||
|
|
||||||
const completedCatId = $derived(
|
|
||||||
store.categories.find(c => c.name === COMPLETED_NAME && c.id !== 0)?.id ?? null
|
|
||||||
);
|
|
||||||
|
|
||||||
const allTabIds = $derived((() => {
|
|
||||||
const catIds = store.categories.filter(c => c.id !== 0).map(c => String(c.id));
|
|
||||||
const pinned = store.settings.libraryPinnedTabOrder ?? [];
|
|
||||||
const known = new Set([...BUILTIN_TABS, ...catIds]);
|
|
||||||
const eligible = pinned.filter(id => known.has(id));
|
|
||||||
const ordered = [...eligible];
|
|
||||||
const inOrder = new Set(ordered);
|
|
||||||
for (const id of [...BUILTIN_TABS, ...catIds]) {
|
|
||||||
if (!inOrder.has(id)) ordered.push(id);
|
|
||||||
}
|
|
||||||
return ordered;
|
|
||||||
})());
|
|
||||||
|
|
||||||
const hiddenTabs = $derived(new Set(store.settings.hiddenLibraryTabs ?? []));
|
|
||||||
|
|
||||||
const visibleTabIds = $derived(allTabIds.filter(id => !hiddenTabs.has(id)));
|
|
||||||
|
|
||||||
const virtualTabIds = $derived(visibleTabIds.filter(id =>
|
|
||||||
id === "library" || id === "downloaded" || (completedCatId !== null && id === String(completedCatId))
|
|
||||||
));
|
|
||||||
|
|
||||||
const folderTabIds = $derived(visibleTabIds.filter(id =>
|
|
||||||
id !== "library" && id !== "downloaded" && (completedCatId === null || id !== String(completedCatId))
|
|
||||||
));
|
|
||||||
|
|
||||||
const visibleCategories = $derived((() => {
|
|
||||||
const defaultId = store.settings.defaultLibraryCategoryId ?? null;
|
|
||||||
const pinned = store.settings.libraryPinnedTabOrder ?? [];
|
|
||||||
const cats = store.categories.filter(c => c.id !== 0 && !hiddenTabs.has(String(c.id)));
|
|
||||||
const pinOrder = (id: number) => { const i = pinned.indexOf(String(id)); return i === -1 ? Infinity : i; };
|
|
||||||
return cats.sort((a, b) => {
|
|
||||||
if (a.id === defaultId) return -1;
|
|
||||||
if (b.id === defaultId) return 1;
|
|
||||||
const pd = pinOrder(a.id) - pinOrder(b.id);
|
|
||||||
return pd !== 0 ? pd : a.order - b.order;
|
|
||||||
});
|
|
||||||
})());
|
|
||||||
|
|
||||||
const categoryMangaMap = $derived((() => {
|
|
||||||
const map = new Map<number, Manga[]>();
|
|
||||||
for (const cat of store.categories) {
|
|
||||||
map.set(cat.id, cat.mangas?.nodes ?? []);
|
|
||||||
}
|
|
||||||
return map;
|
|
||||||
})());
|
|
||||||
|
|
||||||
const filtered = $derived((() => {
|
|
||||||
let items: Manga[];
|
|
||||||
|
|
||||||
if (tab === "library") {
|
|
||||||
items = (store.settings.libraryShowAllInSaved ?? true)
|
|
||||||
? allManga.filter(m => m.inLibrary)
|
|
||||||
: (categoryMangaMap.get(0) ?? []);
|
|
||||||
|
|
||||||
if ((store.settings.libraryShowAllInSaved ?? true) && (store.settings.libraryHideCompletedInSaved ?? false)) {
|
|
||||||
const completedCat = store.categories.find(c => c.name === COMPLETED_NAME);
|
|
||||||
if (completedCat) {
|
|
||||||
const completedIds = new Set((categoryMangaMap.get(completedCat.id) ?? []).map(m => m.id));
|
|
||||||
items = items.filter(m => !completedIds.has(m.id));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (tab === "downloaded") {
|
|
||||||
items = allManga.filter(m => (m.downloadCount ?? 0) > 0);
|
|
||||||
} else {
|
|
||||||
items = categoryMangaMap.get(Number(tab)) ?? [];
|
|
||||||
}
|
|
||||||
|
|
||||||
items = items.filter(m => !shouldHideNsfw(m, store.settings));
|
|
||||||
|
|
||||||
const q = search.trim().toLowerCase();
|
|
||||||
if (q) items = items.filter(m => m.title.toLowerCase().includes(q));
|
|
||||||
|
|
||||||
if (tabStatus !== "ALL") {
|
|
||||||
items = items.filter(m => {
|
|
||||||
const s = m.status?.toUpperCase().replace(/\s+/g, "_") ?? "UNKNOWN";
|
|
||||||
return s === tabStatus;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const f = store.settings.libraryTabFilters?.[tab] ?? {};
|
|
||||||
if (f.unread) items = items.filter(m => (m.unreadCount ?? 0) > 0);
|
|
||||||
if (f.started) items = items.filter(m => (m.unreadCount ?? 0) > 0 && (m.chapters?.totalCount ?? 0) > (m.unreadCount ?? 0));
|
|
||||||
if (f.downloaded) items = items.filter(m => (m.downloadCount ?? 0) > 0);
|
|
||||||
if (f.bookmarked) items = items.filter(m => (m.bookmarkCount ?? 0) > 0);
|
|
||||||
|
|
||||||
const recentlyReadMap = new Map<number, number>();
|
|
||||||
if (tabSortMode === "recentlyRead") {
|
|
||||||
for (const h of store.history) {
|
|
||||||
if (!recentlyReadMap.has(h.mangaId)) recentlyReadMap.set(h.mangaId, h.readAt);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return sortLibrary(items, tabSortMode, tabSortDir, recentlyReadMap.size ? recentlyReadMap : undefined);
|
|
||||||
})());
|
|
||||||
|
|
||||||
const { items: visibleManga, hasMore, remaining: remainingCount } = $derived(
|
|
||||||
paginator.slice(filtered, renderVisible)
|
|
||||||
);
|
|
||||||
|
|
||||||
const counts = $derived((() => {
|
|
||||||
const m: Record<string, number> = {
|
|
||||||
library: (store.settings.libraryShowAllInSaved ?? true)
|
|
||||||
? allManga.filter(x => x.inLibrary).length
|
|
||||||
: (categoryMangaMap.get(0) ?? []).length,
|
|
||||||
downloaded: allManga.filter(m => (m.downloadCount ?? 0) > 0).length,
|
|
||||||
};
|
|
||||||
for (const cat of visibleCategories) {
|
|
||||||
m[String(cat.id)] = (categoryMangaMap.get(cat.id) ?? []).length;
|
|
||||||
}
|
|
||||||
return m;
|
|
||||||
})());
|
|
||||||
|
|
||||||
$effect(() => { filtered; untrack(() => { renderVisible = paginator.reset(); }); });
|
|
||||||
$effect(() => { retryCount; loading = true; error = null; if (retryCount > 0) cache.clear(CACHE_KEYS.LIBRARY); untrack(() => loadData()); });
|
|
||||||
let prevTab = $state(tab);
|
|
||||||
$effect(() => {
|
|
||||||
const nextTab = tab;
|
|
||||||
if (scrollEl && nextTab !== prevTab) {
|
|
||||||
saveScroll(`library:${prevTab}`, scrollEl.scrollTop);
|
|
||||||
const saved = getScroll(`library:${nextTab}`);
|
|
||||||
untrack(() => { scrollEl.scrollTo({ top: saved }); });
|
|
||||||
prevTab = nextTab;
|
|
||||||
} else if (scrollEl && nextTab === prevTab) {
|
|
||||||
scrollEl.scrollTo({ top: 0 });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
$effect(() => {
|
|
||||||
const f = tab;
|
|
||||||
if (f === "library" || f === "downloaded") return;
|
|
||||||
const id = Number(f);
|
|
||||||
if (!store.categories.some(c => c.id === id)) untrack(() => { store.libraryFilter = "library"; });
|
|
||||||
});
|
|
||||||
$effect(() => { tab; untrack(() => exitSelectMode()); });
|
|
||||||
$effect(() => { tab; counts; });
|
|
||||||
|
|
||||||
let prevChapterId: number | null = null;
|
|
||||||
$effect(() => {
|
|
||||||
const wasOpen = prevChapterId !== null;
|
|
||||||
prevChapterId = store.activeChapter?.id ?? null;
|
|
||||||
if (wasOpen && !store.activeChapter) { cache.clear(CACHE_KEYS.LIBRARY); untrack(() => loadData()); }
|
|
||||||
});
|
|
||||||
|
|
||||||
function enterSelectMode(id?: number) { selectMode = true; if (id !== undefined) selectedIds = new Set([id]); }
|
|
||||||
function exitSelectMode() { selectMode = false; selectedIds = new Set(); }
|
|
||||||
function toggleSelect(id: number) { const next = new Set(selectedIds); if (next.has(id)) next.delete(id); else next.add(id); selectedIds = next; if (next.size === 0) exitSelectMode(); }
|
|
||||||
function selectAll() { selectedIds = new Set(visibleManga.map(m => m.id)); }
|
|
||||||
function loadMore() { renderVisible = paginator.nextVisible(renderVisible); }
|
|
||||||
|
|
||||||
let cardLongPressFired = false;
|
|
||||||
|
|
||||||
function rootLongPressAction(node: HTMLElement) {
|
|
||||||
return longPress(node, {
|
|
||||||
onLongPress(e) {
|
|
||||||
if ((e.target as HTMLElement).closest("button, .card")) return;
|
|
||||||
emptyCtx = { x: e.clientX - SIDEBAR_W, y: e.clientY - TITLEBAR_H };
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function cardLongPress(node: HTMLElement, m: Manga) {
|
|
||||||
return longPress(node, {
|
|
||||||
onLongPress(e) {
|
|
||||||
cardLongPressFired = true;
|
|
||||||
ctx = { x: e.clientX - SIDEBAR_W, y: e.clientY - TITLEBAR_H, manga: m };
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function onCardClick(e: MouseEvent, m: Manga) {
|
|
||||||
if (cardLongPressFired) { cardLongPressFired = false; return; }
|
|
||||||
if (selectMode) { toggleSelect(m.id); return; }
|
|
||||||
if (e.metaKey || e.ctrlKey || e.shiftKey) { e.preventDefault(); enterSelectMode(m.id); return; }
|
|
||||||
store.activeManga = m;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function ensureCompletedCategory(cats: Category[]): Promise<Category[]> {
|
|
||||||
if (cats.some(c => c.name === COMPLETED_NAME)) return cats;
|
|
||||||
try {
|
|
||||||
const res = await gql<{ createCategory: { category: Category } }>(CREATE_CATEGORY, { name: COMPLETED_NAME });
|
|
||||||
return [...cats, res.createCategory.category];
|
|
||||||
} catch { return cats; }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function reloadCategories() {
|
|
||||||
try {
|
|
||||||
const d = await gql<{ categories: { nodes: Category[] } }>(GET_CATEGORIES);
|
|
||||||
const cats = await ensureCompletedCategory(d.categories.nodes);
|
|
||||||
setCategories(cats);
|
|
||||||
} catch (e) { console.error(e); }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadData() {
|
|
||||||
try {
|
|
||||||
const [nodes] = await Promise.all([
|
|
||||||
cache.get(CACHE_KEYS.LIBRARY, () => gql<{ mangas: { nodes: Manga[] } }>(GET_LIBRARY).then(d => d.mangas.nodes), DEFAULT_TTL_MS, CACHE_GROUPS.LIBRARY),
|
|
||||||
reloadCategories(),
|
|
||||||
]);
|
|
||||||
const mapped = nodes.map((m: any) => ({ ...m }));
|
|
||||||
allManga = dedupeMangaByTitle(dedupeMangaById(mapped), store.settings.mangaLinks);
|
|
||||||
error = null;
|
|
||||||
await migrateCategorizedToLibrary();
|
|
||||||
} catch (e: any) {
|
|
||||||
error = e.message;
|
|
||||||
} finally {
|
|
||||||
loading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function migrateCategorizedToLibrary() {
|
|
||||||
const allCatManga = store.categories.flatMap(c => c.mangas?.nodes ?? []);
|
|
||||||
const orphanIds = [...new Set(allCatManga.filter(m => !m.inLibrary).map(m => m.id))];
|
|
||||||
if (!orphanIds.length) return;
|
|
||||||
await gql(UPDATE_MANGAS, { ids: orphanIds, inLibrary: true }).catch(console.error);
|
|
||||||
allManga = allManga.map(m => orphanIds.includes(m.id) ? { ...m, inLibrary: true } : m);
|
|
||||||
cache.clearGroup(CACHE_GROUPS.LIBRARY);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function removeFromLibrary(manga: Manga) {
|
|
||||||
await gql(UPDATE_MANGA, { id: manga.id, inLibrary: false }).catch(console.error);
|
|
||||||
allManga = allManga.filter(m => m.id !== manga.id);
|
|
||||||
cache.clearGroup(CACHE_GROUPS.LIBRARY);
|
|
||||||
await reloadCategories();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteAllDownloads(manga: Manga) {
|
|
||||||
try {
|
|
||||||
const data = await gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId: manga.id });
|
|
||||||
const ids = data.chapters.nodes.filter(c => c.isDownloaded).map(c => c.id);
|
|
||||||
if (!ids.length) return;
|
|
||||||
await gql(DELETE_DOWNLOADED_CHAPTERS, { ids });
|
|
||||||
await Promise.allSettled(ids.map(id => gql(DEQUEUE_DOWNLOAD, { chapterId: id })));
|
|
||||||
allManga = allManga.map(m => m.id === manga.id ? { ...m, downloadCount: 0 } : m);
|
|
||||||
} catch (e) { console.error(e); }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function refreshManga(manga: Manga) {
|
|
||||||
if (refreshingMangaId !== null) return;
|
|
||||||
refreshingMangaId = manga.id;
|
|
||||||
try {
|
|
||||||
await gql(UPDATE_LIBRARY_MANGA, { id: manga.id });
|
|
||||||
cache.clearGroup(CACHE_GROUPS.LIBRARY);
|
|
||||||
await loadData();
|
|
||||||
addToast({ kind: "success", title: "Manga refreshed", body: manga.title, duration: 2500 });
|
|
||||||
} catch (e) { console.error(e); }
|
|
||||||
finally { refreshingMangaId = null; }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function refreshCategory(catId: number) {
|
|
||||||
if (refreshingCatId !== null || refreshing) return;
|
|
||||||
refreshingCatId = catId;
|
|
||||||
try {
|
|
||||||
await gql(UPDATE_CATEGORY_MANGA, { categoryId: catId });
|
|
||||||
cache.clearGroup(CACHE_GROUPS.LIBRARY);
|
|
||||||
await loadData();
|
|
||||||
const cat = store.categories.find(c => c.id === catId);
|
|
||||||
addToast({ kind: "success", title: "Folder refreshed", body: cat?.name ?? "", duration: 2500 });
|
|
||||||
} catch (e) { console.error(e); }
|
|
||||||
finally { refreshingCatId = null; }
|
|
||||||
}
|
|
||||||
|
|
||||||
function bumpCategoryFrecency(catId: number) {
|
|
||||||
const prev = (store.settings as any).categoryFrecency ?? {};
|
|
||||||
updateSettings({ categoryFrecency: { ...prev, [catId]: (prev[catId] ?? 0) + 1 } } as any);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function toggleMangaCategory(manga: Manga, cat: Category) {
|
|
||||||
const inCat = (categoryMangaMap.get(cat.id) ?? []).some(m => m.id === manga.id);
|
|
||||||
setCategories(store.categories.map(c => {
|
|
||||||
if (c.id !== cat.id || !c.mangas) return c;
|
|
||||||
const nodes = inCat ? c.mangas.nodes.filter(m => m.id !== manga.id) : [...c.mangas.nodes, manga];
|
|
||||||
return { ...c, mangas: { nodes } };
|
|
||||||
}));
|
|
||||||
if (!inCat) bumpCategoryFrecency(cat.id);
|
|
||||||
try {
|
|
||||||
await gql(UPDATE_MANGA_CATEGORIES, { mangaId: manga.id, addTo: inCat ? [] : [cat.id], removeFrom: inCat ? [cat.id] : [] });
|
|
||||||
if (!inCat && !manga.inLibrary) {
|
|
||||||
await gql(UPDATE_MANGA, { id: manga.id, inLibrary: true });
|
|
||||||
allManga = allManga.map(m => m.id === manga.id ? { ...m, inLibrary: true } : m);
|
|
||||||
cache.clearGroup(CACHE_GROUPS.LIBRARY);
|
|
||||||
}
|
|
||||||
await reloadCategories();
|
|
||||||
} catch (e) { console.error(e); await reloadCategories(); }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createAndAssign(manga: Manga) {
|
|
||||||
const name = prompt("Folder name:");
|
|
||||||
if (!name?.trim()) return;
|
|
||||||
try {
|
|
||||||
const res = await gql<{ createCategory: { category: Category } }>(CREATE_CATEGORY, { name: name.trim() });
|
|
||||||
const cat = res.createCategory.category;
|
|
||||||
await gql(UPDATE_MANGA_CATEGORIES, { mangaId: manga.id, addTo: [cat.id], removeFrom: [] });
|
|
||||||
bumpCategoryFrecency(cat.id);
|
|
||||||
if (!manga.inLibrary) {
|
|
||||||
await gql(UPDATE_MANGA, { id: manga.id, inLibrary: true });
|
|
||||||
allManga = allManga.map(m => m.id === manga.id ? { ...m, inLibrary: true } : m);
|
|
||||||
cache.clearGroup(CACHE_GROUPS.LIBRARY);
|
|
||||||
}
|
|
||||||
await reloadCategories();
|
|
||||||
} catch (e) { console.error(e); }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function bulkMoveToCategory(cat: Category) {
|
|
||||||
bulkWorking = true;
|
|
||||||
try { await Promise.all([...selectedIds].map(id => { const m = allManga.find(x => x.id === id); return m ? toggleMangaCategory(m, cat) : Promise.resolve(); })); }
|
|
||||||
finally { bulkWorking = false; exitSelectMode(); }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function bulkRemoveFromLibrary() {
|
|
||||||
bulkWorking = true;
|
|
||||||
try { await Promise.all([...selectedIds].map(id => { const m = allManga.find(x => x.id === id); return m ? removeFromLibrary(m) : Promise.resolve(); })); }
|
|
||||||
finally { bulkWorking = false; exitSelectMode(); }
|
|
||||||
}
|
|
||||||
|
|
||||||
function bulkAutomate() {
|
|
||||||
if (selectedIds.size === 0) return;
|
|
||||||
bulkAutomateOpen = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function sanitize(s: string) { return s.replace(/[\/\\?%*:|"<>]/g, "_"); }
|
|
||||||
|
|
||||||
async function openMangaFolder(m: Manga) {
|
|
||||||
let base = store.settings.serverDownloadsPath?.trim();
|
|
||||||
if (!base) { try { base = await invoke<string>("get_default_downloads_path"); } catch {} }
|
|
||||||
if (!base) { addToast({ kind: "error", title: "No downloads path set", body: "Configure it in Settings → Storage" }); return; }
|
|
||||||
const source = m.source?.displayName ?? m.source?.name ?? "";
|
|
||||||
const path = source ? `${base}/mangas/${sanitize(source)}/${sanitize(m.title)}` : `${base}/mangas/${sanitize(m.title)}`;
|
|
||||||
try { await invoke("open_path", { path }); }
|
|
||||||
catch (e: any) { addToast({ kind: "error", title: "Could not open folder", body: e?.toString?.() ?? path }); }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function openDownloadsFolder() {
|
|
||||||
let path = store.settings.serverDownloadsPath?.trim();
|
|
||||||
if (!path) { try { path = await invoke<string>("get_default_downloads_path"); } catch {} }
|
|
||||||
if (!path) { addToast({ kind: "error", title: "No downloads path set", body: "Configure it in Settings → Storage" }); return; }
|
|
||||||
try { await invoke("open_path", { path }); }
|
|
||||||
catch (e: any) { addToast({ kind: "error", title: "Could not open folder", body: e?.toString?.() ?? path }); }
|
|
||||||
}
|
|
||||||
|
|
||||||
const SIDEBAR_W = 52;
|
|
||||||
const TITLEBAR_H = 36;
|
|
||||||
|
|
||||||
function openCtx(e: MouseEvent, m: Manga) {
|
|
||||||
if (selectMode) { toggleSelect(m.id); return; }
|
|
||||||
e.preventDefault();
|
|
||||||
ctx = { x: e.clientX - SIDEBAR_W, y: e.clientY - TITLEBAR_H, manga: m };
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildCtxItems(m: Manga): MenuEntry[] {
|
|
||||||
const frecency: Record<number, number> = (store.settings as any).categoryFrecency ?? {};
|
|
||||||
const sorted = [...visibleCategories].sort((a, b) => (frecency[b.id] ?? 0) - (frecency[a.id] ?? 0));
|
|
||||||
const pinned = sorted.slice(0, CTX_FOLDER_CAP);
|
|
||||||
const overflow = sorted.slice(CTX_FOLDER_CAP);
|
|
||||||
|
|
||||||
const makeCatEntry = (cat: Category): MenuEntry => {
|
|
||||||
const inCat = (categoryMangaMap.get(cat.id) ?? []).some(x => x.id === m.id);
|
|
||||||
return { label: inCat ? `Remove from ${cat.name}` : cat.name, icon: Folder, onClick: () => toggleMangaCategory(m, cat) };
|
|
||||||
};
|
|
||||||
|
|
||||||
const pinnedEntries = pinned.map(makeCatEntry);
|
|
||||||
const overflowChildren = overflow.map(makeCatEntry);
|
|
||||||
|
|
||||||
return [
|
|
||||||
{ label: m.inLibrary ? "Remove from library" : "Add to library", icon: Books, onClick: () => m.inLibrary ? removeFromLibrary(m) : gql(UPDATE_MANGA, { id: m.id, inLibrary: true }).then(() => { allManga = allManga.map(x => x.id === m.id ? { ...x, inLibrary: true } : x); cache.clear(CACHE_KEYS.LIBRARY); }).catch(console.error) },
|
|
||||||
{ label: refreshingMangaId === m.id ? "Refreshing…" : "Refresh manga", icon: ArrowsClockwise, disabled: refreshingMangaId !== null, onClick: () => refreshManga(m) },
|
|
||||||
{ label: "Open in file manager", icon: ArrowSquareOut, disabled: !(m.downloadCount && m.downloadCount > 0), onClick: () => openMangaFolder(m) },
|
|
||||||
{ label: "Delete all downloads", icon: Trash, danger: true, disabled: !(m.downloadCount && m.downloadCount > 0), onClick: () => deleteAllDownloads(m) },
|
|
||||||
{ separator: true },
|
|
||||||
{ label: "Select", icon: CheckSquare, onClick: () => enterSelectMode(m.id) },
|
|
||||||
...(pinnedEntries.length ? [{ separator: true } as MenuEntry, ...pinnedEntries] : []),
|
|
||||||
...(overflowChildren.length ? [{ label: `More folders (${overflowChildren.length})`, icon: FolderSimple, onClick: () => {}, children: overflowChildren } as MenuEntry] : []),
|
|
||||||
{ separator: true },
|
|
||||||
{ label: "New folder", icon: FolderSimplePlus, onClick: () => createAndAssign(m) },
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildEmptyCtx(): MenuEntry[] {
|
|
||||||
return [{ label: "New folder", icon: FolderSimplePlus, onClick: async () => {
|
|
||||||
const name = prompt("Folder name:");
|
|
||||||
if (!name?.trim()) return;
|
|
||||||
try { await gql(CREATE_CATEGORY, { name: name.trim() }); await reloadCategories(); }
|
|
||||||
catch (e) { console.error(e); }
|
|
||||||
}}];
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function checkAndMarkCompleted(mangaId: number, chaps: Chapter[]) {
|
|
||||||
await storeCheckAndMarkCompleted(mangaId, chaps, store.categories, gql, UPDATE_MANGA_CATEGORIES, UPDATE_MANGA);
|
|
||||||
await reloadCategories();
|
|
||||||
}
|
|
||||||
|
|
||||||
function showToast(newChapters: number, totalUpdated: number) {
|
|
||||||
if (newChapters > 0) {
|
|
||||||
addToast({ kind: "success", title: "Library updated", body: `${newChapters} new chapter${newChapters !== 1 ? "s" : ""} across ${totalUpdated} series` });
|
|
||||||
} else {
|
|
||||||
addToast({ kind: "info", title: "Already up to date", body: "No new chapters found" });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function cancelLibraryRefresh() {
|
|
||||||
if (!refreshing) return;
|
|
||||||
try { await gql(UPDATE_STOP); } catch (e) { console.error(e); }
|
|
||||||
cancelUpdate?.();
|
|
||||||
cancelUpdate = null;
|
|
||||||
refreshing = false;
|
|
||||||
refreshProgress = { finished: 0, total: 0 };
|
|
||||||
}
|
|
||||||
|
|
||||||
async function startLibraryRefresh() {
|
|
||||||
if (refreshing) return;
|
|
||||||
refreshing = true;
|
|
||||||
refreshProgress = { finished: 0, total: 0 };
|
|
||||||
|
|
||||||
cancelUpdate = startLibraryUpdate({
|
|
||||||
onProgress(p) { refreshProgress = p; },
|
|
||||||
async onDone({ entries, totalUpdated, newChapters }) {
|
|
||||||
refreshing = false; cancelUpdate = null;
|
|
||||||
setLibraryUpdates(entries);
|
|
||||||
cache.clearGroup(CACHE_GROUPS.LIBRARY);
|
|
||||||
await loadData();
|
|
||||||
refreshDone = true;
|
|
||||||
if (refreshDoneTimer) clearTimeout(refreshDoneTimer);
|
|
||||||
refreshDoneTimer = setTimeout(() => { refreshDone = false; }, 2500);
|
|
||||||
showToast(newChapters, totalUpdated);
|
|
||||||
},
|
|
||||||
onError() { refreshing = false; cancelUpdate = null; },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function onTabDragStart(e: DragEvent, id: string) {
|
|
||||||
activeDragKind = "tab"; dragTabId = id;
|
|
||||||
e.dataTransfer!.effectAllowed = "move";
|
|
||||||
e.dataTransfer!.setData(DT_TAB, id);
|
|
||||||
e.dataTransfer!.setData("text/plain", `tab:${id}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function onTabDragOver(e: DragEvent, id: string, idx: number) {
|
|
||||||
if (activeDragKind !== "tab" || dragTabId === null || dragTabId === id) return;
|
|
||||||
e.preventDefault(); e.dataTransfer!.dropEffect = "move";
|
|
||||||
dragOverTabId = id;
|
|
||||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
|
||||||
dragInsertIdx = e.clientX < rect.left + rect.width / 2 ? idx : idx + 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
function onTabDragLeave() { dragOverTabId = null; }
|
|
||||||
|
|
||||||
async function onTabDrop(e: DragEvent, dropId: string) {
|
|
||||||
e.preventDefault(); dragOverTabId = null;
|
|
||||||
const insertAt = dragInsertIdx;
|
|
||||||
dragInsertIdx = -1;
|
|
||||||
if (activeDragKind !== "tab" || dragTabId === null || dragTabId === dropId) { dragTabId = null; return; }
|
|
||||||
const dragStrId = dragTabId; dragTabId = null; activeDragKind = null;
|
|
||||||
|
|
||||||
const tabs = [...allTabIds];
|
|
||||||
const fromIdx = tabs.indexOf(dragStrId);
|
|
||||||
const dropIdx = tabs.indexOf(dropId);
|
|
||||||
if (fromIdx < 0 || dropIdx < 0) return;
|
|
||||||
|
|
||||||
const visibleDrop = visibleTabIds[insertAt] ?? null;
|
|
||||||
const destIdx = visibleDrop ? tabs.indexOf(visibleDrop) : tabs.length;
|
|
||||||
|
|
||||||
tabs.splice(fromIdx, 1);
|
|
||||||
const adjustedDest = Math.max(0, Math.min(destIdx > fromIdx ? destIdx - 1 : destIdx, tabs.length));
|
|
||||||
tabs.splice(adjustedDest, 0, dragStrId);
|
|
||||||
|
|
||||||
updateSettings({ libraryPinnedTabOrder: tabs });
|
|
||||||
const catIds = tabs.filter(id => id !== "library" && id !== "downloaded");
|
|
||||||
const zeroCat = store.categories.filter(c => c.id === 0);
|
|
||||||
const reordered = catIds.map((id, i) => { const c = store.categories.find(x => String(x.id) === id)!; return { ...c, order: i + 1 }; });
|
|
||||||
setCategories([...zeroCat, ...reordered]);
|
|
||||||
const dragIsBuiltin = dragStrId === "library" || dragStrId === "downloaded";
|
|
||||||
if (!dragIsBuiltin) {
|
|
||||||
const serverPos = catIds.indexOf(dragStrId) + 1;
|
|
||||||
try {
|
|
||||||
await gql<{ updateCategoryOrder: { categories: Category[] } }>(UPDATE_CATEGORY_ORDER, { id: Number(dragStrId), position: serverPos });
|
|
||||||
} catch (err) { console.error("Tab reorder failed:", err); await reloadCategories(); }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function onTabDragEnd() { activeDragKind = null; dragTabId = null; dragOverTabId = null; dragInsertIdx = -1; }
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
const ro = new ResizeObserver(([e]) => containerWidth = e.contentRect.width);
|
|
||||||
ro.observe(scrollEl);
|
|
||||||
const unsub = cache.subscribe(CACHE_KEYS.LIBRARY, () => loadData());
|
|
||||||
|
|
||||||
const defaultId = store.settings.defaultLibraryCategoryId;
|
|
||||||
if (defaultId && store.libraryFilter === "library") store.libraryFilter = String(defaultId);
|
|
||||||
|
|
||||||
function onKeyDown(e: KeyboardEvent) {
|
|
||||||
if (e.key === "Escape" && (sortPanelOpen || filterPanelOpen)) { sortPanelOpen = false; filterPanelOpen = false; return; }
|
|
||||||
if (e.key === "Escape" && selectMode) exitSelectMode();
|
|
||||||
if ((e.key === "a" && (e.metaKey || e.ctrlKey)) && selectMode) { e.preventDefault(); selectAll(); }
|
|
||||||
}
|
|
||||||
|
|
||||||
function onDocMouseDown(e: MouseEvent) {
|
|
||||||
const t = e.target as HTMLElement;
|
|
||||||
if (sortPanelOpen && !t.closest(".sort-panel-wrap, .sort-panel")) sortPanelOpen = false;
|
|
||||||
if (filterPanelOpen && !t.closest(".filter-panel-wrap, .filter-panel")) filterPanelOpen = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
window.addEventListener("keydown", onKeyDown);
|
|
||||||
document.addEventListener("mousedown", onDocMouseDown, true);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
ro.disconnect(); unsub();
|
|
||||||
cancelUpdate?.();
|
|
||||||
window.removeEventListener("keydown", onKeyDown);
|
|
||||||
document.removeEventListener("mousedown", onDocMouseDown, true);
|
|
||||||
};
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="root"
|
|
||||||
role="presentation"
|
|
||||||
bind:this={scrollEl}
|
|
||||||
use:rootLongPressAction
|
|
||||||
oncontextmenu={(e) => {
|
|
||||||
if ((e.target as HTMLElement).closest("button")) return;
|
|
||||||
e.preventDefault();
|
|
||||||
emptyCtx = { x: e.clientX - SIDEBAR_W, y: e.clientY - TITLEBAR_H };
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{#if store.settings.libraryBranches ?? true}
|
|
||||||
<svg class="branches" viewBox="0 0 400 600" preserveAspectRatio="xMaxYMid slice" aria-hidden="true">
|
|
||||||
<g stroke="var(--accent)" stroke-width="0.6" fill="none" opacity="0.13">
|
|
||||||
<path d="M380 600 C380 500 340 460 310 400 C280 340 300 280 270 220"/>
|
|
||||||
<path d="M270 220 C255 190 230 175 210 150"/>
|
|
||||||
<path d="M270 220 C290 195 310 185 330 165"/>
|
|
||||||
<path d="M310 400 C290 375 265 368 245 350"/>
|
|
||||||
<path d="M310 400 C330 370 355 362 370 340"/>
|
|
||||||
<path d="M210 150 C195 128 185 108 175 80"/>
|
|
||||||
<path d="M210 150 C225 130 240 122 258 105"/>
|
|
||||||
<path d="M245 350 C228 330 215 315 205 290"/>
|
|
||||||
<path d="M175 80 C168 60 162 42 158 20"/>
|
|
||||||
<path d="M175 80 C185 62 195 50 208 35"/>
|
|
||||||
<path d="M205 290 C196 268 190 250 186 225"/>
|
|
||||||
<path d="M258 105 C268 88 278 72 292 52"/>
|
|
||||||
<path class="anim-branch" d="M186 225 C180 205 176 185 174 160"/>
|
|
||||||
<path class="anim-branch" d="M292 52 C300 36 308 20 318 0"/>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if error}
|
|
||||||
<div class="center">
|
|
||||||
<p class="error-msg">Could not reach Suwayomi</p>
|
|
||||||
<p class="error-detail">Make sure the server is running, then retry.</p>
|
|
||||||
<button class="retry-btn" onclick={() => retryCount++}>Retry</button>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<LibraryToolbar
|
|
||||||
onclick={(e: MouseEvent) => { if (selectMode && !(e.target as HTMLElement).closest("button, input")) exitSelectMode(); }}
|
|
||||||
{tab}
|
|
||||||
{tabSortMode}
|
|
||||||
{tabSortDir}
|
|
||||||
{tabStatus}
|
|
||||||
{tabFilters}
|
|
||||||
{hasActiveFilters}
|
|
||||||
{anims}
|
|
||||||
{visibleCategories}
|
|
||||||
{visibleTabIds}
|
|
||||||
{virtualTabIds}
|
|
||||||
{folderTabIds}
|
|
||||||
{completedCatId}
|
|
||||||
{counts}
|
|
||||||
{search}
|
|
||||||
{refreshing}
|
|
||||||
{refreshProgress}
|
|
||||||
{refreshDone}
|
|
||||||
{refreshingCatId}
|
|
||||||
{activeDragKind}
|
|
||||||
{dragInsertIdx}
|
|
||||||
{dragTabId}
|
|
||||||
{dragOverTabId}
|
|
||||||
{sortPanelOpen}
|
|
||||||
{filterPanelOpen}
|
|
||||||
bind:tabsEl
|
|
||||||
onSearchChange={(v) => search = v}
|
|
||||||
onTabChange={(f) => store.libraryFilter = f}
|
|
||||||
onSortChange={(mode) => { setTabSort(tab, mode); sortPanelOpen = false; }}
|
|
||||||
onSortDirToggle={() => toggleTabSortDir(tab)}
|
|
||||||
onStatusChange={(s) => setTabStatus(tab, s)}
|
|
||||||
onFilterToggle={(f) => toggleTabFilter(tab, f)}
|
|
||||||
onFiltersClear={() => clearTabFilters(tab)}
|
|
||||||
onSortPanelToggle={() => { sortPanelOpen = !sortPanelOpen; filterPanelOpen = false; }}
|
|
||||||
onFilterPanelToggle={() => { filterPanelOpen = !filterPanelOpen; sortPanelOpen = false; }}
|
|
||||||
onRefresh={startLibraryRefresh}
|
|
||||||
onCancelRefresh={cancelLibraryRefresh}
|
|
||||||
onRefreshCategory={refreshCategory}
|
|
||||||
onOpenDownloadsFolder={openDownloadsFolder}
|
|
||||||
onTabDragStart={onTabDragStart}
|
|
||||||
onTabDragOver={onTabDragOver}
|
|
||||||
onTabDragLeave={onTabDragLeave}
|
|
||||||
onTabDrop={onTabDrop}
|
|
||||||
onTabDragEnd={onTabDragEnd}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{#if refreshing && refreshProgress.total > 0}
|
|
||||||
{@const pct = Math.round((refreshProgress.finished / refreshProgress.total) * 100)}
|
|
||||||
<div class="refresh-bar-wrap" aria-hidden="true">
|
|
||||||
<div class="refresh-bar-fill" style="width:{pct}%"></div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<LibraryGrid
|
|
||||||
{visibleManga}
|
|
||||||
{filtered}
|
|
||||||
{loading}
|
|
||||||
{cols}
|
|
||||||
{anims}
|
|
||||||
{selectMode}
|
|
||||||
{selectedIds}
|
|
||||||
{hasMore}
|
|
||||||
{remainingCount}
|
|
||||||
renderLimit={store.settings.renderLimit ?? 48}
|
|
||||||
cropCovers={store.settings.libraryCropCovers}
|
|
||||||
statsAlways={store.settings.libraryStatsAlways ?? false}
|
|
||||||
libraryFilter={tab}
|
|
||||||
onCardClick={onCardClick}
|
|
||||||
onCardContextMenu={openCtx}
|
|
||||||
onCardLongPress={cardLongPress}
|
|
||||||
onLoadMore={loadMore}
|
|
||||||
onRetry={() => retryCount++}
|
|
||||||
onExitSelectMode={exitSelectMode}
|
|
||||||
onSelectAll={selectAll}
|
|
||||||
onBulkMove={bulkMoveToCategory}
|
|
||||||
onBulkRemove={bulkRemoveFromLibrary}
|
|
||||||
onBulkAutomate={bulkAutomate}
|
|
||||||
{bulkWorking}
|
|
||||||
{visibleCategories}
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if ctx}
|
|
||||||
<ContextMenu x={ctx.x} y={ctx.y} items={buildCtxItems(ctx.manga)} onClose={() => ctx = null} />
|
|
||||||
{/if}
|
|
||||||
{#if emptyCtx}
|
|
||||||
<ContextMenu x={emptyCtx.x} y={emptyCtx.y} items={buildEmptyCtx()} onClose={() => emptyCtx = null} />
|
|
||||||
{/if}
|
|
||||||
{#if bulkAutomateOpen}
|
|
||||||
<BulkAutomationPanel
|
|
||||||
ids={selectedIds}
|
|
||||||
onClose={() => { bulkAutomateOpen = false; exitSelectMode(); }}
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.root { position: relative; display: flex; flex-direction: column; height: 100%; overflow: visible; animation: fadeIn 0.14s ease both; }
|
|
||||||
.branches { position: absolute; top: 0; right: 0; width: 400px; height: 600px; pointer-events: none; z-index: 0; }
|
|
||||||
.branches :global(.anim-branch) { stroke-dasharray: 60; stroke-dashoffset: 60; animation: branchGrow 2.4s ease forwards; }
|
|
||||||
.center { position: relative; z-index: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; height: 60%; color: var(--text-muted); font-size: var(--text-sm); gap: var(--sp-2); text-align: center; line-height: var(--leading-base); }
|
|
||||||
.error-msg { color: var(--color-error); font-size: var(--text-base); }
|
|
||||||
.error-detail { color: var(--text-faint); font-size: var(--text-sm); }
|
|
||||||
.retry-btn { margin-top: var(--sp-3); padding: 6px 16px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: var(--bg-raised); color: var(--text-muted); cursor: pointer; font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); }
|
|
||||||
.refresh-bar-wrap { height: 2px; background: var(--border-dim); flex-shrink: 0; overflow: hidden; }
|
|
||||||
.refresh-bar-fill { height: 100%; background: var(--accent); border-radius: 0 2px 2px 0; transition: width 0.6s ease; }
|
|
||||||
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
|
|
||||||
@keyframes branchGrow { to { stroke-dashoffset: 0; } }
|
|
||||||
</style>
|
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { Check, Funnel } from "phosphor-svelte";
|
|
||||||
import type { LibraryStatusFilter, LibraryContentFilter } from "@store/state.svelte";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
tabStatus: LibraryStatusFilter;
|
|
||||||
tabFilters: Partial<Record<LibraryContentFilter, boolean>>;
|
|
||||||
hasActiveFilters: boolean;
|
|
||||||
filterPanelOpen: boolean;
|
|
||||||
onStatusChange: (s: LibraryStatusFilter) => void;
|
|
||||||
onFilterToggle: (f: LibraryContentFilter) => void;
|
|
||||||
onFiltersClear: () => void;
|
|
||||||
onFilterPanelToggle: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
let {
|
|
||||||
tabStatus, tabFilters, hasActiveFilters, filterPanelOpen,
|
|
||||||
onStatusChange, onFilterToggle, onFiltersClear, onFilterPanelToggle,
|
|
||||||
}: Props = $props();
|
|
||||||
|
|
||||||
const STATUS_LABELS: Record<LibraryStatusFilter, string> = {
|
|
||||||
ALL: "All statuses", ONGOING: "Ongoing", COMPLETED: "Completed",
|
|
||||||
CANCELLED: "Cancelled", HIATUS: "Hiatus", UNKNOWN: "Unknown",
|
|
||||||
};
|
|
||||||
|
|
||||||
const ALL_STATUS_FILTERS: LibraryStatusFilter[] = [
|
|
||||||
"ALL", "ONGOING", "COMPLETED", "CANCELLED", "HIATUS", "UNKNOWN",
|
|
||||||
];
|
|
||||||
|
|
||||||
const CONTENT_FILTERS: [LibraryContentFilter, string][] = [
|
|
||||||
["unread", "Unread"],
|
|
||||||
["started", "Started"],
|
|
||||||
["downloaded", "Downloaded"],
|
|
||||||
["bookmarked", "Bookmarked"],
|
|
||||||
];
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="filter-panel-wrap">
|
|
||||||
<button
|
|
||||||
class="icon-btn"
|
|
||||||
class:icon-btn-active={hasActiveFilters}
|
|
||||||
title="Filter"
|
|
||||||
onclick={onFilterPanelToggle}
|
|
||||||
>
|
|
||||||
<Funnel size={15} weight={hasActiveFilters ? "fill" : "bold"} />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{#if filterPanelOpen}
|
|
||||||
<div class="dropdown-panel filter-panel" role="menu">
|
|
||||||
<div class="panel-header">
|
|
||||||
<span class="panel-heading">Filter</span>
|
|
||||||
{#if hasActiveFilters}
|
|
||||||
<button class="panel-clear-btn" onclick={onFiltersClear}>Clear all</button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<div class="panel-divider"></div>
|
|
||||||
|
|
||||||
<p class="panel-label">Content</p>
|
|
||||||
{#each CONTENT_FILTERS as [f, label]}
|
|
||||||
<button
|
|
||||||
class="panel-item panel-item-check"
|
|
||||||
class:panel-item-active={tabFilters[f]}
|
|
||||||
role="menuitem"
|
|
||||||
onclick={() => onFilterToggle(f)}
|
|
||||||
>
|
|
||||||
<span class="panel-check" class:panel-check-on={tabFilters[f]}>
|
|
||||||
{#if tabFilters[f]}<Check size={9} weight="bold" />{/if}
|
|
||||||
</span>
|
|
||||||
{label}
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
|
|
||||||
<div class="panel-divider"></div>
|
|
||||||
|
|
||||||
<p class="panel-label">Status</p>
|
|
||||||
{#each ALL_STATUS_FILTERS.filter(s => s !== "ALL") as s}
|
|
||||||
<button
|
|
||||||
class="panel-item panel-item-check"
|
|
||||||
class:panel-item-active={tabStatus === s}
|
|
||||||
role="menuitem"
|
|
||||||
onclick={() => onStatusChange(tabStatus === s ? "ALL" : s)}
|
|
||||||
>
|
|
||||||
<span class="panel-check" class:panel-check-on={tabStatus === s}>
|
|
||||||
{#if tabStatus === s}<Check size={9} weight="bold" />{/if}
|
|
||||||
</span>
|
|
||||||
{STATUS_LABELS[s]}
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.filter-panel-wrap { position: relative; }
|
|
||||||
.icon-btn { display: flex; align-items: center; justify-content: center; width: 30px; height: 30px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: var(--bg-raised); color: var(--text-faint); cursor: pointer; flex-shrink: 0; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
|
|
||||||
.icon-btn:hover { color: var(--text-primary); border-color: var(--border-strong); }
|
|
||||||
.icon-btn-active { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
|
|
||||||
.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-header { display: flex; align-items: center; justify-content: space-between; padding: 6px 10px 4px; }
|
|
||||||
.panel-heading { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); color: var(--text-secondary); font-weight: var(--weight-medium, 500); }
|
|
||||||
.panel-clear-btn { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--text-faint); background: none; border: none; cursor: pointer; padding: 0; transition: color var(--t-base); }
|
|
||||||
.panel-clear-btn:hover { color: var(--color-error); }
|
|
||||||
.panel-divider { height: 1px; background: var(--border-dim); margin: 4px 2px; }
|
|
||||||
.panel-label { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wider); text-transform: uppercase; color: var(--text-faint); padding: 4px 8px 8px; }
|
|
||||||
.panel-item { display: flex; align-items: center; justify-content: flex-start; gap: var(--sp-2); width: 100%; padding: 7px 10px; border-radius: var(--radius-sm); border: none; background: transparent; color: var(--text-muted); font-family: var(--font-ui); font-size: var(--text-xs); cursor: pointer; text-align: left; transition: background var(--t-base), color var(--t-base); }
|
|
||||||
.panel-item:hover { background: var(--bg-overlay); color: var(--text-primary); }
|
|
||||||
.panel-item-active { color: var(--accent-fg); background: var(--accent-muted); font-weight: var(--weight-medium, 500); }
|
|
||||||
.panel-item-active:hover { background: var(--accent-dim); }
|
|
||||||
.panel-item-check { justify-content: flex-start; }
|
|
||||||
.panel-check { width: 13px; height: 13px; border-radius: 2px; border: 1px solid var(--border-strong); background: transparent; flex-shrink: 0; transition: background var(--t-base), border-color var(--t-base); display: flex; align-items: center; justify-content: center; color: var(--bg-base); }
|
|
||||||
.panel-check-on { background: var(--accent); border-color: var(--accent); }
|
|
||||||
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
|
|
||||||
</style>
|
|
||||||
@@ -1,219 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { Folder, Trash, CheckSquare, Robot } from "phosphor-svelte";
|
|
||||||
import Thumbnail from "@shared/manga/Thumbnail.svelte";
|
|
||||||
import { resolvedCover } from "@core/cover/coverResolver";
|
|
||||||
import { longPress } from "@core/ui/touchscreen";
|
|
||||||
import type { Manga, Category } from "@types";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
visibleManga: Manga[];
|
|
||||||
filtered: Manga[];
|
|
||||||
loading: boolean;
|
|
||||||
cols: number;
|
|
||||||
anims: boolean;
|
|
||||||
selectMode: boolean;
|
|
||||||
selectedIds: Set<number>;
|
|
||||||
hasMore: boolean;
|
|
||||||
remainingCount: number;
|
|
||||||
renderLimit: number;
|
|
||||||
cropCovers: boolean;
|
|
||||||
statsAlways: boolean;
|
|
||||||
libraryFilter: string;
|
|
||||||
bulkWorking: boolean;
|
|
||||||
visibleCategories: Category[];
|
|
||||||
onCardClick: (e: MouseEvent, m: Manga) => void;
|
|
||||||
onCardContextMenu: (e: MouseEvent, m: Manga) => void;
|
|
||||||
onCardLongPress: (node: HTMLElement, m: Manga) => ReturnType<typeof longPress>;
|
|
||||||
onLoadMore: () => void;
|
|
||||||
onRetry: () => void;
|
|
||||||
onExitSelectMode: () => void;
|
|
||||||
onSelectAll: () => void;
|
|
||||||
onBulkMove: (cat: Category) => void;
|
|
||||||
onBulkRemove: () => void;
|
|
||||||
onBulkAutomate: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
let {
|
|
||||||
visibleManga, filtered, loading, cols, anims, selectMode, selectedIds,
|
|
||||||
hasMore, remainingCount, renderLimit, cropCovers, statsAlways, libraryFilter,
|
|
||||||
bulkWorking, visibleCategories,
|
|
||||||
onCardClick, onCardContextMenu, onCardLongPress,
|
|
||||||
onLoadMore, onRetry, onExitSelectMode, onSelectAll, onBulkMove, onBulkRemove, onBulkAutomate,
|
|
||||||
}: Props = $props();
|
|
||||||
|
|
||||||
let bulkMoveOpen: boolean = $state(false);
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (!bulkMoveOpen) return;
|
|
||||||
function onOutside(e: MouseEvent) {
|
|
||||||
if (!(e.target as HTMLElement).closest(".bulk-move-wrap")) bulkMoveOpen = false;
|
|
||||||
}
|
|
||||||
setTimeout(() => document.addEventListener("mousedown", onOutside, true), 0);
|
|
||||||
return () => document.removeEventListener("mousedown", onOutside, true);
|
|
||||||
});
|
|
||||||
|
|
||||||
$effect(() => { if (!selectMode) bulkMoveOpen = false; });
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#if selectMode}
|
|
||||||
<div class="select-bar">
|
|
||||||
<span class="sel-count">{selectedIds.size} selected</span>
|
|
||||||
<button class="sel-text-btn" onclick={onSelectAll} title="Select all (⌘A)">Select all</button>
|
|
||||||
|
|
||||||
<div class="select-bar-right">
|
|
||||||
{#if visibleCategories.length}
|
|
||||||
<div class="bulk-move-wrap">
|
|
||||||
<button
|
|
||||||
class="sel-action-btn"
|
|
||||||
disabled={selectedIds.size === 0 || bulkWorking}
|
|
||||||
onclick={() => bulkMoveOpen = !bulkMoveOpen}
|
|
||||||
>
|
|
||||||
<Folder size={13} weight="bold" />
|
|
||||||
Move
|
|
||||||
</button>
|
|
||||||
{#if bulkMoveOpen}
|
|
||||||
<div class="bulk-folder-list">
|
|
||||||
{#each visibleCategories as cat}
|
|
||||||
<button class="bulk-folder-item" onclick={() => { onBulkMove(cat); bulkMoveOpen = false; }}>
|
|
||||||
<Folder size={11} weight="bold" />
|
|
||||||
{cat.name}
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
<button class="sel-action-btn" disabled={selectedIds.size === 0 || bulkWorking} onclick={onBulkAutomate}>
|
|
||||||
<Robot size={13} weight="bold" />
|
|
||||||
Automate
|
|
||||||
</button>
|
|
||||||
<button class="sel-action-btn sel-action-danger" disabled={selectedIds.size === 0 || bulkWorking} onclick={onBulkRemove}>
|
|
||||||
<Trash size={13} weight="bold" />
|
|
||||||
Remove
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="content" role="presentation" onclick={(e) => { if (selectMode && !(e.target as HTMLElement).closest(".card")) onExitSelectMode(); }}>
|
|
||||||
{#if loading}
|
|
||||||
<div class="grid">
|
|
||||||
{#each Array(12) as _}
|
|
||||||
<div class="card-skeleton">
|
|
||||||
<div class="cover-skeleton skeleton"></div>
|
|
||||||
<div class="title-skeleton skeleton"></div>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{:else if filtered.length === 0}
|
|
||||||
<div class="center">
|
|
||||||
{libraryFilter === "library" ? "No manga saved to library — browse sources to add some."
|
|
||||||
: libraryFilter === "downloaded" ? "No downloaded manga."
|
|
||||||
: "No manga in this folder yet. Right-click manga anywhere to assign them."}
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div class="grid" style="--cols:{cols}">
|
|
||||||
{#each visibleManga as m (m.id)}
|
|
||||||
{@const isSelected = selectedIds.has(m.id)}
|
|
||||||
{@const isCompleted = !m.unreadCount && (m.chapters?.totalCount ?? 0) > 0}
|
|
||||||
<button
|
|
||||||
class="card"
|
|
||||||
class:card-selected={isSelected}
|
|
||||||
class:select-mode={selectMode}
|
|
||||||
class:anims={anims}
|
|
||||||
use:onCardLongPress={m}
|
|
||||||
onclick={(e) => onCardClick(e, m)}
|
|
||||||
oncontextmenu={(e) => onCardContextMenu(e, m)}
|
|
||||||
>
|
|
||||||
<div class="cover-wrap" class:completed={isCompleted}>
|
|
||||||
<Thumbnail src={resolvedCover(m.id, m.thumbnailUrl)} alt={m.title} class="cover" style="object-fit:{cropCovers ? 'cover' : 'contain'}" draggable="false" />
|
|
||||||
<div class="card-info-overlay" class:anim={anims} class:instant={!anims} class:always={statsAlways}>
|
|
||||||
<div class="overlay-badges">
|
|
||||||
{#if isCompleted}
|
|
||||||
<span class="badge badge-done">✓ Done</span>
|
|
||||||
{:else if m.unreadCount}
|
|
||||||
<span class="badge badge-unread">{m.unreadCount} new</span>
|
|
||||||
{/if}
|
|
||||||
{#if m.downloadCount}
|
|
||||||
<span class="badge badge-dl">↓ {m.downloadCount}</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{#if selectMode}
|
|
||||||
<div class="select-overlay" aria-hidden="true">
|
|
||||||
<div class="select-check" class:checked={isSelected}>
|
|
||||||
{#if isSelected}
|
|
||||||
<CheckSquare size={20} weight="fill" />
|
|
||||||
{:else}
|
|
||||||
<div class="select-check-empty"></div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<p class="title">{m.title}</p>
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{#if hasMore}
|
|
||||||
<div class="load-more-row">
|
|
||||||
<button class="load-more-btn" onclick={onLoadMore}>
|
|
||||||
Show {Math.min(remainingCount, renderLimit)} more
|
|
||||||
<span class="load-more-count">({remainingCount} remaining)</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.content { flex: 1; overflow-y: auto; padding: var(--sp-5) var(--sp-6) var(--sp-6); will-change: scroll-position; -webkit-overflow-scrolling: touch; }
|
|
||||||
.select-bar { display: flex; align-items: center; gap: var(--sp-2); padding: var(--sp-2) var(--sp-6); background: var(--bg-raised); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; animation: fadeIn 0.1s ease both; position: relative; z-index: 10; }
|
|
||||||
.select-bar-right { display: flex; align-items: center; gap: var(--sp-2); margin-left: auto; position: relative; }
|
|
||||||
.sel-count { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-secondary); letter-spacing: var(--tracking-wide); white-space: nowrap; }
|
|
||||||
.sel-text-btn { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); background: none; border: none; cursor: pointer; padding: 2px 4px; border-radius: var(--radius-sm); transition: color var(--t-base); }
|
|
||||||
.sel-text-btn:hover { color: var(--text-primary); }
|
|
||||||
.sel-action-btn { display: flex; align-items: center; gap: 5px; font-family: var(--font-ui); font-size: var(--text-xs); padding: 5px 10px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: var(--bg-raised); color: var(--text-muted); cursor: pointer; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); white-space: nowrap; }
|
|
||||||
.sel-action-btn:hover:not(:disabled) { color: var(--text-primary); border-color: var(--border-strong); }
|
|
||||||
.sel-action-btn:disabled { opacity: 0.35; cursor: not-allowed; }
|
|
||||||
.sel-action-danger:hover:not(:disabled) { color: var(--color-error, #e05c5c); border-color: color-mix(in srgb, var(--color-error, #e05c5c) 40%, transparent); background: color-mix(in srgb, var(--color-error, #e05c5c) 8%, transparent); }
|
|
||||||
.bulk-move-wrap { position: relative; }
|
|
||||||
.bulk-folder-list { position: absolute; top: calc(100% + 4px); right: 0; z-index: 9999; background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 4px; min-width: 160px; box-shadow: 0 8px 24px rgba(0,0,0,0.35); animation: fadeIn 0.1s ease both; }
|
|
||||||
.bulk-folder-item { display: flex; align-items: center; gap: 6px; width: 100%; padding: 6px 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); }
|
|
||||||
.bulk-folder-item:hover { background: var(--bg-hover, var(--bg-base)); color: var(--text-primary); }
|
|
||||||
.grid { position: relative; z-index: 1; isolation: isolate; display: grid; grid-template-columns: repeat(var(--cols, auto-fill), minmax(130px, 1fr)); gap: var(--sp-4); }
|
|
||||||
.card { background: none; border: none; padding: 0; cursor: pointer; text-align: left; }
|
|
||||||
.card.anims:not(.select-mode):hover .cover-wrap { transform: translateY(-3px); border-color: var(--border-strong); box-shadow: 0 6px 20px rgba(0,0,0,0.35); }
|
|
||||||
.card:not(.select-mode):hover .title { color: var(--text-primary); }
|
|
||||||
.card.select-mode { cursor: default; }
|
|
||||||
.card.card-selected .cover-wrap { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: var(--radius-md); }
|
|
||||||
.card.card-selected .title { color: var(--accent-fg); }
|
|
||||||
.cover-wrap { position: relative; aspect-ratio: 2/3; overflow: hidden; border-radius: var(--radius-md); background: var(--bg-raised); border: 1px solid var(--border-dim); will-change: transform; }
|
|
||||||
.card.anims .cover-wrap { transition: transform 0.18s cubic-bezier(0.16,1,0.3,1), border-color var(--t-base), box-shadow 0.18s cubic-bezier(0.16,1,0.3,1); }
|
|
||||||
.cover-wrap.completed { box-shadow: inset 0 -2px 0 0 var(--accent); }
|
|
||||||
.card-info-overlay { position: absolute; bottom: -4px; left: 0; right: 0; z-index: 2; padding: 32px 6px 10px; background: linear-gradient(to top, rgba(0,0,0,0.88) 0%, rgba(0,0,0,0.5) 50%, transparent 100%); opacity: 0; pointer-events: none; }
|
|
||||||
.card-info-overlay.anim { transition: opacity 0.18s ease; }
|
|
||||||
.card-info-overlay.instant { transition: none; }
|
|
||||||
.card-info-overlay.always { opacity: 1; }
|
|
||||||
.card:not(.select-mode):hover .card-info-overlay { opacity: 1; }
|
|
||||||
.overlay-badges { display: flex; align-items: flex-end; justify-content: space-between; gap: 4px; flex-wrap: wrap; }
|
|
||||||
.badge { font-family: var(--font-ui); font-size: 9.5px; font-weight: 700; letter-spacing: 0.04em; line-height: 1; padding: 3px 7px; border-radius: 20px; white-space: nowrap; }
|
|
||||||
.badge-unread { background: var(--accent); color: #fff; box-shadow: 0 1px 8px rgba(0,0,0,0.5); }
|
|
||||||
.badge-done { background: rgba(255,255,255,0.18); color: rgba(255,255,255,0.9); border: 1px solid rgba(255,255,255,0.25); }
|
|
||||||
.badge-dl { background: rgba(0,0,0,0.55); color: rgba(255,255,255,0.8); border: 1px solid rgba(255,255,255,0.18); margin-left: auto; }
|
|
||||||
.select-overlay { position: absolute; inset: 0; z-index: 3; background: rgba(0,0,0,0.18); display: flex; align-items: flex-start; justify-content: flex-end; padding: 6px; pointer-events: none; }
|
|
||||||
.select-check { color: var(--text-faint); opacity: 0.7; transition: color var(--t-base), opacity var(--t-base); }
|
|
||||||
.select-check.checked { color: var(--accent-fg); opacity: 1; }
|
|
||||||
.select-check-empty { width: 20px; height: 20px; border-radius: 4px; border: 2px solid var(--text-faint); background: rgba(0,0,0,0.3); }
|
|
||||||
.title { margin-top: var(--sp-2); font-size: var(--text-sm); color: var(--text-secondary); line-height: var(--leading-snug); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; height: 2lh; }
|
|
||||||
.card.anims .title { transition: color var(--t-base); }
|
|
||||||
.card-skeleton { padding: 0; }
|
|
||||||
.cover-skeleton { aspect-ratio: 2/3; border-radius: var(--radius-md); }
|
|
||||||
.title-skeleton { height: 12px; margin-top: var(--sp-2); width: 80%; border-radius: var(--radius-sm); }
|
|
||||||
.load-more-row { display: flex; justify-content: center; padding: var(--sp-5) 0 var(--sp-2); position: relative; z-index: 1; }
|
|
||||||
.load-more-btn { display: flex; align-items: center; gap: var(--sp-2); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 8px 20px; border-radius: var(--radius-full); border: 1px solid var(--border-dim); background: var(--bg-raised); color: var(--text-muted); cursor: pointer; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
|
|
||||||
.load-more-btn:hover { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
|
|
||||||
.load-more-count { color: var(--text-faint); font-size: var(--text-2xs); }
|
|
||||||
.center { position: relative; z-index: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; height: 60%; color: var(--text-muted); font-size: var(--text-sm); gap: var(--sp-2); text-align: center; line-height: var(--leading-base); }
|
|
||||||
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
|
|
||||||
</style>
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
export { default as Library } from "./components/Library.svelte";
|
|
||||||
export { sortLibrary, librarySorter } from "./lib/librarySort";
|
|
||||||
export * from "./store/libraryState.svelte";
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
import { createSorter } from "@core/algorithms/sort";
|
|
||||||
import type { Manga } from "@types";
|
|
||||||
import type { LibrarySortMode, LibrarySortDir } from "@store/state.svelte";
|
|
||||||
|
|
||||||
export const librarySorter = createSorter<Manga>({
|
|
||||||
defaultField: "az",
|
|
||||||
defaultDir: "asc",
|
|
||||||
fields: [
|
|
||||||
{
|
|
||||||
key: "az",
|
|
||||||
comparator: (a, b) => a.title.localeCompare(b.title, undefined, { sensitivity: "base" }),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "unreadCount",
|
|
||||||
comparator: (a, b) => (a.unreadCount ?? 0) - (b.unreadCount ?? 0),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "totalChapters",
|
|
||||||
comparator: (a, b) => (a.chapters?.totalCount ?? 0) - (b.chapters?.totalCount ?? 0),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "recentlyAdded",
|
|
||||||
comparator: (a, b) => Number(a.inLibraryAt ?? 0) - Number(b.inLibraryAt ?? 0),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "recentlyRead",
|
|
||||||
comparator: (a, b, ctx) => {
|
|
||||||
const map = ctx?.recentlyReadMap as Map<number, number> | undefined;
|
|
||||||
const ra = map?.get(a.id) ?? 0;
|
|
||||||
const rb = map?.get(b.id) ?? 0;
|
|
||||||
return ra - rb;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "latestFetched",
|
|
||||||
comparator: (a, b) => Number(a.latestFetchedChapter?.uploadDate ?? 0) - Number(b.latestFetchedChapter?.uploadDate ?? 0),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "latestUploaded",
|
|
||||||
comparator: (a, b) => Number(a.latestUploadedChapter?.uploadDate ?? 0) - Number(b.latestUploadedChapter?.uploadDate ?? 0),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
export function sortLibrary(
|
|
||||||
items: Manga[],
|
|
||||||
mode: LibrarySortMode,
|
|
||||||
dir: LibrarySortDir,
|
|
||||||
recentlyReadMap?: Map<number, number>,
|
|
||||||
): Manga[] {
|
|
||||||
return librarySorter.sort(items, mode, dir, recentlyReadMap ? { recentlyReadMap } : undefined);
|
|
||||||
}
|
|
||||||
@@ -1,166 +0,0 @@
|
|||||||
import { gql } from "@api/client";
|
|
||||||
import { LIBRARY_UPDATE_STATUS } from "@api/queries/manga";
|
|
||||||
import { UPDATE_LIBRARY, FETCH_MANGA } from "@api/mutations/manga";
|
|
||||||
import { GET_LIBRARY } from "@api/queries/manga";
|
|
||||||
import type { LibraryUpdateEntry } from "@store/state.svelte";
|
|
||||||
|
|
||||||
const POLL_INTERVAL_MS = 2000;
|
|
||||||
const POLL_INITIAL_MS = 500;
|
|
||||||
|
|
||||||
export interface UpdateProgress {
|
|
||||||
finished: number;
|
|
||||||
total: number;
|
|
||||||
skippedManga: number;
|
|
||||||
skippedCategories: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UpdateResult {
|
|
||||||
entries: LibraryUpdateEntry[];
|
|
||||||
totalUpdated: number;
|
|
||||||
newChapters: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LibraryUpdaterCallbacks {
|
|
||||||
onProgress: (p: UpdateProgress) => void;
|
|
||||||
onDone: (r: UpdateResult) => void;
|
|
||||||
onError: (e?: unknown) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function refreshLibraryMetadata(
|
|
||||||
onProgress?: (done: number, total: number) => void,
|
|
||||||
): Promise<void> {
|
|
||||||
const data = await gql<{ mangas: { nodes: { id: number }[] } }>(GET_LIBRARY, {});
|
|
||||||
const ids = data.mangas.nodes.map(m => m.id);
|
|
||||||
let done = 0;
|
|
||||||
for (const id of ids) {
|
|
||||||
try {
|
|
||||||
await gql(FETCH_MANGA, { id });
|
|
||||||
} catch {}
|
|
||||||
onProgress?.(++done, ids.length);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function startLibraryUpdate(callbacks: LibraryUpdaterCallbacks): () => void {
|
|
||||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
||||||
let cancelled = false;
|
|
||||||
|
|
||||||
function cancel() {
|
|
||||||
cancelled = true;
|
|
||||||
if (timer) { clearTimeout(timer); timer = null; }
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildEntries(
|
|
||||||
mangaUpdates: { status: string; manga: { id: number; title: string; thumbnailUrl: string; unreadCount: number } }[]
|
|
||||||
): LibraryUpdateEntry[] {
|
|
||||||
const byManga = new Map<number, LibraryUpdateEntry>();
|
|
||||||
for (const u of mangaUpdates) {
|
|
||||||
if (u.status !== "UPDATED") continue;
|
|
||||||
const existing = byManga.get(u.manga.id);
|
|
||||||
if (existing) {
|
|
||||||
existing.newChapters++;
|
|
||||||
} else {
|
|
||||||
byManga.set(u.manga.id, {
|
|
||||||
mangaId: u.manga.id,
|
|
||||||
mangaTitle: u.manga.title,
|
|
||||||
thumbnailUrl: u.manga.thumbnailUrl,
|
|
||||||
newChapters: 1,
|
|
||||||
checkedAt: Date.now(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return [...byManga.values()];
|
|
||||||
}
|
|
||||||
|
|
||||||
async function run() {
|
|
||||||
let jobsStarted = false;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await gql<{
|
|
||||||
updateLibrary: {
|
|
||||||
updateStatus: {
|
|
||||||
jobsInfo: {
|
|
||||||
isRunning: boolean;
|
|
||||||
totalJobs: number;
|
|
||||||
finishedJobs: number;
|
|
||||||
skippedMangasCount: number;
|
|
||||||
skippedCategoriesCount: number;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}>(UPDATE_LIBRARY, {});
|
|
||||||
if (cancelled) return;
|
|
||||||
|
|
||||||
const { jobsInfo } = res.updateLibrary.updateStatus;
|
|
||||||
jobsStarted = jobsInfo.totalJobs > 0;
|
|
||||||
|
|
||||||
callbacks.onProgress({
|
|
||||||
finished: jobsInfo.finishedJobs,
|
|
||||||
total: jobsInfo.totalJobs,
|
|
||||||
skippedManga: jobsInfo.skippedMangasCount,
|
|
||||||
skippedCategories: jobsInfo.skippedCategoriesCount,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!jobsStarted) {
|
|
||||||
callbacks.onDone({ entries: [], totalUpdated: 0, newChapters: 0 });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (jobsStarted && !jobsInfo.isRunning) {
|
|
||||||
callbacks.onDone({ entries: [], totalUpdated: 0, newChapters: 0 });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error("[libraryUpdater] failed to start update", e);
|
|
||||||
if (!cancelled) callbacks.onError(e);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
function poll() {
|
|
||||||
gql<{
|
|
||||||
libraryUpdateStatus: {
|
|
||||||
jobsInfo: {
|
|
||||||
isRunning: boolean;
|
|
||||||
finishedJobs: number;
|
|
||||||
totalJobs: number;
|
|
||||||
skippedMangasCount: number;
|
|
||||||
skippedCategoriesCount: number;
|
|
||||||
};
|
|
||||||
mangaUpdates: {
|
|
||||||
status: string;
|
|
||||||
manga: { id: number; title: string; thumbnailUrl: string; unreadCount: number };
|
|
||||||
}[];
|
|
||||||
}
|
|
||||||
}>(LIBRARY_UPDATE_STATUS, {})
|
|
||||||
.then(async d => {
|
|
||||||
if (cancelled) return;
|
|
||||||
const { jobsInfo, mangaUpdates } = d.libraryUpdateStatus;
|
|
||||||
|
|
||||||
if (jobsInfo.totalJobs > 0) jobsStarted = true;
|
|
||||||
callbacks.onProgress({
|
|
||||||
finished: jobsInfo.finishedJobs,
|
|
||||||
total: jobsInfo.totalJobs,
|
|
||||||
skippedManga: jobsInfo.skippedMangasCount,
|
|
||||||
skippedCategories: jobsInfo.skippedCategoriesCount,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!jobsInfo.isRunning && jobsStarted) {
|
|
||||||
const entries = buildEntries(mangaUpdates);
|
|
||||||
const newChapters = entries.reduce((s, e) => s + e.newChapters, 0);
|
|
||||||
callbacks.onDone({ entries, totalUpdated: entries.length, newChapters });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
timer = setTimeout(poll, POLL_INTERVAL_MS);
|
|
||||||
})
|
|
||||||
.catch((e) => {
|
|
||||||
console.error("[libraryUpdater] poll error", e);
|
|
||||||
if (!cancelled) callbacks.onError(e);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
timer = setTimeout(poll, POLL_INITIAL_MS);
|
|
||||||
}
|
|
||||||
|
|
||||||
run();
|
|
||||||
return cancel;
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user