Compare commits

...

46 Commits

Author SHA1 Message Date
Youwes09 244447da9b Feat: Backtracing + NavPage Store 2026-05-10 04:31:27 -05:00
Youwes09 f05f781b5b Fix: Biometric Revision V1 2026-05-10 03:00:08 -05:00
Youwes09 f7c5aebf29 Fix: PerformanceSettings RenderLimit CSS Revision (#63) 2026-05-10 02:50:48 -05:00
Youwes09 e09ae9d2e7 Fix: Respect Page-Order in Loading & Memory Eviction (#61, #63, #68) 2026-05-10 02:17:25 -05:00
Youwes09 7b2ae74c02 Fix: Trigger Recently-Fetched Data for RecentActivity (#63) 2026-05-03 13:06:02 -05:00
Youwes09 0d53e3f102 Fix: Attempt to Improve UI-Login Cache (#63) 2026-05-03 12:29:20 -05:00
Youwes09 093b395cc1 Fix: Re-Try UI Login Token + GQL Wait 2026-05-03 11:47:18 -05:00
Youwes09 efdd8ff95d Fix: Re-Register Settings Export Function (#63) 2026-05-03 11:35:09 -05:00
Youwes09 c0f0ff9bd3 Fix: TrackingSync Excludes Decimals & Respects Chapter Numbers (#63) 2026-05-02 18:14:34 -05:00
Youwes09 3f6049c12d Fix: Remove Scroll Propagation in Reader (#63) 2026-05-02 18:06:37 -05:00
Youwes09 5451a2654b Fix: Wrap ReaderControls in Scrollable (#63) 2026-05-02 17:58:16 -05:00
Youwes09 e625755c5e Fix: Library Folders Clipping (Anim Removed) (#63) 2026-05-02 17:51:54 -05:00
Youwes09 bd95bf4eb1 Fix: Added Download Toggles to Global-Store (#63) 2026-05-02 17:40:07 -05:00
Youwes09 b4d680ddd1 Fix: Error-Handling & ScrollBox on TrackingSettings (#63) 2026-05-02 17:34:54 -05:00
Youwes09 d1b7429b5d Fix: FolderSettings Revamp & Folders (#63) 2026-05-02 17:23:47 -05:00
Youwes09 000195be89 Fix: State-Based Issues & AboutSettings (WIP) 2026-05-02 16:53:50 -05:00
Youwes09 399d429142 Fix: Rust-Cleanup & Flake-SHA Patch 2026-05-01 11:32:29 -05:00
Youwes09 b79ee99e8a Fix: Linked CORS Bypass to UI-LOGIN 2026-05-01 11:09:29 -05:00
Youwes09 80c4b9d9be Chore: Update pnpm-tauri Packages 2026-05-01 01:14:39 -05:00
Youwes09 4584e6e69e Chore: Post-Bump for v0.9.2 2026-05-01 01:09:41 -05:00
Youwes09 83711c155d Chore: Prepare & Update for v0.9.2 2026-05-01 01:07:10 -05:00
Youwes09 3702a25813 Chore: Patch Biometrics 2026-05-01 05:56:39 -05:00
Youwes09 a71cc719ba Chore: Patch all Svelte-Warnings & Add Aria-Labels 2026-05-01 00:38:15 -05:00
Youwes09 1801fecdbb Feat: Windows-Hello Testing (DevTools Only) 2026-05-01 00:09:49 -05:00
Youwes09 0cd799f450 Fix: Cap ReaderSettings Zoom Value to 100 2026-04-30 23:11:39 -05:00
Youwes09 5dab7761bc Feat: Update TrackingPanel 2026-04-30 22:48:58 -05:00
Youwes09 552a11a517 Feat: Library-Refresh Overhaul & Settings Re-Wiring 2026-04-30 22:42:59 -05:00
Youwes09 c8ec6d6b90 Feat: Update Suwayomi (Stable -> Preview) + UI Login 2026-04-30 22:02:45 -05:00
Youwes09 daaeae00fe Fix: Patch Flake & PKGBUILD for Preview 2026-04-30 01:19:55 -05:00
Youwes09 79cb2f7c56 Feat: Shift from Stable to Preview (WIP) 2026-04-30 01:04:56 -05:00
Youwes09 4d3dfdbec6 Feat: Settings Reset, Data Clear, Date Fixes (#56) 2026-04-29 21:07:53 -05:00
Youwes09 78573eacb1 Feat: TouchScreen Support for SeriesDetail & Modularity Revamp (#29 2026-04-29 18:40:41 -05:00
Youwes09 1bb7da3b22 Merge branch 'main' of github.com:moku-project/Moku 2026-04-29 18:18:41 -05:00
Youwes09 dd0cf9372d Feat: Implement CSS for Chrome & Link to Context-Menu 2026-04-29 18:18:21 -05:00
Shozikan 50928c6343 Chore: Commit to Downloads in README 2026-04-29 11:22:54 -05:00
Youwes09 170493aa71 Feat: Implement Storage-based (JSON) Settings & Data-Storage (WIP) (#56) 2026-04-29 00:18:09 -05:00
Youwes09 c009bd71fc Fix: Optimized KeywordTab with BlobURL like TagTab 2026-04-28 23:45:35 -05:00
Youwes09 4df7f416a7 Feat: Revamped Content-Filtering + Levels & Source-Based Toggle 2026-04-28 23:22:29 -05:00
Youwes09 63209cb828 Fix: Futile Attempt to Implement Image-Dedupe (#55) 2026-04-28 22:45:19 -05:00
Youwes09 2c1391c378 Fix: Integrate Sync-Back on Tracker Addition (#52) 2026-04-28 22:24:37 -05:00
Youwes09 41fd4a820c Fix: Revert Logic for TrackingPanel (#58) 2026-04-28 22:17:16 -05:00
Youwes09 9f3c6d2ac3 Fix: LibraryGrid with Z-Index Applied (#57) 2026-04-28 22:09:36 -05:00
Youwes09 f5b3f76b5d Fix: Search Titles Overlay (Z-Index Applied) 2026-04-28 11:58:42 -05:00
Youwes09 528f966b1f Chore: Refactor Rust-Backend Modularity 2026-04-28 10:54:52 -05:00
Youwes09 75e8bc5986 Feat: Cover-Image Switching & Auto-Link (#55) 2026-04-28 01:22:36 -05:00
Youwes09 8123053a40 Chore: Update to Version 0.9.1 (V3) 2026-04-27 23:45:44 -05:00
136 changed files with 7319 additions and 5323 deletions
+2 -2
View File
@@ -88,10 +88,10 @@ jobs:
- name: Download Suwayomi (Linux x64) - name: Download Suwayomi (Linux x64)
run: | run: |
curl -fsSL \ curl -fsSL \
"https://github.com/Suwayomi/Suwayomi-Server/releases/download/v2.1.1867/Suwayomi-Server-v2.1.1867-linux-x64.tar.gz" \ "https://github.com/Suwayomi/Suwayomi-Server-preview/releases/download/v2.1.2087/Suwayomi-Server-v2.1.2087-linux-x64.tar.gz" \
-o suwayomi-linux.tar.gz -o suwayomi-linux.tar.gz
echo "b2344bd73c4e26bede63cdb4b44b1b4168d8a8500b3b2b1a0219519a3ef708fe suwayomi-linux.tar.gz" | sha256sum -c - echo "888bee202649ce7e3e3468a729c4084fb465f024b4033cab3f8ab98b0c66fe76 suwayomi-linux.tar.gz" | sha256sum -c -
mkdir -p suwayomi-extracted mkdir -p suwayomi-extracted
tar -xzf suwayomi-linux.tar.gz -C suwayomi-extracted --strip-components=1 tar -xzf suwayomi-linux.tar.gz -C suwayomi-extracted --strip-components=1
+5 -5
View File
@@ -79,7 +79,7 @@ jobs:
download_suwayomi() { download_suwayomi() {
local asset="$1" sha="$2" outdir="$3" local asset="$1" sha="$2" outdir="$3"
curl -fsSL \ curl -fsSL \
"https://github.com/Suwayomi/Suwayomi-Server/releases/download/v2.1.1867/${asset}" \ "https://github.com/Suwayomi/Suwayomi-Server-preview/releases/download/v2.1.2087/${asset}" \
-o "${outdir}.tar.gz" -o "${outdir}.tar.gz"
echo "${sha} ${outdir}.tar.gz" | shasum -a 256 -c - echo "${sha} ${outdir}.tar.gz" | shasum -a 256 -c -
mkdir -p "${outdir}" mkdir -p "${outdir}"
@@ -87,13 +87,13 @@ jobs:
} }
download_suwayomi \ download_suwayomi \
"Suwayomi-Server-v2.1.1867-macOS-arm64.tar.gz" \ "Suwayomi-Server-v2.1.2087-macOS-arm64.tar.gz" \
"c80abdbba29f7895e9556c6c9481368557d5f930b5f69bcb30639ba498925f3c" \ "59f73a53a139d5d843e16cab4f3ac425a410add6bee0a60920fa26eb0a4b8a5c" \
"suwayomi-arm64" "suwayomi-arm64"
download_suwayomi \ download_suwayomi \
"Suwayomi-Server-v2.1.1867-macOS-x64.tar.gz" \ "Suwayomi-Server-v2.1.2087-macOS-x64.tar.gz" \
"c7590aeb645dd7135a05b9f3ea1fee384a4abeb465c0b3638d5b738d20dfe174" \ "da7e664e4c2615a0b9eac09ee38fe979feee1d6c0b266e19dba1ceea8ae3795c" \
"suwayomi-x64" "suwayomi-x64"
- name: Stage Suwayomi sidecars - name: Stage Suwayomi sidecars
+2 -2
View File
@@ -79,9 +79,9 @@ jobs:
shell: bash shell: bash
run: | run: |
curl -fsSL \ curl -fsSL \
"https://github.com/Suwayomi/Suwayomi-Server/releases/download/v2.1.1867/Suwayomi-Server-v2.1.1867-windows-x64.zip" \ "https://github.com/Suwayomi/Suwayomi-Server-preview/releases/download/v2.1.2087/Suwayomi-Server-v2.1.2087-windows-x64.zip" \
-o suwayomi-windows.zip -o suwayomi-windows.zip
echo "ab6687d278e0dd0984f67abbc853511a7e764f84b126a35d09bfd9b0307321ff suwayomi-windows.zip" | sha256sum -c - echo "65c3ec544190bc4e52f8ba05b49c87448421d9825aaaeb902cb4e34e69ff7207 suwayomi-windows.zip" | sha256sum -c -
unzip -q suwayomi-windows.zip -d suwayomi-raw unzip -q suwayomi-windows.zip -d suwayomi-raw
- name: Extract Suwayomi bundle - name: Extract Suwayomi bundle
+12 -12
View File
@@ -1,5 +1,5 @@
pkgname=moku pkgname=moku
pkgver=0.9.1 pkgver=0.9.2
pkgrel=1 pkgrel=1
pkgdesc="Native Linux manga reader frontend for Suwayomi-Server" pkgdesc="Native Linux manga reader frontend for Suwayomi-Server"
arch=('x86_64') arch=('x86_64')
@@ -19,11 +19,11 @@ makedepends=(
) )
source=( source=(
"$pkgname-$pkgver.tar.gz::https://github.com/moku-project/Moku/archive/refs/tags/v$pkgver.tar.gz" "$pkgname-$pkgver.tar.gz::https://github.com/moku-project/Moku/archive/refs/tags/v$pkgver.tar.gz"
"Suwayomi-Server-v2.1.1867.jar::https://github.com/Suwayomi/Suwayomi-Server/releases/download/v2.1.1867/Suwayomi-Server-v2.1.1867.jar" "Suwayomi-Server-v2.1.2087.jar::https://github.com/Suwayomi/Suwayomi-Server-preview/releases/download/v2.1.2087/Suwayomi-Server-v2.1.2087.jar"
) )
sha256sums=( sha256sums=(
'SKIP' 'e7f3d70c81af2afd9933aab55372a8b0122bfd201dcf6077a61f2c69990aecf9'
'51e307c2581e4e1a002991ab3e3a77503c8b074c42695987a984a7382d0ac5af' 'f589a422674252394c13b289a9c8be691905bf583efb7f4d5f1501ae5e91e6b3'
) )
prepare() { prepare() {
@@ -45,14 +45,14 @@ package() {
install -Dm755 src-tauri/target/release/moku \ install -Dm755 src-tauri/target/release/moku \
"$pkgdir/usr/bin/moku" "$pkgdir/usr/bin/moku"
install -Dm644 "$srcdir/Suwayomi-Server-v2.1.1867.jar" \ install -Dm644 "$srcdir/Suwayomi-Server-v2.1.2087.jar" \
"$pkgdir/usr/lib/moku/tachidesk/Suwayomi-Server.jar" "$pkgdir/usr/lib/moku/tachidesk/Suwayomi-Server.jar"
install -dm755 "$pkgdir/usr/lib/moku/tachidesk/default-conf" install -dm755 "$pkgdir/usr/lib/moku/tachidesk/default-conf"
cat > "$pkgdir/usr/lib/moku/tachidesk/default-conf/server.conf" << 'EOF' cat > "$pkgdir/usr/lib/moku/tachidesk/default-conf/server.conf" << 'CONF'
server.ip = "127.0.0.1" server.ip = "127.0.0.1"
server.port = 4567 server.port = 4567
server.webUIEnabled = false server.webUIEnabled = true
server.initialOpenInBrowserEnabled = false server.initialOpenInBrowserEnabled = false
server.systemTrayEnabled = false server.systemTrayEnabled = false
server.downloadAsCbz = true server.downloadAsCbz = true
@@ -60,11 +60,11 @@ server.autoDownloadNewChapters = false
server.globalUpdateInterval = 12 server.globalUpdateInterval = 12
server.maxSourcesInParallel = 6 server.maxSourcesInParallel = 6
server.extensionRepos = [] server.extensionRepos = []
EOF CONF
install -Dm755 /dev/stdin "$pkgdir/usr/bin/moku-suwayomi" << 'EOF' install -Dm755 /dev/stdin "$pkgdir/usr/bin/moku-suwayomi" << 'LAUNCHER'
#!/bin/sh #!/bin/sh
DATA_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/moku/tachidesk" DATA_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/Tachidesk"
mkdir -p "$DATA_DIR" mkdir -p "$DATA_DIR"
if [ ! -f "$DATA_DIR/server.conf" ]; then if [ ! -f "$DATA_DIR/server.conf" ]; then
@@ -93,7 +93,7 @@ exec java \
-Dsun.awt.disablegui=true \ -Dsun.awt.disablegui=true \
-Dsuwayomi.tachidesk.config.server.rootDir="$DATA_DIR" \ -Dsuwayomi.tachidesk.config.server.rootDir="$DATA_DIR" \
-jar /usr/lib/moku/tachidesk/Suwayomi-Server.jar -jar /usr/lib/moku/tachidesk/Suwayomi-Server.jar
EOF LAUNCHER
install -Dm644 packaging/io.github.moku_project.Moku.desktop \ install -Dm644 packaging/io.github.moku_project.Moku.desktop \
"$pkgdir/usr/share/applications/io.github.moku_project.Moku.desktop" "$pkgdir/usr/share/applications/io.github.moku_project.Moku.desktop"
@@ -107,4 +107,4 @@ EOF
"$pkgdir/usr/share/metainfo/io.github.moku_project.Moku.metainfo.xml" "$pkgdir/usr/share/metainfo/io.github.moku_project.Moku.metainfo.xml"
install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE" install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
} }
+1 -1
View File
@@ -5,7 +5,7 @@
<div align="center"> <div align="center">
[![Release](https://www.shieldcn.dev/github/release/moku-project/Moku.svg?variant=outline&size=default)](https://github.com/moku-project/Moku/releases/latest) [![Release](https://www.shieldcn.dev/github/release/moku-project/Moku.svg?variant=outline&size=default)](https://github.com/moku-project/Moku/releases/latest)
[![Last Commit](https://www.shieldcn.dev/github/last-commit/moku-project/Moku.svg?variant=outline&size=default)](https://github.com/moku-project/Moku/commits/main) ![GitHub Downloads](https://www.shieldcn.dev/github/downloads/moku-project/Moku.svg?variant=outline&size=default)
[![Stars](https://www.shieldcn.dev/github/stars/moku-project/Moku.svg?variant=outline&size=default)](https://github.com/moku-project/Moku) [![Stars](https://www.shieldcn.dev/github/stars/moku-project/Moku.svg?variant=outline&size=default)](https://github.com/moku-project/Moku)
[![Discord](https://www.shieldcn.dev/discord/members/x97hj8zR72.svg?variant=outline&size=default)](https://discord.gg/x97hj8zR72) [![Discord](https://www.shieldcn.dev/discord/members/x97hj8zR72.svg?variant=outline&size=default)](https://discord.gg/x97hj8zR72)
+11 -11
View File
@@ -7,10 +7,7 @@ Major Revisions:
Minor Revisions: Minor Revisions:
- Investigate feasibility of Multi-Page Screenshot (Reader) - Investigate feasibility of Multi-Page Screenshot (Reader)
- Add Hover Info on Library (Make sure doesn't conflict with additional clicks)
- Revise Migration (https://github.com/Suwayomi/Suwayomi-WebUI/pull/1073) - Revise Migration (https://github.com/Suwayomi/Suwayomi-WebUI/pull/1073)
- Look at how Manga are Organized in WebUI and Implement into Series-Detail (Chapter Display is Off)
Priority Bugs: Priority Bugs:
- Fix Library-Refresh System (TESTING) - Fix Library-Refresh System (TESTING)
@@ -19,21 +16,24 @@ Priority Bugs:
- Allow User to Wipe Suwayomi (Scratch) - Allow User to Wipe Suwayomi (Scratch)
- If Possible, Component based Wipe (Library, Etc) - If Possible, Component based Wipe (Library, Etc)
Pending/On-Hold:
In-Progress: - Enable Cloudflare Bypass (Suwayomi Config) (Requires Patching)
- Enable Cloudflare Bypass (Suwayomi Config) (Requires Patching)
- Working on 3D Display Cards - Working on 3D Display Cards
- Add Flathub Support (Pending Video) - 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 - Fix Tracking Login
- Pasting OAuth URL is not User-Friendly, Look for Alternatives - Pasting OAuth URL is not User-Friendly, Look for Alternatives
- Tracking - Apply Syer's Fix for Library on Backup Load (Manga Metadata)
- Fix SeriesDetail Tracking Window (Maybe Link to TrackingPanel) - Note User's have to always install extensions manually
- Create "Missing Source" for Manga
- Hide Completed from Library Settting
- Wire Series-Detail Refresh to Fix Manga-Metadata (MAJOR)
- UI LOGIN DOES NOT WORK OFFLINE
Notes from last time: Notes from last time:
- Currently working on #42, just need to mount panel and fix button in reader
+50 -263
View File
@@ -2,11 +2,11 @@
description = "Moku manga reader frontend for Suwayomi"; description = "Moku manga reader frontend for Suwayomi";
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"; 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";
}; };
}; };
@@ -14,11 +14,15 @@
outputs = outputs =
inputs@{ flake-parts, crane, rust-overlay, ... }: inputs@{ flake-parts, crane, rust-overlay, ... }:
flake-parts.lib.mkFlake { inherit inputs; } { flake-parts.lib.mkFlake { inherit inputs; } {
systems = [ "x86_64-linux" "aarch64-linux" ]; systems = [
"x86_64-linux"
"aarch64-linux"
];
perSystem = { system, lib, ... }: perSystem =
{ system, lib, ... }:
let let
version = "0.9.1"; version = "0.9.2";
pkgs = import inputs.nixpkgs { pkgs = import inputs.nixpkgs {
inherit system; inherit system;
@@ -26,7 +30,10 @@
}; };
rustToolchain = pkgs.rust-bin.stable.latest.default.override { rustToolchain = pkgs.rust-bin.stable.latest.default.override {
extensions = [ "rust-src" "rust-analyzer" ]; extensions = [
"rust-src"
"rust-analyzer"
];
}; };
craneLib = (crane.mkLib pkgs).overrideToolchain rustToolchain; craneLib = (crane.mkLib pkgs).overrideToolchain rustToolchain;
@@ -48,8 +55,10 @@
frontendSrc = lib.cleanSourceWith { frontendSrc = lib.cleanSourceWith {
src = ./.; src = ./.;
filter = path: type: filter =
let base = builtins.baseNameOf path; path: type:
let
base = builtins.baseNameOf path;
in in
(lib.hasInfix "/src" path) (lib.hasInfix "/src" path)
|| base == "index.html" || base == "index.html"
@@ -59,268 +68,44 @@
|| base == "vite.config.ts"; || base == "vite.config.ts";
}; };
frontend = pkgs.stdenv.mkDerivation {
pname = "moku-frontend";
inherit version;
src = frontendSrc;
nativeBuildInputs = with pkgs; [ nodejs_22 pnpm pnpmConfigHook ];
pnpmDeps = pkgs.fetchPnpmDeps {
pname = "moku-frontend";
inherit version;
src = frontendSrc;
fetcherVersion = 1;
hash = "sha256-nlhm3NYn4x+JlKcCgj1lAX43muB3QRKGDzaxfQNfJwc=";
};
buildPhase = "pnpm build";
installPhase = "cp -r dist $out";
};
cargoSrc = lib.cleanSourceWith { cargoSrc = lib.cleanSourceWith {
src = ./src-tauri; src = ./src-tauri;
filter = path: type: filter =
path: type:
(craneLib.filterCargoSources path type) (craneLib.filterCargoSources path type)
|| (lib.hasInfix "/icons/" path) || (lib.hasInfix "/icons/" path)
|| (lib.hasInfix "/capabilities/" path) || (lib.hasInfix "/capabilities/" path)
|| (builtins.baseNameOf path == "tauri.conf.json"); || (builtins.baseNameOf path == "tauri.conf.json");
}; };
commonArgs = { suwayomiServer = pkgs.callPackage ./nix/server.nix { };
src = cargoSrc;
cargoToml = ./src-tauri/Cargo.toml; frontend = pkgs.callPackage ./nix/frontend.nix {
cargoLock = ./src-tauri/Cargo.lock; inherit version;
strictDeps = true; src = frontendSrc;
buildInputs = runtimeLibs;
nativeBuildInputs = with pkgs; [ pkg-config wrapGAppsHook3 ];
preBuild = ''
cp -r ${frontend} ../dist
'';
}; };
cargoArtifacts = craneLib.buildDepsOnly commonArgs; moku = import ./nix/moku.nix {
inherit lib craneLib pkgs runtimeLibs frontend suwayomiServer version cargoSrc;
moku = craneLib.buildPackage (commonArgs // { appIcon = ./src/assets/moku-icon.svg;
inherit cargoArtifacts;
meta.mainProgram = "moku";
postInstall = ''
mkdir -p "$out/share/applications"
cat > "$out/share/applications/moku.desktop" << EOF
[Desktop Entry]
Version=1.0
Type=Application
Name=Moku
Comment=Manga reader frontend for Suwayomi
Exec=$out/bin/moku
Icon=moku
Terminal=false
Categories=Graphics;Viewer;
Keywords=manga;comic;reader;suwayomi;
StartupWMClass=moku
EOF
for size in 32x32 128x128 256x256 512x512; do
src="icons/$size.png"
[ -f "$src" ] && install -Dm644 "$src" \
"$out/share/icons/hicolor/$size/apps/moku.png"
done
for size in 128x128 256x256; do
src="icons/''${size}@2x.png"
[ -f "$src" ] && install -Dm644 "$src" \
"$out/share/icons/hicolor/''${size}@2/apps/moku.png"
done
install -Dm644 "${./src/assets/moku-icon.svg}" \
"$out/share/icons/hicolor/scalable/apps/moku.svg"
wrapProgram $out/bin/moku \
--prefix XDG_DATA_DIRS : "${lib.makeSearchPath "share/gsettings-schemas" [
pkgs.gsettings-desktop-schemas
pkgs.gtk3
]}" \
--prefix LD_LIBRARY_PATH : "${lib.makeLibraryPath runtimeLibs}" \
--prefix PATH : "${lib.makeBinPath [ pkgs.suwayomi-server ]}" \
--set GDK_BACKEND wayland \
--set WEBKIT_DISABLE_SANDBOX_THIS_IS_DANGEROUS 1
'';
});
bumpScript = pkgs.writeShellApplication {
name = "moku-bump";
runtimeInputs = with pkgs; [ gnused coreutils git rustToolchain
nodejs_22 pnpm
(python3.withPackages (ps: [ ps.aiohttp ps.tomlkit ])) ];
text = ''
[[ $# -lt 1 ]] && { echo "Usage: nix run .#bump -- <version>"; exit 1; }
VERSION="$1"
REPO="$(git rev-parse --show-toplevel)"
echo " Bumping version fields to $VERSION "
sed -i "s/\"version\": \"[^\"]*\"/\"version\": \"$VERSION\"/" \
"$REPO/src-tauri/tauri.conf.json"
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/^pkgrel=.*/pkgrel=1/" "$REPO/PKGBUILD"
echo "Done"
echo " Regenerating Cargo.lock "
(cd "$REPO/src-tauri" && cargo generate-lockfile)
echo "Done"
echo " Building frontend "
cd "$REPO"
pnpm install --frozen-lockfile
pnpm build
echo "Done"
echo " Repacking frontend-dist.tar.gz "
tar -czf "$REPO/packaging/frontend-dist.tar.gz" -C "$REPO" dist
FRONTEND_SHA=$(sha256sum "$REPO/packaging/frontend-dist.tar.gz" | awk '{print $1}')
echo "sha256: $FRONTEND_SHA"
echo " Regenerating cargo-sources.json "
python3 "$REPO/packaging/flatpak-cargo-generator.py" \
"$REPO/src-tauri/Cargo.lock" \
-o "$REPO/packaging/cargo-sources.json"
echo "Done"
echo " Patching flatpak manifest (version + frontend sha256) "
MANIFEST="$REPO/io.github.moku_project.Moku.yml"
sed -i "s/tag: v[^[:space:]]*/tag: v$VERSION/" "$MANIFEST"
python3 - "$MANIFEST" "$FRONTEND_SHA" <<'PYEOF'
import re, sys
path, sha = sys.argv[1], sys.argv[2]
text = open(path).read()
updated, n = re.subn(
r'(path:\s*packaging/frontend-dist\.tar\.gz\s*\n\s*sha256:\s*)[0-9a-f]+',
r'\g<1>' + sha, text)
if n == 0:
sys.exit("ERROR: could not find frontend-dist sha256 in manifest")
open(path, 'w').write(updated)
PYEOF
echo "Done"
echo ""
echo "Bumped to v$VERSION"
echo ""
echo "Commit field in the flatpak manifest still points to the old tag."
echo "After pushing the tag, run:"
echo " nix run .#post-tag-bump -- $VERSION"
'';
}; };
postTagBumpScript = pkgs.writeShellApplication { scripts = import ./nix/scripts.nix { inherit pkgs rustToolchain version; };
name = "moku-post-tag-bump";
runtimeInputs = with pkgs; [ gnused coreutils git curl ];
text = ''
[[ $# -lt 1 ]] && { echo "Usage: nix run .#post-tag-bump -- <version>"; exit 1; }
VERSION="$1"
REPO="$(git rev-parse --show-toplevel)"
MANIFEST="$REPO/io.github.moku_project.Moku.yml"
PKGBUILD="$REPO/PKGBUILD"
echo " Resolving commit for v$VERSION "
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; }
echo "commit: $COMMIT"
sed -i "s/commit: [0-9a-f]\{40\}/commit: $COMMIT/" "$MANIFEST"
echo "Done"
echo " Fetching PKGBUILD tarball sha256 "
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}')
sed -i "s/\(sha256sums=('\)[0-9a-f]\{64\}/\1$TARBALL_SHA/" "$PKGBUILD"
grep -q "$TARBALL_SHA" "$PKGBUILD" \
|| { echo "ERROR: PKGBUILD sha256 replacement failed"; exit 1; }
echo "Done"
echo ""
echo "post-tag-bump complete for v$VERSION"
'';
};
flatpakScript = pkgs.writeShellApplication {
name = "moku-flatpak";
runtimeInputs = with pkgs; [
gnused coreutils git
appstream flatpak-builder flatpak
];
text = ''
[[ $# -lt 1 ]] && { echo "Usage: nix run .#flatpak -- <version>"; exit 1; }
VERSION="$1"
REPO="$(git rev-parse --show-toplevel)"
MANIFEST="$REPO/io.github.moku_project.Moku.yml"
echo " Building flatpak for v$VERSION "
rm -rf "$REPO/build-dir" "$REPO/repo"
flatpak-builder \
--repo="$REPO/repo" \
--force-clean \
"$REPO/build-dir" \
"$MANIFEST"
flatpak build-bundle "$REPO/repo" "$REPO/moku.flatpak" io.github.moku_project.Moku
rm -rf "$REPO/build-dir" "$REPO/repo"
echo ""
echo "moku.flatpak created v$VERSION"
'';
};
pkgbuildBumpScript = pkgs.writeShellApplication {
name = "moku-pkgbuild-bump";
runtimeInputs = with pkgs; [ gnused curl coreutils git ];
text = ''
[[ $# -lt 1 ]] && { echo "Usage: nix run .#pkgbuild-bump -- <version>"; exit 1; }
VERSION="$1"
REPO="$(git rev-parse --show-toplevel)"
PKGBUILD="$REPO/PKGBUILD"
[[ -f "$PKGBUILD" ]] || { echo "PKGBUILD not found"; exit 1; }
TARBALL_URL="https://github.com/moku-project/Moku/archive/refs/tags/v$VERSION.tar.gz"
echo "Fetching tarball sha256..."
TARBALL_SHA=$(curl -fsSL "$TARBALL_URL" | sha256sum | awk '{print $1}')
sed -i "s/^pkgver=.*/pkgver=$VERSION/" "$PKGBUILD"
sed -i "s/^pkgrel=.*/pkgrel=1/" "$PKGBUILD"
sed -i "s/\(sha256sums=('\)[0-9a-f]\{64\}/\1$TARBALL_SHA/" "$PKGBUILD"
grep -q "$TARBALL_SHA" "$PKGBUILD" \
|| { echo "ERROR: sha256 replacement failed"; exit 1; }
echo "PKGBUILD -> $VERSION ($TARBALL_SHA)"
'';
};
tunnelScript = pkgs.writeShellApplication {
name = "moku-tunnel";
runtimeInputs = with pkgs; [ cloudflared ];
text = ''
PORT="''${1:-4567}"
cloudflared tunnel --url "http://localhost:$PORT"
'';
};
in in
{ {
apps = { packages = {
default = { type = "app"; program = "${moku}/bin/moku"; }; inherit moku frontend suwayomiServer;
moku = { type = "app"; program = "${moku}/bin/moku"; }; default = moku;
bump = { type = "app"; program = "${bumpScript}/bin/moku-bump"; };
post-tag-bump = { type = "app"; program = "${postTagBumpScript}/bin/moku-post-tag-bump"; };
flatpak = { type = "app"; program = "${flatpakScript}/bin/moku-flatpak"; };
pkgbuild-bump = { type = "app"; program = "${pkgbuildBumpScript}/bin/moku-pkgbuild-bump"; };
tunnel = { type = "app"; program = "${tunnelScript}/bin/moku-tunnel"; };
}; };
packages = { apps = {
inherit moku frontend; default = { type = "app"; program = "${moku}/bin/moku"; };
default = moku; moku = { type = "app"; program = "${moku}/bin/moku"; };
bump = { type = "app"; program = "${scripts.bump}/bin/moku-bump"; };
post-tag-bump = { type = "app"; program = "${scripts.postTagBump}/bin/moku-post-tag-bump"; };
flatpak = { type = "app"; program = "${scripts.flatpak}/bin/moku-flatpak"; };
tunnel = { type = "app"; program = "${scripts.tunnel}/bin/moku-tunnel"; };
}; };
devShells.default = pkgs.mkShell { devShells.default = pkgs.mkShell {
@@ -331,10 +116,13 @@ EOF
wrapGAppsHook3 wrapGAppsHook3
nodejs_22 nodejs_22
pnpm pnpm
suwayomi-server suwayomiServer
cloudflared cloudflared
xdg-utils xdg-utils
(python3.withPackages (ps: [ ps.aiohttp ps.tomlkit ])) (python3.withPackages (ps: [
ps.aiohttp
ps.tomlkit
]))
]; ];
shellHook = '' shellHook = ''
export NO_STRIP=true export NO_STRIP=true
@@ -343,12 +131,11 @@ EOF
echo "Moku dev shell pnpm install && pnpm tauri:dev" echo "Moku dev shell pnpm install && pnpm tauri:dev"
echo "" echo ""
echo "Release workflow:" echo " nix run .#bump -- <ver>"
echo " nix run .#bump -- <ver> bump all versions + rebuild artifacts"
echo " git commit && git tag && git push" echo " git commit && git tag && git push"
echo " nix run .#post-tag-bump -- <ver> patch manifest commit + PKGBUILD sha" echo " nix run .#post-tag-bump -- <ver>"
echo " nix run .#flatpak -- <ver> build moku.flatpak" echo " nix run .#flatpak -- <ver>"
echo " nix run .#tunnel -- [port] cloudflare tunnel (default 4567)" echo " nix run .#tunnel -- [port]"
''; '';
}; };
+9 -9
View File
@@ -95,12 +95,12 @@ modules:
cat > /app/tachidesk/default-conf/server.conf << 'EOF' cat > /app/tachidesk/default-conf/server.conf << 'EOF'
server.ip = "127.0.0.1" server.ip = "127.0.0.1"
server.port = 4567 server.port = 4567
server.webUIEnabled = false server.webUIEnabled = true
server.initialOpenInBrowserEnabled = false server.initialOpenInBrowserEnabled = false
server.systemTrayEnabled = false server.systemTrayEnabled = false
server.webUIInterface = "browser" server.webUIInterface = "browser"
server.webUIFlavor = "WebUI" server.webUIFlavor = "WebUI"
server.webUIChannel = "stable" server.webUIChannel = "PREVIEW"
server.electronPath = "" server.electronPath = ""
server.debugLogsEnabled = false server.debugLogsEnabled = false
server.downloadAsCbz = true server.downloadAsCbz = true
@@ -114,7 +114,7 @@ modules:
cat > /app/bin/tachidesk-server << 'EOF' cat > /app/bin/tachidesk-server << 'EOF'
#!/bin/sh #!/bin/sh
DATA_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/moku/tachidesk" DATA_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/Tachidesk"
mkdir -p "$DATA_DIR" mkdir -p "$DATA_DIR"
# Seed conf on first run # Seed conf on first run
@@ -130,7 +130,7 @@ modules:
"$DATA_DIR/server.conf" "$DATA_DIR/server.conf"
# Append keys if absent # Append keys if absent
grep -q 'server\.webUIEnabled' "$DATA_DIR/server.conf" || echo 'server.webUIEnabled = false' >> "$DATA_DIR/server.conf" grep -q 'server\.webUIEnabled' "$DATA_DIR/server.conf" || echo 'server.webUIEnabled = true' >> "$DATA_DIR/server.conf"
grep -q 'server\.initialOpenInBrowserEnabled' "$DATA_DIR/server.conf" || echo 'server.initialOpenInBrowserEnabled = false' >> "$DATA_DIR/server.conf" grep -q 'server\.initialOpenInBrowserEnabled' "$DATA_DIR/server.conf" || echo 'server.initialOpenInBrowserEnabled = false' >> "$DATA_DIR/server.conf"
grep -q 'server\.systemTrayEnabled' "$DATA_DIR/server.conf" || echo 'server.systemTrayEnabled = false' >> "$DATA_DIR/server.conf" grep -q 'server\.systemTrayEnabled' "$DATA_DIR/server.conf" || echo 'server.systemTrayEnabled = false' >> "$DATA_DIR/server.conf"
@@ -155,8 +155,8 @@ modules:
sources: sources:
- type: file - type: file
url: https://github.com/Suwayomi/Suwayomi-Server/releases/download/v2.1.1867/suwayomi-server-v2.1.1867.jar url: https://github.com/Suwayomi/Suwayomi-Server-preview/releases/download/v2.1.2087/Suwayomi-Server-v2.1.2087.jar
sha256: 51e307c2581e4e1a002991ab3e3a77503c8b074c42695987a984a7382d0ac5af sha256: f589a422674252394c13b289a9c8be691905bf583efb7f4d5f1501ae5e91e6b3
dest-filename: Suwayomi-Server.jar dest-filename: Suwayomi-Server.jar
- name: moku - name: moku
@@ -179,11 +179,11 @@ modules:
sources: sources:
- type: git - type: git
url: https://github.com/moku-project/Moku.git url: https://github.com/moku-project/Moku.git
tag: v0.9.1 tag: v0.9.2
commit: 514910667b0d6e375569a48fb7cef11411d30fbd commit: 83711c155d3e60ab4e2411ea6e0098231d76f8b9
- type: file - type: file
path: packaging/frontend-dist.tar.gz path: packaging/frontend-dist.tar.gz
sha256: ce773b63c625448df8e128508b46e7e84d2e5cdb1f2b65a6a03f52a4e350b0bf sha256: 22128c591ddacac218b7223106ed3c3f052799db2a647247789492b925370086
- packaging/cargo-sources.json - packaging/cargo-sources.json
- type: inline - type: inline
dest: src-tauri/.cargo dest: src-tauri/.cargo
+18
View File
@@ -0,0 +1,18 @@
{ 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-eRuSSRhNmJ09mp/uhbG+NFeiOZ5dTOdJ94OwdP6IkN0=";
};
buildPhase = "pnpm build";
installPhase = "cp -r dist $out";
}
+74
View File
@@ -0,0 +1,74 @@
{
lib,
craneLib,
pkgs,
runtimeLibs,
frontend,
suwayomiServer,
version,
cargoSrc,
appIcon,
}:
let
commonArgs = {
src = cargoSrc;
pname = "moku";
inherit version;
strictDeps = true;
buildInputs = runtimeLibs;
nativeBuildInputs = with pkgs; [ pkg-config wrapGAppsHook3 ];
preBuild = ''
cp -r ${frontend} ../dist
'';
};
cargoArtifacts = craneLib.buildDepsOnly commonArgs;
in
craneLib.buildPackage (commonArgs // {
inherit cargoArtifacts;
meta.mainProgram = "moku";
postInstall = ''
mkdir -p "$out/share/applications"
cat > "$out/share/applications/moku.desktop" << EOF
[Desktop Entry]
Version=1.0
Type=Application
Name=Moku
Comment=Manga reader frontend for Suwayomi
Exec=$out/bin/moku
Icon=moku
Terminal=false
Categories=Graphics;Viewer;
Keywords=manga;comic;reader;suwayomi;
StartupWMClass=moku
EOF
for size in 32x32 128x128 256x256 512x512; do
src="icons/$size.png"
[ -f "$src" ] && install -Dm644 "$src" \
"$out/share/icons/hicolor/$size/apps/moku.png"
done
for size in 128x128 256x256; do
src="icons/''${size}@2x.png"
[ -f "$src" ] && install -Dm644 "$src" \
"$out/share/icons/hicolor/''${size}@2/apps/moku.png"
done
install -Dm644 "${appIcon}" \
"$out/share/icons/hicolor/scalable/apps/moku.svg"
wrapProgram $out/bin/moku \
--prefix XDG_DATA_DIRS : "${lib.makeSearchPath "share/gsettings-schemas" [
pkgs.gsettings-desktop-schemas
pkgs.gtk3
]}" \
--prefix LD_LIBRARY_PATH : "${lib.makeLibraryPath runtimeLibs}" \
--prefix PATH : "${lib.makeBinPath [ suwayomiServer ]}" \
--set GDK_BACKEND wayland \
--set WEBKIT_DISABLE_SANDBOX_THIS_IS_DANGEROUS 1
'';
})
+133
View File
@@ -0,0 +1,133 @@
{ pkgs, rustToolchain, version }:
{
bump = pkgs.writeShellApplication {
name = "moku-bump";
runtimeInputs = with pkgs; [
gnused
coreutils
git
rustToolchain
nodejs_22
pnpm
(python3.withPackages (ps: [ ps.aiohttp ps.tomlkit ]))
];
text = ''
[[ $# -lt 1 ]] && { echo "Usage: nix run .#bump -- <version>"; exit 1; }
VERSION="$1"
REPO="$(git rev-parse --show-toplevel)"
echo " Bumping version fields to $VERSION "
sed -i "s/\"version\": \"[^\"]*\"/\"version\": \"$VERSION\"/" \
"$REPO/src-tauri/tauri.conf.json"
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/^pkgrel=.*/pkgrel=1/" "$REPO/PKGBUILD"
echo "Done"
echo " Regenerating Cargo.lock "
(cd "$REPO/src-tauri" && cargo generate-lockfile)
echo "Done"
echo " Building frontend "
cd "$REPO"
pnpm install --frozen-lockfile
pnpm build
echo "Done"
echo " Repacking frontend-dist.tar.gz "
tar -czf "$REPO/packaging/frontend-dist.tar.gz" -C "$REPO" dist
FRONTEND_SHA=$(sha256sum "$REPO/packaging/frontend-dist.tar.gz" | awk '{print $1}')
echo "sha256: $FRONTEND_SHA"
echo " Regenerating cargo-sources.json "
python3 "$REPO/packaging/flatpak-cargo-generator.py" \
"$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"
sed -i "s/tag: v[^[:space:]]*/tag: v$VERSION/" "$MANIFEST"
python3 - "$MANIFEST" "$FRONTEND_SHA" <<'PYEOF'
import re, sys
path, sha = sys.argv[1], sys.argv[2]
text = open(path).read()
updated, n = re.subn(
r'(path:\s*packaging/frontend-dist\.tar\.gz\s*\n\s*sha256:\s*)[0-9a-f]+',
r'\g<1>' + sha, text)
if n == 0:
sys.exit("ERROR: could not find frontend-dist sha256 in manifest")
open(path, 'w').write(updated)
PYEOF
echo "Done"
echo ""
echo "Bumped to v$VERSION commit, tag, push, then: nix run .#post-tag-bump -- $VERSION"
'';
};
postTagBump = pkgs.writeShellApplication {
name = "moku-post-tag-bump";
runtimeInputs = with pkgs; [ gnused coreutils git curl ];
text = ''
[[ $# -lt 1 ]] && { echo "Usage: nix run .#post-tag-bump -- <version>"; exit 1; }
VERSION="$1"
REPO="$(git rev-parse --show-toplevel)"
MANIFEST="$REPO/io.github.moku_project.Moku.yml"
PKGBUILD="$REPO/PKGBUILD"
echo " Resolving commit for v$VERSION "
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; }
echo "commit: $COMMIT"
sed -i "s/commit: [0-9a-f]\{40\}/commit: $COMMIT/" "$MANIFEST"
echo "Done"
echo " Fetching PKGBUILD tarball sha256 "
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}')
sed -i "/sha256sums=/,/)/{ 0,/'/s/'[^']*'/'$TARBALL_SHA'/ }" "$PKGBUILD"
grep -q "$TARBALL_SHA" "$PKGBUILD" \
|| { echo "ERROR: PKGBUILD sha256 replacement failed"; exit 1; }
echo "Done"
echo ""
echo "post-tag-bump complete for v$VERSION"
'';
};
flatpak = pkgs.writeShellApplication {
name = "moku-flatpak";
runtimeInputs = with pkgs; [ coreutils git appstream flatpak-builder flatpak ];
text = ''
[[ $# -lt 1 ]] && { echo "Usage: nix run .#flatpak -- <version>"; exit 1; }
REPO="$(git rev-parse --show-toplevel)"
MANIFEST="$REPO/io.github.moku_project.Moku.yml"
rm -rf "$REPO/build-dir" "$REPO/repo"
flatpak-builder \
--repo="$REPO/repo" \
--force-clean \
"$REPO/build-dir" \
"$MANIFEST"
flatpak build-bundle "$REPO/repo" "$REPO/moku.flatpak" io.github.moku_project.Moku
rm -rf "$REPO/build-dir" "$REPO/repo"
echo "moku.flatpak created"
'';
};
tunnel = pkgs.writeShellApplication {
name = "moku-tunnel";
runtimeInputs = with pkgs; [ cloudflared ];
text = ''
PORT="''${1:-4567}"
cloudflared tunnel --url "http://localhost:$PORT"
'';
};
}
+46
View File
@@ -0,0 +1,46 @@
{
lib,
stdenvNoCC,
fetchurl,
makeWrapper,
jdk21_headless,
}:
let
jdk = jdk21_headless;
in
stdenvNoCC.mkDerivation (finalAttrs: {
pname = "suwayomi-server";
version = "2.1.2087";
src = fetchurl {
url = "https://github.com/Suwayomi/Suwayomi-Server-preview/releases/download/v${finalAttrs.version}/Suwayomi-Server-v${finalAttrs.version}.jar";
hash = "sha256-9YmkImdCUjlME7KJqci+aRkFv1g++39NXxUBrl6R5rM=";
};
nativeBuildInputs = [ makeWrapper ];
dontUnpack = true;
buildPhase = ''
runHook preBuild
install -Dm644 $src $out/share/suwayomi-server/suwayomi-server.jar
makeWrapper ${jdk}/bin/java $out/bin/suwayomi-server \
--add-flags "-Dsuwayomi.tachidesk.config.server.initialOpenInBrowserEnabled=false" \
--add-flags "-jar $out/share/suwayomi-server/suwayomi-server.jar"
runHook postBuild
'';
meta = {
description = "Free and open source manga reader server that runs extensions built for Mihon (Tachiyomi)";
homepage = "https://github.com/Suwayomi/Suwayomi-Server";
downloadPage = "https://github.com/Suwayomi/Suwayomi-Server-preview/releases";
changelog = "https://github.com/Suwayomi/Suwayomi-Server-preview/releases/tag/v${finalAttrs.version}";
license = lib.licenses.mpl20;
platforms = jdk.meta.platforms;
sourceProvenance = [ lib.sourceTypes.binaryBytecode ];
mainProgram = "suwayomi-server";
};
})
+9 -8
View File
@@ -10,22 +10,23 @@
"tauri:dev": "tauri dev --config src-tauri/tauri.dev.conf.json" "tauri:dev": "tauri dev --config src-tauri/tauri.dev.conf.json"
}, },
"dependencies": { "dependencies": {
"@tauri-apps/api": "^2.0.0", "@tauri-apps/api": "^2.11.0",
"@tauri-apps/plugin-http": "^2.5.8", "@tauri-apps/plugin-http": "^2.5.8",
"@tauri-apps/plugin-os": "^2.3.2", "@tauri-apps/plugin-os": "^2.3.2",
"@tauri-apps/plugin-shell": "^2.3.5", "@tauri-apps/plugin-shell": "^2.3.5",
"@tauri-apps/plugin-store": "~2.4.2",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"phosphor-svelte": "^3.1.0", "phosphor-svelte": "^3.1.0",
"svelte-spa-router": "^4.0.1", "svelte-spa-router": "^5.1.0",
"tauri-plugin-discord-rpc-api": "github:Youwes09/tauri-plugin-discord-rpc", "tauri-plugin-discord-rpc-api": "github:Youwes09/tauri-plugin-discord-rpc",
"tauri-plugin-drpc": "^1.0.3" "tauri-plugin-drpc": "^1.0.3"
}, },
"devDependencies": { "devDependencies": {
"@sveltejs/vite-plugin-svelte": "^4.0.4", "@sveltejs/vite-plugin-svelte": "^7.0.0",
"@tauri-apps/cli": "^2.0.0", "@tauri-apps/cli": "^2.11.0",
"svelte": "^5.0.0", "svelte": "^5.55.5",
"svelte-check": "^3.0.0", "svelte-check": "^4.4.7",
"typescript": "^5.0.0", "typescript": "^6.0.3",
"vite": "^5.0.0" "vite": "^8.0.10"
} }
} }
File diff suppressed because it is too large Load Diff
+476 -889
View File
File diff suppressed because it is too large Load Diff
+224 -456
View File
File diff suppressed because it is too large Load Diff
+8 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "moku" name = "moku"
version = "0.9.1" version = "0.9.2"
edition = "2021" edition = "2021"
[lib] [lib]
@@ -21,6 +21,7 @@ tauri-plugin-process = "2"
tauri-plugin-http = "2" tauri-plugin-http = "2"
tauri-plugin-dialog = "2" tauri-plugin-dialog = "2"
tauri-plugin-os = "2.3.2" tauri-plugin-os = "2.3.2"
tauri-plugin-store = "2"
tauri-plugin-discord-rpc = { git = "https://github.com/Youwes09/tauri-plugin-discord-rpc" } tauri-plugin-discord-rpc = { git = "https://github.com/Youwes09/tauri-plugin-discord-rpc" }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
@@ -31,6 +32,12 @@ urlencoding = "2"
tokio = { version = "1", features = ["rt-multi-thread"] } tokio = { version = "1", features = ["rt-multi-thread"] }
reqwest = { version = "0.12", features = ["blocking"] } reqwest = { version = "0.12", features = ["blocking"] }
[target.'cfg(target_os = "windows")'.dependencies]
windows = { version = "0.58", features = [
"Security_Credentials_UI",
"Win32_UI_WindowsAndMessaging",
] }
[profile.release] [profile.release]
codegen-units = 1 codegen-units = 1
lto = true lto = true
@@ -1,5 +1,5 @@
#!/bin/sh #!/bin/sh
# Moku — Suwayomi launcher for Linux AppImage/deb. # — Suwayomi launcher for Linux AppImage/deb.
# Tauri resolves this via resolve_server_binary() in lib.rs, which looks for # Tauri resolves this via resolve_server_binary() in lib.rs, which looks for
# "suwayomi-launcher" or "suwayomi-launcher.sh" in the resource directory. # "suwayomi-launcher" or "suwayomi-launcher.sh" in the resource directory.
set -e set -e
@@ -53,7 +53,7 @@ if [ ! -f "$JAR" ]; then
fi fi
# ── Data directory ───────────────────────────────────────────────────────────── # ── Data directory ─────────────────────────────────────────────────────────────
DATA_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/moku/tachidesk" DATA_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/Tachidesk"
mkdir -p "$DATA_DIR" mkdir -p "$DATA_DIR"
# ── Seed server.conf on first run ────────────────────────────────────────────── # ── Seed server.conf on first run ──────────────────────────────────────────────
@@ -61,12 +61,12 @@ if [ ! -f "$DATA_DIR/server.conf" ]; then
cat > "$DATA_DIR/server.conf" << 'EOF' cat > "$DATA_DIR/server.conf" << 'EOF'
server.ip = "127.0.0.1" server.ip = "127.0.0.1"
server.port = 4567 server.port = 4567
server.webUIEnabled = false server.webUIEnabled = true
server.initialOpenInBrowserEnabled = false server.initialOpenInBrowserEnabled = false
server.systemTrayEnabled = false server.systemTrayEnabled = false
server.webUIInterface = "browser" server.webUIInterface = "browser"
server.webUIFlavor = "WebUI" server.webUIFlavor = "WebUI"
server.webUIChannel = "stable" server.webUIChannel = "PREVIEW"
server.electronPath = "" server.electronPath = ""
server.debugLogsEnabled = false server.debugLogsEnabled = false
server.downloadAsCbz = true server.downloadAsCbz = true
@@ -79,13 +79,13 @@ fi
# ── Force-patch the three keys that cause JCEF/GUI crashes ──────────────────── # ── Force-patch the three keys that cause JCEF/GUI crashes ────────────────────
sed -i \ sed -i \
-e 's|server\.webUIEnabled.*|server.webUIEnabled = false|' \ -e 's|server\.webUIEnabled.*|server.webUIEnabled = true|' \
-e 's|server\.initialOpenInBrowserEnabled.*|server.initialOpenInBrowserEnabled = false|' \ -e 's|server\.initialOpenInBrowserEnabled.*|server.initialOpenInBrowserEnabled = false|' \
-e 's|server\.systemTrayEnabled.*|server.systemTrayEnabled = false|' \ -e 's|server\.systemTrayEnabled.*|server.systemTrayEnabled = false|' \
"$DATA_DIR/server.conf" "$DATA_DIR/server.conf"
# Append keys if absent (e.g. user-managed conf missing them) # Append keys if absent (e.g. user-managed conf missing them)
grep -q 'server\.webUIEnabled' "$DATA_DIR/server.conf" || echo 'server.webUIEnabled = false' >> "$DATA_DIR/server.conf" grep -q 'server\.webUIEnabled' "$DATA_DIR/server.conf" || echo 'server.webUIEnabled = true' >> "$DATA_DIR/server.conf"
grep -q 'server\.initialOpenInBrowserEnabled' "$DATA_DIR/server.conf" || echo 'server.initialOpenInBrowserEnabled = false' >> "$DATA_DIR/server.conf" grep -q 'server\.initialOpenInBrowserEnabled' "$DATA_DIR/server.conf" || echo 'server.initialOpenInBrowserEnabled = false' >> "$DATA_DIR/server.conf"
grep -q 'server\.systemTrayEnabled' "$DATA_DIR/server.conf" || echo 'server.systemTrayEnabled = false' >> "$DATA_DIR/server.conf" grep -q 'server\.systemTrayEnabled' "$DATA_DIR/server.conf" || echo 'server.systemTrayEnabled = false' >> "$DATA_DIR/server.conf"
+1 -1
View File
@@ -1,3 +1,3 @@
fn main() { fn main() {
tauri_build::build() tauri_build::build()
} }
+1
View File
@@ -30,6 +30,7 @@
"process:allow-restart", "process:allow-restart",
"http:default", "http:default",
"http:allow-fetch", "http:allow-fetch",
"store:default",
"discord-rpc:default", "discord-rpc:default",
"discord-rpc:allow-connect", "discord-rpc:allow-connect",
"discord-rpc:allow-disconnect", "discord-rpc:allow-disconnect",
+100
View File
@@ -0,0 +1,100 @@
use std::path::PathBuf;
use tauri::Manager;
fn backup_dir(app: &tauri::AppHandle) -> PathBuf {
app.path()
.app_data_dir()
.unwrap_or_else(|_| PathBuf::from("."))
.join("backups")
}
fn unix_now() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
#[tauri::command]
pub async fn export_app_data(app: tauri::AppHandle, bytes: Vec<u8>) -> Result<(), String> {
use tauri_plugin_dialog::DialogExt;
let filename = format!("moku-backup-{}.zip", unix_now());
let path = app
.dialog()
.file()
.set_title("Save Moku app data backup")
.set_file_name(&filename)
.add_filter("Moku Backup", &["zip"])
.blocking_save_file()
.ok_or("Cancelled")?;
std::fs::write(PathBuf::from(path.to_string()), &bytes).map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn import_app_data(app: tauri::AppHandle) -> Result<Vec<u8>, String> {
use tauri_plugin_dialog::DialogExt;
let path = app
.dialog()
.file()
.set_title("Open Moku app data backup")
.add_filter("Moku Backup", &["zip"])
.blocking_pick_file()
.ok_or("Cancelled")?;
std::fs::read(PathBuf::from(path.to_string())).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn auto_backup_app_data(app: tauri::AppHandle, bytes: Vec<u8>) -> Result<(), String> {
let dir = backup_dir(&app);
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
let dest = dir.join(format!("auto-moku-backup-{}.zip", unix_now()));
std::fs::write(&dest, &bytes).map_err(|e| e.to_string())?;
let mut entries: Vec<_> = std::fs::read_dir(&dir)
.map_err(|e| e.to_string())?
.filter_map(|e| e.ok())
.filter(|e| {
e.file_name()
.to_string_lossy()
.starts_with("auto-moku-backup-")
})
.collect();
entries.sort_by_key(|e| e.file_name());
for old in entries.iter().take(entries.len().saturating_sub(5)) {
let _ = std::fs::remove_file(old.path());
}
Ok(())
}
#[tauri::command]
pub fn get_auto_backup_dir(app: tauri::AppHandle) -> String {
let dir = backup_dir(&app);
let _ = std::fs::create_dir_all(&dir);
dir.to_string_lossy().into_owned()
}
#[tauri::command]
pub fn read_store_files(app: tauri::AppHandle, names: Vec<String>) -> Vec<(String, String)> {
let base = app
.path()
.app_local_data_dir()
.unwrap_or_else(|_| PathBuf::from("."));
names
.into_iter()
.map(|name| {
let content = std::fs::read_to_string(base.join(&name))
.unwrap_or_else(|_| "{}".to_string());
(name, content)
})
.collect()
}
+103
View File
@@ -0,0 +1,103 @@
#[cfg(target_os = "windows")]
mod windows_hello {
use windows::{
core::HSTRING,
Security::Credentials::UI::{UserConsentVerificationResult, UserConsentVerifier},
Win32::UI::WindowsAndMessaging::{
BringWindowToTop, FindWindowW, IsIconic, SetForegroundWindow, ShowWindow, SW_RESTORE,
},
};
fn to_wide(s: &str) -> Vec<u16> {
use std::os::windows::ffi::OsStrExt;
std::ffi::OsStr::new(s)
.encode_wide()
.chain(std::iter::once(0))
.collect()
}
fn try_focus_hello_dialog() -> bool {
let cls = to_wide("Credential Dialog Xaml Host");
unsafe {
let Ok(hwnd) = FindWindowW(
windows::core::PCWSTR(cls.as_ptr()),
windows::core::PCWSTR::null(),
) else {
return false;
};
if IsIconic(hwnd).as_bool() {
let _ = ShowWindow(hwnd, SW_RESTORE);
}
let _ = BringWindowToTop(hwnd);
let _ = SetForegroundWindow(hwnd);
true
}
}
fn nudge_focus(retries: u32, delay_ms: u64) {
std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_millis(delay_ms));
for _ in 0..retries {
if try_focus_hello_dialog() {
break;
}
std::thread::sleep(std::time::Duration::from_millis(delay_ms));
}
});
}
pub fn authenticate(reason: &str) -> Result<(), String> {
let reason = reason.to_owned();
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
nudge_focus(5, 250);
let outcome = UserConsentVerifier::RequestVerificationAsync(&HSTRING::from(reason.as_str()))
.and_then(|op| {
nudge_focus(5, 250);
op.get()
});
let _ = tx.send(outcome);
});
let result = rx
.recv()
.map_err(|e| format!("internalError:{e:?}"))?
.map_err(|e| format!("internalError:{e:?}"))?;
match result {
UserConsentVerificationResult::Verified => Ok(()),
UserConsentVerificationResult::Canceled => Err("userCancel".into()),
UserConsentVerificationResult::RetriesExhausted => Err("biometryLockout".into()),
UserConsentVerificationResult::DeviceBusy => Err("systemCancel".into()),
UserConsentVerificationResult::DeviceNotPresent => Err("biometryNotAvailable".into()),
UserConsentVerificationResult::DisabledByPolicy => Err("biometryNotAvailable".into()),
UserConsentVerificationResult::NotConfiguredForUser => Err("biometryNotEnrolled".into()),
_ => Err("authenticationFailed".into()),
}
}
pub fn is_available() -> bool {
use windows::Security::Credentials::UI::UserConsentVerifierAvailability;
UserConsentVerifier::CheckAvailabilityAsync()
.and_then(|op| op.get())
.map(|a| a == UserConsentVerifierAvailability::Available)
.unwrap_or(false)
}
}
#[tauri::command]
pub fn windows_hello_authenticate(_reason: String) -> Result<(), String> {
#[cfg(target_os = "windows")]
return windows_hello::authenticate(&_reason);
#[cfg(not(target_os = "windows"))]
Err("notSupported".into())
}
#[tauri::command]
pub fn windows_hello_available() -> bool {
#[cfg(target_os = "windows")]
return windows_hello::is_available();
#[cfg(not(target_os = "windows"))]
false
}
+6
View File
@@ -0,0 +1,6 @@
pub mod backup;
pub mod biometric;
pub mod server;
pub mod storage;
pub mod system;
pub mod updater;
+80
View File
@@ -0,0 +1,80 @@
use crate::server::{self, resolve::suwayomi_data_dir, SpawnError};
use crate::ServerState;
use tauri::Manager;
#[tauri::command]
pub fn spawn_server(binary: String, app: tauri::AppHandle) -> Result<(), SpawnError> {
{
let state = app.state::<ServerState>();
if state.0.lock().unwrap().is_some() {
return Ok(());
}
}
let data_dir = suwayomi_data_dir();
let log_path = data_dir.join("moku-spawn.log");
let _ = std::fs::create_dir_all(&data_dir);
let mut log = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&log_path)
.ok();
server::do_log(
&mut log,
&format!("[spawn_server] binary={:?} data_dir={:?}", binary, data_dir),
);
server::conf::seed_server_conf(&data_dir);
let mut invocation =
server::resolve::resolve_server_binary(&binary, &app, &mut log).map_err(|e| {
server::do_log(&mut log, &format!("[spawn_server] resolve failed: {:?}", e));
e
})?;
if invocation.bin.ends_with("java") || invocation.bin.ends_with("java.exe") {
let rootdir_flag = format!(
"-Dsuwayomi.tachidesk.config.server.rootDir={}",
data_dir.to_string_lossy()
);
invocation.args.insert(0, rootdir_flag);
}
let working_dir = invocation
.working_dir
.unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
server::do_log(
&mut log,
&format!(
"[spawn_server] bin={:?} args={:?} cwd={:?}",
invocation.bin, invocation.args, working_dir
),
);
use tauri_plugin_shell::ShellExt;
let cmd = app
.shell()
.command(&invocation.bin)
.env("JAVA_TOOL_OPTIONS", "-Djava.awt.headless=true")
.args(&invocation.args)
.current_dir(&working_dir);
match cmd.spawn() {
Ok((_rx, child)) => {
*app.state::<ServerState>().0.lock().unwrap() = Some(child);
Ok(())
}
Err(e) => {
server::do_log(&mut log, &format!("[spawn_server] spawn failed: {}", e));
Err(SpawnError::SpawnFailed(e.to_string()))
}
}
}
#[tauri::command]
pub fn kill_server(app: tauri::AppHandle) -> Result<(), String> {
server::kill_tachidesk(&app);
Ok(())
}
+130
View File
@@ -0,0 +1,130 @@
use serde::Serialize;
use std::path::PathBuf;
use sysinfo::Disks;
use tauri::Emitter;
use walkdir::WalkDir;
use crate::server::resolve::suwayomi_data_dir;
#[derive(Serialize)]
pub struct StorageInfo {
pub manga_bytes: u64,
pub total_bytes: u64,
pub free_bytes: u64,
pub path: String,
}
fn resolve_downloads_path(downloads_path: &str) -> PathBuf {
if !downloads_path.trim().is_empty() {
return PathBuf::from(downloads_path.trim());
}
suwayomi_data_dir().join("downloads")
}
#[tauri::command]
pub fn get_storage_info(downloads_path: String) -> Result<StorageInfo, String> {
let path = resolve_downloads_path(&downloads_path);
let manga_bytes = if path.exists() {
WalkDir::new(&path)
.into_iter()
.filter_map(|e| e.ok())
.filter_map(|e| e.metadata().ok())
.filter(|m| m.is_file())
.map(|m| m.len())
.sum()
} else {
0
};
let stat_path = if path.exists() {
path.clone()
} else {
dirs::home_dir().unwrap_or_else(|| PathBuf::from("/"))
};
let disks = Disks::new_with_refreshed_list();
let disk = disks
.iter()
.filter(|d| stat_path.starts_with(d.mount_point()))
.max_by_key(|d| d.mount_point().as_os_str().len())
.ok_or_else(|| "Could not find disk for path".to_string())?;
Ok(StorageInfo {
manga_bytes,
total_bytes: disk.total_space(),
free_bytes: disk.available_space(),
path: path.to_string_lossy().into_owned(),
})
}
#[tauri::command]
pub fn get_default_downloads_path() -> String {
resolve_downloads_path("").to_string_lossy().into_owned()
}
#[tauri::command]
pub fn check_path_exists(path: String) -> bool {
std::path::Path::new(path.trim()).is_dir()
}
#[tauri::command]
pub fn create_directory(path: String) -> Result<(), String> {
std::fs::create_dir_all(path.trim()).map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn migrate_downloads(
app: tauri::AppHandle,
src: String,
dst: String,
) -> Result<(), String> {
use std::fs;
let src_path = PathBuf::from(src.trim());
let dst_path = PathBuf::from(dst.trim());
if !src_path.is_dir() {
return Ok(());
}
let total: u64 = WalkDir::new(&src_path)
.into_iter()
.filter_map(|e| e.ok())
.filter(|e| e.file_type().is_file())
.count() as u64;
let _ = app.emit(
"migrate_progress",
serde_json::json!({ "done": 0u64, "total": total, "current": "" }),
);
let mut done: u64 = 0;
for entry in WalkDir::new(&src_path).into_iter().filter_map(|e| e.ok()) {
let rel = entry
.path()
.strip_prefix(&src_path)
.map_err(|e| e.to_string())?;
let target = dst_path.join(rel);
if entry.file_type().is_dir() {
fs::create_dir_all(&target).map_err(|e| e.to_string())?;
} else {
if let Some(parent) = target.parent() {
fs::create_dir_all(parent).map_err(|e| e.to_string())?;
}
fs::copy(entry.path(), &target).map_err(|e| e.to_string())?;
done += 1;
let _ = app.emit(
"migrate_progress",
serde_json::json!({
"done": done, "total": total, "current": rel.to_string_lossy()
}),
);
}
}
fs::remove_dir_all(&src_path).map_err(|e| e.to_string())?;
Ok(())
}
+100
View File
@@ -0,0 +1,100 @@
#[cfg(target_os = "windows")]
use crate::server::resolve::strip_unc;
use tauri::Manager;
#[tauri::command]
pub fn get_platform_ui_scale(window: tauri::Window) -> f64 {
window.scale_factor().unwrap_or(1.0)
}
#[tauri::command]
pub fn restart_app(app: tauri::AppHandle) {
tauri::process::restart(&app.env());
}
#[tauri::command]
pub fn open_path(path: String) -> Result<(), String> {
#[cfg(target_os = "windows")]
{
let p = strip_unc(PathBuf::from(path.trim()));
std::process::Command::new("explorer")
.arg(p)
.spawn()
.map_err(|e| e.to_string())?;
}
#[cfg(target_os = "macos")]
{
let p = std::path::Path::new(path.trim());
std::process::Command::new("open")
.arg(p)
.spawn()
.map_err(|e| e.to_string())?;
}
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
{
let p = std::path::Path::new(path.trim());
std::process::Command::new("xdg-open")
.arg(p)
.spawn()
.map_err(|e| e.to_string())?;
}
Ok(())
}
#[tauri::command]
pub async fn pick_downloads_folder(app: tauri::AppHandle) -> Option<String> {
use tauri_plugin_dialog::DialogExt;
app.dialog()
.file()
.set_title("Choose Downloads Folder")
.blocking_pick_folder()
.map(|p| p.to_string())
}
#[tauri::command]
pub fn exit_app(app: tauri::AppHandle) {
app.exit(0);
}
#[tauri::command]
pub fn clear_moku_cache(app: tauri::AppHandle) -> Result<(), String> {
use tauri::Manager;
let cache_dir = app.path().app_cache_dir().map_err(|e| e.to_string())?;
if cache_dir.exists() {
std::fs::remove_dir_all(&cache_dir).map_err(|e| e.to_string())?;
std::fs::create_dir_all(&cache_dir).map_err(|e| e.to_string())?;
}
Ok(())
}
#[tauri::command]
pub fn clear_suwayomi_cache() -> Result<(), String> {
use crate::server::resolve::suwayomi_data_dir;
let data_dir = suwayomi_data_dir();
for dir in &["cache", "bin/kcef", "cache/kcef"] {
let p = data_dir.join(dir);
if p.exists() {
std::fs::remove_dir_all(&p).map_err(|e| e.to_string())?;
}
}
Ok(())
}
#[tauri::command]
pub fn reset_suwayomi_data(app: tauri::AppHandle) -> Result<(), String> {
use crate::server::resolve::suwayomi_data_dir;
crate::server::kill_tachidesk(&app);
std::thread::sleep(std::time::Duration::from_millis(500));
let data_dir = suwayomi_data_dir();
for entry_name in &["database.mv.db", "extensions", "settings", "logs", "local"] {
let p = data_dir.join(entry_name);
if p.is_dir() {
std::fs::remove_dir_all(&p).map_err(|e| format!("{entry_name}: {e}"))?;
} else if p.exists() {
std::fs::remove_file(&p).map_err(|e| format!("{entry_name}: {e}"))?;
}
}
Ok(())
}
+150
View File
@@ -0,0 +1,150 @@
use serde::{Deserialize, Serialize};
#[derive(Serialize, Clone)]
pub struct ReleaseInfo {
pub tag_name: String,
pub name: String,
pub body: String,
pub published_at: String,
pub html_url: String,
}
#[derive(Clone, Serialize)]
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
struct UpdateProgress {
downloaded: u64,
total: Option<u64>,
}
#[tauri::command]
pub async fn list_releases() -> Result<Vec<ReleaseInfo>, String> {
use tauri_plugin_http::reqwest;
#[derive(Deserialize)]
struct GhRelease {
tag_name: String,
name: Option<String>,
body: Option<String>,
published_at: Option<String>,
html_url: String,
}
let client = reqwest::Client::builder()
.user_agent("Moku")
.build()
.map_err(|e| e.to_string())?;
let resp = client
.get("https://api.github.com/repos/moku-project/Moku/releases?per_page=30")
.send()
.await
.map_err(|e| e.to_string())?;
if !resp.status().is_success() {
return Err(format!("GitHub API returned {}", resp.status()));
}
let releases: Vec<GhRelease> =
serde_json::from_str(&resp.text().await.map_err(|e| e.to_string())?)
.map_err(|e| e.to_string())?;
Ok(releases
.into_iter()
.map(|r| ReleaseInfo {
tag_name: r.tag_name.clone(),
name: r.name.unwrap_or_else(|| r.tag_name.clone()),
body: r.body.unwrap_or_default(),
published_at: r.published_at.unwrap_or_default(),
html_url: r.html_url,
})
.collect())
}
#[tauri::command]
#[allow(unused_variables)]
pub async fn download_and_install_update(app: tauri::AppHandle, tag: String) -> Result<(), String> {
#[cfg(not(target_os = "windows"))]
return Err("Native install is Windows-only; open the GitHub release page instead.".into());
#[cfg(target_os = "windows")]
{
use std::io::Write;
use tauri::Emitter;
use tauri_plugin_http::reqwest;
#[derive(Deserialize)]
struct Asset {
name: String,
browser_download_url: String,
size: u64,
}
#[derive(Deserialize)]
struct Release {
assets: Vec<Asset>,
}
let client = reqwest::Client::builder()
.user_agent("Moku")
.build()
.map_err(|e| e.to_string())?;
let resp = client
.get(format!(
"https://api.github.com/repos/moku-project/Moku/releases/tags/{}",
tag
))
.send()
.await
.map_err(|e| e.to_string())?;
if !resp.status().is_success() {
return Err(format!(
"GitHub API returned {} for tag {}",
resp.status(),
tag
));
}
let release: Release = serde_json::from_str(&resp.text().await.map_err(|e| e.to_string())?)
.map_err(|e| e.to_string())?;
let asset = release
.assets
.into_iter()
.find(|a| a.name.ends_with("_x64-setup.exe"))
.ok_or_else(|| format!("No x64-setup.exe asset found in release {}", tag))?;
let total = if asset.size > 0 {
Some(asset.size)
} else {
None
};
let mut resp = client
.get(&asset.browser_download_url)
.send()
.await
.map_err(|e| e.to_string())?;
let tmp_path = std::env::temp_dir().join(&asset.name);
let mut file = std::fs::File::create(&tmp_path).map_err(|e| e.to_string())?;
let mut downloaded: u64 = 0;
while let Some(chunk) = resp.chunk().await.map_err(|e| e.to_string())? {
file.write_all(&chunk).map_err(|e| e.to_string())?;
downloaded += chunk.len() as u64;
let _ = app.emit("update-progress", UpdateProgress { downloaded, total });
}
drop(file);
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x08000000;
std::process::Command::new(&tmp_path)
.creation_flags(CREATE_NO_WINDOW)
.spawn()
.map_err(|e| e.to_string())?;
let _ = app.emit("update-launching", ());
Ok(())
}
}
+31 -818
View File
@@ -1,810 +1,16 @@
use std::path::PathBuf; mod commands;
mod server;
use std::sync::Mutex; use std::sync::Mutex;
use std::io::Write;
use sysinfo::Disks;
use serde::Serialize;
use tauri::{Manager, WindowEvent}; use tauri::{Manager, WindowEvent};
#[cfg(target_os = "windows")] use tauri_plugin_shell::process::CommandChild;
use tauri::Emitter;
use tauri_plugin_shell::{ShellExt, process::CommandChild};
use walkdir::WalkDir;
struct ServerState(Mutex<Option<CommandChild>>); pub struct ServerState(pub Mutex<Option<CommandChild>>);
#[derive(Serialize)]
pub struct StorageInfo {
manga_bytes: u64,
total_bytes: u64,
free_bytes: u64,
path: String,
}
#[derive(Serialize, Debug)]
#[serde(tag = "kind", content = "message")]
pub enum SpawnError {
NotConfigured(String),
SpawnFailed(String),
}
#[derive(Serialize, Clone)]
pub struct ReleaseInfo {
pub tag_name: String,
pub name: String,
pub body: String,
pub published_at: String,
pub html_url: String,
}
#[derive(Clone, serde::Serialize)]
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
struct UpdateProgress {
downloaded: u64,
total: Option<u64>,
}
fn strip_unc(path: PathBuf) -> PathBuf {
let s = path.to_string_lossy();
if let Some(stripped) = s.strip_prefix(r"\\?\") {
PathBuf::from(stripped)
} else {
path
}
}
fn resolve_downloads_path(downloads_path: &str) -> PathBuf {
if !downloads_path.trim().is_empty() {
return PathBuf::from(downloads_path.trim());
}
suwayomi_data_dir().join("downloads")
}
#[tauri::command]
fn get_storage_info(downloads_path: String) -> Result<StorageInfo, String> {
let path = resolve_downloads_path(&downloads_path);
let manga_bytes = if path.exists() {
WalkDir::new(&path)
.into_iter()
.filter_map(|e| e.ok())
.filter_map(|e| e.metadata().ok())
.filter(|m| m.is_file())
.map(|m| m.len())
.sum()
} else {
0
};
let stat_path = if path.exists() {
path.clone()
} else {
dirs::home_dir().unwrap_or_else(|| PathBuf::from("/"))
};
let disks = Disks::new_with_refreshed_list();
let disk = disks
.iter()
.filter(|d| stat_path.starts_with(d.mount_point()))
.max_by_key(|d| d.mount_point().as_os_str().len())
.ok_or_else(|| "Could not find disk for path".to_string())?;
Ok(StorageInfo {
manga_bytes,
total_bytes: disk.total_space(),
free_bytes: disk.available_space(),
path: path.to_string_lossy().into_owned(),
})
}
#[tauri::command]
fn get_default_downloads_path() -> String {
resolve_downloads_path("").to_string_lossy().into_owned()
}
#[tauri::command]
fn check_path_exists(path: String) -> bool {
std::path::Path::new(path.trim()).is_dir()
}
#[tauri::command]
fn create_directory(path: String) -> Result<(), String> {
std::fs::create_dir_all(path.trim()).map_err(|e| e.to_string())
}
#[tauri::command]
async fn migrate_downloads(app: tauri::AppHandle, src: String, dst: String) -> Result<(), String> {
use tauri::Emitter;
use std::fs;
let src_path = std::path::PathBuf::from(src.trim());
let dst_path = std::path::PathBuf::from(dst.trim());
if !src_path.is_dir() {
return Ok(());
}
let total: u64 = WalkDir::new(&src_path)
.into_iter()
.filter_map(|e| e.ok())
.filter(|e| e.file_type().is_file())
.count() as u64;
let _ = app.emit("migrate_progress", serde_json::json!({ "done": 0u64, "total": total, "current": "" }));
let mut done: u64 = 0;
for entry in WalkDir::new(&src_path).into_iter().filter_map(|e| e.ok()) {
let rel = entry.path().strip_prefix(&src_path).map_err(|e| e.to_string())?;
let target = dst_path.join(rel);
if entry.file_type().is_dir() {
fs::create_dir_all(&target).map_err(|e| e.to_string())?;
} else {
if let Some(parent) = target.parent() {
fs::create_dir_all(parent).map_err(|e| e.to_string())?;
}
fs::copy(entry.path(), &target).map_err(|e| e.to_string())?;
done += 1;
let _ = app.emit("migrate_progress", serde_json::json!({
"done": done, "total": total, "current": rel.to_string_lossy()
}));
}
}
fs::remove_dir_all(&src_path).map_err(|e| e.to_string())?;
Ok(())
}
#[tauri::command]
fn get_platform_ui_scale(window: tauri::Window) -> f64 {
window.scale_factor().unwrap_or(1.0)
}
fn kill_tachidesk(app: &tauri::AppHandle) {
let state = app.state::<ServerState>();
if let Some(child) = state.0.lock().unwrap().take() {
let _ = child.kill();
}
#[cfg(target_os = "windows")]
{
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x08000000;
let _ = std::process::Command::new("taskkill")
.args(["/F", "/FI", "IMAGENAME eq java.exe"])
.creation_flags(CREATE_NO_WINDOW)
.status();
for _ in 0..30 {
let still_running = std::process::Command::new("tasklist")
.args(["/FI", "IMAGENAME eq java.exe", "/NH"])
.creation_flags(CREATE_NO_WINDOW)
.output()
.map(|o| String::from_utf8_lossy(&o.stdout).contains("java.exe"))
.unwrap_or(false);
if !still_running { break; }
std::thread::sleep(std::time::Duration::from_millis(100));
}
}
#[cfg(not(target_os = "windows"))]
let _ = std::process::Command::new("pkill").args(["-f", "tachidesk"]).status();
}
const DEFAULT_SERVER_CONF: &str = r#"server.ip = "127.0.0.1"
server.port = 4567
server.webUIEnabled = false
server.initialOpenInBrowserEnabled = false
server.systemTrayEnabled = false
server.webUIInterface = "browser"
server.webUIFlavor = "WebUI"
server.webUIChannel = "stable"
server.electronPath = ""
server.debugLogsEnabled = false
server.downloadAsCbz = true
server.autoDownloadNewChapters = false
server.globalUpdateInterval = 12
server.maxSourcesInParallel = 6
server.extensionRepos = []
"#;
fn seed_server_conf(data_dir: &PathBuf) {
let conf_path = data_dir.join("server.conf");
if !conf_path.exists() {
if let Err(e) = std::fs::create_dir_all(data_dir) {
eprintln!("Could not create Suwayomi data dir: {e}");
return;
}
if let Err(e) = std::fs::write(&conf_path, DEFAULT_SERVER_CONF) {
eprintln!("Could not write server.conf: {e}");
}
return;
}
let Ok(contents) = std::fs::read_to_string(&conf_path) else { return };
let patched = patch_conf_key(
patch_conf_key(
patch_conf_key(contents, "server.webUIEnabled", "false"),
"server.initialOpenInBrowserEnabled", "false",
),
"server.systemTrayEnabled", "false",
);
let _ = std::fs::write(&conf_path, patched);
}
fn patch_conf_key(text: String, key: &str, value: &str) -> String {
let replacement = format!("{key} = {value}");
let lines: Vec<&str> = text.lines().collect();
if let Some(pos) = lines.iter().position(|l| l.trim_start().starts_with(key)) {
let mut out = lines
.iter()
.enumerate()
.map(|(i, l)| if i == pos { replacement.as_str() } else { l })
.collect::<Vec<_>>()
.join("\n");
out.push('\n');
return out;
}
let mut out = text;
if !out.ends_with('\n') { out.push('\n'); }
out.push_str(&replacement);
out.push('\n');
out
}
fn suwayomi_data_dir() -> PathBuf {
#[cfg(target_os = "windows")]
{
dirs::data_dir()
.unwrap_or_else(|| PathBuf::from("C:\\ProgramData"))
.join("moku\\tachidesk")
}
#[cfg(target_os = "macos")]
{
dirs::data_dir()
.unwrap_or_else(|| dirs::home_dir().unwrap_or_else(|| PathBuf::from("~")))
.join("io.github.moku_project.Moku.app/tachidesk")
}
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
{
let base = std::env::var("XDG_DATA_HOME")
.map(PathBuf::from)
.unwrap_or_else(|_| dirs::data_dir().unwrap_or_else(|| PathBuf::from("/tmp")));
base.join("moku/tachidesk")
}
}
struct ServerInvocation {
bin: String,
args: Vec<String>,
working_dir: Option<PathBuf>,
}
#[cfg(not(target_os = "macos"))]
fn find_java_in_bundle(bundle_dir: &PathBuf, log: &mut Option<std::fs::File>) -> Option<PathBuf> {
#[cfg(target_os = "windows")]
let java = strip_unc(bundle_dir.join("jre").join("bin").join("java.exe"));
#[cfg(not(target_os = "windows"))]
let java = bundle_dir.join("jre").join("bin").join("java");
do_log(log, &format!("[find_java] path: {:?} exists: {}", java, java.exists()));
if java.exists() { Some(java) } else { None }
}
fn do_log(log: &mut Option<std::fs::File>, msg: &str) {
eprintln!("{}", msg);
if let Some(f) = log {
let _ = writeln!(f, "{}", msg);
}
}
fn resolve_server_binary(
binary: &str,
app: &tauri::AppHandle,
log: &mut Option<std::fs::File>,
) -> Result<ServerInvocation, SpawnError> {
do_log(log, &format!("[resolve] binary = {:?}", binary));
if !binary.trim().is_empty() {
let path = strip_unc(PathBuf::from(binary.trim()));
do_log(log, &format!("[resolve] user path: {:?} exists={}", path, path.exists()));
if path.exists() {
return Ok(ServerInvocation {
bin: path.to_string_lossy().into_owned(),
args: vec![],
working_dir: path.parent().map(|p| p.to_path_buf()),
});
}
do_log(log, "[resolve] user path not found, falling through");
}
if let Ok(exe) = std::env::current_exe() {
if let Some(bin_dir) = exe.parent() {
for name in &["tachidesk-server", "suwayomi-launcher"] {
let p = bin_dir.join(name);
do_log(log, &format!("[resolve] sibling: {:?} exists={}", p, p.exists()));
if p.exists() {
return Ok(ServerInvocation {
bin: p.to_string_lossy().into_owned(),
args: vec![],
working_dir: Some(bin_dir.to_path_buf()),
});
}
}
}
}
#[cfg(not(target_os = "macos"))]
let resource_dir = {
let raw = app.path().resource_dir().unwrap_or_default();
let stripped = strip_unc(raw);
do_log(log, &format!("[resolve] resource_dir = {:?}", stripped));
stripped
};
#[cfg(not(target_os = "macos"))]
{
let bundle_dir = resource_dir.join("binaries").join("suwayomi-bundle");
let jar = bundle_dir.join("bin").join("Suwayomi-Server.jar");
do_log(log, &format!("[resolve] bundle_dir={:?} exists={}", bundle_dir, bundle_dir.exists()));
do_log(log, &format!("[resolve] jar={:?} exists={}", jar, jar.exists()));
match find_java_in_bundle(&bundle_dir, log) {
Some(java) if jar.exists() => {
do_log(log, "[resolve] using bundled JRE");
return Ok(ServerInvocation {
bin: java.to_string_lossy().into_owned(),
args: vec!["-jar".to_string(), jar.to_string_lossy().into_owned()],
working_dir: Some(bundle_dir),
});
}
_ => do_log(log, "[resolve] bundled JRE/jar not found, falling through"),
}
}
#[cfg(not(target_os = "macos"))]
{
for name in &["suwayomi-launcher", "suwayomi-launcher.sh", "tachidesk-server"] {
let p = resource_dir.join(name);
do_log(log, &format!("[resolve] sidecar: {:?} exists={}", p, p.exists()));
if p.exists() {
return Ok(ServerInvocation {
bin: p.to_string_lossy().into_owned(),
args: vec![],
working_dir: Some(resource_dir.clone()),
});
}
}
if let Some(java) = find_java_in_bundle(&resource_dir, log) {
let jar = std::fs::read_dir(&resource_dir)
.ok()
.and_then(|mut rd| {
rd.find(|e| e.as_ref().map(|e| e.file_name().to_string_lossy().ends_with(".jar")).unwrap_or(false))
.and_then(|e| e.ok())
.map(|e| e.path())
});
if let Some(jar_path) = jar {
do_log(log, &format!("[resolve] generic JRE java={:?} jar={:?}", java, jar_path));
return Ok(ServerInvocation {
bin: java.to_string_lossy().into_owned(),
args: vec!["-jar".to_string(), jar_path.to_string_lossy().into_owned()],
working_dir: Some(resource_dir),
});
}
}
}
#[cfg(target_os = "macos")]
{
let resource_dir = app.path().resource_dir().unwrap_or_default();
let contents_dir = resource_dir
.parent()
.unwrap_or(&resource_dir)
.to_path_buf();
do_log(log, &format!("[resolve] macOS contents_dir = {:?}", contents_dir));
const NATIVE_NAMES: &[&str] = &[
"suwayomi-server-aarch64-apple-darwin",
"suwayomi-server-x86_64-apple-darwin",
"suwayomi-server",
"suwayomi-launcher",
"suwayomi-launcher.sh",
"tachidesk-server",
];
let mut found_binary: Option<ServerInvocation> = None;
let mut found_java: Option<(PathBuf, PathBuf)> = None;
'outer: for depth in 0u8..=8 {
let entries: Vec<PathBuf> = WalkDir::new(&contents_dir)
.min_depth(depth as usize)
.max_depth(depth as usize)
.into_iter()
.filter_map(|e| e.ok())
.filter(|e| e.file_type().is_dir())
.map(|e| e.into_path())
.collect();
for dir in &entries {
do_log(log, &format!("[resolve] scanning depth={} dir={:?}", depth, dir));
for name in NATIVE_NAMES {
let p = dir.join(name);
if p.exists() {
do_log(log, &format!("[resolve] found native binary: {:?}", p));
found_binary = Some(ServerInvocation {
bin: p.to_string_lossy().into_owned(),
args: vec![],
working_dir: Some(dir.clone()),
});
break 'outer;
}
}
if found_java.is_none() {
let java_exe = dir.join("bin").join("java");
if java_exe.exists() {
do_log(log, &format!("[resolve] found java: {:?}", java_exe));
let mut search = dir.as_path();
'jar: for _ in 0..5 {
if let Ok(rd) = std::fs::read_dir(search) {
for entry in rd.filter_map(|e| e.ok()) {
if entry.file_name().to_string_lossy().ends_with(".jar") {
let jar = entry.path();
do_log(log, &format!("[resolve] found jar: {:?}", jar));
found_java = Some((java_exe.clone(), jar));
break 'jar;
}
}
}
let bin_sibling = search.join("bin");
if let Ok(rd) = std::fs::read_dir(&bin_sibling) {
for entry in rd.filter_map(|e| e.ok()) {
if entry.file_name().to_string_lossy().ends_with(".jar") {
let jar = entry.path();
do_log(log, &format!("[resolve] found jar in bin/: {:?}", jar));
found_java = Some((java_exe.clone(), jar));
break 'jar;
}
}
}
match search.parent() {
Some(p) => search = p,
None => break,
}
}
}
}
}
}
if let Some(inv) = found_binary {
return Ok(inv);
}
if let Some((java, jar)) = found_java {
let working_dir = jar.parent().map(|p| p.to_path_buf());
return Ok(ServerInvocation {
bin: java.to_string_lossy().into_owned(),
args: vec!["-jar".to_string(), jar.to_string_lossy().into_owned()],
working_dir,
});
}
do_log(log, "[resolve] macOS scan found nothing in bundle");
}
for name in &["suwayomi-server", "tachidesk-server"] {
#[cfg(target_os = "windows")]
let found = std::process::Command::new("where").arg(name).output().map(|o| o.status.success()).unwrap_or(false);
#[cfg(not(target_os = "windows"))]
let found = std::process::Command::new("which").arg(name).output().map(|o| o.status.success()).unwrap_or(false);
if found {
return Ok(ServerInvocation { bin: name.to_string(), args: vec![], working_dir: None });
}
}
Err(SpawnError::NotConfigured(
"Server binary not found. Install Suwayomi-Server or set the path in Settings.".to_string(),
))
}
#[tauri::command]
fn spawn_server(binary: String, app: tauri::AppHandle) -> Result<(), SpawnError> {
{
let state = app.state::<ServerState>();
if state.0.lock().unwrap().is_some() {
return Ok(());
}
}
let data_dir = suwayomi_data_dir();
let log_path = data_dir.join("moku-spawn.log");
let _ = std::fs::create_dir_all(&data_dir);
let mut log = std::fs::OpenOptions::new().create(true).append(true).open(&log_path).ok();
do_log(&mut log, &format!("[spawn_server] binary={:?} data_dir={:?}", binary, data_dir));
seed_server_conf(&data_dir);
let mut invocation = resolve_server_binary(&binary, &app, &mut log).map_err(|e| {
do_log(&mut log, &format!("[spawn_server] resolve failed: {:?}", e));
e
})?;
if invocation.bin.ends_with("java") || invocation.bin.ends_with("java.exe") {
let rootdir_flag = format!(
"-Dsuwayomi.tachidesk.config.server.rootDir={}",
data_dir.to_string_lossy()
);
invocation.args.insert(0, rootdir_flag);
}
let working_dir = invocation.working_dir.unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
do_log(&mut log, &format!("[spawn_server] bin={:?} args={:?} cwd={:?}", invocation.bin, invocation.args, working_dir));
let cmd = app.shell()
.command(&invocation.bin)
.env("JAVA_TOOL_OPTIONS", "-Djava.awt.headless=true")
.args(&invocation.args)
.current_dir(&working_dir);
match cmd.spawn() {
Ok((_rx, child)) => {
*app.state::<ServerState>().0.lock().unwrap() = Some(child);
Ok(())
}
Err(e) => {
do_log(&mut log, &format!("[spawn_server] spawn failed: {}", e));
Err(SpawnError::SpawnFailed(e.to_string()))
}
}
}
#[tauri::command]
fn kill_server(app: tauri::AppHandle) -> Result<(), String> {
kill_tachidesk(&app);
Ok(())
}
#[tauri::command]
async fn list_releases() -> Result<Vec<ReleaseInfo>, String> {
use tauri_plugin_http::reqwest;
let client = reqwest::Client::builder()
.user_agent("Moku")
.build()
.map_err(|e| e.to_string())?;
let resp = client
.get("https://api.github.com/repos/moku-project/Moku/releases?per_page=30")
.send()
.await
.map_err(|e| e.to_string())?;
if !resp.status().is_success() {
return Err(format!("GitHub API returned {}", resp.status()));
}
#[derive(serde::Deserialize)]
struct GhRelease {
tag_name: String,
name: Option<String>,
body: Option<String>,
published_at: Option<String>,
html_url: String,
}
let body = resp.text().await.map_err(|e| e.to_string())?;
let releases: Vec<GhRelease> = serde_json::from_str(&body).map_err(|e| e.to_string())?;
Ok(releases.into_iter().map(|r| ReleaseInfo {
tag_name: r.tag_name.clone(),
name: r.name.unwrap_or_else(|| r.tag_name.clone()),
body: r.body.unwrap_or_default(),
published_at: r.published_at.unwrap_or_default(),
html_url: r.html_url,
}).collect())
}
#[tauri::command]
#[allow(unused_variables)]
async fn download_and_install_update(app: tauri::AppHandle, tag: String) -> Result<(), String> {
#[cfg(not(target_os = "windows"))]
return Err("Native install is Windows-only; open the GitHub release page instead.".into());
#[cfg(target_os = "windows")]
{
use tauri_plugin_http::reqwest;
use std::io::Write;
let client = reqwest::Client::builder()
.user_agent("Moku")
.build()
.map_err(|e| e.to_string())?;
let url = format!("https://api.github.com/repos/moku-project/Moku/releases/tags/{}", tag);
let resp = client.get(&url).send().await.map_err(|e| e.to_string())?;
if !resp.status().is_success() {
return Err(format!("GitHub API returned {} for tag {}", resp.status(), tag));
}
#[derive(serde::Deserialize)]
struct Asset { name: String, browser_download_url: String, size: u64 }
#[derive(serde::Deserialize)]
struct Release { assets: Vec<Asset> }
let body = resp.text().await.map_err(|e| e.to_string())?;
let release: Release = serde_json::from_str(&body).map_err(|e| e.to_string())?;
let asset = release.assets
.into_iter()
.find(|a| a.name.ends_with("_x64-setup.exe"))
.ok_or_else(|| format!("No x64-setup.exe asset found in release {}", tag))?;
let total = if asset.size > 0 { Some(asset.size) } else { None };
let mut resp = client.get(&asset.browser_download_url).send().await.map_err(|e| e.to_string())?;
let tmp_path = std::env::temp_dir().join(&asset.name);
let mut file = std::fs::File::create(&tmp_path).map_err(|e| e.to_string())?;
let mut downloaded: u64 = 0;
while let Some(chunk) = resp.chunk().await.map_err(|e| e.to_string())? {
file.write_all(&chunk).map_err(|e| e.to_string())?;
downloaded += chunk.len() as u64;
let _ = app.emit("update-progress", UpdateProgress { downloaded, total });
}
drop(file);
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x08000000;
std::process::Command::new(&tmp_path)
.creation_flags(CREATE_NO_WINDOW)
.spawn()
.map_err(|e| e.to_string())?;
let _ = app.emit("update-launching", ());
Ok(())
}
}
#[tauri::command]
fn restart_app(app: tauri::AppHandle) {
tauri::process::restart(&app.env());
}
#[tauri::command]
fn open_path(path: String) -> Result<(), String> {
#[cfg(target_os = "windows")]
{
let p = strip_unc(std::path::PathBuf::from(path.trim()));
std::process::Command::new("explorer")
.arg(p)
.spawn()
.map_err(|e| e.to_string())?;
}
#[cfg(target_os = "macos")]
{
let p = std::path::Path::new(path.trim());
std::process::Command::new("open")
.arg(p)
.spawn()
.map_err(|e| e.to_string())?;
}
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
{
let p = std::path::Path::new(path.trim());
std::process::Command::new("xdg-open")
.arg(p)
.spawn()
.map_err(|e| e.to_string())?;
}
Ok(())
}
#[tauri::command]
async fn pick_downloads_folder(app: tauri::AppHandle) -> Option<String> {
use tauri_plugin_dialog::DialogExt;
app.dialog()
.file()
.set_title("Choose Downloads Folder")
.blocking_pick_folder()
.map(|p| p.to_string())
}
fn moku_backup_dir(app: &tauri::AppHandle) -> PathBuf {
app.path().app_data_dir()
.unwrap_or_else(|_| PathBuf::from("."))
.join("backups")
}
#[tauri::command]
async fn export_app_data(app: tauri::AppHandle, json: String) -> Result<String, String> {
use tauri_plugin_dialog::DialogExt;
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let filename = format!("moku-backup-{}.json", now);
let path = app.dialog()
.file()
.set_title("Save Moku app data backup")
.set_file_name(&filename)
.blocking_save_file()
.ok_or("Cancelled")?;
let dest = PathBuf::from(path.to_string());
std::fs::write(&dest, json.as_bytes()).map_err(|e| e.to_string())?;
Ok(dest.to_string_lossy().into_owned())
}
#[tauri::command]
async fn import_app_data(app: tauri::AppHandle) -> Result<String, String> {
use tauri_plugin_dialog::DialogExt;
let path = app.dialog()
.file()
.set_title("Open Moku app data backup")
.blocking_pick_file()
.ok_or("Cancelled")?;
let src = PathBuf::from(path.to_string());
let contents = std::fs::read_to_string(&src).map_err(|e| e.to_string())?;
Ok(contents)
}
#[tauri::command]
fn auto_backup_app_data(app: tauri::AppHandle, json: String) -> Result<(), String> {
let backup_dir = moku_backup_dir(&app);
std::fs::create_dir_all(&backup_dir).map_err(|e| e.to_string())?;
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let dest = backup_dir.join(format!("auto-moku-backup-{}.json", now));
std::fs::write(&dest, json.as_bytes()).map_err(|e| e.to_string())?;
let mut entries: Vec<_> = std::fs::read_dir(&backup_dir)
.map_err(|e| e.to_string())?
.filter_map(|e| e.ok())
.filter(|e| e.file_name().to_string_lossy().starts_with("auto-moku-backup-"))
.collect();
entries.sort_by_key(|e| e.file_name());
for old in entries.iter().take(entries.len().saturating_sub(5)) {
let _ = std::fs::remove_file(old.path());
}
Ok(())
}
#[tauri::command]
fn get_auto_backup_dir(app: tauri::AppHandle) -> String {
moku_backup_dir(&app).to_string_lossy().into_owned()
}
#[cfg_attr(mobile, tauri::mobile_entry_point)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() { pub fn run() {
tauri::Builder::default() tauri::Builder::default()
.plugin(tauri_plugin_store::Builder::new().build())
.plugin(tauri_plugin_discord_rpc::init()) .plugin(tauri_plugin_discord_rpc::init())
.plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_os::init()) .plugin(tauri_plugin_os::init())
@@ -813,28 +19,35 @@ pub fn run() {
.plugin(tauri_plugin_process::init()) .plugin(tauri_plugin_process::init())
.manage(ServerState(Mutex::new(None))) .manage(ServerState(Mutex::new(None)))
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
get_storage_info, commands::storage::get_storage_info,
get_default_downloads_path, commands::storage::get_default_downloads_path,
check_path_exists, commands::storage::check_path_exists,
create_directory, commands::storage::create_directory,
migrate_downloads, commands::storage::migrate_downloads,
spawn_server, commands::server::spawn_server,
kill_server, commands::server::kill_server,
get_platform_ui_scale, commands::system::get_platform_ui_scale,
list_releases, commands::system::restart_app,
download_and_install_update, commands::system::exit_app,
restart_app, commands::system::clear_moku_cache,
open_path, commands::system::clear_suwayomi_cache,
pick_downloads_folder, commands::system::reset_suwayomi_data,
export_app_data, commands::system::open_path,
import_app_data, commands::system::pick_downloads_folder,
auto_backup_app_data, commands::backup::export_app_data,
get_auto_backup_dir, commands::backup::import_app_data,
commands::backup::auto_backup_app_data,
commands::backup::get_auto_backup_dir,
commands::backup::read_store_files,
commands::updater::list_releases,
commands::updater::download_and_install_update,
commands::biometric::windows_hello_authenticate,
commands::biometric::windows_hello_available,
]) ])
.setup(|_app| Ok(())) .setup(|_app| Ok(()))
.on_window_event(|window, event| { .on_window_event(|window, event| {
if let WindowEvent::Destroyed = event { if let WindowEvent::Destroyed = event {
kill_tachidesk(window.app_handle()); server::kill_tachidesk(window.app_handle());
} }
}) })
.run(tauri::generate_context!()) .run(tauri::generate_context!())
+1 -1
View File
@@ -2,4 +2,4 @@
fn main() { fn main() {
moku_lib::run(); moku_lib::run();
} }
+73
View File
@@ -0,0 +1,73 @@
use std::path::PathBuf;
const DEFAULT_SERVER_CONF: &str = r#"server.ip = "127.0.0.1"
server.port = 4567
server.webUIEnabled = true
server.initialOpenInBrowserEnabled = false
server.systemTrayEnabled = false
server.webUIInterface = "browser"
server.webUIFlavor = "WebUI"
server.webUIChannel = "preview"
server.electronPath = ""
server.debugLogsEnabled = false
server.downloadAsCbz = true
server.autoDownloadNewChapters = false
server.globalUpdateInterval = 12
server.maxSourcesInParallel = 6
server.extensionRepos = []
"#;
pub fn seed_server_conf(data_dir: &PathBuf) {
let conf_path = data_dir.join("server.conf");
if !conf_path.exists() {
if let Err(e) = std::fs::create_dir_all(data_dir) {
eprintln!("Could not create Suwayomi data dir: {e}");
return;
}
if let Err(e) = std::fs::write(&conf_path, DEFAULT_SERVER_CONF) {
eprintln!("Could not write server.conf: {e}");
}
return;
}
let Ok(contents) = std::fs::read_to_string(&conf_path) else {
return;
};
let patched = patch_conf_key(
patch_conf_key(
patch_conf_key(contents, "server.webUIEnabled", "true"),
"server.initialOpenInBrowserEnabled",
"false",
),
"server.systemTrayEnabled",
"false",
);
let _ = std::fs::write(&conf_path, patched);
}
fn patch_conf_key(text: String, key: &str, value: &str) -> String {
let replacement = format!("{key} = {value}");
let lines: Vec<&str> = text.lines().collect();
if let Some(pos) = lines.iter().position(|l| l.trim_start().starts_with(key)) {
let mut out = lines
.iter()
.enumerate()
.map(|(i, l)| if i == pos { replacement.as_str() } else { l })
.collect::<Vec<_>>()
.join("\n");
out.push('\n');
return out;
}
let mut out = text;
if !out.ends_with('\n') {
out.push('\n');
}
out.push_str(&replacement);
out.push('\n');
out
}
+53
View File
@@ -0,0 +1,53 @@
pub mod conf;
pub mod resolve;
use std::io::Write;
use tauri::Manager;
use crate::ServerState;
pub use resolve::SpawnError;
pub fn do_log(log: &mut Option<std::fs::File>, msg: &str) {
eprintln!("{}", msg);
if let Some(f) = log {
let _ = writeln!(f, "{}", msg);
}
}
pub fn kill_tachidesk(app: &tauri::AppHandle) {
let state = app.state::<ServerState>();
if let Some(child) = state.0.lock().unwrap().take() {
let _ = child.kill();
}
#[cfg(target_os = "windows")]
{
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x08000000;
let _ = std::process::Command::new("taskkill")
.args(["/F", "/FI", "IMAGENAME eq java.exe"])
.creation_flags(CREATE_NO_WINDOW)
.status();
for _ in 0..30 {
let still_running = std::process::Command::new("tasklist")
.args(["/FI", "IMAGENAME eq java.exe", "/NH"])
.creation_flags(CREATE_NO_WINDOW)
.output()
.map(|o| String::from_utf8_lossy(&o.stdout).contains("java.exe"))
.unwrap_or(false);
if !still_running {
break;
}
std::thread::sleep(std::time::Duration::from_millis(100));
}
}
#[cfg(not(target_os = "windows"))]
let _ = std::process::Command::new("pkill")
.args(["-f", "tachidesk"])
.status();
}
+339
View File
@@ -0,0 +1,339 @@
use crate::server::do_log;
use serde::Serialize;
use std::path::PathBuf;
use walkdir::WalkDir;
use tauri::Manager;
#[derive(Serialize, Debug)]
#[serde(tag = "kind", content = "message")]
pub enum SpawnError {
NotConfigured(String),
SpawnFailed(String),
}
pub struct ServerInvocation {
pub bin: String,
pub args: Vec<String>,
pub working_dir: Option<PathBuf>,
}
pub fn suwayomi_data_dir() -> PathBuf {
#[cfg(target_os = "windows")]
{
dirs::data_dir()
.unwrap_or_else(|| PathBuf::from("C:\\ProgramData"))
.join("Tachidesk")
}
#[cfg(target_os = "macos")]
{
dirs::data_dir()
.unwrap_or_else(|| dirs::home_dir().unwrap_or_else(|| PathBuf::from("~")))
.join("Tachidesk")
}
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
{
let base = std::env::var("XDG_DATA_HOME")
.map(PathBuf::from)
.unwrap_or_else(|_| dirs::data_dir().unwrap_or_else(|| PathBuf::from("/tmp")));
base.join("Tachidesk")
}
}
pub fn strip_unc(path: PathBuf) -> PathBuf {
let s = path.to_string_lossy();
if let Some(stripped) = s.strip_prefix(r"\\?\") {
PathBuf::from(stripped)
} else {
path
}
}
#[cfg(not(target_os = "macos"))]
fn find_java_in_bundle(bundle_dir: &PathBuf, log: &mut Option<std::fs::File>) -> Option<PathBuf> {
#[cfg(target_os = "windows")]
let java = strip_unc(bundle_dir.join("jre").join("bin").join("java.exe"));
#[cfg(not(target_os = "windows"))]
let java = bundle_dir.join("jre").join("bin").join("java");
do_log(
log,
&format!("[find_java] path: {:?} exists: {}", java, java.exists()),
);
if java.exists() {
Some(java)
} else {
None
}
}
fn data_root_args() -> Vec<String> {
vec!["--dataRoot".to_string(), suwayomi_data_dir().to_string_lossy().into_owned()]
}
fn jar_data_root_flag() -> String {
format!("-Dsuwayomi.server.rootDir={}", suwayomi_data_dir().to_string_lossy())
}
pub fn resolve_server_binary(
binary: &str,
app: &tauri::AppHandle,
log: &mut Option<std::fs::File>,
) -> Result<ServerInvocation, SpawnError> {
do_log(log, &format!("[resolve] binary = {:?}", binary));
if !binary.trim().is_empty() {
let path = strip_unc(PathBuf::from(binary.trim()));
do_log(
log,
&format!("[resolve] user path: {:?} exists={}", path, path.exists()),
);
if path.exists() {
return Ok(ServerInvocation {
bin: path.to_string_lossy().into_owned(),
args: data_root_args(),
working_dir: path.parent().map(|p| p.to_path_buf()),
});
}
do_log(log, "[resolve] user path not found, falling through");
}
if let Ok(exe) = std::env::current_exe() {
if let Some(bin_dir) = exe.parent() {
for name in &["tachidesk-server", "suwayomi-launcher"] {
let p = bin_dir.join(name);
do_log(
log,
&format!("[resolve] sibling: {:?} exists={}", p, p.exists()),
);
if p.exists() {
return Ok(ServerInvocation {
bin: p.to_string_lossy().into_owned(),
args: data_root_args(),
working_dir: Some(bin_dir.to_path_buf()),
});
}
}
}
}
#[cfg(not(target_os = "macos"))]
let resource_dir = {
let raw = app.path().resource_dir().unwrap_or_default();
let stripped = strip_unc(raw);
do_log(log, &format!("[resolve] resource_dir = {:?}", stripped));
stripped
};
#[cfg(not(target_os = "macos"))]
{
let bundle_dir = resource_dir.join("binaries").join("suwayomi-bundle");
let jar = bundle_dir.join("bin").join("Suwayomi-Server.jar");
do_log(
log,
&format!(
"[resolve] bundle_dir={:?} exists={}",
bundle_dir,
bundle_dir.exists()
),
);
do_log(
log,
&format!("[resolve] jar={:?} exists={}", jar, jar.exists()),
);
match find_java_in_bundle(&bundle_dir, log) {
Some(java) if jar.exists() => {
do_log(log, "[resolve] using bundled JRE");
return Ok(ServerInvocation {
bin: java.to_string_lossy().into_owned(),
args: vec![jar_data_root_flag(), "-jar".to_string(), jar.to_string_lossy().into_owned()],
working_dir: Some(bundle_dir),
});
}
_ => do_log(log, "[resolve] bundled JRE/jar not found, falling through"),
}
for name in &[
"suwayomi-launcher",
"suwayomi-launcher.sh",
"tachidesk-server",
] {
let p = resource_dir.join(name);
do_log(
log,
&format!("[resolve] sidecar: {:?} exists={}", p, p.exists()),
);
if p.exists() {
return Ok(ServerInvocation {
bin: p.to_string_lossy().into_owned(),
args: data_root_args(),
working_dir: Some(resource_dir.clone()),
});
}
}
if let Some(java) = find_java_in_bundle(&resource_dir, log) {
let jar = std::fs::read_dir(&resource_dir).ok().and_then(|mut rd| {
rd.find(|e| {
e.as_ref()
.map(|e| e.file_name().to_string_lossy().ends_with(".jar"))
.unwrap_or(false)
})
.and_then(|e| e.ok())
.map(|e| e.path())
});
if let Some(jar_path) = jar {
do_log(
log,
&format!("[resolve] generic JRE java={:?} jar={:?}", java, jar_path),
);
return Ok(ServerInvocation {
bin: java.to_string_lossy().into_owned(),
args: vec![jar_data_root_flag(), "-jar".to_string(), jar_path.to_string_lossy().into_owned()],
working_dir: Some(resource_dir),
});
}
}
}
#[cfg(target_os = "macos")]
{
let resource_dir = app.path().resource_dir().unwrap_or_default();
let contents_dir = resource_dir.parent().unwrap_or(&resource_dir).to_path_buf();
do_log(
log,
&format!("[resolve] macOS contents_dir = {:?}", contents_dir),
);
const NATIVE_NAMES: &[&str] = &[
"suwayomi-server-aarch64-apple-darwin",
"suwayomi-server-x86_64-apple-darwin",
"suwayomi-server",
"suwayomi-launcher",
"suwayomi-launcher.sh",
"tachidesk-server",
];
let mut found_binary: Option<ServerInvocation> = None;
let mut found_java: Option<(PathBuf, PathBuf)> = None;
'outer: for depth in 0u8..=8 {
let entries: Vec<PathBuf> = WalkDir::new(&contents_dir)
.min_depth(depth as usize)
.max_depth(depth as usize)
.into_iter()
.filter_map(|e| e.ok())
.filter(|e| e.file_type().is_dir())
.map(|e| e.into_path())
.collect();
for dir in &entries {
do_log(
log,
&format!("[resolve] scanning depth={} dir={:?}", depth, dir),
);
for name in NATIVE_NAMES {
let p = dir.join(name);
if p.exists() {
do_log(log, &format!("[resolve] found native binary: {:?}", p));
found_binary = Some(ServerInvocation {
bin: p.to_string_lossy().into_owned(),
args: data_root_args(),
working_dir: Some(dir.clone()),
});
break 'outer;
}
}
if found_java.is_none() {
let java_exe = dir.join("bin").join("java");
if java_exe.exists() {
do_log(log, &format!("[resolve] found java: {:?}", java_exe));
let mut search = dir.as_path();
'jar: for _ in 0..5 {
if let Ok(rd) = std::fs::read_dir(search) {
for entry in rd.filter_map(|e| e.ok()) {
if entry.file_name().to_string_lossy().ends_with(".jar") {
let jar = entry.path();
do_log(log, &format!("[resolve] found jar: {:?}", jar));
found_java = Some((java_exe.clone(), jar));
break 'jar;
}
}
}
let bin_sibling = search.join("bin");
if let Ok(rd) = std::fs::read_dir(&bin_sibling) {
for entry in rd.filter_map(|e| e.ok()) {
if entry.file_name().to_string_lossy().ends_with(".jar") {
let jar = entry.path();
do_log(
log,
&format!("[resolve] found jar in bin/: {:?}", jar),
);
found_java = Some((java_exe.clone(), jar));
break 'jar;
}
}
}
match search.parent() {
Some(p) => search = p,
None => break,
}
}
}
}
}
}
if let Some(inv) = found_binary {
return Ok(inv);
}
if let Some((java, jar)) = found_java {
let working_dir = jar.parent().map(|p| p.to_path_buf());
return Ok(ServerInvocation {
bin: java.to_string_lossy().into_owned(),
args: vec![jar_data_root_flag(), "-jar".to_string(), jar.to_string_lossy().into_owned()],
working_dir,
});
}
do_log(log, "[resolve] macOS scan found nothing in bundle");
}
for name in &["suwayomi-server", "tachidesk-server"] {
#[cfg(target_os = "windows")]
let resolved = std::process::Command::new("where")
.arg(name)
.output()
.ok()
.filter(|o| o.status.success())
.and_then(|o| String::from_utf8(o.stdout).ok())
.and_then(|s| s.lines().next().map(|l| l.trim().to_string()));
#[cfg(not(target_os = "windows"))]
let resolved = std::process::Command::new("which")
.arg(name)
.output()
.ok()
.filter(|o| o.status.success())
.and_then(|o| String::from_utf8(o.stdout).ok())
.and_then(|s| s.lines().next().map(|l| l.trim().to_string()));
if let Some(bin_path) = resolved {
do_log(log, &format!("[resolve] found on PATH: {:?}", bin_path));
return Ok(ServerInvocation {
bin: bin_path,
args: vec![],
working_dir: None,
});
}
}
Err(SpawnError::NotConfigured(
"Server binary not found. Install Suwayomi-Server or set the path in Settings.".to_string(),
))
}
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "Moku", "productName": "Moku",
"version": "0.9.1", "version": "0.9.2",
"identifier": "io.github.MokuProject.Moku", "identifier": "io.github.MokuProject.Moku",
"build": { "build": {
"frontendDist": "../dist", "frontendDist": "../dist",
+9 -3
View File
@@ -6,7 +6,7 @@
import { platform } from "@tauri-apps/plugin-os"; import { platform } from "@tauri-apps/plugin-os";
import { store, setActiveDownloads } from "@store/state.svelte"; import { store, setActiveDownloads } from "@store/state.svelte";
import { downloadStore } from "@features/downloads/store/downloadState.svelte"; import { downloadStore } from "@features/downloads/store/downloadState.svelte";
import { boot, startProbe, stopProbe, retryBoot, bypassBoot } from "@store/boot.svelte"; import { boot, initStore, startProbe, stopProbe, retryBoot, bypassBoot } from "@store/boot.svelte";
import { initRpc, setIdle, clearReading, destroyRpc } from "@store/discord"; import { initRpc, setIdle, clearReading, destroyRpc } from "@store/discord";
import { applyTheme } from "@core/theme"; import { applyTheme } from "@core/theme";
import { applyZoom, mountZoomKey, mountIdleDetection } from "@core/ui"; import { applyZoom, mountZoomKey, mountIdleDetection } from "@core/ui";
@@ -100,6 +100,7 @@
}); });
} }
await initStore();
startProbe(); startProbe();
const unlistenDownload = await listen<{ chapterId: number; mangaId: number; progress: number }[]>( const unlistenDownload = await listen<{ chapterId: number; mangaId: number; progress: number }[]>(
@@ -127,7 +128,7 @@
<SplashScreen mode="idle" showFps showCards={store.settings.splashCards ?? true} <SplashScreen mode="idle" showFps showCards={store.settings.splashCards ?? true}
onDismiss={() => setTimeout(() => devSplash = false, 340)} /> onDismiss={() => setTimeout(() => devSplash = false, 340)} />
{:else if !appReady && !boot.loginRequired && !boot.unsupportedMode} {:else if !appReady && !boot.loginRequired}
<SplashScreen mode="loading" ringFull={boot.serverProbeOk} <SplashScreen mode="loading" ringFull={boot.serverProbeOk}
failed={boot.failed} notConfigured={boot.notConfigured} failed={boot.failed} notConfigured={boot.notConfigured}
showCards={store.settings.splashCards ?? true} showCards={store.settings.splashCards ?? true}
@@ -135,7 +136,7 @@
onRetry={retryBoot} onRetry={retryBoot}
onBypass={() => bypassBoot(() => { appReady = true; })} /> onBypass={() => bypassBoot(() => { appReady = true; })} />
{:else if boot.unsupportedMode || boot.loginRequired} {:else if boot.loginRequired}
<SplashScreen mode="loading" ringFull={true} showCards={store.settings.splashCards ?? true} /> <SplashScreen mode="loading" ringFull={true} showCards={store.settings.splashCards ?? true} />
<AuthGate onReady={() => { appReady = true; }} /> <AuthGate onReady={() => { appReady = true; }} />
@@ -145,6 +146,11 @@
onDismiss={() => { idle = false; }} /> onDismiss={() => { idle = false; }} />
{/if} {/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"> <div id="app-shell" class="root">
{#if !store.activeChapter}<TitleBar />{/if} {#if !store.activeChapter}<TitleBar />{/if}
<div class="content"> <div class="content">
+73 -15
View File
@@ -1,9 +1,24 @@
import { store } from "@store/state.svelte"; import { store } from "@store/state.svelte";
import { fetchAuthenticated } from "../core/auth"; import { fetchAuthenticated, AuthRequiredError, uiAuth } from "../core/auth";
import { boot } from "@store/boot.svelte";
import { getBlobUrl } from "@core/cache/imageCache";
const DEFAULT_URL = "http://127.0.0.1:4567"; const DEFAULT_URL = "http://127.0.0.1:4567";
function getServerUrl(): string { type ReauthResolver = () => void;
let _reauthQueue: ReauthResolver[] = [];
export function notifyReauthSuccess() {
const queue = _reauthQueue;
_reauthQueue = [];
queue.forEach(resolve => resolve());
}
function waitForReauth(): Promise<void> {
return new Promise(resolve => { _reauthQueue.push(resolve); });
}
export function getServerUrl(): string {
const url = store.settings.serverUrl; const url = store.settings.serverUrl;
return typeof url === "string" && url.trim() ? url.replace(/\/$/, "") : DEFAULT_URL; return typeof url === "string" && url.trim() ? url.replace(/\/$/, "") : DEFAULT_URL;
} }
@@ -14,6 +29,14 @@ export function plainThumbUrl(path: string): string {
return `${getServerUrl()}${path}`; return `${getServerUrl()}${path}`;
} }
export async function resolveImageUrl(path: string): Promise<string> {
if (!path) return "";
const url = path.startsWith("http") ? path : `${getServerUrl()}${path}`;
const mode = store.settings.serverAuthMode ?? "NONE";
if (mode === "NONE") return url;
return getBlobUrl(url);
}
export const thumbUrl = plainThumbUrl; export const thumbUrl = plainThumbUrl;
interface GQLResponse<T> { interface GQLResponse<T> {
@@ -43,12 +66,13 @@ async function fetchWithRetry(
for (let i = 0; i < retries; i++) { for (let i = 0; i < retries; i++) {
if (signal?.aborted) throw new DOMException("Aborted", "AbortError"); if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
try { try {
const res = await fetchAuthenticated(url, init, signal); const res = await fetchAuthenticated(url, init, signal, boot.skipped);
if (signal?.aborted) throw new DOMException("Aborted", "AbortError"); if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
return res; return res;
} catch (e: any) { } catch (e: any) {
if (e?.authRequired) throw e; if (e?.authRequired) throw e;
if (e?.name === "AbortError" || signal?.aborted) throw new DOMException("Aborted", "AbortError"); if (e?.name === "AbortError" || signal?.aborted) throw new DOMException("Aborted", "AbortError");
if (e instanceof AuthRequiredError) throw e;
if (i === retries - 1) throw e; if (i === retries - 1) throw e;
await abortableSleep(delayMs * Math.pow(1.5, i), signal); await abortableSleep(delayMs * Math.pow(1.5, i), signal);
} }
@@ -56,20 +80,54 @@ async function fetchWithRetry(
throw new Error("unreachable"); throw new Error("unreachable");
} }
export async function fetchImage(
path: string,
signal?: AbortSignal,
): Promise<{ src: string; revoke: () => void }> {
if (!path) return { src: "", revoke: () => {} };
const url = path.startsWith("http") ? path : `${getServerUrl()}${path}`;
const mode = store.settings.serverAuthMode ?? "NONE";
if (mode === "NONE") return { src: url, revoke: () => {} };
const res = await fetchWithRetry(url, { method: "GET" }, signal);
if (!res.ok) throw new Error(`Image fetch failed: ${res.status}`);
const blob = await res.blob();
const src = URL.createObjectURL(blob);
return { src, revoke: () => URL.revokeObjectURL(src) };
}
export async function gql<T>( export async function gql<T>(
query: string, query: string,
variables?: Record<string, unknown>, variables?: Record<string, unknown>,
signal?: AbortSignal, signal?: AbortSignal,
): Promise<T> { ): Promise<T> {
const res = await fetchWithRetry( const attempt = async (): Promise<T> => {
`${getServerUrl()}/api/graphql`, const res = await fetchWithRetry(
{ method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ query, variables }) }, `${getServerUrl()}/api/graphql`,
signal, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ query, variables }) },
); signal,
if (signal?.aborted) throw new DOMException("Aborted", "AbortError"); );
if (!res.ok) throw new Error(`Suwayomi HTTP ${res.status}`); if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
const json: GQLResponse<T> = await res.json(); if (!res.ok) throw new Error(`Suwayomi HTTP ${res.status}`);
if (signal?.aborted) throw new DOMException("Aborted", "AbortError"); const json: GQLResponse<T> = await res.json();
if (json.errors?.length) throw new Error(json.errors[0].message); if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
return json.data; if (json.errors?.length) {
} const isAuthError = json.errors.some(e => /unauthorized|unauthenticated/i.test(e.message));
if (isAuthError && !boot.skipped) {
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();
}
+17 -1
View File
@@ -3,7 +3,7 @@ export const FETCH_CHAPTERS = `
fetchChapters(input: { mangaId: $mangaId }) { fetchChapters(input: { mangaId: $mangaId }) {
chapters { chapters {
id name chapterNumber sourceOrder isRead isDownloaded isBookmarked id name chapterNumber sourceOrder isRead isDownloaded isBookmarked
pageCount mangaId uploadDate realUrl lastPageRead scanlator pageCount mangaId uploadDate realUrl lastPageRead lastReadAt scanlator
} }
} }
} }
@@ -46,3 +46,19 @@ export const DELETE_DOWNLOADED_CHAPTERS = `
} }
} }
`; `;
export const SET_CHAPTER_META = `
mutation SetChapterMeta($chapterId: Int!, $key: String!, $value: String!) {
setChapterMeta(input: { meta: { chapterId: $chapterId, key: $key, value: $value } }) {
meta { key value }
}
}
`;
export const DELETE_CHAPTER_META = `
mutation DeleteChapterMeta($chapterId: Int!, $key: String!) {
deleteChapterMeta(input: { chapterId: $chapterId, key: $key }) {
meta { key value }
}
}
`;
+85 -1
View File
@@ -17,6 +17,14 @@ export const UPDATE_EXTENSION = `
} }
`; `;
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 = ` export const INSTALL_EXTERNAL_EXTENSION = `
mutation InstallExternalExtension($url: String!) { mutation InstallExternalExtension($url: String!) {
installExternalExtension(input: { extensionUrl: $url }) { installExternalExtension(input: { extensionUrl: $url }) {
@@ -25,6 +33,82 @@ export const INSTALL_EXTERNAL_EXTENSION = `
} }
`; `;
export const UPDATE_SOURCE_PREFERENCE = `
mutation UpdateSourcePreference($source: LongString!, $change: SourcePreferenceChangeInput!) {
updateSourcePreference(input: { source: $source, change: $change }) {
source { id displayName }
}
}
`;
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 = ` export const SET_EXTENSION_REPOS = `
mutation SetExtensionRepos($repos: [String!]!) { mutation SetExtensionRepos($repos: [String!]!) {
setSettings(input: { settings: { extensionRepos: $repos } }) { setSettings(input: { settings: { extensionRepos: $repos } }) {
@@ -86,4 +170,4 @@ export const SET_FLARESOLVERR = `
} }
} }
} }
`; `;
+1 -1
View File
@@ -2,4 +2,4 @@ export * from "./manga";
export * from "./chapters"; export * from "./chapters";
export * from "./downloads"; export * from "./downloads";
export * from "./extensions"; export * from "./extensions";
export * from "./tracking"; export * from "./tracking";
+62
View File
@@ -33,6 +33,14 @@ export const UPDATE_MANGA_CATEGORIES = `
} }
`; `;
export const UPDATE_MANGAS_CATEGORIES = `
mutation UpdateMangasCategories($ids: [Int!]!, $addTo: [Int!]!, $removeFrom: [Int!]!) {
updateMangasCategories(input: { ids: $ids, patch: { addToCategories: $addTo, removeFromCategories: $removeFrom } }) {
mangas { id }
}
}
`;
export const CREATE_CATEGORY = ` export const CREATE_CATEGORY = `
mutation CreateCategory($name: String!) { mutation CreateCategory($name: String!) {
createCategory(input: { name: $name }) { createCategory(input: { name: $name }) {
@@ -49,6 +57,14 @@ export const UPDATE_CATEGORY = `
} }
`; `;
export const UPDATE_CATEGORIES = `
mutation UpdateCategories($ids: [Int!]!, $patch: UpdateCategoryPatchInput!) {
updateCategories(input: { ids: $ids, patch: $patch }) {
categories { id name order default includeInUpdate includeInDownload }
}
}
`;
export const DELETE_CATEGORY = ` export const DELETE_CATEGORY = `
mutation DeleteCategory($id: Int!) { mutation DeleteCategory($id: Int!) {
deleteCategory(input: { categoryId: $id }) { deleteCategory(input: { categoryId: $id }) {
@@ -65,6 +81,16 @@ export const UPDATE_CATEGORY_ORDER = `
} }
`; `;
export const UPDATE_CATEGORY_MANGA = `
mutation UpdateCategoryManga($categoryId: Int!) {
updateCategoryManga(input: { categoryId: $categoryId }) {
updateStatus {
jobsInfo { isRunning finishedJobs totalJobs }
}
}
}
`;
export const UPDATE_LIBRARY = ` export const UPDATE_LIBRARY = `
mutation UpdateLibrary { mutation UpdateLibrary {
updateLibrary(input: {}) { updateLibrary(input: {}) {
@@ -75,6 +101,26 @@ export const UPDATE_LIBRARY = `
} }
`; `;
export const UPDATE_LIBRARY_MANGA = `
mutation UpdateLibraryManga($mangaId: Int!) {
updateLibraryManga(input: { mangaId: $mangaId }) {
updateStatus {
jobsInfo { isRunning finishedJobs totalJobs }
}
}
}
`;
export const UPDATE_STOP = `
mutation UpdateStop {
updateStop(input: {}) {
updateStatus {
jobsInfo { isRunning finishedJobs totalJobs }
}
}
}
`;
export const CREATE_BACKUP = ` export const CREATE_BACKUP = `
mutation CreateBackup { mutation CreateBackup {
createBackup(input: {}) { url } createBackup(input: {}) { url }
@@ -89,3 +135,19 @@ export const RESTORE_BACKUP = `
} }
} }
`; `;
export const SET_MANGA_META = `
mutation SetMangaMeta($mangaId: Int!, $key: String!, $value: String!) {
setMangaMeta(input: { meta: { mangaId: $mangaId, key: $key, value: $value } }) {
meta { key value }
}
}
`;
export const DELETE_MANGA_META = `
mutation DeleteMangaMeta($mangaId: Int!, $key: String!) {
deleteMangaMeta(input: { mangaId: $mangaId, key: $key }) {
meta { key value }
}
}
`;
+101 -421
View File
@@ -2,449 +2,129 @@
## Manga (`mutations/manga.ts`) ## Manga (`mutations/manga.ts`)
### `FETCH_MANGA` | Mutation | Variables | Description |
Fetches and refreshes manga metadata from its source. |----------|-----------|-------------|
| `FETCH_MANGA` | `id: Int!` | Fetch and refresh manga metadata from its source |
**Variables:** | `UPDATE_MANGA` | `id: Int!`, `inLibrary: Boolean` | Update a single manga's library membership |
| Name | Type | Description | | `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 |
| `id` | `Int!` | Manga ID | | `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 |
### `UPDATE_MANGA` | `DELETE_CATEGORY` | `id: Int!` | Delete a category |
Updates a single manga's library membership. | `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 |
**Variables:** | `UPDATE_LIBRARY` | — | Trigger a full library metadata refresh |
| Name | Type | Description | | `UPDATE_LIBRARY_MANGA` | `mangaId: Int!` | Trigger a metadata update for a single manga |
|------|------|-------------| | `UPDATE_STOP` | — | Stop the currently running library update job |
| `id` | `Int!` | Manga ID | | `CREATE_BACKUP` | — | Create a backup and return its download URL |
| `inLibrary` | `Boolean` | Add/remove from library | | `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 |
### `UPDATE_MANGAS`
Bulk-updates library membership for multiple manga.
**Variables:**
| Name | Type | Description |
|------|------|-------------|
| `ids` | `[Int!]!` | Manga IDs |
| `inLibrary` | `Boolean` | Add/remove from library |
---
### `UPDATE_MANGA_CATEGORIES`
Adds or removes a manga from categories.
**Variables:**
| Name | Type | Description |
|------|------|-------------|
| `mangaId` | `Int!` | Manga ID |
| `addTo` | `[Int!]!` | Category IDs to add to |
| `removeFrom` | `[Int!]!` | Category IDs to remove from |
---
### `CREATE_CATEGORY`
Creates a new manga category.
**Variables:**
| Name | Type | Description |
|------|------|-------------|
| `name` | `String!` | Category name |
---
### `UPDATE_CATEGORY`
Updates a category's name.
**Variables:**
| Name | Type | Description |
|------|------|-------------|
| `id` | `Int!` | Category ID |
| `name` | `String` | New name |
---
### `DELETE_CATEGORY`
Deletes a category by ID.
**Variables:**
| Name | Type | Description |
|------|------|-------------|
| `id` | `Int!` | Category ID |
---
### `UPDATE_CATEGORY_ORDER`
Moves a category to a new position.
**Variables:**
| Name | Type | Description |
|------|------|-------------|
| `id` | `Int!` | Category ID |
| `position` | `Int!` | New position index |
---
### `UPDATE_LIBRARY`
Triggers a library-wide metadata refresh and returns job status.
**Variables:** none
---
### `CREATE_BACKUP`
Creates a backup and returns its download URL.
**Variables:** none
---
### `RESTORE_BACKUP`
Restores a backup from an uploaded file and returns restore job status.
**Variables:**
| Name | Type | Description |
|------|------|-------------|
| `backup` | `Upload!` | Backup file |
--- ---
## Chapters (`mutations/chapters.ts`) ## Chapters (`mutations/chapters.ts`)
### `FETCH_CHAPTERS` | Mutation | Variables | Description |
Fetches/refreshes the chapter list for a manga from its source. |----------|-----------|-------------|
| `FETCH_CHAPTERS` | `mangaId: Int!` | Fetch/refresh the chapter list for a manga from its source |
**Variables:** | `FETCH_CHAPTER_PAGES` | `chapterId: Int!` | Fetch the page URLs for a specific chapter |
| Name | Type | Description | | `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 |
| `mangaId` | `Int!` | Manga ID | | `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 |
### `FETCH_CHAPTER_PAGES`
Fetches the page URLs for a specific chapter.
**Variables:**
| Name | Type | Description |
|------|------|-------------|
| `chapterId` | `Int!` | Chapter ID |
---
### `MARK_CHAPTER_READ`
Marks a single chapter as read or unread.
**Variables:**
| Name | Type | Description |
|------|------|-------------|
| `id` | `Int!` | Chapter ID |
| `isRead` | `Boolean!` | Read state |
---
### `MARK_CHAPTERS_READ`
Bulk-marks multiple chapters as read or unread.
**Variables:**
| Name | Type | Description |
|------|------|-------------|
| `ids` | `[Int!]!` | Chapter IDs |
| `isRead` | `Boolean!` | Read state |
---
### `UPDATE_CHAPTERS_PROGRESS`
Bulk-updates read state, bookmark state, and last page read for multiple chapters.
**Variables:**
| Name | Type | Description |
|------|------|-------------|
| `ids` | `[Int!]!` | Chapter IDs |
| `isRead` | `Boolean` | Read state |
| `isBookmarked` | `Boolean` | Bookmark state |
| `lastPageRead` | `Int` | Last page index read |
---
### `DELETE_DOWNLOADED_CHAPTERS`
Deletes downloaded chapter files for the given chapter IDs.
**Variables:**
| Name | Type | Description |
|------|------|-------------|
| `ids` | `[Int!]!` | Chapter IDs |
--- ---
## Downloads (`mutations/downloads.ts`) ## Downloads (`mutations/downloads.ts`)
### `ENQUEUE_DOWNLOAD` | Mutation | Variables | Description |
Adds a single chapter to the download queue. |----------|-----------|-------------|
| `ENQUEUE_DOWNLOAD` | `chapterId: Int!` | Add a single chapter to the download queue |
**Variables:** | `ENQUEUE_CHAPTERS_DOWNLOAD` | `chapterIds: [Int!]!` | Add multiple chapters to the download queue |
| Name | Type | Description | | `DEQUEUE_DOWNLOAD` | `chapterId: Int!` | Remove a single chapter from the download queue |
|------|------|-------------| | `DEQUEUE_CHAPTERS_DOWNLOAD` | `chapterIds: [Int!]!` | Remove multiple chapters from the download queue |
| `chapterId` | `Int!` | Chapter ID | | `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 |
### `ENQUEUE_CHAPTERS_DOWNLOAD` | `FETCH_SOURCE_MANGA` | `source: LongString!`, `type: FetchSourceMangaType!`, `page: Int!`, `query: String`, `filters: [FilterChangeInput!]` | Fetch manga from a source (browse/search) with pagination |
Adds multiple chapters to the download queue. | `SET_DOWNLOADS_PATH` | `path: String!` | Set the downloads directory path |
| `SET_LOCAL_SOURCE_PATH` | `path: String!` | Set the local source directory path |
**Variables:**
| Name | Type | Description |
|------|------|-------------|
| `chapterIds` | `[Int!]!` | Chapter IDs |
---
### `DEQUEUE_DOWNLOAD`
Removes a chapter from the download queue.
**Variables:**
| Name | Type | Description |
|------|------|-------------|
| `chapterId` | `Int!` | Chapter ID |
---
### `START_DOWNLOADER`
Starts the downloader and returns the current queue state.
**Variables:** none
---
### `STOP_DOWNLOADER`
Stops the downloader and returns the current queue state.
**Variables:** none
---
### `CLEAR_DOWNLOADER`
Clears all items from the download queue.
**Variables:** none
---
### `FETCH_SOURCE_MANGA`
Fetches manga from a source (browse/search), with pagination and optional filters.
**Variables:**
| Name | Type | Description |
|------|------|-------------|
| `source` | `LongString!` | Source ID |
| `type` | `FetchSourceMangaType!` | Browse type (e.g. popular, latest, search) |
| `page` | `Int!` | Page number |
| `query` | `String` | Search query |
| `filters` | `[FilterChangeInput!]` | Source-specific filters |
---
### `SET_DOWNLOADS_PATH`
Sets the downloads directory path in settings.
**Variables:**
| Name | Type | Description |
|------|------|-------------|
| `path` | `String!` | Filesystem path |
---
### `SET_LOCAL_SOURCE_PATH`
Sets the local source directory path in settings.
**Variables:**
| Name | Type | Description |
|------|------|-------------|
| `path` | `String!` | Filesystem path |
--- ---
## Extensions (`mutations/extensions.ts`) ## Extensions (`mutations/extensions.ts`)
### `FETCH_EXTENSIONS` | Mutation | Variables | Description |
Fetches the latest extension list from configured repos. |----------|-----------|-------------|
| `FETCH_EXTENSIONS` | — | Fetch the latest extension list from configured repos |
**Variables:** none | `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 |
### `UPDATE_EXTENSION` | `SET_SOURCE_META` | `sourceId: LongString!`, `key: String!`, `value: String!` | Set a key/value meta entry on a source |
Installs, uninstalls, or updates an extension. | `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 |
**Variables:** | `DELETE_CATEGORY_META` | `categoryId: Int!`, `key: String!` | Delete a key/value meta entry from a category |
| Name | Type | Description | | `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 |
| `id` | `String!` | Extension package name | | `CLEAR_CACHED_IMAGES` | `cachedPages: Boolean`, `cachedThumbnails: Boolean`, `downloadedThumbnails: Boolean` | Selectively clear cached page images, cached thumbnails, or downloaded thumbnails |
| `install` | `Boolean` | Install the extension | | `RESET_SETTINGS` | — | Reset all server settings to defaults |
| `uninstall` | `Boolean` | Uninstall the extension | | `UPDATE_WEBUI` | — | Trigger a WebUI update and return live status |
| `update` | `Boolean` | Update the extension | | `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 |
### `INSTALL_EXTERNAL_EXTENSION` | `SET_FLARESOLVERR` | `flareSolverrEnabled: Boolean!`, `flareSolverrUrl: String!`, `flareSolverrTimeout: Int!`, `flareSolverrSessionName: String!`, `flareSolverrSessionTtl: Int!`, `flareSolverrAsResponseFallback: Boolean!` | Configure FlareSolverr integration |
Installs an extension from an external APK URL.
**Variables:**
| Name | Type | Description |
|------|------|-------------|
| `url` | `String!` | APK download URL |
---
### `SET_EXTENSION_REPOS`
Sets the list of extension repository URLs.
**Variables:**
| Name | Type | Description |
|------|------|-------------|
| `repos` | `[String!]!` | Repository URLs |
---
### `SET_SERVER_AUTH`
Configures server authentication mode and credentials.
**Variables:**
| Name | Type | Description |
|------|------|-------------|
| `authMode` | `AuthMode!` | Auth mode |
| `authUsername` | `String!` | Username |
| `authPassword` | `String!` | Password |
---
### `SET_SOCKS_PROXY`
Configures SOCKS proxy settings.
**Variables:**
| Name | Type | Description |
|------|------|-------------|
| `socksProxyEnabled` | `Boolean!` | Enable/disable proxy |
| `socksProxyHost` | `String!` | Proxy host |
| `socksProxyPort` | `String!` | Proxy port |
| `socksProxyVersion` | `Int!` | SOCKS version (4 or 5) |
| `socksProxyUsername` | `String!` | Proxy username |
| `socksProxyPassword` | `String!` | Proxy password |
---
### `SET_FLARESOLVERR`
Configures FlareSolverr integration settings.
**Variables:**
| Name | Type | Description |
|------|------|-------------|
| `flareSolverrEnabled` | `Boolean!` | Enable/disable FlareSolverr |
| `flareSolverrUrl` | `String!` | FlareSolverr URL |
| `flareSolverrTimeout` | `Int!` | Request timeout (ms) |
| `flareSolverrSessionName` | `String!` | Session name |
| `flareSolverrSessionTtl` | `Int!` | Session TTL (seconds) |
| `flareSolverrAsResponseFallback` | `Boolean!` | Use as fallback only |
--- ---
## Tracking (`mutations/tracking.ts`) ## Tracking (`mutations/tracking.ts`)
### `BIND_TRACK` | Mutation | Variables | Description |
Binds a manga to a remote tracker entry. |----------|-----------|-------------|
| `BIND_TRACK` | `mangaId: Int!`, `trackerId: Int!`, `remoteId: LongString!` | Bind a manga to a remote tracker entry |
**Variables:** | `UPDATE_TRACK` | `recordId: Int!`, `status: Int`, `lastChapterRead: Float`, `scoreString: String`, `startDate: LongString`, `finishDate: LongString`, `private: Boolean` | Update tracking progress, status, score, and dates |
| Name | Type | Description | | `UNBIND_TRACK` | `recordId: Int!` | Unbind a manga from a tracker record |
|------|------|-------------| | `FETCH_TRACK` | `recordId: Int!` | Refresh a track record from the remote tracker |
| `mangaId` | `Int!` | Manga ID | | `TRACK_PROGRESS` | `mangaId: Int!` | Sync current reading progress to all bound trackers for a manga |
| `trackerId` | `Int!` | Tracker ID | | `LOGIN_TRACKER_OAUTH` | `trackerId: Int!`, `callbackUrl: String!` | Initiate OAuth login for a tracker |
| `remoteId` | `LongString!` | Remote entry ID on the 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 |
--- ---
### `UPDATE_TRACK` ## New in Preview
Updates tracking progress, status, score, and dates for a track record.
**Variables:** Mutations now available and not yet wired to any feature in Moku:
| Name | Type | Description |
|------|------|-------------|
| `recordId` | `Int!` | Track record ID |
| `status` | `Int` | Reading status |
| `lastChapterRead` | `Float` | Last chapter read |
| `scoreString` | `String` | Score in tracker's format |
| `startDate` | `LongString` | Start date |
| `finishDate` | `LongString` | Finish date |
| `private` | `Boolean` | Mark as private |
--- | Mutation | Potential Feature |
|----------|-------------------|
### `UNBIND_TRACK` | `UPDATE_MANGAS_CATEGORIES` | Bulk category editor — move/assign multiple manga at once |
Unbinds a manga from a tracker record. | `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 |
**Variables:** | `UPDATE_LIBRARY_MANGA` | Single manga refresh — trigger from series detail without a full library update |
| Name | Type | Description | | `UPDATE_STOP` | Cancel button for library update jobs |
|------|------|-------------| | `UPDATE_EXTENSIONS` | Bulk extension updater — "update all" button in extensions page |
| `recordId` | `Int!` | Track record ID | | `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 |
### `FETCH_TRACK` | `SET_GLOBAL_META` / `DELETE_GLOBAL_META` | Server-synced app state — replace local persistence for settings that should roam |
Refreshes a track record from the remote tracker. | `CLEAR_CACHED_IMAGES` | Storage settings — granular cache clearing (pages, thumbnails, downloaded) |
| `RESET_SETTINGS` | Settings page — factory reset button |
**Variables:** | `UPDATE_WEBUI` / `RESET_WEBUI_UPDATE_STATUS` | WebUI update flow in settings — trigger and monitor update progress |
| Name | Type | Description | | `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 |
| `recordId` | `Int!` | Track record ID | | `PULL_KOSYNC_PROGRESS` / `PUSH_KOSYNC_PROGRESS` | KOReader sync — manual pull/push per chapter, or auto-sync on chapter open/close |
---
### `LOGIN_TRACKER_OAUTH`
Initiates OAuth login for a tracker using a callback URL.
**Variables:**
| Name | Type | Description |
|------|------|-------------|
| `trackerId` | `Int!` | Tracker ID |
| `callbackUrl` | `String!` | OAuth callback URL |
---
### `LOGIN_TRACKER_CREDENTIALS`
Logs into a tracker using username and password.
**Variables:**
| Name | Type | Description |
|------|------|-------------|
| `trackerId` | `Int!` | Tracker ID |
| `username` | `String!` | Username |
| `password` | `String!` | Password |
---
### `LOGOUT_TRACKER`
Logs out of a tracker.
**Variables:**
| Name | Type | Description |
|------|------|-------------|
| `trackerId` | `Int!` | Tracker ID |
---
### `LOGIN_USER`
Authenticates a user and returns access and refresh tokens.
**Variables:**
| Name | Type | Description |
|------|------|-------------|
| `username` | `String!` | Username |
| `password` | `String!` | Password |
---
### `REFRESH_TOKEN`
Refreshes the current access token.
**Variables:** none
+51 -9
View File
@@ -1,6 +1,6 @@
const TRACK_RECORD_FRAGMENT = ` const TRACK_RECORD_FRAGMENT = `
id trackerId remoteId title status score displayScore id trackerId remoteId title status score displayScore
lastChapterRead totalChapters remoteUrl startDate finishDate private lastChapterRead totalChapters remoteUrl startDate finishDate private libraryId
`; `;
export const BIND_TRACK = ` export const BIND_TRACK = `
@@ -15,7 +15,7 @@ export const UPDATE_TRACK = `
mutation UpdateTrack($recordId: Int!, $status: Int, $lastChapterRead: Float, $scoreString: String, $startDate: LongString, $finishDate: LongString, $private: Boolean) { 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 }) { updateTrack(input: { recordId: $recordId, status: $status, lastChapterRead: $lastChapterRead, scoreString: $scoreString, startDate: $startDate, finishDate: $finishDate, private: $private }) {
trackRecord { trackRecord {
id trackerId status score displayScore lastChapterRead totalChapters startDate finishDate private id trackerId status score displayScore lastChapterRead totalChapters startDate finishDate private libraryId
} }
} }
} }
@@ -33,7 +33,17 @@ export const FETCH_TRACK = `
mutation FetchTrack($recordId: Int!) { mutation FetchTrack($recordId: Int!) {
fetchTrack(input: { recordId: $recordId }) { fetchTrack(input: { recordId: $recordId }) {
trackRecord { trackRecord {
id trackerId status score displayScore lastChapterRead totalChapters startDate finishDate 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
} }
} }
} }
@@ -43,7 +53,7 @@ export const LOGIN_TRACKER_OAUTH = `
mutation LoginTrackerOAuth($trackerId: Int!, $callbackUrl: String!) { mutation LoginTrackerOAuth($trackerId: Int!, $callbackUrl: String!) {
loginTrackerOAuth(input: { trackerId: $trackerId, callbackUrl: $callbackUrl }) { loginTrackerOAuth(input: { trackerId: $trackerId, callbackUrl: $callbackUrl }) {
isLoggedIn isLoggedIn
tracker { id name isLoggedIn authUrl } tracker { id name isLoggedIn isTokenExpired authUrl }
} }
} }
`; `;
@@ -52,7 +62,7 @@ export const LOGIN_TRACKER_CREDENTIALS = `
mutation LoginTrackerCredentials($trackerId: Int!, $username: String!, $password: String!) { mutation LoginTrackerCredentials($trackerId: Int!, $username: String!, $password: String!) {
loginTrackerCredentials(input: { trackerId: $trackerId, username: $username, password: $password }) { loginTrackerCredentials(input: { trackerId: $trackerId, username: $username, password: $password }) {
isLoggedIn isLoggedIn
tracker { id name isLoggedIn authUrl } tracker { id name isLoggedIn isTokenExpired authUrl }
} }
} }
`; `;
@@ -60,7 +70,39 @@ export const LOGIN_TRACKER_CREDENTIALS = `
export const LOGOUT_TRACKER = ` export const LOGOUT_TRACKER = `
mutation LogoutTracker($trackerId: Int!) { mutation LogoutTracker($trackerId: Int!) {
logoutTracker(input: { trackerId: $trackerId }) { logoutTracker(input: { trackerId: $trackerId }) {
tracker { id name isLoggedIn authUrl } 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 }
} }
} }
`; `;
@@ -68,13 +110,13 @@ export const LOGOUT_TRACKER = `
export const LOGIN_USER = ` export const LOGIN_USER = `
mutation Login($username: String!, $password: String!) { mutation Login($username: String!, $password: String!) {
login(input: { username: $username, password: $password }) { login(input: { username: $username, password: $password }) {
accessToken refreshToken accessToken
} }
} }
`; `;
export const REFRESH_TOKEN = ` export const REFRESH_TOKEN = `
mutation RefreshToken { mutation RefreshToken {
refreshToken { accessToken } refreshToken(input: {}) { accessToken }
} }
`; `;
+1 -1
View File
@@ -15,7 +15,7 @@ export const GET_CHAPTERS = `
chapters(condition: { mangaId: $mangaId }) { chapters(condition: { mangaId: $mangaId }) {
nodes { nodes {
id name chapterNumber sourceOrder isRead isDownloaded isBookmarked id name chapterNumber sourceOrder isRead isDownloaded isBookmarked
pageCount mangaId uploadDate realUrl lastPageRead scanlator pageCount mangaId uploadDate realUrl lastPageRead lastReadAt scanlator
} }
} }
} }
+2 -2
View File
@@ -3,7 +3,7 @@ export const GET_DOWNLOAD_STATUS = `
downloadStatus { downloadStatus {
state state
queue { queue {
progress state progress state tries
chapter { chapter {
id name pageCount mangaId id name pageCount mangaId
manga { id title thumbnailUrl } manga { id title thumbnailUrl }
@@ -11,4 +11,4 @@ export const GET_DOWNLOAD_STATUS = `
} }
} }
} }
`; `;
+4 -1
View File
@@ -20,7 +20,10 @@ export const GET_EXTENSIONS = `
export const GET_SOURCES = ` export const GET_SOURCES = `
query GetSources { query GetSources {
sources { sources {
nodes { id name lang displayName iconUrl isNsfw } nodes {
id name lang displayName iconUrl isNsfw
isConfigurable supportsLatest baseUrl
}
} }
} }
`; `;
+2
View File
@@ -3,3 +3,5 @@ export * from "./chapters";
export * from "./downloads"; export * from "./downloads";
export * from "./extensions"; export * from "./extensions";
export * from "./tracking"; export * from "./tracking";
export * from "./updater";
export * from "./meta";
+14 -3
View File
@@ -2,10 +2,15 @@ export const GET_LIBRARY = `
query GetLibrary { query GetLibrary {
mangas(condition: { inLibrary: true }) { mangas(condition: { inLibrary: true }) {
nodes { nodes {
id title thumbnailUrl inLibrary downloadCount unreadCount id title thumbnailUrl inLibrary downloadCount unreadCount bookmarkCount
description status author artist genre description status author artist genre
inLibraryAt lastFetchedAt chaptersLastFetchedAt thumbnailUrlLastFetched
source { id name displayName } source { id name displayName }
chapters { totalCount } chapters { totalCount }
latestFetchedChapter { id uploadDate }
latestUploadedChapter { id uploadDate }
lastReadChapter { id chapterNumber }
firstUnreadChapter { id chapterNumber }
} }
} }
} }
@@ -23,7 +28,11 @@ export const GET_MANGA = `
query GetManga($id: Int!) { query GetManga($id: Int!) {
manga(id: $id) { manga(id: $id) {
id title description thumbnailUrl status author artist genre inLibrary realUrl id title description thumbnailUrl status author artist genre inLibrary realUrl
inLibraryAt lastFetchedAt thumbnailUrlLastFetched updateStrategy
source { id name displayName } source { id name displayName }
lastReadChapter { id chapterNumber lastPageRead }
firstUnreadChapter { id chapterNumber }
highestNumberedChapter { id chapterNumber }
} }
} }
`; `;
@@ -58,7 +67,9 @@ export const GET_DOWNLOADS_PATH = `
export const LIBRARY_UPDATE_STATUS = ` export const LIBRARY_UPDATE_STATUS = `
query LibraryUpdateStatus { query LibraryUpdateStatus {
libraryUpdateStatus { libraryUpdateStatus {
jobsInfo { isRunning finishedJobs totalJobs skippedMangasCount } jobsInfo {
isRunning finishedJobs totalJobs skippedMangasCount skippedCategoriesCount
}
mangaUpdates { mangaUpdates {
status status
manga { id title thumbnailUrl unreadCount } manga { id title thumbnailUrl unreadCount }
@@ -93,4 +104,4 @@ export const MANGAS_BY_GENRE = `
totalCount totalCount
} }
} }
`; `;
+15
View File
@@ -0,0 +1,15 @@
export const GET_META = `
query GetMeta($key: String!) {
meta(key: $key) {
key value
}
}
`;
export const GET_METAS = `
query GetMetas {
metas {
nodes { key value }
}
}
`;
+77 -131
View File
@@ -2,170 +2,116 @@
## Manga (`queries/manga.ts`) ## Manga (`queries/manga.ts`)
### `GET_LIBRARY` | Query | Variables | Description |
Fetches all manga marked as in-library, including metadata, source info, chapter count, download count, and unread count. |-------|-----------|-------------|
| `GET_LIBRARY` | — | All in-library manga with metadata, source, chapter counts, download count, unread count, bookmark count, and read progress anchors (`lastReadChapter`, `firstUnreadChapter`) |
**Variables:** none | `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_ALL_MANGA` | `GET_DOWNLOADS_PATH` | — | `downloadsPath` and `localSourcePath` from settings |
Fetches all manga (library and non-library) with minimal fields. | `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` |
**Variables:** none | `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` |
---
### `GET_MANGA`
Fetches a single manga by ID with full metadata and source info.
**Variables:**
| Name | Type | Description |
|------|------|-------------|
| `id` | `Int!` | Manga ID |
---
### `GET_CATEGORIES`
Fetches all categories with their order, settings, and the manga assigned to each.
**Variables:** none
---
### `GET_DOWNLOADED_CHAPTERS_PAGES`
Fetches page counts for all downloaded chapters.
**Variables:** none
---
### `GET_DOWNLOADS_PATH`
Fetches the configured downloads path and local source path from settings.
**Variables:** none
---
### `LIBRARY_UPDATE_STATUS`
Fetches the current library update job status, including progress and any manga with new chapters.
**Variables:** none
---
### `GET_RESTORE_STATUS`
Fetches the status of a backup restore operation by its job ID.
**Variables:**
| Name | Type | Description |
|------|------|-------------|
| `id` | `String!` | Restore job ID |
---
### `VALIDATE_BACKUP`
Validates a backup file and returns any missing sources or trackers.
**Variables:**
| Name | Type | Description |
|------|------|-------------|
| `backup` | `Upload!` | Backup file |
--- ---
## Chapters (`queries/chapters.ts`) ## Chapters (`queries/chapters.ts`)
### `GET_CHAPTERS` | Query | Variables | Description |
Fetches all chapters for a given manga, including read/download/bookmark state and page info. |-------|-----------|-------------|
| `GET_RECENTLY_UPDATED` | — | Latest 300 chapters ordered by `FETCHED_AT DESC` with parent manga info |
**Variables:** | `GET_CHAPTERS` | `mangaId: Int!` | All chapters for a manga — includes `lastReadAt`, `lastPageRead`, read/download/bookmark state, page count, scanlator |
| Name | Type | Description |
|------|------|-------------|
| `mangaId` | `Int!` | Manga ID |
--- ---
## Downloads (`queries/downloads.ts`) ## Downloads (`queries/downloads.ts`)
### `GET_DOWNLOAD_STATUS` | Query | Variables | Description |
Fetches the current downloader state and full queue with chapter and manga info. |-------|-----------|-------------|
| `GET_DOWNLOAD_STATUS` | — | Downloader state (`DownloaderState` enum) and full queue with chapter and manga info |
**Variables:** none
--- ---
## Extensions (`queries/extensions.ts`) ## Extensions (`queries/extensions.ts`)
### `GET_EXTENSIONS` | Query | Variables | Description |
Fetches all extensions with install status, update availability, and metadata. |-------|-----------|-------------|
| `GET_LOCAL_MANGA` | — | Manga from the local source (`sourceId: "0"`) |
**Variables:** none | `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 |
### `GET_SOURCES`
Fetches all available sources with language and NSFW flags.
**Variables:** none
---
### `GET_SETTINGS`
Fetches extension repository settings.
**Variables:** none
---
### `GET_SERVER_SECURITY`
Fetches all server security settings including auth mode, SOCKS proxy config, and FlareSolverr config.
**Variables:** none
--- ---
## Tracking (`queries/tracking.ts`) ## Tracking (`queries/tracking.ts`)
### `GET_TRACKERS` | Query | Variables | Description |
Fetches all trackers with login status, supported scores, statuses, and auth info. |-------|-----------|-------------|
| `GET_TRACKERS` | — | All trackers with login state, token expiry, capability flags (`supportsPrivateTracking`, `supportsReadingDates`, `supportsTrackDeletion`), scores, and statuses |
**Variables:** none | `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 |
--- ---
### `GET_MANGA_TRACK_RECORDS` ## Updater (`queries/updater.ts`)
Fetches all tracking records for a specific manga across all trackers.
**Variables:** | Query | Variables | Description |
| Name | Type | Description | |-------|-----------|-------------|
|------|------|-------------| | `GET_ABOUT_SERVER` | — | Server name, version, build type, build time, GitHub and Discord links |
| `mangaId` | `Int!` | Manga ID | | `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 |
--- ---
### `SEARCH_TRACKER` ## Meta (`queries/meta.ts`)
Searches a tracker for manga by query string.
**Variables:** | Query | Variables | Description |
| Name | Type | Description | |-------|-----------|-------------|
|------|------|-------------| | `GET_META` | `key: String!` | Single server-side key/value meta entry |
| `trackerId` | `Int!` | Tracker ID | | `GET_METAS` | — | All global meta entries as a node list |
| `query` | `String!` | Search query |
--- ---
### `GET_ALL_TRACKER_RECORDS` ## KoSync (`queries/kosync.ts`)
Fetches all trackers and their full track records, including associated manga info.
**Variables:** none | Query | Variables | Description |
|-------|-----------|-------------|
| `GET_KOSYNC_STATUS` | — | KOReader sync connection status |
--- ---
### `GET_TRACKER_RECORDS` ## New in Preview
Fetches track records for a specific tracker.
**Variables:** Queries and fields now available but not yet wired to any feature in Moku:
| Name | Type | Description |
|------|------|-------------| | Query / Field | Potential Feature |
| `trackerId` | `Int!` | Tracker ID | |---------------|-------------------|
| `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 |
+7 -5
View File
@@ -2,7 +2,9 @@ export const GET_TRACKERS = `
query GetTrackers { query GetTrackers {
trackers { trackers {
nodes { nodes {
id name icon isLoggedIn authUrl supportsPrivateTracking scores id name icon isLoggedIn isTokenExpired authUrl
supportsPrivateTracking supportsReadingDates supportsTrackDeletion
scores
statuses { value name } statuses { value name }
} }
} }
@@ -15,7 +17,7 @@ export const GET_MANGA_TRACK_RECORDS = `
trackRecords { trackRecords {
nodes { nodes {
id trackerId remoteId title status score displayScore id trackerId remoteId title status score displayScore
lastChapterRead totalChapters remoteUrl startDate finishDate private lastChapterRead totalChapters remoteUrl startDate finishDate private libraryId
} }
} }
} }
@@ -37,12 +39,12 @@ export const GET_ALL_TRACKER_RECORDS = `
query GetAllTrackerRecords { query GetAllTrackerRecords {
trackers { trackers {
nodes { nodes {
id name icon isLoggedIn scores id name icon isLoggedIn isTokenExpired scores
statuses { value name } statuses { value name }
trackRecords { trackRecords {
nodes { nodes {
id trackerId title status displayScore lastChapterRead id trackerId title status displayScore lastChapterRead
totalChapters remoteUrl private totalChapters remoteUrl private libraryId
manga { id title thumbnailUrl inLibrary } manga { id title thumbnailUrl inLibrary }
} }
} }
@@ -66,4 +68,4 @@ export const GET_TRACKER_RECORDS = `
} }
} }
} }
`; `;
+23
View File
@@ -0,0 +1,23 @@
export const GET_ABOUT_SERVER = `
query GetAboutServer {
aboutServer {
name version buildType buildTime github discord
}
}
`;
export const GET_ABOUT_WEBUI = `
query GetAboutWebUI {
aboutWebUI {
channel tag updateTimestamp
}
}
`;
export const CHECK_FOR_SERVER_UPDATES = `
query CheckForServerUpdates {
checkForServerUpdates {
channel tag url
}
}
`;
+2 -47
View File
@@ -1,50 +1,5 @@
import type { Manga, Source } from "@types"; export { shouldHideNsfw, shouldHideSource, dedupeSourcesByLang } from "@core/util";
import type { Settings } from "@types";
import { shouldHideSource } from "@core/util";
// ── Source deduplication ──────────────────────────────────────────────────────
/**
* Deduplicates sources by name, preferring `preferredLang` when multiple
* sources share a name. The local source (id "0") is always excluded.
*
* When `applyHide` is true, sources that fail the NSFW/block check are
* also removed — used in fan-out and cache-build paths where only
* user-visible sources should be queried.
*/
export function dedupeSourcesByLang(
sources: Source[],
preferredLang: string,
settings: Pick<Settings, "showNsfw" | "nsfwAllowedSourceIds" | "nsfwBlockedSourceIds">,
applyHide = false,
): Source[] {
const map = new Map<string, Source>();
for (const s of sources) {
if (s.id === "0") continue;
if (applyHide && shouldHideSource(s, settings)) continue;
const existing = map.get(s.name);
if (!existing) { map.set(s.name, s); continue; }
const existingPref = existing.lang === preferredLang;
const newPref = s.lang === preferredLang;
if (newPref && !existingPref) map.set(s.name, s);
else if (!existingPref && !newPref && s.lang < existing.lang) map.set(s.name, s);
}
return Array.from(map.values());
}
// ── Manga predicate filters ───────────────────────────────────────────────────
/**
* Generic predicate pipeline — composes multiple boolean predicates into one.
* All predicates must return true for an item to pass.
*
* Usage:
* const keep = buildFilter<Manga>(
* m => !shouldHideNsfw(m, settings),
* m => m.inLibrary,
* );
* const filtered = items.filter(keep);
*/
export function buildFilter<T>(...predicates: ((item: T) => boolean)[]): (item: T) => boolean { export function buildFilter<T>(...predicates: ((item: T) => boolean)[]): (item: T) => boolean {
return (item) => predicates.every((p) => p(item)); return (item) => predicates.every((p) => p(item));
} }
+90 -24
View File
@@ -1,10 +1,30 @@
import { store, updateSettings } from "@store/state.svelte"; import { store, updateSettings } from "@store/state.svelte";
export type AuthMode = "NONE" | "BASIC_AUTH" | "SIMPLE_LOGIN" | "UI_LOGIN"; 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";
let _accessToken: string | null = sessionStorage.getItem(TOKEN_KEY);
export const uiAuth = {
getToken: () => _accessToken,
setToken: (t: string) => { _accessToken = t; sessionStorage.setItem(TOKEN_KEY, t); },
clearToken: () => { _accessToken = null; sessionStorage.removeItem(TOKEN_KEY); },
};
export const authSession = { export const authSession = {
clearTokens() {}, clearTokens() { uiAuth.clearToken(); },
hasSession(): boolean { return true; }, hasSession(): boolean {
const mode = store.settings.serverAuthMode ?? "NONE";
if (mode === "UI_LOGIN") return _accessToken !== null;
return true;
},
}; };
function getServerBase(): string { function getServerBase(): string {
@@ -22,24 +42,72 @@ function basicHeader(user: string, pass: string): Record<string, string> {
return { Authorization: `Basic ${btoa(`${user}:${pass}`)}` }; return { Authorization: `Basic ${btoa(`${user}:${pass}`)}` };
} }
export function fetchAuthenticated(url: string, init: RequestInit, signal?: AbortSignal): Promise<Response> { function bearerHeader(token: string): Record<string, string> {
const mode = store.settings.serverAuthMode ?? "NONE"; 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") { if (mode === "BASIC_AUTH") {
const user = store.settings.serverAuthUser?.trim() ?? ""; const user = store.settings.serverAuthUser?.trim() ?? "";
const pass = store.settings.serverAuthPass?.trim() ?? ""; const pass = store.settings.serverAuthPass?.trim() ?? "";
return fetch(url, { return fetch(url, {
...init, signal, credentials: "omit", ...init, signal, credentials: "omit",
headers: { ...(init.headers as Record<string, string> ?? {}), ...(user && pass ? basicHeader(user, pass) : {}) }, headers: { ...baseHeaders, ...(user && pass ? basicHeader(user, pass) : {}) },
}); });
} }
if (mode === "UI_LOGIN") {
const token = uiAuth.getToken();
if (!token) {
if (skipped) return fetch(url, { ...init, signal, credentials: "omit", headers: baseHeaders });
throw new AuthRequiredError();
}
return fetch(url, {
...init, signal, credentials: "omit",
headers: { ...baseHeaders, ...bearerHeader(token) },
});
}
return fetch(url, { ...init, signal, credentials: "omit" }); return fetch(url, { ...init, signal, credentials: "omit" });
} }
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 }
}`,
{ username: user, password: pass },
),
signal: timeoutSignal(8000),
});
if (!res.ok) throw new Error(`Login request failed (${res.status})`);
const json = await res.json();
const token: string | undefined = json?.data?.login?.accessToken;
if (!token) throw new Error(json?.errors?.[0]?.message ?? "Login failed");
uiAuth.setToken(token);
updateSettings({ serverAuthMode: "UI_LOGIN" });
}
export async function loginBasic(user: string, pass: string): Promise<void> { export async function loginBasic(user: string, pass: string): Promise<void> {
const res = await fetch(`${getServerBase()}/api/graphql`, { const res = await fetch(`${getServerBase()}/api/graphql`, {
method: "POST", credentials: "omit", method: "POST", credentials: "omit",
headers: { "Content-Type": "application/json", ...basicHeader(user, pass) }, headers: { "Content-Type": "application/json", ...basicHeader(user, pass) },
body: JSON.stringify({ query: "{ __typename }" }), body: gqlBody("{ __typename }"),
signal: timeoutSignal(5000), signal: timeoutSignal(5000),
}); });
if (!res.ok) throw new Error(`Authentication failed (${res.status})`); if (!res.ok) throw new Error(`Authentication failed (${res.status})`);
@@ -47,39 +115,37 @@ export async function loginBasic(user: string, pass: string): Promise<void> {
} }
export async function logout(): Promise<void> { export async function logout(): Promise<void> {
updateSettings({ serverAuthPass: "" }); uiAuth.clearToken();
updateSettings({ serverAuthPass: "", serverAuthMode: "NONE" });
} }
export async function probeServer(): Promise<"ok" | "auth_required" | "unsupported_mode" | "unreachable"> { export async function probeServer(): Promise<"ok" | "auth_required" | "unreachable"> {
const base = getServerBase(); const base = getServerBase();
const mode = store.settings.serverAuthMode ?? "NONE"; const mode = store.settings.serverAuthMode ?? "NONE";
const s = store.settings; const s = store.settings;
if (mode === "UI_LOGIN" && !_accessToken) return "auth_required";
try { try {
const headers: Record<string, string> = { "Content-Type": "application/json" }; const headers: Record<string, string> = { "Content-Type": "application/json" };
if (mode === "BASIC_AUTH") { if (mode === "BASIC_AUTH") {
const user = s.serverAuthUser?.trim() ?? ""; const user = s.serverAuthUser?.trim() ?? "";
const pass = s.serverAuthPass?.trim() ?? ""; const pass = s.serverAuthPass?.trim() ?? "";
if (user && pass) Object.assign(headers, basicHeader(user, pass)); if (user && pass) Object.assign(headers, basicHeader(user, pass));
} else if (mode === "UI_LOGIN" && _accessToken) {
Object.assign(headers, bearerHeader(_accessToken));
} }
const res = await fetch(`${base}/api/graphql`, { const res = await fetch(`${base}/api/graphql`, {
method: "POST", credentials: "omit", headers, method: "POST", credentials: "omit", headers,
body: JSON.stringify({ query: "{ __typename }" }), body: gqlBody("{ __typename }"),
signal: timeoutSignal(5000), signal: timeoutSignal(5000),
}); });
if (res.ok) return "ok"; if (res.ok) return "ok";
if (res.status === 401) { if (res.status === 401) return "auth_required";
const wwwAuth = res.headers.get("WWW-Authenticate") ?? "";
if (/basic/i.test(wwwAuth)) {
if (mode !== "BASIC_AUTH") updateSettings({ serverAuthMode: "BASIC_AUTH" });
return "auth_required";
}
if (/bearer/i.test(wwwAuth)) {
if (mode !== "UI_LOGIN") updateSettings({ serverAuthMode: "UI_LOGIN" });
} else if (mode === "NONE") {
updateSettings({ serverAuthMode: "SIMPLE_LOGIN" });
}
return "unsupported_mode";
}
return "unreachable"; return "unreachable";
} catch { return "unreachable"; } } catch {
return "unreachable";
}
} }
+241 -23
View File
@@ -1,38 +1,256 @@
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import {
persistSettings,
persistLibrary,
persistUpdates,
} from "@core/persistence/persist";
function collectAppData(): Record<string, string> { const STORE_FILES = ["settings.json", "library.json", "updates.json"] as const;
const data: Record<string, string> = {};
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key !== null) data[key] = localStorage.getItem(key) ?? "";
}
return data;
}
function applyAppData(data: Record<string, string>): void {
localStorage.clear();
for (const [key, value] of Object.entries(data)) {
localStorage.setItem(key, value);
}
}
export async function exportAppData(): Promise<void> { export async function exportAppData(): Promise<void> {
const json = JSON.stringify(collectAppData(), null, 2); const entries: [string, string][] = await invoke("read_store_files", {
await invoke("export_app_data", { json }); names: [...STORE_FILES],
});
const zip = buildZip(
entries.map(([name, content]) => ({
name,
bytes: new TextEncoder().encode(content),
}))
);
await invoke("export_app_data", { bytes: Array.from(zip) });
} }
export async function importAppData(): Promise<void> { export async function importAppData(): Promise<void> {
const json = await invoke<string>("import_app_data"); const raw: number[] = await invoke("import_app_data");
const data: Record<string, string> = JSON.parse(json); const files = parseZip(new Uint8Array(raw));
applyAppData(data);
location.reload(); const decode = (name: string) => {
const bytes = files.get(name);
if (!bytes) throw new Error(`Backup is missing ${name}`);
return JSON.parse(new TextDecoder().decode(bytes));
};
const s = decode("settings.json");
const l = decode("library.json");
const u = decode("updates.json");
await Promise.all([
persistSettings({
settings: s.settings ?? null,
storeVersion: s.storeVersion ?? 1,
}),
persistLibrary({
history: l.history ?? [],
bookmarks: l.bookmarks ?? [],
markers: l.markers ?? [],
readLog: l.readLog ?? [],
readingStats: l.readingStats ?? null,
dailyReadCounts: l.dailyReadCounts ?? {},
}),
persistUpdates({
libraryUpdates: u.libraryUpdates ?? [],
lastLibraryRefresh: u.lastLibraryRefresh ?? 0,
acknowledgedUpdateIds: u.acknowledgedUpdateIds ?? [],
}),
]);
await showExitModal();
invoke("exit_app");
}
function showExitModal(): Promise<void> {
return new Promise(resolve => {
const backdrop = document.createElement("div");
backdrop.className = "s-backdrop";
backdrop.style.cssText = "z-index:99999";
const modal = document.createElement("div");
modal.style.cssText = [
"background:var(--bg-surface)",
"border:1px solid var(--border-base)",
"border-radius:var(--radius-2xl)",
"box-shadow:0 0 0 1px rgba(255,255,255,0.04) inset,0 24px 80px rgba(0,0,0,0.7),0 8px 24px rgba(0,0,0,0.4)",
"width:min(400px,calc(100vw - 40px))",
"display:flex",
"flex-direction:column",
"overflow:hidden",
"animation:s-scale-in 0.2s cubic-bezier(0.16,1,0.3,1) both",
].join(";");
const header = document.createElement("div");
header.style.cssText = "padding:var(--sp-4) var(--sp-5) var(--sp-3);border-bottom:1px solid var(--border-dim)";
const title = document.createElement("p");
title.style.cssText = "margin:0;font-size:var(--text-sm);font-weight:var(--weight-medium);color:var(--text-primary);letter-spacing:0.01em";
title.textContent = "Import complete";
header.appendChild(title);
const body = document.createElement("div");
body.style.cssText = "padding:var(--sp-4) var(--sp-5);display:flex;flex-direction:column;gap:var(--sp-2)";
const sub = document.createElement("p");
sub.style.cssText = "margin:0;font-family:var(--font-ui);font-size:var(--text-xs);color:var(--text-faint);letter-spacing:var(--tracking-wide);line-height:var(--leading-snug)";
sub.textContent = "Your settings have been restored. Moku will close so you can relaunch with the imported data.";
const counter = document.createElement("p");
counter.style.cssText = "margin:0;font-family:var(--font-ui);font-size:var(--text-xs);color:var(--text-faint);letter-spacing:var(--tracking-wide)";
counter.textContent = "Closing in 3…";
body.append(sub, counter);
const footer = document.createElement("div");
footer.style.cssText = "padding:var(--sp-3) var(--sp-5);border-top:1px solid var(--border-dim);display:flex;justify-content:flex-end";
const btn = document.createElement("button");
btn.className = "s-btn s-btn-danger";
btn.textContent = "Close now";
footer.appendChild(btn);
modal.append(header, body, footer);
backdrop.appendChild(modal);
document.body.appendChild(backdrop);
let secs = 3;
const tick = setInterval(() => {
secs--;
counter.textContent = secs > 0 ? `Closing in ${secs}` : "Closing…";
if (secs <= 0) { clearInterval(tick); backdrop.remove(); resolve(); }
}, 1000);
btn.addEventListener("click", () => { clearInterval(tick); backdrop.remove(); resolve(); });
});
} }
export async function autoBackupAppData(): Promise<void> { export async function autoBackupAppData(): Promise<void> {
try { try {
const json = JSON.stringify(collectAppData()); const entries: [string, string][] = await invoke("read_store_files", {
await invoke("auto_backup_app_data", { json }); names: [...STORE_FILES],
});
const zip = buildZip(
entries.map(([name, content]) => ({
name,
bytes: new TextEncoder().encode(content),
}))
);
await invoke("auto_backup_app_data", { bytes: Array.from(zip) });
} catch (e) { } catch (e) {
console.warn("[moku] auto-backup failed:", e); console.warn("[moku] auto-backup failed:", e);
} }
}
function crc32(data: Uint8Array): number {
let crc = 0xffffffff;
for (const byte of data) {
crc ^= byte;
for (let j = 0; j < 8; j++) {
crc = crc & 1 ? (crc >>> 1) ^ 0xedb88320 : crc >>> 1;
}
}
return (crc ^ 0xffffffff) >>> 0;
}
function localHeader(name: Uint8Array, data: Uint8Array): Uint8Array {
const buf = new ArrayBuffer(30 + name.byteLength);
const v = new DataView(buf);
v.setUint32(0, 0x04034b50, true);
v.setUint16(4, 20, true);
v.setUint16(6, 0, true);
v.setUint16(8, 0, true);
v.setUint16(10, 0, true);
v.setUint16(12, 0, true);
v.setUint32(14, crc32(data), true);
v.setUint32(18, data.byteLength, true);
v.setUint32(22, data.byteLength, true);
v.setUint16(26, name.byteLength, true);
v.setUint16(28, 0, true);
new Uint8Array(buf).set(name, 30);
return new Uint8Array(buf);
}
function centralHeader(name: Uint8Array, data: Uint8Array, offset: number): Uint8Array {
const buf = new ArrayBuffer(46 + name.byteLength);
const v = new DataView(buf);
v.setUint32(0, 0x02014b50, true);
v.setUint16(4, 20, true);
v.setUint16(6, 20, true);
v.setUint16(8, 0, true);
v.setUint16(10, 0, true);
v.setUint16(12, 0, true);
v.setUint16(14, 0, true);
v.setUint32(16, crc32(data), true);
v.setUint32(20, data.byteLength, true);
v.setUint32(24, data.byteLength, true);
v.setUint16(28, name.byteLength, true);
v.setUint16(30, 0, true);
v.setUint16(32, 0, true);
v.setUint16(34, 0, true);
v.setUint16(36, 0, true);
v.setUint32(38, 0, true);
v.setUint32(42, offset, true);
new Uint8Array(buf).set(name, 46);
return new Uint8Array(buf);
}
function eocd(count: number, cdSize: number, cdOffset: number): Uint8Array {
const buf = new ArrayBuffer(22);
const v = new DataView(buf);
v.setUint32(0, 0x06054b50, true);
v.setUint16(4, 0, true);
v.setUint16(6, 0, true);
v.setUint16(8, count, true);
v.setUint16(10, count, true);
v.setUint32(12, cdSize, true);
v.setUint32(16, cdOffset, true);
v.setUint16(20, 0, true);
return new Uint8Array(buf);
}
function buildZip(files: { name: string; bytes: Uint8Array }[]): Uint8Array {
const enc = new TextEncoder();
const parts: Uint8Array[] = [];
const offsets: number[] = [];
let pos = 0;
for (const { name, bytes } of files) {
const nameBytes = enc.encode(name);
const lh = localHeader(nameBytes, bytes);
offsets.push(pos);
parts.push(lh, bytes);
pos += lh.byteLength + bytes.byteLength;
}
const cdParts = files.map(({ name, bytes }, i) =>
centralHeader(enc.encode(name), bytes, offsets[i])
);
const cd = concat(cdParts);
return concat([...parts, cd, eocd(files.length, cd.byteLength, pos)]);
}
function parseZip(data: Uint8Array): Map<string, Uint8Array> {
const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
const files = new Map<string, Uint8Array>();
let pos = 0;
while (pos + 30 <= data.byteLength && view.getUint32(pos, true) === 0x04034b50) {
const fnLen = view.getUint16(pos + 26, true);
const exLen = view.getUint16(pos + 28, true);
const cSize = view.getUint32(pos + 18, true);
const name = new TextDecoder().decode(data.subarray(pos + 30, pos + 30 + fnLen));
const start = pos + 30 + fnLen + exLen;
files.set(name, data.subarray(start, start + cSize));
pos = start + cSize;
}
return files;
}
function concat(arrays: Uint8Array[]): Uint8Array {
const total = arrays.reduce((n, a) => n + a.byteLength, 0);
const out = new Uint8Array(total);
let pos = 0;
for (const a of arrays) { out.set(a, pos); pos += a.byteLength; }
return out;
} }
+29 -5
View File
@@ -1,5 +1,6 @@
import { fetch as tauriFetch } from "@tauri-apps/plugin-http"; import { fetch as tauriFetch } from "@tauri-apps/plugin-http";
import { store } from "@store/state.svelte"; import { store } from "@store/state.svelte";
import { uiAuth } from "@core/auth";
const cache = new Map<string, string>(); const cache = new Map<string, string>();
const inflight = new Map<string, Promise<string>>(); const inflight = new Map<string, Promise<string>>();
@@ -17,9 +18,17 @@ interface QueueEntry {
const queue: QueueEntry[] = []; const queue: QueueEntry[] = [];
function getAuthHeaders(): Record<string, string> { function getAuthHeaders(): Record<string, string> {
const user = store.settings.serverAuthUser?.trim() ?? ""; const mode = store.settings.serverAuthMode ?? "NONE";
const pass = store.settings.serverAuthPass?.trim() ?? ""; if (mode === "UI_LOGIN") {
return user && pass ? { Authorization: `Basic ${btoa(`${user}:${pass}`)}` } : {}; const token = uiAuth.getToken();
return token ? { Authorization: `Bearer ${token}` } : {};
}
if (mode === "BASIC_AUTH") {
const user = store.settings.serverAuthUser?.trim() ?? "";
const pass = store.settings.serverAuthPass?.trim() ?? "";
return user && pass ? { Authorization: `Basic ${btoa(`${user}:${pass}`)}` } : {};
}
return {};
} }
async function doFetch(url: string): Promise<string> { async function doFetch(url: string): Promise<string> {
@@ -47,7 +56,7 @@ function drain() {
active++; active++;
doFetch(entry.url) doFetch(entry.url)
.then(entry.resolve, entry.reject) .then(entry.resolve, entry.reject)
.finally(() => { inflight.delete(entry.url); active--; drain(); }); .finally(() => { active--; drain(); });
} }
} }
@@ -58,7 +67,12 @@ function scheduleDrain() {
} }
function enqueue(url: string, priority: number): Promise<string> { function enqueue(url: string, priority: number): Promise<string> {
const promise = new Promise<string>((resolve, reject) => { insertSorted({ url, priority, resolve, reject }); }); const promise = new Promise<string>((resolve, reject) => {
insertSorted({ url, priority, resolve, reject });
}).catch(err => {
inflight.delete(url);
return Promise.reject(err);
});
inflight.set(url, promise); inflight.set(url, promise);
scheduleDrain(); scheduleDrain();
return promise; return promise;
@@ -98,7 +112,17 @@ export function deprioritizeQueue(): void {
queue.sort((a, b) => b.priority - a.priority); queue.sort((a, b) => b.priority - a.priority);
} }
export function cancelQueuedFetches(): void {
const dropped = queue.splice(0);
for (const entry of dropped) {
inflight.delete(entry.url);
entry.reject(new DOMException("Cancelled", "AbortError"));
}
}
export function clearBlobCache(): void { export function clearBlobCache(): void {
cancelQueuedFetches();
cache.forEach(blob => URL.revokeObjectURL(blob)); cache.forEach(blob => URL.revokeObjectURL(blob));
cache.clear(); cache.clear();
inflight.clear();
} }
+23 -15
View File
@@ -1,18 +1,23 @@
import { gql, plainThumbUrl } from "@api/client"; import { gql, getServerUrl } from "@api/client";
import { getBlobUrl, preloadBlobUrls } from "@core/cache/imageCache"; import { getBlobUrl, preloadBlobUrls } from "@core/cache/imageCache";
import { dedupeRequest } from "@core/async/batchRequests"; import { dedupeRequest } from "@core/async/batchRequests";
import { FETCH_CHAPTER_PAGES } from "@api/mutations/chapters"; import { FETCH_CHAPTER_PAGES } from "@api/mutations/chapters";
const pageCache = new Map<number, string[]>(); const pageCache = new Map<number, string[]>();
const inflight = new Map<number, Promise<string[]>>(); const inflight = new Map<number, Promise<string[]>>();
const resolvedUrlCache = new Map<string, Promise<string>>(); const resolvedUrlCache = new Map<string, Promise<string>>();
const preloadedUrls = new Set<string>();
const aspectCache = new Map<string, number>(); const aspectCache = new Map<string, number>();
export function resolveUrl(url: string, useBlob: boolean, priority = 0): Promise<string> { export function resolveUrl(url: string, useBlob: boolean, priority = 0): Promise<string> {
if (!useBlob) return Promise.resolve(url); if (!useBlob) return Promise.resolve(url);
if (!resolvedUrlCache.has(url)) resolvedUrlCache.set(url, getBlobUrl(url, priority)); const cached = resolvedUrlCache.get(url);
return resolvedUrlCache.get(url)!; if (cached) return cached;
const p = getBlobUrl(url, priority).catch(err => {
resolvedUrlCache.delete(url);
return Promise.reject(err);
});
resolvedUrlCache.set(url, p);
return p;
} }
export function fetchPages( export function fetchPages(
@@ -29,11 +34,8 @@ export function fetchPages(
const p = dedupeRequest(`chapter-pages:${chapterId}`, () => const p = dedupeRequest(`chapter-pages:${chapterId}`, () =>
gql<{ fetchChapterPages: { pages: string[] } }>(FETCH_CHAPTER_PAGES, { chapterId }) gql<{ fetchChapterPages: { pages: string[] } }>(FETCH_CHAPTER_PAGES, { chapterId })
.then(d => { .then(d => {
const urls = d.fetchChapterPages.pages.map(p => plainThumbUrl(p)); const urls = d.fetchChapterPages.pages.map(p => p.startsWith("http") ? p : `${getServerUrl()}${p}`);
if (useBlob) { if (useBlob && urls[priorityPage]) getBlobUrl(urls[priorityPage], 999);
if (urls[priorityPage]) getBlobUrl(urls[priorityPage], urls.length + 999);
preloadBlobUrls(urls.filter((_, i) => i !== priorityPage), urls.length);
}
pageCache.set(chapterId, urls); pageCache.set(chapterId, urls);
return urls; return urls;
}) })
@@ -60,11 +62,18 @@ export function measureAspect(url: string, useBlob: boolean): Promise<number> {
} }
export function preloadImage(url: string, useBlob: boolean): void { export function preloadImage(url: string, useBlob: boolean): void {
if (preloadedUrls.has(url)) return; if (useBlob) {
preloadedUrls.add(url); preloadBlobUrls([url], 0);
return;
}
resolveUrl(url, useBlob).then(src => { new Image().src = src; }).catch(() => {}); resolveUrl(url, useBlob).then(src => { new Image().src = src; }).catch(() => {});
} }
export function clearResolvedUrlCache(): void {
resolvedUrlCache.clear();
aspectCache.clear();
}
export function clearPageCache(chapterId?: number): void { export function clearPageCache(chapterId?: number): void {
if (chapterId !== undefined) { if (chapterId !== undefined) {
pageCache.delete(chapterId); pageCache.delete(chapterId);
@@ -73,7 +82,6 @@ export function clearPageCache(chapterId?: number): void {
pageCache.clear(); pageCache.clear();
inflight.clear(); inflight.clear();
resolvedUrlCache.clear(); resolvedUrlCache.clear();
preloadedUrls.clear();
aspectCache.clear(); aspectCache.clear();
} }
} }
+13
View File
@@ -159,3 +159,16 @@ export function getTopSources<T extends { id: string }>(sources: T[]): T[] {
} }
return sources.slice(0, MAX_FRECENCY_SOURCES); return sources.slice(0, MAX_FRECENCY_SOURCES);
} }
export async function refreshMangaCache(mangaId: number, thumbnailUrl?: string): Promise<void> {
cache.clear(CACHE_KEYS.MANGA(mangaId));
cache.clear(CACHE_KEYS.CHAPTERS(mangaId));
cache.clear(CACHE_KEYS.LIBRARY);
cache.clear(CACHE_KEYS.ALL_MANGA);
if (thumbnailUrl) {
const { revokeBlobUrl, getBlobUrl } = await import("@core/cache/imageCache");
revokeBlobUrl(thumbnailUrl);
getBlobUrl(thumbnailUrl, 999).catch(() => {});
}
}
+27
View File
@@ -0,0 +1,27 @@
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] ?? [],
});
});
}
+29
View File
@@ -0,0 +1,29 @@
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);
};
+54
View File
@@ -0,0 +1,54 @@
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();
}
+95
View File
@@ -0,0 +1,95 @@
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(" · ") };
});
}
+4
View File
@@ -0,0 +1,4 @@
export { getHash, areDuplicates, clearHashCache } from "./coverHash";
export { resolvedCover, coverCandidatesSync, dedupeByImage } from "./coverResolver";
export type { CoverCandidate } from "./coverResolver";
export { autoLinkLibrary } from "./autoLink";
+88
View File
@@ -0,0 +1,88 @@
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;
}
+5
View File
@@ -0,0 +1,5 @@
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";
+166
View File
@@ -0,0 +1,166 @@
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");
}
+2 -1
View File
@@ -1,2 +1,3 @@
export * from './idle'; export * from './idle';
export * from './zoom'; export * from './zoom';
export * from './touchscreen';
+234
View File
@@ -0,0 +1,234 @@
export interface LongPressOptions {
onLongPress: (e: PointerEvent) => void;
duration?: number;
moveThreshold?: number;
}
export function longPress(node: HTMLElement, opts: LongPressOptions) {
const { onLongPress, duration = 500, moveThreshold = 8 } = opts;
let timer: ReturnType<typeof setTimeout> | null = null;
let startX = 0, startY = 0;
let fired = false;
function start(e: PointerEvent) {
if (e.button !== 0 && e.pointerType === "mouse") return;
startX = e.clientX; startY = e.clientY; fired = false;
timer = setTimeout(() => { timer = null; fired = true; onLongPress(e); }, duration);
}
function move(e: PointerEvent) {
if (!timer) return;
const dx = e.clientX - startX, dy = e.clientY - startY;
if (Math.sqrt(dx * dx + dy * dy) > moveThreshold) cancel();
}
function cancel() { if (timer) { clearTimeout(timer); timer = null; } }
node.addEventListener("pointerdown", start);
node.addEventListener("pointermove", move);
node.addEventListener("pointerup", cancel);
node.addEventListener("pointerleave", cancel);
node.addEventListener("pointercancel",cancel);
return {
get fired() { return fired; },
destroy() {
cancel();
node.removeEventListener("pointerdown", start);
node.removeEventListener("pointermove", move);
node.removeEventListener("pointerup", cancel);
node.removeEventListener("pointerleave", cancel);
node.removeEventListener("pointercancel",cancel);
},
};
}
export interface TapOptions {
onTap: (e: PointerEvent) => void;
onDoubleTap?: (e: PointerEvent) => void;
doubleTapGap?: number;
}
export function tap(node: HTMLElement, opts: TapOptions) {
const { onTap, onDoubleTap, doubleTapGap = 300 } = opts;
let lastTap = 0;
let pending: ReturnType<typeof setTimeout> | null = null;
let startX = 0, startY = 0;
const SLOP = 8;
function down(e: PointerEvent) { startX = e.clientX; startY = e.clientY; }
function up(e: PointerEvent) {
const dx = e.clientX - startX, dy = e.clientY - startY;
if (Math.sqrt(dx * dx + dy * dy) > SLOP) return;
const now = Date.now();
if (onDoubleTap && now - lastTap < doubleTapGap) {
if (pending) { clearTimeout(pending); pending = null; }
onDoubleTap(e);
lastTap = 0;
} else {
lastTap = now;
if (onDoubleTap) {
pending = setTimeout(() => { pending = null; onTap(e); }, doubleTapGap);
} else {
onTap(e);
}
}
}
node.addEventListener("pointerdown", down);
node.addEventListener("pointerup", up);
return { destroy() {
node.removeEventListener("pointerdown", down);
node.removeEventListener("pointerup", up);
}};
}
export interface SwipeOptions {
onSwipeLeft?: (e: PointerEvent) => void;
onSwipeRight?: (e: PointerEvent) => void;
onSwipeUp?: (e: PointerEvent) => void;
onSwipeDown?: (e: PointerEvent) => void;
threshold?: number;
lockAxis?: boolean;
}
export function swipe(node: HTMLElement, opts: SwipeOptions) {
const { onSwipeLeft, onSwipeRight, onSwipeUp, onSwipeDown, threshold = 40, lockAxis = true } = opts;
let startX = 0, startY = 0, active = false;
function down(e: PointerEvent) {
if (e.pointerType === "mouse") return;
startX = e.clientX; startY = e.clientY; active = true;
node.setPointerCapture(e.pointerId);
}
function up(e: PointerEvent) {
if (!active) return; active = false;
const dx = e.clientX - startX, dy = e.clientY - startY;
const ax = Math.abs(dx), ay = Math.abs(dy);
if (Math.max(ax, ay) < threshold) return;
if (lockAxis && ax > ay) {
if (dx < 0) onSwipeLeft?.(e); else onSwipeRight?.(e);
} else if (lockAxis && ay >= ax) {
if (dy < 0) onSwipeUp?.(e); else onSwipeDown?.(e);
} else {
if (ax >= ay) { if (dx < 0) onSwipeLeft?.(e); else onSwipeRight?.(e); }
else { if (dy < 0) onSwipeUp?.(e); else onSwipeDown?.(e); }
}
}
function cancel() { active = false; }
node.addEventListener("pointerdown", down);
node.addEventListener("pointerup", up);
node.addEventListener("pointercancel", cancel);
return { destroy() {
node.removeEventListener("pointerdown", down);
node.removeEventListener("pointerup", up);
node.removeEventListener("pointercancel", cancel);
}};
}
export interface PinchOptions {
onPinch: (scale: number, origin: { x: number; y: number }) => void;
onPinchEnd?: (scale: number) => void;
}
export interface PinchGestureOptions {
onPinch: (scale: number, origin: { x: number; y: number }) => void;
onPinchEnd?: (scale: number) => void;
}
export interface PinchGesture {
onPointerDown: (e: PointerEvent) => void;
onPointerMove: (e: PointerEvent) => void;
onPointerUp: (e: PointerEvent) => void;
isPinching: () => boolean;
}
export function createPinchGesture(opts: PinchGestureOptions): PinchGesture {
const { onPinch, onPinchEnd } = opts;
const pointers = new Map<number, PointerEvent>();
let initDist = 0;
function pdist(a: PointerEvent, b: PointerEvent) {
const dx = a.clientX - b.clientX, dy = a.clientY - b.clientY;
return Math.sqrt(dx * dx + dy * dy);
}
function pmid(a: PointerEvent, b: PointerEvent) {
return { x: (a.clientX + b.clientX) / 2, y: (a.clientY + b.clientY) / 2 };
}
function onPointerDown(e: PointerEvent) {
pointers.set(e.pointerId, e);
if (pointers.size === 2) {
const [a, b] = [...pointers.values()];
initDist = pdist(a, b);
}
}
function onPointerMove(e: PointerEvent) {
if (!pointers.has(e.pointerId)) return;
pointers.set(e.pointerId, e);
if (pointers.size !== 2 || initDist === 0) return;
const [a, b] = [...pointers.values()];
onPinch(pdist(a, b) / initDist, pmid(a, b));
}
function onPointerUp(e: PointerEvent) {
if (pointers.size === 2 && onPinchEnd) {
const [a, b] = [...pointers.values()];
onPinchEnd(pdist(a, b) / initDist);
}
pointers.delete(e.pointerId);
initDist = 0;
}
return { onPointerDown, onPointerMove, onPointerUp, isPinching: () => pointers.size >= 2 };
}
export function pinch(node: HTMLElement, opts: PinchOptions) {
const gesture = createPinchGesture(opts);
function down(e: PointerEvent) { node.setPointerCapture(e.pointerId); gesture.onPointerDown(e); }
node.addEventListener("pointerdown", down);
node.addEventListener("pointermove", gesture.onPointerMove);
node.addEventListener("pointerup", gesture.onPointerUp);
node.addEventListener("pointercancel", gesture.onPointerUp);
return { destroy() {
node.removeEventListener("pointerdown", down);
node.removeEventListener("pointermove", gesture.onPointerMove);
node.removeEventListener("pointerup", gesture.onPointerUp);
node.removeEventListener("pointercancel", gesture.onPointerUp);
}};
}
export interface DragScrollOptions {
direction?: "x" | "y" | "both";
onDragStart?: () => void;
onDragEnd?: () => void;
}
export function dragScroll(node: HTMLElement, opts: DragScrollOptions = {}) {
const { direction = "both", onDragStart, onDragEnd } = opts;
let active = false, startX = 0, startY = 0, scrollX = 0, scrollY = 0;
function down(e: PointerEvent) {
if (e.pointerType === "mouse") return;
active = true;
startX = e.clientX; startY = e.clientY;
scrollX = node.scrollLeft; scrollY = node.scrollTop;
node.setPointerCapture(e.pointerId);
onDragStart?.();
}
function move(e: PointerEvent) {
if (!active) return;
if (direction !== "x") node.scrollTop = scrollY - (e.clientY - startY);
if (direction !== "y") node.scrollLeft = scrollX - (e.clientX - startX);
}
function up() { if (active) { active = false; onDragEnd?.(); } }
node.addEventListener("pointerdown", down);
node.addEventListener("pointermove", move);
node.addEventListener("pointerup", up);
node.addEventListener("pointercancel", up);
return { destroy() {
node.removeEventListener("pointerdown", down);
node.removeEventListener("pointermove", move);
node.removeEventListener("pointerup", up);
node.removeEventListener("pointercancel", up);
}};
}
+71 -89
View File
@@ -1,12 +1,8 @@
import type { Manga, Source } from "@types"; import type { Manga, Source } from "@types";
import type { Settings } from "@types"; import type { Settings } from "@types";
// ── Class utility ─────────────────────────────────────────────────────────────
export { clsx as cn } from "clsx"; export { clsx as cn } from "clsx";
// ── Time / formatting ─────────────────────────────────────────────────────────
export function timeAgo(ts: number): string { export function timeAgo(ts: number): string {
const diff = Date.now() - ts, m = Math.floor(diff / 60000); const diff = Date.now() - ts, m = Math.floor(diff / 60000);
if (m < 1) return "Just now"; if (m < 1) return "Just now";
@@ -33,85 +29,95 @@ export function formatReadTime(m: number): string {
return r === 0 ? `${h}h` : `${h}h ${r}m`; return r === 0 ? `${h}h` : `${h}h ${r}m`;
} }
// ── NSFW filtering ──────────────────────────────────────────────────────────── const STRICT_TAGS: string[] = [
"adult", "mature", "hentai", "ecchi", "erotic", "pornograph",
/** "18+", "smut", "explicit", "sexual violence",
* Default genre substrings used when no user-configured list is available. "gore", "guro", "graphic violence", "torture", "body horror",
* Stored as settings.nsfwFilteredTags; editable in Settings > Content.
*/
export const DEFAULT_NSFW_TAGS = [
"adult",
"mature",
"hentai",
"ecchi",
"erotic", // catches "erotica", "erotic content", "erotic manga"
"pornograph", // catches "pornographic", "pornography"
"18+",
"smut",
"lemon",
"explicit",
"sexual violence",
]; ];
/** const MODERATE_TAGS: string[] = [
* Returns true if the manga's genre list contains any of the given substrings. "adult", "mature", "hentai", "ecchi", "erotic", "pornograph",
* Falls back to DEFAULT_NSFW_TAGS if no tag list is provided. "18+", "smut", "explicit", "sexual violence",
*/ ];
export function isNsfwManga(
manga: { genre?: string[] | null }, type ContentFilterSettings = Pick<
tags: string[] = DEFAULT_NSFW_TAGS, Settings,
): boolean { "contentLevel" | "sourceOverridesEnabled" | "nsfwAllowedSourceIds" | "nsfwBlockedSourceIds"
return (manga.genre ?? []).some(g => >;
tags.some(sub => g.toLowerCase().trim().includes(sub))
); function blockedTagsForSettings(settings: ContentFilterSettings): string[] {
if (settings.contentLevel === "strict") return STRICT_TAGS;
if (settings.contentLevel === "moderate") return MODERATE_TAGS;
return [];
}
function genreMatchesBlocklist(genre: string[], blockedTags: string[]): boolean {
if (!blockedTags.length) return false;
return genre.some(g => {
const norm = g.toLowerCase().trim();
return blockedTags.some(tag => {
const idx = norm.indexOf(tag);
if (idx === -1) return false;
const before = idx === 0 || /\W/.test(norm[idx - 1]);
const after = idx + tag.length === norm.length || /\W/.test(norm[idx + tag.length]);
return before && after;
});
});
} }
/**
* Single authoritative NSFW gate used by all views.
* Returns true when the manga should be HIDDEN. Priority order:
* 1. Source in blockedSourceIds → always hidden, even when showNsfw is on.
* 2. showNsfw globally enabled → only blocked sources are hidden.
* 3. Source in allowedSourceIds → skip isNsfw flag, but genre tags still apply.
* 4. source.isNsfw flag → hidden.
* 5. Genre tag match → hidden.
*
* Usage: items.filter(m => !shouldHideNsfw(m, settings))
*/
export function shouldHideNsfw( export function shouldHideNsfw(
manga: Pick<Manga, "genre" | "source">, manga: Pick<Manga, "genre" | "source">,
settings: Pick<Settings, "showNsfw" | "nsfwFilteredTags" | "nsfwAllowedSourceIds" | "nsfwBlockedSourceIds">, settings: ContentFilterSettings,
): boolean { ): boolean {
const srcId = manga.source?.id; if (settings.contentLevel === "unrestricted") return false;
if (srcId && settings.nsfwBlockedSourceIds.includes(srcId)) return true; const srcId = manga.source?.id;
if (settings.showNsfw) return false; const blocked = settings.sourceOverridesEnabled ? (settings.nsfwBlockedSourceIds ?? []) : [];
const allowed = settings.sourceOverridesEnabled ? (settings.nsfwAllowedSourceIds ?? []) : [];
if (srcId && blocked.includes(srcId)) return true;
const sourceAllowed = !!(srcId && allowed.includes(srcId));
const sourceAllowed = !!(srcId && settings.nsfwAllowedSourceIds.includes(srcId));
if (!sourceAllowed && manga.source?.isNsfw) return true; if (!sourceAllowed && manga.source?.isNsfw) return true;
return isNsfwManga(manga, settings.nsfwFilteredTags); return genreMatchesBlocklist(manga.genre ?? [], blockedTagsForSettings(settings));
} }
/**
* Gate for Source objects — parallel to shouldHideNsfw for manga.
* Usage: sources.filter(s => !shouldHideSource(s, settings))
*/
export function shouldHideSource( export function shouldHideSource(
source: Pick<Source, "id" | "isNsfw">, source: Pick<Source, "id" | "isNsfw">,
settings: Pick<Settings, "showNsfw" | "nsfwAllowedSourceIds" | "nsfwBlockedSourceIds">, settings: ContentFilterSettings,
): boolean { ): boolean {
if (settings.nsfwBlockedSourceIds.includes(source.id)) return true; if (settings.contentLevel === "unrestricted") return false;
if (settings.nsfwAllowedSourceIds.includes(source.id)) return false;
return !settings.showNsfw && source.isNsfw; if (settings.sourceOverridesEnabled) {
if ((settings.nsfwBlockedSourceIds ?? []).includes(source.id)) return true;
if ((settings.nsfwAllowedSourceIds ?? []).includes(source.id)) return false;
}
return source.isNsfw;
} }
// ── Source deduplication ────────────────────────────────────────────────────── export function dedupeSourcesByLang(
sources: Source[],
preferredLang: string,
settings: ContentFilterSettings,
applyHide = false,
): Source[] {
const map = new Map<string, Source>();
for (const s of sources) {
if (s.id === "0") continue;
if (applyHide && shouldHideSource(s, settings)) continue;
const existing = map.get(s.name);
if (!existing) { map.set(s.name, s); continue; }
const existingPref = existing.lang === preferredLang;
const newPref = s.lang === preferredLang;
if (newPref && !existingPref) map.set(s.name, s);
else if (!existingPref && !newPref && s.lang < existing.lang) map.set(s.name, s);
}
return Array.from(map.values());
}
/**
* Deduplicates sources by name. When multiple sources share a name,
* the preferred language wins; otherwise falls back to alphabetical by lang.
* The local source (id "0") is always excluded.
*/
export function dedupeSources(sources: Source[], preferredLang: string): Source[] { export function dedupeSources(sources: Source[], preferredLang: string): Source[] {
const byName = new Map<string, Source[]>(); const byName = new Map<string, Source[]>();
for (const src of sources) { for (const src of sources) {
@@ -127,9 +133,6 @@ export function dedupeSources(sources: Source[], preferredLang: string): Source[
return picked; return picked;
} }
// ── Manga deduplication ───────────────────────────────────────────────────────
/** Strips punctuation, articles, and source suffixes for fuzzy title matching. */
export function normalizeTitle(title: string): string { export function normalizeTitle(title: string): string {
return title return title
.toLowerCase() .toLowerCase()
@@ -140,39 +143,21 @@ export function normalizeTitle(title: string): string {
.trim(); .trim();
} }
/** Strips all non-alphanumeric chars and collapses whitespace. */
function norm(s: string): string { function norm(s: string): string {
return s.toLowerCase().replace(/[^a-z0-9]/g, " ").replace(/\s+/g, " ").trim(); return s.toLowerCase().replace(/[^a-z0-9]/g, " ").replace(/\s+/g, " ").trim();
} }
/**
* First 200 normalized chars of a description — reliable cross-source fingerprint.
* Returns null if too short (< 60 chars) to be a trustworthy signal.
*/
function descFingerprint(desc: string | null | undefined): string | null { function descFingerprint(desc: string | null | undefined): string | null {
if (!desc) return null; if (!desc) return null;
const n = norm(desc); const n = norm(desc);
return n.length >= 60 ? n.slice(0, 200) : null; return n.length >= 60 ? n.slice(0, 200) : null;
} }
/**
* Normalized author + artist concatenation for tie-breaking.
* Returns null if no author info available.
*/
function authorFingerprint(author?: string | null, artist?: string | null): string | null { function authorFingerprint(author?: string | null, artist?: string | null): string | null {
const parts = [author, artist].filter(Boolean).map(s => norm(s!)); const parts = [author, artist].filter(Boolean).map(s => norm(s!));
return parts.length ? parts.sort().join("|") : null; return parts.length ? parts.sort().join("|") : null;
} }
/**
* Deduplicates manga across sources using title, description, and author signals,
* plus explicit user-defined links (settings.mangaLinks).
*
* When two entries match, the better one is kept:
* - Library membership wins over non-library.
* - Otherwise higher downloadCount wins.
* - Otherwise first occurrence wins.
*/
export function dedupeMangaByTitle<T extends { export function dedupeMangaByTitle<T extends {
id: number; id: number;
title: string; title: string;
@@ -228,9 +213,6 @@ export function dedupeMangaByTitle<T extends {
return out; return out;
} }
/**
* Lossless deduplication by ID only. Preserves first occurrence.
*/
export function dedupeMangaById<T extends { id: number }>(items: T[]): T[] { export function dedupeMangaById<T extends { id: number }>(items: T[]): T[] {
const seen = new Set<number>(); const seen = new Set<number>();
const out: T[] = []; const out: T[] = [];
-2
View File
@@ -32,6 +32,4 @@
--dot-active: var(--accent); --dot-active: var(--accent);
--dot-inactive: var(--text-faint); --dot-inactive: var(--text-faint);
--bg-image: none;
} }
+3 -2
View File
@@ -8,5 +8,6 @@
--sp-8: 32px; --sp-8: 32px;
--sp-10: 40px; --sp-10: 40px;
--sidebar-width: 52px; --sidebar-width: 52px;
} --titlebar-height: 36px;
}
@@ -5,8 +5,10 @@
import { runConcurrent } from "@core/async/batchRequests"; import { runConcurrent } from "@core/async/batchRequests";
import { shouldHideNsfw, shouldHideSource, dedupeMangaById, dedupeMangaByTitle } from "@core/util"; import { shouldHideNsfw, shouldHideSource, dedupeMangaById, dedupeMangaByTitle } from "@core/util";
import { store } from "@store/state.svelte"; import { store } from "@store/state.svelte";
import { preloadBlobUrls } from "@core/cache/imageCache";
import Thumbnail from "@shared/manga/Thumbnail.svelte"; import Thumbnail from "@shared/manga/Thumbnail.svelte";
import type { Manga, Source } from "@types"; import type { Manga, Source } from "@types";
import type { CachedManga } from "@features/discover/lib/searchFilter";
interface Props { interface Props {
allSources: Source[]; allSources: Source[];
@@ -16,12 +18,14 @@
pendingPrefill: string; pendingPrefill: string;
popularResults: (Manga & { _priority: number })[]; popularResults: (Manga & { _priority: number })[];
popularLoading: boolean; popularLoading: boolean;
sourceCache: Map<number, CachedManga>;
onPrefillConsumed: () => void; onPrefillConsumed: () => void;
onPreview: (m: Manga) => void; onPreview: (m: Manga) => void;
} }
let { let {
allSources, availableLangs, hasMultipleLangs, loadingSources, allSources, availableLangs, hasMultipleLangs, loadingSources,
pendingPrefill, popularResults, popularLoading, pendingPrefill, popularResults, popularLoading,
sourceCache,
onPrefillConsumed, onPreview, onPrefillConsumed, onPreview,
}: Props = $props(); }: Props = $props();
@@ -72,7 +76,7 @@
let filtered = allSources; let filtered = allSources;
if (kw_selectedLangs.size > 0) if (kw_selectedLangs.size > 0)
filtered = filtered.filter((s) => kw_selectedLangs.has(s.lang)); filtered = filtered.filter((s) => kw_selectedLangs.has(s.lang));
if (!store.settings.showNsfw) if (store.settings.contentLevel !== "unrestricted")
filtered = filtered.filter((s) => !shouldHideSource(s, store.settings)); filtered = filtered.filter((s) => !shouldHideSource(s, store.settings));
return filtered; return filtered;
} }
@@ -99,6 +103,10 @@
); );
if (ctrl.signal.aborted) return; if (ctrl.signal.aborted) return;
const mangas = d.fetchSourceManga.mangas.filter((m) => !shouldHideNsfw(m, store.settings)); const mangas = d.fetchSourceManga.mangas.filter((m) => !shouldHideNsfw(m, store.settings));
preloadBlobUrls(
mangas.map((m) => sourceCache.get(m.id)?.thumbnailUrl ?? m.thumbnailUrl),
12,
);
const next = [...kw_results]; const next = [...kw_results];
next[idx] = { ...next[idx], mangas, loading: false }; next[idx] = { ...next[idx], mangas, loading: false };
kw_results = next; kw_results = next;
@@ -276,7 +284,6 @@
</script> </script>
<style> <style>
.keywordBar { padding: var(--sp-3) var(--sp-4) var(--sp-2); flex-shrink: 0; display: flex; flex-direction: column; gap: var(--sp-2); } .keywordBar { padding: var(--sp-3) var(--sp-4) var(--sp-2); flex-shrink: 0; display: flex; flex-direction: column; gap: var(--sp-2); }
.searchBar { display: flex; align-items: center; gap: var(--sp-2); background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-lg); padding: var(--sp-2) var(--sp-3); transition: border-color var(--t-base); } .searchBar { display: flex; align-items: center; gap: var(--sp-2); background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-lg); padding: var(--sp-2) var(--sp-3); transition: border-color var(--t-base); }
.searchBar:focus-within { border-color: var(--border-strong); } .searchBar:focus-within { border-color: var(--border-strong); }
@@ -285,7 +292,7 @@
.searchInput::placeholder { color: var(--text-faint); } .searchInput::placeholder { color: var(--text-faint); }
.clearBtn { color: var(--text-faint); font-size: 16px; line-height: 1; background: none; border: none; cursor: pointer; padding: 2px; transition: color var(--t-base); } .clearBtn { color: var(--text-faint); font-size: 16px; line-height: 1; background: none; border: none; cursor: pointer; padding: 2px; transition: color var(--t-base); }
.clearBtn:hover { color: var(--text-muted); } .clearBtn:hover { color: var(--text-muted); }
.advancedBtn { display: flex; align-items: center; padding: 4px; border-radius: var(--radius-sm); border: 1px solid transparent; background: none; color: var(--text-faint); cursor: pointer; transition: color var(--t-base), background var(--t-base), border-color var(--t-base); } .advancedBtn { display: flex; align-items: center; padding: 4px; border-radius: var(--radius-sm); border: 1px solid transparent; background: none; color: var(--text-faint); cursor: pointer; transition: color var(--t-base), background var(--t-base), border-color var(--t-base); }
.advancedBtn:hover { color: var(--text-muted); background: var(--bg-overlay); } .advancedBtn:hover { color: var(--text-muted); background: var(--bg-overlay); }
.advancedBtnActive { color: var(--accent-fg); background: var(--accent-muted); border-color: var(--accent-dim); } .advancedBtnActive { color: var(--accent-fg); background: var(--accent-muted); border-color: var(--accent-dim); }
@@ -301,30 +308,30 @@
.langChipActive { color: var(--accent-fg); background: var(--accent-muted); border-color: var(--accent-dim); } .langChipActive { color: var(--accent-fg); background: var(--accent-muted); border-color: var(--accent-dim); }
.advancedDivider { height: 1px; background: var(--border-dim); } .advancedDivider { height: 1px; background: var(--border-dim); }
.advancedFooter { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); } .advancedFooter { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); }
.searchHeader { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-3) var(--sp-4) var(--sp-1); flex-shrink: 0; } .searchHeader { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-3) var(--sp-4) var(--sp-1); flex-shrink: 0; }
.searchLabel { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; } .searchLabel { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; }
.searchGrid { display: grid; grid-template-columns: repeat(auto-fill, minmax(clamp(90px, 11vw, 130px), 1fr)); gap: var(--sp-2); padding: var(--sp-2) var(--sp-4) var(--sp-6); overflow-y: auto; flex: 1; align-content: start; will-change: scroll-position; } .searchGrid { display: grid; grid-template-columns: repeat(auto-fill, minmax(clamp(90px, 11vw, 130px), 1fr)); gap: var(--sp-2); padding: var(--sp-2) var(--sp-4) var(--sp-6); overflow-y: auto; flex: 1; align-content: start; will-change: scroll-position; }
.srchCard { background: none; border: none; padding: 0; cursor: pointer; text-align: left; } .srchCard { background: none; border: none; padding: 0; cursor: pointer; text-align: left; }
.srchCard:hover .srchCoverWrap { filter: brightness(1.08) saturate(1.05); } .srchCard:hover .srchCoverWrap { filter: brightness(1.08) saturate(1.05); }
.srchCoverWrap { position: relative; aspect-ratio: 2/3; overflow: hidden; border-radius: var(--radius-md); background: var(--bg-raised); border: 1px solid var(--border-dim); transform: translateZ(0); transition: filter var(--t-base); contain: layout style; } .srchCoverWrap { position: relative; aspect-ratio: 2/3; overflow: hidden; border-radius: var(--radius-md); background: var(--bg-raised); border: 1px solid var(--border-dim); transform: translateZ(0); transition: filter var(--t-base); }
.srchGradient { position: absolute; inset: 0; background: linear-gradient(to top, rgba(0,0,0,0.82) 0%, rgba(0,0,0,0.15) 50%, transparent 72%); pointer-events: none; } .srchGradient { position: absolute; inset: 0; z-index: 1; background: linear-gradient(to top, rgba(0,0,0,0.82) 0%, rgba(0,0,0,0.15) 50%, transparent 72%); pointer-events: none; }
.srchFooter { position: absolute; bottom: 0; left: 0; right: 0; padding: var(--sp-2); pointer-events: none; } .srchFooter { position: absolute; bottom: 0; left: 0; right: 0; z-index: 2; padding: var(--sp-2); pointer-events: none; }
.srchTitle { font-size: var(--text-xs); font-weight: var(--weight-medium); color: rgba(255,255,255,0.92); line-height: var(--leading-snug); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; text-shadow: 0 1px 4px rgba(0,0,0,0.7); } .srchTitle { font-size: var(--text-xs); font-weight: var(--weight-medium); color: rgba(255,255,255,0.92); line-height: var(--leading-snug); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; text-shadow: 0 1px 4px rgba(0,0,0,0.7); }
.srchSource { font-family: var(--font-ui); font-size: 9px; color: rgba(255,255,255,0.45); letter-spacing: var(--tracking-wide); margin-top: 1px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .srchSource { font-family: var(--font-ui); font-size: 9px; color: rgba(255,255,255,0.45); letter-spacing: var(--tracking-wide); margin-top: 1px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.inLibBadge { position: absolute; top: var(--sp-2); left: var(--sp-2); font-family: var(--font-ui); font-size: 9px; letter-spacing: var(--tracking-wide); background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim); border-radius: var(--radius-sm); padding: 1px 5px; } .inLibBadge { position: absolute; top: var(--sp-2); left: var(--sp-2); z-index: 2; font-family: var(--font-ui); font-size: 9px; letter-spacing: var(--tracking-wide); background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim); border-radius: var(--radius-sm); padding: 1px 5px; }
.skCard { display: flex; flex-direction: column; gap: var(--sp-2); flex-shrink: 0; width: 100%; } .skCard { display: flex; flex-direction: column; gap: var(--sp-2); flex-shrink: 0; width: 100%; }
@keyframes shimmer { from { background-position: -200% 0 } to { background-position: 200% 0 } } @keyframes shimmer { from { background-position: -200% 0 } to { background-position: 200% 0 } }
.skeleton { border-radius: var(--radius-sm); background: linear-gradient(90deg, var(--bg-raised) 25%, var(--bg-overlay, color-mix(in srgb, var(--bg-raised) 80%, var(--text-primary) 6%)) 50%, var(--bg-raised) 75%); background-size: 200% 100%; animation: shimmer 1.6s ease-in-out infinite; } .skeleton { border-radius: var(--radius-sm); background: linear-gradient(90deg, var(--bg-raised) 25%, var(--bg-overlay, color-mix(in srgb, var(--bg-raised) 80%, var(--text-primary) 6%)) 50%, var(--bg-raised) 75%); background-size: 200% 100%; animation: shimmer 1.6s ease-in-out infinite; }
.skCover { aspect-ratio: 2 / 3; width: 100%; border-radius: var(--radius-md); } .skCover { aspect-ratio: 2 / 3; width: 100%; border-radius: var(--radius-md); }
.empty { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: var(--sp-2); padding: var(--sp-8); } .empty { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: var(--sp-2); padding: var(--sp-8); }
.emptyIcon { color: var(--text-faint); opacity: 0.5; } .emptyIcon { color: var(--text-faint); opacity: 0.5; }
.emptyText { font-size: var(--text-sm); color: var(--text-muted); font-weight: var(--weight-medium); margin: 0; } .emptyText { font-size: var(--text-sm); color: var(--text-muted); font-weight: var(--weight-medium); margin: 0; }
.emptyHint { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); margin: 0; } .emptyHint { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); margin: 0; }
@keyframes anim-spin { from { transform: rotate(0deg) } to { transform: rotate(360deg) } } @keyframes anim-spin { from { transform: rotate(0deg) } to { transform: rotate(360deg) } }
.anim-spin { animation: anim-spin 0.8s linear infinite; } .anim-spin { animation: anim-spin 0.8s linear infinite; }
</style> </style>
@@ -8,7 +8,7 @@
import { deprioritizeQueue } from "@core/cache/imageCache"; import { deprioritizeQueue } from "@core/cache/imageCache";
import { dedupeSourcesByLang }from "@core/algorithms/filter"; import { dedupeSourcesByLang }from "@core/algorithms/filter";
import { shouldHideNsfw } from "@core/util"; import { shouldHideNsfw } from "@core/util";
import { store, setSearchPrefill, setPreviewManga } from "@store/state.svelte"; import { store, setSearchPrefill, setPreviewManga, setSearchQuery } from "@store/state.svelte";
import { import {
toCachedManga, toCachedManga,
type CachedManga, type CachedManga,
@@ -287,6 +287,9 @@
{pendingPrefill} {pendingPrefill}
popularResults={popular_results} popularResults={popular_results}
popularLoading={popular_loading} popularLoading={popular_loading}
{sourceCache}
query={store.searchQuery}
onQueryChange={setSearchQuery}
onPrefillConsumed={() => (pendingPrefill = "")} onPrefillConsumed={() => (pendingPrefill = "")}
onPreview={setPreviewManga} onPreview={setPreviewManga}
/> />
+1 -5
View File
@@ -49,7 +49,6 @@ export interface CachedManga {
genreEnriched: boolean; genreEnriched: boolean;
} }
export const COMMON_GENRES = [ export const COMMON_GENRES = [
"Action", "Adventure", "Comedy", "Drama", "Fantasy", "Romance", "Action", "Adventure", "Comedy", "Drama", "Fantasy", "Romance",
"Sci-Fi", "Slice of Life", "Horror", "Mystery", "Thriller", "Sports", "Sci-Fi", "Slice of Life", "Horror", "Mystery", "Thriller", "Sports",
@@ -66,7 +65,6 @@ export const MANGA_STATUSES: { value: string; label: string }[] = [
{ value: "UNKNOWN", label: "Unknown" }, { value: "UNKNOWN", label: "Unknown" },
]; ];
export function buildTagFilter( export function buildTagFilter(
tags: string[], tags: string[],
mode: TagMode, mode: TagMode,
@@ -90,13 +88,12 @@ export function buildTagFilter(
return { and: [genrePart, statusPart] }; return { and: [genrePart, statusPart] };
} }
export function filterSourceCache( export function filterSourceCache(
sourceCache: Map<number, CachedManga>, sourceCache: Map<number, CachedManga>,
tags: string[], tags: string[],
mode: TagMode, mode: TagMode,
statuses: string[], statuses: string[],
settings: Pick<Settings, "showNsfw" | "nsfwFilteredTags" | "nsfwAllowedSourceIds" | "nsfwBlockedSourceIds">, settings: Pick<Settings, "contentLevel" | "sourceOverridesEnabled" | "nsfwAllowedSourceIds" | "nsfwBlockedSourceIds">,
): CachedManga[] { ): CachedManga[] {
return [...sourceCache.values()].filter((m) => { return [...sourceCache.values()].filter((m) => {
if (shouldHideNsfw(m as any, settings)) return false; if (shouldHideNsfw(m as any, settings)) return false;
@@ -118,7 +115,6 @@ export function filterSourceCache(
}); });
} }
export function toCachedManga( export function toCachedManga(
m: { id: number; title: string; thumbnailUrl: string; inLibrary: boolean; genre?: string[]; status?: string }, m: { id: number; title: string; thumbnailUrl: string; inLibrary: boolean; genre?: string[]; status?: string },
srcId: string, srcId: string,
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { CircleNotch, ArrowClockwise, X } from "phosphor-svelte"; import { CircleNotch, ArrowClockwise, X } from "phosphor-svelte";
import Thumbnail from "@shared/manga/Thumbnail.svelte"; import Thumbnail from "@shared/manga/Thumbnail.svelte";
import { longPress } from "@core/ui/touchscreen";
import type { DownloadQueueItem } from "@types/index"; import type { DownloadQueueItem } from "@types/index";
import { pageProgress } from "../lib/downloadQueue"; import { pageProgress } from "../lib/downloadQueue";
@@ -24,6 +25,12 @@
const prog = $derived(pageProgress(item.progress, pages)); const prog = $derived(pageProgress(item.progress, pages));
const isError = $derived(item.state === "ERROR"); const isError = $derived(item.state === "ERROR");
const pct = $derived(Math.round(item.progress * 100)); const pct = $derived(Math.round(item.progress * 100));
function rowLongPress(node: HTMLElement) {
return longPress(node, {
onLongPress() { onSelect(item.chapter.id, { shiftKey: false, ctrlKey: true, metaKey: false } as MouseEvent); },
});
}
</script> </script>
<div <div
@@ -32,7 +39,12 @@
class:row-error={isError} class:row-error={isError}
class:row-selected={isSelected} class:row-selected={isSelected}
class:row-removing={isRemoving} class:row-removing={isRemoving}
role="option"
aria-selected={isSelected}
tabindex="0"
use:rowLongPress
onclick={(e) => { e.stopPropagation(); onSelect(item.chapter.id, e); }} onclick={(e) => { e.stopPropagation(); onSelect(item.chapter.id, e); }}
onkeydown={(e) => { if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); onSelect(item.chapter.id, e as unknown as MouseEvent); } }}
> >
{#if manga?.thumbnailUrl} {#if manga?.thumbnailUrl}
<div class="thumb"> <div class="thumb">
@@ -115,7 +115,7 @@
</div> </div>
<div class="bar-wrap"> <div class="bar-wrap">
<div class="status-bar" onclick={handleClickOff} role="presentation"> <div class="status-bar" role="none">
<div class="status-dot" class:active={downloadStore.isRunning}></div> <div class="status-dot" class:active={downloadStore.isRunning}></div>
<span class="status-text"> <span class="status-text">
{downloadStore.togglingPlay {downloadStore.togglingPlay
@@ -127,7 +127,7 @@
<button class="sel-action-btn" disabled={downloadStore.batchWorking} onclick={(e) => { e.stopPropagation(); downloadStore.reorderSelectedToEdge("top"); }} title="Move to top"> <button class="sel-action-btn" disabled={downloadStore.batchWorking} onclick={(e) => { e.stopPropagation(); downloadStore.reorderSelectedToEdge("top"); }} title="Move to top">
<ArrowLineUp size={12} weight="bold" /> <ArrowLineUp size={12} weight="bold" />
</button> </button>
<div class="move-step" onclick={(e) => e.stopPropagation()} role="presentation"> <div class="move-step" onclick={(e) => e.stopPropagation()} onkeydown={(e) => e.stopPropagation()} role="none">
<button class="sel-action-btn" disabled={downloadStore.batchWorking} onclick={(e) => { e.stopPropagation(); downloadStore.reorderSelected("up", moveBy); }} title="Move up"> <button class="sel-action-btn" disabled={downloadStore.batchWorking} onclick={(e) => { e.stopPropagation(); downloadStore.reorderSelected("up", moveBy); }} title="Move up">
<CaretUp size={12} weight="bold" /> <CaretUp size={12} weight="bold" />
</button> </button>
@@ -168,7 +168,7 @@
</div> </div>
</div> </div>
<div class="content" onclick={handleClickOff}> <div class="content" role="none" onclick={handleClickOff} onkeydown={(e) => e.key === 'Escape' && handleClickOff()}>
<DownloadQueue <DownloadQueue
queue={downloadStore.queue} queue={downloadStore.queue}
loading={downloadStore.loading} loading={downloadStore.loading}
@@ -204,9 +204,6 @@
.sel-controls { display: flex; align-items: center; gap: var(--sp-2); } .sel-controls { display: flex; align-items: center; gap: var(--sp-2); }
.status-bar { cursor: default; } .status-bar { cursor: default; }
.bar-sep { width: 1px; height: 12px; background: var(--border-dim); flex-shrink: 0; } .bar-sep { width: 1px; height: 12px; background: var(--border-dim); flex-shrink: 0; }
.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); white-space: nowrap; }
.sel-text-btn:hover { color: var(--text-primary); }
.sel-action-btn { display: flex; align-items: center; gap: 4px; font-family: var(--font-ui); font-size: var(--text-xs); padding: 3px 8px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: var(--bg-overlay); 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 { display: flex; align-items: center; gap: 4px; font-family: var(--font-ui); font-size: var(--text-xs); padding: 3px 8px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: var(--bg-overlay); 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:hover:not(:disabled) { color: var(--text-primary); border-color: var(--border-strong); }
.sel-action-btn:disabled { opacity: 0.35; cursor: not-allowed; } .sel-action-btn:disabled { opacity: 0.35; cursor: not-allowed; }
@@ -5,7 +5,8 @@ import {
DEQUEUE_DOWNLOAD, DEQUEUE_CHAPTERS_DOWNLOAD, DEQUEUE_DOWNLOAD, DEQUEUE_CHAPTERS_DOWNLOAD,
ENQUEUE_DOWNLOAD, REORDER_DOWNLOAD, ENQUEUE_DOWNLOAD, REORDER_DOWNLOAD,
} from "@api/mutations"; } from "@api/mutations";
import { addToast, setActiveDownloads } from "@store/state.svelte"; import { addToast, setActiveDownloads, store, updateSettings } from "@store/state.svelte";
import { boot } from "@store/boot.svelte";
import type { DownloadStatus, DownloadQueueItem } from "@types/index"; import type { DownloadStatus, DownloadQueueItem } from "@types/index";
import { import {
toActiveDownloads, optimisticRemove, optimisticRemoveMany, toActiveDownloads, optimisticRemove, optimisticRemoveMany,
@@ -25,8 +26,8 @@ class DownloadStore {
pagesPerSec: number | null = $state(null); pagesPerSec: number | null = $state(null);
eta: number | null = $state(null); eta: number | null = $state(null);
toastsEnabled = $state(true); get toastsEnabled() { return store.settings.downloadToastsEnabled ?? true; }
autoRetryEnabled = $state(false); get autoRetryEnabled() { return store.settings.downloadAutoRetry ?? false; }
private lastSample: SpeedSample | null = null; private lastSample: SpeedSample | null = null;
private prevQueue: DownloadQueueItem[] = []; private prevQueue: DownloadQueueItem[] = [];
@@ -38,18 +39,19 @@ class DownloadStore {
get hasErrored() { return this.erroredIds.size > 0; } get hasErrored() { return this.erroredIds.size > 0; }
toggleToasts() { toggleToasts() {
this.toastsEnabled = !this.toastsEnabled; const next = !this.toastsEnabled;
addToast({ kind: "info", title: this.toastsEnabled ? "Notifications enabled" : "Notifications muted", body: this.toastsEnabled ? "You'll be notified when chapters finish downloading" : "Download notifications are silenced", duration: 2500 }); updateSettings({ downloadToastsEnabled: next });
addToast({ kind: "info", title: next ? "Notifications enabled" : "Notifications muted", body: next ? "You'll be notified when chapters finish downloading" : "Download notifications are silenced", duration: 2500 });
} }
toggleAutoRetry() { toggleAutoRetry() {
if (this.autoRetryEnabled) { if (this.autoRetryEnabled) {
this.autoRetryHnd?.stop(); this.autoRetryHnd?.stop();
this.autoRetryHnd = null; this.autoRetryHnd = null;
this.autoRetryEnabled = false; updateSettings({ downloadAutoRetry: false });
addToast({ kind: "info", title: "Auto-retry disabled", body: "Failed downloads will no longer retry automatically", duration: 2500 }); addToast({ kind: "info", title: "Auto-retry disabled", body: "Failed downloads will no longer retry automatically", duration: 2500 });
} else { } else {
this.autoRetryEnabled = true; updateSettings({ downloadAutoRetry: true });
this.autoRetryHnd = startAutoRetry( this.autoRetryHnd = startAutoRetry(
() => this.queue, () => this.queue,
() => this.isRunning, () => this.isRunning,
@@ -104,6 +106,7 @@ class DownloadStore {
} }
async poll() { async poll() {
if (boot.sessionExpired) return;
gql<{ downloadStatus: DownloadStatus }>(GET_DOWNLOAD_STATUS) gql<{ downloadStatus: DownloadStatus }>(GET_DOWNLOAD_STATUS)
.then((d) => this.applyStatus(d.downloadStatus)) .then((d) => this.applyStatus(d.downloadStatus))
.catch(console.error) .catch(console.error)
@@ -323,8 +323,8 @@
.root { display: flex; flex-direction: column; height: 100%; overflow: hidden; } .root { display: flex; flex-direction: column; height: 100%; overflow: hidden; }
.list { flex: 1; overflow-y: auto; padding: 0 var(--sp-4) var(--sp-4); display: flex; flex-direction: column; gap: 1px; } .list { flex: 1; overflow-y: auto; padding: 0 var(--sp-4) var(--sp-4); display: flex; flex-direction: column; gap: 1px; }
.empty { display: flex; align-items: center; justify-content: center; flex: 1; color: var(--text-faint); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); } .empty { display: flex; align-items: center; justify-content: center; flex: 1; color: var(--text-faint); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); }
.icon-btn { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: var(--radius-md); color: var(--text-muted); transition: color var(--t-base), background var(--t-base); } :global(.icon-btn) { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: var(--radius-md); color: var(--text-muted); transition: color var(--t-base), background var(--t-base); }
.icon-btn:hover:not(:disabled) { color: var(--text-primary); background: var(--bg-raised); } :global(.icon-btn:hover:not(:disabled)) { color: var(--text-primary); background: var(--bg-raised); }
.ext-panel { display: flex; flex-direction: column; gap: var(--sp-2); padding: var(--sp-3) var(--sp-6); flex-shrink: 0; border-bottom: 1px solid var(--border-dim); background: var(--bg-raised); opacity: 1; } .ext-panel { display: flex; flex-direction: column; gap: var(--sp-2); padding: var(--sp-3) var(--sp-6); flex-shrink: 0; border-bottom: 1px solid var(--border-dim); background: var(--bg-raised); opacity: 1; }
.ext-panel-anim { animation: panelSlide 0.18s cubic-bezier(0.16,1,0.3,1) both; } .ext-panel-anim { animation: panelSlide 0.18s cubic-bezier(0.16,1,0.3,1) both; }
.panel-header { display: flex; align-items: center; padding-bottom: var(--sp-1); } .panel-header { display: flex; align-items: center; padding-bottom: var(--sp-1); }
@@ -2,19 +2,26 @@
import { Play, ArrowRight, BookOpen, Clock } from "phosphor-svelte"; import { Play, ArrowRight, BookOpen, Clock } from "phosphor-svelte";
import Thumbnail from "@shared/manga/Thumbnail.svelte"; import Thumbnail from "@shared/manga/Thumbnail.svelte";
import type { HistoryEntry } from "@store/state.svelte"; import type { HistoryEntry } from "@store/state.svelte";
import type { Manga } from "@types";
import { timeAgo } from "../lib/homeHelpers"; import { timeAgo } from "../lib/homeHelpers";
let { let {
entries, entries,
libraryManga,
onresume, onresume,
onviewhistory, onviewhistory,
onopenlibrary, onopenlibrary,
}: { }: {
entries: HistoryEntry[]; entries: HistoryEntry[];
libraryManga: Manga[];
onresume: (entry: HistoryEntry) => void; onresume: (entry: HistoryEntry) => void;
onviewhistory: () => void; onviewhistory: () => void;
onopenlibrary: () => void; onopenlibrary: () => void;
} = $props(); } = $props();
function thumbFor(entry: HistoryEntry): string {
return libraryManga.find(m => m.id === entry.mangaId)?.thumbnailUrl ?? entry.thumbnailUrl ?? "";
}
</script> </script>
<div class="section"> <div class="section">
@@ -31,7 +38,7 @@
{#if entries.length > 0} {#if entries.length > 0}
{#each entries as entry (entry.chapterId)} {#each entries as entry (entry.chapterId)}
<button class="row" onclick={() => onresume(entry)}> <button class="row" onclick={() => onresume(entry)}>
<Thumbnail src={entry.thumbnailUrl} alt={entry.mangaTitle} class="row-thumb" /> <Thumbnail src={thumbFor(entry)} alt={entry.mangaTitle} class="row-thumb" />
<div class="row-info"> <div class="row-info">
<span class="row-title">{entry.mangaTitle}</span> <span class="row-title">{entry.mangaTitle}</span>
<span class="row-sub"> <span class="row-sub">
@@ -191,4 +198,4 @@
.placeholder-cta:hover { background: rgba(255,255,255,0.14); color: rgba(255,255,255,0.9); } .placeholder-cta:hover { background: rgba(255,255,255,0.14); color: rgba(255,255,255,0.9); }
@keyframes pulse { 0%, 100% { opacity: 0.4 } 50% { opacity: 0.7 } } @keyframes pulse { 0%, 100% { opacity: 0.4 } 50% { opacity: 0.7 } }
</style> </style>
@@ -29,6 +29,10 @@
return new Date(d + "T00:00:00").toLocaleDateString("en-US", { month: "short", day: "numeric" }); 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 wrapEl: HTMLElement;
let cellSize = $state(12); let cellSize = $state(12);
let numWeeks = $state(26); let numWeeks = $state(26);
@@ -55,7 +59,7 @@
const visibleWeeks = $derived((() => { const visibleWeeks = $derived((() => {
const today = new Date(); const today = new Date();
today.setHours(0, 0, 0, 0); today.setHours(0, 0, 0, 0);
const todayStr = today.toISOString().slice(0, 10); const todayStr = localDateStr(today);
const endDow = today.getDay(); // 0=Sun ... 6=Sat const endDow = today.getDay(); // 0=Sun ... 6=Sat
const weekEnd = new Date(today); const weekEnd = new Date(today);
weekEnd.setDate(weekEnd.getDate() + (6 - endDow)); // advance to Saturday weekEnd.setDate(weekEnd.getDate() + (6 - endDow)); // advance to Saturday
@@ -66,7 +70,7 @@
for (let di = 0; di < 7; di++) { for (let di = 0; di < 7; di++) {
const d = new Date(weekEnd); const d = new Date(weekEnd);
d.setDate(d.getDate() - wi * 7 - (6 - di)); d.setDate(d.getDate() - wi * 7 - (6 - di));
const dateStr = d.toISOString().slice(0, 10); const dateStr = localDateStr(d);
week.push({ dateStr, count: dailyReadCounts[dateStr] ?? 0, isToday: dateStr === todayStr, isFuture: d > today }); week.push({ dateStr, count: dailyReadCounts[dateStr] ?? 0, isToday: dateStr === todayStr, isFuture: d > today });
} }
weeks.push(week); weeks.push(week);
@@ -21,6 +21,7 @@
heroEntry, heroEntry,
heroMangaId, heroMangaId,
heroChapters, heroChapters,
heroNewChapter,
loadingHeroChapters, loadingHeroChapters,
resuming, resuming,
onresume, onresume,
@@ -40,6 +41,7 @@
heroEntry: HistoryEntry | null; heroEntry: HistoryEntry | null;
heroMangaId: number | null; heroMangaId: number | null;
heroChapters: Chapter[]; heroChapters: Chapter[];
heroNewChapter: Chapter | null;
loadingHeroChapters: boolean; loadingHeroChapters: boolean;
resuming: boolean; resuming: boolean;
onresume: () => void; onresume: () => void;
@@ -102,6 +104,9 @@
{:else} {:else}
<span class="hero-tag hero-tag-pinned"><PushPin size={8} weight="fill" /> Pinned</span> <span class="hero-tag hero-tag-pinned"><PushPin size={8} weight="fill" /> Pinned</span>
{/if} {/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} {#each (heroManga?.genre ?? []).slice(0, 3) as g}
<button <button
class="hero-tag hero-tag-genre" class="hero-tag hero-tag-genre"
@@ -326,6 +331,7 @@
} }
.hero-tag-reading { background: var(--accent-muted); color: var(--accent-fg); border-color: var(--accent-dim); } .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-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 { 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-tag-genre:hover { background: rgba(255, 255, 255, 0.18); color: rgba(255, 255, 255, 0.9); }
+39 -34
View File
@@ -1,7 +1,6 @@
<script lang="ts"> <script lang="ts">
import { onMount, untrack } from "svelte"; import { onMount, untrack } from "svelte";
import { gql, thumbUrl } from "@api/client"; import { gql, resolveImageUrl } from "@api/client";
import { getBlobUrl } from "@core/cache/imageCache";
import { GET_CHAPTERS } from "@api/queries/chapters"; import { GET_CHAPTERS } from "@api/queries/chapters";
import { GET_LIBRARY } from "@api/queries/manga"; import { GET_LIBRARY } from "@api/queries/manga";
import { cache, CACHE_KEYS } from "@core/cache"; import { cache, CACHE_KEYS } from "@core/cache";
@@ -87,32 +86,34 @@
let activeIdx = $state(0); let activeIdx = $state(0);
const activeSlot = $derived(resolvedSlots[activeIdx]); const activeSlot = $derived(resolvedSlots[activeIdx]);
const heroThumbSrc = $derived(
activeSlot?.kind === "pinned" ? (activeSlot.manga?.thumbnailUrl ?? "") :
activeSlot?.kind === "continue" ? (activeSlot.entry?.thumbnailUrl ?? "") : ""
);
let heroThumb = $state("");
$effect(() => {
const path = heroThumbSrc;
const mode = store.settings.serverAuthMode ?? "NONE";
if (!path) { heroThumb = ""; return; }
if (mode !== "BASIC_AUTH") { heroThumb = thumbUrl(path); return; }
getBlobUrl(thumbUrl(path))
.then(url => { heroThumb = url; })
.catch(() => { heroThumb = ""; });
});
const heroTitle = $derived( const heroManga = $derived(
activeSlot?.kind === "pinned" ? (activeSlot.manga?.title ?? "") :
activeSlot?.kind === "continue" ? (activeSlot.entry?.mangaTitle ?? "") : ""
);
const heroManga = $derived(
activeSlot?.kind === "pinned" ? activeSlot.manga : activeSlot?.kind === "pinned" ? activeSlot.manga :
activeSlot?.kind === "continue" ? libraryManga.find(m => m.id === activeSlot.entry?.mangaId) : null activeSlot?.kind === "continue" ? libraryManga.find(m => m.id === activeSlot.entry?.mangaId) : null
); );
const heroEntry = $derived(activeSlot?.kind === "continue" ? activeSlot.entry : null); const heroEntry = $derived(activeSlot?.kind === "continue" ? activeSlot.entry : null);
const heroMangaId = $derived(heroEntry?.mangaId ?? heroManga?.id ?? null); const heroMangaId = $derived(heroManga?.id ?? heroEntry?.mangaId ?? null);
const heroTitle = $derived(heroManga?.title ?? heroEntry?.mangaTitle ?? "");
const heroThumbSrc = $derived(
heroManga?.thumbnailUrl
?? (activeSlot?.kind === "continue" ? activeSlot.entry?.thumbnailUrl : undefined)
?? ""
);
let heroThumb = $state("");
$effect(() => {
const path = heroThumbSrc;
if (!path) { heroThumb = ""; return; }
resolveImageUrl(path)
.then(url => { heroThumb = url; })
.catch(() => { heroThumb = ""; });
});
const heroNewChapter = $derived(
heroManga ? (libraryManga.find(m => m.id === heroManga!.id) as any)?.latestUploadedChapter ?? null : null
);
function cycleNext() { activeIdx = (activeIdx + 1) % TOTAL_SLOTS; heroChapters = []; heroAllChapters = []; } function cycleNext() { activeIdx = (activeIdx + 1) % TOTAL_SLOTS; heroChapters = []; heroAllChapters = []; }
function cyclePrev() { activeIdx = (activeIdx - 1 + TOTAL_SLOTS) % TOTAL_SLOTS; heroChapters = []; heroAllChapters = []; } function cyclePrev() { activeIdx = (activeIdx - 1 + TOTAL_SLOTS) % TOTAL_SLOTS; heroChapters = []; heroAllChapters = []; }
@@ -155,6 +156,10 @@
let resuming = $state(false); let resuming = $state(false);
function liveMangaStub(): Manga {
return heroManga ?? { id: heroMangaId!, title: heroTitle, thumbnailUrl: heroThumbSrc } as any;
}
async function openChapter(chapter: Chapter) { async function openChapter(chapter: Chapter) {
if (!heroMangaId) return; if (!heroMangaId) return;
resuming = true; resuming = true;
@@ -165,13 +170,12 @@
all = [...d.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder); all = [...d.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder);
} }
if (all.length) { if (all.length) {
const manga = heroManga ?? { id: heroMangaId, title: heroTitle, thumbnailUrl: heroManga?.thumbnailUrl ?? "" } as any; store.activeManga = liveMangaStub();
store.activeManga = manga;
const list = buildReaderChapterList(all, store.settings.mangaPrefs?.[heroMangaId]); const list = buildReaderChapterList(all, store.settings.mangaPrefs?.[heroMangaId]);
const target = list.find(c => c.id === chapter.id) ?? list[0]; const target = list.find(c => c.id === chapter.id) ?? list[0];
if (target) openReader(target, list); if (target) openReader(target, list);
} }
} catch { store.activeManga = { id: heroMangaId, title: heroTitle, thumbnailUrl: heroManga?.thumbnailUrl ?? "" } as any; } } catch { store.activeManga = liveMangaStub(); }
finally { resuming = false; } finally { resuming = false; }
} }
@@ -187,24 +191,24 @@
const list = buildReaderChapterList(raw, store.settings.mangaPrefs?.[heroEntry.mangaId]); const list = buildReaderChapterList(raw, store.settings.mangaPrefs?.[heroEntry.mangaId]);
const ch = list.find(c => c.id === heroEntry!.chapterId) ?? list[0]; const ch = list.find(c => c.id === heroEntry!.chapterId) ?? list[0];
if (ch) { if (ch) {
store.activeManga = heroManga ?? { id: heroEntry.mangaId, title: heroEntry.mangaTitle, thumbnailUrl: heroEntry.thumbnailUrl } as any; store.activeManga = liveMangaStub();
openReader(ch, list); openReader(ch, list);
} }
} catch { store.activeManga = { id: heroEntry.mangaId, title: heroEntry.mangaTitle, thumbnailUrl: heroEntry.thumbnailUrl } as any; } } catch { store.activeManga = liveMangaStub(); }
finally { resuming = false; } finally { resuming = false; }
} }
async function resumeEntry(entry: HistoryEntry) { async function resumeEntry(entry: HistoryEntry) {
const liveManga = libraryManga.find(m => m.id === entry.mangaId);
const stub = liveManga ?? { id: entry.mangaId, title: entry.mangaTitle, thumbnailUrl: liveManga?.thumbnailUrl ?? entry.thumbnailUrl } as any;
try { try {
const d = await gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId: entry.mangaId }); const d = await gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId: entry.mangaId });
const raw = [...d.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder); const raw = [...d.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder);
const list = buildReaderChapterList(raw, store.settings.mangaPrefs?.[entry.mangaId]); const list = buildReaderChapterList(raw, store.settings.mangaPrefs?.[entry.mangaId]);
const ch = list.find(c => c.id === entry.chapterId) ?? list[0]; const ch = list.find(c => c.id === entry.chapterId) ?? list[0];
if (ch) { store.activeManga = stub;
store.activeManga = { id: entry.mangaId, title: entry.mangaTitle, thumbnailUrl: entry.thumbnailUrl } as any; if (ch) openReader(ch, list);
openReader(ch, list); } catch { store.activeManga = stub; }
} else store.activeManga = { id: entry.mangaId, title: entry.mangaTitle, thumbnailUrl: entry.thumbnailUrl } as any;
} catch { store.activeManga = { id: entry.mangaId, title: entry.mangaTitle, thumbnailUrl: entry.thumbnailUrl } as any; }
} }
let pickerOpen = $state(false); let pickerOpen = $state(false);
@@ -234,6 +238,7 @@
{heroEntry} {heroEntry}
{heroMangaId} {heroMangaId}
{heroChapters} {heroChapters}
{heroNewChapter}
{loadingHeroChapters} {loadingHeroChapters}
{resuming} {resuming}
onresume={resumeActive} onresume={resumeActive}
@@ -252,6 +257,7 @@
<div class="mid-left"> <div class="mid-left">
<ActivityFeed <ActivityFeed
entries={recentHistory} entries={recentHistory}
{libraryManga}
onresume={resumeEntry} onresume={resumeEntry}
onviewhistory={() => setNavPage("history")} onviewhistory={() => setNavPage("history")}
onopenlibrary={() => setNavPage("library")} onopenlibrary={() => setNavPage("library")}
@@ -328,7 +334,6 @@
min-width: 0; min-width: 0;
overflow: hidden; overflow: hidden;
} }
/* suppress ActivityFeed's own border-top — mid-row provides it */
.mid-left :global(.section) { border-top: none; } .mid-left :global(.section) { border-top: none; }
.mid-divider { background: var(--border-dim); align-self: stretch; } .mid-divider { background: var(--border-dim); align-self: stretch; }
.mid-right { .mid-right {
+41 -24
View File
@@ -9,9 +9,11 @@ export interface RecommendedManga {
matchedGenres: string[]; matchedGenres: string[];
} }
const TOP_GENRES = 6; const TOP_GENRES = 6;
const PAGE_SIZE = 100; const PAGE_SIZE = 100;
const MAX_PAGES = 5; const MAX_PAGES = 10;
const TARGET_PER_GENRE = 20;
const EXCLUDED_STATUSES = ["CANCELLED", "ABANDONED"];
export function topGenres(history: HistoryEntry[], libraryManga: Manga[]): string[] { export function topGenres(history: HistoryEntry[], libraryManga: Manga[]): string[] {
const byId = new Map(libraryManga.map(m => [m.id, m])); const byId = new Map(libraryManga.map(m => [m.id, m]));
@@ -36,7 +38,11 @@ export function topGenres(history: HistoryEntry[], libraryManga: Manga[]): strin
type Result = { mangas: { nodes: Manga[] } }; type Result = { mangas: { nodes: Manga[] } };
async function fetchGenrePages(genre: string, signal?: AbortSignal): Promise<Manga[]> { async function fetchGenrePages(
genre: string,
globalSeen: Set<number>,
signal?: AbortSignal,
): Promise<Manga[]> {
const filter = { const filter = {
and: [ and: [
buildTagFilter([genre], "OR", []), buildTagFilter([genre], "OR", []),
@@ -44,23 +50,33 @@ async function fetchGenrePages(genre: string, signal?: AbortSignal): Promise<Man
], ],
}; };
const pages = await Promise.all( const localSeen = new Set<number>();
Array.from({ length: MAX_PAGES }, (_, i) =>
gql<Result>(MANGAS_BY_GENRE, { filter, first: PAGE_SIZE, offset: i * PAGE_SIZE }, signal)
.then(d => d.mangas.nodes)
.catch(() => [] as Manga[])
)
);
const seen = new Set<number>();
const nodes: Manga[] = []; const nodes: Manga[] = [];
for (const page of pages) {
if (!page.length) break; for (let page = 0; page < MAX_PAGES; page++) {
for (const m of page) { if (signal?.aborted) break;
if (!seen.has(m.id)) { seen.add(m.id); nodes.push(m); }
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 (page.length < PAGE_SIZE) 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; return nodes;
} }
@@ -74,13 +90,14 @@ export async function fetchRecommendations(
const genres = topGenres(history, libraryManga); const genres = topGenres(history, libraryManga);
if (!genres.length) return []; if (!genres.length) return [];
const perGenre = await Promise.all(genres.map(g => fetchGenrePages(g, signal))); const globalSeen = new Set<number>();
const seen = new Set<number>();
const merged: Manga[] = []; const merged: Manga[] = [];
for (const page of perGenre) {
for (const m of page) { for (const genre of genres) {
if (!seen.has(m.id)) { seen.add(m.id); merged.push(m); } const results = await fetchGenrePages(genre, globalSeen, signal);
for (const m of results) {
globalSeen.add(m.id);
merged.push(m);
} }
} }
+116 -45
View File
@@ -6,20 +6,22 @@
GET_CATEGORIES, GET_LIBRARY, UPDATE_MANGA, UPDATE_MANGAS, GET_CATEGORIES, GET_LIBRARY, UPDATE_MANGA, UPDATE_MANGAS,
GET_CHAPTERS, DELETE_DOWNLOADED_CHAPTERS, DEQUEUE_DOWNLOAD, GET_CHAPTERS, DELETE_DOWNLOADED_CHAPTERS, DEQUEUE_DOWNLOAD,
CREATE_CATEGORY, UPDATE_MANGA_CATEGORIES, CREATE_CATEGORY, UPDATE_MANGA_CATEGORIES,
UPDATE_CATEGORY_ORDER, UPDATE_CATEGORY_ORDER, UPDATE_STOP, UPDATE_LIBRARY_MANGA, UPDATE_CATEGORY_MANGA,
} from "@api"; } from "@api";
import { cache, CACHE_KEYS, CACHE_GROUPS, DEFAULT_TTL_MS } from "@core/cache/queryCache"; import { cache, CACHE_KEYS, CACHE_GROUPS, DEFAULT_TTL_MS } from "@core/cache/queryCache";
import { dedupeMangaById, dedupeMangaByTitle, shouldHideNsfw } from "@core/util"; import { dedupeMangaById, dedupeMangaByTitle, shouldHideNsfw } from "@core/util";
import { sortLibrary } from "../lib/librarySort"; import { sortLibrary } from "../lib/librarySort";
import { startLibraryUpdate } from "../lib/libraryUpdater"; import { startLibraryUpdate } from "../lib/libraryUpdater";
import { createPaginator } from "@core/algorithms/paginate"; import { createPaginator } from "@core/algorithms/paginate";
import { longPress } from "@core/ui/touchscreen";
import { import {
store, setCategories, setLibraryUpdates, addToast, store, setCategories, setLibraryUpdates, addToast,
setTabSort, toggleTabSortDir, setTabStatus, toggleTabFilter, clearTabFilters, setTabSort, toggleTabSortDir, setTabStatus, toggleTabFilter, clearTabFilters,
} from "../store/libraryState.svelte"; } from "../store/libraryState.svelte";
import { saveScroll, getScroll } from "@store/state.svelte";
import type { LibrarySortMode, LibrarySortDir, LibraryStatusFilter, LibraryContentFilter, LibraryUpdateEntry } from "@store/state.svelte"; import type { LibrarySortMode, LibrarySortDir, LibraryStatusFilter, LibraryContentFilter, LibraryUpdateEntry } from "@store/state.svelte";
import type { Manga, Category, Chapter } from "@types"; import type { Manga, Category, Chapter } from "@types";
import { checkAndMarkCompleted as storeCheckAndMarkCompleted } from "@store/state.svelte"; import { checkAndMarkCompleted as storeCheckAndMarkCompleted, updateSettings } from "@store/state.svelte";
import LibraryToolbar from "./LibraryToolbar.svelte"; import LibraryToolbar from "./LibraryToolbar.svelte";
import LibraryGrid from "./LibraryGrid.svelte"; import LibraryGrid from "./LibraryGrid.svelte";
@@ -27,11 +29,12 @@
import BulkAutomationPanel from "../panels/BulkAutomationPanel.svelte"; import BulkAutomationPanel from "../panels/BulkAutomationPanel.svelte";
import ContextMenu, { type MenuEntry } from "@shared/ui/ContextMenu.svelte"; import ContextMenu, { type MenuEntry } from "@shared/ui/ContextMenu.svelte";
import { Books, DownloadSimple, Folder, FolderSimple, FolderSimplePlus, Trash, Star, CheckSquare, ArrowSquareOut } from "phosphor-svelte"; import { Books, DownloadSimple, Folder, FolderSimple, FolderSimplePlus, Trash, Star, CheckSquare, ArrowSquareOut, ArrowsClockwise } from "phosphor-svelte";
const CARD_MIN_W = 130; const CARD_MIN_W = 130;
const CARD_GAP = 16; const CARD_GAP = 16;
const COMPLETED_NAME = "Completed"; const COMPLETED_NAME = "Completed";
const CTX_FOLDER_CAP = 4;
const paginator = createPaginator<Manga>(store.settings.renderLimit ?? 48); const paginator = createPaginator<Manga>(store.settings.renderLimit ?? 48);
@@ -42,7 +45,7 @@
let search: string = $state(""); let search: string = $state("");
let renderVisible: number = $state(store.settings.renderLimit ?? 48); let renderVisible: number = $state(store.settings.renderLimit ?? 48);
let scrollEl: HTMLDivElement; let scrollEl: HTMLDivElement;
let tabsEl: HTMLDivElement; let tabsEl = $state<HTMLDivElement>(null!);
let containerWidth: number = $state(800); let containerWidth: number = $state(800);
let ctx: { x: number; y: number; manga: Manga } | null = $state(null); let ctx: { x: number; y: number; manga: Manga } | null = $state(null);
let emptyCtx: { x: number; y: number } | null = $state(null); let emptyCtx: { x: number; y: number } | null = $state(null);
@@ -63,16 +66,18 @@
let refreshDone: boolean = $state(false); let refreshDone: boolean = $state(false);
let refreshDoneTimer: ReturnType<typeof setTimeout> | null = null; 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 activeDragKind: "tab" | null = $state(null);
let dragInsertIdx: number = $state(-1); let dragInsertIdx: number = $state(-1);
let dragTabId: number | null = $state(null); let dragTabId: number | null = $state(null);
let dragOverTabId: number | null = $state(null); let dragOverTabId: number | null = $state(null);
const DT_TAB = "application/x-moku-tab"; const DT_TAB = "application/x-moku-tab";
const anims = $derived(store.settings.qolAnimations ?? true); const anims = $derived(store.settings.qolAnimations ?? true);
const tab = $derived(store.libraryFilter); const tab = $derived(store.libraryFilter);
const tabSortMode = $derived(store.settings.libraryTabSort[tab]?.mode ?? "az" as LibrarySortMode); const tabSortMode = $derived(store.settings.libraryTabSort[tab]?.mode ?? "az" as LibrarySortMode);
const tabSortDir = $derived(store.settings.libraryTabSort[tab]?.dir ?? "asc" as LibrarySortDir); const tabSortDir = $derived(store.settings.libraryTabSort[tab]?.dir ?? "asc" as LibrarySortDir);
const tabStatus = $derived(store.settings.libraryTabStatus[tab] ?? "ALL" as LibraryStatusFilter); const tabStatus = $derived(store.settings.libraryTabStatus[tab] ?? "ALL" as LibraryStatusFilter);
@@ -134,9 +139,9 @@
const f = store.settings.libraryTabFilters?.[tab] ?? {}; const f = store.settings.libraryTabFilters?.[tab] ?? {};
if (f.unread) items = items.filter(m => (m.unreadCount ?? 0) > 0); if (f.unread) items = items.filter(m => (m.unreadCount ?? 0) > 0);
if (f.started) items = items.filter(m => (m.unreadCount ?? 0) > 0 && (m.chapterCount ?? 0) > (m.unreadCount ?? 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.downloaded) items = items.filter(m => (m.downloadCount ?? 0) > 0);
if (f.bookmarked) items = items.filter(m => !!(m as any).hasBookmark); if (f.bookmarked) items = items.filter(m => (m.bookmarkCount ?? 0) > 0);
const recentlyReadMap = new Map<number, number>(); const recentlyReadMap = new Map<number, number>();
if (tabSortMode === "recentlyRead") { if (tabSortMode === "recentlyRead") {
@@ -167,7 +172,18 @@
$effect(() => { filtered; untrack(() => { renderVisible = paginator.reset(); }); }); $effect(() => { filtered; untrack(() => { renderVisible = paginator.reset(); }); });
$effect(() => { retryCount; loading = true; error = null; if (retryCount > 0) cache.clear(CACHE_KEYS.LIBRARY); untrack(() => loadData()); }); $effect(() => { retryCount; loading = true; error = null; if (retryCount > 0) cache.clear(CACHE_KEYS.LIBRARY); untrack(() => loadData()); });
$effect(() => { if (scrollEl) scrollEl.scrollTo({ top: 0 }); }); let prevTab = tab;
$effect(() => {
const nextTab = tab;
if (scrollEl && nextTab !== prevTab) {
saveScroll(`library:${prevTab}`, scrollEl.scrollTop);
const saved = getScroll(`library:${nextTab}`);
untrack(() => { scrollEl.scrollTo({ top: saved }); });
prevTab = nextTab;
} else if (scrollEl && nextTab === prevTab) {
scrollEl.scrollTo({ top: 0 });
}
});
$effect(() => { $effect(() => {
const f = tab; const f = tab;
if (f === "library" || f === "downloaded") return; if (f === "library" || f === "downloaded") return;
@@ -175,7 +191,7 @@
if (!store.categories.some(c => c.id === id)) untrack(() => { store.libraryFilter = "library"; }); if (!store.categories.some(c => c.id === id)) untrack(() => { store.libraryFilter = "library"; });
}); });
$effect(() => { tab; untrack(() => exitSelectMode()); }); $effect(() => { tab; untrack(() => exitSelectMode()); });
$effect(() => { tab; counts; requestAnimationFrame(updateTabIndicator); }); $effect(() => { tab; counts; });
let prevChapterId: number | null = null; let prevChapterId: number | null = null;
$effect(() => { $effect(() => {
@@ -184,28 +200,34 @@
if (wasOpen && !store.activeChapter) { cache.clear(CACHE_KEYS.LIBRARY); untrack(() => loadData()); } if (wasOpen && !store.activeChapter) { cache.clear(CACHE_KEYS.LIBRARY); untrack(() => loadData()); }
}); });
function updateTabIndicator() {
if (!tabsEl) return;
const active = tabsEl.querySelector<HTMLElement>(".tab.active");
if (!active) return;
tabIndicator = { left: active.offsetLeft, width: active.offsetWidth };
}
function enterSelectMode(id?: number) { selectMode = true; if (id !== undefined) selectedIds = new Set([id]); } function enterSelectMode(id?: number) { selectMode = true; if (id !== undefined) selectedIds = new Set([id]); }
function exitSelectMode() { selectMode = false; selectedIds = new Set(); } function exitSelectMode() { selectMode = false; selectedIds = new Set(); }
function toggleSelect(id: number) { const next = new Set(selectedIds); if (next.has(id)) next.delete(id); else next.add(id); selectedIds = next; if (next.size === 0) exitSelectMode(); } function toggleSelect(id: number) { const next = new Set(selectedIds); if (next.has(id)) next.delete(id); else next.add(id); selectedIds = next; if (next.size === 0) exitSelectMode(); }
function selectAll() { selectedIds = new Set(visibleManga.map(m => m.id)); } function selectAll() { selectedIds = new Set(visibleManga.map(m => m.id)); }
function loadMore() { renderVisible = paginator.nextVisible(renderVisible); } function loadMore() { renderVisible = paginator.nextVisible(renderVisible); }
let longPressTimer: ReturnType<typeof setTimeout> | null = null; let cardLongPressFired = false;
function onCardPointerDown(e: PointerEvent, m: Manga) {
if (e.button !== 0) return; function rootLongPressAction(node: HTMLElement) {
longPressTimer = setTimeout(() => { longPressTimer = null; enterSelectMode(m.id); }, 500); 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 onCardPointerUp() { if (longPressTimer) { clearTimeout(longPressTimer); longPressTimer = null; } }
function onCardPointerLeave() { if (longPressTimer) { clearTimeout(longPressTimer); longPressTimer = null; } }
function onCardClick(e: MouseEvent, m: Manga) { function onCardClick(e: MouseEvent, m: Manga) {
if (cardLongPressFired) { cardLongPressFired = false; return; }
if (selectMode) { toggleSelect(m.id); return; } if (selectMode) { toggleSelect(m.id); return; }
if (e.metaKey || e.ctrlKey || e.shiftKey) { e.preventDefault(); enterSelectMode(m.id); return; } if (e.metaKey || e.ctrlKey || e.shiftKey) { e.preventDefault(); enterSelectMode(m.id); return; }
store.activeManga = m; store.activeManga = m;
@@ -233,7 +255,7 @@
cache.get(CACHE_KEYS.LIBRARY, () => gql<{ mangas: { nodes: Manga[] } }>(GET_LIBRARY).then(d => d.mangas.nodes), DEFAULT_TTL_MS, CACHE_GROUPS.LIBRARY), cache.get(CACHE_KEYS.LIBRARY, () => gql<{ mangas: { nodes: Manga[] } }>(GET_LIBRARY).then(d => d.mangas.nodes), DEFAULT_TTL_MS, CACHE_GROUPS.LIBRARY),
reloadCategories(), reloadCategories(),
]); ]);
const mapped = nodes.map((m: any) => ({ ...m, chapterCount: m.chapters?.totalCount ?? m.chapterCount ?? 0 })); const mapped = nodes.map((m: any) => ({ ...m }));
allManga = dedupeMangaByTitle(dedupeMangaById(mapped), store.settings.mangaLinks); allManga = dedupeMangaByTitle(dedupeMangaById(mapped), store.settings.mangaLinks);
error = null; error = null;
await migrateCategorizedToLibrary(); await migrateCategorizedToLibrary();
@@ -271,6 +293,36 @@
} catch (e) { console.error(e); } } 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) { async function toggleMangaCategory(manga: Manga, cat: Category) {
const inCat = (categoryMangaMap.get(cat.id) ?? []).some(m => m.id === manga.id); const inCat = (categoryMangaMap.get(cat.id) ?? []).some(m => m.id === manga.id);
setCategories(store.categories.map(c => { setCategories(store.categories.map(c => {
@@ -278,6 +330,7 @@
const nodes = inCat ? c.mangas.nodes.filter(m => m.id !== manga.id) : [...c.mangas.nodes, manga]; const nodes = inCat ? c.mangas.nodes.filter(m => m.id !== manga.id) : [...c.mangas.nodes, manga];
return { ...c, mangas: { nodes } }; return { ...c, mangas: { nodes } };
})); }));
if (!inCat) bumpCategoryFrecency(cat.id);
try { try {
await gql(UPDATE_MANGA_CATEGORIES, { mangaId: manga.id, addTo: inCat ? [] : [cat.id], removeFrom: inCat ? [cat.id] : [] }); await gql(UPDATE_MANGA_CATEGORIES, { mangaId: manga.id, addTo: inCat ? [] : [cat.id], removeFrom: inCat ? [cat.id] : [] });
if (!inCat && !manga.inLibrary) { if (!inCat && !manga.inLibrary) {
@@ -296,6 +349,7 @@
const res = await gql<{ createCategory: { category: Category } }>(CREATE_CATEGORY, { name: name.trim() }); const res = await gql<{ createCategory: { category: Category } }>(CREATE_CATEGORY, { name: name.trim() });
const cat = res.createCategory.category; const cat = res.createCategory.category;
await gql(UPDATE_MANGA_CATEGORIES, { mangaId: manga.id, addTo: [cat.id], removeFrom: [] }); await gql(UPDATE_MANGA_CATEGORIES, { mangaId: manga.id, addTo: [cat.id], removeFrom: [] });
bumpCategoryFrecency(cat.id);
if (!manga.inLibrary) { if (!manga.inLibrary) {
await gql(UPDATE_MANGA, { id: manga.id, inLibrary: true }); await gql(UPDATE_MANGA, { id: manga.id, inLibrary: true });
allManga = allManga.map(m => m.id === manga.id ? { ...m, inLibrary: true } : m); allManga = allManga.map(m => m.id === manga.id ? { ...m, inLibrary: true } : m);
@@ -342,24 +396,38 @@
catch (e: any) { addToast({ kind: "error", title: "Could not open folder", body: e?.toString?.() ?? 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) { function openCtx(e: MouseEvent, m: Manga) {
if (selectMode) { toggleSelect(m.id); return; } if (selectMode) { toggleSelect(m.id); return; }
e.preventDefault(); e.preventDefault();
ctx = { x: e.clientX, y: e.clientY, manga: m }; ctx = { x: e.clientX - SIDEBAR_W, y: e.clientY - TITLEBAR_H, manga: m };
} }
function buildCtxItems(m: Manga): MenuEntry[] { function buildCtxItems(m: Manga): MenuEntry[] {
const catEntries: MenuEntry[] = visibleCategories.map(cat => { 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); const inCat = (categoryMangaMap.get(cat.id) ?? []).some(x => x.id === m.id);
return { label: inCat ? `Remove from ${cat.name}` : `Add to ${cat.name}`, icon: Folder, onClick: () => toggleMangaCategory(m, cat) }; 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 [ return [
{ label: m.inLibrary ? "Remove from library" : "Add to library", icon: Books, onClick: () => m.inLibrary ? removeFromLibrary(m) : gql(UPDATE_MANGA, { id: m.id, inLibrary: true }).then(() => { allManga = allManga.map(x => x.id === m.id ? { ...x, inLibrary: true } : x); cache.clear(CACHE_KEYS.LIBRARY); }).catch(console.error) }, { label: m.inLibrary ? "Remove from library" : "Add to library", icon: Books, onClick: () => m.inLibrary ? removeFromLibrary(m) : gql(UPDATE_MANGA, { id: m.id, inLibrary: true }).then(() => { allManga = allManga.map(x => x.id === m.id ? { ...x, inLibrary: true } : x); cache.clear(CACHE_KEYS.LIBRARY); }).catch(console.error) },
{ label: 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: "Open in file manager", icon: ArrowSquareOut, disabled: !(m.downloadCount && m.downloadCount > 0), onClick: () => openMangaFolder(m) },
{ label: "Delete all downloads", icon: Trash, danger: true, disabled: !(m.downloadCount && m.downloadCount > 0), onClick: () => deleteAllDownloads(m) }, { label: "Delete all downloads", icon: Trash, danger: true, disabled: !(m.downloadCount && m.downloadCount > 0), onClick: () => deleteAllDownloads(m) },
{ separator: true }, { separator: true },
{ label: "Select this manga", icon: CheckSquare, onClick: () => enterSelectMode(m.id) }, { label: "Select", icon: CheckSquare, onClick: () => enterSelectMode(m.id) },
...(catEntries.length ? [{ separator: true } as MenuEntry, ...catEntries] : []), ...(pinnedEntries.length ? [{ separator: true } as MenuEntry, ...pinnedEntries] : []),
...(overflowChildren.length ? [{ label: `More folders (${overflowChildren.length})`, icon: FolderSimple, onClick: () => {}, children: overflowChildren } as MenuEntry] : []),
{ separator: true }, { separator: true },
{ label: "New folder", icon: FolderSimplePlus, onClick: () => createAndAssign(m) }, { label: "New folder", icon: FolderSimplePlus, onClick: () => createAndAssign(m) },
]; ];
@@ -387,18 +455,24 @@
} }
} }
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() { async function startLibraryRefresh() {
if (refreshing) return; if (refreshing) return;
refreshing = true; refreshing = true;
refreshProgress = { finished: 0, total: 0 }; refreshProgress = { finished: 0, total: 0 };
cancelUpdate = startLibraryUpdate({ cancelUpdate = startLibraryUpdate({
onProgress(p) { onProgress(p) { refreshProgress = p; },
refreshProgress = p;
},
async onDone({ entries, totalUpdated, newChapters }) { async onDone({ entries, totalUpdated, newChapters }) {
refreshing = false; refreshing = false; cancelUpdate = null;
cancelUpdate = null;
setLibraryUpdates(entries); setLibraryUpdates(entries);
cache.clearGroup(CACHE_GROUPS.LIBRARY); cache.clearGroup(CACHE_GROUPS.LIBRARY);
await loadData(); await loadData();
@@ -407,10 +481,7 @@
refreshDoneTimer = setTimeout(() => { refreshDone = false; }, 2500); refreshDoneTimer = setTimeout(() => { refreshDone = false; }, 2500);
showToast(newChapters, totalUpdated); showToast(newChapters, totalUpdated);
}, },
onError() { onError() { refreshing = false; cancelUpdate = null; },
refreshing = false;
cancelUpdate = null;
},
}); });
} }
@@ -475,7 +546,6 @@
window.addEventListener("keydown", onKeyDown); window.addEventListener("keydown", onKeyDown);
document.addEventListener("mousedown", onDocMouseDown, true); document.addEventListener("mousedown", onDocMouseDown, true);
requestAnimationFrame(updateTabIndicator);
return () => { return () => {
ro.disconnect(); unsub(); ro.disconnect(); unsub();
@@ -490,10 +560,11 @@
class="root" class="root"
role="presentation" role="presentation"
bind:this={scrollEl} bind:this={scrollEl}
use:rootLongPressAction
oncontextmenu={(e) => { oncontextmenu={(e) => {
if ((e.target as HTMLElement).closest("button")) return; if ((e.target as HTMLElement).closest("button")) return;
e.preventDefault(); e.preventDefault();
emptyCtx = { x: e.clientX, y: e.clientY }; emptyCtx = { x: e.clientX - SIDEBAR_W, y: e.clientY - TITLEBAR_H };
}} }}
> >
{#if store.settings.libraryBranches ?? true} {#if store.settings.libraryBranches ?? true}
@@ -533,13 +604,13 @@
{tabFilters} {tabFilters}
{hasActiveFilters} {hasActiveFilters}
{anims} {anims}
{tabIndicator}
{visibleCategories} {visibleCategories}
{counts} {counts}
{search} {search}
{refreshing} {refreshing}
{refreshProgress} {refreshProgress}
{refreshDone} {refreshDone}
{refreshingCatId}
{activeDragKind} {activeDragKind}
{dragInsertIdx} {dragInsertIdx}
{dragTabId} {dragTabId}
@@ -557,6 +628,8 @@
onSortPanelToggle={() => { sortPanelOpen = !sortPanelOpen; filterPanelOpen = false; }} onSortPanelToggle={() => { sortPanelOpen = !sortPanelOpen; filterPanelOpen = false; }}
onFilterPanelToggle={() => { filterPanelOpen = !filterPanelOpen; sortPanelOpen = false; }} onFilterPanelToggle={() => { filterPanelOpen = !filterPanelOpen; sortPanelOpen = false; }}
onRefresh={startLibraryRefresh} onRefresh={startLibraryRefresh}
onCancelRefresh={cancelLibraryRefresh}
onRefreshCategory={refreshCategory}
onOpenDownloadsFolder={openDownloadsFolder} onOpenDownloadsFolder={openDownloadsFolder}
onTabDragStart={onTabDragStart} onTabDragStart={onTabDragStart}
onTabDragOver={onTabDragOver} onTabDragOver={onTabDragOver}
@@ -588,9 +661,7 @@
libraryFilter={tab} libraryFilter={tab}
onCardClick={onCardClick} onCardClick={onCardClick}
onCardContextMenu={openCtx} onCardContextMenu={openCtx}
onCardPointerDown={onCardPointerDown} onCardLongPress={cardLongPress}
onCardPointerUp={onCardPointerUp}
onCardPointerLeave={onCardPointerLeave}
onLoadMore={loadMore} onLoadMore={loadMore}
onRetry={() => retryCount++} onRetry={() => retryCount++}
onExitSelectMode={exitSelectMode} onExitSelectMode={exitSelectMode}
@@ -1,6 +1,8 @@
<script lang="ts"> <script lang="ts">
import { Folder, Trash, CheckSquare, Robot } from "phosphor-svelte"; import { Folder, Trash, CheckSquare, Robot } from "phosphor-svelte";
import Thumbnail from "@shared/manga/Thumbnail.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"; import type { Manga, Category } from "@types";
interface Props { interface Props {
@@ -21,9 +23,7 @@
visibleCategories: Category[]; visibleCategories: Category[];
onCardClick: (e: MouseEvent, m: Manga) => void; onCardClick: (e: MouseEvent, m: Manga) => void;
onCardContextMenu: (e: MouseEvent, m: Manga) => void; onCardContextMenu: (e: MouseEvent, m: Manga) => void;
onCardPointerDown: (e: PointerEvent, m: Manga) => void; onCardLongPress: (node: HTMLElement, m: Manga) => ReturnType<typeof longPress>;
onCardPointerUp: () => void;
onCardPointerLeave: () => void;
onLoadMore: () => void; onLoadMore: () => void;
onRetry: () => void; onRetry: () => void;
onExitSelectMode: () => void; onExitSelectMode: () => void;
@@ -37,7 +37,7 @@
visibleManga, filtered, loading, cols, anims, selectMode, selectedIds, visibleManga, filtered, loading, cols, anims, selectMode, selectedIds,
hasMore, remainingCount, renderLimit, cropCovers, statsAlways, libraryFilter, hasMore, remainingCount, renderLimit, cropCovers, statsAlways, libraryFilter,
bulkWorking, visibleCategories, bulkWorking, visibleCategories,
onCardClick, onCardContextMenu, onCardPointerDown, onCardPointerUp, onCardPointerLeave, onCardClick, onCardContextMenu, onCardLongPress,
onLoadMore, onRetry, onExitSelectMode, onSelectAll, onBulkMove, onBulkRemove, onBulkAutomate, onLoadMore, onRetry, onExitSelectMode, onSelectAll, onBulkMove, onBulkRemove, onBulkAutomate,
}: Props = $props(); }: Props = $props();
@@ -95,7 +95,7 @@
</div> </div>
{/if} {/if}
<div class="content" onclick={(e) => { if (selectMode && !(e.target as HTMLElement).closest(".card")) onExitSelectMode(); }}> <div class="content" role="presentation" onclick={(e) => { if (selectMode && !(e.target as HTMLElement).closest(".card")) onExitSelectMode(); }}>
{#if loading} {#if loading}
<div class="grid"> <div class="grid">
{#each Array(12) as _} {#each Array(12) as _}
@@ -115,20 +115,18 @@
<div class="grid" style="--cols:{cols}"> <div class="grid" style="--cols:{cols}">
{#each visibleManga as m (m.id)} {#each visibleManga as m (m.id)}
{@const isSelected = selectedIds.has(m.id)} {@const isSelected = selectedIds.has(m.id)}
{@const isCompleted = !m.unreadCount && (m.chapterCount ?? 0) > 0} {@const isCompleted = !m.unreadCount && (m.chapters?.totalCount ?? 0) > 0}
<button <button
class="card" class="card"
class:card-selected={isSelected} class:card-selected={isSelected}
class:select-mode={selectMode} class:select-mode={selectMode}
class:anims={anims} class:anims={anims}
use:onCardLongPress={m}
onclick={(e) => onCardClick(e, m)} onclick={(e) => onCardClick(e, m)}
oncontextmenu={(e) => onCardContextMenu(e, m)} oncontextmenu={(e) => onCardContextMenu(e, m)}
onpointerdown={(e) => onCardPointerDown(e, m)}
onpointerup={onCardPointerUp}
onpointerleave={onCardPointerLeave}
> >
<div class="cover-wrap" class:completed={isCompleted}> <div class="cover-wrap" class:completed={isCompleted}>
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="cover" style="object-fit:{cropCovers ? 'cover' : 'contain'}" draggable="false" /> <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="card-info-overlay" class:anim={anims} class:instant={!anims} class:always={statsAlways}>
<div class="overlay-badges"> <div class="overlay-badges">
{#if isCompleted} {#if isCompleted}
@@ -186,7 +184,6 @@
.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); } .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 { 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.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.anims:not(.select-mode):hover .cover { filter: brightness(1.1); }
.card:not(.select-mode):hover .title { color: var(--text-primary); } .card:not(.select-mode):hover .title { color: var(--text-primary); }
.card.select-mode { cursor: default; } .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 .cover-wrap { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: var(--radius-md); }
@@ -194,8 +191,7 @@
.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; } .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); } .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); } .cover-wrap.completed { box-shadow: inset 0 -2px 0 0 var(--accent); }
.card.anims .cover { transition: filter var(--t-base); } .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 { position: absolute; bottom: -4px; left: 0; right: 0; 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.anim { transition: opacity 0.18s ease; }
.card-info-overlay.instant { transition: none; } .card-info-overlay.instant { transition: none; }
.card-info-overlay.always { opacity: 1; } .card-info-overlay.always { opacity: 1; }
@@ -205,7 +201,7 @@
.badge-unread { background: var(--accent); color: #fff; box-shadow: 0 1px 8px rgba(0,0,0,0.5); } .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-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; } .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; background: rgba(0,0,0,0.18); display: flex; align-items: flex-start; justify-content: flex-end; padding: 6px; pointer-events: none; } .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 { 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.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); } .select-check-empty { width: 20px; height: 20px; border-radius: 4px; border: 2px solid var(--text-faint); background: rgba(0,0,0,0.3); }
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { import {
MagnifyingGlass, Books, DownloadSimple, Folder, FolderSimple, MagnifyingGlass, Books, DownloadSimple, Folder, FolderSimple,
SortAscending, CaretUp, CaretDown, ArrowsClockwise, Star, SortAscending, CaretUp, CaretDown, ArrowsClockwise, Star, X,
} from "phosphor-svelte"; } from "phosphor-svelte";
import LibraryFilters from "./LibraryFilters.svelte"; import LibraryFilters from "./LibraryFilters.svelte";
import type { Category } from "@types"; import type { Category } from "@types";
@@ -15,13 +15,9 @@
tabFilters: Partial<Record<LibraryContentFilter, boolean>>; tabFilters: Partial<Record<LibraryContentFilter, boolean>>;
hasActiveFilters: boolean; hasActiveFilters: boolean;
anims: boolean; anims: boolean;
tabIndicator: { left: number; width: number };
visibleCategories: Category[]; visibleCategories: Category[];
counts: Record<string, number>; counts: Record<string, number>;
search: string; search: string;
refreshing: boolean;
refreshProgress: { finished: number; total: number };
refreshDone: boolean;
activeDragKind: "tab" | null; activeDragKind: "tab" | null;
dragInsertIdx: number; dragInsertIdx: number;
dragTabId: number | null; dragTabId: number | null;
@@ -29,29 +25,28 @@
sortPanelOpen: boolean; sortPanelOpen: boolean;
filterPanelOpen: boolean; filterPanelOpen: boolean;
tabsEl: HTMLDivElement; tabsEl: HTMLDivElement;
onSearchChange: (v: string) => void; onSearchChange: (v: string) => void;
onTabChange: (f: string) => void; onTabChange: (f: string) => void;
onSortChange: (mode: LibrarySortMode) => void; onSortChange: (mode: LibrarySortMode) => void;
onSortDirToggle: () => void; onSortDirToggle: () => void;
onStatusChange: (s: LibraryStatusFilter) => void; onStatusChange: (s: LibraryStatusFilter) => void;
onFilterToggle: (f: LibraryContentFilter) => void; onFilterToggle: (f: LibraryContentFilter) => void;
onFiltersClear: () => void; onFiltersClear: () => void;
onSortPanelToggle: () => void; onSortPanelToggle: () => void;
onFilterPanelToggle: () => void; onFilterPanelToggle: () => void;
onRefresh: () => void;
onOpenDownloadsFolder: () => void; onOpenDownloadsFolder: () => void;
onTabDragStart: (e: DragEvent, cat: Category) => void; onTabDragStart: (e: DragEvent, cat: Category) => void;
onTabDragOver: (e: DragEvent, cat: Category, idx: number) => void; onTabDragOver: (e: DragEvent, cat: Category, idx: number) => void;
onTabDragLeave: () => void; onTabDragLeave: () => void;
onTabDrop: (e: DragEvent, cat: Category) => void; onTabDrop: (e: DragEvent, cat: Category) => void;
onTabDragEnd: () => void; onTabDragEnd: () => void;
} }
let { let {
tab, tabSortMode, tabSortDir, tabStatus, tabFilters, hasActiveFilters, tab, tabSortMode, tabSortDir, tabStatus, tabFilters, hasActiveFilters,
anims, tabIndicator, visibleCategories, counts, search, refreshing, anims, visibleCategories, counts, search, refreshing,
refreshProgress, refreshDone, activeDragKind, dragInsertIdx, dragTabId, refreshProgress, refreshDone, activeDragKind, dragInsertIdx,
dragOverTabId, sortPanelOpen, filterPanelOpen, dragTabId, dragOverTabId, sortPanelOpen, filterPanelOpen,
tabsEl = $bindable(), tabsEl = $bindable(),
onSearchChange, onTabChange, onSortChange, onSortDirToggle, onStatusChange, onSearchChange, onTabChange, onSortChange, onSortDirToggle, onStatusChange,
onFilterToggle, onFiltersClear, onSortPanelToggle, onFilterPanelToggle, onFilterToggle, onFiltersClear, onSortPanelToggle, onFilterPanelToggle,
@@ -73,16 +68,15 @@
"az", "unreadCount", "totalChapters", "recentlyAdded", "recentlyRead", "latestFetched", "latestUploaded", "az", "unreadCount", "totalChapters", "recentlyAdded", "recentlyRead", "latestFetched", "latestUploaded",
]; ];
const activeCatId = $derived(
tab !== "library" && tab !== "downloaded" ? Number(tab) : null
);
</script> </script>
<div class="header"> <div class="header">
<span class="heading">Library</span> <span class="heading">Library</span>
<div class="tabs" class:tabs-anims={anims} bind:this={tabsEl}> <div class="tabs" class:tabs-anims={anims} bind:this={tabsEl}>
{#if anims && tabIndicator.width > 0}
<div class="tab-slide-indicator" style="left:{tabIndicator.left}px;width:{tabIndicator.width}px" aria-hidden="true"></div>
{/if}
{#each [["library", "Saved"], ["downloaded", "Downloaded"]] as [f, label]} {#each [["library", "Saved"], ["downloaded", "Downloaded"]] as [f, label]}
<button class="tab" class:active={tab === f} onclick={() => onTabChange(f)}> <button class="tab" class:active={tab === f} onclick={() => onTabChange(f)}>
{#if f === "library"}<Books size={11} weight="bold" /> {#if f === "library"}<Books size={11} weight="bold" />
@@ -128,19 +122,27 @@
<input class="search" placeholder="Search" value={search} oninput={(e) => onSearchChange((e.target as HTMLInputElement).value)} /> <input class="search" placeholder="Search" value={search} oninput={(e) => onSearchChange((e.target as HTMLInputElement).value)} />
</div> </div>
<button {#if refreshing}
class="icon-btn refresh-btn" <button
class:icon-btn-active={refreshing} class="icon-btn refresh-btn icon-btn-active"
class:refresh-btn-done={refreshDone} title={`Checking… ${refreshProgress.finished}/${refreshProgress.total}`}
title={refreshing ? `Checking… ${refreshProgress.finished}/${refreshProgress.total}` : refreshDone ? "Library updated" : "Check for updates"} onclick={onRefresh}
disabled={refreshing} >
onclick={onRefresh} <ArrowsClockwise size={15} weight="bold" class="anim-spin" />
> {#if refreshProgress.total > 0}
<ArrowsClockwise size={15} weight="bold" class={refreshing ? "anim-spin" : ""} /> <span class="refresh-progress">{refreshProgress.finished}/{refreshProgress.total}</span>
{#if refreshing && refreshProgress.total > 0} {/if}
<span class="refresh-progress">{refreshProgress.finished}/{refreshProgress.total}</span> </button>
{/if} {:else}
</button> <button
class="icon-btn refresh-btn"
class:refresh-btn-done={refreshDone}
title={refreshDone ? "Library updated" : "Check for updates"}
onclick={onRefresh}
>
<ArrowsClockwise size={15} weight="bold" />
</button>
{/if}
<button class="icon-btn" title="Open downloads folder" onclick={onOpenDownloadsFolder}> <button class="icon-btn" title="Open downloads folder" onclick={onOpenDownloadsFolder}>
<FolderSimple size={15} weight="bold" /> <FolderSimple size={15} weight="bold" />
@@ -202,15 +204,13 @@
.header { position: relative; z-index: 100; display: flex; align-items: center; gap: var(--sp-4); padding: var(--sp-4) var(--sp-6); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; min-width: 0; } .header { position: relative; z-index: 100; display: flex; align-items: center; gap: var(--sp-4); padding: var(--sp-4) var(--sp-6); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; min-width: 0; }
.header-right { display: flex; align-items: center; gap: var(--sp-2); margin-left: auto; flex-shrink: 0; } .header-right { display: flex; align-items: center; gap: var(--sp-2); margin-left: auto; flex-shrink: 0; }
.heading { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; flex-shrink: 0; } .heading { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; flex-shrink: 0; }
.tabs { display: flex; align-items: center; gap: 2px; background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 2px; position: relative; flex-shrink: 1; min-width: 0; } .tabs { display: flex; align-items: center; gap: 2px; background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 2px; position: relative; flex-shrink: 1; min-width: 0; overflow: hidden; }
.tabs-scroll { display: flex; gap: 2px; overflow-x: auto; scrollbar-width: none; min-width: 0; flex-shrink: 1; } .tabs-scroll { display: flex; gap: 2px; overflow-x: auto; scrollbar-width: none; min-width: 0; flex-shrink: 1; }
.tabs-scroll::-webkit-scrollbar { display: none; } .tabs-scroll::-webkit-scrollbar { display: none; }
.tab-separator { width: 1px; height: 16px; background: var(--border-dim); flex-shrink: 0; margin: 0 2px; } .tab-separator { width: 1px; height: 16px; background: var(--border-dim); flex-shrink: 0; margin: 0 2px; }
.tab-slide-indicator { position: absolute; top: 2px; bottom: 2px; border-radius: var(--radius-sm); background: var(--accent-muted); border: 1px solid var(--accent-dim); pointer-events: none; z-index: 0; transition: left 0.22s cubic-bezier(0.16,1,0.3,1), width 0.22s cubic-bezier(0.16,1,0.3,1); } .tab { position: relative; z-index: 1; display: flex; align-items: center; gap: 5px; font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); text-transform: uppercase; padding: 4px 10px; border-radius: var(--radius-sm); border: 1px solid transparent; color: var(--text-faint); white-space: nowrap; transition: background var(--t-base), color var(--t-base), border-color var(--t-base); cursor: grab; flex-shrink: 0; }
.tab { position: relative; z-index: 1; display: flex; align-items: center; gap: 5px; font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); text-transform: uppercase; padding: 4px 10px; border-radius: var(--radius-sm); color: var(--text-faint); white-space: nowrap; transition: background var(--t-base), color var(--t-base); cursor: grab; flex-shrink: 0; }
.tab:hover { color: var(--text-muted); } .tab:hover { color: var(--text-muted); }
.tab.active { background: var(--accent-muted); color: var(--accent-fg); border: 1px solid transparent; } .tab.active { background: var(--accent-muted); color: var(--accent-fg); border-color: var(--accent-dim); }
.tabs-anims .tab.active { background: transparent; }
.tab-dragging { opacity: 0.4; cursor: grabbing; } .tab-dragging { opacity: 0.4; cursor: grabbing; }
.tab-insert-bar { width: 2px; height: 22px; background: var(--accent); border-radius: 2px; flex-shrink: 0; box-shadow: 0 0 6px var(--accent); pointer-events: none; } .tab-insert-bar { width: 2px; height: 22px; background: var(--accent); border-radius: 2px; flex-shrink: 0; box-shadow: 0 0 6px var(--accent); pointer-events: none; }
.tab-count { font-size: var(--text-2xs); opacity: 0.6; } .tab-count { font-size: var(--text-2xs); opacity: 0.6; }
@@ -222,6 +222,7 @@
.icon-btn { display: flex; align-items: center; justify-content: center; width: 30px; height: 30px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: var(--bg-raised); color: var(--text-faint); cursor: pointer; flex-shrink: 0; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); } .icon-btn { display: flex; align-items: center; justify-content: center; width: 30px; height: 30px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: var(--bg-raised); color: var(--text-faint); cursor: pointer; flex-shrink: 0; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
.icon-btn:hover { color: var(--text-primary); border-color: var(--border-strong); } .icon-btn:hover { color: var(--text-primary); border-color: var(--border-strong); }
.icon-btn-active { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); } .icon-btn-active { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
.refresh-btn { gap: var(--sp-1); width: auto; padding: 0 8px; } .refresh-btn { gap: var(--sp-1); width: auto; padding: 0 8px; }
.refresh-btn:disabled { cursor: default; } .refresh-btn:disabled { cursor: default; }
.refresh-progress { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--accent-fg); } .refresh-progress { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--accent-fg); }
@@ -236,11 +237,6 @@
.panel-divider { height: 1px; background: var(--border-dim); margin: 4px 2px; } .panel-divider { height: 1px; background: var(--border-dim); margin: 4px 2px; }
.panel-header { display: flex; align-items: center; justify-content: space-between; padding: 6px 10px 4px; } .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-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-item-check { justify-content: flex-start; gap: var(--sp-2); }
.panel-check { width: 13px; height: 13px; border-radius: 2px; border: 1px solid var(--border-strong); background: transparent; flex-shrink: 0; transition: background var(--t-base), border-color var(--t-base); display: flex; align-items: center; justify-content: center; color: var(--bg-base); }
.panel-check-on { background: var(--accent); border-color: var(--accent); }
.dir-toggle { color: var(--text-secondary); justify-content: flex-start; gap: var(--sp-2); border-top: 1px solid var(--border-dim); border-radius: 0 0 var(--radius-sm) var(--radius-sm); margin-top: 2px; padding-top: 9px; } .dir-toggle { color: var(--text-secondary); justify-content: flex-start; gap: var(--sp-2); border-top: 1px solid var(--border-dim); border-radius: 0 0 var(--radius-sm) var(--radius-sm); margin-top: 2px; padding-top: 9px; }
:global(.sort-caret) { flex-shrink: 0; } :global(.sort-caret) { flex-shrink: 0; }
</style> </style>
+5 -5
View File
@@ -16,11 +16,11 @@ export const librarySorter = createSorter<Manga>({
}, },
{ {
key: "totalChapters", key: "totalChapters",
comparator: (a, b) => (a.chapterCount ?? 0) - (b.chapterCount ?? 0), comparator: (a, b) => (a.chapters?.totalCount ?? 0) - (b.chapters?.totalCount ?? 0),
}, },
{ {
key: "recentlyAdded", key: "recentlyAdded",
comparator: (a, b) => a.id - b.id, comparator: (a, b) => Number(a.inLibraryAt ?? 0) - Number(b.inLibraryAt ?? 0),
}, },
{ {
key: "recentlyRead", key: "recentlyRead",
@@ -33,11 +33,11 @@ export const librarySorter = createSorter<Manga>({
}, },
{ {
key: "latestFetched", key: "latestFetched",
comparator: (a, b) => a.id - b.id, comparator: (a, b) => Number(a.latestFetchedChapter?.uploadDate ?? 0) - Number(b.latestFetchedChapter?.uploadDate ?? 0),
}, },
{ {
key: "latestUploaded", key: "latestUploaded",
comparator: (a, b) => a.id - b.id, comparator: (a, b) => Number(a.latestUploadedChapter?.uploadDate ?? 0) - Number(b.latestUploadedChapter?.uploadDate ?? 0),
}, },
], ],
}); });
@@ -49,4 +49,4 @@ export function sortLibrary(
recentlyReadMap?: Map<number, number>, recentlyReadMap?: Map<number, number>,
): Manga[] { ): Manga[] {
return librarySorter.sort(items, mode, dir, recentlyReadMap ? { recentlyReadMap } : undefined); return librarySorter.sort(items, mode, dir, recentlyReadMap ? { recentlyReadMap } : undefined);
} }
+104 -53
View File
@@ -1,15 +1,17 @@
import { gql } from "@api/client"; import { gql } from "@api/client";
import { LIBRARY_UPDATE_STATUS } from "@api/queries/manga"; import { LIBRARY_UPDATE_STATUS } from "@api/queries/manga";
import { UPDATE_LIBRARY } from "@api/mutations/manga"; import { UPDATE_LIBRARY, FETCH_MANGA } from "@api/mutations/manga";
import { GET_RECENTLY_UPDATED } from "@api/queries/chapters"; import { GET_LIBRARY } from "@api/queries/manga";
import type { LibraryUpdateEntry } from "@store/state.svelte"; import type { LibraryUpdateEntry } from "@store/state.svelte";
const POLL_INTERVAL_MS = 3000; const POLL_INTERVAL_MS = 2000;
const POLL_INITIAL_MS = 2000; const POLL_INITIAL_MS = 500;
export interface UpdateProgress { export interface UpdateProgress {
finished: number; finished: number;
total: number; total: number;
skippedManga: number;
skippedCategories: number;
} }
export interface UpdateResult { export interface UpdateResult {
@@ -21,89 +23,138 @@ export interface UpdateResult {
export interface LibraryUpdaterCallbacks { export interface LibraryUpdaterCallbacks {
onProgress: (p: UpdateProgress) => void; onProgress: (p: UpdateProgress) => void;
onDone: (r: UpdateResult) => void; onDone: (r: UpdateResult) => void;
onError: () => 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 { export function startLibraryUpdate(callbacks: LibraryUpdaterCallbacks): () => void {
let timer: ReturnType<typeof setTimeout> | null = null; let timer: ReturnType<typeof setTimeout> | null = null;
let cancelled = false; let cancelled = false;
const startedAt = Math.floor(Date.now() / 1000);
function cancel() { function cancel() {
cancelled = true; cancelled = true;
if (timer) { clearTimeout(timer); timer = null; } 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() { async function run() {
let seenWork = false; let jobsStarted = false;
try { try {
const res = await gql<{ const res = await gql<{
updateLibrary: { updateStatus: { jobsInfo: { isRunning: boolean; totalJobs: number } } } updateLibrary: {
updateStatus: {
jobsInfo: {
isRunning: boolean;
totalJobs: number;
finishedJobs: number;
skippedMangasCount: number;
skippedCategoriesCount: number;
}
}
}
}>(UPDATE_LIBRARY, {}); }>(UPDATE_LIBRARY, {});
if (cancelled) return; if (cancelled) return;
seenWork = res.updateLibrary.updateStatus.jobsInfo.totalJobs > 0;
} catch { const { jobsInfo } = res.updateLibrary.updateStatus;
if (!cancelled) callbacks.onError(); 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; return;
} }
function poll() { function poll() {
gql<{ gql<{
libraryUpdateStatus: { libraryUpdateStatus: {
jobsInfo: { isRunning: boolean; finishedJobs: number; totalJobs: number }; jobsInfo: {
mangaUpdates: { status: string; manga: { id: number } }[]; 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, {}) }>(LIBRARY_UPDATE_STATUS, {})
.then(async d => { .then(async d => {
if (cancelled) return; if (cancelled) return;
const { jobsInfo } = d.libraryUpdateStatus; const { jobsInfo, mangaUpdates } = d.libraryUpdateStatus;
if (jobsInfo.totalJobs > 0) seenWork = true; if (jobsInfo.totalJobs > 0) jobsStarted = true;
callbacks.onProgress({ finished: jobsInfo.finishedJobs, total: jobsInfo.totalJobs }); callbacks.onProgress({
finished: jobsInfo.finishedJobs,
total: jobsInfo.totalJobs,
skippedManga: jobsInfo.skippedMangasCount,
skippedCategories: jobsInfo.skippedCategoriesCount,
});
if (!jobsInfo.isRunning && seenWork) { if (!jobsInfo.isRunning && jobsStarted) {
const recent = await gql<{ const entries = buildEntries(mangaUpdates);
chapters: {
nodes: {
mangaId: number;
fetchedAt: string;
manga: { id: number; title: string; thumbnailUrl: string; inLibrary: boolean };
}[]
}
}>(GET_RECENTLY_UPDATED, {}).catch(() => ({ chapters: { nodes: [] } }));
if (cancelled) return;
const byManga = new Map<number, LibraryUpdateEntry>();
for (const ch of recent.chapters.nodes) {
if (!ch.manga.inLibrary) continue;
if (Number(ch.fetchedAt) < startedAt) continue;
const existing = byManga.get(ch.mangaId);
if (existing) {
existing.newChapters++;
} else {
byManga.set(ch.mangaId, {
mangaId: ch.mangaId,
mangaTitle: ch.manga.title,
thumbnailUrl: ch.manga.thumbnailUrl,
newChapters: 1,
checkedAt: Date.now(),
});
}
}
const entries = [...byManga.values()];
const newChapters = entries.reduce((s, e) => s + e.newChapters, 0); const newChapters = entries.reduce((s, e) => s + e.newChapters, 0);
callbacks.onDone({ entries, totalUpdated: entries.length, newChapters }); callbacks.onDone({ entries, totalUpdated: entries.length, newChapters });
return; return;
} }
timer = setTimeout(poll, POLL_INTERVAL_MS); timer = setTimeout(poll, POLL_INTERVAL_MS);
}) })
.catch(() => { .catch((e) => {
if (!cancelled) callbacks.onError(); console.error("[libraryUpdater] poll error", e);
if (!cancelled) callbacks.onError(e);
}); });
} }
+240 -47
View File
@@ -19,6 +19,7 @@
fadingOut: boolean; fadingOut: boolean;
tapToToggleBar: boolean; tapToToggleBar: boolean;
pinchZoomEnabled: boolean; pinchZoomEnabled: boolean;
chapterEpoch: number;
onGetZoom: () => number; onGetZoom: () => number;
onSetZoom: (z: number) => void; onSetZoom: (z: number) => void;
resolveUrl: (url: string, priority?: number) => Promise<string>; resolveUrl: (url: string, priority?: number) => Promise<string>;
@@ -31,10 +32,152 @@
const { const {
style, imgCls, effectiveWidth, loading, error, pageReady, style, imgCls, effectiveWidth, loading, error, pageReady,
pageGroups, currentGroup, stripToRender, fadingOut, pageGroups, currentGroup, stripToRender, fadingOut,
tapToToggleBar, pinchZoomEnabled, onGetZoom, onSetZoom, tapToToggleBar, pinchZoomEnabled, chapterEpoch, onGetZoom, onSetZoom,
resolveUrl, onTap, onWheel, onToggleUi, bindContainer, resolveUrl, onTap, onWheel, onToggleUi, bindContainer,
}: Props = $props(); }: Props = $props();
const LOAD_RADIUS = 5;
const UNLOAD_RADIUS = 10;
type FlatPage = { chapterId: number; chapterName: string; localIndex: number; url: string; total: number };
const flatPages = $derived.by<FlatPage[]>(() => {
const out: FlatPage[] = [];
for (const chunk of stripToRender) {
for (let i = 0; i < chunk.urls.length; i++) {
out.push({ chapterId: chunk.chapterId, chapterName: chunk.chapterName, localIndex: i, url: chunk.urls[i], total: chunk.urls.length });
}
}
return out;
});
let loadedSet = $state(new Set<number>());
let resolvedSrc = $state<Record<number, string>>({});
let revokeQueue: string[] = [];
let observer: IntersectionObserver | null = null;
const elementIndex = new Map<Element, number>();
let viewportCenter = $state(0);
function scheduleRevoke(src: string) {
if (!src || !src.startsWith("blob:")) return;
revokeQueue.push(src);
requestAnimationFrame(() => {
const url = revokeQueue.shift();
if (url) {
try { URL.revokeObjectURL(url); } catch { }
}
});
}
function loadPage(idx: number) {
if (loadedSet.has(idx)) return;
const page = flatPages[idx];
if (!page) return;
const newSet = new Set(loadedSet);
newSet.add(idx);
loadedSet = newSet;
const priority = (page.localIndex < 8 && page.chapterId === flatPages[0]?.chapterId) ? 8 - page.localIndex : 0;
resolveUrl(page.url, priority).then(src => {
if (loadedSet.has(idx)) {
resolvedSrc = { ...resolvedSrc, [idx]: src };
} else {
scheduleRevoke(src);
}
});
}
function unloadPage(idx: number) {
if (!loadedSet.has(idx)) return;
const newSet = new Set(loadedSet);
newSet.delete(idx);
loadedSet = newSet;
const oldSrc = resolvedSrc[idx];
if (oldSrc) {
const next = { ...resolvedSrc };
delete next[idx];
resolvedSrc = next;
scheduleRevoke(oldSrc);
}
}
function recalcWindow() {
const center = viewportCenter;
const lo = center - LOAD_RADIUS;
const hi = center + LOAD_RADIUS;
const evictLo = center - UNLOAD_RADIUS;
const evictHi = center + UNLOAD_RADIUS;
for (let i = 0; i < flatPages.length; i++) {
if (i >= lo && i <= hi) {
loadPage(i);
} else if (i < evictLo || i > evictHi) {
unloadPage(i);
}
}
}
$effect(() => {
void viewportCenter;
recalcWindow();
});
$effect(() => {
void flatPages.length;
recalcWindow();
});
function setupObserver(containerEl: HTMLElement) {
observer?.disconnect();
elementIndex.clear();
observer = new IntersectionObserver(
(entries) => {
let best = -1;
let bestRatio = -1;
for (const entry of entries) {
const idx = elementIndex.get(entry.target);
if (idx === undefined) continue;
if (entry.isIntersecting && entry.intersectionRatio > bestRatio) {
bestRatio = entry.intersectionRatio;
best = idx;
}
}
if (best >= 0 && best !== viewportCenter) {
viewportCenter = best;
}
},
{
root: containerEl,
rootMargin: "0px",
threshold: [0, 0.1, 0.5, 1.0],
}
);
}
function observePage(el: HTMLDivElement, idx: number) {
elementIndex.set(el, idx);
observer?.observe(el);
return {
update(newIdx: number) {
elementIndex.set(el, newIdx);
},
destroy() {
observer?.unobserve(el);
elementIndex.delete(el);
}
};
}
$effect(() => {
void chapterEpoch;
loadedSet = new Set<number>();
resolvedSrc = {};
const resume = readerState.resumePage;
viewportCenter = resume > 1 ? resume - 1 : 0;
});
const INSPECT_ZOOM_STEP = 0.15; const INSPECT_ZOOM_STEP = 0.15;
const INSPECT_ZOOM_MAX = 8; const INSPECT_ZOOM_MAX = 8;
@@ -63,7 +206,7 @@
let inspectPanStartX = 0; let inspectPanStartX = 0;
let inspectPanStartY = 0; let inspectPanStartY = 0;
let stripDragging = false; let stripDragging = $state(false);
let stripDragMoved = false; let stripDragMoved = false;
let stripDragStartY = 0; let stripDragStartY = 0;
let stripScrollStart = 0; let stripScrollStart = 0;
@@ -86,6 +229,7 @@
}); });
export function onInspectMouseDown(e: MouseEvent) { export function onInspectMouseDown(e: MouseEvent) {
if ((e.target as Element).closest(".bar")) return;
if (style === "longstrip") { if (style === "longstrip") {
stripDragging = true; stripDragging = true;
stripDragMoved = false; stripDragMoved = false;
@@ -126,6 +270,7 @@
} }
export function onPointerDown(e: PointerEvent) { export function onPointerDown(e: PointerEvent) {
if ((e.target as Element).closest(".bar")) return;
pinch?.onPointerDown(e); pinch?.onPointerDown(e);
} }
@@ -192,7 +337,17 @@
function setContainer(el: HTMLDivElement) { function setContainer(el: HTMLDivElement) {
containerEl = el; containerEl = el;
bindContainer(el); bindContainer(el);
if (style === "longstrip") setupObserver(el);
} }
$effect(() => {
if (style === "longstrip" && containerEl) {
setupObserver(containerEl);
} else if (style !== "longstrip") {
observer?.disconnect();
observer = null;
}
});
</script> </script>
<div <div
@@ -219,53 +374,77 @@
<div class="center-overlay"><p class="error-msg">{error}</p></div> <div class="center-overlay"><p class="error-msg">{error}</p></div>
{/if} {/if}
{#if style === "longstrip"} {#key chapterEpoch}
{#each stripToRender as chunk} {#if style === "longstrip"}
{#each chunk.urls as url, i} {#each flatPages as page, gi}
{#await resolveUrl(url, chunk.urls.length - i)} {@const src = resolvedSrc[gi]}
<img src="" alt="{chunk.chapterName} Page {i + 1}" data-local-page={i + 1} data-chapter={chunk.chapterId} data-total={chunk.urls.length} class="{imgCls}{store.settings.pageGap ? ' strip-gap' : ''}" loading={i < 5 ? "eager" : "lazy"} decoding="async" /> {@const isLoaded = loadedSet.has(gi)}
{:then src} <div
<img {src} alt="{chunk.chapterName} Page {i + 1}" data-local-page={i + 1} data-chapter={chunk.chapterId} data-total={chunk.urls.length} class="{imgCls}{store.settings.pageGap ? ' strip-gap' : ''}" loading={i < 5 ? "eager" : "lazy"} decoding="async" /> class="strip-slot"
{/await} use:observePage={gi}
{/each} data-gi={gi}
{/each} >
<div style="height:1px;flex-shrink:0"></div> {#if isLoaded}
<img
{:else if style === "fade" && pageReady} src={src ?? ""}
<div class="inspect-wrap" style="transform:scale({readerState.inspectScale}) translate({readerState.inspectPanX / readerState.inspectScale}px,{readerState.inspectPanY / readerState.inspectScale}px)"> alt="{page.chapterName} Page {page.localIndex + 1}"
{#await resolveUrl(store.pageUrls[store.pageNumber - 1], 999)} data-local-page={page.localIndex + 1}
<img src="" alt="Page {store.pageNumber}" class={imgCls} decoding="async" style="opacity:0" /> data-chapter={page.chapterId}
{:then src} data-total={page.total}
<img {src} alt="Page {store.pageNumber}" class={imgCls} decoding="async" style="opacity: {fadingOut ? 0 : 1}; transition: opacity 0.1s ease;" /> class="{imgCls}{store.settings.pageGap ? ' strip-gap' : ''}"
{/await} loading="eager"
</div> decoding="async"
onload={(e) => {
{:else if style === "double" && pageReady} const img = e.currentTarget as HTMLImageElement;
<div class="inspect-wrap" style="transform:scale({readerState.inspectScale}) translate({readerState.inspectPanX / readerState.inspectScale}px,{readerState.inspectPanY / readerState.inspectScale}px)"> const slot = img.closest<HTMLElement>(".strip-slot");
{#if pageGroups.length} if (slot && img.naturalWidth > 0) {
<div class="double-wrap"> slot.style.setProperty("--aspect", String(img.naturalWidth / img.naturalHeight));
{#each currentGroup as pg, i} }
{#await resolveUrl(store.pageUrls[pg - 1], 999)} }}
<img src="" alt="Page {pg}" class="{imgCls} page-half {i === 0 ? 'gap-left' : 'gap-right'}" decoding="async" /> />
{:then src} {:else}
<img {src} alt="Page {pg}" class="{imgCls} page-half {i === 0 ? 'gap-left' : 'gap-right'}" decoding="async" /> <div class="strip-placeholder"></div>
{/await} {/if}
{/each}
</div> </div>
{:else} {/each}
<div class="center-overlay"><CircleNotch size={20} weight="light" class="anim-spin" style="color:var(--text-faint)" /></div> <div style="height:1px;flex-shrink:0"></div>
{/if}
</div>
{:else if pageReady} {:else if style === "fade" && pageReady}
<div class="inspect-wrap" style="transform:scale({readerState.inspectScale}) translate({readerState.inspectPanX / readerState.inspectScale}px,{readerState.inspectPanY / readerState.inspectScale}px)"> <div class="inspect-wrap" style="transform:scale({readerState.inspectScale}) translate({readerState.inspectPanX / readerState.inspectScale}px,{readerState.inspectPanY / readerState.inspectScale}px)">
{#await resolveUrl(store.pageUrls[store.pageNumber - 1], 999)} {#await resolveUrl(store.pageUrls[store.pageNumber - 1], 999)}
<img src="" alt="Page {store.pageNumber}" class={imgCls} decoding="async" /> <img src="" alt="Page {store.pageNumber}" class={imgCls} decoding="async" style="opacity:0" />
{:then src} {:then src}
<img {src} alt="Page {store.pageNumber}" class={imgCls} decoding="async" /> <img {src} alt="Page {store.pageNumber}" class={imgCls} decoding="async" style="opacity: {fadingOut ? 0 : 1}; transition: opacity 0.1s ease;" />
{/await} {/await}
</div> </div>
{/if}
{:else if style === "double" && pageReady}
<div class="inspect-wrap" style="transform:scale({readerState.inspectScale}) translate({readerState.inspectPanX / readerState.inspectScale}px,{readerState.inspectPanY / readerState.inspectScale}px)">
{#if pageGroups.length}
<div class="double-wrap">
{#each currentGroup as pg, i}
{#await resolveUrl(store.pageUrls[pg - 1], 999)}
<img src="" alt="Page {pg}" class="{imgCls} page-half {i === 0 ? 'gap-left' : 'gap-right'}" decoding="async" />
{:then src}
<img {src} alt="Page {pg}" class="{imgCls} page-half {i === 0 ? 'gap-left' : 'gap-right'}" decoding="async" />
{/await}
{/each}
</div>
{:else}
<div class="center-overlay"><CircleNotch size={20} weight="light" class="anim-spin" style="color:var(--text-faint)" /></div>
{/if}
</div>
{:else if pageReady}
<div class="inspect-wrap" style="transform:scale({readerState.inspectScale}) translate({readerState.inspectPanX / readerState.inspectScale}px,{readerState.inspectPanY / readerState.inspectScale}px)">
{#await resolveUrl(store.pageUrls[store.pageNumber - 1], 999)}
<img src="" alt="Page {store.pageNumber}" class={imgCls} decoding="async" />
{:then src}
<img {src} alt="Page {store.pageNumber}" class={imgCls} decoding="async" />
{/await}
</div>
{/if}
{/key}
</div> </div>
@@ -280,6 +459,20 @@
.inspect-wrap { display: flex; align-items: center; justify-content: center; transform-origin: center center; will-change: transform; } .inspect-wrap { display: flex; align-items: center; justify-content: center; transform-origin: center center; will-change: transform; }
.strip-slot {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
}
.strip-placeholder {
width: var(--effective-width, 100%);
max-width: var(--effective-width, 100%);
aspect-ratio: var(--aspect, 0.667);
background: transparent;
}
.img { display: block; user-select: none; image-rendering: auto; } .img { display: block; user-select: none; image-rendering: auto; }
.img:global(.optimize-contrast) { image-rendering: -webkit-optimize-contrast; } .img:global(.optimize-contrast) { image-rendering: -webkit-optimize-contrast; }
:global(.fit-width) { max-width: var(--effective-width, 100%); width: 100%; height: auto; } :global(.fit-width) { max-width: var(--effective-width, 100%); width: 100%; height: auto; }
+22 -17
View File
@@ -25,7 +25,7 @@
import ReaderPresetPanel from "./ReaderPresetPanel.svelte"; import ReaderPresetPanel from "./ReaderPresetPanel.svelte";
const win = getCurrentWindow(); const win = getCurrentWindow();
const useBlob = $derived((store.settings.serverAuthMode ?? "NONE") === "BASIC_AUTH"); const useBlob = $derived((store.settings.serverAuthMode ?? "NONE") !== "NONE");
const effectiveReaderSettings = $derived.by(() => { const effectiveReaderSettings = $derived.by(() => {
const mangaId = store.activeManga?.id; const mangaId = store.activeManga?.id;
@@ -139,7 +139,7 @@
let containerEl: HTMLDivElement | null = null; let containerEl: HTMLDivElement | null = null;
let pageViewRef: PageView; let pageViewRef: PageView;
let zoomAnchor = { el: null as HTMLElement | null, offset: 0 }; let zoomAnchor = { el: null as HTMLElement | null, offset: 0 };
let hideTimer: ReturnType<typeof setTimeout> | null = null; let hideTimer = $state<ReturnType<typeof setTimeout> | null>(null);
let markedRead = new Set<number>(); let markedRead = new Set<number>();
let appending = false; let appending = false;
let abortCtrl = { current: null as AbortController | null }; let abortCtrl = { current: null as AbortController | null };
@@ -420,23 +420,28 @@
$effect(() => { $effect(() => {
const ahead = store.settings.preloadPages ?? 3; const ahead = store.settings.preloadPages ?? 3;
const current = store.pageUrls[store.pageNumber - 1]; const current = store.pageUrls[store.pageNumber - 1];
const pageNum = store.pageNumber;
const urls = store.pageUrls;
if (!current) return; if (!current) return;
if (useBlob) { const t = setTimeout(() => {
import("@core/cache/imageCache").then(({ getBlobUrl, preloadBlobUrls }) => { if (useBlob) {
getBlobUrl(current, 999); import("@core/cache/imageCache").then(({ getBlobUrl, preloadBlobUrls }) => {
const upcoming = Array.from({ length: ahead }, (_, i) => store.pageUrls[store.pageNumber + i]).filter(Boolean) as string[]; getBlobUrl(current, 999);
const behind = store.pageUrls[store.pageNumber - 2]; const upcoming = Array.from({ length: ahead }, (_, i) => urls[pageNum + i]).filter(Boolean) as string[];
preloadBlobUrls(upcoming, ahead); const behind = urls[pageNum - 2];
if (behind) preloadBlobUrls([behind], 0); preloadBlobUrls(upcoming, ahead);
}); if (behind) preloadBlobUrls([behind], 0);
} else { });
for (let i = 1; i <= ahead; i++) { } else {
const url = store.pageUrls[store.pageNumber - 1 + i]; for (let i = 1; i <= ahead; i++) {
if (url) preloadImage(url, useBlob); const url = urls[pageNum - 1 + i];
if (url) preloadImage(url, useBlob);
}
const behind = urls[pageNum - 2];
if (behind) preloadImage(behind, useBlob);
} }
const behind = store.pageUrls[store.pageNumber - 2]; }, 150);
if (behind) preloadImage(behind, useBlob); return () => clearTimeout(t);
}
}); });
$effect(() => { $effect(() => {
@@ -142,12 +142,18 @@
{#if isVertical} {#if isVertical}
<span class="ch-info">&#xE2CE;</span> <span class="ch-info">&#xE2CE;</span>
{:else} {:else}
<span class="ch-title">{store.activeManga?.title}</span> <span class="ch-marquee-track" onwheel={(e) => { e.stopPropagation(); (e.currentTarget as HTMLElement).scrollLeft += e.deltaY; }}>
<span class="ch-sep">/</span> <span class="ch-marquee-content">
<span class="ch-name">{displayChapter?.name}</span> <span class="ch-title">{store.activeManga?.title}</span>
<span class="ch-page">{store.pageNumber} / {visibleChunkLastPage || "…"}</span> <span class="ch-sep">/</span>
<span class="ch-name">{displayChapter?.name}</span>
</span>
</span>
{/if} {/if}
</button> </button>
{#if !isVertical}
<span class="ch-page">{store.pageNumber} / {visibleChunkLastPage || "…"}</span>
{/if}
{#if chapterHover && isVertical} {#if chapterHover && isVertical}
<div class="ch-popover ch-popover-{popoverSide}"> <div class="ch-popover ch-popover-{popoverSide}">
@@ -306,6 +312,7 @@
{#if readerState.winOpen} {#if readerState.winOpen}
<div <div
class="wc-clip wc-clip-{popoverSide}" class="wc-clip wc-clip-{popoverSide}"
role="presentation"
onmouseenter={wcResetTimer} onmouseenter={wcResetTimer}
onmousemove={wcResetTimer} onmousemove={wcResetTimer}
> >
@@ -403,16 +410,14 @@
.icon-btn.active { color: var(--accent-fg); } .icon-btn.active { color: var(--accent-fg); }
.marker-btn-has { color: var(--marker-color, var(--accent-fg)) !important; } .marker-btn-has { color: var(--marker-color, var(--accent-fg)) !important; }
.ch-hover-wrap { position: relative; min-width: 0; } .ch-hover-wrap { position: relative; min-width: 0; display: flex; align-items: center; gap: var(--sp-2); }
.ch-pill { .ch-pill {
display: flex; display: flex;
align-items: center; align-items: center;
gap: var(--sp-2);
font-size: var(--text-sm); font-size: var(--text-sm);
color: var(--text-muted); color: var(--text-muted);
overflow: hidden; overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
min-width: 0; min-width: 0;
padding: 2px 4px; padding: 2px 4px;
@@ -428,9 +433,24 @@
padding: 0; padding: 0;
} }
.ch-info { font-size: 15px; line-height: 1; color: var(--text-faint); flex-shrink: 0; } .ch-info { font-size: 15px; line-height: 1; color: var(--text-faint); flex-shrink: 0; }
.ch-title { color: var(--text-secondary); font-weight: var(--weight-medium); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ch-marquee-track {
overflow-x: auto;
min-width: 0;
flex: 1;
scrollbar-width: none;
}
.ch-marquee-track::-webkit-scrollbar { display: none; }
.ch-marquee-content {
display: inline-flex;
align-items: center;
gap: var(--sp-2);
white-space: nowrap;
}
.ch-title { color: var(--text-secondary); font-weight: var(--weight-medium); }
.ch-sep { color: var(--text-faint); flex-shrink: 0; } .ch-sep { color: var(--text-faint); flex-shrink: 0; }
.ch-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .ch-name { color: var(--text-muted); }
.ch-page { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); letter-spacing: var(--tracking-wide); flex-shrink: 0; } .ch-page { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); letter-spacing: var(--tracking-wide); flex-shrink: 0; }
.ch-popover { .ch-popover {
@@ -108,7 +108,7 @@
]; ];
</script> </script>
<div class="backdrop" role="presentation" onclick={close} transition:fade={{ duration: 150 }}></div> <div class="backdrop" role="button" tabindex="-1" aria-label="Close settings" onclick={close} onkeydown={(e) => e.key === 'Escape' && close()} transition:fade={{ duration: 150 }}></div>
<div <div
class="panel" class="panel"
@@ -130,12 +130,13 @@
<p class="section-label">Page Style</p> <p class="section-label">Page Style</p>
<div class="option-grid"> <div class="option-grid">
{#each styleOptions as o} {#each styleOptions as o}
{@const Icon = o.icon}
<button <button
class="option-tile" class="option-tile"
class:active={style === o.value} class:active={style === o.value}
onclick={() => onApplySettings({ pageStyle: o.value as typeof PAGE_STYLES[number] })} onclick={() => onApplySettings({ pageStyle: o.value as typeof PAGE_STYLES[number] })}
> >
<div class="tile-icon"><svelte:component this={o.icon} size={18} weight={style === o.value ? "fill" : "light"} /></div> <div class="tile-icon"><Icon size={18} weight={style === o.value ? "fill" : "light"} /></div>
<span class="tile-label">{o.label}</span> <span class="tile-label">{o.label}</span>
</button> </button>
{/each} {/each}
@@ -149,6 +150,7 @@
class:on={effectiveSettings.offsetDoubleSpreads} class:on={effectiveSettings.offsetDoubleSpreads}
onclick={() => onApplySettings({ offsetDoubleSpreads: !effectiveSettings.offsetDoubleSpreads })} onclick={() => onApplySettings({ offsetDoubleSpreads: !effectiveSettings.offsetDoubleSpreads })}
role="switch" role="switch"
aria-label="Offset double spreads"
aria-checked={effectiveSettings.offsetDoubleSpreads} aria-checked={effectiveSettings.offsetDoubleSpreads}
><span class="toggle-knob"></span></button> ><span class="toggle-knob"></span></button>
</label> </label>
@@ -161,6 +163,7 @@
class:on={effectiveSettings.pageGap ?? true} class:on={effectiveSettings.pageGap ?? true}
onclick={() => onApplySettings({ pageGap: !(effectiveSettings.pageGap ?? true) })} onclick={() => onApplySettings({ pageGap: !(effectiveSettings.pageGap ?? true) })}
role="switch" role="switch"
aria-label="Gap between pages"
aria-checked={effectiveSettings.pageGap ?? true} aria-checked={effectiveSettings.pageGap ?? true}
><span class="toggle-knob"></span></button> ><span class="toggle-knob"></span></button>
</label> </label>
@@ -171,6 +174,7 @@
class:on={store.settings.autoNextChapter ?? false} class:on={store.settings.autoNextChapter ?? false}
onclick={() => updateSettings({ autoNextChapter: !(store.settings.autoNextChapter ?? false) })} onclick={() => updateSettings({ autoNextChapter: !(store.settings.autoNextChapter ?? false) })}
role="switch" role="switch"
aria-label="Auto next chapter"
aria-checked={store.settings.autoNextChapter ?? false} aria-checked={store.settings.autoNextChapter ?? false}
><span class="toggle-knob"></span></button> ><span class="toggle-knob"></span></button>
</label> </label>
@@ -181,12 +185,13 @@
<p class="section-label">Fit Mode</p> <p class="section-label">Fit Mode</p>
<div class="option-grid"> <div class="option-grid">
{#each fitOptions as o} {#each fitOptions as o}
{@const Icon = o.icon}
<button <button
class="option-tile" class="option-tile"
class:active={fit === o.value} class:active={fit === o.value}
onclick={() => onApplySettings({ fitMode: o.value })} onclick={() => onApplySettings({ fitMode: o.value })}
> >
<div class="tile-icon"><svelte:component this={o.icon} size={18} weight={fit === o.value ? "fill" : "light"} /></div> <div class="tile-icon"><Icon size={18} weight={fit === o.value ? "fill" : "light"} /></div>
<span class="tile-label">{o.label}</span> <span class="tile-label">{o.label}</span>
</button> </button>
{/each} {/each}
@@ -240,7 +245,7 @@
<span class="zoom-readout">{zoomPct}%</span> <span class="zoom-readout">{zoomPct}%</span>
</div> </div>
<div class="zoom-row"> <div class="zoom-row">
<button class="zoom-step" onclick={() => setZoom(zoom - 0.1)} disabled={zoom <= ZOOM_MIN}></button> <button class="zoom-step" aria-label="Zoom out" onclick={() => setZoom(zoom - 0.1)} disabled={zoom <= ZOOM_MIN}></button>
<input <input
type="range" type="range"
class="zoom-slider" class="zoom-slider"
@@ -250,7 +255,7 @@
value={zoomPct} value={zoomPct}
oninput={(e) => setZoom(Number(e.currentTarget.value) / 100)} oninput={(e) => setZoom(Number(e.currentTarget.value) / 100)}
/> />
<button class="zoom-step" onclick={() => setZoom(zoom + 0.1)} disabled={zoom >= ZOOM_MAX}>+</button> <button class="zoom-step" aria-label="Zoom in" onclick={() => setZoom(zoom + 0.1)} disabled={zoom >= ZOOM_MAX}>+</button>
</div> </div>
</section> </section>
@@ -263,6 +268,7 @@
class:on={effectiveSettings.optimizeContrast} class:on={effectiveSettings.optimizeContrast}
onclick={() => onApplySettings({ optimizeContrast: !effectiveSettings.optimizeContrast })} onclick={() => onApplySettings({ optimizeContrast: !effectiveSettings.optimizeContrast })}
role="switch" role="switch"
aria-label="Optimize contrast"
aria-checked={effectiveSettings.optimizeContrast} aria-checked={effectiveSettings.optimizeContrast}
><span class="toggle-knob"></span></button> ><span class="toggle-knob"></span></button>
</label> </label>
@@ -273,6 +279,7 @@
class:on={store.settings.pinchZoom ?? false} class:on={store.settings.pinchZoom ?? false}
onclick={() => updateSettings({ pinchZoom: !(store.settings.pinchZoom ?? false) })} onclick={() => updateSettings({ pinchZoom: !(store.settings.pinchZoom ?? false) })}
role="switch" role="switch"
aria-label="Pinch to zoom"
aria-checked={store.settings.pinchZoom ?? false} aria-checked={store.settings.pinchZoom ?? false}
><span class="toggle-knob"></span></button> ><span class="toggle-knob"></span></button>
</label> </label>
@@ -283,6 +290,7 @@
class:on={store.settings.markReadOnNext ?? true} class:on={store.settings.markReadOnNext ?? true}
onclick={() => updateSettings({ markReadOnNext: !(store.settings.markReadOnNext ?? true) })} onclick={() => updateSettings({ markReadOnNext: !(store.settings.markReadOnNext ?? true) })}
role="switch" role="switch"
aria-label="Mark read on chapter advance"
aria-checked={store.settings.markReadOnNext ?? true} aria-checked={store.settings.markReadOnNext ?? true}
><span class="toggle-knob"></span></button> ><span class="toggle-knob"></span></button>
</label> </label>
@@ -297,6 +305,7 @@
class:on={perMangaEnabled} class:on={perMangaEnabled}
onclick={onTogglePerManga} onclick={onTogglePerManga}
role="switch" role="switch"
aria-label="Per-manga settings"
aria-checked={perMangaEnabled} aria-checked={perMangaEnabled}
><span class="toggle-knob"></span></button> ><span class="toggle-knob"></span></button>
</label> </label>
@@ -319,8 +328,8 @@
bind:value={presetNameInput} bind:value={presetNameInput}
onkeydown={(e) => { if (e.key === "Enter") commitSavePreset(); if (e.key === "Escape") presetSaving = false; }} onkeydown={(e) => { if (e.key === "Enter") commitSavePreset(); if (e.key === "Escape") presetSaving = false; }}
/> />
<button class="small-btn" disabled={!presetNameInput.trim()} onclick={commitSavePreset}><Check size={12} weight="bold" /></button> <button class="small-btn" aria-label="Confirm" disabled={!presetNameInput.trim()} onclick={commitSavePreset}><Check size={12} weight="bold" /></button>
<button class="small-btn" onclick={() => presetSaving = false}><X size={12} weight="light" /></button> <button class="small-btn" aria-label="Cancel" onclick={() => presetSaving = false}><X size={12} weight="light" /></button>
</div> </div>
{/if} {/if}
@@ -336,8 +345,8 @@
bind:value={presetEditName} bind:value={presetEditName}
onkeydown={(e) => { if (e.key === "Enter") commitRenamePreset(); if (e.key === "Escape") presetEditId = null; }} onkeydown={(e) => { if (e.key === "Enter") commitRenamePreset(); if (e.key === "Escape") presetEditId = null; }}
/> />
<button class="small-btn" disabled={!presetEditName.trim()} onclick={commitRenamePreset}><Check size={12} weight="bold" /></button> <button class="small-btn" aria-label="Confirm" disabled={!presetEditName.trim()} onclick={commitRenamePreset}><Check size={12} weight="bold" /></button>
<button class="small-btn" onclick={() => presetEditId = null}><X size={12} weight="light" /></button> <button class="small-btn" aria-label="Cancel" onclick={() => presetEditId = null}><X size={12} weight="light" /></button>
</div> </div>
{:else} {:else}
<div class="preset-row"> <div class="preset-row">
+11 -5
View File
@@ -1,7 +1,9 @@
import { store, openReader } from "@store/state.svelte"; import { store } from "@store/state.svelte";
import { readerState } from "../store/readerState.svelte"; import { readerState } from "../store/readerState.svelte";
import { fetchPages } from "./pageLoader"; import { fetchPages } from "./pageLoader";
import { trackingState } from "@features/tracking/store/trackingState.svelte"; import { trackingState } from "@features/tracking/store/trackingState.svelte";
import { cancelQueuedFetches } from "@core/cache/imageCache";
import { clearResolvedUrlCache } from "@core/cache/pageCache";
export function scheduleResumeDismiss() { export function scheduleResumeDismiss() {
setTimeout(() => { readerState.resumeFading = true; }, 1500); setTimeout(() => { readerState.resumeFading = true; }, 1500);
@@ -19,6 +21,10 @@ export async function loadChapter(
abortCtrl.current?.abort(); abortCtrl.current?.abort();
const ctrl = new AbortController(); const ctrl = new AbortController();
abortCtrl.current = ctrl; abortCtrl.current = ctrl;
cancelQueuedFetches();
if (useBlob) clearResolvedUrlCache();
startAtLastPage.current = false; startAtLastPage.current = false;
markedRead.clear(); markedRead.clear();
readerState.resetForChapter(); readerState.resetForChapter();
@@ -43,7 +49,7 @@ export async function loadChapter(
else if (resumeTo > 1) store.pageNumber = Math.min(resumeTo, urls.length || resumeTo); else if (resumeTo > 1) store.pageNumber = Math.min(resumeTo, urls.length || resumeTo);
readerState.pageReady = true; readerState.pageReady = true;
readerState.loading = false; readerState.loading = false;
if (adjacent.next) fetchPages(adjacent.next.id, useBlob).catch(() => {}); if (adjacent.next) fetchPages(adjacent.next.id, useBlob, ctrl.signal).catch(() => {});
} catch (e: any) { } catch (e: any) {
if (ctrl.signal.aborted) return; if (ctrl.signal.aborted) return;
readerState.error = e instanceof Error ? e.message : String(e); readerState.error = e instanceof Error ? e.message : String(e);
+21 -56
View File
@@ -1,3 +1,4 @@
import { createPinchGesture } from "@core/ui/touchscreen";
import { clampZoom } from "./zoomHelpers"; import { clampZoom } from "./zoomHelpers";
import { ZOOM_MIN, ZOOM_MAX } from "../store/readerState.svelte"; import { ZOOM_MIN, ZOOM_MAX } from "../store/readerState.svelte";
@@ -10,69 +11,33 @@ export interface PinchTrackerOptions {
isLongstrip: () => boolean; isLongstrip: () => boolean;
} }
export interface PinchTracker { export type { PinchGesture as PinchTracker } from "@core/ui/touchscreen";
onPointerDown: (e: PointerEvent) => void;
onPointerMove: (e: PointerEvent) => void;
onPointerUp: (e: PointerEvent) => void;
isPinching: () => boolean;
}
const INSPECT_ZOOM_MAX = 8; const INSPECT_ZOOM_MAX = 8;
export function createPinchTracker(opts: PinchTrackerOptions): PinchTracker { export function createPinchTracker(opts: PinchTrackerOptions) {
const pointers = new Map<number, { x: number; y: number }>();
let startDist = 0;
let startZoom = 0; let startZoom = 0;
let startInspect = 0; let startInspect = 0;
let pinching = false;
function dist(a: { x: number; y: number }, b: { x: number; y: number }): number { return createPinchGesture({
return Math.hypot(b.x - a.x, b.y - a.y); onPinch(scale) {
} if (startZoom === 0) {
startZoom = opts.getZoom();
function onPointerDown(e: PointerEvent) { startInspect = opts.getInspectScale();
pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
if (pointers.size === 2) {
const [a, b] = [...pointers.values()];
startDist = dist(a, b);
startZoom = opts.getZoom();
startInspect = opts.getInspectScale();
pinching = true;
}
}
function onPointerMove(e: PointerEvent) {
if (!pinching || !pointers.has(e.pointerId)) return;
pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
if (pointers.size < 2) return;
const [a, b] = [...pointers.values()];
const current = dist(a, b);
if (startDist === 0) return;
const ratio = current / startDist;
if (opts.isLongstrip()) {
opts.setZoom(clampZoom(startZoom * ratio));
} else {
const next = Math.max(1, Math.min(INSPECT_ZOOM_MAX, startInspect * ratio));
if (next !== opts.getInspectScale()) {
if (next === 1) opts.resetInspectPan();
opts.setInspectScale(next);
} }
} if (opts.isLongstrip()) {
} opts.setZoom(clampZoom(startZoom * scale));
} else {
function onPointerUp(e: PointerEvent) { const next = Math.max(1, Math.min(INSPECT_ZOOM_MAX, startInspect * scale));
pointers.delete(e.pointerId); if (next !== opts.getInspectScale()) {
if (pointers.size < 2) { if (next === 1) opts.resetInspectPan();
pinching = false; opts.setInspectScale(next);
startDist = 0; }
}
},
onPinchEnd() {
startZoom = 0; startZoom = 0;
startInspect = 0; startInspect = 0;
} },
} });
function isPinching() { return pinching; }
return { onPointerDown, onPointerMove, onPointerUp, isPinching };
} }
+30 -29
View File
@@ -25,57 +25,58 @@ export function setupScrollTracking(
onAppend, getStripChapters, getPageUrls, shouldAutoMark, onAppend, getStripChapters, getPageUrls, shouldAutoMark,
} = callbacks; } = callbacks;
function onScroll() { let rafId: number | null = null;
function tick() {
rafId = null;
const imgs = containerEl.querySelectorAll<HTMLElement>("img[data-local-page]"); const imgs = containerEl.querySelectorAll<HTMLElement>("img[data-local-page]");
if (!imgs.length) return; if (!imgs.length) return;
const containerTop = containerEl.getBoundingClientRect().top; const containerTop = containerEl.getBoundingClientRect().top;
const readLineY = containerTop + containerEl.clientHeight * READ_LINE_PCT; const readLineY = containerTop + containerEl.clientHeight * READ_LINE_PCT;
let activePage: number | null = null; let lo = 0, hi = imgs.length - 1, best = 0;
let activeChId: number | null = null; while (lo <= hi) {
const mid = (lo + hi) >>> 1;
for (const img of imgs) { if (imgs[mid].getBoundingClientRect().top <= readLineY) { best = mid; lo = mid + 1; }
if (img.getBoundingClientRect().top <= readLineY) { else hi = mid - 1;
activePage = Number(img.dataset.localPage);
activeChId = Number(img.dataset.chapter);
} else break;
} }
if (activePage === null) { const active = imgs[best];
activePage = Number(imgs[0].dataset.localPage); const activePage = Number(active.dataset.localPage);
activeChId = Number(imgs[0].dataset.chapter); const activeChId = Number(active.dataset.chapter);
}
if (activePage !== null) onPageChange(activePage); onPageChange(activePage);
if (activeChId) onChapterChange(activeChId); if (activeChId) onChapterChange(activeChId);
if (shouldAutoMark() && activePage !== null && activeChId) { if (shouldAutoMark() && activeChId) {
const chunks = getStripChapters(); const chunks = getStripChapters();
const chunk = chunks.find(c => c.chapterId === activeChId); const chunk = chunks.find(c => c.chapterId === activeChId);
const total = chunk ? chunk.urls.length : getPageUrls().length; const total = chunk ? chunk.urls.length : getPageUrls().length;
if (total > 0 && activePage >= total) onMarkRead(activeChId); if (total > 0 && activePage >= total) onMarkRead(activeChId);
const atBottom = containerEl.scrollTop + containerEl.clientHeight >= containerEl.scrollHeight - 40;
if (atBottom) {
const last = chunks[chunks.length - 1];
if (last) onMarkRead(last.chapterId);
}
} }
const atBottom = containerEl.scrollTop + containerEl.clientHeight >= containerEl.scrollHeight - 40;
if (atBottom && shouldAutoMark()) {
const chunks = getStripChapters();
const last = chunks[chunks.length - 1];
if (last) onMarkRead(last.chapterId);
}
}
function onScrollAppend() {
const pct = (containerEl.scrollTop + containerEl.clientHeight) / containerEl.scrollHeight; const pct = (containerEl.scrollTop + containerEl.clientHeight) / containerEl.scrollHeight;
if (pct >= 0.80) onAppend(); if (pct >= 0.80) onAppend();
} }
containerEl.addEventListener("scroll", onScroll, { passive: true }); function onScroll() {
containerEl.addEventListener("scroll", onScrollAppend, { passive: true }); if (rafId !== null) return;
rafId = requestAnimationFrame(tick);
}
containerEl.addEventListener("scroll", onScroll, { passive: true });
return () => { return () => {
containerEl.removeEventListener("scroll", onScroll); containerEl.removeEventListener("scroll", onScroll);
containerEl.removeEventListener("scroll", onScrollAppend); if (rafId !== null) cancelAnimationFrame(rafId);
}; };
} }
@@ -107,4 +108,4 @@ export function appendNextChapter(
onDone(); onDone();
}) })
.catch(() => onDone()); .catch(() => onDone());
} }
@@ -2,6 +2,7 @@
import { Download, CheckCircle, Circle, CircleNotch, Trash } from "phosphor-svelte"; import { Download, CheckCircle, Circle, CircleNotch, Trash } from "phosphor-svelte";
import ContextMenu from "@shared/ui/ContextMenu.svelte"; import ContextMenu from "@shared/ui/ContextMenu.svelte";
import type { MenuEntry } from "@shared/ui/ContextMenu.svelte"; import type { MenuEntry } from "@shared/ui/ContextMenu.svelte";
import { longPress } from "@core/ui/touchscreen";
import type { Chapter } from "@types"; import type { Chapter } from "@types";
interface Props { interface Props {
@@ -13,6 +14,7 @@
enqueueing: Set<number>; enqueueing: Set<number>;
chapterPage: number; chapterPage: number;
totalPages: number; totalPages: number;
scrollEl?: HTMLDivElement | null;
onOpen: (ch: Chapter, inProgress: boolean) => void; onOpen: (ch: Chapter, inProgress: boolean) => void;
onToggleSelect: (id: number, e: MouseEvent | KeyboardEvent) => void; onToggleSelect: (id: number, e: MouseEvent | KeyboardEvent) => void;
onEnqueue: (ch: Chapter, e: MouseEvent) => void; onEnqueue: (ch: Chapter, e: MouseEvent) => void;
@@ -24,6 +26,7 @@
let { let {
pageChapters, sortedChapters, viewMode, loadingChapters, pageChapters, sortedChapters, viewMode, loadingChapters,
selectedIds, enqueueing, chapterPage, totalPages, selectedIds, enqueueing, chapterPage, totalPages,
scrollEl = $bindable(null),
onOpen, onToggleSelect, onEnqueue, onDeleteDownload, onOpen, onToggleSelect, onEnqueue, onDeleteDownload,
onPageChange, buildCtxItems, onPageChange, buildCtxItems,
}: Props = $props(); }: Props = $props();
@@ -32,6 +35,13 @@
const hasSelection = $derived(selectedIds.size > 0); const hasSelection = $derived(selectedIds.size > 0);
function chapterLongPress(node: HTMLElement, param: [Chapter, number]) {
const [ch, idx] = param;
return longPress(node, {
onLongPress(e) { ctx = { x: e.clientX, y: e.clientY, chapter: ch, idx }; },
});
}
function formatDate(ts: string | null | undefined): string { function formatDate(ts: string | null | undefined): string {
if (!ts) return ""; if (!ts) return "";
const n = Number(ts); const n = Number(ts);
@@ -40,7 +50,7 @@
} }
</script> </script>
<div class={viewMode === "grid" ? "ch-grid" : "ch-list"}> <div class={viewMode === "grid" ? "ch-grid" : "ch-list"} bind:this={scrollEl}>
{#if loadingChapters && sortedChapters.length === 0} {#if loadingChapters && sortedChapters.length === 0}
{#if viewMode === "grid"} {#if viewMode === "grid"}
{#each Array(24) as _}<div class="grid-cell-skeleton skeleton"></div>{/each} {#each Array(24) as _}<div class="grid-cell-skeleton skeleton"></div>{/each}
@@ -58,9 +68,11 @@
{@const inProgress = !ch.isRead && (ch.lastPageRead ?? 0) > 0} {@const inProgress = !ch.isRead && (ch.lastPageRead ?? 0) > 0}
{@const isGridSelected = selectedIds.has(ch.id)} {@const isGridSelected = selectedIds.has(ch.id)}
<button class="grid-cell" class:read={ch.isRead} class:in-progress={inProgress} class:grid-selected={isGridSelected} <button class="grid-cell" class:read={ch.isRead} class:in-progress={inProgress} class:grid-selected={isGridSelected}
use:chapterLongPress={[ch, i]}
onclick={(e) => hasSelection ? onToggleSelect(ch.id, e) : onOpen(ch, inProgress)} onclick={(e) => hasSelection ? onToggleSelect(ch.id, e) : onOpen(ch, inProgress)}
oncontextmenu={(e) => { e.preventDefault(); ctx = { x: e.clientX, y: e.clientY, chapter: ch, idx: i }; }} oncontextmenu={(e) => { e.preventDefault(); ctx = { x: e.clientX, y: e.clientY, chapter: ch, idx: i }; }}
title={ch.name}> title={ch.name}
>{#if isGridSelected}<span class="grid-cell-check"></span>{/if}
<span class="grid-cell-num">{ch.chapterNumber % 1 === 0 ? ch.chapterNumber.toFixed(0) : ch.chapterNumber}</span> <span class="grid-cell-num">{ch.chapterNumber % 1 === 0 ? ch.chapterNumber.toFixed(0) : ch.chapterNumber}</span>
{#if ch.isDownloaded}<span class="grid-cell-dl" title="Downloaded"></span>{/if} {#if ch.isDownloaded}<span class="grid-cell-dl" title="Downloaded"></span>{/if}
{#if ch.isRead}<span class="grid-cell-dot"></span>{/if} {#if ch.isRead}<span class="grid-cell-dot"></span>{/if}
@@ -74,6 +86,7 @@
{@const isSelected = selectedIds.has(ch.id)} {@const isSelected = selectedIds.has(ch.id)}
{@const chInProgress = !ch.isRead && (ch.lastPageRead ?? 0) > 0} {@const chInProgress = !ch.isRead && (ch.lastPageRead ?? 0) > 0}
<div role="button" tabindex="0" class="ch-row" class:read={ch.isRead} class:ch-selected={isSelected} <div role="button" tabindex="0" class="ch-row" class:read={ch.isRead} class:ch-selected={isSelected}
use:chapterLongPress={[ch, idxInSorted]}
onclick={(e) => hasSelection ? onToggleSelect(ch.id, e) : onOpen(ch, chInProgress)} onclick={(e) => hasSelection ? onToggleSelect(ch.id, e) : onOpen(ch, chInProgress)}
onkeydown={(e) => e.key === "Enter" && (hasSelection ? onToggleSelect(ch.id, e) : onOpen(ch, chInProgress))} onkeydown={(e) => e.key === "Enter" && (hasSelection ? onToggleSelect(ch.id, e) : onOpen(ch, chInProgress))}
oncontextmenu={(e) => { e.preventDefault(); ctx = { x: e.clientX, y: e.clientY, chapter: ch, idx: idxInSorted }; }}> oncontextmenu={(e) => { e.preventDefault(); ctx = { x: e.clientX, y: e.clientY, chapter: ch, idx: idxInSorted }; }}>
@@ -164,10 +177,11 @@
.grid-cell-spinner { position: absolute; top: 2px; right: 2px; } .grid-cell-spinner { position: absolute; top: 2px; right: 2px; }
.grid-cell-skeleton { aspect-ratio: 1; border-radius: var(--radius-sm); } .grid-cell-skeleton { aspect-ratio: 1; border-radius: var(--radius-sm); }
.grid-selected { background: var(--accent-muted) !important; border-color: var(--accent-dim) !important; } .grid-selected { background: var(--accent-muted) !important; border-color: var(--accent-dim) !important; }
.grid-cell-check { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; font-size: 14px; color: var(--accent-fg); pointer-events: none; }
.pagination-bottom { display: flex; align-items: center; justify-content: center; gap: var(--sp-2); padding: var(--sp-3); border-top: 1px solid var(--border-dim); flex-shrink: 0; } .pagination-bottom { display: flex; align-items: center; justify-content: center; gap: var(--sp-2); padding: var(--sp-3); border-top: 1px solid var(--border-dim); flex-shrink: 0; }
.page-btn { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 4px 10px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); color: var(--text-faint); background: none; cursor: pointer; transition: color var(--t-base), border-color var(--t-base); } .page-btn { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 4px 10px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); color: var(--text-faint); background: none; cursor: pointer; transition: color var(--t-base), border-color var(--t-base); }
.page-btn:hover:not(:disabled) { color: var(--text-muted); border-color: var(--border-strong); } .page-btn:hover:not(:disabled) { color: var(--text-muted); border-color: var(--border-strong); }
.page-btn:disabled { opacity: 0.3; cursor: default; } .page-btn:disabled { opacity: 0.3; cursor: default; }
.page-num { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); } .page-num { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
</style> </style>
@@ -3,8 +3,8 @@
import { gql } from "@api/client"; import { gql } from "@api/client";
import Thumbnail from "@shared/manga/Thumbnail.svelte"; import Thumbnail from "@shared/manga/Thumbnail.svelte";
import { import {
X, CheckCircle, Circle, ArrowFatLinesUp, ArrowFatLinesDown, CheckCircle, Circle, ArrowFatLinesUp, ArrowFatLinesDown,
ArrowFatLineUp, ArrowFatLineDown, Download, Trash, DownloadSimple, ArrowFatLineUp, ArrowFatLineDown, Download, Trash, DownloadSimple, CheckSquare,
} from "phosphor-svelte"; } from "phosphor-svelte";
import { GET_MANGA, GET_ALL_MANGA, GET_CATEGORIES } from "@api/queries/manga"; import { GET_MANGA, GET_ALL_MANGA, GET_CATEGORIES } from "@api/queries/manga";
import { GET_CHAPTERS } from "@api/queries/chapters"; import { GET_CHAPTERS } from "@api/queries/chapters";
@@ -13,10 +13,11 @@
import { ENQUEUE_DOWNLOAD, ENQUEUE_CHAPTERS_DOWNLOAD } from "@api/mutations/downloads"; import { ENQUEUE_DOWNLOAD, ENQUEUE_CHAPTERS_DOWNLOAD } from "@api/mutations/downloads";
import { cache, CACHE_KEYS, recordSourceAccess } from "@core/cache"; import { cache, CACHE_KEYS, recordSourceAccess } from "@core/cache";
import { import {
store, addToast, openReader, setActiveManga, linkManga, unlinkManga, store, addToast, openReader, setActiveManga,
addBookmark, acknowledgeUpdate, addBookmark, acknowledgeUpdate,
checkAndMarkCompleted as storeCheckAndMarkCompleted, checkAndMarkCompleted as storeCheckAndMarkCompleted,
clearMarkersForManga, clearMarkersForManga,
saveScroll, getScroll,
} from "@store/state.svelte"; } from "@store/state.svelte";
import { trackingState } from "@features/tracking/store/trackingState.svelte"; import { trackingState } from "@features/tracking/store/trackingState.svelte";
import type { MangaPrefs } from "@store/state.svelte"; import type { MangaPrefs } from "@store/state.svelte";
@@ -28,11 +29,14 @@
import TrackingPanel from "../panels/TrackingPanel.svelte"; import TrackingPanel from "../panels/TrackingPanel.svelte";
import AutomationPanel from "../panels/AutomationPanel.svelte"; import AutomationPanel from "../panels/AutomationPanel.svelte";
import MarkersPanel from "../panels/MarkersPanel.svelte"; import MarkersPanel from "../panels/MarkersPanel.svelte";
import CoverPickerPanel from "../panels/CoverPickerPanel.svelte";
import SeriesLinkPanel from "../panels/SeriesLinkPanel.svelte";
import SeriesHeader from "./SeriesHeader.svelte"; import SeriesHeader from "./SeriesHeader.svelte";
import SeriesActions from "./SeriesActions.svelte"; import SeriesActions from "./SeriesActions.svelte";
import ChapterList from "./ChapterList.svelte"; import ChapterList from "./ChapterList.svelte";
import { buildChapterList, chaptersAscending } from "../lib/chapterList"; import { buildChapterList, chaptersAscending } from "../lib/chapterList";
import { getPref, setPref } from "../lib/mangaPrefs"; import { getPref, setPref } from "../lib/mangaPrefs";
import { autoLinkLibrary } from "@core/cover/autoLink";
const CHAPTERS_PER_PAGE = 25; const CHAPTERS_PER_PAGE = 25;
const MANGA_TTL_MS = 5 * 60 * 1000; const MANGA_TTL_MS = 5 * 60 * 1000;
@@ -57,7 +61,7 @@
let trackingOpen: boolean = $state(false); let trackingOpen: boolean = $state(false);
let markersOpen: boolean = $state(false); let markersOpen: boolean = $state(false);
let linkPickerOpen: boolean = $state(false); let linkPickerOpen: boolean = $state(false);
let linkSearch: string = $state(""); let coverPickerOpen: boolean = $state(false);
let allMangaForLink: Manga[] = $state([]); let allMangaForLink: Manga[] = $state([]);
let loadingLinkList: boolean = $state(false); let loadingLinkList: boolean = $state(false);
let mangaCategories: Category[] = $state([]); let mangaCategories: Category[] = $state([]);
@@ -138,16 +142,6 @@
store.activeManga ? (store.settings.mangaLinks?.[store.activeManga.id] ?? []) : [] store.activeManga ? (store.settings.mangaLinks?.[store.activeManga.id] ?? []) : []
); );
const linkPickerResults = $derived.by(() => {
const id = store.activeManga?.id;
const others = allMangaForLink.filter(m => m.id !== id);
const q = linkSearch.trim().toLowerCase();
const filtered = q ? others.filter(m => m.title.toLowerCase().includes(q)) : others;
const linked = filtered.filter(m => linkedIds.includes(m.id));
const rest = filtered.filter(m => !linkedIds.includes(m.id)).slice(0, 30);
return [...linked, ...rest];
});
function focusOnMount(node: HTMLElement) { node.focus(); } function focusOnMount(node: HTMLElement) { node.focus(); }
function clearSelection() { selectedIds = new Set(); } function clearSelection() { selectedIds = new Set(); }
@@ -269,6 +263,7 @@
$effect(() => { $effect(() => {
const m = store.activeManga; const m = store.activeManga;
const shouldAutoLink = store.settings.autoLinkOnOpen;
if (m) untrack(() => { if (m) untrack(() => {
acknowledgeUpdate(m.id); acknowledgeUpdate(m.id);
loadManga(m.id); loadManga(m.id);
@@ -277,6 +272,22 @@
trackingState.loadForManga(m.id).then(() => { trackingState.loadForManga(m.id).then(() => {
syncTrackersIntoChapters(m.id, chapters); syncTrackersIntoChapters(m.id, chapters);
}); });
if (shouldAutoLink) {
if (allMangaForLink.length) {
autoLinkLibrary(m, allMangaForLink)
.then(n => { if (n > 0) addToast({ kind: "success", title: "Series linked", body: `${n} new link${n === 1 ? "" : "s"} found` }); });
} else {
loadingLinkList = true;
gql<{ mangas: { nodes: Manga[] } }>(GET_ALL_MANGA)
.then(d => {
allMangaForLink = d.mangas.nodes;
return autoLinkLibrary(m, d.mangas.nodes);
})
.then(n => { if (n > 0) addToast({ kind: "success", title: "Series linked", body: `${n} new link${n === 1 ? "" : "s"} found` }); })
.catch(console.error)
.finally(() => { loadingLinkList = false; });
}
}
}); });
}); });
@@ -442,6 +453,7 @@
const above = sortedChapters.slice(0, idx + 1), below = sortedChapters.slice(idx), last = sortedChapters.length - 1; const above = sortedChapters.slice(0, idx + 1), below = sortedChapters.slice(idx), last = sortedChapters.length - 1;
return [ return [
{ label: ch.isRead ? "Mark as unread" : "Mark as read", icon: ch.isRead ? Circle : CheckCircle, onClick: () => markRead(ch.id, !ch.isRead) }, { label: ch.isRead ? "Mark as unread" : "Mark as read", icon: ch.isRead ? Circle : CheckCircle, onClick: () => markRead(ch.id, !ch.isRead) },
{ label: "Select", icon: CheckSquare, onClick: () => { const next = new Set(selectedIds); next.add(ch.id); selectedIds = next; } },
{ separator: true }, { separator: true },
{ label: "Mark above as read", icon: ArrowFatLinesUp, onClick: () => markAboveRead(idx), disabled: above.filter(c => !c.isRead).length === 0 }, { label: "Mark above as read", icon: ArrowFatLinesUp, onClick: () => markAboveRead(idx), disabled: above.filter(c => !c.isRead).length === 0 },
{ label: "Mark above as unread", icon: ArrowFatLineUp, onClick: () => markAboveUnread(idx), disabled: above.filter(c => c.isRead).length === 0 }, { label: "Mark above as unread", icon: ArrowFatLineUp, onClick: () => markAboveUnread(idx), disabled: above.filter(c => c.isRead).length === 0 },
@@ -519,7 +531,7 @@
} }
async function openLinkPicker() { async function openLinkPicker() {
linkPickerOpen = true; linkSearch = ""; linkPickerOpen = true;
if (allMangaForLink.length) return; if (allMangaForLink.length) return;
loadingLinkList = true; loadingLinkList = true;
gql<{ mangas: { nodes: Manga[] } }>(GET_ALL_MANGA) gql<{ mangas: { nodes: Manga[] } }>(GET_ALL_MANGA)
@@ -528,12 +540,16 @@
.finally(() => { loadingLinkList = false; }); .finally(() => { loadingLinkList = false; });
} }
function closeLinkPicker() { linkPickerOpen = false; linkSearch = ""; } function closeLinkPicker() { linkPickerOpen = false; }
function handleLink(other: Manga) { async function openCoverPicker() {
if (!store.activeManga) return; coverPickerOpen = true;
if (linkedIds.includes(other.id)) unlinkManga(store.activeManga.id, other.id); if (allMangaForLink.length) return;
else linkManga(store.activeManga.id, other.id); loadingLinkList = true;
gql<{ mangas: { nodes: Manga[] } }>(GET_ALL_MANGA)
.then(d => { allMangaForLink = d.mangas.nodes; })
.catch(console.error)
.finally(() => { loadingLinkList = false; });
} }
async function toggleCategory(cat: Category) { async function toggleCategory(cat: Category) {
@@ -568,6 +584,20 @@
} catch (e) { console.error(e); } } catch (e) { console.error(e); }
} }
let chapterListEl: HTMLDivElement | null = $state(null);
let prevMangaId: number | null = null;
$effect(() => {
const mangaId = store.activeManga?.id ?? null;
if (mangaId === prevMangaId) return;
if (chapterListEl && prevMangaId !== null) saveScroll(`series:${prevMangaId}`, chapterListEl.scrollTop);
prevMangaId = mangaId;
if (chapterListEl && mangaId !== null) {
const saved = getScroll(`series:${mangaId}`);
chapterListEl.scrollTo({ top: saved });
}
});
onMount(() => () => { mangaAbort?.abort(); chapterAbort?.abort(); }); onMount(() => () => { mangaAbort?.abort(); chapterAbort?.abort(); });
</script> </script>
@@ -598,6 +628,7 @@
onAutoOpen={() => autoOpen = true} onAutoOpen={() => autoOpen = true}
onMarkersToggle={() => markersOpen = !markersOpen} onMarkersToggle={() => markersOpen = !markersOpen}
onLinkPickerOpen={openLinkPicker} onLinkPickerOpen={openLinkPicker}
onCoverPickerOpen={openCoverPicker}
/> />
<div class="list-wrap"> <div class="list-wrap">
@@ -649,6 +680,7 @@
{enqueueing} {enqueueing}
{chapterPage} {chapterPage}
{totalPages} {totalPages}
bind:scrollEl={chapterListEl}
onOpen={openReaderWithAhead} onOpen={openReaderWithAhead}
onToggleSelect={toggleSelect} onToggleSelect={toggleSelect}
onEnqueue={enqueue} onEnqueue={enqueue}
@@ -684,40 +716,20 @@
</div> </div>
{/if} {/if}
{#if linkPickerOpen} {#if coverPickerOpen && store.activeManga}
<div class="link-backdrop" role="presentation" <CoverPickerPanel
onclick={(e) => { if (e.target === e.currentTarget) closeLinkPicker(); }} manga={manga ?? store.activeManga}
onkeydown={(e) => e.key === "Escape" && closeLinkPicker()}> allManga={allMangaForLink}
<div class="link-modal"> onClose={() => coverPickerOpen = false}
<div class="link-header"> />
<span class="link-title">Link as same series</span> {/if}
<button class="link-close" onclick={closeLinkPicker}><X size={14} weight="light" /></button>
</div> {#if linkPickerOpen && store.activeManga}
<p class="link-hint">Mark two manga as the same series so duplicates are merged in search. Click a linked entry again to unlink.</p> <SeriesLinkPanel
<div class="link-search-wrap"> manga={manga ?? store.activeManga}
<input class="link-search" placeholder="Search your library…" bind:value={linkSearch} use:focusOnMount /> allManga={allMangaForLink}
</div> onClose={closeLinkPicker}
<div class="link-list"> />
{#if loadingLinkList}
<p class="link-empty">Loading…</p>
{:else if linkPickerResults.length === 0}
<p class="link-empty">No results</p>
{:else}
{#each linkPickerResults as m (m.id)}
{@const isLinked = linkedIds.includes(m.id)}
<button class="link-row" class:link-row-linked={isLinked} onclick={() => handleLink(m)}>
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="link-thumb" />
<div class="link-info">
<span class="link-manga-title">{m.title}</span>
{#if m.source?.displayName}<span class="link-source">{m.source.displayName}</span>{/if}
</div>
<span class="link-status">{isLinked ? "✓ Linked" : "Link"}</span>
</button>
{/each}
{/if}
</div>
</div>
</div>
{/if} {/if}
{/if} {/if}
@@ -731,82 +743,6 @@
.list-wrap { flex: 1; display: flex; flex-direction: column; overflow: hidden; } .list-wrap { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
.link-backdrop {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.72);
z-index: var(--z-settings);
display: flex;
align-items: center;
justify-content: center;
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
animation: fadeIn 0.12s ease both;
}
.link-modal {
width: min(460px, calc(100vw - 48px));
max-height: 70vh;
display: flex;
flex-direction: column;
background: var(--bg-surface);
border: 1px solid var(--border-base);
border-radius: var(--radius-xl);
overflow: hidden;
box-shadow: 0 24px 64px rgba(0,0,0,0.6);
animation: scaleIn 0.14s ease both;
}
.link-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--sp-4) var(--sp-5);
border-bottom: 1px solid var(--border-dim);
flex-shrink: 0;
}
.link-title { font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-secondary); }
.link-close {
display: flex; align-items: center; justify-content: center;
width: 28px; height: 28px; border-radius: var(--radius-sm);
color: var(--text-faint); background: none; border: none; cursor: pointer;
transition: color var(--t-base), background var(--t-base);
}
.link-close:hover { color: var(--text-muted); background: var(--bg-raised); }
.link-hint {
font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint);
letter-spacing: var(--tracking-wide); line-height: var(--leading-snug);
padding: var(--sp-3) var(--sp-5) 0; flex-shrink: 0;
}
.link-search-wrap { padding: var(--sp-3) var(--sp-4); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
.link-search {
width: 100%; background: var(--bg-raised); border: 1px solid var(--border-dim);
border-radius: var(--radius-md); padding: 6px 10px; color: var(--text-primary);
font-size: var(--text-sm); outline: none; transition: border-color var(--t-base);
}
.link-search:focus { border-color: var(--border-strong); }
.link-list { flex: 1; overflow-y: auto; padding: var(--sp-2); scrollbar-width: none; }
.link-list::-webkit-scrollbar { display: none; }
.link-empty {
font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint);
padding: var(--sp-4) var(--sp-3); text-align: center; letter-spacing: var(--tracking-wide);
}
.link-row {
display: flex; align-items: center; gap: var(--sp-3); width: 100%;
padding: 8px var(--sp-3); border-radius: var(--radius-md); border: none;
background: none; text-align: left; cursor: pointer; transition: background var(--t-fast);
}
.link-row:hover { background: var(--bg-raised); }
.link-row-linked { background: var(--accent-muted) !important; }
:global(.link-thumb) { width: 34px; height: 48px; border-radius: var(--radius-sm); object-fit: cover; flex-shrink: 0; border: 1px solid var(--border-dim); }
.link-info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; min-width: 0; }
.link-manga-title { font-size: var(--text-sm); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.link-source { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
.link-status {
font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide);
color: var(--text-faint); flex-shrink: 0; padding: 2px 8px;
border-radius: var(--radius-sm); border: 1px solid var(--border-dim);
}
.link-row-linked .link-status { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
.markers-panel-overlay { .markers-panel-overlay {
position: fixed; inset: 0; z-index: var(--z-settings); position: fixed; inset: 0; z-index: var(--z-settings);
display: flex; align-items: stretch; justify-content: flex-start; display: flex; align-items: stretch; justify-content: flex-start;
@@ -2,9 +2,10 @@
import { import {
ArrowLeft, BookmarkSimple, ArrowSquareOut, Play, CaretDown, ArrowLeft, BookmarkSimple, ArrowSquareOut, Play, CaretDown,
Eye, ArrowsClockwise, LinkSimpleHorizontalBreak, ChartLineUp, Eye, ArrowsClockwise, LinkSimpleHorizontalBreak, ChartLineUp,
MapPin, Gear, Trash, X, MapPin, Gear, Trash, Image,
} from "phosphor-svelte"; } from "phosphor-svelte";
import Thumbnail from "@shared/manga/Thumbnail.svelte"; import Thumbnail from "@shared/manga/Thumbnail.svelte";
import { resolvedCover } from "@core/cover/coverResolver";
import type { Manga, Chapter, Category } from "@types"; import type { Manga, Chapter, Category } from "@types";
import type { MangaPrefs } from "@store/state.svelte"; import type { MangaPrefs } from "@store/state.svelte";
import { store, setActiveManga, setGenreFilter, setNavPage, setPreviewManga, linkManga, unlinkManga } from "@store/state.svelte"; import { store, setActiveManga, setGenreFilter, setNavPage, setPreviewManga, linkManga, unlinkManga } from "@store/state.svelte";
@@ -38,6 +39,7 @@
onAutoOpen: () => void; onAutoOpen: () => void;
onMarkersToggle: () => void; onMarkersToggle: () => void;
onLinkPickerOpen: () => void; onLinkPickerOpen: () => void;
onCoverPickerOpen: () => void;
togglingLibrary: boolean; togglingLibrary: boolean;
} }
@@ -48,7 +50,7 @@
mangaCategories, mangaCategories,
onRead, onToggleLibrary, onDeleteAll, onMigrateOpen, onRead, onToggleLibrary, onDeleteAll, onMigrateOpen,
onTrackingOpen, onAutoOpen, onMarkersToggle, onLinkPickerOpen, onTrackingOpen, onAutoOpen, onMarkersToggle, onLinkPickerOpen,
togglingLibrary, onCoverPickerOpen, togglingLibrary,
}: Props = $props(); }: Props = $props();
let manageOpen: boolean = $state(false); let manageOpen: boolean = $state(false);
@@ -62,6 +64,10 @@
store.activeManga ? store.getMarkersForManga(store.activeManga.id).length : 0 store.activeManga ? store.getMarkersForManga(store.activeManga.id).length : 0
); );
const hasCoverOverride = $derived(
!!store.settings.mangaPrefs?.[store.activeManga!.id]?.coverUrl
);
function focusOnMount(node: HTMLElement) { node.focus(); } function focusOnMount(node: HTMLElement) { node.focus(); }
</script> </script>
@@ -71,7 +77,7 @@
</button> </button>
<div class="cover-wrap"> <div class="cover-wrap">
<Thumbnail src={store.activeManga!.thumbnailUrl} alt={store.activeManga!.title} class="cover" /> <Thumbnail src={resolvedCover(store.activeManga!.id, store.activeManga!.thumbnailUrl)} alt={store.activeManga!.title} class="cover" />
</div> </div>
{#if loadingManga} {#if loadingManga}
@@ -157,6 +163,9 @@
<LinkSimpleHorizontalBreak size={12} weight={linkedIds.length > 0 ? "fill" : "light"} /> <LinkSimpleHorizontalBreak size={12} weight={linkedIds.length > 0 ? "fill" : "light"} />
Series Link{linkedIds.length > 0 ? ` (${linkedIds.length})` : ""} Series Link{linkedIds.length > 0 ? ` (${linkedIds.length})` : ""}
</button> </button>
<button class="detail-action-btn" class:detail-action-active={hasCoverOverride} onclick={onCoverPickerOpen}>
<Image size={12} weight={hasCoverOverride ? "fill" : "light"} /> Cover Image
</button>
<button class="detail-action-btn" onclick={onTrackingOpen}> <button class="detail-action-btn" onclick={onTrackingOpen}>
<ChartLineUp size={12} weight="light" /> Tracking <ChartLineUp size={12} weight="light" /> Tracking
</button> </button>
@@ -310,4 +319,4 @@
.detail-action-danger { color: var(--color-error); } .detail-action-danger { color: var(--color-error); }
.detail-action-danger:hover:not(:disabled) { background: var(--color-error-bg); border-color: var(--color-error); color: var(--color-error); } .detail-action-danger:hover:not(:disabled) { background: var(--color-error-bg); border-color: var(--color-error); color: var(--color-error); }
.detail-action-danger:disabled { opacity: 0.4; cursor: default; } .detail-action-danger:disabled { opacity: 0.4; cursor: default; }
</style> </style>
@@ -0,0 +1,212 @@
<script lang="ts">
import { X, CaretLeft, CaretRight, CircleNotch } from "phosphor-svelte";
import { setPref } from "@features/series/lib/mangaPrefs";
import { coverCandidatesSync, dedupeByImage } from "@core/cover/coverResolver";
import Thumbnail from "@shared/manga/Thumbnail.svelte";
import type { Manga } from "@types";
interface Props {
manga: Manga;
allManga: Manga[];
onClose: () => void;
}
let { manga, allManga, onClose }: Props = $props();
type MangaWithTitle = Manga & { title: string };
const mangaById = $derived(new Map(allManga.map(m => [m.id, m as MangaWithTitle])));
const syncCandidates = $derived(
coverCandidatesSync(manga.id, manga.title, manga.thumbnailUrl, mangaById)
);
let candidates = $state<typeof syncCandidates>([]);
let hashingDone = $state(false);
let index = $state(0);
$effect(() => {
const snap = syncCandidates;
candidates = [];
hashingDone = false;
index = 0;
dedupeByImage(snap).then(merged => {
candidates = merged;
index = Math.max(0, merged.findIndex(c => c.isActive));
hashingDone = true;
});
});
const current = $derived(candidates[index]);
function prev() { index = (index - 1 + candidates.length) % candidates.length; }
function next() { index = (index + 1) % candidates.length; }
function confirm() {
if (!current) return;
if (current.mangaId === manga.id) setPref(manga.id, "coverUrl", undefined as any);
else setPref(manga.id, "coverUrl", current.url);
onClose();
}
function onKeydown(e: KeyboardEvent) {
if (e.key === "ArrowLeft") { e.preventDefault(); prev(); }
if (e.key === "ArrowRight") { e.preventDefault(); next(); }
if (e.key === "Enter") { e.preventDefault(); confirm(); }
if (e.key === "Escape") onClose();
}
</script>
<div
class="backdrop"
role="button"
tabindex="-1"
aria-label="Close cover picker"
onclick={(e) => { if (e.target === e.currentTarget) onClose(); }}
onkeydown={(e) => e.key === "Escape" && onClose()}
>
<div class="modal" role="dialog" aria-label="Choose cover image" tabindex="-1" onkeydown={onKeydown}>
<div class="header">
<span class="title">Cover Image</span>
<button class="close-btn" onclick={onClose}><X size={14} weight="light" /></button>
</div>
{#if !hashingDone}
<div class="loading">
<CircleNotch size={24} weight="light" class="anim-spin" style="color:var(--text-faint)" />
</div>
{:else}
<div class="stage">
<button class="arrow" onclick={prev} disabled={candidates.length <= 1} aria-label="Previous">
<CaretLeft size={18} weight="bold" />
</button>
<div class="cover-wrap">
{#if current}
<Thumbnail src={current.url} alt="" class="cover-img" />
{/if}
</div>
<button class="arrow" onclick={next} disabled={candidates.length <= 1} aria-label="Next">
<CaretRight size={18} weight="bold" />
</button>
</div>
{#if candidates.length > 1}
<div class="filmstrip">
{#each candidates as c, i (c.url)}
<button
class="film-thumb"
class:film-active={i === index}
onclick={() => index = i}
aria-label="Cover {i + 1}"
>
<Thumbnail src={c.url} alt="" class="film-img" />
</button>
{/each}
</div>
{/if}
{/if}
<div class="footer">
<button class="confirm-btn" onclick={confirm}>Use this cover</button>
</div>
</div>
</div>
<style>
.backdrop {
position: fixed; inset: 0;
background: rgba(0,0,0,0.72);
z-index: calc(var(--z-settings) + 2);
display: flex; align-items: center; justify-content: center;
backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px);
animation: fadeIn 0.1s ease both;
}
.modal {
width: min(380px, calc(100vw - 48px));
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;
outline: none;
}
.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; gap: var(--sp-2);
}
.title {
font-size: var(--text-sm); font-weight: var(--weight-medium);
color: var(--text-secondary); flex: 1;
}
.close-btn {
display: flex; align-items: center; justify-content: center;
width: 26px; height: 26px; border-radius: var(--radius-sm);
color: var(--text-faint); background: none; border: none; cursor: pointer;
transition: color var(--t-base), background var(--t-base);
}
.close-btn:hover { color: var(--text-muted); background: var(--bg-raised); }
.stage {
display: flex; align-items: center; justify-content: center;
gap: var(--sp-3);
padding: var(--sp-5) var(--sp-4) var(--sp-4);
}
.cover-wrap {
flex: 1; max-width: 200px; aspect-ratio: 2/3;
border-radius: var(--radius-md); overflow: hidden;
border: 1px solid var(--border-dim);
background: var(--bg-raised);
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
}
:global(.cover-img) { width: 100%; height: 100%; object-fit: cover; display: block; }
.arrow {
display: flex; align-items: center; justify-content: center;
width: 36px; height: 36px; flex-shrink: 0;
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);
}
.arrow:hover:not(:disabled) { color: var(--text-primary); border-color: var(--border-strong); background: var(--bg-overlay); }
.arrow:disabled { opacity: 0.2; cursor: default; }
.filmstrip {
display: flex; gap: var(--sp-2); align-items: center; justify-content: center;
padding: 0 var(--sp-4) var(--sp-4);
overflow-x: auto; scrollbar-width: none;
}
.filmstrip::-webkit-scrollbar { display: none; }
.film-thumb {
flex-shrink: 0; width: 44px; aspect-ratio: 2/3;
border-radius: var(--radius-sm); overflow: hidden;
border: 2px solid transparent;
background: var(--bg-raised);
cursor: pointer; padding: 0;
opacity: 0.5;
transition: border-color var(--t-base), opacity var(--t-base);
}
.film-thumb:hover { opacity: 0.8; }
.film-active { border-color: var(--accent); opacity: 1; }
:global(.film-img) { width: 100%; height: 100%; object-fit: cover; display: block; }
.footer { padding: 0 var(--sp-4) var(--sp-4); flex-shrink: 0; }
.confirm-btn {
width: 100%; padding: 9px;
border-radius: var(--radius-md);
background: var(--accent); border: 1px solid var(--accent);
color: var(--accent-contrast, #fff);
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
cursor: pointer;
transition: opacity var(--t-base);
}
.confirm-btn:hover { opacity: 0.88; }
.loading { display: flex; align-items: center; justify-content: center; padding: var(--sp-10) 0; }
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
@keyframes scaleIn { from { opacity: 0; transform: scale(0.97) } to { opacity: 1; transform: scale(1) } }
@keyframes pulse { 0%, 100% { opacity: 0.4 } 50% { opacity: 1 } }
</style>
@@ -0,0 +1,237 @@
<script lang="ts">
import { X, LinkSimple, LinkBreak, Sparkle } from "phosphor-svelte";
import Thumbnail from "@shared/manga/Thumbnail.svelte";
import { store, linkManga, unlinkManga } from "@store/state.svelte";
import type { Manga } from "@types";
interface Props {
manga: Manga;
allManga: Manga[];
onClose: () => void;
}
let { manga, allManga, onClose }: Props = $props();
let query = $state("");
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;
}
const linkedIds = $derived(store.settings.mangaLinks?.[manga.id] ?? []);
const others = $derived(allManga.filter(m => m.id !== manga.id));
const suggestions = $derived.by(() => {
if (linkedIds.length === others.length) return [];
return others
.filter(m => !linkedIds.includes(m.id))
.map(m => ({ manga: m, score: titleSimilarity(manga.title, m.title) }))
.filter(r => r.score >= 0.65)
.sort((a, b) => b.score - a.score)
.slice(0, 8);
});
const searchResults = $derived.by(() => {
const q = query.trim().toLowerCase();
if (!q) return [];
return others
.filter(m => m.title.toLowerCase().includes(q))
.slice(0, 30);
});
const linked = $derived(
others.filter(m => linkedIds.includes(m.id))
);
function toggle(other: Manga) {
if (linkedIds.includes(other.id)) unlinkManga(manga.id, other.id);
else linkManga(manga.id, other.id);
}
</script>
<div
class="backdrop"
role="presentation"
onclick={(e) => { if (e.target === e.currentTarget) onClose(); }}
onkeydown={(e) => e.key === "Escape" && onClose()}
>
<div class="modal" role="dialog" aria-label="Link as same series">
<div class="header">
<span class="title">Link as same series</span>
<button class="close-btn" onclick={onClose}><X size={14} weight="light" /></button>
</div>
<p class="hint">Linked entries share covers and are merged in search. Click a linked entry to unlink.</p>
<div class="search-wrap">
<input class="search" placeholder="Search your library…" bind:value={query} />
</div>
<div class="list">
{#if query.trim()}
{#if searchResults.length === 0}
<p class="empty">No results</p>
{:else}
{#each searchResults as m (m.id)}
{@const isLinked = linkedIds.includes(m.id)}
<button class="row" class:row-linked={isLinked} onclick={() => toggle(m)}>
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="thumb" />
<div class="info">
<span class="manga-title">{m.title}</span>
{#if m.source?.displayName}<span class="source">{m.source.displayName}</span>{/if}
</div>
<span class="row-icon">{#if isLinked}<LinkBreak size={14} />{:else}<LinkSimple size={14} />{/if}</span>
</button>
{/each}
{/if}
{:else}
{#if linked.length > 0}
<p class="section-label">Linked</p>
{#each linked as m (m.id)}
<button class="row row-linked" onclick={() => toggle(m)}>
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="thumb" />
<div class="info">
<span class="manga-title">{m.title}</span>
{#if m.source?.displayName}<span class="source">{m.source.displayName}</span>{/if}
</div>
<span class="row-icon"><LinkBreak size={14} /></span>
</button>
{/each}
{/if}
{#if suggestions.length > 0}
<p class="section-label">
<Sparkle size={10} weight="fill" style="color:var(--accent)" />
Suggested
</p>
{#each suggestions as { manga: m, score } (m.id)}
<button class="row" onclick={() => toggle(m)}>
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="thumb" />
<div class="info">
<span class="manga-title">{m.title}</span>
{#if m.source?.displayName}<span class="source">{m.source.displayName}</span>{/if}
</div>
<span class="sim-bar">
<span class="sim-fill" style="width:{Math.round(score * 100)}%"></span>
</span>
<span class="row-icon"><LinkSimple size={14} /></span>
</button>
{/each}
{/if}
{#if linked.length === 0 && suggestions.length === 0}
<p class="empty">No suggestions — search your library above.</p>
{/if}
{/if}
</div>
</div>
</div>
<style>
.backdrop {
position: fixed; inset: 0;
background: rgba(0,0,0,0.72);
z-index: var(--z-settings);
display: flex; align-items: center; justify-content: center;
backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px);
animation: fadeIn 0.12s ease both;
}
.modal {
width: min(460px, calc(100vw - 48px));
max-height: 70vh;
display: flex; flex-direction: column;
background: var(--bg-surface);
border: 1px solid var(--border-base); border-radius: var(--radius-xl);
overflow: hidden;
box-shadow: 0 24px 64px rgba(0,0,0,0.6);
animation: scaleIn 0.14s ease both;
}
.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;
}
.title {
font-size: var(--text-sm); font-weight: var(--weight-medium);
color: var(--text-secondary);
}
.close-btn {
display: flex; align-items: center; justify-content: center;
width: 28px; height: 28px; border-radius: var(--radius-sm);
color: var(--text-faint); background: none; border: none; cursor: pointer;
transition: color var(--t-base), background var(--t-base);
}
.close-btn:hover { color: var(--text-muted); background: var(--bg-raised); }
.hint {
font-family: var(--font-ui); font-size: var(--text-xs);
color: var(--text-faint); letter-spacing: var(--tracking-wide);
line-height: var(--leading-snug);
padding: var(--sp-3) var(--sp-5) 0;
flex-shrink: 0;
}
.search-wrap {
padding: var(--sp-3) var(--sp-4);
border-bottom: 1px solid var(--border-dim);
flex-shrink: 0;
}
.search {
width: 100%; background: var(--bg-raised);
border: 1px solid var(--border-dim); border-radius: var(--radius-md);
padding: 6px 10px; color: var(--text-primary);
font-size: var(--text-sm); outline: none;
transition: border-color var(--t-base);
}
.search:focus { border-color: var(--border-strong); }
.list {
flex: 1; overflow-y: auto;
padding: var(--sp-2);
scrollbar-width: none;
}
.list::-webkit-scrollbar { display: none; }
.section-label {
display: flex; align-items: center; gap: var(--sp-1);
font-family: var(--font-ui); font-size: 9px;
color: var(--text-faint); letter-spacing: var(--tracking-wider);
text-transform: uppercase;
padding: var(--sp-3) var(--sp-3) var(--sp-1);
}
.empty {
font-family: var(--font-ui); font-size: var(--text-xs);
color: var(--text-faint); padding: var(--sp-4) var(--sp-3);
text-align: center; letter-spacing: var(--tracking-wide);
}
.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);
}
.row:hover { background: var(--bg-raised); }
.row-linked { background: var(--accent-muted) !important; }
:global(.thumb) { width: 34px; height: 48px; border-radius: var(--radius-sm); object-fit: cover; flex-shrink: 0; border: 1px solid var(--border-dim); }
.info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; min-width: 0; }
.manga-title { font-size: var(--text-sm); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.source { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
.sim-bar {
width: 36px; height: 3px;
background: var(--bg-overlay); border-radius: var(--radius-full);
overflow: hidden; flex-shrink: 0;
}
.sim-fill { display: block; height: 100%; background: var(--accent); border-radius: var(--radius-full); }
.row-icon { display: flex; align-items: center; color: var(--text-faint); flex-shrink: 0; opacity: 0.6; transition: opacity var(--t-base); }
.row:hover .row-icon { opacity: 1; }
.row-linked .row-icon { color: var(--accent-fg); opacity: 1; }
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
@keyframes scaleIn { from { opacity: 0; transform: scale(0.97) } to { opacity: 1; transform: scale(1) } }
</style>
+301 -212
View File
@@ -1,13 +1,12 @@
<script lang="ts"> <script lang="ts">
import { CircleNotch, X, MagnifyingGlass, ArrowSquareOut, Lock, LockOpen, ArrowsClockwise, ArrowLineDown } from "phosphor-svelte"; import { CircleNotch, X, MagnifyingGlass, ArrowSquareOut, Lock, LockOpen, ArrowsClockwise, ArrowLineDown, CalendarBlank } from "phosphor-svelte";
import { gql } from "@api/client"; import { gql } from "@api/client";
import Thumbnail from "@shared/manga/Thumbnail.svelte"; import Thumbnail from "@shared/manga/Thumbnail.svelte";
import { GET_TRACKERS, SEARCH_TRACKER } from "@api/queries/tracking"; import { GET_TRACKERS, GET_MANGA_TRACK_RECORDS, SEARCH_TRACKER } from "@api/queries/tracking";
import { BIND_TRACK, UPDATE_TRACK, UNBIND_TRACK } from "@api/mutations/tracking"; import { BIND_TRACK, UPDATE_TRACK, UNBIND_TRACK, FETCH_TRACK } from "@api/mutations/tracking";
import { GET_CHAPTERS } from "@api/queries/chapters"; import { GET_CHAPTERS } from "@api/queries/chapters";
import { addToast, store } from "@store/state.svelte"; import { addToast, store } from "@store/state.svelte";
import { trackingState } from "@features/tracking/store/trackingState.svelte"; import { syncBackFromTracker } from "@features/tracking/lib/trackingSync";
import { syncBackFromTracker } from "@features/tracking/lib/trackingSync";
import type { Tracker, TrackRecord, TrackSearch } from "@types"; import type { Tracker, TrackRecord, TrackSearch } from "@types";
import type { Chapter } from "@types/index"; import type { Chapter } from "@types/index";
@@ -20,7 +19,8 @@
type TabId = "records" | number; type TabId = "records" | number;
let trackers: Tracker[] = $state([]); let trackers: Tracker[] = $state([]);
let loadingTrackers: boolean = $state(true); let records: TrackRecord[] = $state([]);
let loading: boolean = $state(true);
let activeTab: TabId = $state("records"); let activeTab: TabId = $state("records");
let searchQuery: string = $state(""); let searchQuery: string = $state("");
@@ -31,24 +31,38 @@
let binding: boolean = $state(false); let binding: boolean = $state(false);
let updatingRecord: number | null = $state(null); let updatingRecord: number | null = $state(null);
let syncing: number | null = $state(null); let syncing: number | null = $state(null);
let editingChapter: number | null = $state(null);
let chapterDraft: number = $state(0);
let applyingRecord: number | null = $state(null); let applyingRecord: number | null = $state(null);
const records = $derived(trackingState.records); let editingId: number | null = $state(null);
const loading = $derived(trackingState.loading || loadingTrackers); let chapterDraft: number = $state(0);
let startDraft: string = $state("");
let finishDraft: string = $state("");
let confirmUnbindId: number | null = $state(null);
const loggedInTrackers = $derived(trackers.filter(t => t.isLoggedIn)); const loggedInTrackers = $derived(trackers.filter(t => t.isLoggedIn));
function autoFocus(node: HTMLElement) { setTimeout(() => node.focus(), 50); } function autoFocus(node: HTMLElement) { setTimeout(() => node.focus(), 50); }
$effect(() => { async function load() {
loadingTrackers = true; loading = true;
gql<{ trackers: { nodes: Tracker[] } }>(GET_TRACKERS) try {
.then(r => { trackers = r.trackers.nodes; }) const [tRes, rRes] = await Promise.all([
.catch(e => addToast({ kind: "error", title: "Failed to load trackers", body: e?.message })) gql<{ trackers: { nodes: Tracker[] } }>(GET_TRACKERS),
.finally(() => { loadingTrackers = false; }); gql<{ manga: { trackRecords: { nodes: TrackRecord[] } } }>(GET_MANGA_TRACK_RECORDS, { mangaId }),
trackingState.loadForManga(mangaId); ]);
}); trackers = tRes.trackers.nodes;
records = rRes.manga.trackRecords.nodes;
if (store.settings.trackerSyncBack && records.length > 0) {
await Promise.all(records.map(r => applyToLibrary(r, true)));
}
} catch (e: any) {
addToast({ kind: "error", title: "Failed to load tracking", body: e?.message });
} finally {
loading = false;
}
}
$effect(() => { load(); });
$effect(() => { $effect(() => {
const tab = activeTab; const tab = activeTab;
@@ -91,12 +105,19 @@
if (typeof activeTab !== "number") return; if (typeof activeTab !== "number") return;
binding = true; binding = true;
try { try {
const existing = recordFor(activeTab);
if (existing) {
await gql(UNBIND_TRACK, { recordId: existing.id });
records = records.filter(r => r.id !== existing.id);
}
const res = await gql<{ bindTrack: { trackRecord: TrackRecord } }>( const res = await gql<{ bindTrack: { trackRecord: TrackRecord } }>(
BIND_TRACK, { mangaId, trackerId: activeTab, remoteId: result.remoteId } BIND_TRACK, { mangaId, trackerId: activeTab, remoteId: result.remoteId }
); );
trackingState.patchRecord(res.bindTrack.trackRecord); const newRecord = res.bindTrack.trackRecord;
records = [...records, newRecord];
activeTab = "records"; activeTab = "records";
addToast({ kind: "success", title: "Now tracking", body: result.title }); addToast({ kind: "success", title: "Now tracking", body: result.title });
if (store.settings.trackerSyncBack) await applyToLibrary(newRecord, true);
} catch (e: any) { } catch (e: any) {
addToast({ kind: "error", title: "Failed to bind", body: e?.message }); addToast({ kind: "error", title: "Failed to bind", body: e?.message });
} finally { } finally {
@@ -106,9 +127,10 @@
async function unbind(record: TrackRecord) { async function unbind(record: TrackRecord) {
updatingRecord = record.id; updatingRecord = record.id;
confirmUnbindId = null;
try { try {
await gql(UNBIND_TRACK, { recordId: record.id }); await gql(UNBIND_TRACK, { recordId: record.id });
trackingState.removeRecord(record.id); records = records.filter(r => r.id !== record.id);
addToast({ kind: "info", title: "Unlinked from " + trackerFor(record.trackerId)?.name }); addToast({ kind: "info", title: "Unlinked from " + trackerFor(record.trackerId)?.name });
} catch (e: any) { } catch (e: any) {
addToast({ kind: "error", title: "Failed to unlink", body: e?.message }); addToast({ kind: "error", title: "Failed to unlink", body: e?.message });
@@ -117,69 +139,67 @@
} }
} }
function patchRecord(updated: Partial<TrackRecord> & { id: number }) {
records = records.map(r => r.id === updated.id ? { ...r, ...updated } : r);
}
async function updateStatus(record: TrackRecord, status: number) { async function updateStatus(record: TrackRecord, status: number) {
updatingRecord = record.id; updatingRecord = record.id;
try { try {
const res = await gql<{ updateTrack: { trackRecord: TrackRecord } }>(UPDATE_TRACK, { recordId: record.id, status }); const res = await gql<{ updateTrack: { trackRecord: TrackRecord } }>(UPDATE_TRACK, { recordId: record.id, status });
trackingState.patchRecord(res.updateTrack.trackRecord); patchRecord(res.updateTrack.trackRecord);
} catch (e: any) { } catch (e: any) {
addToast({ kind: "error", title: "Update failed", body: e?.message }); addToast({ kind: "error", title: "Update failed", body: e?.message });
} finally { } finally { updatingRecord = null; }
updatingRecord = null;
}
} }
async function updateScore(record: TrackRecord, scoreString: string) { async function updateScore(record: TrackRecord, scoreString: string) {
updatingRecord = record.id; updatingRecord = record.id;
try { try {
const res = await gql<{ updateTrack: { trackRecord: TrackRecord } }>(UPDATE_TRACK, { recordId: record.id, scoreString }); const res = await gql<{ updateTrack: { trackRecord: TrackRecord } }>(UPDATE_TRACK, { recordId: record.id, scoreString });
trackingState.patchRecord(res.updateTrack.trackRecord); patchRecord(res.updateTrack.trackRecord);
} catch (e: any) { } catch (e: any) {
addToast({ kind: "error", title: "Update failed", body: e?.message }); addToast({ kind: "error", title: "Update failed", body: e?.message });
} finally { } finally { updatingRecord = null; }
updatingRecord = null;
}
} }
async function togglePrivate(record: TrackRecord) { async function togglePrivate(record: TrackRecord) {
updatingRecord = record.id; updatingRecord = record.id;
try { try {
const res = await gql<{ updateTrack: { trackRecord: TrackRecord } }>(UPDATE_TRACK, { recordId: record.id, private: !record.private }); const res = await gql<{ updateTrack: { trackRecord: TrackRecord } }>(UPDATE_TRACK, { recordId: record.id, private: !record.private });
trackingState.patchRecord(res.updateTrack.trackRecord); patchRecord(res.updateTrack.trackRecord);
} catch (e: any) { } catch (e: any) {
addToast({ kind: "error", title: "Update failed", body: e?.message }); addToast({ kind: "error", title: "Update failed", body: e?.message });
} finally { } finally { updatingRecord = null; }
updatingRecord = null;
}
} }
async function syncRecord(record: TrackRecord) { async function syncRecord(record: TrackRecord) {
syncing = record.id; syncing = record.id;
try { try {
const fresh = await trackingState.syncRecordFromRemote(record.id); const res = await gql<{ fetchTrack: { trackRecord: TrackRecord } }>(FETCH_TRACK, { recordId: record.id });
if (fresh) addToast({ kind: "success", title: "Synced from tracker" }); patchRecord(res.fetchTrack.trackRecord);
addToast({ kind: "success", title: "Synced from tracker" });
} catch (e: any) { } catch (e: any) {
addToast({ kind: "error", title: "Sync failed", body: e?.message }); addToast({ kind: "error", title: "Sync failed", body: e?.message });
} finally { } finally { syncing = null; }
syncing = null;
}
} }
function openChapterEditor(record: TrackRecord) { function openChapterEditor(record: TrackRecord) {
editingChapter = record.id; editingId = record.id;
chapterDraft = record.lastChapterRead; chapterDraft = record.lastChapterRead;
startDraft = record.startDate ?? "";
finishDraft = record.finishDate ?? "";
} }
function cancelChapterEditor() { editingChapter = null; } function cancelEditor() { editingId = null; }
async function applyToLibrary(record: TrackRecord) { async function applyToLibrary(record: TrackRecord, silent = false) {
applyingRecord = record.id; applyingRecord = record.id;
try { try {
const chapRes = await gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId }); const chapRes = await gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId });
const prefs = store.settings.mangaPrefs?.[mangaId] ?? {}; const prefs = store.settings.mangaPrefs?.[mangaId] ?? {};
const marked = await syncBackFromTracker( const marked = await syncBackFromTracker(
[record], [record], chapRes.chapters.nodes,
chapRes.chapters.nodes,
{ {
threshold: store.settings.trackerSyncBackThreshold ?? null, threshold: store.settings.trackerSyncBackThreshold ?? null,
respectScanlatorFilter: store.settings.trackerRespectScanlatorFilter ?? true, respectScanlatorFilter: store.settings.trackerRespectScanlatorFilter ?? true,
@@ -187,35 +207,44 @@
}, },
(query, vars) => gql(query, vars), (query, vars) => gql(query, vars),
); );
if (marked.length > 0) { if (!silent) {
addToast({ kind: "success", title: `${marked.length} chapter${marked.length !== 1 ? "s" : ""} marked read` }); if (marked.length > 0) addToast({ kind: "success", title: `${marked.length} chapter${marked.length !== 1 ? "s" : ""} marked read` });
} else { else addToast({ kind: "info", title: "Already up to date" });
addToast({ kind: "info", title: "Already up to date" });
} }
} catch (e: any) { } catch (e: any) {
addToast({ kind: "error", title: "Apply failed", body: e?.message }); addToast({ kind: "error", title: "Apply failed", body: e?.message });
} finally { } finally { applyingRecord = null; }
applyingRecord = null;
}
} }
async function submitChapter(record: TrackRecord) { async function submitChapter(record: TrackRecord) {
const val = Math.max(0, chapterDraft); const tracker = trackerFor(record.trackerId);
editingChapter = null; const val = Math.max(0, chapterDraft);
if (val === record.lastChapterRead) return; const sd = tracker?.supportsReadingDates ? (startDraft.trim() || undefined) : undefined;
const fd = tracker?.supportsReadingDates ? (finishDraft.trim() || undefined) : undefined;
editingId = null;
const chapterChanged = val !== record.lastChapterRead;
const startChanged = sd !== (record.startDate ?? undefined);
const finishChanged = fd !== (record.finishDate ?? undefined);
if (!chapterChanged && !startChanged && !finishChanged) return;
updatingRecord = record.id; updatingRecord = record.id;
try { try {
const res = await gql<{ updateTrack: { trackRecord: TrackRecord } }>(UPDATE_TRACK, { recordId: record.id, lastChapterRead: val }); const res = await gql<{ updateTrack: { trackRecord: TrackRecord } }>(UPDATE_TRACK, {
trackingState.patchRecord(res.updateTrack.trackRecord); recordId: record.id,
lastChapterRead: chapterChanged ? val : undefined,
startDate: sd,
finishDate: fd,
});
patchRecord(res.updateTrack.trackRecord);
} catch (e: any) { } catch (e: any) {
addToast({ kind: "error", title: "Update failed", body: e?.message }); addToast({ kind: "error", title: "Update failed", body: e?.message });
} finally { } finally { updatingRecord = null; }
updatingRecord = null;
}
} }
</script> </script>
<svelte:window onkeydown={(e) => e.key === "Escape" && onClose()} /> <svelte:window onkeydown={(e) => { if (e.key === "Escape") { if (confirmUnbindId !== null) { confirmUnbindId = null; } else if (editingId !== null) { editingId = null; } else { onClose(); } } }} />
<div class="backdrop" role="presentation" onclick={(e) => { if (e.target === e.currentTarget) onClose(); }}> <div class="backdrop" role="presentation" onclick={(e) => { if (e.target === e.currentTarget) onClose(); }}>
<div class="modal" role="dialog" aria-label="Tracking"> <div class="modal" role="dialog" aria-label="Tracking">
@@ -225,13 +254,12 @@
<span class="modal-title">Tracking</span> <span class="modal-title">Tracking</span>
<span class="modal-subtitle">{mangaTitle}</span> <span class="modal-subtitle">{mangaTitle}</span>
</div> </div>
<button class="close-btn" onclick={onClose}><X size={15} weight="light" /></button> <button class="close-btn" onclick={onClose}><X size={14} weight="light" /></button>
</div> </div>
{#if loading} {#if loading}
<div class="state-body"> <div class="state-body">
<CircleNotch size={20} weight="light" class="anim-spin" style="color:var(--text-faint)" /> <CircleNotch size={18} weight="light" class="anim-spin" style="color:var(--text-faint)" />
<span class="state-label">Loading…</span>
</div> </div>
{:else if loggedInTrackers.length === 0} {:else if loggedInTrackers.length === 0}
@@ -260,119 +288,140 @@
<div class="tab-body"> <div class="tab-body">
{#if records.length === 0} {#if records.length === 0}
<div class="state-body"> <div class="state-body">
<p class="state-text">Not tracking this manga yet.</p> <p class="state-text">Not tracking yet.</p>
<p class="state-hint">Click a tracker tab above to search and add it.</p> <p class="state-hint">Click a tracker tab above to search and link it.</p>
</div> </div>
{:else} {:else}
{#each records as record (record.id)} {#each records as record (record.id)}
{@const tracker = trackerFor(record.trackerId)} {@const tracker = trackerFor(record.trackerId)}
{@const isBusy = updatingRecord === record.id} {@const isBusy = updatingRecord === record.id}
{@const isEdit = editingId === record.id}
{@const pct = record.totalChapters > 0 ? Math.min(100, (record.lastChapterRead / record.totalChapters) * 100) : null}
{@const canUnlink = !tracker || tracker.supportsTrackDeletion !== false}
<div class="record-card" class:record-busy={isBusy}> <div class="record-card" class:record-busy={isBusy}>
<div class="record-head"> <div class="record-head">
<div class="record-source"> <div class="record-source">
{#if tracker}<Thumbnail src={tracker.icon} alt={tracker.name} class="record-tracker-icon" />{/if} {#if tracker}<Thumbnail src={tracker.icon} alt={tracker.name} class="record-icon" />{/if}
<span class="record-source-name">{tracker?.name ?? "Tracker"}</span> <span class="record-source-name">{tracker?.name ?? "Tracker"}</span>
{#if record.remoteUrl}
<a href={record.remoteUrl} target="_blank" rel="noreferrer" class="record-external" title="Open on {tracker?.name}">
<ArrowSquareOut size={10} weight="light" />
</a>
{/if}
</div> </div>
<div class="record-head-actions"> <div class="record-actions">
{#if tracker?.supportsPrivateTracking} {#if tracker?.supportsPrivateTracking}
<button <button
class="record-icon-btn" class="pill-btn"
class:icon-active={record.private} class:pill-btn-on={record.private}
title={record.private ? "Private — click to make public" : "Public"} title={record.private ? "Private" : "Public"}
disabled={isBusy} disabled={isBusy}
onclick={() => togglePrivate(record)} onclick={() => togglePrivate(record)}
> >
{#if record.private}<Lock size={11} weight="fill" />{:else}<LockOpen size={11} weight="light" />{/if} {#if record.private}<Lock size={9} weight="fill" />{:else}<LockOpen size={9} weight="light" />{/if}
{record.private ? "Private" : "Public"}
</button> </button>
{/if} {/if}
<button class="record-icon-btn" title="Sync from tracker" disabled={syncing === record.id} onclick={() => syncRecord(record)}> <button class="icon-action" title="Sync from tracker" disabled={syncing === record.id} onclick={() => syncRecord(record)}>
<ArrowsClockwise size={11} weight="light" class={syncing === record.id ? "anim-spin" : ""} /> <ArrowsClockwise size={12} weight="light" class={syncing === record.id ? "anim-spin" : ""} />
</button> </button>
{#if store.settings.trackerSyncBack} {#if store.settings.trackerSyncBack}
<button class="record-icon-btn" title="Apply tracker progress to library" disabled={applyingRecord === record.id} onclick={() => applyToLibrary(record)}> <button class="icon-action" title="Apply to library" disabled={applyingRecord === record.id} onclick={() => applyToLibrary(record)}>
<ArrowLineDown size={11} weight="light" class={applyingRecord === record.id ? "anim-spin" : ""} /> <ArrowLineDown size={12} weight="light" class={applyingRecord === record.id ? "anim-spin" : ""} />
</button> </button>
{/if} {/if}
<button class="record-icon-btn icon-danger" title="Unlink" disabled={isBusy} onclick={() => unbind(record)}> {#if canUnlink}
<X size={11} weight="bold" /> <button class="icon-action icon-action-danger" title="Unlink" disabled={isBusy} onclick={() => confirmUnbindId = record.id}>
<X size={11} weight="bold" />
</button>
{/if}
</div>
</div>
<div class="record-body">
<div class="record-selects">
<select class="field-select" value={record.status} disabled={isBusy}
onchange={(e) => updateStatus(record, parseInt((e.target as HTMLSelectElement).value))}>
{#each (tracker?.statuses ?? []) as s}
<option value={s.value}>{s.name}</option>
{/each}
</select>
{#if (tracker?.scores ?? []).length > 0}
<select class="field-select score-select" value={record.displayScore} disabled={isBusy}
onchange={(e) => updateScore(record, (e.target as HTMLSelectElement).value)}>
{#each (tracker?.scores ?? []) as s}
<option value={s}> {s}</option>
{/each}
</select>
{/if}
</div>
{#if isEdit}
<div class="editor">
<div class="editor-row">
<span class="editor-label">Chapter read</span>
<div class="editor-input-row">
<input
type="number" class="editor-input"
min="0" max={record.totalChapters > 0 ? record.totalChapters : undefined}
step="0.5" bind:value={chapterDraft}
onkeydown={(e) => { if (e.key === "Enter") submitChapter(record); if (e.key === "Escape") cancelEditor(); }}
use:autoFocus
/>
{#if record.totalChapters > 0}
<span class="editor-total">/ {record.totalChapters}</span>
{/if}
</div>
</div>
{#if record.totalChapters > 0}
<input type="range" class="chapter-slider" min="0" max={record.totalChapters} step="1" bind:value={chapterDraft} />
{/if}
{#if tracker?.supportsReadingDates}
<div class="date-row">
<div class="date-field">
<CalendarBlank size={11} weight="light" class="date-icon" />
<span class="editor-label">Started</span>
<input type="date" class="date-input" bind:value={startDraft} />
</div>
<div class="date-field">
<CalendarBlank size={11} weight="light" class="date-icon" />
<span class="editor-label">Finished</span>
<input type="date" class="date-input" bind:value={finishDraft} />
</div>
</div>
{/if}
<div class="editor-actions">
<button class="editor-cancel" onclick={cancelEditor}>Cancel</button>
<button class="editor-save" onclick={() => submitChapter(record)}>Save</button>
</div>
</div>
{:else}
<button class="progress-row" onclick={() => openChapterEditor(record)} disabled={isBusy}>
<div class="progress-labels">
<span class="progress-text">
{#if record.totalChapters > 0}Ch. {record.lastChapterRead} / {record.totalChapters}
{:else if record.lastChapterRead > 0}Ch. {record.lastChapterRead} read
{:else}Set progress…{/if}
</span>
{#if record.startDate || record.finishDate}
<span class="progress-dates">
{#if record.startDate}{record.startDate}{/if}
{#if record.startDate && record.finishDate}{/if}
{#if record.finishDate}{record.finishDate}{/if}
</span>
{/if}
</div>
<span class="progress-edit-hint">Edit</span>
</button> </button>
</div> {#if pct !== null}
</div> <div class="progress-track">
<div class="progress-fill" style="width:{pct}%"></div>
{#if record.remoteUrl}
<a href={record.remoteUrl} target="_blank" rel="noreferrer" class="record-title">
{record.title} <ArrowSquareOut size={10} weight="light" />
</a>
{:else}
<span class="record-title-plain">{record.title}</span>
{/if}
<div class="record-selects">
<select class="record-select record-select-status" value={record.status} disabled={isBusy}
onchange={(e) => updateStatus(record, parseInt((e.target as HTMLSelectElement).value))}>
{#each (tracker?.statuses ?? []) as s}
<option value={s.value}>{s.name}</option>
{/each}
</select>
<select class="record-select record-select-score" value={record.displayScore} disabled={isBusy}
onchange={(e) => updateScore(record, (e.target as HTMLSelectElement).value)}>
{#each (tracker?.scores ?? []) as s}
<option value={s}> {s}</option>
{/each}
</select>
</div>
{#if editingChapter === record.id}
<div class="chapter-editor">
<div class="chapter-editor-top">
<span class="chapter-editor-label">Chapter read</span>
<div class="chapter-input-wrap">
<input
type="number" class="chapter-input"
min="0" max={record.totalChapters > 0 ? record.totalChapters : undefined}
step="0.5" bind:value={chapterDraft}
onkeydown={(e) => { if (e.key === "Enter") submitChapter(record); if (e.key === "Escape") cancelChapterEditor(); }}
use:autoFocus
/>
{#if record.totalChapters > 0}
<span class="chapter-total">/ {record.totalChapters}</span>
{/if}
</div>
</div>
{#if record.totalChapters > 0}
<input type="range" class="chapter-slider" min="0" max={record.totalChapters} step="1" bind:value={chapterDraft} />
{/if}
<div class="chapter-editor-actions">
<button class="chapter-cancel-btn" onclick={cancelChapterEditor}>Cancel</button>
<button class="chapter-save-btn" onclick={() => submitChapter(record)}>Save</button>
</div>
</div>
{:else}
<div class="record-progress clickable" role="button" tabindex="0"
onclick={() => openChapterEditor(record)}
onkeydown={(e) => e.key === "Enter" && openChapterEditor(record)}
title="Click to edit"
>
<div class="record-progress-header">
<span class="record-progress-label">
{#if record.totalChapters > 0}
Ch. {record.lastChapterRead} / {record.totalChapters}
{:else if record.lastChapterRead > 0}
Ch. {record.lastChapterRead} read
{:else}
Set chapter…
{/if}
</span>
<span class="edit-hint">Edit</span>
</div>
{#if record.totalChapters > 0}
<div class="record-progress-track">
<div class="record-progress-fill" style="width:{Math.min(100,(record.lastChapterRead/record.totalChapters)*100)}%"></div>
</div> </div>
{/if} {/if}
</div> {/if}
{/if} </div>
</div> </div>
{/each} {/each}
@@ -392,7 +441,7 @@
onkeydown={(e) => e.key === "Enter" && doSearch(activeTab as number, searchQuery)} onkeydown={(e) => e.key === "Enter" && doSearch(activeTab as number, searchQuery)}
use:autoFocus use:autoFocus
/> />
{#if searching}<CircleNotch size={13} weight="light" class="anim-spin search-icon" />{/if} {#if searching}<CircleNotch size={13} weight="light" class="anim-spin" style="color:var(--text-faint)" />{/if}
</div> </div>
<div class="search-results"> <div class="search-results">
@@ -429,7 +478,7 @@
{/if} {/if}
</div> </div>
<span class="result-action" class:result-action-on={isBound}> <span class="result-action" class:result-action-on={isBound}>
{isBound ? "✓ Tracking" : "Track"} {isBound ? "✓ Linked" : "Link"}
</span> </span>
</button> </button>
{/each} {/each}
@@ -441,41 +490,59 @@
</div> </div>
</div> </div>
{#if confirmUnbindId !== null}
{@const rec = records.find(r => r.id === confirmUnbindId)}
{@const trk = rec ? trackerFor(rec.trackerId) : null}
<div class="confirm-backdrop" role="button" tabindex="-1" aria-label="Cancel" onclick={() => confirmUnbindId = null} onkeydown={(e) => { if (e.key === 'Escape') confirmUnbindId = null; }}>
<div class="confirm-modal" role="dialog" tabindex="-1" onclick={(e) => e.stopPropagation()} onkeydown={(e) => e.stopPropagation()}>
<p class="confirm-title">Unlink from {trk?.name ?? "tracker"}?</p>
<p class="confirm-body">Your progress on {trk?.name} is unaffected.</p>
<div class="confirm-row">
<button class="confirm-cancel" onclick={() => confirmUnbindId = null}>Cancel</button>
<button class="confirm-ok" onclick={() => rec && unbind(rec)}>Unlink</button>
</div>
</div>
</div>
{/if}
<style> <style>
.backdrop { .backdrop {
position: fixed; inset: 0; background: rgba(0,0,0,0.72); position: fixed; inset: 0; background: rgba(0,0,0,0.68);
z-index: var(--z-settings); z-index: var(--z-settings);
display: flex; align-items: center; justify-content: center; display: flex; align-items: center; justify-content: center;
backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px); backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px);
animation: fadeIn 0.12s ease both; animation: fadeIn 0.12s ease both;
} }
.modal { .modal {
width: min(560px, calc(100vw - 48px)); width: min(520px, calc(100vw - 40px));
max-height: min(660px, calc(100vh - 80px)); max-height: min(640px, calc(100vh - 72px));
display: flex; flex-direction: column; display: flex; flex-direction: column;
background: var(--bg-surface); border: 1px solid var(--border-base); background: var(--bg-surface); border: 1px solid var(--border-base);
border-radius: var(--radius-xl); overflow: hidden; border-radius: var(--radius-xl); overflow: hidden;
box-shadow: 0 0 0 1px var(--border-dim), 0 24px 64px rgba(0,0,0,0.6); box-shadow: 0 0 0 1px rgba(255,255,255,0.04) inset, 0 24px 64px rgba(0,0,0,0.6);
animation: scaleIn 0.15s ease both; animation: scaleIn 0.15s 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-header {
.header-left { display: flex; flex-direction: column; gap: 2px; min-width: 0; } display: flex; align-items: center; justify-content: space-between;
.modal-title { font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-primary); letter-spacing: var(--tracking-tight); } padding: var(--sp-4) var(--sp-4) var(--sp-4) var(--sp-5);
border-bottom: 1px solid var(--border-dim); flex-shrink: 0;
}
.header-left { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
.modal-title { font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-primary); letter-spacing: var(--tracking-tight); }
.modal-subtitle { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .modal-subtitle { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.close-btn { display: flex; align-items: center; justify-content: center; width: 26px; height: 26px; border-radius: var(--radius-sm); color: var(--text-faint); background: none; border: none; cursor: pointer; transition: color var(--t-base), background var(--t-base); flex-shrink: 0; } .close-btn { display: flex; align-items: center; justify-content: center; width: 26px; height: 26px; border-radius: var(--radius-sm); color: var(--text-faint); background: none; border: none; cursor: pointer; flex-shrink: 0; transition: color var(--t-base), background var(--t-base); }
.close-btn:hover { color: var(--text-muted); background: var(--bg-raised); } .close-btn:hover { color: var(--text-muted); background: var(--bg-raised); }
.state-body { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: var(--sp-2); padding: var(--sp-10) var(--sp-5); flex: 1; } .state-body { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: var(--sp-2); padding: var(--sp-10) var(--sp-5); flex: 1; }
.state-label { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
.state-text { font-size: var(--text-sm); color: var(--text-muted); } .state-text { font-size: var(--text-sm); color: var(--text-muted); }
.state-hint { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); text-align: center; } .state-hint { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); text-align: center; }
.tabs { display: flex; align-items: center; gap: 1px; padding: 0 var(--sp-4); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; overflow-x: auto; scrollbar-width: none; } .tabs { display: flex; align-items: center; gap: 1px; padding: 0 var(--sp-4); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; overflow-x: auto; scrollbar-width: none; }
.tabs::-webkit-scrollbar { display: none; } .tabs::-webkit-scrollbar { display: none; }
.tab { display: flex; align-items: center; gap: var(--sp-2); position: relative; font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 10px 10px 9px; color: var(--text-faint); background: none; border: none; border-bottom: 2px solid transparent; cursor: pointer; white-space: nowrap; transition: color var(--t-base), border-color var(--t-base); margin-bottom: -1px; } .tab { display: flex; align-items: center; gap: 6px; font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 10px 8px 9px; color: var(--text-faint); background: none; border: none; border-bottom: 2px solid transparent; cursor: pointer; white-space: nowrap; transition: color var(--t-base), border-color var(--t-base); margin-bottom: -1px; }
.tab:hover { color: var(--text-muted); } .tab:hover { color: var(--text-muted); }
.tab-active { color: var(--text-secondary); border-bottom-color: var(--accent); } .tab-active { color: var(--text-secondary); border-bottom-color: var(--accent); }
:global(.tab-icon) { width: 13px; height: 13px; border-radius: 2px; object-fit: contain; } :global(.tab-icon) { width: 13px; height: 13px; border-radius: 2px; object-fit: contain; }
.tab-badge { font-size: 10px; padding: 0 4px; border-radius: var(--radius-full); background: var(--bg-overlay); color: var(--text-faint); min-width: 16px; text-align: center; line-height: 16px; } .tab-badge { font-size: 10px; padding: 0 4px; border-radius: var(--radius-full); background: var(--bg-overlay); color: var(--text-faint); min-width: 16px; text-align: center; line-height: 16px; }
.tab-active .tab-badge { background: var(--accent-muted); color: var(--accent-fg); } .tab-active .tab-badge { background: var(--accent-muted); color: var(--accent-fg); }
@@ -484,63 +551,75 @@
.tab-body { flex: 1; overflow-y: auto; padding: var(--sp-3); scrollbar-width: none; display: flex; flex-direction: column; gap: var(--sp-2); } .tab-body { flex: 1; overflow-y: auto; padding: var(--sp-3); scrollbar-width: none; display: flex; flex-direction: column; gap: var(--sp-2); }
.tab-body::-webkit-scrollbar { display: none; } .tab-body::-webkit-scrollbar { display: none; }
.record-card { display: flex; flex-direction: column; gap: var(--sp-3); padding: var(--sp-4); border-radius: var(--radius-lg); border: 1px solid var(--border-dim); background: var(--bg-raised); transition: opacity var(--t-base), border-color var(--t-base); } .record-card { display: flex; flex-direction: column; border-radius: var(--radius-lg); border: 1px solid var(--border-dim); background: var(--bg-raised); overflow: hidden; transition: border-color var(--t-base); }
.record-card:hover { border-color: var(--border-strong); } .record-card:hover { border-color: var(--border-strong); }
.record-busy { opacity: 0.4; pointer-events: none; } .record-busy { opacity: 0.45; pointer-events: none; }
.record-head { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-2); } .record-head { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-2); padding: var(--sp-3) var(--sp-3) 0; }
.record-source { display: flex; align-items: center; gap: var(--sp-2); } .record-source { display: flex; align-items: center; gap: 6px; }
:global(.record-tracker-icon) { width: 14px; height: 14px; border-radius: 2px; flex-shrink: 0; object-fit: contain; opacity: 0.75; } :global(.record-icon) { width: 14px; height: 14px; border-radius: 2px; flex-shrink: 0; object-fit: contain; opacity: 0.7; }
.record-source-name { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; } .record-source-name { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; }
.record-head-actions { display: flex; align-items: center; gap: 2px; } .record-external { display: flex; align-items: center; color: var(--text-faint); transition: color var(--t-base); }
.record-external:hover { color: var(--accent-fg); }
.record-actions { display: flex; align-items: center; gap: 2px; }
.record-title { display: inline-flex; align-items: center; gap: 4px; font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-secondary); text-decoration: none; line-height: var(--leading-snug); transition: color var(--t-base); } .pill-btn { display: flex; align-items: center; gap: 4px; font-family: var(--font-ui); font-size: 10px; letter-spacing: var(--tracking-wide); padding: 2px 7px; border-radius: var(--radius-full); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); cursor: pointer; transition: color var(--t-base), background var(--t-base), border-color var(--t-base); }
.record-title:hover { color: var(--accent-fg); } .pill-btn:hover:not(:disabled) { color: var(--text-muted); border-color: var(--border-strong); }
.record-title-plain { font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-secondary); line-height: var(--leading-snug); } .pill-btn-on { color: var(--accent-fg); background: var(--accent-muted); border-color: var(--accent-dim); }
.pill-btn:disabled { opacity: 0.35; cursor: default; }
.record-selects { display: flex; gap: var(--sp-2); flex-wrap: wrap; } .icon-action { display: flex; align-items: center; justify-content: center; width: 24px; height: 24px; border-radius: var(--radius-sm); border: none; background: none; color: var(--text-faint); cursor: pointer; transition: color var(--t-base), background var(--t-base); flex-shrink: 0; }
.record-select { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 5px 24px 5px 10px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: var(--bg-surface); color: var(--text-muted); outline: none; cursor: pointer; flex: 1; min-width: 0; appearance: none; -webkit-appearance: none; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%23555' stroke-width='1.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: right 8px center; transition: border-color var(--t-base), color var(--t-base); } .icon-action:hover:not(:disabled) { color: var(--text-muted); background: var(--bg-overlay); }
.record-select:hover:not(:disabled) { border-color: var(--border-strong); color: var(--text-secondary); } .icon-action-danger:hover:not(:disabled) { color: var(--color-error); background: color-mix(in srgb, var(--color-error) 10%, transparent); }
.record-select:focus { border-color: var(--accent-dim); color: var(--text-secondary); } .icon-action:disabled { opacity: 0.3; cursor: default; }
.record-select:disabled { opacity: 0.35; cursor: default; }
.record-select option { background: var(--bg-surface); color: var(--text-secondary); }
.record-select-score { flex: 0 0 auto; min-width: 80px; }
.record-select-status { flex: 1; }
.record-icon-btn { display: flex; align-items: center; justify-content: center; width: 22px; height: 22px; border-radius: var(--radius-sm); border: none; background: none; color: var(--text-faint); cursor: pointer; transition: color var(--t-base), background var(--t-base); flex-shrink: 0; } .record-body { display: flex; flex-direction: column; gap: var(--sp-2); padding: var(--sp-2) var(--sp-3) var(--sp-3); }
.record-icon-btn:hover:not(:disabled) { color: var(--text-muted); background: var(--bg-overlay); }
.record-icon-btn.icon-active { color: var(--accent-fg); }
.record-icon-btn.icon-danger:hover:not(:disabled) { color: var(--color-error); background: var(--color-error-bg); }
.record-icon-btn:disabled { opacity: 0.3; cursor: default; }
.record-progress { display: flex; flex-direction: column; gap: 6px; } .record-selects { display: flex; gap: var(--sp-2); }
.record-progress.clickable { cursor: pointer; border-radius: var(--radius-sm); padding: 4px 6px; margin: -4px -6px; transition: background var(--t-fast); } .field-select { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 5px 22px 5px 9px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: var(--bg-surface); color: var(--text-muted); outline: none; cursor: pointer; flex: 1; min-width: 0; appearance: none; -webkit-appearance: none; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='5' viewBox='0 0 8 5'%3E%3Cpath d='M1 1l3 3 3-3' stroke='%23555' stroke-width='1.3' fill='none' stroke-linecap='round'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: right 7px center; transition: border-color var(--t-base), color var(--t-base); }
.record-progress.clickable:hover { background: var(--bg-overlay); } .field-select:hover:not(:disabled) { border-color: var(--border-strong); color: var(--text-secondary); }
.record-progress-header { display: flex; align-items: center; justify-content: space-between; } .field-select:focus { border-color: var(--accent-dim); }
.record-progress-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); } .field-select:disabled { opacity: 0.35; cursor: default; }
.edit-hint { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); opacity: 0; transition: opacity var(--t-fast); } .field-select option { background: var(--bg-surface); color: var(--text-secondary); }
.record-progress.clickable:hover .edit-hint { opacity: 0.6; } .score-select { flex: 0 0 auto; min-width: 76px; }
.record-progress-track { height: 2px; background: var(--border-strong); border-radius: var(--radius-full); overflow: hidden; }
.record-progress-fill { height: 100%; background: var(--accent); border-radius: var(--radius-full); transition: width 0.3s ease; }
.chapter-editor { display: flex; flex-direction: column; gap: var(--sp-2); padding: var(--sp-3); border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: var(--bg-surface); } .progress-row { display: flex; align-items: center; justify-content: space-between; width: 100%; padding: 6px 8px; border-radius: var(--radius-sm); border: 1px solid transparent; background: none; cursor: pointer; text-align: left; transition: background var(--t-fast), border-color var(--t-fast); }
.chapter-editor-top { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-3); } .progress-row:hover:not(:disabled) { background: var(--bg-overlay); border-color: var(--border-dim); }
.chapter-editor-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); } .progress-row:disabled { cursor: default; }
.chapter-input-wrap { display: flex; align-items: center; gap: var(--sp-2); } .progress-labels { display: flex; flex-direction: column; gap: 1px; }
.chapter-input { width: 64px; background: var(--bg-raised); border: 1px solid var(--border-strong); border-radius: var(--radius-sm); padding: 3px 6px; font-family: var(--font-ui); font-size: var(--text-sm); color: var(--text-primary); outline: none; text-align: center; } .progress-text { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
.chapter-input:focus { border-color: var(--accent); } .progress-dates { font-family: var(--font-ui); font-size: 10px; color: var(--text-faint); opacity: 0.7; }
.chapter-total { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); } .progress-edit-hint { font-family: var(--font-ui); font-size: 10px; color: var(--text-faint); opacity: 0; letter-spacing: var(--tracking-wide); transition: opacity var(--t-fast); }
.progress-row:hover:not(:disabled) .progress-edit-hint { opacity: 0.5; }
.progress-track { height: 2px; background: var(--border-strong); border-radius: var(--radius-full); overflow: hidden; }
.progress-fill { height: 100%; background: var(--accent); border-radius: var(--radius-full); transition: width 0.3s ease; }
.editor { display: flex; flex-direction: column; gap: var(--sp-2); padding: var(--sp-3); border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: var(--bg-surface); }
.editor-row { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-3); }
.editor-label { font-family: var(--font-ui); font-size: 10px; color: var(--text-faint); letter-spacing: var(--tracking-wide); text-transform: uppercase; }
.editor-input-row { display: flex; align-items: center; gap: var(--sp-2); }
.editor-input { width: 60px; background: var(--bg-raised); border: 1px solid var(--border-strong); border-radius: var(--radius-sm); padding: 3px 6px; font-family: var(--font-ui); font-size: var(--text-sm); color: var(--text-primary); outline: none; text-align: center; transition: border-color var(--t-base); }
.editor-input:focus { border-color: var(--accent); }
.editor-total { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); }
.chapter-slider { width: 100%; accent-color: var(--accent); cursor: pointer; height: 3px; } .chapter-slider { width: 100%; accent-color: var(--accent); cursor: pointer; height: 3px; }
.chapter-editor-actions { display: flex; gap: var(--sp-2); justify-content: flex-end; }
.chapter-save-btn { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 4px 12px; border-radius: var(--radius-sm); border: 1px solid var(--accent-dim); background: var(--accent-muted); color: var(--accent-fg); cursor: pointer; transition: filter var(--t-base); }
.chapter-save-btn:hover { filter: brightness(1.15); }
.chapter-cancel-btn { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 4px 8px; border-radius: var(--radius-sm); border: none; background: none; color: var(--text-faint); cursor: pointer; transition: color var(--t-base); }
.chapter-cancel-btn:hover { color: var(--text-muted); }
.search-bar { display: flex; align-items: center; gap: var(--sp-2); padding: var(--sp-3) var(--sp-4); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; background: var(--bg-surface); } .date-row { display: flex; gap: var(--sp-3); padding-top: var(--sp-1); border-top: 1px solid var(--border-dim); }
.date-field { display: flex; align-items: center; gap: 5px; flex: 1; }
:global(.date-icon) { color: var(--text-faint); flex-shrink: 0; }
.date-input { flex: 1; min-width: 0; background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-sm); padding: 3px 6px; font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-muted); outline: none; transition: border-color var(--t-base); }
.date-input:focus { border-color: var(--accent-dim); }
.editor-actions { display: flex; gap: var(--sp-2); justify-content: flex-end; padding-top: var(--sp-1); }
.editor-save { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 4px 12px; border-radius: var(--radius-sm); border: 1px solid var(--accent-dim); background: var(--accent-muted); color: var(--accent-fg); cursor: pointer; transition: filter var(--t-base); }
.editor-save:hover { filter: brightness(1.15); }
.editor-cancel { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 4px 8px; border-radius: var(--radius-sm); border: none; background: none; color: var(--text-faint); cursor: pointer; transition: color var(--t-base); }
.editor-cancel:hover { color: var(--text-muted); }
.search-bar { display: flex; align-items: center; gap: var(--sp-2); padding: var(--sp-3) var(--sp-4); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
:global(.search-icon) { color: var(--text-faint); flex-shrink: 0; } :global(.search-icon) { color: var(--text-faint); flex-shrink: 0; }
.search-input { flex: 1; background: none; border: none; outline: none; font-size: var(--text-sm); color: var(--text-primary); } .search-input { flex: 1; background: none; border: none; outline: none; font-size: var(--text-sm); color: var(--text-primary); }
.search-input::placeholder { color: var(--text-faint); } .search-input::placeholder { color: var(--text-faint); }
.search-results { flex: 1; overflow-y: auto; padding: var(--sp-2); scrollbar-width: none; } .search-results { flex: 1; overflow-y: auto; padding: var(--sp-2); scrollbar-width: none; }
.search-results::-webkit-scrollbar { display: none; } .search-results::-webkit-scrollbar { display: none; }
@@ -548,17 +627,27 @@
.result-row:hover:not(:disabled) { background: var(--bg-raised); } .result-row:hover:not(:disabled) { background: var(--bg-raised); }
.result-row:disabled { opacity: 0.4; cursor: default; } .result-row:disabled { opacity: 0.4; cursor: default; }
.result-bound { background: color-mix(in srgb, var(--accent) 8%, transparent) !important; } .result-bound { background: color-mix(in srgb, var(--accent) 8%, transparent) !important; }
.result-cover { width: 44px; height: 62px; object-fit: cover; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); flex-shrink: 0; } .result-cover { width: 40px; height: 56px; object-fit: cover; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); flex-shrink: 0; }
.result-cover-empty { background: var(--bg-raised); } .result-cover-empty { background: var(--bg-raised); }
.result-info { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: var(--sp-1); padding-top: 2px; } .result-info { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 4px; padding-top: 2px; }
.result-title { font-size: var(--text-sm); color: var(--text-secondary); line-height: var(--leading-snug); text-align: left; } .result-title { font-size: var(--text-sm); color: var(--text-secondary); line-height: var(--leading-snug); text-align: left; }
.result-meta { display: flex; flex-wrap: wrap; gap: var(--sp-1); } .result-meta { display: flex; flex-wrap: wrap; gap: 4px; }
.result-tag { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 1px 5px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: var(--bg-raised); color: var(--text-faint); } .result-tag { font-family: var(--font-ui); font-size: 10px; letter-spacing: var(--tracking-wide); padding: 1px 5px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: var(--bg-raised); color: var(--text-faint); }
.result-summary { font-size: var(--text-xs); color: var(--text-faint); line-height: var(--leading-snug); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; text-align: left; } .result-summary { font-size: var(--text-xs); color: var(--text-faint); line-height: var(--leading-snug); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; text-align: left; }
.result-action { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 3px 10px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); flex-shrink: 0; align-self: center; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); } .result-action { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 3px 10px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); flex-shrink: 0; align-self: center; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
.result-row:hover:not(:disabled) .result-action { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); } .result-row:hover:not(:disabled) .result-action { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
.result-action-on { color: var(--accent-fg) !important; border-color: var(--accent-dim) !important; background: var(--accent-muted) !important; } .result-action-on { color: var(--accent-fg) !important; border-color: var(--accent-dim) !important; background: var(--accent-muted) !important; }
.confirm-backdrop { position: fixed; inset: 0; z-index: calc(var(--z-settings) + 1); background: rgba(0,0,0,0.45); backdrop-filter: blur(2px); display: flex; align-items: center; justify-content: center; animation: fadeIn 0.1s ease both; }
.confirm-modal { background: var(--bg-surface); border: 1px solid var(--border-dim); border-radius: var(--radius-xl); padding: var(--sp-5); width: 260px; display: flex; flex-direction: column; gap: var(--sp-3); box-shadow: 0 16px 48px rgba(0,0,0,0.5); animation: scaleIn 0.15s ease both; }
.confirm-title { font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-primary); margin: 0; }
.confirm-body { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); line-height: 1.5; margin: 0; letter-spacing: var(--tracking-wide); }
.confirm-row { display: flex; gap: var(--sp-2); }
.confirm-cancel { flex: 1; font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 7px 0; border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: none; color: var(--text-muted); cursor: pointer; transition: border-color var(--t-base), color var(--t-base); }
.confirm-cancel:hover { border-color: var(--border-strong); color: var(--text-primary); }
.confirm-ok { flex: 1; font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 7px 0; border-radius: var(--radius-md); border: 1px solid color-mix(in srgb, var(--color-error) 30%, transparent); background: color-mix(in srgb, var(--color-error) 8%, transparent); color: var(--color-error); cursor: pointer; transition: filter var(--t-base); }
.confirm-ok:hover { filter: brightness(1.2); }
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } } @keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
@keyframes scaleIn { from { opacity: 0; transform: scale(0.97) } to { opacity: 1; transform: scale(1) } } @keyframes scaleIn { from { opacity: 0; transform: scale(0.97) } to { opacity: 1; transform: scale(1) } }
</style> </style>
@@ -318,6 +318,12 @@
text-align: center; text-align: center;
} }
.s-presets {
display: flex;
gap: var(--sp-2);
padding: var(--sp-3) var(--sp-4);
}
/* ── Select Dropdown ──────────────────────────────────────────────── */ /* ── Select Dropdown ──────────────────────────────────────────────── */
.s-select { position: relative; flex-shrink: 0; } .s-select { position: relative; flex-shrink: 0; }
@@ -682,6 +688,16 @@
border-top: 1px solid var(--border-dim); border-top: 1px solid var(--border-dim);
} }
.s-subsection-title {
font-family: var(--font-ui);
font-size: var(--text-2xs);
color: var(--text-faint);
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
padding: var(--sp-3) var(--sp-4) var(--sp-1);
border-bottom: 1px solid var(--border-dim);
}
/* ── Storage bar ──────────────────────────────────────────────────── */ /* ── Storage bar ──────────────────────────────────────────────────── */
.s-storage-wrap { .s-storage-wrap {

Some files were not shown because too many files have changed in this diff Show More