Compare commits

..

44 Commits

Author SHA1 Message Date
Youwes09 023b23288b Chore: Update for 0.7.1 2026-04-05 20:02:27 -05:00
Youwes09 67a9f0b944 Fix: SplashScreen Scaling on Windows (WIP) 2026-04-06 00:39:16 -05:00
Youwes09 56392e2427 Fix: Caching Logic & Settings Warning for Auth 2026-04-05 11:54:46 -05:00
Youwes09 843e205072 Feat: Scanlator-based Filtering & Directory Changes 2026-04-05 11:36:43 -05:00
Youwes09 ee708d85d0 Fix: Persistent Security State 2026-04-05 00:59:27 -05:00
Youwes09 8005c82654 Fix: Update flake.nix Hash 2026-04-04 23:31:53 -05:00
Youwes09 d989b2d67e Fix: Tauri-Plugin-HTTP for Windows Auth Support (Major WIP) 2026-04-05 04:14:33 -05:00
Youwes09 6446a19b2d Fix: Auth Thumbnails on Windows (WIP) 2026-04-04 19:28:00 -05:00
Youwes09 5cd96abc0c Feat: Switch DRPC Plugins 2026-04-03 22:07:42 -05:00
Youwes09 db44afc4dc Chore: Bump versions for 0.7.0 2026-04-02 23:28:16 -05:00
Youwes09 4248e344ab Chore: Embed AuthURL into Images 2026-04-02 23:19:41 -05:00
Youwes09 8941bfef10 Feat: Improved ThemeEditor with ColorPicker (WIP) 2026-04-02 22:50:19 -05:00
Youwes09 11cd6ff870 Fix: CSS Issues 2026-04-02 22:46:55 -05:00
Youwes09 15adb02be3 Fix: Revise Authentication Methods & Add Edge-Case Handling for Auth 2026-04-02 22:27:39 -05:00
Youwes09 51bb6cdab9 Feat: Markers 2026-04-02 18:07:49 -05:00
Youwes09 454a674ada Merge branch 'main' of github.com:Youwes09/Moku 2026-04-02 11:05:25 -05:00
Youwes09 f146de5c02 Fix: Search Tags + Status (WIP) 2026-04-02 11:05:19 -05:00
Shozikan 04f680c3bb Fix Discord link in README
Updated Discord link in README to new URL.
2026-04-02 09:13:41 -05:00
Youwes09 f49f7e7ac1 Feat: Automation Panel (WIP) & SeriesDetail Additions 2026-04-02 00:56:27 -05:00
Youwes09 a62512bf42 Feat: Filtering in Library 2026-04-01 22:26:29 -05:00
Youwes09 d91ed2e6d1 Chore: Redesign MigrationModal Sources 2026-04-01 15:38:10 -05:00
Youwes09 61e3c4ee2f Chore: Redesign SeriesDetail Elements 2026-04-01 14:25:18 -05:00
Youwes09 9151820843 Fix: TitleBar Issue (WIP) & Allow Sources in Content Settings 2026-04-01 11:09:40 -05:00
Youwes09 63c890dadf Fix: Bookmark Notification Spam 2026-04-01 10:53:18 -05:00
Youwes09 51a33679d5 Chore: Bump for 0.6.7 2026-03-31 23:49:17 -05:00
Youwes09 82f8a9a36b Fix: Forgot Auto-Bookmark Toggle & NSFW On GenreDrill 2026-03-31 22:55:26 -05:00
Youwes09 4decce9a7f Fix: Reworked Bookmark System & Added Double Page (WIP) 2026-03-31 19:46:11 -05:00
Youwes09 a69d5eacc5 Fix: Improved Loading (WIP) 2026-03-31 11:28:00 -05:00
Youwes09 4959722759 Feat: Change Download Directory (WIP) 2026-03-30 23:14:40 -05:00
Youwes09 35ba0171c7 Fix: Zoom Issue & Sidebar Overflow 2026-03-30 00:26:04 -05:00
Youwes09 d26fa50e76 Chore: Bump to 0.6.1 2026-03-30 00:04:54 -05:00
Youwes09 fd9d216325 Fix: Emergency Push + Bookmark Feature (WIP) 2026-03-30 00:02:21 -05:00
Youwes09 581eb2adb0 Fix: Home-Screen Argument for RPC & Total Time 2026-03-29 17:35:25 -05:00
Youwes09 8aa2dc2547 Chore: Prepare for Version 0.6.0 2026-03-29 15:47:12 -05:00
Youwes09 0a11fe3982 Feat: Discord RPC 2026-03-29 15:38:39 -05:00
Youwes09 f6786def87 Fix: SeriesDetail passing Incorrect Args to Reader 2026-03-29 14:03:28 -05:00
Youwes09 262027d9f9 Feat: Added Filtering System in Library (Request: #13) 2026-03-29 13:22:08 -05:00
Youwes09 d407359973 Fix: Added Slight Border to Mitigate Windows Tab Issue (WIP) 2026-03-29 12:58:03 -05:00
Youwes09 a77572a8d4 Fix: Constrained Home-Screen Completed & SplashScreen #15 2026-03-29 12:51:17 -05:00
Youwes09 32d2fffdc5 Fix: Zoom Issue (Bug #14) 2026-03-29 12:40:28 -05:00
Youwes09 e850cbac1e Fix: Bump Update for 0.5.1 2026-03-28 20:17:14 -05:00
Youwes09 eebd1b6446 Fix: Remove Manga Drag & Drop + Libray Move System 2026-03-28 20:09:40 -05:00
Youwes09 5ed072211b Fix: Folder State & Tabs 2026-03-28 19:36:16 -05:00
Youwes09 62e41e5f07 Fix: Reader Store Refactor (Issue #11) & Feat: Drag n Drop (WIP) 2026-03-28 17:15:01 -05:00
53 changed files with 8208 additions and 4455 deletions
+2 -2
View File
@@ -7,7 +7,7 @@
[![release](https://img.shields.io/github/v/release/Youwes09/Moku?style=flat&color=a8c4a8&labelColor=151515)](https://github.com/Youwes09/Moku/releases/latest) [![release](https://img.shields.io/github/v/release/Youwes09/Moku?style=flat&color=a8c4a8&labelColor=151515)](https://github.com/Youwes09/Moku/releases/latest)
[![downloads](https://img.shields.io/github/downloads/Youwes09/Moku/total?style=flat&color=a8c4a8&labelColor=151515)](https://github.com/Youwes09/Moku/releases/latest) [![downloads](https://img.shields.io/github/downloads/Youwes09/Moku/total?style=flat&color=a8c4a8&labelColor=151515)](https://github.com/Youwes09/Moku/releases/latest)
[![license](https://img.shields.io/github/license/Youwes09/Moku?style=flat&color=a8c4a8&labelColor=151515)](./LICENSE) [![license](https://img.shields.io/github/license/Youwes09/Moku?style=flat&color=a8c4a8&labelColor=151515)](./LICENSE)
[![discord](https://img.shields.io/discord/1485151759511978077?style=flat&color=a8c4a8&labelColor=151515&label=discord)](https://discord.gg/cfncTbJ2) [![discord](https://img.shields.io/discord/1485151759511978077?style=flat&color=a8c4a8&labelColor=151515&label=discord)](https://discord.gg/x97hj8zR72)
</div> </div>
@@ -131,7 +131,7 @@ pnpm tauri:dev
Questions, feedback, or just want to hang out — join the Discord. Questions, feedback, or just want to hang out — join the Discord.
[![Discord](https://img.shields.io/discord/1485151759511978077?style=for-the-badge&color=a8c4a8&labelColor=151515&label=Join+the+Discord)](https://discord.gg/cfncTbJ2) [![Discord](https://img.shields.io/discord/1485151759511978077?style=for-the-badge&color=a8c4a8&labelColor=151515&label=Join+the+Discord)](https://discord.gg/x97hj8zR72)
--- ---
+21 -20
View File
@@ -1,21 +1,24 @@
Major Revisions: Major Revisions:
- Moku + Crossplatform Support (MacOS Remaining)
- Contemplate Anime Support, Add Novel Support (Consumet API)
- Enable Cloudflare Bypass (Suwayomi Config)
- Moku-Share to Easily Migrate/Share Manga (Investigate Usecase/Feasibility) - Moku-Share to Easily Migrate/Share Manga (Investigate Usecase/Feasibility)
- Adjustment in Settings for Theme Editor:
- Allow User to Edit/Create Themes
- Allow For Command-Line IPC for Temporary (Apply Once) & Permanent Themes OR External Methodology for Matugen Colors
Minor Revisions: Minor Revisions:
- Improve Deduplication Algorithm with Advanced Option Settings (Implement Chapter & Author-based Comparisons, Resource Intensive) - Improve Deduplication Algorithm with Advanced Option Settings (Implement Chapter & Author-based Comparisons, Resource Intensive)
- Integrate Download Directory Changes (Settings)
- Investigate feasibility of Multi-Page Screenshot (Reader) - Investigate feasibility of Multi-Page Screenshot (Reader)
- Add Hover Info on Library (Make sure doesn't conflict with additional clicks) - Add Hover Info on Library (Make sure doesn't conflict with additional clicks)
- Revise Migration (https://github.com/Suwayomi/Suwayomi-WebUI/pull/1073)
- Look at how Manga are Organized in WebUI and Implement into Series-Detail (Chapter Display is Off)
- Adjustment in Settings for Theme Editor:
- Patch Color-Picker to Work Properly
- Moku Discord RPC
- Write a better library for Discord RPC & Tauri
- Integrate Download Directory Changes (Settings)
Priority Bugs: Priority Bugs:
- Cache ALL Cover Pictures & Details for Manga in Library - Cache ALL Cover Pictures & Details for Manga in Library
- MacOS Full-Screen & UI Compatability (TitleBar) - Fix Library Build not Updating
- Check Auth System (Only Supports Basic-Auth)
General/Misc Bugs: General/Misc Bugs:
- Fix Highlightable Elements - Fix Highlightable Elements
@@ -24,19 +27,17 @@ General/Misc Bugs:
- Fix Delete-All Crash (Deletes All but Cripples App) - Fix Delete-All Crash (Deletes All but Cripples App)
- Investigate Prod vs. Dev Directory Change/Data Load (Caused by Library Algorithm Revision?) - Investigate Prod vs. Dev Directory Change/Data Load (Caused by Library Algorithm Revision?)
In-Progress:`
- Fix Reader Chapter Shifts (Glitched Sentinel) In-Progress:
- Still Shifts Down after reading ~8+ Chapters? - Enable Cloudflare Bypass (Suwayomi Config) (Requires Patching)
- Identify When Chapters are Unloaded, How to Preserve Structure
- Patch Migrate Modal to Fill Language Options, not Limit to 7-9.
Important Commands:
cd ~/Projects/Manga/Moku
pnpm build
tar -czf packaging/frontend-dist.tar.gz -C dist .
sha256sum packaging/frontend-dist.tar.gz | awk '{print $1}'
nix-shell -p "python311.withPackages(ps: [ ps.aiohttp ps.tomlkit ])" --run "python3 packaging/flatpak-cargo-generator.py src-tauri/Cargo.lock -o packaging/cargo-sources.json" Testing:
nix shell nixpkgs#appstream nixpkgs#flatpak-builder --command flatpak-builder --repo=repo --force-clean build-dir dev.moku.app.yml
flatpak build-bundle repo moku.flatpak dev.moku.app - Fix TitleBar not Appearing on Windows in Fullscreen (Locks in User)
- Integrate Download Directory Changes (Settings)
- Fix Source Allow in Content (Doesn't even work)
+1 -1
View File
@@ -181,7 +181,7 @@ modules:
path: . path: .
- type: file - type: file
path: packaging/frontend-dist.tar.gz path: packaging/frontend-dist.tar.gz
sha256: 3ac5d822ac1840473333510b5e45220298702e6d1435e2cdd4b5c2f7195d764f sha256: d3ebde4d39e3de61420b78a9506df1a5c77c14d705e42662a45a2179bc96030e
- packaging/cargo-sources.json - packaging/cargo-sources.json
- type: inline - type: inline
dest: src-tauri/.cargo dest: src-tauri/.cargo
+16 -3
View File
@@ -18,7 +18,7 @@
perSystem = { system, lib, ... }: perSystem = { system, lib, ... }:
let let
version = "0.5.0"; version = "0.7.1";
pkgs = import inputs.nixpkgs { pkgs = import inputs.nixpkgs {
inherit system; inherit system;
@@ -71,7 +71,7 @@
inherit version; inherit version;
src = frontendSrc; src = frontendSrc;
fetcherVersion = 1; fetcherVersion = 1;
hash = "sha256-4QUSgWgMu7FGn44+TGmACheokPhaBdHvA/055SqUs0Q="; hash = "sha256-nlhm3NYn4x+JlKcCgj1lAX43muB3QRKGDzaxfQNfJwc=";
}; };
buildPhase = "pnpm build"; buildPhase = "pnpm build";
@@ -149,7 +149,7 @@ EOF
bumpScript = pkgs.writeShellApplication { bumpScript = pkgs.writeShellApplication {
name = "moku-bump"; name = "moku-bump";
runtimeInputs = with pkgs; [ gnused coreutils git ]; runtimeInputs = with pkgs; [ gnused coreutils git rustToolchain ];
text = '' text = ''
[[ $# -lt 1 ]] && { echo "Usage: nix run .#bump -- <version>"; exit 1; } [[ $# -lt 1 ]] && { echo "Usage: nix run .#bump -- <version>"; exit 1; }
VERSION="$1" VERSION="$1"
@@ -160,6 +160,7 @@ EOF
"$REPO/src-tauri/Cargo.toml" "$REPO/src-tauri/Cargo.toml"
sed -i "s/version = \"[^\"]*\";/version = \"$VERSION\";/g" \ sed -i "s/version = \"[^\"]*\";/version = \"$VERSION\";/g" \
"$REPO/flake.nix" "$REPO/flake.nix"
(cd "$REPO/src-tauri" && cargo generate-lockfile)
echo "Bumped to $VERSION" echo "Bumped to $VERSION"
''; '';
}; };
@@ -263,6 +264,15 @@ EOF
''; '';
}; };
tunnelScript = pkgs.writeShellApplication {
name = "moku-tunnel";
runtimeInputs = with pkgs; [ cloudflared ];
text = ''
PORT="''${1:-4567}"
cloudflared tunnel --url "http://localhost:$PORT"
'';
};
in in
{ {
apps = { apps = {
@@ -271,6 +281,7 @@ EOF
bump = { type = "app"; program = "${bumpScript}/bin/moku-bump"; }; bump = { type = "app"; program = "${bumpScript}/bin/moku-bump"; };
flatpak = { type = "app"; program = "${flatpakScript}/bin/moku-flatpak"; }; flatpak = { type = "app"; program = "${flatpakScript}/bin/moku-flatpak"; };
pkgbuild-bump = { type = "app"; program = "${pkgbuildBumpScript}/bin/moku-pkgbuild-bump"; }; pkgbuild-bump = { type = "app"; program = "${pkgbuildBumpScript}/bin/moku-pkgbuild-bump"; };
tunnel = { type = "app"; program = "${tunnelScript}/bin/moku-tunnel"; };
}; };
packages = { packages = {
@@ -287,6 +298,7 @@ EOF
nodejs_22 nodejs_22
pnpm pnpm
suwayomi-server suwayomi-server
cloudflared
xdg-utils xdg-utils
]; ];
shellHook = '' shellHook = ''
@@ -300,6 +312,7 @@ EOF
echo " nix run .#bump -- <ver> bump versions only" echo " nix run .#bump -- <ver> bump versions only"
echo " nix run .#flatpak -- <ver> full flatpak build" echo " nix run .#flatpak -- <ver> full flatpak build"
echo " nix run .#pkgbuild-bump -- <ver> patch PKGBUILD (after tag push)" echo " nix run .#pkgbuild-bump -- <ver> patch PKGBUILD (after tag push)"
echo " nix run .#tunnel -- [port] cloudflare tunnel (default 4567)"
''; '';
}; };
+4 -1
View File
@@ -11,11 +11,14 @@
}, },
"dependencies": { "dependencies": {
"@tauri-apps/api": "^2.0.0", "@tauri-apps/api": "^2.0.0",
"@tauri-apps/plugin-http": "^2.5.8",
"@tauri-apps/plugin-os": "^2.3.2", "@tauri-apps/plugin-os": "^2.3.2",
"@tauri-apps/plugin-shell": "^2.3.5", "@tauri-apps/plugin-shell": "^2.3.5",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"phosphor-svelte": "^3.1.0", "phosphor-svelte": "^3.1.0",
"svelte-spa-router": "^4.0.1" "svelte-spa-router": "^4.0.1",
"tauri-plugin-discord-rpc-api": "github:Youwes09/tauri-plugin-discord-rpc",
"tauri-plugin-drpc": "^1.0.3"
}, },
"devDependencies": { "devDependencies": {
"@sveltejs/vite-plugin-svelte": "^4.0.4", "@sveltejs/vite-plugin-svelte": "^4.0.4",
File diff suppressed because it is too large Load Diff
+33
View File
@@ -11,6 +11,9 @@ importers:
'@tauri-apps/api': '@tauri-apps/api':
specifier: ^2.0.0 specifier: ^2.0.0
version: 2.10.1 version: 2.10.1
'@tauri-apps/plugin-http':
specifier: ^2.5.8
version: 2.5.8
'@tauri-apps/plugin-os': '@tauri-apps/plugin-os':
specifier: ^2.3.2 specifier: ^2.3.2
version: 2.3.2 version: 2.3.2
@@ -26,6 +29,12 @@ importers:
svelte-spa-router: svelte-spa-router:
specifier: ^4.0.1 specifier: ^4.0.1
version: 4.0.2 version: 4.0.2
tauri-plugin-discord-rpc-api:
specifier: github:Youwes09/tauri-plugin-discord-rpc
version: https://codeload.github.com/Youwes09/tauri-plugin-discord-rpc/tar.gz/4b20388e4b65e0efcff2aa9a8622b5884554cd8a
tauri-plugin-drpc:
specifier: ^1.0.3
version: 1.0.3(typescript@5.9.3)
devDependencies: devDependencies:
'@sveltejs/vite-plugin-svelte': '@sveltejs/vite-plugin-svelte':
specifier: ^4.0.4 specifier: ^4.0.4
@@ -439,6 +448,9 @@ packages:
engines: {node: '>= 10'} engines: {node: '>= 10'}
hasBin: true hasBin: true
'@tauri-apps/plugin-http@2.5.8':
resolution: {integrity: sha512-oxd7oypzQeu8kAfFCrw534Kq7Cw+NzozcnCY21O4rz3A+veJiIiuSCMIprgGcZOcLAXFP9GmDhKUbhuKWcunRw==}
'@tauri-apps/plugin-os@2.3.2': '@tauri-apps/plugin-os@2.3.2':
resolution: {integrity: sha512-n+nXWeuSeF9wcEsSPmRnBEGrRgOy6jjkSU+UVCOV8YUGKb2erhDOxis7IqRXiRVHhY8XMKks00BJ0OAdkpf6+A==} resolution: {integrity: sha512-n+nXWeuSeF9wcEsSPmRnBEGrRgOy6jjkSU+UVCOV8YUGKb2erhDOxis7IqRXiRVHhY8XMKks00BJ0OAdkpf6+A==}
@@ -744,6 +756,15 @@ packages:
resolution: {integrity: sha512-TTDxwYnHkova6Wsyj1PGt9TByuWqvMoeY1bQiuAf2DM/JeDSMw7FjRKzk8K/5mJ99vGOKhbCqTDpyAKwjp4igg==} resolution: {integrity: sha512-TTDxwYnHkova6Wsyj1PGt9TByuWqvMoeY1bQiuAf2DM/JeDSMw7FjRKzk8K/5mJ99vGOKhbCqTDpyAKwjp4igg==}
engines: {node: '>=18'} engines: {node: '>=18'}
tauri-plugin-discord-rpc-api@https://codeload.github.com/Youwes09/tauri-plugin-discord-rpc/tar.gz/4b20388e4b65e0efcff2aa9a8622b5884554cd8a:
resolution: {tarball: https://codeload.github.com/Youwes09/tauri-plugin-discord-rpc/tar.gz/4b20388e4b65e0efcff2aa9a8622b5884554cd8a}
version: 0.1.0
tauri-plugin-drpc@1.0.3:
resolution: {integrity: sha512-vl5dXhjKbl7+Nf9veW12usdmIUtZXwEf91SzxQPZlbRRJ/sjizbbQlnkUTtx6baJuGzz0KXXgP9xUhF39BdiXQ==}
peerDependencies:
typescript: ^5.0.0
to-regex-range@5.0.1: to-regex-range@5.0.1:
resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
engines: {node: '>=8.0'} engines: {node: '>=8.0'}
@@ -1038,6 +1059,10 @@ snapshots:
'@tauri-apps/cli-win32-ia32-msvc': 2.10.1 '@tauri-apps/cli-win32-ia32-msvc': 2.10.1
'@tauri-apps/cli-win32-x64-msvc': 2.10.1 '@tauri-apps/cli-win32-x64-msvc': 2.10.1
'@tauri-apps/plugin-http@2.5.8':
dependencies:
'@tauri-apps/api': 2.10.1
'@tauri-apps/plugin-os@2.3.2': '@tauri-apps/plugin-os@2.3.2':
dependencies: dependencies:
'@tauri-apps/api': 2.10.1 '@tauri-apps/api': 2.10.1
@@ -1364,6 +1389,14 @@ snapshots:
magic-string: 0.30.21 magic-string: 0.30.21
zimmerframe: 1.1.4 zimmerframe: 1.1.4
tauri-plugin-discord-rpc-api@https://codeload.github.com/Youwes09/tauri-plugin-discord-rpc/tar.gz/4b20388e4b65e0efcff2aa9a8622b5884554cd8a:
dependencies:
'@tauri-apps/api': 2.10.1
tauri-plugin-drpc@1.0.3(typescript@5.9.3):
dependencies:
typescript: 5.9.3
to-regex-range@5.0.1: to-regex-range@5.0.1:
dependencies: dependencies:
is-number: 7.0.0 is-number: 7.0.0
+309 -148
View File
File diff suppressed because it is too large Load Diff
+6 -2
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "moku" name = "moku"
version = "0.5.0" version = "0.7.1"
edition = "2021" edition = "2021"
[lib] [lib]
@@ -20,12 +20,16 @@ tauri-plugin-shell = "2"
tauri-plugin-updater = "2" tauri-plugin-updater = "2"
tauri-plugin-process = "2" tauri-plugin-process = "2"
tauri-plugin-http = "2" tauri-plugin-http = "2"
tauri-plugin-os = "2.3.2"
tauri-plugin-discord-rpc = { git = "https://github.com/Youwes09/tauri-plugin-discord-rpc" }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
walkdir = "2" walkdir = "2"
sysinfo = "0.32" sysinfo = "0.32"
dirs = "5" dirs = "5"
tauri-plugin-os = "2.3.2" urlencoding = "2"
tokio = { version = "1", features = ["rt-multi-thread"] }
reqwest = { version = "0.12", features = ["blocking"] }
[profile.release] [profile.release]
codegen-units = 1 codegen-units = 1
+7 -1
View File
@@ -32,6 +32,12 @@
"process:default", "process:default",
"process:allow-restart", "process:allow-restart",
"http:default", "http:default",
"http:allow-fetch" "http:allow-fetch",
"discord-rpc:default",
"discord-rpc:allow-connect",
"discord-rpc:allow-disconnect",
"discord-rpc:allow-set-activity",
"discord-rpc:allow-clear-activity",
"discord-rpc:allow-is-running"
] ]
} }
+17
View File
@@ -0,0 +1,17 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "http-scope",
"description": "HTTP fetch scope",
"windows": ["main"],
"permissions": [
{
"identifier": "http:default",
"allow": [
{ "url": "http://*:*/*" },
{ "url": "https://*:*/*" },
{ "url": "http://*/*" },
{ "url": "https://*/*" }
]
}
]
}
+144 -139
View File
@@ -26,9 +26,6 @@ pub enum SpawnError {
SpawnFailed(String), SpawnFailed(String),
} }
// ── Update types ──────────────────────────────────────────────────────────────
/// A single GitHub release returned to the frontend.
#[derive(Serialize, Clone)] #[derive(Serialize, Clone)]
pub struct ReleaseInfo { pub struct ReleaseInfo {
pub tag_name: String, pub tag_name: String,
@@ -38,7 +35,6 @@ pub struct ReleaseInfo {
pub html_url: String, pub html_url: String,
} }
/// Progress event emitted during download — matches what the frontend listens for.
#[derive(Clone, serde::Serialize)] #[derive(Clone, serde::Serialize)]
#[cfg_attr(not(target_os = "windows"), allow(dead_code))] #[cfg_attr(not(target_os = "windows"), allow(dead_code))]
struct UpdateProgress { struct UpdateProgress {
@@ -46,8 +42,6 @@ struct UpdateProgress {
total: Option<u64>, total: Option<u64>,
} }
/// Strip the \\?\ extended-length path prefix that Windows adds to long paths.
/// Java and many other tools do not accept this prefix and will fail silently.
fn strip_unc(path: PathBuf) -> PathBuf { fn strip_unc(path: PathBuf) -> PathBuf {
let s = path.to_string_lossy(); let s = path.to_string_lossy();
if let Some(stripped) = s.strip_prefix(r"\\?\") { if let Some(stripped) = s.strip_prefix(r"\\?\") {
@@ -64,7 +58,7 @@ fn resolve_downloads_path(downloads_path: &str) -> PathBuf {
let base = std::env::var("XDG_DATA_HOME") let base = std::env::var("XDG_DATA_HOME")
.map(PathBuf::from) .map(PathBuf::from)
.unwrap_or_else(|_| dirs::data_dir().unwrap_or_else(|| PathBuf::from("/"))); .unwrap_or_else(|_| dirs::data_dir().unwrap_or_else(|| PathBuf::from("/")));
base.join("Tachidesk/downloads") base.join("Tachidesk").join("downloads")
} }
#[tauri::command] #[tauri::command]
@@ -105,13 +99,67 @@ fn get_storage_info(downloads_path: String) -> Result<StorageInfo, String> {
} }
#[tauri::command] #[tauri::command]
fn get_platform_ui_scale() -> f64 { fn get_default_downloads_path() -> String {
#[cfg(target_os = "windows")] resolve_downloads_path("").to_string_lossy().into_owned()
return 1.0; }
#[cfg(target_os = "macos")]
return 1.0; #[tauri::command]
#[cfg(not(any(target_os = "windows", target_os = "macos")))] fn check_path_exists(path: String) -> bool {
return 1.5; 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) { fn kill_tachidesk(app: &tauri::AppHandle) {
@@ -130,8 +178,6 @@ fn kill_tachidesk(app: &tauri::AppHandle) {
.creation_flags(CREATE_NO_WINDOW) .creation_flags(CREATE_NO_WINDOW)
.status(); .status();
// Poll until no java.exe remains (up to ~3 s) so the installer can
// overwrite the JRE DLLs without hitting a sharing-violation error.
for _ in 0..30 { for _ in 0..30 {
let still_running = std::process::Command::new("tasklist") let still_running = std::process::Command::new("tasklist")
.args(["/FI", "IMAGENAME eq java.exe", "/NH"]) .args(["/FI", "IMAGENAME eq java.exe", "/NH"])
@@ -140,20 +186,15 @@ fn kill_tachidesk(app: &tauri::AppHandle) {
.map(|o| String::from_utf8_lossy(&o.stdout).contains("java.exe")) .map(|o| String::from_utf8_lossy(&o.stdout).contains("java.exe"))
.unwrap_or(false); .unwrap_or(false);
if !still_running { if !still_running { break; }
break;
}
std::thread::sleep(std::time::Duration::from_millis(100)); std::thread::sleep(std::time::Duration::from_millis(100));
} }
} }
#[cfg(not(target_os = "windows"))] #[cfg(not(target_os = "windows"))]
let _ = std::process::Command::new("pkill") let _ = std::process::Command::new("pkill").args(["-f", "tachidesk"]).status();
.args(["-f", "tachidesk"])
.status();
} }
const DEFAULT_SERVER_CONF: &str = r#"server.ip = "127.0.0.1" const DEFAULT_SERVER_CONF: &str = r#"server.ip = "127.0.0.1"
server.port = 4567 server.port = 4567
server.webUIEnabled = false server.webUIEnabled = false
@@ -251,14 +292,11 @@ struct ServerInvocation {
#[cfg(not(target_os = "macos"))] #[cfg(not(target_os = "macos"))]
fn find_java_in_bundle(bundle_dir: &PathBuf, log: &mut Option<std::fs::File>) -> Option<PathBuf> { fn find_java_in_bundle(bundle_dir: &PathBuf, log: &mut Option<std::fs::File>) -> Option<PathBuf> {
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
let java = bundle_dir.join("jre").join("bin").join("java.exe"); let java = strip_unc(bundle_dir.join("jre").join("bin").join("java.exe"));
#[cfg(not(target_os = "windows"))] #[cfg(not(target_os = "windows"))]
let java = bundle_dir.join("jre").join("bin").join("java"); let java = bundle_dir.join("jre").join("bin").join("java");
do_log(log, &format!("[find_java] checking path: {:?}", java)); do_log(log, &format!("[find_java] path: {:?} exists: {}", java, java.exists()));
do_log(log, &format!("[find_java] exists: {}", java.exists()));
if java.exists() { Some(java) } else { None } if java.exists() { Some(java) } else { None }
} }
@@ -274,28 +312,27 @@ fn resolve_server_binary(
app: &tauri::AppHandle, app: &tauri::AppHandle,
log: &mut Option<std::fs::File>, log: &mut Option<std::fs::File>,
) -> Result<ServerInvocation, SpawnError> { ) -> Result<ServerInvocation, SpawnError> {
do_log(log, &format!("[resolve] binary arg = {:?}", binary)); do_log(log, &format!("[resolve] binary = {:?}", binary));
if !binary.trim().is_empty() { if !binary.trim().is_empty() {
do_log(log, "[resolve] using user-supplied binary path"); 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 { return Ok(ServerInvocation {
bin: binary.to_string(), bin: path.to_string_lossy().into_owned(),
args: vec![], args: vec![],
working_dir: None, working_dir: path.parent().map(|p| p.to_path_buf()),
}); });
} }
do_log(log, "[resolve] user path not found, falling through");
}
let resource_dir = match app.path().resource_dir() { #[cfg(not(target_os = "macos"))]
Ok(p) => { let resource_dir = {
let stripped = strip_unc(p); let raw = app.path().resource_dir().unwrap_or_default();
do_log(log, &format!("[resolve] resource_dir (stripped) = {:?}", stripped)); let stripped = strip_unc(raw);
do_log(log, &format!("[resolve] resource_dir = {:?}", stripped));
stripped stripped
}
Err(e) => {
let msg = format!("resource_dir error: {e}");
do_log(log, &format!("[resolve] ERROR: {}", msg));
return Err(SpawnError::SpawnFailed(msg));
}
}; };
#[cfg(not(target_os = "macos"))] #[cfg(not(target_os = "macos"))]
@@ -303,63 +340,75 @@ fn resolve_server_binary(
let bundle_dir = resource_dir.join("binaries").join("suwayomi-bundle"); let bundle_dir = resource_dir.join("binaries").join("suwayomi-bundle");
let jar = bundle_dir.join("bin").join("Suwayomi-Server.jar"); let jar = bundle_dir.join("bin").join("Suwayomi-Server.jar");
do_log(log, &format!("[resolve] bundle_dir = {:?}", bundle_dir)); do_log(log, &format!("[resolve] bundle_dir={:?} exists={}", bundle_dir, bundle_dir.exists()));
do_log(log, &format!("[resolve] bundle_dir exists: {}", bundle_dir.exists())); do_log(log, &format!("[resolve] jar={:?} exists={}", jar, jar.exists()));
do_log(log, &format!("[resolve] jar = {:?}", jar));
do_log(log, &format!("[resolve] jar exists: {}", jar.exists()));
match find_java_in_bundle(&bundle_dir, log) { match find_java_in_bundle(&bundle_dir, log) {
Some(java) => { Some(java) if jar.exists() => {
do_log(log, &format!("[resolve] java found: {:?}", java)); do_log(log, "[resolve] using bundled JRE");
if jar.exists() {
do_log(log, "[resolve] both java and jar found — using bundled JRE");
return Ok(ServerInvocation { return Ok(ServerInvocation {
bin: java.to_string_lossy().into_owned(), bin: java.to_string_lossy().into_owned(),
args: vec![ args: vec!["-jar".to_string(), jar.to_string_lossy().into_owned()],
"-jar".to_string(),
jar.to_string_lossy().into_owned(),
],
working_dir: Some(bundle_dir), working_dir: Some(bundle_dir),
}); });
} else { }
do_log(log, "[resolve] java found but jar MISSING — skipping bundled path"); _ => do_log(log, "[resolve] bundled JRE/jar not found, falling through"),
} }
} }
None => {
do_log(log, "[resolve] java NOT found in bundle — skipping bundled path"); #[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")] #[cfg(target_os = "macos")]
{ {
// Tauri places externalBin sidecars next to the main binary in let resource_dir = app.path().resource_dir().unwrap_or_default();
// Contents/MacOS/, not in Contents/Resources/. Derive that path let macos_dir = resource_dir.parent().map(|p| p.join("MacOS")).unwrap_or_default();
// from resource_dir (Contents/Resources → Contents/MacOS).
let macos_dir = resource_dir.join("../MacOS")
.canonicalize()
.unwrap_or_else(|_| resource_dir.join("../MacOS"));
do_log(log, &format!("[resolve] macOS macos_dir = {:?}", macos_dir));
// Tauri strips the target triple when installing externalBin sidecars
// into Contents/MacOS/, so the binary is always just "suwayomi-server"
// at runtime. The triple-suffixed names are only needed on disk at
// build time for Tauri to pick the right arch during bundling.
let candidates = [ let candidates = [
"suwayomi-server", "suwayomi-server",
"suwayomi-server-aarch64-apple-darwin", "suwayomi-server-aarch64-apple-darwin",
"suwayomi-server-x86_64-apple-darwin", "suwayomi-server-x86_64-apple-darwin",
"suwayomi-launcher",
"suwayomi-launcher.sh",
"tachidesk-server",
]; ];
// Search MacOS/ first (correct location), then Resources/ as fallback
// for flat dev layouts where the script sits next to resources.
for search_dir in &[&macos_dir, &resource_dir] { for search_dir in &[&macos_dir, &resource_dir] {
for name in &candidates { for name in &candidates {
let p = search_dir.join(name); let p = search_dir.join(name);
do_log(log, &format!("[resolve] macOS candidate: {:?} exists={}", p, p.exists())); do_log(log, &format!("[resolve] macOS candidate: {:?} exists={}", p, p.exists()));
if p.exists() { if p.exists() {
do_log(log, &format!("[resolve] using macOS sidecar: {:?}", p));
return Ok(ServerInvocation { return Ok(ServerInvocation {
bin: p.to_string_lossy().into_owned(), bin: p.to_string_lossy().into_owned(),
args: vec![], args: vec![],
@@ -370,27 +419,17 @@ fn resolve_server_binary(
} }
} }
do_log(log, "[resolve] trying PATH fallback");
for name in &["suwayomi-server", "tachidesk-server"] { for name in &["suwayomi-server", "tachidesk-server"] {
let found = std::process::Command::new("which") #[cfg(target_os = "windows")]
.arg(name) let found = std::process::Command::new("where").arg(name).output().map(|o| o.status.success()).unwrap_or(false);
.output() #[cfg(not(target_os = "windows"))]
.map(|o| o.status.success()) let found = std::process::Command::new("which").arg(name).output().map(|o| o.status.success()).unwrap_or(false);
.unwrap_or(false);
do_log(log, &format!("[resolve] PATH check {:?}: found={}", name, found));
if found { if found {
do_log(log, &format!("[resolve] using PATH binary: {}", name)); return Ok(ServerInvocation { bin: name.to_string(), args: vec![], working_dir: None });
return Ok(ServerInvocation {
bin: name.to_string(),
args: vec![],
working_dir: None,
});
} }
} }
do_log(log, "[resolve] FAILED — no binary found anywhere");
Err(SpawnError::NotConfigured( Err(SpawnError::NotConfigured(
"Server binary not found. Install Suwayomi-Server or set the path in Settings.".to_string(), "Server binary not found. Install Suwayomi-Server or set the path in Settings.".to_string(),
)) ))
@@ -406,50 +445,28 @@ fn spawn_server(binary: String, app: tauri::AppHandle) -> Result<(), SpawnError>
} }
let data_dir = suwayomi_data_dir(); let data_dir = suwayomi_data_dir();
let log_path = data_dir.join("moku-spawn.log"); let log_path = data_dir.join("moku-spawn.log");
let _ = std::fs::create_dir_all(&data_dir); let _ = std::fs::create_dir_all(&data_dir);
let mut log = std::fs::OpenOptions::new() let mut log = std::fs::OpenOptions::new().create(true).append(true).open(&log_path).ok();
.create(true)
.append(true)
.open(&log_path)
.ok();
do_log(&mut log, ""); do_log(&mut log, &format!("[spawn_server] binary={:?} data_dir={:?}", binary, data_dir));
do_log(&mut log, "========================================");
do_log(&mut log, &format!("[spawn_server] called at {:?}", std::time::SystemTime::now()));
do_log(&mut log, &format!("[spawn_server] binary arg = {:?}", binary));
do_log(&mut log, &format!("[spawn_server] data_dir = {:?}", data_dir));
do_log(&mut log, &format!("[spawn_server] log file = {:?}", log_path));
do_log(&mut log, &format!("[spawn_server] APPDATA = {:?}", std::env::var("APPDATA")));
do_log(&mut log, &format!("[spawn_server] LOCALAPPDATA = {:?}", std::env::var("LOCALAPPDATA")));
do_log(&mut log, &format!("[spawn_server] current_dir = {:?}", std::env::current_dir()));
seed_server_conf(&data_dir); seed_server_conf(&data_dir);
do_log(&mut log, "[spawn_server] server.conf seeded");
let mut invocation = match resolve_server_binary(&binary, &app, &mut log) { let mut invocation = resolve_server_binary(&binary, &app, &mut log).map_err(|e| {
Ok(i) => i, do_log(&mut log, &format!("[spawn_server] resolve failed: {:?}", e));
Err(e) => { e
do_log(&mut log, &format!("[spawn_server] resolve FAILED: {:?}", e)); })?;
return Err(e);
}
};
let bin_display = invocation.bin.clone();
let rootdir_flag = format!( let rootdir_flag = format!(
"-Dsuwayomi.tachidesk.config.server.rootDir={}", "-Dsuwayomi.tachidesk.config.server.rootDir={}",
data_dir.to_string_lossy() data_dir.to_string_lossy()
); );
invocation.args.insert(0, rootdir_flag); invocation.args.insert(0, rootdir_flag);
let working_dir = invocation.working_dir let working_dir = invocation.working_dir.unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
.unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
do_log(&mut log, &format!("[spawn_server] bin = {:?}", bin_display)); do_log(&mut log, &format!("[spawn_server] bin={:?} args={:?} cwd={:?}", invocation.bin, invocation.args, working_dir));
do_log(&mut log, &format!("[spawn_server] args = {:?}", invocation.args));
do_log(&mut log, &format!("[spawn_server] working_dir = {:?}", working_dir));
let cmd = app.shell() let cmd = app.shell()
.command(&invocation.bin) .command(&invocation.bin)
@@ -457,17 +474,13 @@ fn spawn_server(binary: String, app: tauri::AppHandle) -> Result<(), SpawnError>
.args(&invocation.args) .args(&invocation.args)
.current_dir(&working_dir); .current_dir(&working_dir);
do_log(&mut log, "[spawn_server] calling cmd.spawn()...");
match cmd.spawn() { match cmd.spawn() {
Ok((_rx, child)) => { Ok((_rx, child)) => {
do_log(&mut log, &format!("[spawn_server] SUCCESS — spawned: {}", bin_display));
*app.state::<ServerState>().0.lock().unwrap() = Some(child); *app.state::<ServerState>().0.lock().unwrap() = Some(child);
Ok(()) Ok(())
} }
Err(e) => { Err(e) => {
do_log(&mut log, &format!("[spawn_server] SPAWN FAILED: {}", e)); do_log(&mut log, &format!("[spawn_server] spawn failed: {}", e));
do_log(&mut log, &format!("[spawn_server] error kind: {:?}", e));
Err(SpawnError::SpawnFailed(e.to_string())) Err(SpawnError::SpawnFailed(e.to_string()))
} }
} }
@@ -479,10 +492,6 @@ fn kill_server(app: tauri::AppHandle) -> Result<(), String> {
Ok(()) Ok(())
} }
// ── Update commands ───────────────────────────────────────────────────────────
/// Fetch the list of all GitHub releases so the frontend can show a version picker.
/// Uses tauri-plugin-http so it goes through Tauri's permission system.
#[tauri::command] #[tauri::command]
async fn list_releases() -> Result<Vec<ReleaseInfo>, String> { async fn list_releases() -> Result<Vec<ReleaseInfo>, String> {
use tauri_plugin_http::reqwest; use tauri_plugin_http::reqwest;
@@ -514,22 +523,15 @@ async fn list_releases() -> Result<Vec<ReleaseInfo>, String> {
let body = resp.text().await.map_err(|e| e.to_string())?; let body = resp.text().await.map_err(|e| e.to_string())?;
let releases: Vec<GhRelease> = serde_json::from_str(&body).map_err(|e| e.to_string())?; let releases: Vec<GhRelease> = serde_json::from_str(&body).map_err(|e| e.to_string())?;
Ok(releases Ok(releases.into_iter().map(|r| ReleaseInfo {
.into_iter()
.map(|r| ReleaseInfo {
tag_name: r.tag_name.clone(), tag_name: r.tag_name.clone(),
name: r.name.unwrap_or_else(|| r.tag_name.clone()), name: r.name.unwrap_or_else(|| r.tag_name.clone()),
body: r.body.unwrap_or_default(), body: r.body.unwrap_or_default(),
published_at: r.published_at.unwrap_or_default(), published_at: r.published_at.unwrap_or_default(),
html_url: r.html_url, html_url: r.html_url,
}) }).collect())
.collect())
} }
/// Download and install the latest update using tauri-plugin-updater.
/// Emits `update-progress` events with `{ downloaded, total }` while downloading.
/// On Windows the installer runs in passive (silent) mode; the frontend prompts restart.
/// On other platforms this command is a no-op — the frontend opens the GitHub page instead.
#[tauri::command] #[tauri::command]
#[allow(unused_variables)] #[allow(unused_variables)]
async fn download_and_install_update(app: tauri::AppHandle) -> Result<(), String> { async fn download_and_install_update(app: tauri::AppHandle) -> Result<(), String> {
@@ -544,7 +546,7 @@ async fn download_and_install_update(app: tauri::AppHandle) -> Result<(), String
let update = updater.check().await.map_err(|e| e.to_string())?; let update = updater.check().await.map_err(|e| e.to_string())?;
let Some(update) = update else { let Some(update) = update else {
return Err("No update available from the updater endpoint.".into()); return Err("No update available.".into());
}; };
let app_clone = app.clone(); let app_clone = app.clone();
@@ -562,17 +564,16 @@ async fn download_and_install_update(app: tauri::AppHandle) -> Result<(), String
} }
} }
/// Restart the app after a successful update install.
#[tauri::command] #[tauri::command]
fn restart_app(app: tauri::AppHandle) { fn restart_app(app: tauri::AppHandle) {
tauri::process::restart(&app.env()); tauri::process::restart(&app.env());
} }
// ── App entry point ───────────────────────────────────────────────────────────
#[cfg_attr(mobile, tauri::mobile_entry_point)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() { pub fn run() {
tauri::Builder::default() tauri::Builder::default()
.plugin(tauri_plugin_discord_rpc::init())
.plugin(tauri_plugin_os::init()) .plugin(tauri_plugin_os::init())
.plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_http::init()) .plugin(tauri_plugin_http::init())
@@ -581,6 +582,10 @@ pub fn run() {
.manage(ServerState(Mutex::new(None))) .manage(ServerState(Mutex::new(None)))
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
get_storage_info, get_storage_info,
get_default_downloads_path,
check_path_exists,
create_directory,
migrate_downloads,
spawn_server, spawn_server,
kill_server, kill_server,
get_platform_ui_scale, get_platform_ui_scale,
+1 -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.5.0", "version": "0.7.1",
"identifier": "dev.moku.app", "identifier": "dev.moku.app",
"build": { "build": {
"frontendDist": "../dist", "frontendDist": "../dist",
+204 -32
View File
@@ -5,16 +5,19 @@
import { getVersion } from "@tauri-apps/api/app"; import { getVersion } from "@tauri-apps/api/app";
import { getCurrentWindow } from "@tauri-apps/api/window"; import { getCurrentWindow } from "@tauri-apps/api/window";
import { gql } from "./lib/client"; import { gql } from "./lib/client";
import logoUrl from "./assets/moku-icon-splash.svg";
import { probeServer, loginBasic, authSession, logout } from "./lib/auth";
import { GET_DOWNLOAD_STATUS } from "./lib/queries"; import { GET_DOWNLOAD_STATUS } from "./lib/queries";
import { store, addToast, setActiveDownloads, setSettingsOpen } from "./store/state.svelte"; import { store, addToast, setActiveDownloads, setSettingsOpen } from "./store/state.svelte";
import { initRpc, setIdle, clearReading, destroyRpc } from "./lib/discord";
import type { DownloadStatus, DownloadQueueItem } from "./lib/types"; import type { DownloadStatus, DownloadQueueItem } from "./lib/types";
import Layout from "./components/layout/Layout.svelte"; import Layout from "./components/chrome/Layout.svelte";
import Reader from "./components/reader/Reader.svelte"; import Reader from "./components/reader/Reader.svelte";
import Settings from "./components/settings/Settings.svelte"; import Settings from "./components/settings/Settings.svelte";
import ThemeEditor from "./components/settings/ThemeEditor.svelte"; import ThemeEditor from "./components/settings/ThemeEditor.svelte";
import TitleBar from "./components/layout/TitleBar.svelte"; import TitleBar from "./components/chrome/TitleBar.svelte";
import Toaster from "./components/layout/Toaster.svelte"; import Toaster from "./components/chrome/Toaster.svelte";
import SplashScreen from "./components/layout/SplashScreen.svelte"; import SplashScreen from "./components/chrome/SplashScreen.svelte";
import MangaPreview from "./components/shared/MangaPreview.svelte"; import MangaPreview from "./components/shared/MangaPreview.svelte";
let themeStyleEl: HTMLStyleElement | null = null; let themeStyleEl: HTMLStyleElement | null = null;
@@ -74,13 +77,33 @@
let notConfigured = $state(false); let notConfigured = $state(false);
let idle = $state(false); let idle = $state(false);
let devSplash = $state(false); let devSplash = $state(false);
let platformScale = $state(1);
let loginRequired = $state(false);
let loginUser = $state(store.settings.serverAuthUser ?? "");
let loginPass = $state("");
let loginError = $state<string | null>(null);
let loginBusy = $state(false);
let unsupportedMode = $state(false);
let platformScale = $state(1.0);
let _appliedZoom = -1;
let _vhRafId: number | null = null;
function applyZoom() { function applyZoom() {
const normalized = store.settings.uiScale * platformScale; const uiZoom = store.settings.uiZoom ?? 1.0;
document.documentElement.style.zoom = `${normalized}%`; if (uiZoom === _appliedZoom) return;
document.documentElement.style.setProperty("--ui-scale", String(normalized)); _appliedZoom = uiZoom;
document.documentElement.style.setProperty("--visual-vh", `${window.innerHeight / (normalized / 100)}px`);
const pct = uiZoom * 100;
document.documentElement.style.setProperty("--ui-zoom", String(uiZoom));
document.documentElement.style.setProperty("--ui-scale", String(uiZoom));
document.documentElement.style.zoom = `${pct}%`;
if (_vhRafId !== null) cancelAnimationFrame(_vhRafId);
_vhRafId = requestAnimationFrame(() => {
_vhRafId = null;
document.documentElement.style.setProperty("--visual-vh", `${window.innerHeight / uiZoom}px`);
});
} }
let prevQueue: DownloadQueueItem[] = []; let prevQueue: DownloadQueueItem[] = [];
@@ -126,7 +149,7 @@
}); });
$effect(() => { $effect(() => {
store.settings.uiScale; platformScale; void store.settings.uiZoom;
applyZoom(); applyZoom();
}); });
@@ -181,30 +204,44 @@
function startProbe() { function startProbe() {
cancelProbe = false; cancelProbe = false;
failed = false; failed = false;
loginRequired = false;
let tries = 0; let tries = 0;
async function probe() { async function probe() {
if (cancelProbe) return; if (cancelProbe) return;
tries++; tries++;
const result = await probeServer();
if (cancelProbe) return;
if (result === "ok") {
serverProbeOk = true;
loginRequired = false;
return;
}
if (result === "auth_required") {
serverProbeOk = true;
const savedUser = store.settings.serverAuthUser?.trim() ?? "";
const savedPass = store.settings.serverAuthPass?.trim() ?? "";
if (savedUser && savedPass) {
try { try {
const rawUrl = store.settings.serverUrl; await loginBasic(savedUser, savedPass);
const base = typeof rawUrl === "string" && rawUrl.trim() loginRequired = false;
? rawUrl.replace(/\/$/, "") return;
: "http://127.0.0.1:4567";
const s = store.settings;
const auth: Record<string, string> = s.serverAuthEnabled && s.serverAuthUser && s.serverAuthPass
? { Authorization: `Basic ${btoa(`${s.serverAuthUser.trim()}:${s.serverAuthPass.trim()}`)}` }
: {};
const res = await fetch(`${base}/api/graphql`, {
method: "POST",
headers: { "Content-Type": "application/json", ...auth },
body: JSON.stringify({ query: "{ __typename }" }),
signal: AbortSignal.timeout(2000),
});
if (res.ok && !cancelProbe) { serverProbeOk = true; return; }
} catch {} } catch {}
if (tries >= MAX_ATTEMPTS && !cancelProbe) { failed = true; return; } }
if (!cancelProbe) setTimeout(probe, 750); loginRequired = true;
return;
}
if (result === "unsupported_mode") {
serverProbeOk = true;
unsupportedMode = true;
return;
}
if (tries >= MAX_ATTEMPTS) { failed = true; return; }
setTimeout(probe, 750);
} }
setTimeout(probe, 800); setTimeout(probe, 800);
@@ -214,14 +251,20 @@
document.addEventListener("contextmenu", e => e.preventDefault()); document.addEventListener("contextmenu", e => e.preventDefault());
(window as any).__mokuShowSplash = () => devSplash = true; (window as any).__mokuShowSplash = () => devSplash = true;
platformScale = await invoke<number>("get_platform_ui_scale").catch(() => 1); platformScale = await invoke<number>("get_platform_ui_scale").catch(() => 1.0);
applyZoom(); applyZoom();
store.isFullscreen = await win.isFullscreen(); store.isFullscreen = await win.isFullscreen();
const unlistenResize = await win.onResized(async () => { const unlistenResize = await win.onResized(async () => {
store.isFullscreen = await win.isFullscreen(); store.isFullscreen = await win.isFullscreen();
}); });
const unlistenScale = await win.onScaleChanged(async (event) => {
platformScale = event.payload.scaleFactor;
applyZoom();
});
if (store.settings.autoStartServer) { if (store.settings.autoStartServer) {
invoke<void>("spawn_server", { binary: store.settings.serverBinary }).catch((err: any) => { invoke<void>("spawn_server", { binary: store.settings.serverBinary }).catch((err: any) => {
if (err?.kind === "NotConfigured") { if (err?.kind === "NotConfigured") {
@@ -240,6 +283,8 @@
return () => { return () => {
cancelProbe = true; cancelProbe = true;
unlistenResize(); unlistenResize();
unlistenScale();
destroyRpc();
if (store.settings.autoStartServer) invoke("kill_server").catch(() => {}); if (store.settings.autoStartServer) invoke("kill_server").catch(() => {});
if (idleTimer) clearTimeout(idleTimer); if (idleTimer) clearTimeout(idleTimer);
if (pollInterval) clearInterval(pollInterval); if (pollInterval) clearInterval(pollInterval);
@@ -254,16 +299,74 @@
return () => clearTimeout(timer); return () => clearTimeout(timer);
}); });
$effect(() => {
if (store.settings.discordRpc) {
initRpc();
} else {
clearReading();
destroyRpc();
}
});
$effect(() => {
if (!store.activeChapter) {
if (store.settings.discordRpc) setIdle();
}
});
function handleZoomKey(e: KeyboardEvent) {
if (!e.ctrlKey) return;
if (e.key === "=" || e.key === "+") {
e.preventDefault();
store.settings.uiZoom = Math.min(2.0, Math.round(((store.settings.uiZoom ?? 1.0) + 0.1) * 10) / 10);
} else if (e.key === "-") {
e.preventDefault();
store.settings.uiZoom = Math.max(0.5, Math.round(((store.settings.uiZoom ?? 1.0) - 0.1) * 10) / 10);
} else if (e.key === "0") {
e.preventDefault();
store.settings.uiZoom = 1.0;
}
}
$effect(() => {
window.addEventListener("keydown", handleZoomKey);
return () => window.removeEventListener("keydown", handleZoomKey);
});
async function handleLogin() {
if (!loginUser.trim() || !loginPass.trim()) {
loginError = "Username and password are required";
return;
}
loginBusy = true;
loginError = null;
try {
await loginBasic(loginUser.trim(), loginPass.trim());
loginRequired = false;
loginPass = "";
loginError = null;
appReady = true;
} catch (e: any) {
loginError = e?.message ?? "Login failed";
} finally {
loginBusy = false;
}
}
function handleRetry() { function handleRetry() {
failed = false; failed = false;
notConfigured = false; notConfigured = false;
serverProbeOk = false; serverProbeOk = false;
loginRequired = false;
unsupportedMode = false;
startProbe(); startProbe();
} }
function handleBypass() { function handleBypass() {
cancelProbe = true; cancelProbe = true;
serverProbeOk = true; serverProbeOk = true;
loginRequired = false;
unsupportedMode = false;
appReady = true; appReady = true;
} }
</script> </script>
@@ -271,19 +374,65 @@
{#if devSplash} {#if devSplash}
<SplashScreen mode="idle" showFps showCards={store.settings.splashCards ?? true} <SplashScreen mode="idle" showFps showCards={store.settings.splashCards ?? true}
onDismiss={() => setTimeout(() => devSplash = false, 340)} /> onDismiss={() => setTimeout(() => devSplash = false, 340)} />
{:else if !appReady} {:else if !appReady && !loginRequired && !unsupportedMode}
<SplashScreen mode="loading" ringFull={serverProbeOk} {failed} {notConfigured} <SplashScreen mode="loading" ringFull={serverProbeOk} {failed} {notConfigured}
showCards={store.settings.splashCards ?? true} showCards={store.settings.splashCards ?? true}
onReady={() => appReady = true} onReady={() => { appReady = true; }}
onRetry={handleRetry} onRetry={handleRetry}
onBypass={handleBypass} /> onBypass={handleBypass} />
{:else if unsupportedMode}
<SplashScreen mode="loading" ringFull={true} showCards={store.settings.splashCards ?? true} />
<div class="auth-overlay">
<div class="auth-card">
<img src={logoUrl} alt="Moku" class="auth-logo" />
<p class="auth-title">moku</p>
<span class="auth-mode-badge auth-mode-badge--warn">{
store.settings.serverAuthMode === "SIMPLE_LOGIN" ? "Simple Login" :
store.settings.serverAuthMode === "UI_LOGIN" ? "UI Login" : "Unsupported Auth"
}</span>
<p class="auth-host">{store.settings.serverUrl || "localhost:4567"}</p>
<p class="auth-body">
<strong>{
store.settings.serverAuthMode === "SIMPLE_LOGIN" ? "Simple Login" :
store.settings.serverAuthMode === "UI_LOGIN" ? "UI Login" : "This auth mode"
}</strong> is not supported. Switch your server to <strong>Basic Auth</strong> and update Settings → Security.
</p>
<button class="auth-btn auth-btn--ghost" onclick={handleBypass}>Continue anyway</button>
</div>
</div>
{:else if loginRequired}
<SplashScreen mode="loading" ringFull={true} showCards={store.settings.splashCards ?? true} />
<div class="auth-overlay">
<div class="auth-card">
<img src={logoUrl} alt="Moku" class="auth-logo" />
<p class="auth-title">moku</p>
<span class="auth-mode-badge">Basic Auth</span>
<p class="auth-host">{store.settings.serverUrl || "localhost:4567"}</p>
{#if loginError}
<p class="auth-error">{loginError}</p>
{/if}
<div class="auth-fields">
<input class="auth-input" type="text" placeholder="Username"
bind:value={loginUser} disabled={loginBusy} autocomplete="username"
onkeydown={(e) => e.key === "Enter" && handleLogin()} />
<input class="auth-input" type="password" placeholder="Password"
bind:value={loginPass} disabled={loginBusy} autocomplete="current-password"
onkeydown={(e) => e.key === "Enter" && handleLogin()} />
</div>
<button class="auth-btn" onclick={handleLogin}
disabled={loginBusy || !loginUser.trim() || !loginPass.trim()}>
{loginBusy ? "Signing in…" : "Sign in"}
</button>
<button class="auth-btn auth-btn--ghost" onclick={handleBypass}>Skip</button>
</div>
</div>
{:else} {:else}
<div class="root"> <div id="app-shell" class="root">
{#if idle && !store.activeChapter} {#if idle && !store.activeChapter}
<SplashScreen mode="idle" showCards={store.settings.splashCards ?? true} <SplashScreen mode="idle" showCards={store.settings.splashCards ?? true}
onDismiss={() => { idle = false; resetIdle(); }} /> onDismiss={() => { idle = false; resetIdle(); }} />
{/if} {/if}
{#if !store.activeChapter && !store.isFullscreen}<TitleBar />{/if} {#if !store.activeChapter}<TitleBar />{/if}
<div class="content"> <div class="content">
{#if store.activeChapter}<Reader />{:else}<Layout />{/if} {#if store.activeChapter}<Reader />{:else}<Layout />{/if}
</div> </div>
@@ -302,4 +451,27 @@
<style> <style>
.root { display: flex; flex-direction: column; height: 100%; overflow: hidden; } .root { display: flex; flex-direction: column; height: 100%; overflow: hidden; }
.content { flex: 1; overflow: hidden; } .content { flex: 1; overflow: hidden; }
/* Auth overlay — floats above the SplashScreen */
.auth-overlay { position: fixed; inset: 0; z-index: 10000; display: flex; align-items: center; justify-content: center; pointer-events: none; }
.auth-card { pointer-events: auto; width: min(280px, calc(100vw - 48px)); background: var(--bg-surface); border: 1px solid var(--border-base); border-radius: var(--radius-xl); padding: var(--sp-6) var(--sp-5); display: flex; flex-direction: column; align-items: center; gap: var(--sp-3); box-shadow: 0 32px 80px rgba(0,0,0,0.75); animation: authIn 0.28s cubic-bezier(0.16,1,0.3,1) both; text-align: center; }
@keyframes authIn { from { opacity: 0; transform: translateY(10px) scale(0.97); } to { opacity: 1; transform: none; } }
.auth-logo { width: 56px; height: 56px; border-radius: 14px; display: block; }
.auth-title { font-family: var(--font-ui); font-size: 11px; font-weight: 500; letter-spacing: 0.26em; text-transform: uppercase; color: var(--text-secondary); margin: -6px 0 0; user-select: none; }
.auth-mode-badge { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wider); text-transform: uppercase; color: var(--accent-fg); background: var(--accent-muted); border: 1px solid var(--accent-dim); border-radius: var(--radius-full); padding: 2px 10px; }
.auth-mode-badge--warn { color: var(--color-error); background: var(--color-error-bg); border-color: var(--color-error); }
.auth-host { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); margin: -4px 0 0; }
.auth-body { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); letter-spacing: var(--tracking-wide); line-height: var(--leading-snug); margin: 0; }
.auth-body strong { color: var(--text-secondary); font-weight: var(--weight-medium); }
.auth-error { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--color-error); background: var(--color-error-bg); border: 1px solid var(--color-error); border-radius: var(--radius-sm); padding: var(--sp-2) var(--sp-3); margin: 0; width: 100%; box-sizing: border-box; }
.auth-fields { display: flex; flex-direction: column; gap: var(--sp-2); width: 100%; }
.auth-input { width: 100%; background: var(--bg-raised); border: 1px solid var(--border-strong); border-radius: var(--radius-md); padding: 8px 12px; font-size: var(--text-sm); color: var(--text-primary); outline: none; box-sizing: border-box; transition: border-color var(--t-base), box-shadow var(--t-base); font-family: inherit; }
.auth-input:focus { border-color: var(--border-focus); box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 20%, transparent); }
.auth-input:disabled { opacity: 0.5; }
.auth-btn { width: 100%; padding: 9px; border-radius: var(--radius-md); background: var(--accent); border: 1px solid var(--accent); color: var(--accent-fg); font-size: var(--text-sm); font-family: var(--font-ui); letter-spacing: var(--tracking-wide); cursor: pointer; transition: opacity var(--t-base); }
.auth-btn:hover:not(:disabled) { opacity: 0.85; }
.auth-btn:disabled { opacity: 0.35; cursor: default; }
.auth-btn--ghost { background: none; border-color: transparent; color: var(--text-faint); font-size: var(--text-xs); padding: 4px; }
.auth-btn--ghost:hover:not(:disabled) { color: var(--text-muted); opacity: 1; }
</style> </style>
@@ -3,7 +3,7 @@
import Sidebar from "./Sidebar.svelte"; import Sidebar from "./Sidebar.svelte";
import Home from "../pages/Home.svelte"; import Home from "../pages/Home.svelte";
import Library from "../pages/Library.svelte"; import Library from "../pages/Library.svelte";
import SeriesDetail from "../pages/SeriesDetail.svelte"; import SeriesDetail from "../series/SeriesDetail.svelte";
import RecentActivity from "./RecentActivity.svelte"; import RecentActivity from "./RecentActivity.svelte";
import Search from "../pages/Search.svelte"; import Search from "../pages/Search.svelte";
import Discover from "../pages/Discover.svelte"; import Discover from "../pages/Discover.svelte";
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { ClockCounterClockwise, Trash, MagnifyingGlass, Play, Books, Fire, BookOpen, Clock, TrendUp } from "phosphor-svelte"; import { ClockCounterClockwise, Trash, MagnifyingGlass, Play, Books, Fire, BookOpen, Clock, TrendUp } from "phosphor-svelte";
import { thumbUrl } from "../../lib/client"; import Thumbnail from "../shared/Thumbnail.svelte";
import { store, clearHistory, openReader, setActiveManga } from "../../store/state.svelte"; import { store, clearHistory, openReader, setActiveManga } from "../../store/state.svelte";
import type { HistoryEntry } from "../../store/state.svelte"; import type { HistoryEntry } from "../../store/state.svelte";
@@ -192,7 +192,7 @@
{#each items as session (session.latestChapterId)} {#each items as session (session.latestChapterId)}
<button class="session-row" onclick={() => resume(session)}> <button class="session-row" onclick={() => resume(session)}>
<div class="thumb-wrap"> <div class="thumb-wrap">
<img src={thumbUrl(session.thumbnailUrl)} alt={session.mangaTitle} class="thumb" /> <Thumbnail src={session.thumbnailUrl} alt={session.mangaTitle} class="thumb" />
{#if session.chapterCount > 1} {#if session.chapterCount > 1}
<span class="session-count">{session.chapterCount}</span> <span class="session-count">{session.chapterCount}</span>
{/if} {/if}
@@ -290,7 +290,7 @@
.session-row:hover .play-pill { opacity: 1; transform: translateX(0); } .session-row:hover .play-pill { opacity: 1; transform: translateX(0); }
.thumb-wrap { position: relative; flex-shrink: 0; } .thumb-wrap { position: relative; flex-shrink: 0; }
.thumb { width: 38px; height: 54px; border-radius: var(--radius-sm); object-fit: cover; display: block; background: var(--bg-raised); border: 1px solid var(--border-dim); } :global(.thumb) { width: 38px; height: 54px; border-radius: var(--radius-sm); object-fit: cover; display: block; background: var(--bg-raised); border: 1px solid var(--border-dim); }
.session-count { .session-count {
position: absolute; bottom: -4px; right: -6px; position: absolute; bottom: -4px; right: -6px;
background: var(--accent-muted); border: 1px solid var(--accent-dim); color: var(--accent-fg); background: var(--accent-muted); border: 1px solid var(--accent-dim); color: var(--accent-fg);
@@ -50,19 +50,20 @@
</aside> </aside>
<style> <style>
.root { width: var(--sidebar-width); flex-shrink: 0; background: var(--bg-void); display: flex; flex-direction: column; align-items: center; padding: var(--sp-4) 0; } .root { width: var(--sidebar-width); flex-shrink: 0; background: var(--bg-void); display: flex; flex-direction: column; align-items: center; padding: var(--sp-4) 0; overflow: hidden; min-height: 0; height: 100%; }
.logo { width: 80px; height: 80px; display: flex; align-items: center; justify-content: center; margin-bottom: var(--sp-3); background: none; border: none; outline: none; cursor: pointer; border-radius: var(--radius-lg); transition: opacity var(--t-base), transform var(--t-base); padding: 0; appearance: none; -webkit-appearance: none; } .logo { width: 80px; height: 80px; flex-shrink: 0; display: flex; align-items: center; justify-content: center; margin-bottom: var(--sp-3); background: none; border: none; outline: none; cursor: pointer; border-radius: var(--radius-lg); transition: opacity var(--t-base), transform var(--t-base); padding: 0; appearance: none; -webkit-appearance: none; }
.logo:hover { opacity: 0.8; transform: scale(0.96); } .logo:hover { opacity: 0.8; transform: scale(0.96); }
.logo:active { transform: scale(0.92); } .logo:active { transform: scale(0.92); }
.logo:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } .logo:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.logo-icon { width: 67px; height: 67px; background-color: var(--accent); mask-image: url("../../assets/moku-icon-wordmark.svg"); mask-repeat: no-repeat; mask-position: center; mask-size: contain; -webkit-mask-image: url("../../assets/moku-icon-wordmark.svg"); -webkit-mask-repeat: no-repeat; -webkit-mask-position: center; -webkit-mask-size: contain; filter: drop-shadow(0 0 8px rgba(107,143,107,0.35)); pointer-events: none; } .logo-icon { width: 67px; height: 67px; background-color: var(--accent); mask-image: url("../../assets/moku-icon-wordmark.svg"); mask-repeat: no-repeat; mask-position: center; mask-size: contain; -webkit-mask-image: url("../../assets/moku-icon-wordmark.svg"); -webkit-mask-repeat: no-repeat; -webkit-mask-position: center; -webkit-mask-size: contain; filter: drop-shadow(0 0 8px rgba(107,143,107,0.35)); pointer-events: none; }
.nav { flex: 1; display: flex; flex-direction: column; align-items: center; gap: var(--sp-1); width: 100%; padding: 0 var(--sp-2); } .nav { flex: 1; min-height: 0; display: flex; flex-direction: column; align-items: center; gap: var(--sp-1); width: 100%; padding: 0 var(--sp-2); overflow-y: auto; overflow-x: hidden; scrollbar-width: none; }
.tab { width: 36px; height: 36px; display: flex; align-items: center; justify-content: center; border-radius: var(--radius-md); color: var(--text-faint); background: none; border: none; outline: none; cursor: pointer; padding: 0; appearance: none; -webkit-appearance: none; transition: color var(--t-base), background var(--t-base); } .nav::-webkit-scrollbar { display: none; }
.tab { width: 36px; height: 36px; flex-shrink: 0; display: flex; align-items: center; justify-content: center; border-radius: var(--radius-md); color: var(--text-faint); background: none; border: none; outline: none; cursor: pointer; padding: 0; appearance: none; -webkit-appearance: none; transition: color var(--t-base), background var(--t-base); }
.tab:hover { color: var(--text-muted); background: var(--bg-raised); } .tab:hover { color: var(--text-muted); background: var(--bg-raised); }
.tab:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; } .tab:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
.tab.active { color: var(--accent-fg); background: var(--accent-muted); } .tab.active { color: var(--accent-fg); background: var(--accent-muted); }
.tab.active:hover { color: var(--accent-fg); background: var(--accent-muted); } .tab.active:hover { color: var(--accent-fg); background: var(--accent-muted); }
.bottom { display: flex; flex-direction: column; align-items: center; width: 100%; padding: var(--sp-3) var(--sp-2) 0; border-top: 1px solid var(--border-dim); margin-top: var(--sp-3); } .bottom { flex-shrink: 0; display: flex; flex-direction: column; align-items: center; width: 100%; padding: var(--sp-3) var(--sp-2) 0; border-top: 1px solid var(--border-dim); margin-top: var(--sp-3); }
.settings-btn { width: 36px; height: 36px; display: flex; align-items: center; justify-content: center; border-radius: var(--radius-md); color: var(--text-faint); background: none; border: none; outline: none; cursor: pointer; padding: 0; appearance: none; -webkit-appearance: none; transition: color var(--t-base), background var(--t-base), transform var(--t-slow); } .settings-btn { width: 36px; height: 36px; display: flex; align-items: center; justify-content: center; border-radius: var(--radius-md); color: var(--text-faint); background: none; border: none; outline: none; cursor: pointer; padding: 0; appearance: none; -webkit-appearance: none; transition: color var(--t-base), background var(--t-base), transform var(--t-slow); }
.settings-btn:hover { color: var(--text-muted); background: var(--bg-raised); transform: rotate(30deg); } .settings-btn:hover { color: var(--text-muted); background: var(--bg-raised); transform: rotate(30deg); }
.settings-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; } .settings-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
@@ -17,8 +17,11 @@
onDismiss?: () => void; onDismiss?: () => void;
} }
let { mode = "loading", ringFull = false, failed = false, notConfigured = false, let {
showCards = true, showFps = false, onReady, onRetry, onBypass, onDismiss }: Props = $props(); mode = "loading", ringFull = false, failed = false,
notConfigured = false, showCards = true, showFps = false,
onReady, onRetry, onBypass, onDismiss,
}: Props = $props();
const lockEnabled = $derived( const lockEnabled = $derived(
store.settings.appLockEnabled && (store.settings.appLockPin?.length ?? 0) >= 4 store.settings.appLockEnabled && (store.settings.appLockPin?.length ?? 0) >= 4
@@ -28,6 +31,21 @@
let pinShake = $state(false); let pinShake = $state(false);
let pinUnlocked = $state(false); let pinUnlocked = $state(false);
let pinVisible = $state(false); let pinVisible = $state(false);
let uiScale = $state(1);
let fpsEl = $state<HTMLSpanElement | undefined>(undefined);
const logoLoadingSize = 140;
const logoIdleSize = 128;
const logoLockSize = 96;
const ringR = $derived(70);
const ringPad = $derived(12);
const ringSize = $derived((ringR + ringPad) * 2);
const ringC = $derived(ringR + ringPad);
const ringCirc = $derived(2 * Math.PI * ringR);
const ringArc = $derived(ringCirc * Math.min(Math.max(ringProg, 0.025), 0.999));
const ringTop = $derived(-((ringSize - logoLoadingSize) / 2));
const ringLeft = $derived(-((ringSize - logoLoadingSize) / 2));
function submitPin() { function submitPin() {
if (pinEntry === store.settings.appLockPin) { if (pinEntry === store.settings.appLockPin) {
@@ -37,7 +55,7 @@
} else { } else {
pinShake = true; pinShake = true;
pinEntry = ""; pinEntry = "";
setTimeout(() => pinShake = false, 500); setTimeout(() => (pinShake = false), 500);
} }
} }
@@ -50,9 +68,6 @@
} }
} }
function handleRetry() { onRetry?.(); }
function handleBypass() { onBypass?.(); }
const EXIT_MS = 320; const EXIT_MS = 320;
const PHASE1_TARGET = 0.85; const PHASE1_TARGET = 0.85;
const PHASE1_MS = 3000; const PHASE1_MS = 3000;
@@ -64,8 +79,6 @@
let exiting = $state(false); let exiting = $state(false);
let exitLock = false; let exitLock = false;
let fpsEl = $state<HTMLSpanElement | undefined>(undefined);
function triggerExit(cb?: () => void) { function triggerExit(cb?: () => void) {
if (exitLock) return; if (exitLock) return;
exitLock = true; exitLock = true;
@@ -81,18 +94,14 @@
if (exitLock) return; if (exitLock) return;
if (animStart === null) animStart = ts; if (animStart === null) animStart = ts;
const elapsed = ts - animStart; const elapsed = ts - animStart;
if (animPhase === 1) { if (animPhase === 1) {
const t = Math.min(elapsed / PHASE1_MS, 1); const t = Math.min(elapsed / PHASE1_MS, 1);
const eased = 1 - Math.pow(1 - t, 3); ringProg = 0.025 + (1 - Math.pow(1 - t, 3)) * (PHASE1_TARGET - 0.025);
ringProg = 0.025 + eased * (PHASE1_TARGET - 0.025);
if (t >= 1) { animPhase = 2; animStart = ts; } if (t >= 1) { animPhase = 2; animStart = ts; }
} else if (animPhase === 2) { } else {
const t = Math.min(elapsed / PHASE2_MS, 1); const t = Math.min(elapsed / PHASE2_MS, 1);
const eased = 1 - Math.pow(1 - t, 4); ringProg = PHASE1_TARGET + (1 - Math.pow(1 - t, 4)) * (PHASE2_TARGET - PHASE1_TARGET);
ringProg = PHASE1_TARGET + eased * (PHASE2_TARGET - PHASE1_TARGET);
} }
animFrame = requestAnimationFrame(animateRing); animFrame = requestAnimationFrame(animateRing);
} }
@@ -104,26 +113,39 @@
}); });
$effect(() => { $effect(() => {
if (ringFull) { if (!ringFull) return;
cancelAnimationFrame(animFrame); cancelAnimationFrame(animFrame);
ringProg = 1; ringProg = 1;
if (lockEnabled && !pinUnlocked) { if (lockEnabled && !pinUnlocked) {
setTimeout(() => { pinVisible = true; }, 400); setTimeout(() => (pinVisible = true), 400);
} else { } else {
setTimeout(() => triggerExit(onReady), 650); setTimeout(() => triggerExit(onReady), 650);
} }
} });
$effect(() => {
const needsPin =
(mode === "idle" && lockEnabled) ||
(mode === "loading" && lockEnabled && ringFull && !pinUnlocked);
if (!needsPin) return;
window.addEventListener("keydown", onPinKey);
return () => window.removeEventListener("keydown", onPinKey);
});
$effect(() => {
if (pinUnlocked && mode !== "idle") triggerExit(onReady);
}); });
const dotsInterval = setInterval(() => { const dotsInterval = setInterval(() => {
dots = dots.length >= 3 ? "" : dots + "."; dots = dots.length >= 3 ? "" : dots + ".";
}, 420); }, 420);
onMount(() => { onMount(async () => {
const win = getCurrentWindow();
uiScale = await win.scaleFactor();
if (mode === "idle" && onDismiss) { if (mode === "idle" && onDismiss) {
if (lockEnabled) { if (lockEnabled) return () => clearInterval(dotsInterval);
return () => clearInterval(dotsInterval);
}
const handler = () => triggerExit(onDismiss); const handler = () => triggerExit(onDismiss);
const t = setTimeout(() => { const t = setTimeout(() => {
window.addEventListener("keydown", handler, { once: true }); window.addEventListener("keydown", handler, { once: true });
@@ -143,6 +165,7 @@
interface CardDef { cx: number; w: number; h: number; lines: number; alpha: number; speed: number; cycleSec: number; phase: number; travel: number; yStart: number; angleStart: number; tilt: number; } interface CardDef { cx: number; w: number; h: number; lines: number; alpha: number; speed: number; cycleSec: number; phase: number; travel: number; yStart: number; angleStart: number; tilt: number; }
interface CardTrig { cosA: number; sinA: number; tiltRad: number; } interface CardTrig { cosA: number; sinA: number; tiltRad: number; }
interface RenderState { cards: CardDef[]; trigs: CardTrig[]; stamps: HTMLCanvasElement[]; vignette: HTMLCanvasElement; CW: number; CH: number; scale: number; }
const LAYER_CFG = [ const LAYER_CFG = [
{ wMin: 26, wMax: 40, speedMin: 30, speedMax: 50, alpha: 0.22 }, { wMin: 26, wMax: 40, speedMin: 30, speedMax: 50, alpha: 0.22 },
@@ -159,7 +182,8 @@
} }
function buildCards(vw: number, vh: number) { function buildCards(vw: number, vh: number) {
const cards: CardDef[] = [], laneW = vw / COLS; const cards: CardDef[] = [];
const laneW = vw / COLS;
for (let layer = 0; layer < 3; layer++) { for (let layer = 0; layer < 3; layer++) {
const cfg = LAYER_CFG[layer]; const cfg = LAYER_CFG[layer];
for (let col = 0; col < COLS; col++) { for (let col = 0; col < COLS; col++) {
@@ -170,10 +194,14 @@
const travel = vh + h + BUF; const travel = vh + h + BUF;
cards.push({ cards.push({
cx: (col + 0.5) * laneW + (hash(seed + 2) * 2 - 1) * Math.max(0, (laneW - w) / 2 - 2), cx: (col + 0.5) * laneW + (hash(seed + 2) * 2 - 1) * Math.max(0, (laneW - w) / 2 - 2),
w, h, lines: 1 + Math.floor(hash(seed + 7) * 3), alpha: cfg.alpha, speed, w, h,
lines: 1 + Math.floor(hash(seed + 7) * 3),
alpha: cfg.alpha,
speed,
cycleSec: travel / speed, cycleSec: travel / speed,
phase: ((col / COLS) + hash(seed + 6) * 0.6 + layer * 0.23) % 1, phase: ((col / COLS) + hash(seed + 6) * 0.6 + layer * 0.23) % 1,
travel, yStart: vh + h / 2 + BUF / 2, travel,
yStart: vh + h / 2 + BUF / 2,
angleStart: hash(seed + 3) * 50 - 25, angleStart: hash(seed + 3) * 50 - 25,
tilt: (hash(seed + 4) * 2 - 1) * 18, tilt: (hash(seed + 4) * 2 - 1) * 18,
}); });
@@ -205,11 +233,12 @@
const ctx = oc.getContext("2d")!; const ctx = oc.getContext("2d")!;
ctx.scale(dpr, dpr); ctx.scale(dpr, dpr);
const x0 = STAMP_PAD, y0 = STAMP_PAD; const x0 = STAMP_PAD, y0 = STAMP_PAD;
const coverH = (c.w * 0.72) * 1.05; const coverH = c.w * 0.72 * 1.05;
const lineY0 = y0 + 3 + coverH + 5; const lineY0 = y0 + 3 + coverH + 5;
ctx.fillStyle = "rgba(0,0,0,0.5)"; rrect(ctx, x0 + 2, y0 + 2, c.w, c.h, 4); ctx.fill(); ctx.fillStyle = "rgba(0,0,0,0.5)"; rrect(ctx, x0 + 2, y0 + 2, c.w, c.h, 4); ctx.fill();
ctx.fillStyle = "rgba(255,255,255,0.07)"; rrect(ctx, x0, y0, c.w, c.h, 4); ctx.fill(); ctx.fillStyle = "rgba(255,255,255,0.07)"; rrect(ctx, x0, y0, c.w, c.h, 4); ctx.fill();
ctx.strokeStyle = "rgba(255,255,255,0.75)"; ctx.lineWidth = 1.2; rrect(ctx, x0, y0, c.w, c.h, 4); ctx.stroke(); ctx.strokeStyle = "rgba(255,255,255,0.75)";
ctx.lineWidth = 1.2; rrect(ctx, x0, y0, c.w, c.h, 4); ctx.stroke();
ctx.fillStyle = "rgba(255,255,255,0.15)"; rrect(ctx, x0 + 3, y0 + 3, c.w - 6, coverH, 3); ctx.fill(); ctx.fillStyle = "rgba(255,255,255,0.15)"; rrect(ctx, x0 + 3, y0 + 3, c.w - 6, coverH, 3); ctx.fill();
ctx.fillStyle = "rgba(255,255,255,0.08)"; rrect(ctx, x0 + 3, y0 + 3, (c.w - 6) * 0.45, coverH, 3); ctx.fill(); ctx.fillStyle = "rgba(255,255,255,0.08)"; rrect(ctx, x0 + 3, y0 + 3, (c.w - 6) * 0.45, coverH, 3); ctx.fill();
for (let li = 0; li < c.lines; li++) { for (let li = 0; li < c.lines; li++) {
@@ -221,12 +250,17 @@
function buildVignette(vw: number, vh: number, dpr: number): HTMLCanvasElement { function buildVignette(vw: number, vh: number, dpr: number): HTMLCanvasElement {
const oc = document.createElement("canvas"); const oc = document.createElement("canvas");
oc.width = Math.round(vw * dpr); oc.height = Math.round(vh * dpr); oc.width = Math.round(vw * dpr);
oc.height = Math.round(vh * dpr);
const ctx = oc.getContext("2d")!; const ctx = oc.getContext("2d")!;
ctx.scale(dpr, dpr); ctx.scale(dpr, dpr);
const g = ctx.createRadialGradient(vw / 2, vh / 2, 0, vw / 2, vh / 2, Math.max(vw, vh) * 0.65); const g = ctx.createRadialGradient(vw / 2, vh / 2, 0, vw / 2, vh / 2, Math.max(vw, vh) * 0.65);
g.addColorStop(0, "rgba(0,0,0,0)"); g.addColorStop(0.4, "rgba(0,0,0,0)"); g.addColorStop(0.7, "rgba(0,0,0,0.25)"); g.addColorStop(1, "rgba(0,0,0,0.65)"); g.addColorStop(0, "rgba(0,0,0,0)");
ctx.fillStyle = g; ctx.fillRect(0, 0, vw, vh); g.addColorStop(0.4, "rgba(0,0,0,0)");
g.addColorStop(0.7, "rgba(0,0,0,0.25)");
g.addColorStop(1, "rgba(0,0,0,0.65)");
ctx.fillStyle = g;
ctx.fillRect(0, 0, vw, vh);
return oc; return oc;
} }
@@ -250,7 +284,8 @@
const sw = stamps[i].width / dpr, sh = stamps[i].height / dpr; const sw = stamps[i].width / dpr, sh = stamps[i].height / dpr;
ctx.drawImage(stamps[i], -sw / 2, -sh / 2, sw, sh); ctx.drawImage(stamps[i], -sw / 2, -sh / 2, sw, sh);
} }
ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.globalAlpha = 1; ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.globalAlpha = 1;
ctx.drawImage(vignette, 0, 0, cw, ch); ctx.drawImage(vignette, 0, 0, cw, ch);
} }
@@ -259,7 +294,8 @@
fpsFrames++; fpsFrames++;
if (now - fpsLast >= 500) { if (now - fpsLast >= 500) {
fps = Math.round(fpsFrames / ((now - fpsLast) / 1000)); fps = Math.round(fpsFrames / ((now - fpsLast) / 1000));
fpsFrames = 0; fpsLast = now; fpsFrames = 0;
fpsLast = now;
if (fpsEl) fpsEl.textContent = `${fps} fps`; if (fpsEl) fpsEl.textContent = `${fps} fps`;
} }
} }
@@ -267,10 +303,6 @@
function mountCanvas(el: HTMLCanvasElement) { function mountCanvas(el: HTMLCanvasElement) {
const win = getCurrentWindow(); const win = getCurrentWindow();
const ctx = el.getContext("2d")!; const ctx = el.getContext("2d")!;
interface RenderState {
cards: CardDef[]; trigs: CardTrig[]; stamps: HTMLCanvasElement[];
vignette: HTMLCanvasElement; CW: number; CH: number; scale: number;
}
let live: RenderState | null = null; let live: RenderState | null = null;
let lastLogW = 0, lastLogH = 0, lastScale = 0, buildGen = 0; let lastLogW = 0, lastLogH = 0, lastScale = 0, buildGen = 0;
@@ -289,7 +321,8 @@
} }
const ro = new ResizeObserver(() => syncSize()); const ro = new ResizeObserver(() => syncSize());
ro.observe(el); syncSize(); ro.observe(el);
syncSize();
let raf = 0, t0 = -1; let raf = 0, t0 = -1;
function frame(now: number) { function frame(now: number) {
@@ -303,30 +336,6 @@
raf = requestAnimationFrame(frame); raf = requestAnimationFrame(frame);
return () => { cancelAnimationFrame(raf); ro.disconnect(); }; return () => { cancelAnimationFrame(raf); ro.disconnect(); };
} }
$effect(() => {
const needsPin =
(mode === "idle" && lockEnabled) ||
(mode === "loading" && lockEnabled && ringFull && !pinUnlocked);
if (!needsPin) return;
window.addEventListener("keydown", onPinKey);
return () => window.removeEventListener("keydown", onPinKey);
});
$effect(() => {
if (pinUnlocked && mode !== "idle") {
triggerExit(onReady);
}
});
const ringR = $derived(70);
const ringPad = $derived(12);
const ringSize = $derived((ringR + ringPad) * 2);
const ringC = $derived(ringR + ringPad);
const ringCirc = $derived(2 * Math.PI * ringR);
const ringArc = $derived(ringCirc * Math.min(Math.max(ringProg, 0.025), 0.999));
const ringTop = $derived(-((ringSize - 140) / 2));
const ringLeft = $derived(-((ringSize - 140) / 2));
</script> </script>
<div class="splash" class:exiting style="cursor: {mode === 'idle' && !lockEnabled ? 'pointer' : 'default'}"> <div class="splash" class:exiting style="cursor: {mode === 'idle' && !lockEnabled ? 'pointer' : 'default'}">
@@ -339,9 +348,9 @@
{#if mode === "idle" && lockEnabled} {#if mode === "idle" && lockEnabled}
<div style="z-index:1;display:flex;flex-direction:column;align-items:center;gap:var(--sp-6)"> <div style="z-index:1;display:flex;flex-direction:column;align-items:center;gap:var(--sp-6)">
<div style="position:relative;width:96px;height:96px"> <div style="position:relative;width:{logoLockSize}px;height:{logoLockSize}px">
<div class="logo-glow"></div> <div class="logo-glow"></div>
<img src={logoUrl} alt="Moku" class="logo-breathe" style="width:96px;height:96px;border-radius:22px;display:block;position:relative" /> <img src={logoUrl} alt="Moku" class="logo-breathe" style="width:{logoLockSize}px;height:{logoLockSize}px;border-radius:22px;display:block;position:relative" />
</div> </div>
<div class="pin-block"> <div class="pin-block">
<div class="pin-dots" class:pin-shake={pinShake}> <div class="pin-dots" class:pin-shake={pinShake}>
@@ -355,15 +364,15 @@
{:else if mode === "idle"} {:else if mode === "idle"}
<div style="z-index:1;display:flex;flex-direction:column;align-items:center"> <div style="z-index:1;display:flex;flex-direction:column;align-items:center">
<div style="position:relative;width:128px;height:128px;margin-bottom:32px"> <div style="position:relative;width:{logoIdleSize}px;height:{logoIdleSize}px;margin-bottom:32px">
<div class="logo-glow"></div> <div class="logo-glow"></div>
<img src={logoUrl} alt="Moku" class="logo-breathe" style="width:128px;height:128px;border-radius:28px;display:block;position:relative" /> <img src={logoUrl} alt="Moku" class="logo-breathe" style="width:{logoIdleSize}px;height:{logoIdleSize}px;border-radius:28px;display:block;position:relative" />
</div> </div>
<p class="hint">press any key to continue</p> <p class="hint">press any key to continue</p>
</div> </div>
{:else} {:else}
<div style="position:relative;width:140px;height:140px;margin-bottom:20px;z-index:1"> <div style="position:relative;width:{logoLoadingSize}px;height:{logoLoadingSize}px;margin-bottom:20px;z-index:1">
{#if !failed && !notConfigured} {#if !failed && !notConfigured}
<svg width={ringSize} height={ringSize} <svg width={ringSize} height={ringSize}
class="loading-ring" class="loading-ring"
@@ -377,7 +386,7 @@
style="transition: stroke-dasharray 0.4s cubic-bezier(0.4,0,0.2,1)" /> style="transition: stroke-dasharray 0.4s cubic-bezier(0.4,0,0.2,1)" />
</svg> </svg>
{/if} {/if}
<img src={logoUrl} alt="Moku" style="width:140px;height:140px;border-radius:32px;display:block" /> <img src={logoUrl} alt="Moku" style="width:{logoLoadingSize}px;height:{logoLoadingSize}px;border-radius:32px;display:block" />
</div> </div>
<p class="title-label">moku</p> <p class="title-label">moku</p>
@@ -385,12 +394,10 @@
<div class="status-slot" class:status-slot-hide={lockEnabled && pinVisible}> <div class="status-slot" class:status-slot-hide={lockEnabled && pinVisible}>
{#if failed || notConfigured} {#if failed || notConfigured}
<div class="error-box"> <div class="error-box">
<p class="error-label"> <p class="error-label">{failed ? "Could not reach server" : "Server not configured"}</p>
{failed ? "Could not reach server" : "Server not configured"}
</p>
<div class="error-actions"> <div class="error-actions">
<button class="err-btn" onclick={handleRetry}>Retry</button> <button class="err-btn" onclick={() => onRetry?.()}>Retry</button>
<button class="err-btn err-btn--primary" onclick={handleBypass}>Enter app</button> <button class="err-btn err-btn--primary" onclick={() => onBypass?.()}>Enter app</button>
</div> </div>
</div> </div>
{:else} {:else}
@@ -415,16 +422,20 @@
<style> <style>
.splash { position: fixed; inset: 0; z-index: 9999; background: var(--bg-base); overflow: hidden; display: flex; flex-direction: column; align-items: center; justify-content: center; animation: spIn 0.35s cubic-bezier(0,0,0.2,1) both; } .splash { position: fixed; inset: 0; z-index: 9999; background: var(--bg-base); overflow: hidden; display: flex; flex-direction: column; align-items: center; justify-content: center; animation: spIn 0.35s cubic-bezier(0,0,0.2,1) both; }
.splash.exiting { animation: spOut 320ms cubic-bezier(0.4,0,1,1) both; } .splash.exiting { animation: spOut 320ms cubic-bezier(0.4,0,1,1) both; }
@keyframes spIn { from { opacity:0; transform:scale(1.015) } to { opacity:1; transform:scale(1) } } @keyframes spIn { from { opacity:0; transform:scale(1.015) } to { opacity:1; transform:scale(1) } }
@keyframes spOut { from { opacity:1; transform:scale(1) } to { opacity:0; transform:scale(0.96) } } @keyframes spOut { from { opacity:1; transform:scale(1) } to { opacity:0; transform:scale(0.96) } }
@keyframes logoBreathe { 0%,100% { transform:scale(1); filter:drop-shadow(0 0 0px rgba(255,255,255,0)) } 50% { transform:scale(1.04); filter:drop-shadow(0 0 18px rgba(255,255,255,0.12)) } } @keyframes logoBreathe { 0%,100% { transform:scale(1); filter:drop-shadow(0 0 0px rgba(255,255,255,0)) } 50% { transform:scale(1.04); filter:drop-shadow(0 0 18px rgba(255,255,255,0.12)) } }
@keyframes hintFade { 0%,100% { opacity:0.35 } 50% { opacity:0.7 } } @keyframes hintFade { 0%,100% { opacity:0.35 } 50% { opacity:0.7 } }
@keyframes errIn { from { opacity:0; transform:translateY(4px) } to { opacity:1; transform:translateY(0) } }
@keyframes pinShake { 0%,100% { transform:translateX(0) } 20%,60% { transform:translateX(-6px) } 40%,80% { transform:translateX(6px) } }
.logo-glow { position: absolute; inset: -20px; border-radius: 50%; background: radial-gradient(circle, rgba(255,255,255,0.06) 0%, transparent 70%); animation: logoBreathe 4s ease-in-out infinite; } .logo-glow { position: absolute; inset: -20px; border-radius: 50%; background: radial-gradient(circle, rgba(255,255,255,0.06) 0%, transparent 70%); animation: logoBreathe 4s ease-in-out infinite; }
.logo-breathe { animation: logoBreathe 4s ease-in-out infinite; } .logo-breathe { animation: logoBreathe 4s ease-in-out infinite; }
.hint { font-family: var(--font-ui); font-size: 10px; color: var(--text-faint); letter-spacing: 0.22em; text-transform: uppercase; margin: 0; user-select: none; animation: hintFade 3.5s ease-in-out infinite; } .hint { font-family: var(--font-ui); font-size: 10px; color: var(--text-faint); letter-spacing: 0.22em; text-transform: uppercase; margin: 0; user-select: none; animation: hintFade 3.5s ease-in-out infinite; }
.title-label { font-family: var(--font-ui); font-size: 11px; font-weight: 500; letter-spacing: 0.26em; text-transform: uppercase; color: var(--text-secondary); margin: 0 0 8px; z-index: 1; user-select: none; } .title-label { font-family: var(--font-ui); font-size: 11px; font-weight: 500; letter-spacing: 0.26em; text-transform: uppercase; color: var(--text-secondary); margin: 0 0 8px; z-index: 1; user-select: none; }
.error-box { display: flex; flex-direction: column; align-items: center; gap: 12px; padding: 16px 20px; border-radius: var(--radius-lg); background: var(--bg-surface); border: 1px solid var(--border-base); min-width: 200px; text-align: center; animation: errIn 0.25s cubic-bezier(0,0,0.2,1) both; } .error-box { display: flex; flex-direction: column; align-items: center; gap: 12px; padding: 16px 20px; border-radius: var(--radius-lg); background: var(--bg-surface); border: 1px solid var(--border-base); min-width: 200px; text-align: center; animation: errIn 0.25s cubic-bezier(0,0,0.2,1) both; }
@keyframes errIn { from { opacity:0; transform:translateY(4px) } to { opacity:1; transform:translateY(0) } }
.error-label { font-family: var(--font-ui); font-size: 11px; font-weight: 500; color: var(--text-muted); letter-spacing: 0.06em; margin: 0; } .error-label { font-family: var(--font-ui); font-size: 11px; font-weight: 500; color: var(--text-muted); letter-spacing: 0.06em; margin: 0; }
.error-actions { display: flex; gap: 6px; } .error-actions { display: flex; gap: 6px; }
.err-btn { padding: 5px 14px; border-radius: var(--radius-md); border: 1px solid var(--border-base); background: transparent; color: var(--text-muted); cursor: pointer; font-family: var(--font-ui); font-size: 11px; letter-spacing: 0.04em; transition: border-color 0.15s, color 0.15s; } .err-btn { padding: 5px 14px; border-radius: var(--radius-md); border: 1px solid var(--border-base); background: transparent; color: var(--text-muted); cursor: pointer; font-family: var(--font-ui); font-size: 11px; letter-spacing: 0.04em; transition: border-color 0.15s, color 0.15s; }
@@ -438,13 +449,13 @@
.status-text { font-family: var(--font-ui); font-size: 10px; color: var(--text-faint); letter-spacing: 0.12em; margin: 0; min-width: 160px; text-align: center; } .status-text { font-family: var(--font-ui); font-size: 10px; color: var(--text-faint); letter-spacing: 0.12em; margin: 0; min-width: 160px; text-align: center; }
.loading-ring { transition: opacity 0.5s ease; } .loading-ring { transition: opacity 0.5s ease; }
.ring-hide { opacity: 0; } .ring-hide { opacity: 0; }
.pin-slot { display: flex; flex-direction: column; align-items: center; gap: var(--sp-3); position: absolute; opacity: 0; transform: translateY(4px); transition: opacity 0.4s ease, transform 0.4s ease; pointer-events: none; } .pin-slot { display: flex; flex-direction: column; align-items: center; gap: var(--sp-3); position: absolute; opacity: 0; transform: translateY(4px); transition: opacity 0.4s ease, transform 0.4s ease; pointer-events: none; }
.pin-slot-visible { opacity: 1; transform: translateY(0); pointer-events: auto; } .pin-slot-visible { opacity: 1; transform: translateY(0); pointer-events: auto; }
.pin-block { display: flex; flex-direction: column; align-items: center; gap: var(--sp-3); position: relative; } .pin-block { display: flex; flex-direction: column; align-items: center; gap: var(--sp-3); position: relative; }
.pin-dots { display: flex; gap: 12px; align-items: center; } .pin-dots { display: flex; gap: 12px; align-items: center; }
.pin-dot { width: 10px; height: 10px; border-radius: 50%; border: 1px solid var(--border-strong); background: transparent; transition: background 0.12s, border-color 0.12s; } .pin-dot { width: 10px; height: 10px; border-radius: 50%; border: 1px solid var(--border-strong); background: transparent; transition: background 0.12s, border-color 0.12s; }
.pin-dot-filled { background: var(--accent); border-color: var(--accent); } .pin-dot-filled { background: var(--accent); border-color: var(--accent); }
@keyframes pinShake { 0%,100% { transform:translateX(0) } 20%,60% { transform:translateX(-6px) } 40%,80% { transform:translateX(6px) } }
.pin-shake { animation: pinShake 0.42s ease; } .pin-shake { animation: pinShake 0.42s ease; }
.pin-submit-btn { opacity: 0; width: 1px; height: 1px; overflow: hidden; padding: 0; border: none; background: none; cursor: pointer; pointer-events: auto; position: absolute; } .pin-submit-btn { opacity: 0; width: 1px; height: 1px; overflow: hidden; padding: 0; border: none; background: none; cursor: pointer; pointer-events: auto; position: absolute; }
</style> </style>
+127
View File
@@ -0,0 +1,127 @@
<script lang="ts">
import { onMount } from "svelte";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { platform } from "@tauri-apps/plugin-os";
const win = getCurrentWindow();
const os = platform();
const isMac = os === "macos";
const isWindows = os === "windows";
let isFullscreen = $state(false);
onMount(async () => {
isFullscreen = await win.isFullscreen();
const unlisten = await win.onResized(async () => {
isFullscreen = await win.isFullscreen();
});
return unlisten;
});
</script>
{#if !isFullscreen}
<div class="bar" data-tauri-drag-region>
{#if isMac}<div class="mac-spacer"></div>{/if}
<span class="title" data-tauri-drag-region>Moku</span>
{#if !isMac}
<div class="controls">
<button onclick={() => win.minimize()} title="Minimize" aria-label="Minimize">
<svg width="10" height="1" viewBox="0 0 10 1">
<line x1="0" y1="0.5" x2="10" y2="0.5" stroke="currentColor" stroke-width="1.5" />
</svg>
</button>
<button onclick={() => win.toggleMaximize()} title="Maximize" aria-label="Maximize">
<svg width="9" height="9" viewBox="0 0 9 9">
<rect x="0.75" y="0.75" width="7.5" height="7.5" rx="1" fill="none" stroke="currentColor" stroke-width="1.5" />
</svg>
</button>
<button class="close" onclick={() => win.close()} title="Close" aria-label="Close">
<svg width="10" height="10" viewBox="0 0 10 10">
<line x1="1" y1="1" x2="9" y2="9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
<line x1="9" y1="1" x2="1" y2="9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
</svg>
</button>
</div>
{/if}
</div>
{:else if isWindows}
<!-- On Windows, fullscreen hides the native titlebar — show a hoverable overlay so the user isn't locked in -->
<div class="fullscreen-controls">
<button onclick={() => win.setFullscreen(false)} title="Exit Fullscreen" aria-label="Exit Fullscreen">
<svg width="10" height="10" viewBox="0 0 10 10">
<polyline points="1,4 1,1 4,1" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<polyline points="6,1 9,1 9,4" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<polyline points="9,6 9,9 6,9" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<polyline points="4,9 1,9 1,6" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
<button class="close" onclick={() => win.close()} title="Close" aria-label="Close">
<svg width="10" height="10" viewBox="0 0 10 10">
<line x1="1" y1="1" x2="9" y2="9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
<line x1="9" y1="1" x2="1" y2="9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
</svg>
</button>
</div>
{/if}
<style>
.fullscreen-controls {
position: fixed;
top: 0;
right: 0;
z-index: 9999;
display: flex;
align-items: center;
gap: 2px;
padding: 4px;
opacity: 0;
transition: opacity 0.2s ease;
-webkit-app-region: no-drag;
}
.fullscreen-controls:hover { opacity: 1; }
.bar {
display: flex;
align-items: center;
justify-content: space-between;
height: 32px;
padding: 0 var(--sp-3) 0 var(--sp-4);
background: var(--bg-void);
border-bottom: 1px solid var(--border-dim);
flex-shrink: 0;
user-select: none;
-webkit-app-region: drag;
}
/* Spacer to clear the native macOS traffic lights (~70px) */
.mac-spacer {
width: 70px;
flex-shrink: 0;
-webkit-app-region: drag;
}
.title {
font-family: var(--font-ui);
font-size: var(--text-2xs);
color: var(--text-faint);
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
-webkit-app-region: drag;
}
.controls {
display: flex;
align-items: center;
gap: 2px;
-webkit-app-region: no-drag;
}
button {
display: flex;
align-items: center;
justify-content: center;
width: 28px; height: 28px;
border-radius: var(--radius-sm);
color: var(--text-faint);
transition: color var(--t-base), background var(--t-base);
-webkit-app-region: no-drag;
}
button:hover { color: var(--text-muted); background: var(--bg-raised); }
.close:hover { color: #fff; background: #c0392b; }
</style>
+183
View File
@@ -0,0 +1,183 @@
<script lang="ts">
import { store, dismissToast } from "../../store/state.svelte";
import type { Toast } from "../../store/state.svelte";
const EXIT_MS = 280;
const leaving = new Set<string>();
const timers = new Map<string, ReturnType<typeof setTimeout>>();
function schedule(t: Toast) {
if (timers.has(t.id)) return;
const dur = t.duration ?? 3500;
if (dur === 0) return;
timers.set(t.id, setTimeout(() => dismiss(t.id), dur));
}
function dismiss(id: string) {
if (leaving.has(id)) return;
leaving.add(id);
if (timers.has(id)) { clearTimeout(timers.get(id)!); timers.delete(id); }
const el = document.querySelector<HTMLElement>(`[data-toast-id="${id}"]`);
if (!el) { finalize(id); return; }
const h = el.offsetHeight;
el.style.setProperty("--exit-h", `${h}px`);
el.classList.add("leaving");
setTimeout(() => finalize(id), EXIT_MS);
}
function finalize(id: string) {
leaving.delete(id);
dismissToast(id);
}
$effect(() => {
store.toasts.forEach(schedule);
return () => timers.forEach(clearTimeout);
});
const icons: Record<Toast["kind"], string> = {
success: "M20 6L9 17l-5-5",
error: "M12 9v4M12 17h.01M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z",
info: "M12 16v-4M12 8h.01M12 2a10 10 0 1 0 0 20A10 10 0 0 0 12 2z",
download: "M12 3v13M7 11l5 5 5-5M5 21h14",
};
</script>
{#if store.toasts.length}
<div class="toaster" aria-live="polite">
{#each store.toasts as t (t.id)}
<div
role="alert"
class="toast toast-{t.kind}"
data-toast-id={t.id}
onclick={() => dismiss(t.id)}
>
<div class="accent-bar"></div>
<span class="icon">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d={icons[t.kind]} />
</svg>
</span>
<div class="body">
<p class="title">{t.title}</p>
{#if t.body}<p class="sub">{t.body}</p>{/if}
</div>
</div>
{/each}
</div>
{/if}
<style>
.toaster {
position: fixed;
bottom: var(--sp-5);
right: var(--sp-5);
z-index: 9999;
display: flex;
flex-direction: column;
gap: 6px;
pointer-events: none;
max-width: 300px;
}
.toast {
display: flex;
align-items: center;
gap: var(--sp-2);
padding: 10px var(--sp-3) 10px 0;
border-radius: var(--radius-md);
background: var(--bg-raised);
border: 1px solid var(--border-dim);
box-shadow: 0 8px 32px rgba(0,0,0,0.5), 0 1px 0 rgba(255,255,255,0.04) inset;
pointer-events: all;
min-width: 200px;
overflow: hidden;
cursor: pointer;
font-family: inherit;
font-size: inherit;
color: inherit;
text-align: left;
will-change: transform, opacity;
animation: slideIn 0.35s cubic-bezier(0.16, 1, 0.3, 1) both;
transition: border-color 0.15s ease, box-shadow 0.15s ease, transform 0.15s ease;
}
.toast:hover {
border-color: var(--border-base);
box-shadow: 0 12px 40px rgba(0,0,0,0.6), 0 1px 0 rgba(255,255,255,0.06) inset;
transform: translateX(-3px);
}
.toast:active { transform: translateX(0) scale(0.98); }
:global(.toast.leaving) {
animation: slideOut 0.28s cubic-bezier(0.4, 0, 1, 1) forwards !important;
pointer-events: none;
}
@keyframes slideIn {
from { opacity: 0; transform: translateX(20px) scale(0.96); }
to { opacity: 1; transform: translateX(0) scale(1); }
}
@keyframes slideOut {
0% { opacity: 1; transform: translateX(0) scale(1); max-height: var(--exit-h, 80px); margin-bottom: 0; }
40% { opacity: 0; transform: translateX(14px) scale(0.96); max-height: var(--exit-h, 80px); margin-bottom: 0; }
100% { opacity: 0; transform: translateX(14px) scale(0.96); max-height: 0; margin-bottom: -6px; }
}
.accent-bar {
width: 3px;
align-self: stretch;
flex-shrink: 0;
border-radius: 0 2px 2px 0;
margin-right: 2px;
}
.toast-success .accent-bar { background: var(--accent-fg); }
.toast-error .accent-bar { background: var(--color-error); }
.toast-info .accent-bar { background: var(--text-faint); }
.toast-download .accent-bar { background: var(--accent-fg); }
.icon {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
}
.toast-success .icon { color: var(--accent-fg); }
.toast-error .icon { color: var(--color-error); }
.toast-info .icon { color: var(--text-muted); }
.toast-download .icon { color: var(--accent-fg); }
.body {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 1px;
}
.title {
font-size: var(--text-xs);
font-family: var(--font-ui);
color: var(--text-secondary);
font-weight: var(--weight-medium);
letter-spacing: var(--tracking-wide);
line-height: 1.3;
}
.sub {
font-family: var(--font-ui);
font-size: var(--text-2xs);
color: var(--text-faint);
letter-spacing: var(--tracking-wide);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
</style>
-92
View File
@@ -1,92 +0,0 @@
<script lang="ts">
import { onMount } from "svelte";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { platform } from "@tauri-apps/plugin-os";
const win = getCurrentWindow();
const isMac = platform() === "macos";
let isFullscreen = $state(false);
onMount(async () => {
isFullscreen = await win.isFullscreen();
const unlisten = await win.onResized(async () => {
isFullscreen = await win.isFullscreen();
});
return unlisten;
});
</script>
{#if !isFullscreen}
<div class="bar" data-tauri-drag-region>
{#if isMac}<div class="mac-spacer"></div>{/if}
<span class="title" data-tauri-drag-region>Moku</span>
{#if !isMac}
<div class="controls">
<button onclick={() => win.minimize()} title="Minimize" aria-label="Minimize">
<svg width="10" height="1" viewBox="0 0 10 1">
<line x1="0" y1="0.5" x2="10" y2="0.5" stroke="currentColor" stroke-width="1.5" />
</svg>
</button>
<button onclick={() => win.toggleMaximize()} title="Maximize" aria-label="Maximize">
<svg width="9" height="9" viewBox="0 0 9 9">
<rect x="0.75" y="0.75" width="7.5" height="7.5" rx="1" fill="none" stroke="currentColor" stroke-width="1.5" />
</svg>
</button>
<button class="close" onclick={() => win.close()} title="Close" aria-label="Close">
<svg width="10" height="10" viewBox="0 0 10 10">
<line x1="1" y1="1" x2="9" y2="9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
<line x1="9" y1="1" x2="1" y2="9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
</svg>
</button>
</div>
{/if}
</div>
{/if}
<style>
.bar {
display: flex;
align-items: center;
justify-content: space-between;
height: 32px;
padding: 0 var(--sp-3) 0 var(--sp-4);
background: var(--bg-void);
border-bottom: 1px solid var(--border-dim);
flex-shrink: 0;
user-select: none;
-webkit-app-region: drag;
}
/* Spacer to clear the native macOS traffic lights (~70px) */
.mac-spacer {
width: 70px;
flex-shrink: 0;
-webkit-app-region: drag;
}
.title {
font-family: var(--font-ui);
font-size: var(--text-2xs);
color: var(--text-faint);
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
-webkit-app-region: drag;
}
.controls {
display: flex;
align-items: center;
gap: 2px;
-webkit-app-region: no-drag;
}
button {
display: flex;
align-items: center;
justify-content: center;
width: 28px; height: 28px;
border-radius: var(--radius-sm);
color: var(--text-faint);
transition: color var(--t-base), background var(--t-base);
-webkit-app-region: no-drag;
}
button:hover { color: var(--text-muted); background: var(--bg-raised); }
.close:hover { color: #fff; background: #c0392b; }
</style>
-91
View File
@@ -1,91 +0,0 @@
<script lang="ts">
import { store, dismissToast } from "../../store/state.svelte";
import type { Toast } from "../../store/state.svelte";
const timers = new Map<string, ReturnType<typeof setTimeout>>();
function schedule(t: Toast) {
if (timers.has(t.id)) return;
const dur = t.duration ?? 3500;
if (dur === 0) return;
timers.set(t.id, setTimeout(() => dismissToast(t.id), dur));
}
$effect(() => {
store.toasts.forEach(schedule);
return () => timers.forEach(clearTimeout);
});
const icons: Record<Toast["kind"], string> = {
success: "M9 12l2 2 4-4M12 2a10 10 0 1 0 0 20A10 10 0 0 0 12 2z",
error: "M12 9v4M12 17h.01M12 2a10 10 0 1 0 0 20A10 10 0 0 0 12 2z",
info: "M12 16v-4M12 8h.01M12 2a10 10 0 1 0 0 20A10 10 0 0 0 12 2z",
download: "M12 3v13M7 11l5 5 5-5M5 21h14",
};
</script>
{#if store.toasts.length}
<div class="toaster" aria-live="polite">
{#each store.toasts as t (t.id)}
<div class="toast toast-{t.kind}" role="alert">
<span class="icon">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d={icons[t.kind]} />
</svg>
</span>
<div class="body">
<p class="title">{t.title}</p>
{#if t.body}<p class="sub">{t.body}</p>{/if}
</div>
<button class="close" onclick={() => dismissToast(t.id)} title="Dismiss">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="1.5" stroke-linecap="round">
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
</div>
{/each}
</div>
{/if}
<style>
.toaster {
position: fixed; bottom: var(--sp-5); right: var(--sp-5);
z-index: 9999; display: flex; flex-direction: column;
gap: var(--sp-2); pointer-events: none; max-width: 320px;
}
.toast {
display: flex; align-items: flex-start; gap: var(--sp-2);
padding: var(--sp-2) var(--sp-3);
border-radius: var(--radius-lg);
border: 1px solid var(--border-base);
background: var(--bg-raised);
box-shadow: 0 4px 24px rgba(0,0,0,0.45), 0 0 0 1px rgba(0,0,0,0.08);
pointer-events: all; min-width: 220px;
animation: toastIn 0.18s cubic-bezier(0.16,1,0.3,1) both;
}
@keyframes toastIn {
from { opacity: 0; transform: translateX(24px) scale(0.96); }
to { opacity: 1; transform: translateX(0) scale(1); }
}
.toast-success { border-color: var(--accent-dim); }
.toast-success .icon { color: var(--accent-fg); }
.toast-error { border-color: var(--color-error); }
.toast-error .icon { color: var(--color-error); }
.toast-download .icon, .toast-info .icon { color: var(--accent-fg); }
.icon { flex-shrink: 0; margin-top: 2px; color: var(--text-faint); }
.body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
.title { font-size: var(--text-sm); color: var(--text-secondary); font-weight: var(--weight-medium); line-height: 1.3; }
.sub {
font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint);
letter-spacing: var(--tracking-wide); white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.close {
display: flex; align-items: center; justify-content: center;
width: 18px; height: 18px; border-radius: var(--radius-sm);
color: var(--text-faint); flex-shrink: 0; margin-top: 1px;
transition: color var(--t-base), background var(--t-base);
}
.close:hover { color: var(--text-muted); background: var(--bg-overlay); }
</style>
+42 -46
View File
@@ -1,22 +1,22 @@
<script lang="ts"> <script lang="ts">
import { onDestroy } from "svelte"; import { onDestroy } from "svelte";
import { BookmarkSimple, FolderSimplePlus, Folder, Sparkle, ArrowsClockwise } from "phosphor-svelte"; import { BookmarkSimple, FolderSimplePlus, Folder, Sparkle, ArrowsClockwise } from "phosphor-svelte";
import { gql, thumbUrl } from "../../lib/client"; import { gql } from "../../lib/client";
import { GET_SOURCES, FETCH_SOURCE_MANGA, UPDATE_MANGA } from "../../lib/queries"; import { GET_SOURCES, FETCH_SOURCE_MANGA, UPDATE_MANGA, GET_CATEGORIES, CREATE_CATEGORY, UPDATE_MANGA_CATEGORIES } from "../../lib/queries";
import { cache, CACHE_KEYS } from "../../lib/cache"; import { cache, CACHE_KEYS } from "../../lib/cache";
import { dedupeSources, dedupeMangaByTitle, dedupeMangaById } from "../../lib/util"; import { dedupeSources, dedupeMangaByTitle, dedupeMangaById, shouldHideNsfw, shouldHideSource } from "../../lib/util";
import { store, addFolder, assignMangaToFolder, setPreviewManga, clearDiscoverCache } from "../../store/state.svelte"; import { store, setPreviewManga, clearDiscoverCache } from "../../store/state.svelte";
import type { Manga, Source } from "../../lib/types"; import type { Manga, Source, Category } from "../../lib/types";
import ContextMenu from "../shared/ContextMenu.svelte"; import ContextMenu from "../shared/ContextMenu.svelte";
import type { MenuEntry } from "../shared/ContextMenu.svelte"; import type { MenuEntry } from "../shared/ContextMenu.svelte";
import SourceBrowse from "../shared/SourceBrowse.svelte"; import SourceBrowse from "../shared/SourceBrowse.svelte";
import Thumbnail from "../shared/Thumbnail.svelte";
// ── Constants ─────────────────────────────────────────────────────────────
const GENRE_TABS = ["All", "Action", "Romance", "Fantasy", "Comedy", "Drama", "Horror", "Sci-Fi", "Adventure", "Thriller"]; const GENRE_TABS = ["All", "Action", "Romance", "Fantasy", "Comedy", "Drama", "Horror", "Sci-Fi", "Adventure", "Thriller"];
const GRID_LIMIT = 200; const GRID_LIMIT = 200;
const CONCURRENCY = 6; const CONCURRENCY = 6;
const PAGES_INIT = 3; // pages per source on All tab const PAGES_INIT = 3;
const PAGES_GENRE = 2; // pages per source on genre tabs const PAGES_GENRE = 2;
const EXPLORE_ALL_MANGA = ` const EXPLORE_ALL_MANGA = `
query ExploreAllManga { query ExploreAllManga {
@@ -37,7 +37,6 @@
return `${srcId}|${type}|${genre}:p${page}`; return `${srcId}|${type}|${genre}:p${page}`;
} }
// ── Local component state ─────────────────────────────────────────────────
let allSources: Source[] = $state([]); let allSources: Source[] = $state([]);
let loadingLib = $state(true); let loadingLib = $state(true);
let loadError = $state(false); let loadError = $state(false);
@@ -48,22 +47,28 @@
let activeCtrl: AbortController | null = null; let activeCtrl: AbortController | null = null;
let ctx: { x: number; y: number; manga: Manga } | null = $state(null); let ctx: { x: number; y: number; manga: Manga } | null = $state(null);
let categories: Category[] = $state([]);
let catsLoaded = false;
const isLoading = $derived(genreLoading || refreshing || (currentGenre === "All" && loadingLib)); const isLoading = $derived(genreLoading || refreshing || (currentGenre === "All" && loadingLib));
const visibleGrid = $derived(genreResults.get(currentGenre) ?? []); const visibleGrid = $derived(genreResults.get(currentGenre) ?? []);
// ── Helpers ───────────────────────────────────────────────────────────────
function dedup(items: Manga[]): Manga[] { function dedup(items: Manga[]): Manga[] {
return dedupeMangaByTitle(dedupeMangaById(items), store.settings.mangaLinks); return dedupeMangaByTitle(dedupeMangaById(items), store.settings.mangaLinks);
} }
function filterOut(mangas: Manga[]): Manga[] { function filterOut(mangas: Manga[]): Manga[] {
return dedup(mangas.filter(m => !m.inLibrary && !store.discoverLibraryIds.has(m.id))); return dedup(mangas.filter(m => {
if (m.inLibrary || store.discoverLibraryIds.has(m.id)) return false;
if (shouldHideNsfw(m, store.settings)) return false;
return true;
}));
} }
function rotatedSources(): Source[] { function rotatedSources(): Source[] {
const lang = store.settings.preferredExtensionLang || "en"; const lang = store.settings.preferredExtensionLang || "en";
const srcs = dedupeSources(allSources.filter(s => s.id !== "0"), lang); const eligible = allSources.filter(s => s.id !== "0" && !shouldHideSource(s, store.settings));
const srcs = dedupeSources(eligible, lang);
if (!srcs.length) return []; if (!srcs.length) return [];
const off = store.discoverSrcOffset % srcs.length; const off = store.discoverSrcOffset % srcs.length;
return [...srcs.slice(off), ...srcs.slice(0, off)]; return [...srcs.slice(off), ...srcs.slice(0, off)];
@@ -80,7 +85,6 @@
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, items.length) }, worker)); await Promise.all(Array.from({ length: Math.min(CONCURRENCY, items.length) }, worker));
} }
// Push results into the reactive grid immediately — no batch delay.
function pushToGrid(genre: string, incoming: Manga[]) { function pushToGrid(genre: string, incoming: Manga[]) {
const filtered = filterOut(incoming); const filtered = filterOut(incoming);
if (!filtered.length) return; if (!filtered.length) return;
@@ -89,7 +93,6 @@
genreResults = new Map(genreResults); genreResults = new Map(genreResults);
} }
// ── Source fan-out ────────────────────────────────────────────────────────
async function fanOut(genre: string, ctrl: AbortController) { async function fanOut(genre: string, ctrl: AbortController) {
const srcs = rotatedSources(); const srcs = rotatedSources();
if (!srcs.length) return; if (!srcs.length) return;
@@ -108,7 +111,6 @@
let hasNextPage = false; let hasNextPage = false;
if (store.discoverCache.has(key)) { if (store.discoverCache.has(key)) {
// Cache hit — no network call needed
mangas = store.discoverCache.get(key)!; mangas = store.discoverCache.get(key)!;
} else { } else {
const result = await gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>( const result = await gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>(
@@ -134,13 +136,11 @@
pushToGrid(genre, matching.length ? matching : mangas); pushToGrid(genre, matching.length ? matching : mangas);
} }
// Stop paging early if source is exhausted
if (!hasNextPage) return; if (!hasNextPage) return;
} }
}, ctrl.signal); }, ctrl.signal);
} }
// ── Tab switch ────────────────────────────────────────────────────────────
async function switchGenre(genre: string) { async function switchGenre(genre: string) {
if (currentGenre === genre) return; if (currentGenre === genre) return;
@@ -151,7 +151,6 @@
activeCtrl = ctrl; activeCtrl = ctrl;
if (genre === "All") { if (genre === "All") {
// Already have results from this session — show instantly, re-fan in background
if ((genreResults.get("All") ?? []).length > 0) { if ((genreResults.get("All") ?? []).length > 0) {
genreLoading = false; genreLoading = false;
fanOut("All", ctrl).catch(() => {}); fanOut("All", ctrl).catch(() => {});
@@ -165,7 +164,6 @@
return; return;
} }
// Genre tab: serve cached local results instantly, always fan out too
const localKey = `local|${genre}`; const localKey = `local|${genre}`;
if (store.discoverCache.has(localKey)) { if (store.discoverCache.has(localKey)) {
genreResults.set(genre, store.discoverCache.get(localKey)!); genreResults.set(genre, store.discoverCache.get(localKey)!);
@@ -181,7 +179,7 @@
); );
if (ctrl.signal.aborted) return; if (ctrl.signal.aborted) return;
const local = dedup(d.mangas.nodes); const local = dedup(d.mangas.nodes.filter(m => !shouldHideNsfw(m, store.settings)));
store.discoverCache.set(localKey, local); store.discoverCache.set(localKey, local);
genreResults.set(genre, local.slice(0, GRID_LIMIT)); genreResults.set(genre, local.slice(0, GRID_LIMIT));
genreResults = new Map(genreResults); genreResults = new Map(genreResults);
@@ -194,10 +192,9 @@
} }
} }
// ── Refresh ───────────────────────────────────────────────────────────────
async function refresh() { async function refresh() {
activeCtrl?.abort(); activeCtrl?.abort();
clearDiscoverCache(); // wipes store.discoverCache + bumps discoverSrcOffset clearDiscoverCache();
genreResults = new Map(); genreResults = new Map();
refreshing = true; refreshing = true;
genreLoading = true; genreLoading = true;
@@ -208,17 +205,14 @@
refreshing = false; refreshing = false;
} }
// ── Initial load ──────────────────────────────────────────────────────────
function loadAll() { function loadAll() {
loadingLib = true; loadingLib = true;
loadError = false; loadError = false;
// Already have a session grid — show it immediately
if ((genreResults.get("All") ?? []).length > 0) { if ((genreResults.get("All") ?? []).length > 0) {
loadingLib = false; loadingLib = false;
} }
// Refresh library ID set so newly-added manga get filtered out
cache.get(CACHE_KEYS.DISCOVER, () => cache.get(CACHE_KEYS.DISCOVER, () =>
gql<{ mangas: { nodes: Manga[] } }>(EXPLORE_ALL_MANGA).then(d => d.mangas.nodes) gql<{ mangas: { nodes: Manga[] } }>(EXPLORE_ALL_MANGA).then(d => d.mangas.nodes)
).then(m => { ).then(m => {
@@ -228,7 +222,6 @@
}).catch(e => { console.error(e); loadError = true; }) }).catch(e => { console.error(e); loadError = true; })
.finally(() => { loadingLib = false; }); .finally(() => { loadingLib = false; });
// Load sources then kick off All tab fan-out (only if grid is empty)
gql<{ sources: { nodes: Source[] } }>(GET_SOURCES) gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
.then(d => { .then(d => {
allSources = d.sources.nodes; allSources = d.sources.nodes;
@@ -249,10 +242,15 @@
loadAll(); loadAll();
// ── Context menu ──────────────────────────────────────────────────────────
function openCtx(e: MouseEvent, m: Manga) { function openCtx(e: MouseEvent, m: Manga) {
e.preventDefault(); e.stopPropagation(); e.preventDefault(); e.stopPropagation();
ctx = { x: e.clientX, y: e.clientY, manga: m }; ctx = { x: e.clientX, y: e.clientY, manga: m };
if (!catsLoaded) {
catsLoaded = true;
gql<{ categories: { nodes: Category[] } }>(GET_CATEGORIES)
.then(d => { categories = d.categories.nodes.filter(c => c.id !== 0); })
.catch(console.error);
}
} }
function buildCtxItems(m: Manga): MenuEntry[] { function buildCtxItems(m: Manga): MenuEntry[] {
@@ -266,20 +264,26 @@
store.discoverLibraryIds = new Set([...store.discoverLibraryIds, m.id]); store.discoverLibraryIds = new Set([...store.discoverLibraryIds, m.id]);
}).catch(console.error), }).catch(console.error),
}, },
...(store.settings.folders.length > 0 ? [ ...(categories.length > 0 ? [
{ separator: true } as MenuEntry, { separator: true } as MenuEntry,
...store.settings.folders.map(f => ({ ...categories.map(cat => ({
label: f.mangaIds.includes(m.id) ? `✓ ${f.name}` : f.name, label: (cat.mangas?.nodes ?? []).some(x => x.id === m.id) ? `✓ ${cat.name}` : cat.name,
icon: Folder, icon: Folder,
onClick: () => assignMangaToFolder(f.id, m.id), onClick: () => gql(UPDATE_MANGA_CATEGORIES, { mangaId: m.id, addTo: [cat.id], removeFrom: [] }).catch(console.error),
})), })),
] : []), ] : []),
{ separator: true }, { separator: true },
{ {
label: "New folder & add", icon: FolderSimplePlus, label: "New folder & add", icon: FolderSimplePlus,
onClick: () => { onClick: async () => {
const n = prompt("Folder name:"); const n = prompt("Folder name:");
if (n?.trim()) { const id = addFolder(n.trim()); assignMangaToFolder(id, m.id); } if (!n?.trim()) return;
const res = await gql<{ createCategory: { category: Category } }>(CREATE_CATEGORY, { name: n.trim() }).catch(console.error);
if (res) {
const cat = res.createCategory.category;
categories = [...categories, cat];
await gql(UPDATE_MANGA_CATEGORIES, { mangaId: m.id, addTo: [cat.id], removeFrom: [] }).catch(console.error);
}
}, },
}, },
]; ];
@@ -295,11 +299,7 @@
<span class="heading">Discover</span> <span class="heading">Discover</span>
<div class="tab-strip"> <div class="tab-strip">
{#each GENRE_TABS as tab (tab)} {#each GENRE_TABS as tab (tab)}
<button <button class="genre-tab" class:active={currentGenre === tab} onclick={() => switchGenre(tab)}>
class="genre-tab"
class:active={currentGenre === tab}
onclick={() => switchGenre(tab)}
>
{#if tab === "All"}<Sparkle size={10} weight="fill" />{/if} {#if tab === "All"}<Sparkle size={10} weight="fill" />{/if}
{tab} {tab}
</button> </button>
@@ -330,13 +330,9 @@
{:else} {:else}
<div class="manga-grid"> <div class="manga-grid">
{#each visibleGrid as m (m.id)} {#each visibleGrid as m (m.id)}
<button <button class="manga-card" onclick={() => setPreviewManga(m)} oncontextmenu={(e) => openCtx(e, m)}>
class="manga-card"
onclick={() => setPreviewManga(m)}
oncontextmenu={(e) => openCtx(e, m)}
>
<div class="cover-wrap"> <div class="cover-wrap">
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} class="cover" loading="lazy" decoding="async" /> <Thumbnail src={m.thumbnailUrl} alt={m.title} class="cover" />
<div class="cover-gradient"></div> <div class="cover-gradient"></div>
{#if m.inLibrary}<span class="lib-badge">Saved</span>{/if} {#if m.inLibrary}<span class="lib-badge">Saved</span>{/if}
<div class="card-footer"> <div class="card-footer">
@@ -371,11 +367,11 @@
.body { flex: 1; overflow-y: auto; padding: var(--sp-4) var(--sp-5) var(--sp-6); will-change: scroll-position; -webkit-overflow-scrolling: touch; contain: layout style; } .body { flex: 1; overflow-y: auto; padding: var(--sp-4) var(--sp-5) var(--sp-6); will-change: scroll-position; -webkit-overflow-scrolling: touch; contain: layout style; }
.manga-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(clamp(90px, 11vw, 130px), 1fr)); gap: var(--sp-2); align-content: start; contain: layout style; } .manga-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(clamp(90px, 11vw, 130px), 1fr)); gap: var(--sp-2); align-content: start; contain: layout style; }
.manga-card { background: none; border: none; padding: 0; cursor: pointer; text-align: left; } .manga-card { background: none; border: none; padding: 0; cursor: pointer; text-align: left; }
.manga-card:hover .cover { filter: brightness(1.08) saturate(1.05); transform: scale(1.02); } .manga-card:hover :global(.cover) { filter: brightness(1.08) saturate(1.05); transform: scale(1.02); }
.manga-card:hover .card-title { color: #fff; } .manga-card:hover .card-title { color: #fff; }
.manga-card:hover { will-change: transform; } .manga-card:hover { will-change: transform; }
.cover-wrap { position: relative; aspect-ratio: 2/3; overflow: hidden; border-radius: var(--radius-md); background: var(--bg-raised); border: 1px solid var(--border-dim); box-shadow: 0 2px 8px rgba(0,0,0,0.25); } .cover-wrap { position: relative; aspect-ratio: 2/3; overflow: hidden; border-radius: var(--radius-md); background: var(--bg-raised); border: 1px solid var(--border-dim); box-shadow: 0 2px 8px rgba(0,0,0,0.25); }
.cover { width: 100%; height: 100%; object-fit: cover; display: block; transition: filter 0.15s ease, transform 0.15s ease; } :global(.cover) { width: 100%; height: 100%; object-fit: cover; display: block; transition: filter 0.15s ease, transform 0.15s ease; }
.cover-gradient { position: absolute; inset: 0; background: linear-gradient(to top, rgba(0,0,0,0.82) 0%, rgba(0,0,0,0.15) 50%, transparent 72%); pointer-events: none; } .cover-gradient { position: absolute; inset: 0; background: linear-gradient(to top, rgba(0,0,0,0.82) 0%, rgba(0,0,0,0.15) 50%, transparent 72%); pointer-events: none; }
.lib-badge { position: absolute; top: var(--sp-1); right: var(--sp-1); font-family: var(--font-ui); font-size: 9px; letter-spacing: var(--tracking-wide); text-transform: uppercase; background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim); padding: 1px 5px; border-radius: var(--radius-sm); } .lib-badge { position: absolute; top: var(--sp-1); right: var(--sp-1); font-family: var(--font-ui); font-size: 9px; letter-spacing: var(--tracking-wide); text-transform: uppercase; background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim); padding: 1px 5px; border-radius: var(--radius-sm); }
.card-footer { position: absolute; bottom: 0; left: 0; right: 0; padding: var(--sp-2); pointer-events: none; } .card-footer { position: absolute; bottom: 0; left: 0; right: 0; padding: var(--sp-2); pointer-events: none; }
+4 -3
View File
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { Play, Pause, Trash, CircleNotch, X } from "phosphor-svelte"; import { Play, Pause, Trash, CircleNotch, X } from "phosphor-svelte";
import { gql, thumbUrl } from "../../lib/client"; import { gql } from "../../lib/client";
import Thumbnail from "../shared/Thumbnail.svelte";
import { GET_DOWNLOAD_STATUS, START_DOWNLOADER, STOP_DOWNLOADER, CLEAR_DOWNLOADER, DEQUEUE_DOWNLOAD } from "../../lib/queries"; import { GET_DOWNLOAD_STATUS, START_DOWNLOADER, STOP_DOWNLOADER, CLEAR_DOWNLOADER, DEQUEUE_DOWNLOAD } from "../../lib/queries";
import { store, setActiveDownloads } from "../../store/state.svelte"; import { store, setActiveDownloads } from "../../store/state.svelte";
import type { DownloadStatus } from "../../lib/types"; import type { DownloadStatus } from "../../lib/types";
@@ -114,7 +115,7 @@
<div class="row" class:row-active={isActive} class:row-removing={isRemoving}> <div class="row" class:row-active={isActive} class:row-removing={isRemoving}>
{#if manga?.thumbnailUrl} {#if manga?.thumbnailUrl}
<div class="thumb"> <div class="thumb">
<img src={thumbUrl(manga.thumbnailUrl)} alt={manga?.title} class="thumb-img" loading="lazy" decoding="async" /> <Thumbnail src={manga.thumbnailUrl} alt={manga?.title} class="thumb-img" />
</div> </div>
{/if} {/if}
<div class="info"> <div class="info">
@@ -165,7 +166,7 @@
.row.row-active { border-color: var(--accent-dim); } .row.row-active { border-color: var(--accent-dim); }
.row.row-removing { opacity: 0.4; pointer-events: none; } .row.row-removing { opacity: 0.4; pointer-events: none; }
.thumb { width: 36px; height: 54px; border-radius: var(--radius-sm); overflow: hidden; background: var(--bg-overlay); flex-shrink: 0; border: 1px solid var(--border-dim); } .thumb { width: 36px; height: 54px; border-radius: var(--radius-sm); overflow: hidden; background: var(--bg-overlay); flex-shrink: 0; border: 1px solid var(--border-dim); }
.thumb-img { width: 100%; height: 100%; object-fit: cover; } :global(.thumb-img) { width: 100%; height: 100%; object-fit: cover; }
.info { flex: 1; display: flex; flex-direction: column; gap: 3px; overflow: hidden; min-width: 0; } .info { flex: 1; display: flex; flex-direction: column; gap: 3px; overflow: hidden; min-width: 0; }
.manga-title { font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .manga-title { font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.chapter-name { font-size: var(--text-xs); color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .chapter-name { font-size: var(--text-xs); color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+5 -4
View File
@@ -1,7 +1,8 @@
<script lang="ts"> <script lang="ts">
import { untrack } from "svelte"; import { untrack } from "svelte";
import { MagnifyingGlass, ArrowsClockwise, Plus, CircleNotch, CaretRight, CaretDown, X, Check, GitBranch } from "phosphor-svelte"; import { MagnifyingGlass, ArrowsClockwise, Plus, CircleNotch, CaretRight, CaretDown, X, Check, GitBranch } from "phosphor-svelte";
import { gql, thumbUrl } from "../../lib/client"; import { gql } from "../../lib/client";
import Thumbnail from "../shared/Thumbnail.svelte";
import { GET_EXTENSIONS, FETCH_EXTENSIONS, UPDATE_EXTENSION, INSTALL_EXTERNAL_EXTENSION, GET_SETTINGS, SET_EXTENSION_REPOS } from "../../lib/queries"; import { GET_EXTENSIONS, FETCH_EXTENSIONS, UPDATE_EXTENSION, INSTALL_EXTERNAL_EXTENSION, GET_SETTINGS, SET_EXTENSION_REPOS } from "../../lib/queries";
import { store } from "../../store/state.svelte"; import { store } from "../../store/state.svelte";
import type { Extension } from "../../lib/types"; import type { Extension } from "../../lib/types";
@@ -225,7 +226,7 @@
{@const hasVariants = variants.length > 0} {@const hasVariants = variants.length > 0}
<div class="group"> <div class="group">
<div class="row"> <div class="row">
<img src={thumbUrl(primary.iconUrl)} alt={primary.name} class="icon" onerror={(e) => (e.target as HTMLImageElement).style.display = "none"} /> <Thumbnail src={primary.iconUrl} alt={primary.name} class="icon" onerror={(e) => (e.target as HTMLImageElement).style.display = "none"} />
<div class="info"> <div class="info">
<span class="name">{base}</span> <span class="name">{base}</span>
<span class="meta"><span class="lang-tag">{primary.lang.toUpperCase()}</span> v{primary.versionName}</span> <span class="meta"><span class="lang-tag">{primary.lang.toUpperCase()}</span> v{primary.versionName}</span>
@@ -284,7 +285,7 @@
.header { display: flex; align-items: center; gap: var(--sp-4); padding: var(--sp-4) var(--sp-6); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; } .header { display: flex; align-items: center; gap: var(--sp-4); padding: var(--sp-4) var(--sp-6); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
.header-right { display: flex; align-items: center; gap: var(--sp-2); margin-left: auto; } .header-right { display: flex; align-items: center; gap: var(--sp-2); margin-left: auto; }
.heading { font-family: var(--font-ui); font-size: var(--text-xs); font-weight: var(--weight-normal); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; } .heading { font-family: var(--font-ui); font-size: var(--text-xs); font-weight: var(--weight-normal); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; }
.header-actions { display: flex; gap: var(--sp-1); }
.icon-btn { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: var(--radius-md); color: var(--text-muted); transition: color var(--t-base), background var(--t-base); } .icon-btn { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: var(--radius-md); color: var(--text-muted); transition: color var(--t-base), background var(--t-base); }
.icon-btn:hover:not(:disabled) { color: var(--text-primary); background: var(--bg-raised); } .icon-btn:hover:not(:disabled) { color: var(--text-primary); background: var(--bg-raised); }
.icon-btn:disabled { opacity: 0.4; } .icon-btn:disabled { opacity: 0.4; }
@@ -323,7 +324,7 @@
.group { display: flex; flex-direction: column; } .group { display: flex; flex-direction: column; }
.row { display: flex; align-items: center; gap: var(--sp-3); padding: 8px var(--sp-3); border-radius: var(--radius-md); border: 1px solid transparent; transition: background var(--t-fast), border-color var(--t-fast); } .row { display: flex; align-items: center; gap: var(--sp-3); padding: 8px var(--sp-3); border-radius: var(--radius-md); border: 1px solid transparent; transition: background var(--t-fast), border-color var(--t-fast); }
.row:hover { background: var(--bg-raised); border-color: var(--border-dim); } .row:hover { background: var(--bg-raised); border-color: var(--border-dim); }
.icon { width: 32px; height: 32px; border-radius: var(--radius-md); object-fit: cover; flex-shrink: 0; background: var(--bg-raised); } :global(.icon) { width: 32px; height: 32px; border-radius: var(--radius-md); object-fit: cover; flex-shrink: 0; background: var(--bg-raised); }
.info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; min-width: 0; } .info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; min-width: 0; }
.name { font-size: var(--text-base); font-weight: var(--weight-medium); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .name { font-size: var(--text-base); font-weight: var(--weight-medium); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.meta { display: flex; align-items: center; gap: var(--sp-2); font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); } .meta { display: flex; align-items: center; gap: var(--sp-2); font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
+42 -16
View File
@@ -1,12 +1,13 @@
<script lang="ts"> <script lang="ts">
import { ArrowLeft, BookmarkSimple, FolderSimplePlus, Folder, CircleNotch } from "phosphor-svelte"; import { ArrowLeft, BookmarkSimple, FolderSimplePlus, Folder, CircleNotch } from "phosphor-svelte";
import { untrack } from "svelte"; import { untrack } from "svelte";
import { gql, thumbUrl } from "../../lib/client"; import { gql } from "../../lib/client";
import { GET_ALL_MANGA, GET_LIBRARY, GET_SOURCES, FETCH_SOURCE_MANGA, UPDATE_MANGA } from "../../lib/queries"; import Thumbnail from "../shared/Thumbnail.svelte";
import { GET_ALL_MANGA, GET_LIBRARY, GET_SOURCES, FETCH_SOURCE_MANGA, UPDATE_MANGA, GET_CATEGORIES, CREATE_CATEGORY, UPDATE_MANGA_CATEGORIES } from "../../lib/queries";
import { cache, CACHE_KEYS, getPageSet } from "../../lib/cache"; import { cache, CACHE_KEYS, getPageSet } from "../../lib/cache";
import { dedupeSources, dedupeMangaById } from "../../lib/util"; import { dedupeSources, dedupeMangaById, shouldHideNsfw } from "../../lib/util";
import { store, addFolder, assignMangaToFolder, setGenreFilter, setPreviewManga, setNavPage } from "../../store/state.svelte"; import { store, setGenreFilter, setPreviewManga, setNavPage } from "../../store/state.svelte";
import type { Manga, Source } from "../../lib/types"; import type { Manga, Source, Category } from "../../lib/types";
import ContextMenu, { type MenuEntry } from "../shared/ContextMenu.svelte"; import ContextMenu, { type MenuEntry } from "../shared/ContextMenu.svelte";
const PAGE_SIZE = 50; const PAGE_SIZE = 50;
@@ -39,15 +40,17 @@
let loadingMore = $state(false); let loadingMore = $state(false);
let visibleCount = $state(PAGE_SIZE); let visibleCount = $state(PAGE_SIZE);
let ctx: { x: number; y: number; manga: Manga } | null = $state(null); let ctx: { x: number; y: number; manga: Manga } | null = $state(null);
let categories: Category[] = $state([]);
let catsLoaded = false;
const nextPageMap = new Map<string, number>(); const nextPageMap = new Map<string, number>();
let sources: Source[] = $state([]); let sources: Source[] = $state([]);
let abortCtrl: AbortController | null = null; let abortCtrl: AbortController | null = null;
const filtered = $derived.by(() => { const filtered = $derived.by(() => {
const libMatches = libraryManga.filter((m) => matchesAllTags(m, tags)); const libMatches = libraryManga.filter((m) => matchesAllTags(m, tags) && !shouldHideNsfw(m, store.settings));
const libIds = new Set(libMatches.map((m) => m.id)); const libIds = new Set(libMatches.map((m) => m.id));
return dedupeMangaById([...libMatches, ...sourceManga.filter((m) => !libIds.has(m.id))]); return dedupeMangaById([...libMatches, ...sourceManga.filter((m) => !libIds.has(m.id) && !shouldHideNsfw(m, store.settings))]);
}); });
const visibleItems = $derived(filtered.slice(0, visibleCount)); const visibleItems = $derived(filtered.slice(0, visibleCount));
const hasMoreVisible = $derived(visibleCount < filtered.length); const hasMoreVisible = $derived(visibleCount < filtered.length);
@@ -143,19 +146,42 @@
} }
} }
function openCtx(e: MouseEvent, m: Manga) {
e.preventDefault();
ctx = { x: e.clientX, y: e.clientY, manga: m };
if (!catsLoaded) {
catsLoaded = true;
gql<{ categories: { nodes: Category[] } }>(GET_CATEGORIES)
.then(d => { categories = d.categories.nodes.filter(c => c.id !== 0); })
.catch(console.error);
}
}
function buildCtxItems(m: Manga): MenuEntry[] { function buildCtxItems(m: Manga): MenuEntry[] {
return [ return [
{ label: m.inLibrary ? "In Library" : "Add to library", icon: BookmarkSimple, disabled: m.inLibrary, { label: m.inLibrary ? "In Library" : "Add to library", icon: BookmarkSimple, disabled: m.inLibrary,
onClick: () => gql(UPDATE_MANGA, { id: m.id, inLibrary: true }).then(() => { sourceManga = sourceManga.map((x) => x.id === m.id ? { ...x, inLibrary: true } : x); cache.clear(CACHE_KEYS.LIBRARY); }).catch(console.error) }, onClick: () => gql(UPDATE_MANGA, { id: m.id, inLibrary: true }).then(() => { sourceManga = sourceManga.map((x) => x.id === m.id ? { ...x, inLibrary: true } : x); cache.clear(CACHE_KEYS.LIBRARY); }).catch(console.error) },
...(store.settings.folders.length > 0 ? [ ...(categories.length > 0 ? [
{ separator: true } as MenuEntry, { separator: true } as MenuEntry,
...store.settings.folders.map((f): MenuEntry => ({ ...categories.map((cat): MenuEntry => ({
label: f.mangaIds.includes(m.id) ? `✓ ${f.name}` : f.name, icon: Folder, label: (cat.mangas?.nodes ?? []).some(x => x.id === m.id) ? `✓ ${cat.name}` : cat.name, icon: Folder,
onClick: () => assignMangaToFolder(f.id, m.id), onClick: () => gql(UPDATE_MANGA_CATEGORIES, { mangaId: m.id, addTo: [cat.id], removeFrom: [] }).catch(console.error),
})), })),
] : []), ] : []),
{ separator: true }, { separator: true },
{ label: "New folder & add", icon: FolderSimplePlus, onClick: () => { const name = prompt("Folder name:"); if (name?.trim()) { const id = addFolder(name.trim()); assignMangaToFolder(id, m.id); } } }, { label: "New folder & add", icon: FolderSimplePlus, onClick: async () => {
const name = prompt("Folder name:");
if (!name?.trim()) return;
const res = await gql<{ createCategory: { category: Category } }>(
CREATE_CATEGORY,
{ name: name.trim() }
).catch(console.error);
if (res) {
const cat = (res as any).createCategory.category;
categories = [...categories, cat];
await gql(UPDATE_MANGA_CATEGORIES, { mangaId: m.id, addTo: [cat.id], removeFrom: [] }).catch(console.error);
}
}},
]; ];
} }
@@ -190,9 +216,9 @@
{:else} {:else}
<div class="grid"> <div class="grid">
{#each visibleItems as m (m.id)} {#each visibleItems as m (m.id)}
<button class="card" onclick={() => setPreviewManga(m)} oncontextmenu={(e) => { e.preventDefault(); e.stopPropagation(); ctx = { x: e.clientX, y: e.clientY, manga: m }; }}> <button class="card" onclick={() => setPreviewManga(m)} oncontextmenu={(e) => { e.stopPropagation(); openCtx(e, m); }}>
<div class="cover-wrap"> <div class="cover-wrap">
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} class="cover" loading="lazy" decoding="async" /> <Thumbnail src={m.thumbnailUrl} alt={m.title} class="cover" />
{#if m.inLibrary}<span class="in-library-badge">Saved</span>{/if} {#if m.inLibrary}<span class="in-library-badge">Saved</span>{/if}
</div> </div>
<p class="card-title">{m.title}</p> <p class="card-title">{m.title}</p>
@@ -222,10 +248,10 @@
.result-count, .loading-hint { margin-left: auto; font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); } .result-count, .loading-hint { margin-left: auto; font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(clamp(100px,13vw,140px),1fr)); gap: var(--sp-4); padding: var(--sp-5) var(--sp-6) var(--sp-6); overflow-y: auto; flex: 1; align-content: start; will-change: scroll-position; -webkit-overflow-scrolling: touch; contain: layout style; } .grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(clamp(100px,13vw,140px),1fr)); gap: var(--sp-4); padding: var(--sp-5) var(--sp-6) var(--sp-6); overflow-y: auto; flex: 1; align-content: start; will-change: scroll-position; -webkit-overflow-scrolling: touch; contain: layout style; }
.card { background: none; border: none; padding: 0; cursor: pointer; text-align: left; } .card { background: none; border: none; padding: 0; cursor: pointer; text-align: left; }
.card:hover .cover { filter: brightness(1.06); } .card:hover :global(.cover) { filter: brightness(1.06); }
.card:hover .card-title { color: var(--text-primary); } .card:hover .card-title { color: var(--text-primary); }
.cover-wrap { position: relative; aspect-ratio: 2/3; overflow: hidden; border-radius: var(--radius-md); background: var(--bg-raised); border: 1px solid var(--border-dim); transform: translateZ(0); } .cover-wrap { position: relative; aspect-ratio: 2/3; overflow: hidden; border-radius: var(--radius-md); background: var(--bg-raised); border: 1px solid var(--border-dim); transform: translateZ(0); }
.cover { width: 100%; height: 100%; object-fit: cover; transition: filter var(--t-base); will-change: filter; } :global(.cover) { width: 100%; height: 100%; object-fit: cover; transition: filter var(--t-base); will-change: filter; }
.in-library-badge { position: absolute; bottom: var(--sp-1); left: var(--sp-1); font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); text-transform: uppercase; background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim); padding: 2px 5px; border-radius: var(--radius-sm); } .in-library-badge { position: absolute; bottom: var(--sp-1); left: var(--sp-1); font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); text-transform: uppercase; background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim); padding: 2px 5px; border-radius: var(--radius-sm); }
.card-title { margin-top: var(--sp-2); font-size: var(--text-sm); color: var(--text-secondary); line-height: var(--leading-snug); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; transition: color var(--t-base); } .card-title { margin-top: var(--sp-2); font-size: var(--text-sm); color: var(--text-secondary); line-height: var(--leading-snug); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; transition: color var(--t-base); }
.card-skeleton { padding: 0; } .card-skeleton { padding: 0; }
+86 -37
View File
@@ -2,11 +2,14 @@
import { onMount, untrack } from "svelte"; import { onMount, untrack } from "svelte";
import { Play, ArrowRight, ArrowLeft, BookOpen, Clock, Fire, TrendUp, CalendarBlank, CheckCircle, PushPin, X as XIcon, MagnifyingGlass, ListBullets } from "phosphor-svelte"; import { Play, ArrowRight, ArrowLeft, BookOpen, Clock, Fire, TrendUp, CalendarBlank, CheckCircle, PushPin, X as XIcon, MagnifyingGlass, ListBullets } from "phosphor-svelte";
import { gql, thumbUrl } from "../../lib/client"; import { gql, thumbUrl } from "../../lib/client";
import { GET_LIBRARY, GET_CHAPTERS, GET_MANGA } from "../../lib/queries"; import { getBlobUrl } from "../../lib/imageCache";
import Thumbnail from "../shared/Thumbnail.svelte";
import { GET_LIBRARY, GET_CHAPTERS, GET_MANGA, GET_CATEGORIES } from "../../lib/queries";
import { cache, CACHE_KEYS } from "../../lib/cache"; import { cache, CACHE_KEYS } from "../../lib/cache";
import { store, openReader, COMPLETED_FOLDER_ID, setHeroSlot, setActiveManga, setPreviewManga, setNavPage, setGenreFilter } from "../../store/state.svelte"; import { store, openReader, setHeroSlot, setActiveManga, setPreviewManga, setNavPage, setGenreFilter, setLibraryFilter } from "../../store/state.svelte";
import type { HistoryEntry } from "../../store/state.svelte"; import type { HistoryEntry } from "../../store/state.svelte";
import type { Manga, Chapter } from "../../lib/types"; import type { Manga, Chapter, Category } from "../../lib/types";
import { buildReaderChapterList } from "../../lib/chapterList";
function timeAgo(ts: number): string { function timeAgo(ts: number): string {
const diff = Date.now() - ts, m = Math.floor(diff / 60000); const diff = Date.now() - ts, m = Math.floor(diff / 60000);
@@ -33,34 +36,51 @@
let libraryManga: Manga[] = $state([]); let libraryManga: Manga[] = $state([]);
let extraManga: Manga[] = $state([]); let extraManga: Manga[] = $state([]);
let loadingLibrary: boolean = $state(true); let loadingLibrary: boolean = $state(true);
let completedCategory: Category | null = $state(null);
onMount(() => { onMount(() => {
loadLibrary(); loadLibrary();
}); });
function loadLibrary() { function loadLibrary() {
cache.get(CACHE_KEYS.LIBRARY, () => const libraryP = cache.get(CACHE_KEYS.LIBRARY, () =>
gql<{ mangas: { nodes: Manga[] } }>(GET_LIBRARY).then(d => d.mangas.nodes) gql<{ mangas: { nodes: Manga[] } }>(GET_LIBRARY).then(d => d.mangas.nodes)
).then(m => { libraryManga = m; fetchExtraCompleted(m); }) );
const categoriesP = gql<{ categories: { nodes: Category[] } }>(GET_CATEGORIES)
.then(d => d.categories.nodes.find(c => c.name === "Completed") ?? null)
.catch(() => null);
Promise.all([libraryP, categoriesP])
.then(([m, completed]) => {
libraryManga = m;
completedCategory = completed;
fetchExtraCompleted(m, completed);
})
.catch(console.error) .catch(console.error)
.finally(() => loadingLibrary = false); .finally(() => loadingLibrary = false);
} }
// Re-fetch library and reset hero chapters whenever the reader closes, function resetAndReload() {
// so the hero reflects the latest-read chapter immediately.
$effect(() => {
const sessionId = store.readerSessionId;
if (sessionId === 0) return; // skip initial mount — onMount handles that
cache.clear(CACHE_KEYS.LIBRARY); cache.clear(CACHE_KEYS.LIBRARY);
loadingLibrary = true; loadingLibrary = true;
heroChapters = []; heroChapters = [];
heroAllChapters = []; heroAllChapters = [];
heroChaptersFor = null; heroChaptersFor = null;
loadLibrary(); loadLibrary();
}
$effect(() => {
if (store.navPage === "home") untrack(() => resetAndReload());
}); });
async function fetchExtraCompleted(library: Manga[]) { $effect(() => {
const completedIds = store.settings.folders.find(f => f.id === COMPLETED_FOLDER_ID)?.mangaIds ?? []; const sessionId = store.readerSessionId;
if (sessionId === 0) return;
untrack(() => resetAndReload());
});
async function fetchExtraCompleted(library: Manga[], completed: Category | null) {
const completedIds = completed?.mangas?.nodes.map(m => m.id) ?? [];
const missingIds = completedIds.filter(id => !library.some(m => m.id === id)); const missingIds = completedIds.filter(id => !library.some(m => m.id === id));
if (!missingIds.length) return; if (!missingIds.length) return;
const results = await Promise.allSettled(missingIds.map(id => gql<{ manga: Manga }>(GET_MANGA, { id }).then(d => d.manga))); const results = await Promise.allSettled(missingIds.map(id => gql<{ manga: Manga }>(GET_MANGA, { id }).then(d => d.manga)));
@@ -103,7 +123,21 @@
let activeIdx = $state(0); let activeIdx = $state(0);
const activeSlot = $derived(resolvedSlots[activeIdx]); const activeSlot = $derived(resolvedSlots[activeIdx]);
const heroThumb = $derived(activeSlot?.kind === "pinned" ? thumbUrl(activeSlot.manga?.thumbnailUrl ?? "") : activeSlot?.kind === "continue" ? thumbUrl(activeSlot.entry?.thumbnailUrl ?? "") : ""); const heroThumbSrc = $derived(
activeSlot?.kind === "pinned" ? (activeSlot.manga?.thumbnailUrl ?? "") :
activeSlot?.kind === "continue" ? (activeSlot.entry?.thumbnailUrl ?? "") : ""
);
let heroThumb = $state("");
$effect(() => {
const path = heroThumbSrc;
const mode = store.settings.serverAuthMode ?? "NONE";
if (!path) { heroThumb = ""; return; }
if (mode !== "BASIC_AUTH") { heroThumb = thumbUrl(path); return; }
// Use tauri-plugin-http backed getBlobUrl which handles auth and bypasses CORS
getBlobUrl(thumbUrl(path))
.then(url => { heroThumb = url; })
.catch(() => { heroThumb = ""; });
});
const heroTitle = $derived(activeSlot?.kind === "pinned" ? (activeSlot.manga?.title ?? "") : activeSlot?.kind === "continue" ? (activeSlot.entry?.mangaTitle ?? "") : ""); const heroTitle = $derived(activeSlot?.kind === "pinned" ? (activeSlot.manga?.title ?? "") : activeSlot?.kind === "continue" ? (activeSlot.entry?.mangaTitle ?? "") : "");
const heroManga = $derived(activeSlot?.kind === "pinned" ? activeSlot.manga : activeSlot?.kind === "continue" ? libraryManga.find(m => m.id === activeSlot.entry?.mangaId) : null); const heroManga = $derived(activeSlot?.kind === "pinned" ? activeSlot.manga : activeSlot?.kind === "continue" ? libraryManga.find(m => m.id === activeSlot.entry?.mangaId) : null);
const heroEntry = $derived(activeSlot?.kind === "continue" ? activeSlot.entry : null); const heroEntry = $derived(activeSlot?.kind === "continue" ? activeSlot.entry : null);
@@ -131,7 +165,8 @@
$effect(() => { $effect(() => {
const id = heroMangaId; const id = heroMangaId;
if (id && id !== heroChaptersFor) untrack(() => loadHeroChapters(id)); void store.settings.mangaPrefs?.[id!];
if (id) untrack(() => loadHeroChapters(id));
}); });
async function loadHeroChapters(mangaId: number) { async function loadHeroChapters(mangaId: number) {
@@ -144,9 +179,10 @@
if (heroChaptersFor !== mangaId) return; if (heroChaptersFor !== mangaId) return;
const all = [...d.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder); const all = [...d.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder);
heroAllChapters = all; heroAllChapters = all;
const lastReadIdx = heroEntry ? all.findIndex(c => c.id === heroEntry!.chapterId) : all.findLastIndex(c => c.isRead); const filtered = buildReaderChapterList(all, store.settings.mangaPrefs?.[mangaId]);
const lastReadIdx = heroEntry ? filtered.findIndex(c => c.id === heroEntry!.chapterId) : filtered.findLastIndex(c => c.isRead);
const startIdx = Math.max(0, lastReadIdx); const startIdx = Math.max(0, lastReadIdx);
heroChapters = all.slice(startIdx, startIdx + 5); heroChapters = filtered.slice(startIdx, startIdx + 5);
} catch { heroChapters = []; heroAllChapters = []; } } catch { heroChapters = []; heroAllChapters = []; }
finally { loadingHeroChapters = false; } finally { loadingHeroChapters = false; }
} }
@@ -162,7 +198,13 @@
const d = await gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId: heroMangaId }); const d = await gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId: heroMangaId });
all = [...d.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder); all = [...d.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder);
} }
openReader(chapter, all); if (all.length) {
const manga = heroManga ?? { id: heroMangaId, title: heroTitle, thumbnailUrl: heroManga?.thumbnailUrl ?? "" } as any;
store.activeManga = manga;
const list = buildReaderChapterList(all, store.settings.mangaPrefs?.[heroMangaId]);
const target = list.find(c => c.id === chapter.id) ?? list[0];
if (target) openReader(target, list);
}
} catch { store.activeManga = { id: heroMangaId, title: heroTitle, thumbnailUrl: heroManga?.thumbnailUrl ?? "" } as any; } } catch { store.activeManga = { id: heroMangaId, title: heroTitle, thumbnailUrl: heroManga?.thumbnailUrl ?? "" } as any; }
finally { resuming = false; } finally { resuming = false; }
} }
@@ -175,10 +217,13 @@
resuming = true; resuming = true;
try { try {
const d = await gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId: heroEntry.mangaId }); const d = await gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId: heroEntry.mangaId });
const chapters = [...d.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder); const raw = [...d.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder);
const ch = chapters.find(c => c.id === heroEntry!.chapterId) ?? chapters[0]; const list = buildReaderChapterList(raw, store.settings.mangaPrefs?.[heroEntry.mangaId]);
if (ch) openReader(ch, chapters); const ch = list.find(c => c.id === heroEntry!.chapterId) ?? list[0];
else store.activeManga = { id: heroEntry.mangaId, title: heroEntry.mangaTitle, thumbnailUrl: heroEntry.thumbnailUrl } as any; if (ch) {
store.activeManga = heroManga ?? { id: heroEntry.mangaId, title: heroEntry.mangaTitle, thumbnailUrl: heroEntry.thumbnailUrl } as any;
openReader(ch, list);
}
} catch { store.activeManga = { id: heroEntry.mangaId, title: heroEntry.mangaTitle, thumbnailUrl: heroEntry.thumbnailUrl } as any; } } catch { store.activeManga = { id: heroEntry.mangaId, title: heroEntry.mangaTitle, thumbnailUrl: heroEntry.thumbnailUrl } as any; }
finally { resuming = false; } finally { resuming = false; }
} }
@@ -186,10 +231,13 @@
async function resumeEntry(entry: HistoryEntry) { async function resumeEntry(entry: HistoryEntry) {
try { try {
const d = await gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId: entry.mangaId }); const d = await gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId: entry.mangaId });
const chapters = [...d.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder); const raw = [...d.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder);
const ch = chapters.find(c => c.id === entry.chapterId) ?? chapters[0]; const list = buildReaderChapterList(raw, store.settings.mangaPrefs?.[entry.mangaId]);
if (ch) openReader(ch, chapters); const ch = list.find(c => c.id === entry.chapterId) ?? list[0];
else store.activeManga = { id: entry.mangaId, title: entry.mangaTitle, thumbnailUrl: entry.thumbnailUrl } as any; if (ch) {
store.activeManga = { id: entry.mangaId, title: entry.mangaTitle, thumbnailUrl: entry.thumbnailUrl } as any;
openReader(ch, list);
} else store.activeManga = { id: entry.mangaId, title: entry.mangaTitle, thumbnailUrl: entry.thumbnailUrl } as any;
} catch { store.activeManga = { id: entry.mangaId, title: entry.mangaTitle, thumbnailUrl: entry.thumbnailUrl } as any; } } catch { store.activeManga = { id: entry.mangaId, title: entry.mangaTitle, thumbnailUrl: entry.thumbnailUrl } as any; }
} }
@@ -206,9 +254,9 @@
function pinManga(m: Manga) { if (pickerSlotIndex !== null) { setHeroSlot(pickerSlotIndex, m.id); closePicker(); } } function pinManga(m: Manga) { if (pickerSlotIndex !== null) { setHeroSlot(pickerSlotIndex, m.id); closePicker(); } }
function unpinSlot(i: 1|2|3) { setHeroSlot(i, null); } function unpinSlot(i: 1|2|3) { setHeroSlot(i, null); }
const completedIds = $derived(store.settings.folders.find(f => f.id === COMPLETED_FOLDER_ID)?.mangaIds ?? []); const completedIds = $derived(completedCategory?.mangas?.nodes.map(m => m.id) ?? []);
const completedPool = $derived([...libraryManga, ...extraManga.filter(m => !libraryManga.some(l => l.id === m.id))]); const completedPool = $derived([...libraryManga, ...extraManga.filter(m => !libraryManga.some(l => l.id === m.id))]);
const completedManga = $derived(completedIds.length > 0 ? completedPool.filter(m => completedIds.includes(m.id)).slice(0, 20) : []); const completedManga = $derived(completedIds.length > 0 ? completedPool.filter(m => completedIds.includes(m.id)).slice(0, 7) : []);
const recentHistory = $derived(store.history.slice(0, 6)); const recentHistory = $derived(store.history.slice(0, 6));
const stats = $derived(store.readingStats); const stats = $derived(store.readingStats);
@@ -368,7 +416,7 @@
{#if recentHistory.length > 0} {#if recentHistory.length > 0}
{#each recentHistory as entry (entry.chapterId)} {#each recentHistory as entry (entry.chapterId)}
<button class="activity-row" onclick={() => resumeEntry(entry)}> <button class="activity-row" onclick={() => resumeEntry(entry)}>
<img src={thumbUrl(entry.thumbnailUrl)} alt={entry.mangaTitle} class="activity-thumb" loading="lazy" decoding="async" /> <Thumbnail src={entry.thumbnailUrl} alt={entry.mangaTitle} class="activity-thumb" />
<div class="activity-info"> <div class="activity-info">
<span class="activity-title">{entry.mangaTitle}</span> <span class="activity-title">{entry.mangaTitle}</span>
<span class="activity-sub">{entry.chapterName}{entry.pageNumber > 1 ? ` · p.${entry.pageNumber}` : ""}</span> <span class="activity-sub">{entry.chapterName}{entry.pageNumber > 1 ? ` · p.${entry.pageNumber}` : ""}</span>
@@ -404,7 +452,7 @@
<div class="bottom-section-hd"> <div class="bottom-section-hd">
<span class="section-title"><CheckCircle size={10} weight="bold" /> Completed</span> <span class="section-title"><CheckCircle size={10} weight="bold" /> Completed</span>
{#if completedManga.length > 0} {#if completedManga.length > 0}
<button class="see-all" onclick={() => store.navPage = "library"}>View all <ArrowRight size={9} weight="bold" /></button> <button class="see-all" onclick={() => { if (completedCategory) setLibraryFilter(String(completedCategory.id)); store.navPage = "library"; }}>View all <ArrowRight size={9} weight="bold" /></button>
{/if} {/if}
</div> </div>
{#if completedManga.length > 0} {#if completedManga.length > 0}
@@ -412,7 +460,7 @@
{#each completedManga as m (m.id)} {#each completedManga as m (m.id)}
<button class="mini-card" onclick={() => store.previewManga = m}> <button class="mini-card" onclick={() => store.previewManga = m}>
<div class="mini-cover-wrap"> <div class="mini-cover-wrap">
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} class="mini-cover" loading="lazy" decoding="async" /> <Thumbnail src={m.thumbnailUrl} alt={m.title} class="mini-cover" />
<div class="mini-gradient"></div> <div class="mini-gradient"></div>
<div class="mini-footer"> <div class="mini-footer">
<p class="mini-card-title">{m.title}</p> <p class="mini-card-title">{m.title}</p>
@@ -468,7 +516,7 @@
{:else} {:else}
{#each pickerResults as m (m.id)} {#each pickerResults as m (m.id)}
<button class="picker-row" onclick={() => pinManga(m)}> <button class="picker-row" onclick={() => pinManga(m)}>
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} class="picker-thumb" loading="lazy" /> <Thumbnail src={m.thumbnailUrl} alt={m.title} class="picker-thumb" />
<div class="picker-info"> <div class="picker-info">
<span class="picker-manga-title">{m.title}</span> <span class="picker-manga-title">{m.title}</span>
{#if m.source?.displayName}<span class="picker-source">{m.source.displayName}</span>{/if} {#if m.source?.displayName}<span class="picker-source">{m.source.displayName}</span>{/if}
@@ -558,7 +606,7 @@
.activity-row { display: flex; align-items: center; gap: var(--sp-3); padding: 7px var(--sp-2); border-radius: var(--radius-md); border: 1px solid transparent; background: none; text-align: left; cursor: pointer; width: 100%; transition: background var(--t-fast), border-color var(--t-fast); } .activity-row { display: flex; align-items: center; gap: var(--sp-3); padding: 7px var(--sp-2); border-radius: var(--radius-md); border: 1px solid transparent; background: none; text-align: left; cursor: pointer; width: 100%; transition: background var(--t-fast), border-color var(--t-fast); }
.activity-row:hover { background: var(--bg-raised); border-color: var(--border-dim); } .activity-row:hover { background: var(--bg-raised); border-color: var(--border-dim); }
.activity-row:hover .activity-play { opacity: 1; } .activity-row:hover .activity-play { opacity: 1; }
.activity-thumb { width: 33px; height: 48px; border-radius: var(--radius-sm); object-fit: cover; flex-shrink: 0; border: 1px solid var(--border-dim); } :global(.activity-thumb) { width: 33px; height: 48px; border-radius: var(--radius-sm); object-fit: cover; flex-shrink: 0; border: 1px solid var(--border-dim); }
.activity-info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; min-width: 0; } .activity-info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; min-width: 0; }
.activity-title { font-size: var(--text-base); font-weight: var(--weight-medium); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .activity-title { font-size: var(--text-base); font-weight: var(--weight-medium); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.activity-sub { font-family: var(--font-ui); font-size: var(--text-sm); color: var(--text-muted); letter-spacing: var(--tracking-wide); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .activity-sub { font-family: var(--font-ui); font-size: var(--text-sm); color: var(--text-muted); letter-spacing: var(--tracking-wide); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
@@ -571,13 +619,14 @@
.bottom-col:last-child { padding-left: var(--sp-4); } .bottom-col:last-child { padding-left: var(--sp-4); }
.bottom-section-hd { display: flex; align-items: center; justify-content: space-between; padding-bottom: var(--sp-2); } .bottom-section-hd { display: flex; align-items: center; justify-content: space-between; padding-bottom: var(--sp-2); }
.bottom-empty { font-family: var(--font-ui); font-size: var(--text-sm); color: var(--text-faint); letter-spacing: var(--tracking-wide); padding: var(--sp-1) 0; } .bottom-empty { font-family: var(--font-ui); font-size: var(--text-sm); color: var(--text-faint); letter-spacing: var(--tracking-wide); padding: var(--sp-1) 0; }
.mini-row { display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: var(--sp-3); } .mini-row { display: flex; flex-direction: row; gap: var(--sp-3); overflow-x: auto; overflow-y: hidden; scrollbar-width: none; padding-bottom: var(--sp-1); }
.mini-row::-webkit-scrollbar { display: none; }
.mini-card { width: 100%; background: none; border: none; padding: 0; cursor: pointer; text-align: left; } .mini-card { flex: 0 0 120px; width: 120px; background: none; border: none; padding: 0; cursor: pointer; text-align: left; }
.mini-card:hover .mini-cover { filter: brightness(1.08) saturate(1.05); transform: scale(1.02); } .mini-card:hover :global(.mini-cover) { filter: brightness(1.08) saturate(1.05); transform: scale(1.02); }
.mini-card:hover { will-change: transform; } .mini-card:hover { will-change: transform; }
.mini-cover-wrap { position: relative; aspect-ratio: 2/3; overflow: hidden; border-radius: var(--radius-md); background: var(--bg-raised); border: 1px solid var(--border-dim); box-shadow: 0 2px 12px rgba(0,0,0,0.35); } .mini-cover-wrap { position: relative; aspect-ratio: 2/3; overflow: hidden; border-radius: var(--radius-md); background: var(--bg-raised); border: 1px solid var(--border-dim); box-shadow: 0 2px 12px rgba(0,0,0,0.35); }
.mini-cover { width: 100%; height: 100%; object-fit: cover; display: block; transition: filter 0.15s ease, transform 0.15s ease; } :global(.mini-cover) { width: 100%; height: 100%; object-fit: cover; display: block; transition: filter 0.15s ease, transform 0.15s ease; }
.mini-gradient { position: absolute; inset: 0; background: linear-gradient(to top, rgba(0,0,0,0.85) 0%, rgba(0,0,0,0.1) 55%, transparent 75%); pointer-events: none; } .mini-gradient { position: absolute; inset: 0; background: linear-gradient(to top, rgba(0,0,0,0.85) 0%, rgba(0,0,0,0.1) 55%, transparent 75%); pointer-events: none; }
.mini-footer { position: absolute; bottom: 0; left: 0; right: 0; padding: var(--sp-2); pointer-events: none; } .mini-footer { position: absolute; bottom: 0; left: 0; right: 0; padding: var(--sp-2); pointer-events: none; }
.mini-card-title { font-size: var(--text-xs); font-weight: var(--weight-medium); color: rgba(255,255,255,0.92); line-height: var(--leading-snug); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; text-shadow: 0 1px 4px rgba(0,0,0,0.7); } .mini-card-title { font-size: var(--text-xs); font-weight: var(--weight-medium); color: rgba(255,255,255,0.92); line-height: var(--leading-snug); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; text-shadow: 0 1px 4px rgba(0,0,0,0.7); }
@@ -616,7 +665,7 @@
.picker-empty { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); padding: var(--sp-4) var(--sp-3); text-align: center; } .picker-empty { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); padding: var(--sp-4) var(--sp-3); text-align: center; }
.picker-row { display: flex; align-items: center; gap: var(--sp-3); width: 100%; padding: 8px var(--sp-3); border-radius: var(--radius-md); border: none; background: none; text-align: left; cursor: pointer; transition: background var(--t-fast); } .picker-row { display: flex; align-items: center; gap: var(--sp-3); width: 100%; padding: 8px var(--sp-3); border-radius: var(--radius-md); border: none; background: none; text-align: left; cursor: pointer; transition: background var(--t-fast); }
.picker-row:hover { background: var(--bg-raised); } .picker-row:hover { background: var(--bg-raised); }
.picker-thumb { height: 50px; width: 35px; aspect-ratio: 1 / 1.42; border-radius: var(--radius-sm); object-fit: cover; flex-shrink: 0; border: 1px solid var(--border-dim); background: var(--bg-raised); display: block; } :global(.picker-thumb) { height: 50px; width: 35px; aspect-ratio: 1 / 1.42; border-radius: var(--radius-sm); object-fit: cover; flex-shrink: 0; border: 1px solid var(--border-dim); background: var(--bg-raised); display: block; }
.picker-info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; min-width: 0; } .picker-info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; min-width: 0; }
.picker-manga-title { font-size: var(--text-sm); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .picker-manga-title { font-size: var(--text-sm); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.picker-source { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); } .picker-source { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+497 -231
View File
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { CircleNotch, ArrowSquareOut, ArrowsClockwise, X, Lock, MagnifyingGlass, Funnel } from "phosphor-svelte"; import { CircleNotch, ArrowSquareOut, ArrowsClockwise, X, Lock, MagnifyingGlass, Funnel } from "phosphor-svelte";
import { gql, thumbUrl } from "../../lib/client"; import { gql } from "../../lib/client";
import Thumbnail from "../shared/Thumbnail.svelte";
import { import {
GET_ALL_TRACKER_RECORDS, GET_ALL_TRACKER_RECORDS,
UPDATE_TRACK, UPDATE_TRACK,
@@ -10,8 +11,6 @@
import { addToast, setActiveManga, setNavPage } from "../../store/state.svelte"; import { addToast, setActiveManga, setNavPage } from "../../store/state.svelte";
import type { Tracker, TrackRecord } from "../../lib/types"; import type { Tracker, TrackRecord } from "../../lib/types";
// ── Types ──────────────────────────────────────────────────────────────────
interface TrackerWithRecords extends Tracker { interface TrackerWithRecords extends Tracker {
trackRecords: { nodes: TrackRecord[] }; trackRecords: { nodes: TrackRecord[] };
} }
@@ -20,26 +19,20 @@
tracker: Tracker; tracker: Tracker;
} }
// ── State ──────────────────────────────────────────────────────────────────
let trackers: TrackerWithRecords[] = $state([]); let trackers: TrackerWithRecords[] = $state([]);
let loading: boolean = $state(true); let loading: boolean = $state(true);
let error: string | null = $state(null); let error: string | null = $state(null);
// Filter/view state
let activeTrackerId: number | "all" = $state("all"); let activeTrackerId: number | "all" = $state("all");
let statusFilter: number | "all" = $state("all"); let statusFilter: number | "all" = $state("all");
let searchQuery: string = $state(""); let searchQuery: string = $state("");
let sortBy: "title" | "status" | "score" | "progress" = $state("title"); let sortBy: "title" | "status" | "score" | "progress" = $state("title");
// Mutation state
let updatingId: number | null = $state(null); let updatingId: number | null = $state(null);
let syncingId: number | null = $state(null); let syncingId: number | null = $state(null);
// Chapter editing: recordId → draft value
let editingChapter: number | null = $state(null); let editingChapter: number | null = $state(null);
let chapterDraft: number = $state(0); let chapterDraft: number = $state(0);
let confirmUnbindRecord: FlatRecord | null = $state(null);
// ── Load ───────────────────────────────────────────────────────────────────
async function load() { async function load() {
loading = true; error = null; loading = true; error = null;
@@ -55,15 +48,13 @@
$effect(() => { load(); }); $effect(() => { load(); });
// ── Derived data ───────────────────────────────────────────────────────────
const loggedInTrackers = $derived(trackers.filter(t => t.isLoggedIn)); const loggedInTrackers = $derived(trackers.filter(t => t.isLoggedIn));
const allRecords: FlatRecord[] = $derived( const allRecords: FlatRecord[] = $derived(
loggedInTrackers.flatMap(t => loggedInTrackers.flatMap(t =>
t.trackRecords.nodes.map(r => ({ t.trackRecords.nodes.map(r => ({
...r, ...r,
trackerId: r.trackerId ?? t.id, // fallback in case field is missing trackerId: r.trackerId ?? t.id,
tracker: t as Tracker, tracker: t as Tracker,
})) }))
) )
@@ -71,14 +62,11 @@
const totalCount = $derived(allRecords.length); const totalCount = $derived(allRecords.length);
// Status options across active tracker
const statusOptions = $derived.by(() => { const statusOptions = $derived.by(() => {
if (activeTrackerId === "all") { if (activeTrackerId === "all") {
// Merge all statuses, dedupe by value+name
const seen = new Map<string, { value: number; name: string }>(); const seen = new Map<string, { value: number; name: string }>();
for (const t of loggedInTrackers) { for (const t of loggedInTrackers)
for (const s of t.statuses ?? []) seen.set(`${s.value}:${s.name}`, s); for (const s of t.statuses ?? []) seen.set(`${s.value}:${s.name}`, s);
}
return [...seen.values()]; return [...seen.values()];
} }
return loggedInTrackers.find(t => t.id === activeTrackerId)?.statuses ?? []; return loggedInTrackers.find(t => t.id === activeTrackerId)?.statuses ?? [];
@@ -111,8 +99,6 @@
}); });
}); });
// ── Mutations ──────────────────────────────────────────────────────────────
async function updateStatus(record: FlatRecord, status: number) { async function updateStatus(record: FlatRecord, status: number) {
updatingId = record.id; updatingId = record.id;
try { try {
@@ -122,9 +108,7 @@
patchRecord(record.trackerId, res.updateTrack.trackRecord); patchRecord(record.trackerId, 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 { updatingId = null; }
updatingId = null;
}
} }
async function updateScore(record: FlatRecord, scoreString: string) { async function updateScore(record: FlatRecord, scoreString: string) {
@@ -136,9 +120,7 @@
patchRecord(record.trackerId, res.updateTrack.trackRecord); patchRecord(record.trackerId, 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 { updatingId = null; }
updatingId = null;
}
} }
async function syncRecord(record: FlatRecord) { async function syncRecord(record: FlatRecord) {
@@ -151,9 +133,7 @@
addToast({ kind: "success", title: "Synced from tracker" }); 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 { syncingId = null; }
syncingId = null;
}
} }
async function unbind(record: FlatRecord) { async function unbind(record: FlatRecord) {
@@ -169,9 +149,7 @@
addToast({ kind: "info", title: "Unlinked from " + record.tracker.name }); addToast({ kind: "info", title: "Unlinked from " + record.tracker.name });
} catch (e: any) { } catch (e: any) {
addToast({ kind: "error", title: "Unbind failed", body: e?.message }); addToast({ kind: "error", title: "Unbind failed", body: e?.message });
} finally { } finally { updatingId = null; }
updatingId = null;
}
} }
function patchRecord(trackerId: number, updated: Partial<TrackRecord> & { id: number }) { function patchRecord(trackerId: number, updated: Partial<TrackRecord> & { id: number }) {
@@ -196,9 +174,7 @@
chapterDraft = record.lastChapterRead; chapterDraft = record.lastChapterRead;
} }
function cancelChapterEditor() { function cancelChapterEditor() { editingChapter = null; }
editingChapter = null;
}
async function submitChapter(record: FlatRecord) { async function submitChapter(record: FlatRecord) {
const val = Math.max(0, chapterDraft); const val = Math.max(0, chapterDraft);
@@ -212,15 +188,34 @@
patchRecord(record.trackerId, res.updateTrack.trackRecord); patchRecord(record.trackerId, 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 { updatingId = null; }
updatingId = null;
} }
function requestUnbind(record: FlatRecord) {
confirmUnbindRecord = record;
}
function cancelUnbind() {
confirmUnbindRecord = null;
}
async function confirmAndUnbind() {
if (!confirmUnbindRecord) return;
const record = confirmUnbindRecord;
confirmUnbindRecord = null;
await unbind(record);
}
function scoreToStars(score: string | undefined, scores: string[] | undefined): number {
if (!score || !scores || scores.length === 0) return 0;
const idx = scores.indexOf(score);
if (idx < 0) return 0;
return Math.round((idx / (scores.length - 1)) * 5);
} }
</script> </script>
<div class="page"> <div class="page">
<!-- ── Header ──────────────────────────────────────────────────────────── -->
<div class="header"> <div class="header">
<div class="header-top"> <div class="header-top">
<h1 class="heading">Tracking</h1> <h1 class="heading">Tracking</h1>
@@ -231,7 +226,6 @@
</div> </div>
</div> </div>
<!-- Tracker filter tabs -->
{#if !loading && loggedInTrackers.length > 0} {#if !loading && loggedInTrackers.length > 0}
<div class="tracker-tabs"> <div class="tracker-tabs">
<button <button
@@ -249,14 +243,13 @@
class:tab-active={activeTrackerId === t.id} class:tab-active={activeTrackerId === t.id}
onclick={() => { activeTrackerId = Number(t.id); statusFilter = "all"; }} onclick={() => { activeTrackerId = Number(t.id); statusFilter = "all"; }}
> >
<img src={thumbUrl(t.icon)} alt={t.name} class="tab-tracker-icon" /> <Thumbnail src={t.icon} alt={t.name} class="tab-tracker-icon" />
{t.name} {t.name}
<span class="tab-count">{count}</span> <span class="tab-count">{count}</span>
</button> </button>
{/each} {/each}
</div> </div>
<!-- Filter + sort bar -->
<div class="filter-bar"> <div class="filter-bar">
<div class="search-wrap"> <div class="search-wrap">
<MagnifyingGlass size={12} weight="light" class="search-ico" /> <MagnifyingGlass size={12} weight="light" class="search-ico" />
@@ -266,7 +259,6 @@
bind:value={searchQuery} bind:value={searchQuery}
/> />
</div> </div>
<div class="filter-right"> <div class="filter-right">
<Funnel size={12} weight="light" style="color:var(--text-faint);flex-shrink:0" /> <Funnel size={12} weight="light" style="color:var(--text-faint);flex-shrink:0" />
<select class="filter-select" bind:value={statusFilter} <select class="filter-select" bind:value={statusFilter}
@@ -279,25 +271,23 @@
<option value={s.value}>{s.name}</option> <option value={s.value}>{s.name}</option>
{/each} {/each}
</select> </select>
<select class="filter-select" bind:value={sortBy}> <select class="filter-select" bind:value={sortBy}>
<option value="title">Sort: Title</option> <option value="title">Title</option>
<option value="status">Sort: Status</option> <option value="status">Status</option>
<option value="score">Sort: Score</option> <option value="score">Score</option>
<option value="progress">Sort: Progress</option> <option value="progress">Progress</option>
</select> </select>
</div> </div>
</div> </div>
{/if} {/if}
</div> </div>
<!-- ── Body ────────────────────────────────────────────────────────────── -->
<div class="page-body"> <div class="page-body">
{#if loading} {#if loading}
<div class="state-center"> <div class="state-center">
<CircleNotch size={22} weight="light" class="anim-spin" style="color:var(--text-faint)" /> <CircleNotch size={20} weight="light" class="anim-spin" style="color:var(--text-faint)" />
<span class="state-label">Loading tracking data</span> <span class="state-label">Loading…</span>
</div> </div>
{:else if error} {:else if error}
@@ -309,19 +299,19 @@
{:else if loggedInTrackers.length === 0} {:else if loggedInTrackers.length === 0}
<div class="state-center"> <div class="state-center">
<p class="state-text">No trackers connected.</p> <p class="state-text">No trackers connected.</p>
<p class="state-hint">Go to Settings → Tracking to log in to AniList, MAL, or others.</p> <p class="state-hint">Go to Settings → Tracking to connect AniList, MAL, or others.</p>
</div> </div>
{:else if filtered.length === 0} {:else if filtered.length === 0}
<div class="state-center"> <div class="state-center">
<p class="state-text">{searchQuery || statusFilter !== "all" ? "No results match your filters." : "Nothing tracked yet."}</p> <p class="state-text">{searchQuery || statusFilter !== "all" ? "No results." : "Nothing tracked yet."}</p>
{#if searchQuery || statusFilter !== "all"} {#if searchQuery || statusFilter !== "all"}
<button class="retry-btn" onclick={() => { searchQuery = ""; statusFilter = "all"; }}>Clear filters</button> <button class="retry-btn" onclick={() => { searchQuery = ""; statusFilter = "all"; }}>Clear filters</button>
{/if} {/if}
</div> </div>
{:else} {:else}
<div class="records-list"> <div class="records-grid">
{#each filtered as record (record.tracker.id + ":" + record.id)} {#each filtered as record (record.tracker.id + ":" + record.id)}
{@const tracker = record.tracker} {@const tracker = record.tracker}
{@const isBusy = updatingId === record.id} {@const isBusy = updatingId === record.id}
@@ -329,64 +319,74 @@
{@const progress = record.totalChapters > 0 {@const progress = record.totalChapters > 0
? Math.min(100, (record.lastChapterRead / record.totalChapters) * 100) ? Math.min(100, (record.lastChapterRead / record.totalChapters) * 100)
: null} : null}
{@const stars = scoreToStars(record.displayScore, tracker.scores)}
{@const statusName = (tracker.statuses ?? []).find(s => s.value === record.status)?.name ?? "—"}
<div class="record-card" class:record-busy={isBusy}> <div class="record-card" class:record-busy={isBusy}>
<!-- Cover --> <div class="card-cover-wrap">
<div class="record-cover-wrap" role="button" tabindex="0" <div class="card-cover-region"
role="button" tabindex="0"
onclick={() => openManga(record)} onclick={() => openManga(record)}
onkeydown={(e) => e.key === "Enter" && openManga(record)} onkeydown={(e) => e.key === "Enter" && openManga(record)}
title="Open in library"
> >
{#if record.manga?.thumbnailUrl} {#if record.manga?.thumbnailUrl}
<img src={thumbUrl(record.manga.thumbnailUrl)} alt={record.title} class="record-cover" loading="lazy" /> <Thumbnail src={record.manga.thumbnailUrl} alt={record.title} class="card-cover-img" />
{:else} {:else}
<div class="record-cover record-cover-empty"></div> <div class="card-cover-empty"></div>
{/if} {/if}
<!-- Tracker badge -->
<img src={thumbUrl(tracker.icon)} alt={tracker.name} class="record-tracker-badge" />
</div> </div>
<!-- Info --> <div class="card-top-actions">
<div class="record-body"> {#if record.private}
<div class="record-top"> <span class="card-badge-btn" title="Private"><Lock size={10} weight="fill" /></span>
<div class="record-titles" role="button" tabindex="0"
onclick={() => openManga(record)}
onkeydown={(e) => e.key === "Enter" && openManga(record)}
>
<span class="record-title">{record.title}</span>
{#if record.manga?.title && record.manga.title !== record.title}
<span class="record-local-title">{record.manga.title}</span>
{/if}
</div>
<div class="record-header-actions">
{#if activeTrackerId === "all"}
<span class="record-tracker-label">
<img src={thumbUrl(record.tracker.icon)} alt={record.tracker.name} class="record-tracker-label-icon" />
{record.tracker.name}
</span>
{/if} {/if}
{#if isSyncing} {#if isSyncing}
<CircleNotch size={12} weight="light" class="anim-spin" style="color:var(--text-faint)" /> <span class="card-badge-btn">
<CircleNotch size={10} weight="light" class="anim-spin" />
</span>
{:else} {:else}
<button class="card-icon-btn" title="Sync from tracker" onclick={() => syncRecord(record)}> <button class="card-badge-btn" title="Sync" onclick={() => syncRecord(record)}>
<ArrowsClockwise size={12} weight="light" /> <ArrowsClockwise size={10} weight="light" />
</button> </button>
{/if} {/if}
{#if record.remoteUrl} {#if record.remoteUrl}
<a href={record.remoteUrl} target="_blank" rel="noreferrer" class="card-icon-btn" title="Open on {record.tracker.name}"> <a href={record.remoteUrl} target="_blank" rel="noreferrer" class="card-badge-btn" title="Open on {tracker.name}">
<ArrowSquareOut size={12} weight="light" /> <ArrowSquareOut size={10} weight="light" />
</a> </a>
{/if} {/if}
<button class="card-icon-btn danger" title="Unlink" onclick={() => unbind(record)} disabled={isBusy}> <button class="card-badge-btn danger" title="Unlink" onclick={() => requestUnbind(record)} disabled={isBusy}>
<X size={12} weight="bold" /> <X size={10} weight="bold" />
</button> </button>
</div> </div>
<div class="card-tracker-badge">
<Thumbnail src={tracker.icon} alt={tracker.name} class="tracker-badge-img" />
</div>
</div> </div>
<!-- Controls row --> <div class="card-footer">
<div class="record-controls"> <div class="card-stars">
{#each Array(5) as _, i}
<span class="star" class:star-filled={i < stars}>★</span>
{/each}
</div>
<div class="card-title-block"
role="button" tabindex="0"
onclick={() => openManga(record)}
onkeydown={(e) => e.key === "Enter" && openManga(record)}
>
<span class="card-title">{record.title}</span>
{#if record.manga?.title && record.manga.title !== record.title}
<span class="card-local-title">{record.manga.title}</span>
{/if}
</div>
<div class="card-meta-row">
<select <select
class="record-select" class="status-pill"
value={record.status} value={record.status}
disabled={isBusy} disabled={isBusy}
onchange={(e) => updateStatus(record, parseInt((e.target as HTMLSelectElement).value))} onchange={(e) => updateStatus(record, parseInt((e.target as HTMLSelectElement).value))}
@@ -397,7 +397,7 @@
</select> </select>
<select <select
class="record-select record-select-score" class="score-select"
value={record.displayScore} value={record.displayScore}
disabled={isBusy} disabled={isBusy}
onchange={(e) => updateScore(record, (e.target as HTMLSelectElement).value)} onchange={(e) => updateScore(record, (e.target as HTMLSelectElement).value)}
@@ -406,29 +406,18 @@
<option value={s}> {s}</option> <option value={s}> {s}</option>
{/each} {/each}
</select> </select>
{#if record.private}
<span class="private-badge" title="Private"><Lock size={10} weight="fill" /></span>
{/if}
</div> </div>
<!-- Progress / Chapter editor -->
{#if editingChapter === record.id} {#if editingChapter === record.id}
<div class="chapter-editor"> <div class="chapter-editor" role="presentation" onclick={(e) => e.stopPropagation()}>
<div class="chapter-editor-top"> <div class="chapter-editor-top">
<span class="chapter-editor-label">Chapter read</span> <span class="chapter-editor-label">Chapter</span>
<div class="chapter-input-wrap"> <div class="chapter-input-wrap">
<input <input
type="number" type="number" class="chapter-input"
class="chapter-input" min="0" max={record.totalChapters > 0 ? record.totalChapters : undefined}
min="0" step="0.5" bind:value={chapterDraft}
max={record.totalChapters > 0 ? record.totalChapters : undefined} onkeydown={(e) => { if (e.key === "Enter") submitChapter(record); if (e.key === "Escape") cancelChapterEditor(); }}
step="0.5"
bind:value={chapterDraft}
onkeydown={(e) => {
if (e.key === "Enter") submitChapter(record);
if (e.key === "Escape") cancelChapterEditor();
}}
use:focusEl use:focusEl
/> />
{#if record.totalChapters > 0} {#if record.totalChapters > 0}
@@ -437,45 +426,39 @@
</div> </div>
</div> </div>
{#if record.totalChapters > 0} {#if record.totalChapters > 0}
<input <input type="range" class="chapter-slider" min="0" max={record.totalChapters} step="1" bind:value={chapterDraft} />
type="range"
class="chapter-slider"
min="0"
max={record.totalChapters}
step="1"
bind:value={chapterDraft}
/>
{/if} {/if}
<div class="chapter-editor-actions"> <div class="chapter-editor-actions">
<button class="chapter-save-btn" onclick={() => submitChapter(record)}>Save</button>
<button class="chapter-cancel-btn" onclick={cancelChapterEditor}>Cancel</button> <button class="chapter-cancel-btn" onclick={cancelChapterEditor}>Cancel</button>
<button class="chapter-save-btn" onclick={() => submitChapter(record)}>Save</button>
</div> </div>
</div> </div>
{:else if progress !== null}
<div class="record-progress clickable" role="button" tabindex="0"
onclick={() => openChapterEditor(record)}
onkeydown={(e) => e.key === "Enter" && openChapterEditor(record)}
title="Click to edit"
>
<div class="progress-track">
<div class="progress-fill" style="width:{progress}%"></div>
</div>
<span class="progress-label">Ch. {record.lastChapterRead} / {record.totalChapters}</span>
<span class="progress-edit-hint"></span>
</div>
{:else} {:else}
<div class="record-progress clickable no-total" role="button" tabindex="0" <div class="progress-block clickable"
role="button" tabindex="0"
onclick={() => openChapterEditor(record)} onclick={() => openChapterEditor(record)}
onkeydown={(e) => e.key === "Enter" && openChapterEditor(record)} onkeydown={(e) => e.key === "Enter" && openChapterEditor(record)}
title="Click to set chapter" title="Click to edit chapter"
> >
<span class="progress-label"> <div class="progress-labels">
{record.lastChapterRead > 0 ? `Ch. ${record.lastChapterRead} read` : "Set chapter…"} <span class="progress-text">
{#if progress !== null}
Ch.&nbsp;{record.lastChapterRead}&thinsp;/&thinsp;{record.totalChapters}
{:else if record.lastChapterRead > 0}
Ch.&nbsp;{record.lastChapterRead}&nbsp;read
{:else}
Set chapter…
{/if}
</span> </span>
<span class="progress-edit-hint"></span> {#if progress !== null}
<span class="progress-pct">{Math.round(progress)}%</span>
{/if}
</div>
<div class="progress-track">
<div class="progress-fill" style="width:{progress ?? 0}%"></div>
</div>
</div> </div>
{/if} {/if}
</div> </div>
</div> </div>
{/each} {/each}
@@ -485,142 +468,425 @@
</div> </div>
</div> </div>
<style> {#if confirmUnbindRecord}
.page { display: flex; flex-direction: column; height: 100%; overflow: hidden; animation: fadeIn 0.14s ease both; } {@const r = confirmUnbindRecord}
<div class="modal-backdrop" role="presentation" onclick={cancelUnbind}>
<div class="modal" role="dialog" aria-modal="true" onclick={(e) => e.stopPropagation()}>
<div class="modal-icon">
<X size={18} weight="bold" />
</div>
<p class="modal-title">Unlink from {r.tracker.name}?</p>
<p class="modal-body">
<strong>{r.title}</strong> will be removed from your tracking list. This won't affect your progress on {r.tracker.name} itself.
</p>
<div class="modal-actions">
<button class="modal-cancel" onclick={cancelUnbind}>Cancel</button>
<button class="modal-confirm" onclick={confirmAndUnbind}>Unlink</button>
</div>
</div>
</div>
{/if}
/* ── Header ─────────────────────────────────────────────────────────────── */ <style>
.header { flex-shrink: 0; border-bottom: 1px solid var(--border-dim); background: var(--bg-base); } .page {
.header-top { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-4) var(--sp-6) var(--sp-3); } display: flex; flex-direction: column; height: 100%; overflow: hidden;
.heading { font-family: var(--font-ui); font-size: var(--text-xs); font-weight: var(--weight-normal); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; } animation: fadeIn 0.16s ease both;
}
.header {
flex-shrink: 0;
border-bottom: 1px solid var(--border-dim);
background: var(--bg-base);
}
.header-top {
display: flex; align-items: center; justify-content: space-between;
padding: var(--sp-4) var(--sp-6) var(--sp-3);
}
.heading {
font-family: var(--font-ui); font-size: var(--text-xs);
font-weight: var(--weight-normal); color: var(--text-faint);
letter-spacing: var(--tracking-wider); text-transform: uppercase;
}
.header-actions { display: flex; align-items: center; gap: var(--sp-2); } .header-actions { display: flex; align-items: center; gap: var(--sp-2); }
.icon-btn { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); color: var(--text-muted); background: none; cursor: pointer; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); } .icon-btn {
.icon-btn:hover:not(:disabled) { color: var(--text-secondary); border-color: var(--border-strong); background: var(--bg-raised); } display: flex; align-items: center; justify-content: center;
width: 26px; height: 26px; border-radius: var(--radius-sm);
border: none; color: var(--text-faint); background: none;
cursor: pointer; transition: color var(--t-base), background var(--t-base);
}
.icon-btn:hover:not(:disabled) { color: var(--text-muted); background: var(--bg-raised); }
.icon-btn:disabled { opacity: 0.3; cursor: default; } .icon-btn:disabled { opacity: 0.3; cursor: default; }
/* ── Tracker tabs ───────────────────────────────────────────────────────── */ .tracker-tabs {
.tracker-tabs { display: flex; align-items: center; gap: 2px; padding: var(--sp-2) var(--sp-4); overflow-x: auto; scrollbar-width: none; } display: flex; align-items: center; gap: 1px;
padding: 0 var(--sp-5); overflow-x: auto; scrollbar-width: none;
}
.tracker-tabs::-webkit-scrollbar { display: none; } .tracker-tabs::-webkit-scrollbar { display: none; }
.tracker-tab { display: flex; align-items: center; gap: var(--sp-2); padding: 4px 10px; font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); color: var(--text-faint); background: none; border: none; border-radius: var(--radius-md); cursor: pointer; white-space: nowrap; transition: color var(--t-base), background var(--t-base); } .tracker-tab {
display: flex; align-items: center; gap: var(--sp-2);
padding: 9px 10px 8px;
font-family: var(--font-ui); font-size: var(--text-xs);
letter-spacing: var(--tracking-wide); color: var(--text-faint);
background: none; border: none; border-bottom: 2px solid transparent;
cursor: pointer; white-space: nowrap; margin-bottom: -1px;
transition: color var(--t-base), border-color var(--t-base);
}
.tracker-tab:hover { color: var(--text-muted); } .tracker-tab:hover { color: var(--text-muted); }
.tab-active { background: var(--accent-muted); color: var(--accent-fg) !important; border: 1px solid var(--accent-dim); } .tab-active { color: var(--text-secondary) !important; border-bottom-color: var(--accent); }
.tab-tracker-icon { width: 14px; height: 14px; border-radius: 2px; object-fit: contain; } :global(.tab-tracker-icon) { width: 13px; height: 13px; border-radius: 2px; object-fit: contain; opacity: 0.85; }
.tab-count { font-size: 10px; padding: 1px 5px; border-radius: var(--radius-full); background: var(--bg-overlay); color: var(--text-faint); min-width: 18px; text-align: center; } .tab-count {
font-size: 10px; padding: 0 4px; border-radius: var(--radius-full);
background: var(--bg-overlay); color: var(--text-faint);
min-width: 16px; text-align: center; line-height: 16px;
}
.tab-active .tab-count { background: var(--accent-muted); color: var(--accent-fg); }
/* ── Filter bar ─────────────────────────────────────────────────────────── */ .filter-bar {
.filter-bar { display: flex; align-items: center; gap: var(--sp-3); padding: var(--sp-2) var(--sp-5); border-top: 1px solid var(--border-dim); } display: flex; align-items: center; gap: var(--sp-3);
.search-wrap { display: flex; align-items: center; gap: var(--sp-2); flex: 1; background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 5px 10px; } padding: var(--sp-2) var(--sp-5);
border-top: 1px solid var(--border-dim);
}
.search-wrap {
display: flex; align-items: center; gap: var(--sp-2); flex: 1;
background: var(--bg-raised); border: 1px solid var(--border-dim);
border-radius: var(--radius-md); padding: 4px 10px;
}
:global(.search-ico) { color: var(--text-faint); flex-shrink: 0; } :global(.search-ico) { color: var(--text-faint); flex-shrink: 0; }
.filter-search { flex: 1; background: none; border: none; outline: none; font-size: var(--text-sm); color: var(--text-primary); min-width: 0; } .filter-search {
flex: 1; background: none; border: none; outline: none;
font-size: var(--text-sm); color: var(--text-primary); min-width: 0;
}
.filter-search::placeholder { color: var(--text-faint); } .filter-search::placeholder { color: var(--text-faint); }
.filter-right { display: flex; align-items: center; gap: var(--sp-2); flex-shrink: 0; } .filter-right { display: flex; align-items: center; gap: var(--sp-2); flex-shrink: 0; }
.filter-select { .filter-select {
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); font-family: var(--font-ui); font-size: var(--text-2xs);
padding: 5px 28px 5px 10px; border-radius: var(--radius-md); letter-spacing: var(--tracking-wide); padding: 4px 22px 4px 8px;
border: 1px solid var(--border-dim); background: var(--bg-raised); border-radius: var(--radius-sm); border: 1px solid var(--border-dim);
color: var(--text-muted); outline: none; cursor: pointer; background: var(--bg-raised); color: var(--text-faint);
appearance: none; -webkit-appearance: none; outline: none; cursor: pointer; appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%23888' stroke-width='1.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E"); 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; background-repeat: no-repeat; background-position: right 6px center;
transition: border-color var(--t-base), color var(--t-base); transition: border-color var(--t-base), color var(--t-base);
} }
.filter-select:hover { border-color: var(--border-strong); color: var(--text-secondary); } .filter-select:hover { border-color: var(--border-strong); color: var(--text-muted); }
.filter-select option { background: var(--bg-surface); color: var(--text-secondary); } .filter-select option { background: var(--bg-surface); color: var(--text-secondary); }
/* ── Body ───────────────────────────────────────────────────────────────── */ .page-body {
.page-body { flex: 1; overflow-y: auto; padding: var(--sp-4) var(--sp-5); scrollbar-width: thin; scrollbar-color: var(--border-strong) transparent; } flex: 1; overflow-y: auto; padding: var(--sp-5);
scrollbar-width: thin; scrollbar-color: var(--border-strong) transparent;
}
/* ── States ─────────────────────────────────────────────────────────────── */ .state-center {
.state-center { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: var(--sp-3); height: 100%; padding: var(--sp-10); text-align: center; } display: flex; flex-direction: column; align-items: center;
justify-content: center; gap: var(--sp-3); height: 100%;
padding: var(--sp-10); text-align: center;
}
.state-label { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); } .state-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); } .state-hint { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
.state-error { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--color-error); letter-spacing: var(--tracking-wide); } .state-error { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--color-error); letter-spacing: var(--tracking-wide); }
.retry-btn { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 6px 16px; border-radius: var(--radius-md); border: 1px solid var(--border-strong); background: none; color: var(--text-muted); cursor: pointer; transition: color var(--t-base), border-color var(--t-base); } .retry-btn {
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
padding: 5px 14px; border-radius: var(--radius-md);
border: 1px solid var(--border-dim); background: none;
color: var(--text-faint); cursor: pointer;
transition: color var(--t-base), border-color var(--t-base);
}
.retry-btn:hover { color: var(--accent-fg); border-color: var(--accent-dim); } .retry-btn:hover { color: var(--accent-fg); border-color: var(--accent-dim); }
/* ── Records list ───────────────────────────────────────────────────────── */ .records-grid {
.records-list { display: flex; flex-direction: column; gap: var(--sp-2); } display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: var(--sp-4);
align-content: start;
}
.record-card { .record-card {
display: flex; align-items: flex-start; gap: var(--sp-4); display: flex; flex-direction: column;
padding: var(--sp-3) var(--sp-4); border-radius: var(--radius-lg);
border-radius: var(--radius-md);
border: 1px solid var(--border-dim); border: 1px solid var(--border-dim);
background: var(--bg-raised); background: var(--bg-raised);
transition: border-color var(--t-base), opacity var(--t-base); overflow: hidden;
transition: border-color var(--t-base), opacity var(--t-base), transform var(--t-base);
} }
.record-card:hover { border-color: var(--border-strong); } .record-card:hover {
.record-busy { opacity: 0.5; pointer-events: none; } border-color: var(--border-strong);
transform: translateY(-2px);
/* Cover */
.record-cover-wrap { position: relative; flex-shrink: 0; cursor: pointer; }
.record-cover { width: 48px; height: 68px; object-fit: cover; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); display: block; }
.record-cover-empty { background: var(--bg-overlay); }
.record-cover-wrap:hover .record-cover { opacity: 0.8; }
.record-tracker-badge { position: absolute; bottom: -4px; right: -4px; width: 16px; height: 16px; border-radius: 3px; border: 1px solid var(--bg-raised); object-fit: contain; background: var(--bg-raised); }
/* Body */
.record-body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: var(--sp-2); }
.record-top { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--sp-3); }
.record-titles { display: flex; flex-direction: column; gap: 2px; flex: 1; min-width: 0; cursor: pointer; }
.record-title { font-size: var(--text-sm); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; transition: color var(--t-base); }
.record-titles:hover .record-title { color: var(--accent-fg); }
.record-local-title { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.record-header-actions { display: flex; align-items: center; gap: 2px; flex-shrink: 0; }
.record-tracker-label { display: flex; align-items: center; gap: 4px; font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); padding: 2px 6px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: var(--bg-overlay); margin-right: var(--sp-1); }
.record-tracker-label-icon { width: 12px; height: 12px; border-radius: 2px; object-fit: contain; }
.card-icon-btn { display: flex; align-items: center; justify-content: center; width: 22px; height: 22px; border-radius: var(--radius-sm); color: var(--text-faint); background: none; border: none; cursor: pointer; text-decoration: none; transition: color var(--t-base), background var(--t-base); }
.card-icon-btn:hover { color: var(--text-muted); background: var(--bg-overlay); }
.card-icon-btn.danger:hover { color: var(--color-error); background: var(--color-error-bg); }
.card-icon-btn:disabled { opacity: 0.3; cursor: default; }
/* Controls */
.record-controls { display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap; }
.record-select {
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
padding: 3px 26px 3px 8px; border-radius: var(--radius-sm);
border: 1px solid var(--border-strong); background: var(--bg-overlay);
color: var(--text-secondary); outline: none; cursor: pointer;
appearance: none; -webkit-appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%23888' stroke-width='1.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E");
background-repeat: no-repeat; background-position: right 7px center;
transition: border-color var(--t-base);
} }
.record-select:hover:not(:disabled) { border-color: var(--accent-dim); } .record-busy { opacity: 0.35; pointer-events: none; }
.record-select:disabled { opacity: 0.4; cursor: default; }
.record-select option { background: var(--bg-surface); color: var(--text-secondary); }
.record-select-score { max-width: 90px; }
.private-badge { display: flex; align-items: center; color: var(--text-faint); padding: 2px 4px; }
/* Progress */ .card-cover-wrap {
.record-progress { display: flex; align-items: center; gap: var(--sp-3); } position: relative;
.progress-track { flex: 1; height: 3px; background: var(--border-base); border-radius: var(--radius-full); overflow: hidden; max-width: 160px; } aspect-ratio: 2 / 3;
.progress-fill { height: 100%; background: var(--accent); border-radius: var(--radius-full); transition: width 0.3s ease; } flex-shrink: 0;
.progress-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); white-space: nowrap; } overflow: hidden;
.record-progress.clickable { cursor: pointer; border-radius: var(--radius-sm); padding: 2px 4px; margin: -2px -4px; transition: background var(--t-fast); } background: var(--bg-overlay);
.record-progress.clickable:hover { background: var(--bg-overlay); } }
.record-progress.clickable:hover .progress-label { color: var(--text-muted); }
.progress-edit-hint { font-size: 10px; color: var(--text-faint); opacity: 0; transition: opacity var(--t-fast); }
.record-progress.clickable:hover .progress-edit-hint { opacity: 1; }
/* Chapter editor */ .card-cover-region {
.chapter-editor { display: flex; flex-direction: column; gap: var(--sp-2); padding: var(--sp-3); border-radius: var(--radius-md); border: 1px solid var(--accent-dim); background: var(--bg-overlay); } position: absolute; inset: 0;
.chapter-editor-top { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-3); } cursor: pointer;
.chapter-editor-label { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); } }
.chapter-input-wrap { display: flex; align-items: center; gap: var(--sp-2); }
.chapter-input { width: 72px; background: var(--bg-raised); border: 1px solid var(--accent-dim); border-radius: var(--radius-sm); padding: 4px 8px; font-family: var(--font-ui); font-size: var(--text-sm); color: var(--text-primary); outline: none; text-align: center; appearance: none; -webkit-appearance: none; -moz-appearance: textfield; } :global(.card-cover-img) {
width: 100%; height: 100%;
object-fit: cover; display: block;
transition: transform 0.35s ease, opacity 0.2s ease;
}
.card-cover-wrap:hover :global(.card-cover-img) {
transform: scale(1.04);
opacity: 0.88;
}
.card-cover-empty { width: 100%; height: 100%; background: var(--bg-overlay); }
.card-stars {
display: flex; gap: 3px; align-items: center;
padding-bottom: 2px;
}
.star {
font-size: 15px; line-height: 1;
color: var(--border-strong);
transition: color var(--t-base);
}
.star-filled { color: #f5c518; }
.card-top-actions {
position: absolute; top: 6px; right: 6px; z-index: 2;
display: flex; gap: 2px;
opacity: 0;
transition: opacity var(--t-base);
}
.card-cover-wrap:hover .card-top-actions { opacity: 1; }
.card-badge-btn {
display: flex; align-items: center; justify-content: center;
width: 24px; height: 24px; border-radius: var(--radius-sm);
background: rgba(0,0,0,0.6); backdrop-filter: blur(8px);
border: 1px solid rgba(255,255,255,0.1);
color: rgba(255,255,255,0.75); cursor: pointer;
text-decoration: none;
transition: background var(--t-base), color var(--t-base);
}
.card-badge-btn:hover { background: rgba(0,0,0,0.75); color: #fff; }
.card-badge-btn.danger:hover { background: rgba(180,40,40,0.7); color: #fff; }
.card-badge-btn:disabled { opacity: 0.3; cursor: default; }
.card-tracker-badge {
position: absolute; bottom: 9px; right: 9px; z-index: 2;
width: 22px; height: 22px;
border-radius: 6px;
border: 1px solid rgba(0,0,0,0.35);
background: var(--bg-raised);
box-shadow: 0 2px 8px rgba(0,0,0,0.55);
overflow: hidden;
display: flex; align-items: center; justify-content: center;
}
:global(.tracker-badge-img) {
width: 100%; height: 100%;
object-fit: contain; display: block;
}
/* ── Footer panel ───────────────────────────────────────────────────────── */
.card-footer {
display: flex; flex-direction: column; gap: 10px;
padding: 13px 13px 13px;
border-top: 1px solid var(--border-dim);
}
/* Title */
.card-title-block {
display: flex; flex-direction: column; gap: 3px;
cursor: pointer; min-width: 0;
}
.card-title {
font-size: var(--text-sm); font-weight: var(--weight-medium);
color: var(--text-secondary); line-height: 1.38;
display: -webkit-box; -webkit-line-clamp: 2;
-webkit-box-orient: vertical; overflow: hidden;
transition: color var(--t-base);
}
.card-title-block:hover .card-title { color: var(--accent-fg); }
.card-local-title {
font-family: var(--font-ui); font-size: 11px; color: var(--text-faint);
letter-spacing: var(--tracking-wide);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.card-meta-row {
display: flex; align-items: center; gap: var(--sp-1);
}
.status-pill {
flex: 1; min-width: 0;
font-family: var(--font-ui); font-size: var(--text-2xs);
letter-spacing: var(--tracking-wide);
padding: 5px 20px 5px 9px;
border-radius: 999px;
border: 1px solid var(--border-dim);
background: var(--bg-overlay);
color: var(--text-muted);
outline: none; cursor: pointer;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='5' viewBox='0 0 8 5'%3E%3Cpath d='M1 1l3 3 3-3' stroke='%23555' stroke-width='1.3' fill='none' stroke-linecap='round'/%3E%3C/svg%3E");
background-repeat: no-repeat; background-position: right 6px center;
transition: border-color var(--t-base), color var(--t-base);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.status-pill:hover:not(:disabled) { border-color: var(--border-strong); color: var(--text-secondary); }
.status-pill:disabled { opacity: 0.35; cursor: default; }
.status-pill option { background: var(--bg-surface); color: var(--text-secondary); }
.score-select {
flex-shrink: 0; width: 58px;
font-family: var(--font-ui); font-size: var(--text-2xs);
letter-spacing: var(--tracking-wide);
padding: 5px 16px 5px 6px;
border-radius: var(--radius-sm);
border: 1px solid var(--border-dim);
background: var(--bg-overlay);
color: var(--text-faint);
outline: none; cursor: pointer;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='5' viewBox='0 0 8 5'%3E%3Cpath d='M1 1l3 3 3-3' stroke='%23555' stroke-width='1.3' fill='none' stroke-linecap='round'/%3E%3C/svg%3E");
background-repeat: no-repeat; background-position: right 4px center;
transition: border-color var(--t-base), color var(--t-base);
}
.score-select:hover:not(:disabled) { border-color: var(--border-strong); color: var(--text-secondary); }
.score-select:disabled { opacity: 0.35; cursor: default; }
.score-select option { background: var(--bg-surface); color: var(--text-secondary); }
.progress-block {
display: flex; flex-direction: column; gap: 7px;
}
.progress-block.clickable {
cursor: pointer; border-radius: var(--radius-sm);
padding: 4px 5px;
margin: 0 -5px;
transition: background var(--t-fast);
}
.progress-block.clickable:hover { background: var(--bg-overlay); }
.progress-labels {
display: flex; align-items: center; justify-content: space-between;
}
.progress-text {
font-family: var(--font-ui); font-size: var(--text-2xs);
color: var(--text-muted); letter-spacing: var(--tracking-wide);
}
.progress-pct {
font-family: var(--font-ui); font-size: var(--text-2xs);
color: var(--text-faint); letter-spacing: var(--tracking-wide);
}
.progress-track {
height: 3px; background: var(--border-strong);
border-radius: var(--radius-full); overflow: hidden;
}
.progress-fill {
height: 100%; background: var(--accent);
border-radius: var(--radius-full); transition: width 0.3s ease;
}
.chapter-editor {
display: flex; flex-direction: column; gap: var(--sp-2);
padding: var(--sp-2); border-radius: var(--radius-md);
border: 1px solid var(--border-dim); background: var(--bg-surface);
}
.chapter-editor-top { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-2); }
.chapter-editor-label { font-family: var(--font-ui); font-size: 10px; color: var(--text-faint); letter-spacing: var(--tracking-wide); }
.chapter-input-wrap { display: flex; align-items: center; gap: var(--sp-1); }
.chapter-input {
width: 58px; background: var(--bg-surface);
border: 1px solid var(--border-strong); border-radius: var(--radius-sm);
padding: 3px 6px; font-family: var(--font-ui); font-size: var(--text-xs);
color: var(--text-primary); outline: none; text-align: center;
appearance: none; -moz-appearance: textfield;
}
.chapter-input:focus { border-color: var(--accent); } .chapter-input:focus { border-color: var(--accent); }
.chapter-input::-webkit-outer-spin-button, .chapter-input::-webkit-outer-spin-button,
.chapter-input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; } .chapter-input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
.chapter-total { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); } .chapter-total { font-family: var(--font-ui); font-size: 10px; color: var(--text-faint); letter-spacing: var(--tracking-wide); }
.chapter-slider { width: 100%; accent-color: var(--accent); cursor: pointer; height: 4px; } .chapter-slider { width: 100%; accent-color: var(--accent); cursor: pointer; height: 3px; }
.chapter-editor-actions { display: flex; align-items: center; gap: var(--sp-2); justify-content: flex-end; } .chapter-editor-actions { display: flex; align-items: center; gap: var(--sp-2); justify-content: flex-end; }
.chapter-save-btn { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 4px 14px; border-radius: var(--radius-sm); border: 1px solid var(--accent); background: var(--accent-muted); color: var(--accent-fg); cursor: pointer; transition: filter var(--t-base); } .chapter-save-btn {
font-family: var(--font-ui); font-size: 10px; letter-spacing: var(--tracking-wide);
padding: 3px 10px; border-radius: var(--radius-sm);
border: 1px solid var(--accent-dim); background: var(--accent-muted);
color: var(--accent-fg); cursor: pointer; transition: filter var(--t-base);
}
.chapter-save-btn:hover { filter: brightness(1.15); } .chapter-save-btn:hover { filter: brightness(1.15); }
.chapter-cancel-btn { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 4px 10px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); cursor: pointer; transition: color var(--t-base), border-color var(--t-base); } .chapter-cancel-btn {
.chapter-cancel-btn:hover { color: var(--text-muted); border-color: var(--border-strong); } font-family: var(--font-ui); font-size: 10px; letter-spacing: var(--tracking-wide);
padding: 3px 6px; border-radius: var(--radius-sm);
border: none; background: none; color: var(--text-faint);
cursor: pointer; transition: color var(--t-base);
}
.chapter-cancel-btn:hover { color: var(--text-muted); }
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } } .modal-backdrop {
position: fixed; inset: 0; z-index: 200;
background: rgba(0,0,0,0.55);
backdrop-filter: blur(4px);
display: flex; align-items: center; justify-content: center;
animation: fadeIn 0.12s ease both;
}
.modal {
background: var(--bg-surface);
border: 1px solid var(--border-dim);
border-radius: var(--radius-xl, 14px);
padding: var(--sp-6, 24px);
width: 320px; max-width: calc(100vw - 32px);
display: flex; flex-direction: column; align-items: center; gap: var(--sp-3);
box-shadow: 0 24px 60px rgba(0,0,0,0.5);
animation: modalIn 0.18s cubic-bezier(0.34,1.56,0.64,1) both;
}
.modal-icon {
width: 40px; height: 40px; border-radius: 50%;
background: var(--color-error-bg, rgba(200,50,50,0.12));
border: 1px solid var(--color-error-dim, rgba(200,50,50,0.25));
color: var(--color-error, #e05252);
display: flex; align-items: center; justify-content: center;
flex-shrink: 0;
}
.modal-title {
font-size: var(--text-sm); font-weight: var(--weight-medium);
color: var(--text-primary); text-align: center; margin: 0;
}
.modal-body {
font-family: var(--font-ui); font-size: var(--text-xs);
color: var(--text-muted); text-align: center; line-height: 1.5;
margin: 0;
}
.modal-body strong { color: var(--text-secondary); font-weight: var(--weight-medium); }
.modal-actions {
display: flex; gap: var(--sp-2); width: 100%; margin-top: var(--sp-1);
}
.modal-cancel {
flex: 1;
font-family: var(--font-ui); font-size: var(--text-xs);
letter-spacing: var(--tracking-wide);
padding: 8px 0; border-radius: var(--radius-md);
border: 1px solid var(--border-dim); background: none;
color: var(--text-muted); cursor: pointer;
transition: border-color var(--t-base), color var(--t-base);
}
.modal-cancel:hover { border-color: var(--border-strong); color: var(--text-primary); }
.modal-confirm {
flex: 1;
font-family: var(--font-ui); font-size: var(--text-xs);
letter-spacing: var(--tracking-wide);
padding: 8px 0; border-radius: var(--radius-md);
border: 1px solid var(--color-error-dim, rgba(200,50,50,0.3));
background: var(--color-error-bg, rgba(200,50,50,0.1));
color: var(--color-error, #e05252); cursor: pointer;
transition: filter var(--t-base), background var(--t-base);
}
.modal-confirm:hover { filter: brightness(1.2); background: var(--color-error-bg, rgba(200,50,50,0.18)); }
@keyframes modalIn {
from { opacity: 0; transform: scale(0.92) translateY(8px); }
to { opacity: 1; transform: none; }
}
@keyframes fadeIn { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } }
</style> </style>
<script module> <script module>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,271 @@
<script lang="ts">
import { X } from "phosphor-svelte";
import { store, updateSettings } from "../../store/state.svelte";
import { DEFAULT_MANGA_PREFS } from "../../store/state.svelte";
import type { MangaPrefs } from "../../store/state.svelte";
let { mangaId, onClose }: {
mangaId: number;
onClose: () => void;
} = $props();
const mangaPrefs = $derived(
(store.settings.mangaPrefs?.[mangaId] ?? {}) as Partial<MangaPrefs>
);
function getPref<K extends keyof MangaPrefs>(key: K): MangaPrefs[K] {
return (mangaPrefs[key] ?? DEFAULT_MANGA_PREFS[key]) as MangaPrefs[K];
}
function setPref<K extends keyof MangaPrefs>(key: K, value: MangaPrefs[K]) {
updateSettings({
mangaPrefs: {
...store.settings.mangaPrefs,
[mangaId]: { ...(store.settings.mangaPrefs?.[mangaId] ?? {}), [key]: value },
},
});
}
const DOWNLOAD_AHEAD_OPTIONS = [
{ value: 0, label: "Off" },
{ value: 2, label: "2" },
{ value: 5, label: "5" },
{ value: 10, label: "10" },
];
const MAX_KEEP_OPTIONS = [
{ value: 0, label: "Off" },
{ value: 5, label: "5" },
{ value: 10, label: "10" },
{ value: 25, label: "25" },
];
const DELETE_DELAY_OPTIONS = [
{ value: 0, label: "Now" },
{ value: 24, label: "1 day" },
{ value: 168, label: "1 week" },
];
const REFRESH_INTERVAL_OPTIONS = [
{ value: "global", label: "Default" },
{ value: "daily", label: "Daily" },
{ value: "weekly", label: "Weekly" },
{ value: "manual", label: "Manual" },
];
function onBackdrop(e: MouseEvent) {
if (e.target === e.currentTarget) onClose();
}
</script>
<div class="backdrop" role="presentation" tabindex="-1" onmousedown={onBackdrop}>
<div class="modal" role="dialog" aria-modal="true" aria-label="Automation">
<div class="modal-header">
<div class="header-left">
<span class="modal-title">Automation</span>
<span class="modal-subtitle">Per-series rules</span>
</div>
<button class="close-btn" onclick={onClose} aria-label="Close"><X size={16} weight="light" /></button>
</div>
<div class="modal-body">
<p class="section-label">Downloads</p>
<div class="auto-row">
<div class="auto-info">
<span class="auto-label">Auto-download new chapters</span>
<span class="auto-desc">Queue new chapters when this series refreshes</span>
</div>
<button
role="switch"
aria-checked={getPref("autoDownload")}
aria-label="Auto-download new chapters"
class="auto-toggle"
class:auto-toggle-on={getPref("autoDownload")}
onclick={() => setPref("autoDownload", !getPref("autoDownload"))}
><span class="auto-toggle-thumb"></span></button>
</div>
<div class="auto-row">
<div class="auto-info">
<span class="auto-label">Download ahead</span>
<span class="auto-desc">Pre-fetch chapters while reading</span>
</div>
<div class="auto-chip-group">
{#each DOWNLOAD_AHEAD_OPTIONS as opt}
<button
class="auto-chip"
class:auto-chip-on={getPref("downloadAhead") === opt.value}
onclick={() => setPref("downloadAhead", opt.value)}
>{opt.label}</button>
{/each}
</div>
</div>
<div class="auto-row">
<div class="auto-info">
<span class="auto-label">Max chapters to keep</span>
<span class="auto-desc">Delete oldest downloads when limit is exceeded</span>
</div>
<div class="auto-chip-group">
{#each MAX_KEEP_OPTIONS as opt}
<button
class="auto-chip"
class:auto-chip-on={getPref("maxKeepChapters") === opt.value}
onclick={() => setPref("maxKeepChapters", opt.value)}
>{opt.label}</button>
{/each}
</div>
</div>
<div class="divider"></div>
<p class="section-label">On Read</p>
<div class="auto-row">
<div class="auto-info">
<span class="auto-label">Delete after reading</span>
<span class="auto-desc">Remove download when chapter is marked read</span>
</div>
<button
role="switch"
aria-checked={getPref("deleteOnRead")}
aria-label="Delete after reading"
class="auto-toggle"
class:auto-toggle-on={getPref("deleteOnRead")}
onclick={() => setPref("deleteOnRead", !getPref("deleteOnRead"))}
><span class="auto-toggle-thumb"></span></button>
</div>
{#if getPref("deleteOnRead")}
<div class="auto-row auto-row-sub">
<span class="auto-label">Delete delay</span>
<div class="auto-chip-group">
{#each DELETE_DELAY_OPTIONS as opt}
<button
class="auto-chip"
class:auto-chip-on={getPref("deleteDelayHours") === opt.value}
onclick={() => setPref("deleteDelayHours", opt.value)}
>{opt.label}</button>
{/each}
</div>
</div>
{/if}
<div class="divider"></div>
<p class="section-label">Updates</p>
<div class="auto-row">
<div class="auto-info">
<span class="auto-label">Pause updates</span>
<span class="auto-desc">Skip this series during global refresh</span>
</div>
<button
role="switch"
aria-checked={getPref("pauseUpdates")}
aria-label="Pause updates"
class="auto-toggle"
class:auto-toggle-on={getPref("pauseUpdates")}
onclick={() => setPref("pauseUpdates", !getPref("pauseUpdates"))}
><span class="auto-toggle-thumb"></span></button>
</div>
<div class="auto-row">
<div class="auto-info">
<span class="auto-label">Refresh interval</span>
<span class="auto-desc">How often to check for new chapters</span>
</div>
<div class="auto-chip-group">
{#each REFRESH_INTERVAL_OPTIONS as opt}
<button
class="auto-chip"
class:auto-chip-on={getPref("refreshInterval") === opt.value}
onclick={() => setPref("refreshInterval", opt.value as MangaPrefs["refreshInterval"])}
>{opt.label}</button>
{/each}
</div>
</div>
</div>
</div>
</div>
<style>
.backdrop {
position: fixed; inset: 0; z-index: 300;
background: rgba(0,0,0,0.6);
display: flex; align-items: center; justify-content: center;
animation: fadeIn 0.1s ease both;
}
.modal {
width: 420px; max-width: calc(100vw - var(--sp-6));
max-height: 80vh;
display: flex; flex-direction: column;
background: var(--bg-surface); border: 1px solid var(--border-base);
border-radius: var(--radius-xl); overflow: hidden;
box-shadow: 0 0 0 1px var(--border-dim), 0 24px 64px rgba(0,0,0,0.6);
animation: scaleIn 0.15s ease both;
}
/* Header */
.modal-header {
display: flex; align-items: center; justify-content: space-between;
padding: var(--sp-4) var(--sp-5); border-bottom: 1px solid var(--border-dim); flex-shrink: 0;
}
.header-left { display: flex; flex-direction: column; gap: 2px; }
.modal-title { font-size: var(--text-base); font-weight: var(--weight-medium); color: var(--text-primary); letter-spacing: var(--tracking-tight); }
.modal-subtitle { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
.close-btn { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: var(--radius-sm); color: var(--text-faint); background: none; border: none; cursor: pointer; transition: color var(--t-base), background var(--t-base); flex-shrink: 0; }
.close-btn:hover { color: var(--text-muted); background: var(--bg-raised); }
/* Body */
.modal-body {
flex: 1; overflow-y: auto; scrollbar-width: none;
display: flex; flex-direction: column; gap: var(--sp-3);
padding: var(--sp-4) var(--sp-5);
}
.modal-body::-webkit-scrollbar { display: none; }
/* Section labels */
.section-label {
font-family: var(--font-ui); font-size: var(--text-2xs);
letter-spacing: var(--tracking-widest); color: var(--text-faint);
text-transform: uppercase; margin: 0;
}
.divider { height: 1px; background: var(--border-dim); margin: var(--sp-1) 0; }
/* Rows — mirrors SeriesDetail auto-row */
.auto-row {
display: flex; align-items: center; justify-content: space-between;
gap: var(--sp-3);
}
.auto-row-align-start { align-items: flex-start; }
.auto-row-sub {
padding-left: var(--sp-3);
border-left: 2px solid var(--border-dim);
}
.auto-info { display: flex; flex-direction: column; gap: 2px; flex: 1; min-width: 0; }
.auto-label { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); letter-spacing: var(--tracking-wide); }
.auto-desc { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); line-height: var(--leading-snug); }
/* Toggle */
.auto-toggle { width: 28px; height: 16px; border-radius: var(--radius-full); border: 1px solid var(--border-strong); background: var(--bg-overlay); cursor: pointer; padding: 0; flex-shrink: 0; position: relative; transition: background var(--t-base), border-color var(--t-base); }
.auto-toggle-on { background: var(--accent); border-color: var(--accent); }
.auto-toggle-thumb { position: absolute; top: 1px; left: 1px; width: 12px; height: 12px; border-radius: 50%; background: var(--text-faint); transition: transform var(--t-base), background var(--t-base); }
.auto-toggle-on .auto-toggle-thumb { transform: translateX(12px); background: var(--bg-base); }
/* Chips */
.auto-chip-group { display: flex; flex-direction: row; gap: 4px; flex-shrink: 0; flex-wrap: wrap; justify-content: flex-end; }
.auto-chip { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 2px 7px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); cursor: pointer; white-space: nowrap; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
.auto-chip:hover { color: var(--text-muted); border-color: var(--border-strong); background: var(--bg-raised); }
.auto-chip-on { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
@keyframes scaleIn { from { opacity: 0; transform: scale(0.97) } to { opacity: 1; transform: scale(1) } }
</style>
+198
View File
@@ -0,0 +1,198 @@
<script lang="ts">
import { X, MapPin, Trash, PencilSimple, Check } from "phosphor-svelte";
import { store, removeMarker, updateMarker, openReader } from "../../store/state.svelte";
import type { MarkerEntry, MarkerColor } from "../../store/state.svelte";
import type { Chapter } from "../../lib/types";
interface Props {
mangaId: number;
chapters: Chapter[];
onClose: () => void;
}
let { mangaId, chapters, onClose }: Props = $props();
const COLOR_HEX: Record<MarkerColor, string> = {
yellow: "#c4a94a",
red: "#c47a7a",
blue: "#7a9ec4",
green: "#7aab7a",
purple: "#a07ac4",
};
const markers = $derived(store.getMarkersForManga(mangaId));
const grouped = $derived.by(() => {
const map = new Map<number, MarkerEntry[]>();
for (const m of markers) {
if (!map.has(m.chapterId)) map.set(m.chapterId, []);
map.get(m.chapterId)!.push(m);
}
const entries = [...map.entries()].map(([chapterId, items]) => ({
chapterId,
chapterName: items[0].chapterName,
items: [...items].sort((a, b) => a.pageNumber - b.pageNumber),
}));
const chapterOrder = new Map(chapters.map((c, i) => [c.id, i]));
entries.sort((a, b) => (chapterOrder.get(a.chapterId) ?? 9999) - (chapterOrder.get(b.chapterId) ?? 9999));
return entries;
});
let editingId: string = $state("");
let editNote: string = $state("");
let editColor: MarkerColor = $state("yellow");
function startEdit(m: MarkerEntry) {
editingId = m.id;
editNote = m.note;
editColor = m.color;
}
function commitEdit() {
if (!editingId) return;
updateMarker(editingId, { note: editNote.trim(), color: editColor });
editingId = "";
}
function jumpToMarker(m: MarkerEntry) {
const chapter = chapters.find(c => c.id === m.chapterId);
if (!chapter) return;
const chaptersAsc = [...chapters].sort((a, b) => a.sourceOrder - b.sourceOrder);
openReader(chapter, chaptersAsc);
}
function formatDate(ts: number): string {
return new Date(ts).toLocaleDateString("en-US", { month: "short", day: "numeric" });
}
</script>
<div class="panel">
<div class="panel-header">
<div class="panel-title">
<MapPin size={13} weight="fill" />
<span>Markers</span>
{#if markers.length > 0}
<span class="count">{markers.length}</span>
{/if}
</div>
<button class="close-btn" onclick={onClose}><X size={14} weight="light" /></button>
</div>
<div class="panel-body">
{#if grouped.length === 0}
<div class="empty">
<MapPin size={22} weight="light" style="color:var(--text-faint);opacity:0.4" />
<p>No markers yet</p>
<p class="empty-sub">Mark pages while reading with the marker button or keybind</p>
</div>
{:else}
{#each grouped as group}
<div class="group">
<div class="group-header">
<span class="group-name">{group.chapterName}</span>
<span class="group-count">{group.items.length}</span>
</div>
{#each group.items as m (m.id)}
<div class="marker-row" class:editing={editingId === m.id}>
<div class="marker-dot" style="background:{COLOR_HEX[m.color]}"></div>
<div class="marker-body">
{#if editingId === m.id}
<div class="edit-wrap">
<div class="color-row">
{#each Object.entries(COLOR_HEX) as [c, hex]}
<button
class="color-swatch"
class:color-active={editColor === c}
style="background:{hex}"
onclick={() => editColor = c as MarkerColor}
title={c}
></button>
{/each}
</div>
<textarea
class="edit-input"
rows={3}
bind:value={editNote}
placeholder="Add a note…"
onkeydown={(e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); commitEdit(); } if (e.key === "Escape") editingId = ""; }}
></textarea>
<div class="edit-actions">
<button class="edit-save" onclick={commitEdit}><Check size={12} weight="bold" /> Save</button>
<button class="edit-cancel" onclick={() => editingId = ""}>Cancel</button>
</div>
</div>
{:else}
<button class="marker-jump" onclick={() => jumpToMarker(m)}>
<span class="page-label">p.{m.pageNumber}</span>
{#if m.note}
<span class="marker-note">{m.note}</span>
{:else}
<span class="marker-note marker-note-empty">No note</span>
{/if}
<span class="marker-date">{formatDate(m.updatedAt ?? m.createdAt)}</span>
</button>
<div class="marker-actions">
<button class="marker-action-btn" onclick={() => startEdit(m)} title="Edit"><PencilSimple size={11} weight="light" /></button>
<button class="marker-action-btn danger" onclick={() => removeMarker(m.id)} title="Delete"><Trash size={11} weight="light" /></button>
</div>
{/if}
</div>
</div>
{/each}
</div>
{/each}
{/if}
</div>
</div>
<style>
.panel { display: flex; flex-direction: column; height: 100%; overflow: hidden; }
.panel-header { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-3) var(--sp-4); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
.panel-title { display: flex; align-items: center; gap: var(--sp-2); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); color: var(--text-muted); }
.count { background: var(--bg-overlay); border: 1px solid var(--border-dim); border-radius: var(--radius-full); font-size: var(--text-2xs); padding: 0 5px; color: var(--text-faint); }
.close-btn { display: flex; align-items: center; justify-content: center; width: 24px; height: 24px; border-radius: var(--radius-sm); color: var(--text-faint); transition: color var(--t-base), background var(--t-base); }
.close-btn:hover { color: var(--text-muted); background: var(--bg-raised); }
.panel-body { flex: 1; overflow-y: auto; padding: var(--sp-2); display: flex; flex-direction: column; gap: var(--sp-1); }
.empty { display: flex; flex-direction: column; align-items: center; gap: var(--sp-2); padding: var(--sp-8) var(--sp-4); text-align: center; }
.empty p { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
.empty-sub { font-size: var(--text-2xs) !important; opacity: 0.7; max-width: 180px; line-height: var(--leading-snug); }
.group { display: flex; flex-direction: column; gap: 2px; }
.group-header { display: flex; align-items: center; justify-content: space-between; padding: 6px var(--sp-2) 4px; }
.group-name { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.group-count { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); opacity: 0.6; flex-shrink: 0; }
.marker-row { display: flex; align-items: flex-start; gap: var(--sp-2); padding: 5px var(--sp-2); border-radius: var(--radius-md); transition: background var(--t-fast); }
.marker-row:hover { background: var(--bg-raised); }
.marker-row.editing { background: var(--bg-raised); }
.marker-dot { width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0; margin-top: 5px; }
.marker-body { flex: 1; min-width: 0; display: flex; align-items: flex-start; gap: var(--sp-1); }
.marker-jump { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; text-align: left; background: none; border: none; padding: 0; cursor: pointer; }
.page-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--accent-fg); letter-spacing: var(--tracking-wide); }
.marker-note { font-size: var(--text-xs); color: var(--text-secondary); line-height: var(--leading-snug); white-space: pre-wrap; word-break: break-word; }
.marker-note-empty { color: var(--text-faint); font-style: italic; }
.marker-date { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
.marker-actions { display: flex; flex-direction: column; gap: 2px; flex-shrink: 0; opacity: 0; transition: opacity var(--t-fast); }
.marker-row:hover .marker-actions { opacity: 1; }
.marker-action-btn { display: flex; align-items: center; justify-content: center; width: 20px; height: 20px; border-radius: var(--radius-sm); color: var(--text-faint); transition: color var(--t-base), background var(--t-base); }
.marker-action-btn:hover { color: var(--text-muted); background: var(--bg-overlay); }
.marker-action-btn.danger:hover { color: var(--color-error); background: var(--color-error-bg); }
.edit-wrap { flex: 1; display: flex; flex-direction: column; gap: var(--sp-2); }
.color-row { display: flex; gap: 5px; }
.color-swatch { width: 14px; height: 14px; border-radius: 50%; border: 2px solid transparent; cursor: pointer; transition: border-color var(--t-fast), transform var(--t-fast); flex-shrink: 0; }
.color-swatch:hover { transform: scale(1.15); }
.color-active { border-color: var(--text-primary) !important; }
.edit-input { width: 100%; background: var(--bg-overlay); border: 1px solid var(--border-strong); border-radius: var(--radius-sm); padding: 6px 8px; font-size: var(--text-xs); color: var(--text-secondary); outline: none; resize: none; font-family: inherit; line-height: var(--leading-snug); transition: border-color var(--t-base); }
.edit-input:focus { border-color: var(--border-focus); }
.edit-actions { display: flex; align-items: center; gap: var(--sp-2); }
.edit-save { display: flex; align-items: center; gap: 4px; padding: 4px 10px; border-radius: var(--radius-sm); border: 1px solid var(--accent-dim); background: var(--accent-muted); color: var(--accent-fg); font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); cursor: pointer; transition: filter var(--t-fast); }
.edit-save:hover { filter: brightness(1.15); }
.edit-cancel { padding: 4px 8px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); cursor: pointer; transition: color var(--t-base), border-color var(--t-base); }
.edit-cancel:hover { color: var(--text-muted); border-color: var(--border-strong); }
</style>
@@ -1,8 +1,10 @@
<script lang="ts"> <script lang="ts">
import { X, MagnifyingGlass, CircleNotch, ArrowRight, Check, Warning, Sparkle } from "phosphor-svelte"; import { X, MagnifyingGlass, CircleNotch, ArrowRight, Check, Warning, Sparkle } from "phosphor-svelte";
import { untrack } from "svelte"; import { untrack } from "svelte";
import { gql, thumbUrl } from "../../lib/client"; import { gql } from "../../lib/client";
import Thumbnail from "../shared/Thumbnail.svelte";
import { GET_SOURCES, FETCH_SOURCE_MANGA, FETCH_CHAPTERS, UPDATE_MANGA, UPDATE_CHAPTERS_PROGRESS } from "../../lib/queries"; import { GET_SOURCES, FETCH_SOURCE_MANGA, FETCH_CHAPTERS, UPDATE_MANGA, UPDATE_CHAPTERS_PROGRESS } from "../../lib/queries";
import { store } from "../../store/state.svelte";
import type { Manga, Source, Chapter } from "../../lib/types"; import type { Manga, Source, Chapter } from "../../lib/types";
interface Props { interface Props {
@@ -37,6 +39,46 @@
let sources: Source[] = $state([]); let sources: Source[] = $state([]);
let loadingSources = $state(true); let loadingSources = $state(true);
let selectedSource: Source | null = $state(null); let selectedSource: Source | null = $state(null);
// Lang filter: "en" first, then alphabetical
let selectedLang: string = $state("all");
let langStripEl: HTMLDivElement | undefined = $state();
const availableLangs = $derived.by(() => {
const langs = Array.from(new Set<string>(sources.map(s => s.lang))).sort();
const en = langs.indexOf("en");
if (en > 0) { langs.splice(en, 1); langs.unshift("en"); }
return langs;
});
const hasMultipleLangs = $derived(availableLangs.length > 1);
function scrollLangStrip(dir: -1 | 1) {
if (!langStripEl) return;
const strip = langStripEl;
const chips = Array.from(strip.children) as HTMLElement[];
const scrollLeft = strip.scrollLeft;
const viewEnd = scrollLeft + strip.clientWidth;
if (dir === 1) {
// Find first chip that is cut off or fully outside the right edge, scroll it flush left
const next = chips.find(c => c.offsetLeft + c.offsetWidth > viewEnd + 2);
if (next) strip.scrollTo({ left: next.offsetLeft, behavior: "smooth" });
} else {
// Find last chip that is cut off or fully outside the left edge, scroll it flush right
const prev = [...chips].reverse().find(c => c.offsetLeft < scrollLeft - 2);
if (prev) strip.scrollTo({ left: prev.offsetLeft + prev.offsetWidth - strip.clientWidth, behavior: "smooth" });
}
}
const visibleSources = $derived.by(() => {
if (selectedLang !== "all") return sources.filter(s => s.lang === selectedLang);
const map = new Map<string, Source>();
for (const s of sources) {
const existing = map.get(s.name);
if (!existing) { map.set(s.name, s); continue; }
if (s.lang < existing.lang) map.set(s.name, s);
}
return Array.from(map.values());
});
let query = $state(untrack(() => manga.title)); let query = $state(untrack(() => manga.title));
let results: { manga: Manga; similarity: number }[] = $state([]); let results: { manga: Manga; similarity: number }[] = $state([]);
let searching = $state(false); let searching = $state(false);
@@ -52,7 +94,14 @@
$effect(() => { $effect(() => {
gql<{ sources: { nodes: Source[] } }>(GET_SOURCES) gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
.then((d) => { sources = d.sources.nodes.filter((s) => s.id !== "0" && s.id !== manga.source?.id); }) .then((d) => {
const filtered = d.sources.nodes.filter((s) => s.id !== "0" && s.id !== manga.source?.id);
sources = filtered;
// Pre-select preferred lang if available and there are multiple
const prefLang = store?.settings?.preferredExtensionLang ?? "";
const langs = new Set(filtered.map(s => s.lang));
if (prefLang && langs.has(prefLang) && langs.size > 1) selectedLang = prefLang;
})
.catch(console.error) .catch(console.error)
.finally(() => { loadingSources = false; }); .finally(() => { loadingSources = false; });
@@ -178,7 +227,6 @@
<!-- Step 1: Pick source --> <!-- Step 1: Pick source -->
{#if step === "source"} {#if step === "source"}
<div class="source-list">
{#if loadingSources} {#if loadingSources}
<div class="centered"> <div class="centered">
<CircleNotch size={16} weight="light" class="anim-spin" style="color:var(--text-faint)" /> <CircleNotch size={16} weight="light" class="anim-spin" style="color:var(--text-faint)" />
@@ -186,13 +234,27 @@
{:else if sources.length === 0} {:else if sources.length === 0}
<div class="centered"><span class="hint">No other sources installed.</span></div> <div class="centered"><span class="hint">No other sources installed.</span></div>
{:else} {:else}
{#each sources as src} {#if hasMultipleLangs}
<div class="src-lang-bar">
<button class="src-lang-nav" onclick={() => scrollLangStrip(-1)}></button>
<div class="src-lang-chips" bind:this={langStripEl}>
<button class="src-lang-chip" class:src-lang-chip-active={selectedLang === "all"} onclick={() => selectedLang = "all"}>All</button>
{#each availableLangs as lang}
<button class="src-lang-chip" class:src-lang-chip-active={selectedLang === lang} onclick={() => selectedLang = lang}>
{lang.toUpperCase()}
</button>
{/each}
</div>
<button class="src-lang-nav" onclick={() => scrollLangStrip(1)}></button>
</div>
{/if}
<div class="source-list">
{#each visibleSources as src}
<button <button
class="source-row" class="source-row"
class:source-row-active={selectedSource?.id === src.id} class:source-row-active={selectedSource?.id === src.id}
onclick={() => pickSource(src)}> onclick={() => pickSource(src)}>
<img src={thumbUrl(src.iconUrl)} alt={src.name} class="source-icon" <Thumbnail src={src.iconUrl} alt={src.name} class="source-icon" onerror={(e) => (e.target as HTMLImageElement).style.display = "none"} />
onerror={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
<div class="source-info"> <div class="source-info">
<span class="source-name">{src.displayName}</span> <span class="source-name">{src.displayName}</span>
<span class="source-meta">{src.lang.toUpperCase()}{src.isNsfw ? " · NSFW" : ""}</span> <span class="source-meta">{src.lang.toUpperCase()}{src.isNsfw ? " · NSFW" : ""}</span>
@@ -200,8 +262,8 @@
<ArrowRight size={13} weight="light" class="source-arrow" /> <ArrowRight size={13} weight="light" class="source-arrow" />
</button> </button>
{/each} {/each}
{/if}
</div> </div>
{/if}
<!-- Step 2: Search & pick match --> <!-- Step 2: Search & pick match -->
{:else if step === "search"} {:else if step === "search"}
@@ -210,8 +272,7 @@
<!-- Source context pill --> <!-- Source context pill -->
{#if selectedSource} {#if selectedSource}
<div class="search-context"> <div class="search-context">
<img src={thumbUrl(selectedSource.iconUrl)} alt="" class="search-context-icon" <Thumbnail src={selectedSource.iconUrl} alt="" class="search-context-icon" onerror={(e) => (e.target as HTMLImageElement).style.display = "none"} />
onerror={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
<span class="search-context-name">{selectedSource.displayName}</span> <span class="search-context-name">{selectedSource.displayName}</span>
<button class="search-context-change" onclick={() => { step = "source"; results = []; }}>Change</button> <button class="search-context-change" onclick={() => { step = "source"; results = []; }}>Change</button>
</div> </div>
@@ -254,7 +315,7 @@
onclick={() => selectMatch(m, similarity)} onclick={() => selectMatch(m, similarity)}
disabled={loadingMatchId !== null}> disabled={loadingMatchId !== null}>
<div class="result-cover-wrap"> <div class="result-cover-wrap">
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} class="result-cover" /> <Thumbnail src={m.thumbnailUrl} alt={m.title} class="result-cover" />
</div> </div>
<div class="result-info"> <div class="result-info">
<span class="result-title">{m.title}</span> <span class="result-title">{m.title}</span>
@@ -288,7 +349,7 @@
<div class="confirm-row"> <div class="confirm-row">
<div class="confirm-manga"> <div class="confirm-manga">
<div class="confirm-cover-wrap"> <div class="confirm-cover-wrap">
<img src={thumbUrl(manga.thumbnailUrl)} alt={manga.title} class="confirm-cover" /> <Thumbnail src={manga.thumbnailUrl} alt={manga.title} class="confirm-cover" />
</div> </div>
<p class="confirm-title">{manga.title}</p> <p class="confirm-title">{manga.title}</p>
<p class="confirm-source">{manga.source?.displayName ?? "Unknown"}</p> <p class="confirm-source">{manga.source?.displayName ?? "Unknown"}</p>
@@ -301,7 +362,7 @@
<div class="confirm-manga"> <div class="confirm-manga">
<div class="confirm-cover-wrap"> <div class="confirm-cover-wrap">
<img src={thumbUrl(selectedMatch.manga.thumbnailUrl)} alt={selectedMatch.manga.title} class="confirm-cover" /> <Thumbnail src={selectedMatch.manga.thumbnailUrl} alt={selectedMatch.manga.title} class="confirm-cover" />
</div> </div>
<p class="confirm-title">{selectedMatch.manga.title}</p> <p class="confirm-title">{selectedMatch.manga.title}</p>
<p class="confirm-source">{selectedSource?.displayName ?? "Unknown"}</p> <p class="confirm-source">{selectedSource?.displayName ?? "Unknown"}</p>
@@ -393,17 +454,29 @@
.source-row { display: flex; align-items: center; gap: var(--sp-3); padding: 9px var(--sp-3); border-radius: var(--radius-md); border: 1px solid transparent; background: none; text-align: left; width: 100%; cursor: pointer; transition: background var(--t-fast), border-color var(--t-fast); } .source-row { display: flex; align-items: center; gap: var(--sp-3); padding: 9px var(--sp-3); border-radius: var(--radius-md); border: 1px solid transparent; background: none; text-align: left; width: 100%; cursor: pointer; transition: background var(--t-fast), border-color var(--t-fast); }
.source-row:hover { background: var(--bg-raised); border-color: var(--border-dim); } .source-row:hover { background: var(--bg-raised); border-color: var(--border-dim); }
.source-row-active { background: var(--accent-muted); border-color: var(--accent-dim); } .source-row-active { background: var(--accent-muted); border-color: var(--accent-dim); }
.source-icon { width: 28px; height: 28px; border-radius: var(--radius-md); object-fit: cover; flex-shrink: 0; background: var(--bg-raised); } :global(.source-icon) { width: 28px; height: 28px; border-radius: var(--radius-md); object-fit: cover; flex-shrink: 0; background: var(--bg-raised); }
.source-info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; } .source-info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; }
.source-name { font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .source-name { font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.source-meta { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); } .source-meta { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
:global(.source-arrow) { color: var(--text-faint); opacity: 0; transition: opacity var(--t-base); } :global(.source-arrow) { color: var(--text-faint); opacity: 0; transition: opacity var(--t-base); }
.source-row:hover :global(.source-arrow) { opacity: 1; } .source-row:hover :global(.source-arrow) { opacity: 1; }
/* Lang filter bar */
.src-lang-bar { display: flex; align-items: center; gap: var(--sp-1); padding: var(--sp-2); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
.src-lang-nav { display: flex; align-items: center; justify-content: center; width: 22px; height: 22px; flex-shrink: 0; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); font-size: 15px; line-height: 1; cursor: pointer; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
.src-lang-nav:hover { color: var(--text-muted); border-color: var(--border-strong); background: var(--bg-raised); }
.src-lang-chips { display: flex; align-items: center; gap: var(--sp-1); flex: 1; min-width: 0; overflow-x: auto; scrollbar-width: none; scroll-behavior: smooth; }
.src-lang-chip:last-child { margin-right: var(--sp-1); }
.src-lang-chips::-webkit-scrollbar { display: none; }
.src-lang-chip { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 3px 8px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); cursor: pointer; white-space: nowrap; flex-shrink: 0; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
.src-lang-chip:hover { color: var(--text-muted); border-color: var(--border-strong); background: var(--bg-raised); }
.src-lang-chip-active { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
.src-lang-chip-active:hover { color: var(--accent-fg); border-color: var(--accent); }
/* Search step */ /* Search step */
.search-step { flex: 1; overflow: hidden; display: flex; flex-direction: column; gap: var(--sp-3); padding: var(--sp-3) var(--sp-4); } .search-step { flex: 1; overflow: hidden; display: flex; flex-direction: column; gap: var(--sp-3); padding: var(--sp-3) var(--sp-4); }
.search-context { display: flex; align-items: center; gap: var(--sp-2); padding: var(--sp-2) var(--sp-3); background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); flex-shrink: 0; } .search-context { display: flex; align-items: center; gap: var(--sp-2); padding: var(--sp-2) var(--sp-3); background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); flex-shrink: 0; }
.search-context-icon { width: 18px; height: 18px; border-radius: var(--radius-sm); object-fit: cover; flex-shrink: 0; } :global(.search-context-icon) { width: 18px; height: 18px; border-radius: var(--radius-sm); object-fit: cover; flex-shrink: 0; }
.search-context-name { flex: 1; font-size: var(--text-sm); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .search-context-name { flex: 1; font-size: var(--text-sm); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.search-context-change { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--accent-fg); background: none; border: none; cursor: pointer; padding: 0; flex-shrink: 0; transition: opacity var(--t-base); } .search-context-change { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--accent-fg); background: none; border: none; cursor: pointer; padding: 0; flex-shrink: 0; transition: opacity var(--t-base); }
.search-context-change:hover { opacity: 0.75; } .search-context-change:hover { opacity: 0.75; }
@@ -421,7 +494,7 @@
.result-row:hover:not(:disabled) { background: var(--bg-raised); } .result-row:hover:not(:disabled) { background: var(--bg-raised); }
.result-row:disabled { opacity: 0.5; cursor: default; } .result-row:disabled { opacity: 0.5; cursor: default; }
.result-cover-wrap { width: 36px; height: 54px; border-radius: var(--radius-sm); overflow: hidden; background: var(--bg-raised); border: 1px solid var(--border-dim); flex-shrink: 0; } .result-cover-wrap { width: 36px; height: 54px; border-radius: var(--radius-sm); overflow: hidden; background: var(--bg-raised); border: 1px solid var(--border-dim); flex-shrink: 0; }
.result-cover { width: 100%; height: 100%; object-fit: cover; } :global(.result-cover) { width: 100%; height: 100%; object-fit: cover; }
.result-info { flex: 1; display: flex; flex-direction: column; gap: 4px; overflow: hidden; min-width: 0; } .result-info { flex: 1; display: flex; flex-direction: column; gap: 4px; overflow: hidden; min-width: 0; }
.result-title { font-size: var(--text-sm); color: var(--text-secondary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .result-title { font-size: var(--text-sm); color: var(--text-secondary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.result-meta { display: flex; align-items: center; gap: var(--sp-2); } .result-meta { display: flex; align-items: center; gap: var(--sp-2); }
@@ -441,7 +514,7 @@
.confirm-row { display: flex; align-items: center; justify-content: center; gap: var(--sp-4); } .confirm-row { display: flex; align-items: center; justify-content: center; gap: var(--sp-4); }
.confirm-manga { display: flex; flex-direction: column; align-items: center; gap: var(--sp-2); flex: 1; max-width: 160px; } .confirm-manga { display: flex; flex-direction: column; align-items: center; gap: var(--sp-2); flex: 1; max-width: 160px; }
.confirm-cover-wrap { width: 100%; aspect-ratio: 2/3; border-radius: var(--radius-md); overflow: hidden; background: var(--bg-raised); border: 1px solid var(--border-dim); } .confirm-cover-wrap { width: 100%; aspect-ratio: 2/3; border-radius: var(--radius-md); overflow: hidden; background: var(--bg-raised); border: 1px solid var(--border-dim); }
.confirm-cover { width: 100%; height: 100%; object-fit: cover; } :global(.confirm-cover) { width: 100%; height: 100%; object-fit: cover; }
.confirm-title { font-size: var(--text-xs); color: var(--text-secondary); text-align: center; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; line-height: var(--leading-snug); } .confirm-title { font-size: var(--text-xs); color: var(--text-secondary); text-align: center; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; line-height: var(--leading-snug); }
.confirm-source { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); text-align: center; } .confirm-source { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); text-align: center; }
.confirm-divider { display: flex; align-items: center; justify-content: center; flex-shrink: 0; } .confirm-divider { display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
@@ -1,14 +1,20 @@
<script lang="ts"> <script lang="ts">
import { onMount, untrack } from "svelte"; import { onMount, untrack } from "svelte";
import { ArrowLeft, BookmarkSimple, Download, CheckCircle, Circle, ArrowSquareOut, CircleNotch, Play, SortAscending, SortDescending, CaretDown, ArrowsClockwise, List, SquaresFour, FolderSimplePlus, Trash, DownloadSimple, X, LinkSimpleHorizontalBreak, ChartLineUp } from "phosphor-svelte"; import { ArrowLeft, BookmarkSimple, Download, CheckCircle, Circle, ArrowSquareOut, CircleNotch, Play, SortAscending, SortDescending, CaretDown, ArrowsClockwise, List, SquaresFour, FolderSimplePlus, Trash, DownloadSimple, X, LinkSimpleHorizontalBreak, ChartLineUp, MagnifyingGlass, Gear, Eye, MapPin, Funnel, Check } from "phosphor-svelte";
import { gql, thumbUrl } from "../../lib/client"; import { gql } from "../../lib/client";
import { GET_MANGA, GET_CHAPTERS, FETCH_CHAPTERS, ENQUEUE_DOWNLOAD, UPDATE_MANGA, MARK_CHAPTER_READ, MARK_CHAPTERS_READ, DELETE_DOWNLOADED_CHAPTERS, ENQUEUE_CHAPTERS_DOWNLOAD, GET_ALL_MANGA } from "../../lib/queries"; import Thumbnail from "../shared/Thumbnail.svelte";
import { GET_MANGA, GET_CHAPTERS, FETCH_CHAPTERS, ENQUEUE_DOWNLOAD, UPDATE_MANGA, MARK_CHAPTER_READ, MARK_CHAPTERS_READ, DELETE_DOWNLOADED_CHAPTERS, ENQUEUE_CHAPTERS_DOWNLOAD, GET_ALL_MANGA, GET_CATEGORIES, CREATE_CATEGORY, UPDATE_MANGA_CATEGORIES } from "../../lib/queries";
import { cache, CACHE_KEYS, recordSourceAccess } from "../../lib/cache"; import { cache, CACHE_KEYS, recordSourceAccess } from "../../lib/cache";
import { store, addToast, updateSettings, addFolder, assignMangaToFolder, removeMangaFromFolder, getMangaFolders, openReader, checkAndMarkCompleted, setActiveManga, setGenreFilter, setNavPage, linkManga, unlinkManga } from "../../store/state.svelte"; import { dedupeMangaById, dedupeMangaByTitle } from "../../lib/util";
import type { Manga, Chapter } from "../../lib/types"; import { store, addToast, updateSettings, openReader, setActiveManga, setGenreFilter, setNavPage, linkManga, unlinkManga, setPreviewManga, checkAndMarkCompleted as storeCheckAndMarkCompleted, clearMarkersForManga } from "../../store/state.svelte";
import type { MangaPrefs } from "../../store/state.svelte";
import { DEFAULT_MANGA_PREFS } from "../../store/state.svelte";
import type { Manga, Chapter, Category } from "../../lib/types";
import ContextMenu, { type MenuEntry } from "../shared/ContextMenu.svelte"; import ContextMenu, { type MenuEntry } from "../shared/ContextMenu.svelte";
import MigrateModal from "./MigrateModal.svelte"; import MigrateModal from "./MigrateModal.svelte";
import TrackingPanel from "../shared/TrackingPanel.svelte"; import TrackingPanel from "./TrackingPanel.svelte";
import AutomationPanel from "./AutomationPanel.svelte";
import MarkersPanel from "./MarkersPanel.svelte";
const CHAPTERS_PER_PAGE = 25; const CHAPTERS_PER_PAGE = 25;
const MANGA_TTL_MS = 5 * 60 * 1000; const MANGA_TTL_MS = 5 * 60 * 1000;
@@ -23,7 +29,7 @@
let loadingChapters: boolean = $state(true); let loadingChapters: boolean = $state(true);
let enqueueing: Set<number> = $state(new Set()); let enqueueing: Set<number> = $state(new Set());
let dlOpen: boolean = $state(false); let dlOpen: boolean = $state(false);
let detailsOpen: boolean = $state(false); let manageOpen: boolean = $state(false);
let togglingLibrary: boolean = $state(false); let togglingLibrary: boolean = $state(false);
let chapterPage: number = $state(1); let chapterPage: number = $state(1);
let ctx: { x: number; y: number; chapter: Chapter; idx: number } | null = $state(null); let ctx: { x: number; y: number; chapter: Chapter; idx: number } | null = $state(null);
@@ -36,25 +42,169 @@
let folderPickerOpen: boolean = $state(false); let folderPickerOpen: boolean = $state(false);
let folderCreating: boolean = $state(false); let folderCreating: boolean = $state(false);
let folderNewName: string = $state(""); let folderNewName: string = $state("");
let mangaCategories: Category[] = $state([]);
let allCategories: Category[] = $state([]);
let catsLoading: boolean = $state(false);
let rangeFrom: string = $state(""); let rangeFrom: string = $state("");
let rangeTo: string = $state(""); let rangeTo: string = $state("");
let showRange: boolean = $state(false); let showRange: boolean = $state(false);
let migrateOpen: boolean = $state(false); let migrateOpen: boolean = $state(false);
let dlDropRef: HTMLDivElement | undefined = $state(); let autoOpen: boolean = $state(false);
let folderPickerRef: HTMLDivElement | undefined = $state(); let trackingOpen: boolean = $state(false);
let markersOpen: boolean = $state(false);
// Series link state
let linkPickerOpen: boolean = $state(false); let linkPickerOpen: boolean = $state(false);
let linkSearch: string = $state(""); let linkSearch: string = $state("");
let allMangaForLink: Manga[] = $state([]); let allMangaForLink: Manga[] = $state([]);
let loadingLinkList: boolean = $state(false); let loadingLinkList: boolean = $state(false);
let selectedIds: Set<number> = $state(new Set());
// Tracking modal let sortMenuOpen: boolean = $state(false);
let trackingOpen: boolean = $state(false); let scanFilterOpen: boolean = $state(false);
let dlDropRef: HTMLDivElement | undefined = $state();
let folderPickerRef: HTMLDivElement | undefined = $state();
let mangaAbort: AbortController | null = null; let mangaAbort: AbortController | null = null;
let chapterAbort: AbortController | null = null; let chapterAbort: AbortController | null = null;
let loadingFor: number | null = null; let loadingFor: number | null = null;
let _prevChapterIds: Set<number> = new Set();
function focusOnMount(node: HTMLElement) { node.focus(); }
const mangaPrefs = $derived.by((): Partial<MangaPrefs> => {
if (!store.activeManga) return {};
return store.settings.mangaPrefs?.[store.activeManga.id] ?? {};
});
function getPref<K extends keyof MangaPrefs>(key: K): MangaPrefs[K] {
return (mangaPrefs[key] ?? DEFAULT_MANGA_PREFS[key]) as MangaPrefs[K];
}
function setPref<K extends keyof MangaPrefs>(key: K, value: MangaPrefs[K]) {
const id = store.activeManga?.id;
if (!id) return;
updateSettings({
mangaPrefs: {
...store.settings.mangaPrefs,
[id]: { ...(store.settings.mangaPrefs?.[id] ?? {}), [key]: value },
},
});
}
const hasSelection = $derived(selectedIds.size > 0);
const sortDir = $derived(store.settings.chapterSortDir);
const sortMode = $derived(store.settings.chapterSortMode ?? "source");
const availableScanlators = $derived(
[...new Set(chapters.map(c => c.scanlator).filter((s): s is string => !!s?.trim()))]
.sort((a, b) => a.localeCompare(b))
);
const scanlatorFilter = $derived((getPref("scanlatorFilter") ?? []) as string[]);
const sortedChapters = $derived.by(() => {
let base = [...chapters];
if (sortMode === "chapterNumber") base.sort((a, b) => a.chapterNumber - b.chapterNumber);
else if (sortMode === "uploadDate") base.sort((a, b) => Number(a.uploadDate ?? 0) - Number(b.uploadDate ?? 0));
else base.sort((a, b) => a.sourceOrder - b.sourceOrder);
const preferred = getPref("preferredScanlator");
if (preferred) {
const pref: Chapter[] = [], rest: Chapter[] = [];
for (const c of base) (c.scanlator === preferred ? pref : rest).push(c);
base = [...pref, ...rest];
}
if (scanlatorFilter.length > 0) {
const seen = new Map<number, Chapter>();
for (const ch of base) {
const existing = seen.get(ch.chapterNumber);
if (!existing) {
seen.set(ch.chapterNumber, ch);
} else {
const np = scanlatorFilter.indexOf(ch.scanlator ?? "");
const op = scanlatorFilter.indexOf(existing.scanlator ?? "");
if (np !== -1 && (op === -1 || np < op)) seen.set(ch.chapterNumber, ch);
}
}
base = [...seen.values()];
if (sortMode === "chapterNumber") base.sort((a, b) => a.chapterNumber - b.chapterNumber);
else if (sortMode === "uploadDate") base.sort((a, b) => Number(a.uploadDate ?? 0) - Number(b.uploadDate ?? 0));
else base.sort((a, b) => a.sourceOrder - b.sourceOrder);
}
return sortDir === "desc" ? base.reverse() : base;
});
const chaptersAsc = $derived([...chapters].sort((a, b) => a.sourceOrder - b.sourceOrder));
const totalPages = $derived(Math.ceil(sortedChapters.length / CHAPTERS_PER_PAGE));
const pageChapters = $derived(sortedChapters.slice((chapterPage - 1) * CHAPTERS_PER_PAGE, chapterPage * CHAPTERS_PER_PAGE));
const readCount = $derived(chapters.filter(c => c.isRead).length);
const totalCount = $derived(chapters.length);
const progressPct = $derived(totalCount > 0 ? (readCount / totalCount) * 100 : 0);
const downloadedCount = $derived(chapters.filter(c => c.isDownloaded).length);
const statusLabel = $derived(manga?.status ? manga.status.charAt(0) + manga.status.slice(1).toLowerCase() : null);
const assignedFolders = $derived(mangaCategories.filter(c => c.id !== 0));
const hasFolders = $derived(assignedFolders.length > 0);
const continueChapter = $derived((() => {
if (!sortedChapters.length) return null;
const asc = [...sortedChapters].sort((a, b) => a.sourceOrder - b.sourceOrder);
const anyRead = asc.some(c => c.isRead);
const inProgress = asc.find(c => !c.isRead && (c.lastPageRead ?? 0) > 0);
if (inProgress) return { chapter: inProgress, type: "continue" as const };
const firstUnread = asc.find(c => !c.isRead);
if (firstUnread) return { chapter: firstUnread, type: (anyRead ? "continue" : "start") as const };
return { chapter: asc[0], type: "reread" as const };
})());
const jumpChapter = $derived.by(() => {
const q = jumpInput.trim().toLowerCase();
if (!q) return null;
const num = parseFloat(q);
if (!isNaN(num)) return sortedChapters.find(c => c.chapterNumber === num) ?? null;
return sortedChapters.find(c => c.name.toLowerCase().includes(q)) ?? null;
});
const hasAnyAutomation = $derived(
getPref("autoDownload") ||
getPref("downloadAhead") > 0 ||
getPref("maxKeepChapters") > 0 ||
getPref("deleteOnRead") ||
getPref("pauseUpdates") ||
getPref("refreshInterval") !== "global" ||
!!getPref("preferredScanlator")
);
const linkedIds = $derived(
store.activeManga ? (store.settings.mangaLinks?.[store.activeManga.id] ?? []) : []
);
const linkPickerResults = $derived.by(() => {
const id = store.activeManga?.id;
const others = allMangaForLink.filter(m => m.id !== id);
const q = linkSearch.trim().toLowerCase();
const filtered = q ? others.filter(m => m.title.toLowerCase().includes(q)) : others;
const linked = filtered.filter(m => linkedIds.includes(m.id));
const rest = filtered.filter(m => !linkedIds.includes(m.id)).slice(0, 30);
return [...linked, ...rest];
});
function doJump() {
if (!jumpChapter) return;
const pageIdx = sortedChapters.indexOf(jumpChapter);
if (pageIdx >= 0) chapterPage = Math.floor(pageIdx / CHAPTERS_PER_PAGE) + 1;
jumpOpen = false;
jumpInput = "";
}
function toggleSelect(id: number, e: MouseEvent | KeyboardEvent) {
e.stopPropagation();
const next = new Set(selectedIds);
if (next.has(id)) next.delete(id); else next.add(id);
selectedIds = next;
}
function clearSelection() { selectedIds = new Set(); }
function formatDate(ts: string | null | undefined): string { function formatDate(ts: string | null | undefined): string {
if (!ts) return ""; if (!ts) return "";
@@ -64,46 +214,38 @@
} }
function applyChapters(nodes: Chapter[]) { function applyChapters(nodes: Chapter[]) {
if (getPref("autoDownload") && _prevChapterIds.size > 0) {
const newChapters = nodes.filter(c => !_prevChapterIds.has(c.id) && !c.isDownloaded);
if (newChapters.length) enqueueMultiple(newChapters.map(c => c.id));
}
_prevChapterIds = new Set(nodes.map(c => c.id));
chapters = nodes; chapters = nodes;
if (store.activeManga && nodes.length > 0) checkAndMarkCompleted(store.activeManga.id, nodes); if (store.activeManga && nodes.length > 0) checkAndMarkCompleted(store.activeManga.id, nodes);
} }
const sortDir = $derived(store.settings.chapterSortDir); function loadCategories(mangaId: number) {
const sortMode = $derived(store.settings.chapterSortMode ?? "source"); catsLoading = true;
let sortMenuOpen = $state(false); gql<{ categories: { nodes: Category[] } }>(GET_CATEGORIES)
.then(d => {
const sortedChapters = $derived.by(() => { allCategories = d.categories.nodes.filter(c => c.id !== 0);
const base = [...chapters]; mangaCategories = allCategories.filter(c => c.mangas?.nodes.some(m => m.id === mangaId));
if (sortMode === "chapterNumber") { })
base.sort((a, b) => a.chapterNumber - b.chapterNumber); .catch(console.error)
} else if (sortMode === "uploadDate") { .finally(() => { catsLoading = false; });
base.sort((a, b) => Number(a.uploadDate ?? 0) - Number(b.uploadDate ?? 0));
} else {
base.sort((a, b) => a.sourceOrder - b.sourceOrder);
} }
return sortDir === "desc" ? base.reverse() : base;
});
const totalPages = $derived(Math.ceil(sortedChapters.length / CHAPTERS_PER_PAGE));
const pageChapters = $derived(sortedChapters.slice((chapterPage - 1) * CHAPTERS_PER_PAGE, chapterPage * CHAPTERS_PER_PAGE));
const readCount = $derived(chapters.filter(c => c.isRead).length);
const totalCount = $derived(chapters.length);
const progressPct = $derived(totalCount > 0 ? (readCount / totalCount) * 100 : 0);
const downloadedCount = $derived(chapters.filter(c => c.isDownloaded).length);
const continueChapter = $derived((() => { async function checkAndMarkCompleted(mangaId: number, chaps: Chapter[]) {
if (!chapters.length) return null; await storeCheckAndMarkCompleted(mangaId, chaps, allCategories, gql, UPDATE_MANGA_CATEGORIES, UPDATE_MANGA);
const asc = [...chapters].sort((a, b) => a.sourceOrder - b.sourceOrder); if (chaps.length) {
const anyRead = asc.some(c => c.isRead); const allRead = chaps.every(c => c.isRead);
const inProgress = asc.find(c => !c.isRead && (c.lastPageRead ?? 0) > 0); const completed = allCategories.find(c => c.name === "Completed");
if (inProgress) return { chapter: inProgress, type: "continue" as const }; if (completed) {
const firstUnread = asc.find(c => !c.isRead); const inCompleted = mangaCategories.some(c => c.id === completed.id);
if (firstUnread) return { chapter: firstUnread, type: (anyRead ? "continue" : "start") as const }; if (allRead && !inCompleted) mangaCategories = [...mangaCategories, completed];
return { chapter: asc[0], type: "reread" as const }; else if (!allRead && inCompleted) mangaCategories = mangaCategories.filter(c => c.id !== completed.id);
})()); }
}
const statusLabel = $derived(manga?.status ? manga.status.charAt(0) + manga.status.slice(1).toLowerCase() : null); }
const assignedFolders = $derived(store.activeManga ? getMangaFolders(store.activeManga.id) : []);
const hasFolders = $derived(assignedFolders.length > 0);
function loadManga(id: number) { function loadManga(id: number) {
mangaAbort?.abort(); mangaAbort?.abort();
@@ -164,7 +306,7 @@
$effect(() => { $effect(() => {
const m = store.activeManga; const m = store.activeManga;
if (m) untrack(() => { loadManga(m.id); loadChapters(m.id); }); if (m) untrack(() => { loadManga(m.id); loadChapters(m.id); loadCategories(m.id); });
}); });
let prevChapterId: number | null = null; let prevChapterId: number | null = null;
@@ -177,6 +319,27 @@
} }
}); });
$effect(() => {
if (dlOpen) { setTimeout(() => document.addEventListener("mousedown", handleDlOutside, true), 0); }
else document.removeEventListener("mousedown", handleDlOutside, true);
});
$effect(() => {
if (folderPickerOpen) { setTimeout(() => document.addEventListener("mousedown", handleFolderOutside, true), 0); }
else document.removeEventListener("mousedown", handleFolderOutside, true);
});
function handleDlOutside(e: MouseEvent) { if (dlDropRef && !dlDropRef.contains(e.target as Node)) dlOpen = false; }
function handleFolderOutside(e: MouseEvent) { if (folderPickerRef && !folderPickerRef.contains(e.target as Node)) { folderPickerOpen = false; folderCreating = false; folderNewName = ""; } }
$effect(() => {
if (!scanFilterOpen) return;
function onOutside(e: MouseEvent) {
if (!(e.target as HTMLElement).closest(".scan-filter-wrap")) scanFilterOpen = false;
}
setTimeout(() => document.addEventListener("mousedown", onOutside, true), 0);
return () => document.removeEventListener("mousedown", onOutside, true);
});
async function toggleLibrary() { async function toggleLibrary() {
if (!manga) return; if (!manga) return;
togglingLibrary = true; togglingLibrary = true;
@@ -214,6 +377,24 @@
await gql(MARK_CHAPTER_READ, { id: chapterId, isRead }).catch(console.error); await gql(MARK_CHAPTER_READ, { id: chapterId, isRead }).catch(console.error);
chapters = chapters.map(c => c.id === chapterId ? { ...c, isRead } : c); chapters = chapters.map(c => c.id === chapterId ? { ...c, isRead } : c);
if (store.activeManga) { chapterStore.set(store.activeManga.id, { data: chapters, fetchedAt: Date.now() }); checkAndMarkCompleted(store.activeManga.id, chapters); } if (store.activeManga) { chapterStore.set(store.activeManga.id, { data: chapters, fetchedAt: Date.now() }); checkAndMarkCompleted(store.activeManga.id, chapters); }
if (isRead) {
if (getPref("deleteOnRead")) {
const ch = chapters.find(c => c.id === chapterId);
if (ch?.isDownloaded) {
const delayMs = getPref("deleteDelayHours") * 60 * 60 * 1000;
if (delayMs === 0) deleteDownloaded(chapterId);
else setTimeout(() => deleteDownloaded(chapterId), delayMs);
}
}
const ahead = getPref("downloadAhead");
if (ahead > 0) {
const idx = sortedChapters.findIndex(c => c.id === chapterId);
if (idx >= 0) {
const toQueue = sortedChapters.slice(idx + 1, idx + 1 + ahead).filter(c => !c.isDownloaded).map(c => c.id);
if (toQueue.length) enqueueMultiple(toQueue);
}
}
}
} }
async function markBulk(ids: number[], isRead: boolean) { async function markBulk(ids: number[], isRead: boolean) {
@@ -222,6 +403,40 @@
const idSet = new Set(ids); const idSet = new Set(ids);
chapters = chapters.map(c => idSet.has(c.id) ? { ...c, isRead } : c); chapters = chapters.map(c => idSet.has(c.id) ? { ...c, isRead } : c);
if (store.activeManga) { chapterStore.set(store.activeManga.id, { data: chapters, fetchedAt: Date.now() }); checkAndMarkCompleted(store.activeManga.id, chapters); } if (store.activeManga) { chapterStore.set(store.activeManga.id, { data: chapters, fetchedAt: Date.now() }); checkAndMarkCompleted(store.activeManga.id, chapters); }
if (isRead && getPref("deleteOnRead")) {
const toDelete = ids.filter(id => chapters.find(c => c.id === id)?.isDownloaded);
if (toDelete.length) {
const delayMs = getPref("deleteDelayHours") * 60 * 60 * 1000;
const doDelete = async () => {
await gql(DELETE_DOWNLOADED_CHAPTERS, { ids: toDelete }).catch(console.error);
chapters = chapters.map(c => toDelete.includes(c.id) ? { ...c, isDownloaded: false } : c);
if (store.activeManga) chapterStore.set(store.activeManga.id, { data: chapters, fetchedAt: Date.now() });
};
if (delayMs === 0) doDelete();
else setTimeout(doDelete, delayMs);
}
}
}
async function deleteSelected() {
const ids = [...selectedIds].filter(id => chapters.find(c => c.id === id)?.isDownloaded);
if (ids.length) {
await gql(DELETE_DOWNLOADED_CHAPTERS, { ids }).catch(console.error);
chapters = chapters.map(c => ids.includes(c.id) ? { ...c, isDownloaded: false } : c);
if (store.activeManga) chapterStore.set(store.activeManga.id, { data: chapters, fetchedAt: Date.now() });
}
clearSelection();
}
async function downloadSelected() {
const ids = [...selectedIds].filter(id => !chapters.find(c => c.id === id)?.isDownloaded);
await enqueueMultiple(ids);
clearSelection();
}
async function markSelectedRead(isRead: boolean) {
await markBulk([...selectedIds], isRead);
clearSelection();
} }
const markAboveRead = (i: number) => markBulk(sortedChapters.slice(0, i + 1).filter(c => !c.isRead).map(c => c.id), true); const markAboveRead = (i: number) => markBulk(sortedChapters.slice(0, i + 1).filter(c => !c.isRead).map(c => c.id), true);
@@ -274,18 +489,6 @@
]; ];
} }
function handleDlOutside(e: MouseEvent) { if (dlDropRef && !dlDropRef.contains(e.target as Node)) dlOpen = false; }
function handleFolderOutside(e: MouseEvent) { if (folderPickerRef && !folderPickerRef.contains(e.target as Node)) { folderPickerOpen = false; folderCreating = false; folderNewName = ""; } }
$effect(() => {
if (dlOpen) { setTimeout(() => document.addEventListener("mousedown", handleDlOutside, true), 0); }
else document.removeEventListener("mousedown", handleDlOutside, true);
});
$effect(() => {
if (folderPickerOpen) { setTimeout(() => document.addEventListener("mousedown", handleFolderOutside, true), 0); }
else document.removeEventListener("mousedown", handleFolderOutside, true);
});
function enqueueNext(n: number) { function enqueueNext(n: number) {
if (!continueChapter) return; if (!continueChapter) return;
const idx = sortedChapters.indexOf(continueChapter.chapter); const idx = sortedChapters.indexOf(continueChapter.chapter);
@@ -300,31 +503,43 @@
enqueueMultiple(sortedChapters.filter(c => c.chapterNumber >= lo && c.chapterNumber <= hi && !c.isDownloaded).map(c => c.id)); enqueueMultiple(sortedChapters.filter(c => c.chapterNumber >= lo && c.chapterNumber <= hi && !c.isDownloaded).map(c => c.id));
} }
function createFolder() { async function createCategory() {
const name = folderNewName.trim(); const name = folderNewName.trim();
if (!name || !store.activeManga) return; if (!name || !store.activeManga) return;
const id = addFolder(name); try {
assignMangaToFolder(id, store.activeManga.id); const res = await gql<{ createCategory: { category: Category } }>(CREATE_CATEGORY, { name });
const cat = res.createCategory.category;
await gql(UPDATE_MANGA_CATEGORIES, { mangaId: store.activeManga.id, addTo: [cat.id], removeFrom: [] });
allCategories = [...allCategories, cat];
mangaCategories = [...mangaCategories, cat];
} catch (e) { console.error(e); }
folderNewName = ""; folderCreating = false; folderNewName = ""; folderCreating = false;
} }
onMount(() => () => { mangaAbort?.abort(); chapterAbort?.abort(); }); async function toggleCategory(cat: Category) {
if (!store.activeManga) return;
// ── Series link ──────────────────────────────────────────────────────────── const inCat = mangaCategories.some(c => c.id === cat.id);
try {
const linkedIds = $derived( await gql(UPDATE_MANGA_CATEGORIES, {
store.activeManga ? (store.settings.mangaLinks?.[store.activeManga.id] ?? []) : [] mangaId: store.activeManga.id,
); addTo: inCat ? [] : [cat.id],
removeFrom: inCat ? [cat.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];
}); });
mangaCategories = inCat ? mangaCategories.filter(c => c.id !== cat.id) : [...mangaCategories, cat];
} catch (e) { console.error(e); }
}
function openReaderWithAhead(ch: Chapter, list: Chapter[]) {
const ahead = getPref("downloadAhead");
if (ahead > 0) {
const idx = list.indexOf(ch);
if (idx >= 0) {
const toQueue = list.slice(idx + 1, idx + 1 + ahead).filter(c => !c.isDownloaded).map(c => c.id);
if (toQueue.length) enqueueMultiple(toQueue);
}
}
openReader(ch, list);
}
async function openLinkPicker() { async function openLinkPicker() {
linkPickerOpen = true; linkSearch = ""; linkPickerOpen = true; linkSearch = "";
@@ -343,23 +558,22 @@
if (linkedIds.includes(other.id)) unlinkManga(store.activeManga.id, other.id); if (linkedIds.includes(other.id)) unlinkManga(store.activeManga.id, other.id);
else linkManga(store.activeManga.id, other.id); else linkManga(store.activeManga.id, other.id);
} }
onMount(() => () => { mangaAbort?.abort(); chapterAbort?.abort(); });
</script> </script>
{#if store.activeManga} {#if store.activeManga}
<div class="root" role="presentation" oncontextmenu={(e) => e.preventDefault()}> <div class="root" role="presentation" oncontextmenu={(e) => e.preventDefault()}>
<!-- ── Sidebar ────────────────────────────────────────────────────────── -->
<div class="sidebar"> <div class="sidebar">
<button class="back" onclick={() => setActiveManga(null)}> <button class="back" onclick={() => setActiveManga(null)}>
<ArrowLeft size={13} weight="light" /> Back <ArrowLeft size={13} weight="light" /> Back
</button> </button>
<!-- Zone 1: Cover -->
<div class="cover-wrap"> <div class="cover-wrap">
<img src={thumbUrl(store.activeManga.thumbnailUrl)} alt={store.activeManga.title} class="cover" /> <Thumbnail src={store.activeManga.thumbnailUrl} alt={store.activeManga.title} class="cover" />
</div> </div>
<!-- Zone 2: Meta -->
{#if loadingManga} {#if loadingManga}
<div class="meta-skeleton"> <div class="meta-skeleton">
<div class="skeleton sk-line" style="width:90%;height:14px"></div> <div class="skeleton sk-line" style="width:90%;height:14px"></div>
@@ -392,10 +606,9 @@
</div> </div>
{/if} {/if}
<!-- Zone 3: Primary CTA + library action -->
<div class="cta-section"> <div class="cta-section">
{#if continueChapter} {#if continueChapter}
<button class="read-btn" onclick={() => openReader(continueChapter!.chapter, sortedChapters)}> <button class="read-btn" onclick={() => openReaderWithAhead(continueChapter!.chapter, sortedChapters)}>
<Play size={12} weight="fill" /> <Play size={12} weight="fill" />
{continueChapter.type === "continue" {continueChapter.type === "continue"
? `Continue · Ch.${continueChapter.chapter.chapterNumber}${(continueChapter.chapter.lastPageRead ?? 0) > 0 ? ` p.${continueChapter.chapter.lastPageRead}` : ""}` ? `Continue · Ch.${continueChapter.chapter.chapterNumber}${(continueChapter.chapter.lastPageRead ?? 0) > 0 ? ` p.${continueChapter.chapter.lastPageRead}` : ""}`
@@ -415,7 +628,6 @@
</div> </div>
</div> </div>
<!-- Zone 4: Progress -->
{#if totalCount > 0} {#if totalCount > 0}
<div class="progress-section"> <div class="progress-section">
<div class="progress-header"> <div class="progress-header">
@@ -426,38 +638,37 @@
</div> </div>
{/if} {/if}
<!-- Zone 5: Details accordion (source info + all secondary actions) --> {#if !loadingManga && manga}
{#if !loadingManga && manga?.source}
<div class="details-section"> <div class="details-section">
<button class="details-toggle" onclick={() => detailsOpen = !detailsOpen}> <button class="details-toggle" onclick={() => manageOpen = !manageOpen}>
<span>Details</span> <span>Manage</span>
<CaretDown size={11} weight="light" style="transform:{detailsOpen ? 'rotate(180deg)' : 'rotate(0)'};transition:transform 0.15s ease" /> <CaretDown size={11} weight="light" style="transform:{manageOpen ? 'rotate(180deg)' : 'rotate(0)'};transition:transform 0.15s ease" />
</button> </button>
{#if detailsOpen} {#if manageOpen}
<div class="details-body"> <div class="details-body">
<div class="detail-row"><span class="detail-key">Source</span><span class="detail-val">{manga.source.displayName}</span></div>
{#if manga.status}<div class="detail-row"><span class="detail-key">Status</span><span class="detail-val">{statusLabel}</span></div>{/if}
{#if manga.author}<div class="detail-row"><span class="detail-key">Author</span><span class="detail-val">{manga.author}</span></div>{/if}
{#if manga.artist && manga.artist !== manga.author}<div class="detail-row"><span class="detail-key">Artist</span><span class="detail-val">{manga.artist}</span></div>{/if}
<div class="detail-actions"> <div class="detail-actions">
<button class="detail-action-btn" onclick={() => setPreviewManga(manga)}>
<Eye size={12} weight="light" /> Preview
</button>
<button class="detail-action-btn" onclick={() => migrateOpen = true}> <button class="detail-action-btn" onclick={() => migrateOpen = true}>
<ArrowsClockwise size={12} weight="light" /> Switch Source <ArrowsClockwise size={12} weight="light" /> Switch Source
</button> </button>
<button <button class="detail-action-btn" class:detail-action-active={linkedIds.length > 0} onclick={openLinkPicker}>
class="detail-action-btn"
class:detail-action-active={linkedIds.length > 0}
onclick={openLinkPicker}
>
<LinkSimpleHorizontalBreak size={12} weight={linkedIds.length > 0 ? "fill" : "light"} /> <LinkSimpleHorizontalBreak size={12} weight={linkedIds.length > 0 ? "fill" : "light"} />
Series Link{linkedIds.length > 0 ? ` (${linkedIds.length})` : ""} Series Link{linkedIds.length > 0 ? ` (${linkedIds.length})` : ""}
</button> </button>
<button <button class="detail-action-btn" onclick={() => trackingOpen = true}>
class="detail-action-btn"
onclick={() => trackingOpen = true}
>
<ChartLineUp size={12} weight="light" /> Tracking <ChartLineUp size={12} weight="light" /> Tracking
</button> </button>
<button class="detail-action-btn" class:detail-action-active={markersOpen} onclick={() => markersOpen = !markersOpen}>
<MapPin size={12} weight={markersOpen ? "fill" : "light"} />
Markers{store.activeManga && store.getMarkersForManga(store.activeManga.id).length > 0 ? ` (${store.getMarkersForManga(store.activeManga.id).length})` : ""}
</button>
{#if manga?.inLibrary}
<button class="detail-action-btn" class:detail-action-active={hasAnyAutomation} onclick={() => autoOpen = true}>
<Gear size={12} weight={hasAnyAutomation ? "fill" : "light"} /> Automation
</button>
{/if}
{#if downloadedCount > 0} {#if downloadedCount > 0}
<button class="detail-action-btn detail-action-danger" onclick={deleteAllDownloads} disabled={deletingAll}> <button class="detail-action-btn detail-action-danger" onclick={deleteAllDownloads} disabled={deletingAll}>
<Trash size={12} weight="light" /> {deletingAll ? "Deleting…" : `Delete Downloads (${downloadedCount})`} <Trash size={12} weight="light" /> {deletingAll ? "Deleting…" : `Delete Downloads (${downloadedCount})`}
@@ -470,10 +681,17 @@
{/if} {/if}
</div> </div>
<!-- ── Chapter list ───────────────────────────────────────────────────── -->
<div class="list-wrap"> <div class="list-wrap">
<div class="list-header"> <div class="list-header">
<div class="list-header-left"> <div class="list-header-left">
{#if hasSelection}
<span class="sel-count">{selectedIds.size} selected</span>
<button class="sel-action-btn" onclick={downloadSelected} title="Download selected"><Download size={13} weight="light" /></button>
<button class="sel-action-btn sel-action-danger" onclick={deleteSelected} title="Delete selected downloads"><Trash size={13} weight="light" /></button>
<button class="sel-action-btn" onclick={() => markSelectedRead(true)} title="Mark selected as read"><CheckCircle size={13} weight="light" /></button>
<button class="sel-action-btn" onclick={() => markSelectedRead(false)} title="Mark selected as unread"><Circle size={13} weight="light" /></button>
<button class="sel-action-btn" onclick={clearSelection} title="Clear selection"><X size={13} weight="light" /></button>
{:else}
<div class="sort-wrap"> <div class="sort-wrap">
<button class="sort-btn" onclick={() => sortMenuOpen = !sortMenuOpen}> <button class="sort-btn" onclick={() => sortMenuOpen = !sortMenuOpen}>
{#if sortDir === "desc"}<SortDescending size={14} weight="light" />{:else}<SortAscending size={14} weight="light" />{/if} {#if sortDir === "desc"}<SortDescending size={14} weight="light" />{:else}<SortAscending size={14} weight="light" />{/if}
@@ -495,42 +713,92 @@
</div> </div>
{/if} {/if}
</div> </div>
<!-- View toggle: icon reflects current state -->
<button class="icon-btn" class:active={viewMode === "grid"} onclick={() => viewMode = viewMode === "list" ? "grid" : "list"} title={viewMode === "list" ? "Grid view" : "List view"}> <button class="icon-btn" class:active={viewMode === "grid"} onclick={() => viewMode = viewMode === "list" ? "grid" : "list"} title={viewMode === "list" ? "Grid view" : "List view"}>
{#if viewMode === "list"}<SquaresFour size={14} weight="light" />{:else}<List size={14} weight="light" />{/if} {#if viewMode === "list"}<SquaresFour size={14} weight="light" />{:else}<List size={14} weight="light" />{/if}
</button> </button>
{/if}
</div> </div>
<div class="list-header-right"> <div class="list-header-right">
<div class="jump-wrap">
<button class="icon-btn" class:active={jumpOpen} onclick={() => { jumpOpen = !jumpOpen; jumpInput = ""; }} title="Jump to chapter">
<MagnifyingGlass size={14} weight="light" />
</button>
{#if jumpOpen}
<div class="jump-popover">
<input class="jump-input" placeholder="Chapter # or name…" bind:value={jumpInput} use:focusOnMount
onkeydown={(e) => { if (e.key === "Enter") doJump(); if (e.key === "Escape") { jumpOpen = false; jumpInput = ""; } }} />
{#if jumpChapter}
<button class="jump-go" onclick={doJump}>Go · {jumpChapter.name}</button>
{:else if jumpInput.trim()}
<p class="jump-none">No match</p>
{/if}
</div>
{/if}
</div>
{#if availableScanlators.length > 1}
<div class="scan-filter-wrap">
<button class="icon-btn" class:active={scanlatorFilter.length > 0} onclick={() => scanFilterOpen = !scanFilterOpen} title="Filter by scanlator">
<Funnel size={14} weight={scanlatorFilter.length > 0 ? "fill" : "light"} />
</button>
{#if scanFilterOpen}
<div class="scan-filter-panel" role="menu">
<div class="scan-filter-header">
<span class="scan-filter-heading">Scanlators</span>
{#if scanlatorFilter.length > 0}
<button class="scan-filter-clear" onclick={() => { setPref("scanlatorFilter", []); chapterPage = 1; }}>Clear</button>
{/if}
</div>
<div class="scan-filter-divider"></div>
{#each availableScanlators as s}
<button class="scan-filter-item" class:scan-filter-item-active={scanlatorFilter.includes(s)} role="menuitem"
onclick={() => {
const next = scanlatorFilter.includes(s)
? scanlatorFilter.filter(x => x !== s)
: [...scanlatorFilter, s];
setPref("scanlatorFilter", next);
chapterPage = 1;
}}>
<span class="scan-filter-check" class:scan-filter-check-on={scanlatorFilter.includes(s)}>
{#if scanlatorFilter.includes(s)}<Check size={9} weight="bold" />{/if}
</span>
{s}
</button>
{/each}
</div>
{/if}
</div>
{/if}
<button class="icon-btn" onclick={refreshChapters} disabled={refreshing}> <button class="icon-btn" onclick={refreshChapters} disabled={refreshing}>
<ArrowsClockwise size={14} weight="light" class={refreshing ? "anim-spin" : ""} /> <ArrowsClockwise size={14} weight="light" class={refreshing ? "anim-spin" : ""} />
</button> </button>
<!-- Folder picker -->
<div class="fp-wrap" bind:this={folderPickerRef}> <div class="fp-wrap" bind:this={folderPickerRef}>
<button class="icon-btn" class:active={hasFolders} onclick={() => folderPickerOpen = !folderPickerOpen}> <button class="icon-btn" class:active={hasFolders} onclick={() => folderPickerOpen = !folderPickerOpen}>
<FolderSimplePlus size={14} weight={hasFolders ? "fill" : "light"} /> <FolderSimplePlus size={14} weight={hasFolders ? "fill" : "light"} />
</button> </button>
{#if folderPickerOpen} {#if folderPickerOpen}
<div class="fp-menu"> <div class="fp-menu">
{#if store.settings.folders.length === 0 && !folderCreating} {#if catsLoading}
<p class="fp-empty">Loading…</p>
{:else if allCategories.length === 0 && !folderCreating}
<p class="fp-empty">No folders yet</p> <p class="fp-empty">No folders yet</p>
{/if} {/if}
{#each store.settings.folders as folder} {#each allCategories as cat}
{@const isIn = store.activeManga ? folder.mangaIds.includes(store.activeManga.id) : false} {@const isIn = mangaCategories.some(c => c.id === cat.id)}
<button class="fp-item" class:fp-item-active={isIn} <button class="fp-item" class:fp-item-active={isIn} onclick={() => toggleCategory(cat)}>
onclick={() => store.activeManga && (isIn ? removeMangaFromFolder(folder.id, store.activeManga.id) : assignMangaToFolder(folder.id, store.activeManga.id))}> <span class="fp-check">{isIn ? "✓" : ""}</span>{cat.name}
<span class="fp-check">{isIn ? "✓" : ""}</span>{folder.name}
</button> </button>
{/each} {/each}
<div class="fp-div"></div> <div class="fp-div"></div>
{#if folderCreating} {#if folderCreating}
<div class="fp-create"> <div class="fp-create">
<input class="fp-input" placeholder="Folder name…" bind:value={folderNewName} <input class="fp-input" placeholder="Folder name…" bind:value={folderNewName} use:focusOnMount
onkeydown={(e) => { if (e.key === "Enter") createFolder(); if (e.key === "Escape") { folderCreating = false; folderNewName = ""; } }} use:focusOnMount /> onkeydown={(e) => { if (e.key === "Enter") createCategory(); if (e.key === "Escape") { folderCreating = false; folderNewName = ""; } }} />
<button class="fp-confirm" onclick={createFolder} disabled={!folderNewName.trim()}>Add</button> <button class="fp-confirm" onclick={createCategory} disabled={!folderNewName.trim()}>Add</button>
<button class="fp-cancel" onclick={() => { folderCreating = false; folderNewName = ""; }}> <button class="fp-cancel" onclick={() => { folderCreating = false; folderNewName = ""; }}><X size={12} weight="light" /></button>
<X size={12} weight="light" />
</button>
</div> </div>
{:else} {:else}
<button class="fp-new" onclick={() => folderCreating = true}>+ New folder</button> <button class="fp-new" onclick={() => folderCreating = true}>+ New folder</button>
@@ -539,14 +807,18 @@
{/if} {/if}
</div> </div>
<!-- Download dropdown -->
{#if chapters.length > 0} {#if chapters.length > 0}
<div class="dl-wrap" bind:this={dlDropRef}> <div class="dl-wrap" bind:this={dlDropRef}>
<button class="icon-btn" onclick={() => dlOpen = !dlOpen}> <button class="icon-btn dl-unified-btn" class:active={dlOpen} class:dl-has-count={downloadedCount > 0} onclick={() => dlOpen = !dlOpen} title="Download options">
<Download size={13} weight="light" /> <Download size={13} weight={downloadedCount > 0 ? "fill" : "light"} />
{#if downloadedCount > 0}<span class="dl-unified-count">{downloadedCount}</span>{/if}
</button> </button>
{#if dlOpen} {#if dlOpen}
<div class="dl-dropdown"> <div class="dl-dropdown">
{#if downloadedCount > 0}
<p class="dl-section-label">{downloadedCount} / {totalCount} downloaded</p>
<div class="dl-divider"></div>
{/if}
{#if continueChapter} {#if continueChapter}
{@const contIdx = sortedChapters.indexOf(continueChapter.chapter)} {@const contIdx = sortedChapters.indexOf(continueChapter.chapter)}
{#if contIdx >= 0} {#if contIdx >= 0}
@@ -614,11 +886,13 @@
{:else if viewMode === "grid"} {:else if viewMode === "grid"}
{#each sortedChapters as ch, i} {#each sortedChapters as ch, i}
{@const inProgress = !ch.isRead && (ch.lastPageRead ?? 0) > 0} {@const inProgress = !ch.isRead && (ch.lastPageRead ?? 0) > 0}
<button class="grid-cell" class:read={ch.isRead} class:in-progress={inProgress} {@const isGridSelected = selectedIds.has(ch.id)}
onclick={() => openReader(ch, sortedChapters)} <button class="grid-cell" class:read={ch.isRead} class:in-progress={inProgress} class:grid-selected={isGridSelected}
onclick={(e) => hasSelection ? toggleSelect(ch.id, e) : openReaderWithAhead(ch, sortedChapters)}
oncontextmenu={(e) => { e.preventDefault(); ctx = { x: e.clientX, y: e.clientY, chapter: ch, idx: i }; }} oncontextmenu={(e) => { e.preventDefault(); ctx = { x: e.clientX, y: e.clientY, chapter: ch, idx: i }; }}
title={ch.name}> title={ch.name}>
<span class="grid-cell-num">{ch.chapterNumber % 1 === 0 ? ch.chapterNumber.toFixed(0) : ch.chapterNumber}</span> <span class="grid-cell-num">{ch.chapterNumber % 1 === 0 ? ch.chapterNumber.toFixed(0) : ch.chapterNumber}</span>
{#if ch.isDownloaded}<span class="grid-cell-dl" title="Downloaded"></span>{/if}
{#if ch.isRead}<span class="grid-cell-dot"></span>{/if} {#if ch.isRead}<span class="grid-cell-dot"></span>{/if}
{#if enqueueing.has(ch.id)}<span class="grid-cell-spinner"><CircleNotch size={10} weight="light" class="anim-spin" /></span>{/if} {#if enqueueing.has(ch.id)}<span class="grid-cell-spinner"><CircleNotch size={10} weight="light" class="anim-spin" /></span>{/if}
</button> </button>
@@ -626,10 +900,14 @@
{:else} {:else}
{#each pageChapters as ch} {#each pageChapters as ch}
{@const idxInSorted = sortedChapters.indexOf(ch)} {@const idxInSorted = sortedChapters.indexOf(ch)}
<div role="button" tabindex="0" class="ch-row" class:read={ch.isRead} {@const isSelected = selectedIds.has(ch.id)}
onclick={() => openReader(ch, sortedChapters)} <div role="button" tabindex="0" class="ch-row" class:read={ch.isRead} class:ch-selected={isSelected}
onkeydown={(e) => e.key === "Enter" && openReader(ch, sortedChapters)} onclick={(e) => hasSelection ? toggleSelect(ch.id, e) : openReaderWithAhead(ch, sortedChapters)}
onkeydown={(e) => e.key === "Enter" && (hasSelection ? toggleSelect(ch.id, e) : openReaderWithAhead(ch, sortedChapters))}
oncontextmenu={(e) => { e.preventDefault(); ctx = { x: e.clientX, y: e.clientY, chapter: ch, idx: idxInSorted }; }}> oncontextmenu={(e) => { e.preventDefault(); ctx = { x: e.clientX, y: e.clientY, chapter: ch, idx: idxInSorted }; }}>
<button class="ch-check" class:ch-check-visible={hasSelection} onclick={(e) => toggleSelect(ch.id, e)} title="Select">
{#if isSelected}<CheckCircle size={15} weight="fill" />{:else}<Circle size={15} weight="light" />{/if}
</button>
<div class="ch-left"> <div class="ch-left">
<span class="ch-name">{ch.name}</span> <span class="ch-name">{ch.name}</span>
<div class="ch-meta"> <div class="ch-meta">
@@ -641,11 +919,14 @@
<div class="ch-right"> <div class="ch-right">
{#if ch.isRead}<CheckCircle size={14} weight="light" class="read-icon" />{/if} {#if ch.isRead}<CheckCircle size={14} weight="light" class="read-icon" />{/if}
{#if ch.isDownloaded} {#if ch.isDownloaded}
<button class="dl-btn" onclick={(e) => { e.stopPropagation(); deleteDownloaded(ch.id); }}><Trash size={13} weight="light" /></button> <div class="ch-dl-wrap">
<Download size={13} weight="fill" class="ch-dl-icon" />
<button class="dl-btn dl-btn-delete" onclick={(e) => { e.stopPropagation(); deleteDownloaded(ch.id); }} title="Delete download"><Trash size={13} weight="light" /></button>
</div>
{:else if enqueueing.has(ch.id)} {:else if enqueueing.has(ch.id)}
<CircleNotch size={14} weight="light" class="anim-spin enqueue-icon" /> <CircleNotch size={14} weight="light" class="anim-spin enqueue-icon" />
{:else} {:else}
<button class="dl-btn" onclick={(e) => { e.stopPropagation(); enqueue(ch, e); }}><Download size={13} weight="light" /></button> <button class="dl-btn" onclick={(e) => { e.stopPropagation(); enqueue(ch, e); }} title="Download"><Download size={13} weight="light" /></button>
{/if} {/if}
</div> </div>
</div> </div>
@@ -677,20 +958,25 @@
{/if} {/if}
{#if trackingOpen && store.activeManga} {#if trackingOpen && store.activeManga}
<TrackingPanel <TrackingPanel mangaId={store.activeManga.id} mangaTitle={store.activeManga.title} onClose={() => trackingOpen = false} />
mangaId={store.activeManga.id} {/if}
mangaTitle={store.activeManga.title}
onClose={() => trackingOpen = false} {#if autoOpen && store.activeManga}
/> <AutomationPanel mangaId={store.activeManga.id} {chapters} onClose={() => autoOpen = false} />
{/if}
{#if markersOpen && store.activeManga}
<div class="markers-panel-overlay" role="presentation" onclick={(e) => { if (e.target === e.currentTarget) markersOpen = false; }}>
<div class="markers-panel-drawer">
<MarkersPanel mangaId={store.activeManga.id} {chapters} onClose={() => markersOpen = false} />
</div>
</div>
{/if} {/if}
{#if linkPickerOpen} {#if linkPickerOpen}
<div <div class="link-backdrop" role="presentation"
class="link-backdrop"
role="presentation"
onclick={(e) => { if (e.target === e.currentTarget) closeLinkPicker(); }} onclick={(e) => { if (e.target === e.currentTarget) closeLinkPicker(); }}
onkeydown={(e) => e.key === "Escape" && closeLinkPicker()} onkeydown={(e) => e.key === "Escape" && closeLinkPicker()}>
>
<div class="link-modal"> <div class="link-modal">
<div class="link-header"> <div class="link-header">
<span class="link-title">Link as same series</span> <span class="link-title">Link as same series</span>
@@ -709,7 +995,7 @@
{#each linkPickerResults as m (m.id)} {#each linkPickerResults as m (m.id)}
{@const isLinked = linkedIds.includes(m.id)} {@const isLinked = linkedIds.includes(m.id)}
<button class="link-row" class:link-row-linked={isLinked} onclick={() => handleLink(m)}> <button class="link-row" class:link-row-linked={isLinked} onclick={() => handleLink(m)}>
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} class="link-thumb" loading="lazy" decoding="async" /> <Thumbnail src={m.thumbnailUrl} alt={m.title} class="link-thumb" />
<div class="link-info"> <div class="link-info">
<span class="link-manga-title">{m.title}</span> <span class="link-manga-title">{m.title}</span>
{#if m.source?.displayName}<span class="link-source">{m.source.displayName}</span>{/if} {#if m.source?.displayName}<span class="link-source">{m.source.displayName}</span>{/if}
@@ -728,16 +1014,13 @@
<style> <style>
.root { display: flex; height: 100%; overflow: hidden; animation: fadeIn 0.14s ease both; } .root { display: flex; height: 100%; overflow: hidden; animation: fadeIn 0.14s ease both; }
/* ── Sidebar ─────────────────────────────────────────────────────────────── */
.sidebar { width: 240px; flex-shrink: 0; padding: var(--sp-5); border-right: 1px solid var(--border-dim); overflow-y: auto; display: flex; flex-direction: column; gap: var(--sp-4); background: var(--bg-base); } .sidebar { width: 240px; flex-shrink: 0; padding: var(--sp-5); border-right: 1px solid var(--border-dim); overflow-y: auto; display: flex; flex-direction: column; gap: var(--sp-4); background: var(--bg-base); }
.back { display: flex; align-items: center; gap: var(--sp-2); color: var(--text-muted); font-size: var(--text-xs); font-family: var(--font-ui); letter-spacing: var(--tracking-wide); text-transform: uppercase; transition: color var(--t-base); } .back { display: flex; align-items: center; gap: var(--sp-2); color: var(--text-muted); font-size: var(--text-xs); font-family: var(--font-ui); letter-spacing: var(--tracking-wide); text-transform: uppercase; transition: color var(--t-base); }
.back:hover { color: var(--text-secondary); } .back:hover { color: var(--text-secondary); }
/* Zone 1: Cover */
.cover-wrap { width: 100%; aspect-ratio: 2/3; border-radius: var(--radius-md); overflow: hidden; background: var(--bg-raised); border: 1px solid var(--border-dim); flex-shrink: 0; } .cover-wrap { width: 100%; aspect-ratio: 2/3; border-radius: var(--radius-md); overflow: hidden; background: var(--bg-raised); border: 1px solid var(--border-dim); flex-shrink: 0; }
.cover { width: 100%; height: 100%; object-fit: cover; } :global(.cover) { width: 100%; height: 100%; object-fit: cover; }
/* Zone 2: Meta */
.meta-skeleton { display: flex; flex-direction: column; gap: var(--sp-2); } .meta-skeleton { display: flex; flex-direction: column; gap: var(--sp-2); }
.sk-line { border-radius: var(--radius-sm); } .sk-line { border-radius: var(--radius-sm); }
.meta { display: flex; flex-direction: column; gap: var(--sp-3); } .meta { display: flex; flex-direction: column; gap: var(--sp-3); }
@@ -751,10 +1034,8 @@
.genre:hover { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); } .genre:hover { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
.genre-toggle { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-sm); padding: 1px 6px; letter-spacing: var(--tracking-wide); cursor: pointer; transition: color var(--t-base), border-color var(--t-base); } .genre-toggle { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-sm); padding: 1px 6px; letter-spacing: var(--tracking-wide); cursor: pointer; transition: color var(--t-base), border-color var(--t-base); }
.genre-toggle:hover { color: var(--accent-fg); border-color: var(--accent-dim); } .genre-toggle:hover { color: var(--accent-fg); border-color: var(--accent-dim); }
/* Description clamped — no expand in 240px sidebar */
.desc { font-size: var(--text-xs); color: var(--text-muted); line-height: var(--leading-base); display: -webkit-box; -webkit-line-clamp: 4; -webkit-box-orient: vertical; overflow: hidden; } .desc { font-size: var(--text-xs); color: var(--text-muted); line-height: var(--leading-base); display: -webkit-box; -webkit-line-clamp: 4; -webkit-box-orient: vertical; overflow: hidden; }
/* Zone 3: CTA */
.cta-section { display: flex; flex-direction: column; gap: var(--sp-2); } .cta-section { display: flex; flex-direction: column; gap: var(--sp-2); }
.read-btn { display: flex; align-items: center; justify-content: center; gap: var(--sp-2); width: 100%; padding: 9px var(--sp-3); border-radius: var(--radius-md); background: var(--accent); border: 1px solid var(--accent); color: var(--accent-contrast, #fff); font-size: var(--text-xs); font-family: var(--font-ui); letter-spacing: var(--tracking-wide); cursor: pointer; transition: opacity var(--t-base); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .read-btn { display: flex; align-items: center; justify-content: center; gap: var(--sp-2); width: 100%; padding: 9px var(--sp-3); border-radius: var(--radius-md); background: var(--accent); border: 1px solid var(--accent); color: var(--accent-contrast, #fff); font-size: var(--text-xs); font-family: var(--font-ui); letter-spacing: var(--tracking-wide); cursor: pointer; transition: opacity var(--t-base); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.read-btn:hover { opacity: 0.88; } .read-btn:hover { opacity: 0.88; }
@@ -766,7 +1047,6 @@
.external-link { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); color: var(--text-faint); flex-shrink: 0; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); } .external-link { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); color: var(--text-faint); flex-shrink: 0; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
.external-link:hover { color: var(--text-muted); border-color: var(--border-strong); } .external-link:hover { color: var(--text-muted); border-color: var(--border-strong); }
/* Zone 4: Progress */
.progress-section { display: flex; flex-direction: column; gap: var(--sp-1); } .progress-section { display: flex; flex-direction: column; gap: var(--sp-1); }
.progress-header { display: flex; justify-content: space-between; align-items: center; } .progress-header { display: flex; justify-content: space-between; align-items: center; }
.progress-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); } .progress-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
@@ -774,14 +1054,10 @@
.progress-track { height: 3px; background: var(--border-base); border-radius: var(--radius-full); overflow: hidden; } .progress-track { height: 3px; background: var(--border-base); border-radius: var(--radius-full); overflow: hidden; }
.progress-fill { height: 100%; background: var(--accent); border-radius: var(--radius-full); transition: width 0.4s ease; } .progress-fill { height: 100%; background: var(--accent); border-radius: var(--radius-full); transition: width 0.4s ease; }
/* Zone 5: Details accordion */
.details-section { display: flex; flex-direction: column; gap: 2px; } .details-section { display: flex; flex-direction: column; gap: 2px; }
.details-toggle { display: flex; align-items: center; justify-content: space-between; font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); padding: 4px 0; transition: color var(--t-base); } .details-toggle { display: flex; align-items: center; justify-content: space-between; font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); padding: 4px 0; transition: color var(--t-base); }
.details-toggle:hover { color: var(--text-muted); } .details-toggle:hover { color: var(--text-muted); }
.details-body { display: flex; flex-direction: column; gap: var(--sp-2); padding-top: var(--sp-2); } .details-body { display: flex; flex-direction: column; gap: var(--sp-2); padding-top: var(--sp-2); }
.detail-row { display: flex; justify-content: space-between; align-items: baseline; gap: var(--sp-2); }
.detail-key { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
.detail-val { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-secondary); letter-spacing: var(--tracking-wide); text-align: right; }
.detail-actions { display: flex; flex-direction: column; gap: var(--sp-1); padding-top: var(--sp-1); } .detail-actions { display: flex; flex-direction: column; gap: var(--sp-1); padding-top: var(--sp-1); }
.detail-action-btn { display: flex; align-items: center; gap: var(--sp-2); width: 100%; padding: 6px var(--sp-2); border-radius: var(--radius-md); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); color: var(--text-faint); background: none; border: 1px solid var(--border-dim); cursor: pointer; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); } .detail-action-btn { display: flex; align-items: center; gap: var(--sp-2); width: 100%; padding: 6px var(--sp-2); border-radius: var(--radius-md); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); color: var(--text-faint); background: none; border: 1px solid var(--border-dim); cursor: pointer; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
.detail-action-btn:hover { color: var(--text-muted); border-color: var(--border-strong); background: var(--bg-raised); } .detail-action-btn:hover { color: var(--text-muted); border-color: var(--border-strong); background: var(--bg-raised); }
@@ -791,7 +1067,6 @@
.detail-action-danger:hover:not(:disabled) { background: var(--color-error-bg); border-color: var(--color-error); color: var(--color-error); } .detail-action-danger:hover:not(:disabled) { background: var(--color-error-bg); border-color: var(--color-error); color: var(--color-error); }
.detail-action-danger:disabled { opacity: 0.4; cursor: default; } .detail-action-danger:disabled { opacity: 0.4; cursor: default; }
/* ── Series link modal ───────────────────────────────────────────────────── */
.link-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.72); z-index: var(--z-settings); display: flex; align-items: center; justify-content: center; backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px); animation: fadeIn 0.12s ease both; } .link-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.72); z-index: var(--z-settings); display: flex; align-items: center; justify-content: center; backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px); animation: fadeIn 0.12s ease both; }
.link-modal { width: min(460px, calc(100vw - 48px)); max-height: 70vh; display: flex; flex-direction: column; background: var(--bg-surface); border: 1px solid var(--border-base); border-radius: var(--radius-xl); overflow: hidden; box-shadow: 0 24px 64px rgba(0,0,0,0.6); animation: scaleIn 0.14s ease both; } .link-modal { width: min(460px, calc(100vw - 48px)); max-height: 70vh; display: flex; flex-direction: column; background: var(--bg-surface); border: 1px solid var(--border-base); border-radius: var(--radius-xl); overflow: hidden; box-shadow: 0 24px 64px rgba(0,0,0,0.6); animation: scaleIn 0.14s ease both; }
.link-header { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-4) var(--sp-5); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; } .link-header { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-4) var(--sp-5); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
@@ -808,14 +1083,13 @@
.link-row { display: flex; align-items: center; gap: var(--sp-3); width: 100%; padding: 8px var(--sp-3); border-radius: var(--radius-md); border: none; background: none; text-align: left; cursor: pointer; transition: background var(--t-fast); } .link-row { display: flex; align-items: center; gap: var(--sp-3); width: 100%; padding: 8px var(--sp-3); border-radius: var(--radius-md); border: none; background: none; text-align: left; cursor: pointer; transition: background var(--t-fast); }
.link-row:hover { background: var(--bg-raised); } .link-row:hover { background: var(--bg-raised); }
.link-row-linked { background: var(--accent-muted) !important; } .link-row-linked { background: var(--accent-muted) !important; }
.link-thumb { width: 34px; height: 48px; border-radius: var(--radius-sm); object-fit: cover; flex-shrink: 0; border: 1px solid var(--border-dim); } :global(.link-thumb) { width: 34px; height: 48px; border-radius: var(--radius-sm); object-fit: cover; flex-shrink: 0; border: 1px solid var(--border-dim); }
.link-info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; min-width: 0; } .link-info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; min-width: 0; }
.link-manga-title { font-size: var(--text-sm); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .link-manga-title { font-size: var(--text-sm); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.link-source { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); } .link-source { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
.link-status { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--text-faint); flex-shrink: 0; padding: 2px 8px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); } .link-status { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--text-faint); flex-shrink: 0; padding: 2px 8px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); }
.link-row-linked .link-status { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); } .link-row-linked .link-status { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
/* ── Chapter list ────────────────────────────────────────────────────────── */
.list-wrap { flex: 1; display: flex; flex-direction: column; overflow: hidden; } .list-wrap { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
.list-header { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-3) var(--sp-4); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; gap: var(--sp-2); flex-wrap: wrap; } .list-header { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-3) var(--sp-4); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; gap: var(--sp-2); flex-wrap: wrap; }
.list-header-left, .list-header-right { display: flex; align-items: center; gap: var(--sp-1); } .list-header-left, .list-header-right { display: flex; align-items: center; gap: var(--sp-1); }
@@ -832,7 +1106,14 @@
.icon-btn.active { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); } .icon-btn.active { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
.icon-btn:disabled { opacity: 0.3; cursor: default; } .icon-btn:disabled { opacity: 0.3; cursor: default; }
/* ── Folder picker ───────────────────────────────────────────────────────── */ .jump-wrap { position: relative; }
.jump-popover { position: absolute; top: calc(100% + 4px); right: 0; width: 220px; background: var(--bg-raised); border: 1px solid var(--border-base); border-radius: var(--radius-md); padding: var(--sp-2); z-index: 200; box-shadow: 0 8px 24px rgba(0,0,0,0.5); animation: scaleIn 0.1s ease both; transform-origin: top right; display: flex; flex-direction: column; gap: var(--sp-1); }
.jump-input { width: 100%; background: var(--bg-overlay); border: 1px solid var(--border-strong); border-radius: var(--radius-sm); padding: 5px 9px; font-size: var(--text-xs); color: var(--text-secondary); outline: none; transition: border-color var(--t-base); }
.jump-input:focus { border-color: var(--border-focus); }
.jump-go { width: 100%; padding: 6px var(--sp-2); border-radius: var(--radius-sm); background: var(--accent-muted); border: 1px solid var(--accent-dim); color: var(--accent-fg); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); cursor: pointer; text-align: left; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; transition: background var(--t-fast), border-color var(--t-fast); }
.jump-go:hover { background: var(--accent); border-color: var(--accent); color: var(--accent-contrast, #fff); }
.jump-none { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); padding: 4px var(--sp-1); letter-spacing: var(--tracking-wide); }
.fp-wrap { position: relative; } .fp-wrap { position: relative; }
.fp-menu { position: absolute; top: calc(100% + 4px); right: 0; min-width: 180px; background: var(--bg-raised); border: 1px solid var(--border-base); border-radius: var(--radius-md); padding: var(--sp-1); z-index: 200; box-shadow: 0 8px 24px rgba(0,0,0,0.5); animation: scaleIn 0.1s ease both; transform-origin: top right; } .fp-menu { position: absolute; top: calc(100% + 4px); right: 0; min-width: 180px; background: var(--bg-raised); border: 1px solid var(--border-base); border-radius: var(--radius-md); padding: var(--sp-1); z-index: 200; box-shadow: 0 8px 24px rgba(0,0,0,0.5); animation: scaleIn 0.1s ease both; transform-origin: top right; }
.fp-empty { padding: var(--sp-2) var(--sp-3); font-size: var(--text-xs); color: var(--text-faint); } .fp-empty { padding: var(--sp-2) var(--sp-3); font-size: var(--text-xs); color: var(--text-faint); }
@@ -851,7 +1132,6 @@
.fp-new { width: 100%; padding: 6px var(--sp-3); border-radius: var(--radius-sm); font-size: var(--text-xs); color: var(--text-faint); background: none; border: none; cursor: pointer; text-align: left; transition: color var(--t-fast), background var(--t-fast); } .fp-new { width: 100%; padding: 6px var(--sp-3); border-radius: var(--radius-sm); font-size: var(--text-xs); color: var(--text-faint); background: none; border: none; cursor: pointer; text-align: left; transition: color var(--t-fast), background var(--t-fast); }
.fp-new:hover { color: var(--text-secondary); background: var(--bg-overlay); } .fp-new:hover { color: var(--text-secondary); background: var(--bg-overlay); }
/* ── Download dropdown ───────────────────────────────────────────────────── */
.dl-wrap { position: relative; } .dl-wrap { position: relative; }
.dl-dropdown { position: absolute; top: calc(100% + 4px); right: 0; min-width: 220px; background: var(--bg-raised); border: 1px solid var(--border-base); border-radius: var(--radius-lg); padding: var(--sp-1); z-index: 200; box-shadow: 0 8px 32px rgba(0,0,0,0.5); animation: scaleIn 0.1s ease both; transform-origin: top right; } .dl-dropdown { position: absolute; top: calc(100% + 4px); right: 0; min-width: 220px; background: var(--bg-raised); border: 1px solid var(--border-base); border-radius: var(--radius-lg); padding: var(--sp-1); z-index: 200; box-shadow: 0 8px 32px rgba(0,0,0,0.5); animation: scaleIn 0.1s ease both; transform-origin: top right; }
.dl-section-label { padding: 6px var(--sp-3) 4px; font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; } .dl-section-label { padding: 6px var(--sp-3) 4px; font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; }
@@ -876,7 +1156,6 @@
.dl-range-go { padding: 4px 10px; border-radius: var(--radius-sm); border: 1px solid var(--accent-dim); background: var(--accent-muted); color: var(--accent-fg); font-family: var(--font-ui); font-size: var(--text-xs); cursor: pointer; } .dl-range-go { padding: 4px 10px; border-radius: var(--radius-sm); border: 1px solid var(--accent-dim); background: var(--accent-muted); color: var(--accent-fg); font-family: var(--font-ui); font-size: var(--text-xs); cursor: pointer; }
.dl-range-go:disabled { opacity: 0.3; cursor: default; } .dl-range-go:disabled { opacity: 0.3; cursor: default; }
/* ── Pagination ──────────────────────────────────────────────────────────── */
.pagination, .pagination-bottom { display: flex; align-items: center; gap: var(--sp-2); } .pagination, .pagination-bottom { display: flex; align-items: center; gap: var(--sp-2); }
.pagination-bottom { justify-content: center; padding: var(--sp-3); border-top: 1px solid var(--border-dim); flex-shrink: 0; } .pagination-bottom { justify-content: center; padding: var(--sp-3); border-top: 1px solid var(--border-dim); flex-shrink: 0; }
.page-btn { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 4px 10px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); color: var(--text-faint); background: none; cursor: pointer; transition: color var(--t-base), border-color var(--t-base); } .page-btn { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 4px 10px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); color: var(--text-faint); background: none; cursor: pointer; transition: color var(--t-base), border-color var(--t-base); }
@@ -884,7 +1163,6 @@
.page-btn:disabled { opacity: 0.3; cursor: default; } .page-btn:disabled { opacity: 0.3; cursor: default; }
.page-num { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); } .page-num { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
/* ── Chapter rows ────────────────────────────────────────────────────────── */
.ch-list { flex: 1; overflow-y: auto; } .ch-list { flex: 1; overflow-y: auto; }
.ch-grid { flex: 1; overflow-y: auto; display: grid; grid-template-columns: repeat(auto-fill, minmax(42px, 1fr)); gap: 4px; padding: var(--sp-3); align-content: start; } .ch-grid { flex: 1; overflow-y: auto; display: grid; grid-template-columns: repeat(auto-fill, minmax(42px, 1fr)); gap: 4px; padding: var(--sp-3); align-content: start; }
.ch-row { display: flex; align-items: center; padding: 10px var(--sp-4); border-bottom: 1px solid var(--border-dim); cursor: pointer; transition: background var(--t-fast); gap: var(--sp-3); } .ch-row { display: flex; align-items: center; padding: 10px var(--sp-4); border-bottom: 1px solid var(--border-dim); cursor: pointer; transition: background var(--t-fast); gap: var(--sp-3); }
@@ -910,10 +1188,58 @@
.grid-cell-spinner { position: absolute; top: 2px; right: 2px; } .grid-cell-spinner { position: absolute; top: 2px; right: 2px; }
.grid-cell-skeleton { aspect-ratio: 1; border-radius: var(--radius-sm); } .grid-cell-skeleton { aspect-ratio: 1; border-radius: var(--radius-sm); }
.sel-count { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); letter-spacing: var(--tracking-wide); padding: 0 var(--sp-1); }
.sel-action-btn { display: flex; align-items: center; justify-content: center; width: 26px; height: 26px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: none; color: var(--text-muted); cursor: pointer; transition: color var(--t-base), background var(--t-base), border-color var(--t-base); }
.sel-action-btn:hover { color: var(--text-primary); background: var(--bg-raised); border-color: var(--border-strong); }
.sel-action-danger { color: var(--color-error) !important; }
.sel-action-danger:hover { background: var(--color-error-bg) !important; border-color: var(--color-error) !important; }
.dl-unified-btn { gap: 5px; padding: 0 8px; width: auto; min-width: 28px; }
.dl-unified-count { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--text-faint); transition: color var(--t-base); }
.dl-unified-btn:hover .dl-unified-count,
.dl-unified-btn.active .dl-unified-count { color: var(--text-secondary); }
.dl-unified-btn.dl-has-count { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
.dl-unified-btn.dl-has-count .dl-unified-count { color: var(--accent-fg); opacity: 0.8; }
.dl-unified-btn.dl-has-count:hover { background: var(--accent-muted); border-color: var(--accent); opacity: 0.9; }
.dl-unified-btn.active { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
.ch-check { display: flex; align-items: center; justify-content: center; width: 20px; height: 20px; flex-shrink: 0; border-radius: var(--radius-sm); border: none; background: none; color: var(--text-faint); cursor: pointer; opacity: 0; transition: opacity var(--t-fast), color var(--t-fast); padding: 0; }
.ch-row:hover .ch-check { opacity: 1; }
.ch-check-visible { opacity: 1 !important; }
.ch-selected { background: color-mix(in srgb, var(--accent) 8%, transparent) !important; }
.ch-selected .ch-check { color: var(--accent-fg); opacity: 1; }
.dl-btn-delete { color: var(--color-error) !important; opacity: 0; }
.ch-row:hover .dl-btn-delete { opacity: 1; }
.dl-btn-delete:hover { background: var(--color-error-bg) !important; }
.ch-dl-wrap { position: relative; display: flex; align-items: center; justify-content: center; width: 24px; height: 24px; }
:global(.ch-dl-icon) { color: var(--text-faint); transition: opacity var(--t-fast); }
.ch-row:hover .ch-dl-wrap :global(.ch-dl-icon) { opacity: 0; }
.ch-dl-wrap .dl-btn-delete { position: absolute; inset: 0; opacity: 0; }
.ch-row:hover .ch-dl-wrap .dl-btn-delete { opacity: 1; }
.grid-selected { background: var(--accent-muted) !important; border-color: var(--accent-dim) !important; }
.grid-cell-dl { position: absolute; top: 3px; left: 3px; width: 4px; height: 4px; border-radius: 50%; background: var(--accent-fg); }
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } } @keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
@keyframes scaleIn { from { opacity: 0; transform: scale(0.97) } to { opacity: 1; transform: scale(1) } } @keyframes scaleIn { from { opacity: 0; transform: scale(0.97) } to { opacity: 1; transform: scale(1) } }
</style>
<script module> .markers-panel-overlay { position: fixed; inset: 0; z-index: var(--z-settings); display: flex; align-items: stretch; justify-content: flex-start; animation: fadeIn 0.12s ease both; }
function focusOnMount(node: HTMLElement) { node.focus(); } .markers-panel-drawer { width: 280px; max-width: 90vw; background: var(--bg-surface); border-right: 1px solid var(--border-base); box-shadow: 4px 0 24px rgba(0,0,0,0.4); display: flex; flex-direction: column; animation: drawerIn 0.18s cubic-bezier(0.16,1,0.3,1) both; }
</script> @keyframes drawerIn { from { opacity: 0; transform: translateX(-12px); } to { opacity: 1; transform: translateX(0); } }
.scan-filter-wrap { position: relative; }
.scan-filter-panel { position: absolute; top: calc(100% + 6px); right: 0; z-index: 200; min-width: 200px; background: var(--bg-raised); border: 1px solid var(--border-base); border-radius: var(--radius-lg); padding: var(--sp-1); box-shadow: 0 8px 32px rgba(0,0,0,0.5); animation: scaleIn 0.1s ease both; transform-origin: top right; }
.scan-filter-header { display: flex; align-items: center; justify-content: space-between; padding: 4px 8px 6px; }
.scan-filter-heading { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); color: var(--text-secondary); font-weight: var(--weight-medium); }
.scan-filter-clear { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--text-faint); background: none; border: none; cursor: pointer; padding: 0; transition: color var(--t-base); }
.scan-filter-clear:hover { color: var(--color-error); }
.scan-filter-divider { height: 1px; background: var(--border-dim); margin: 0 2px 4px; }
.scan-filter-item { display: flex; align-items: center; gap: var(--sp-2); width: 100%; padding: 7px 10px; border-radius: var(--radius-sm); border: none; background: transparent; color: var(--text-muted); font-family: var(--font-ui); font-size: var(--text-xs); cursor: pointer; text-align: left; transition: background var(--t-base), color var(--t-base); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.scan-filter-item:hover { background: var(--bg-overlay); color: var(--text-primary); }
.scan-filter-item-active { color: var(--accent-fg); background: var(--accent-muted); }
.scan-filter-item-active:hover { background: var(--accent-dim); }
.scan-filter-check { width: 13px; height: 13px; border-radius: 2px; border: 1px solid var(--border-strong); background: transparent; flex-shrink: 0; display: flex; align-items: center; justify-content: center; color: var(--bg-base); transition: background var(--t-base), border-color var(--t-base); }
.scan-filter-check-on { background: var(--accent); border-color: var(--accent); }
</style>
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { CircleNotch, X, MagnifyingGlass, ArrowSquareOut, Lock, LockOpen, ArrowsClockwise } from "phosphor-svelte"; import { CircleNotch, X, MagnifyingGlass, ArrowSquareOut, Lock, LockOpen, ArrowsClockwise } from "phosphor-svelte";
import { gql, thumbUrl } from "../../lib/client"; import { gql } from "../../lib/client";
import Thumbnail from "../shared/Thumbnail.svelte";
import { import {
GET_TRACKERS, GET_TRACKERS,
GET_MANGA_TRACK_RECORDS, GET_MANGA_TRACK_RECORDS,
@@ -19,8 +20,6 @@
onClose: () => void; onClose: () => void;
} = $props(); } = $props();
// ── State ──────────────────────────────────────────────────────────────────
type TabId = "records" | number; type TabId = "records" | number;
let trackers: Tracker[] = $state([]); let trackers: Tracker[] = $state([]);
@@ -39,8 +38,6 @@
let editingChapter: number | null = $state(null); let editingChapter: number | null = $state(null);
let chapterDraft: number = $state(0); let chapterDraft: number = $state(0);
// ── Load ───────────────────────────────────────────────────────────────────
async function load() { async function load() {
loading = true; loading = true;
try { try {
@@ -61,7 +58,6 @@
$effect(() => { load(); }); $effect(() => { load(); });
// Auto-search with manga title when switching to a tracker tab
$effect(() => { $effect(() => {
const tab = activeTab; const tab = activeTab;
if (typeof tab !== "number") return; if (typeof tab !== "number") return;
@@ -71,14 +67,10 @@
doSearch(tab, mangaTitle); doSearch(tab, mangaTitle);
}); });
// ── Helpers ────────────────────────────────────────────────────────────────
function trackerFor(id: number) { return trackers.find(t => t.id === id); } function trackerFor(id: number) { return trackers.find(t => t.id === id); }
function recordFor(trackerId: number){ return records.find(r => r.trackerId === trackerId); } function recordFor(trackerId: number){ return records.find(r => r.trackerId === trackerId); }
const loggedInTrackers = $derived(trackers.filter(t => t.isLoggedIn)); const loggedInTrackers = $derived(trackers.filter(t => t.isLoggedIn));
// ── Search ─────────────────────────────────────────────────────────────────
let searchTimer: ReturnType<typeof setTimeout>; let searchTimer: ReturnType<typeof setTimeout>;
function onSearchInput() { function onSearchInput() {
@@ -105,8 +97,6 @@
} }
} }
// ── Bind / Unbind ──────────────────────────────────────────────────────────
async function bind(result: TrackSearch) { async function bind(result: TrackSearch) {
if (typeof activeTab !== "number") return; if (typeof activeTab !== "number") return;
binding = true; binding = true;
@@ -137,8 +127,6 @@
} }
} }
// ── Update ─────────────────────────────────────────────────────────────────
async function updateStatus(record: TrackRecord, status: number) { async function updateStatus(record: TrackRecord, status: number) {
updatingRecord = record.id; updatingRecord = record.id;
try { try {
@@ -234,7 +222,6 @@
> >
<div class="modal" role="dialog" aria-label="Tracking"> <div class="modal" role="dialog" aria-label="Tracking">
<!-- ── Header ─────────────────────────────────────────────────────────── -->
<div class="modal-header"> <div class="modal-header">
<div class="header-left"> <div class="header-left">
<span class="modal-title">Tracking</span> <span class="modal-title">Tracking</span>
@@ -256,7 +243,6 @@
</div> </div>
{:else} {:else}
<!-- ── Tabs ──────────────────────────────────────────────────────────── -->
<div class="tabs"> <div class="tabs">
<button <button
class="tab" class="tab"
@@ -275,14 +261,13 @@
class:tab-active={activeTab === t.id} class:tab-active={activeTab === t.id}
onclick={() => { activeTab = t.id; searchResults = []; }} onclick={() => { activeTab = t.id; searchResults = []; }}
> >
<img src={thumbUrl(t.icon)} alt={t.name} class="tab-icon" /> <Thumbnail src={t.icon} alt={t.name} class="tab-icon" />
{t.name} {t.name}
{#if rec}<span class="tab-dot"></span>{/if} {#if rec}<span class="tab-dot"></span>{/if}
</button> </button>
{/each} {/each}
</div> </div>
<!-- ── My List tab ───────────────────────────────────────────────────── -->
{#if activeTab === "records"} {#if activeTab === "records"}
<div class="tab-body"> <div class="tab-body">
{#if records.length === 0} {#if records.length === 0}
@@ -294,25 +279,50 @@
{#each records as record (record.id)} {#each records as record (record.id)}
{@const tracker = trackerFor(record.trackerId)} {@const tracker = trackerFor(record.trackerId)}
{@const isBusy = updatingRecord === record.id} {@const isBusy = updatingRecord === record.id}
<div class="record-row" class:record-busy={isBusy}> <div class="record-card" class:record-busy={isBusy}>
<div class="record-identity"> <!-- Title row -->
<div class="record-head">
<div class="record-source">
{#if tracker} {#if tracker}
<img src={thumbUrl(tracker.icon)} alt={tracker.name} class="record-tracker-icon" /> <Thumbnail src={tracker.icon} alt={tracker.name} class="record-tracker-icon" />
{/if} {/if}
<span class="record-source-name">{tracker?.name ?? "Tracker"}</span>
</div>
<div class="record-head-actions">
{#if tracker?.supportsPrivateTracking}
<button
class="record-icon-btn"
class:icon-active={record.private}
title={record.private ? "Private — click to make public" : "Public"}
disabled={isBusy}
onclick={() => togglePrivate(record)}
>
{#if record.private}<Lock size={11} weight="fill" />{:else}<LockOpen size={11} weight="light" />{/if}
</button>
{/if}
<button class="record-icon-btn" title="Sync from tracker" disabled={syncing === record.id} onclick={() => syncRecord(record)}>
<ArrowsClockwise size={11} weight="light" class={syncing === record.id ? "anim-spin" : ""} />
</button>
<button class="record-icon-btn icon-danger" title="Unlink" disabled={isBusy} onclick={() => unbind(record)}>
<X size={11} weight="bold" />
</button>
</div>
</div>
<!-- Linked title -->
{#if record.remoteUrl} {#if record.remoteUrl}
<a href={record.remoteUrl} target="_blank" rel="noreferrer" class="record-title"> <a href={record.remoteUrl} target="_blank" rel="noreferrer" class="record-title">
{record.title} {record.title} <ArrowSquareOut size={10} weight="light" />
<ArrowSquareOut size={10} weight="light" />
</a> </a>
{:else} {:else}
<span class="record-title-plain">{record.title}</span> <span class="record-title-plain">{record.title}</span>
{/if} {/if}
</div>
<div class="record-controls"> <!-- Status + score row -->
<div class="record-selects">
<select <select
class="record-select" class="record-select record-select-status"
value={record.status} value={record.status}
disabled={isBusy} disabled={isBusy}
onchange={(e) => updateStatus(record, parseInt((e.target as HTMLSelectElement).value))} onchange={(e) => updateStatus(record, parseInt((e.target as HTMLSelectElement).value))}
@@ -321,7 +331,6 @@
<option value={s.value}>{s.name}</option> <option value={s.value}>{s.name}</option>
{/each} {/each}
</select> </select>
<select <select
class="record-select record-select-score" class="record-select record-select-score"
value={record.displayScore} value={record.displayScore}
@@ -332,58 +341,19 @@
<option value={s}> {s}</option> <option value={s}> {s}</option>
{/each} {/each}
</select> </select>
{#if tracker?.supportsPrivateTracking}
<button
class="record-icon-btn"
class:icon-active={record.private}
title={record.private ? "Private — click to make public" : "Public — click to make private"}
disabled={isBusy}
onclick={() => togglePrivate(record)}
>
{#if record.private}
<Lock size={12} weight="fill" />
{:else}
<LockOpen size={12} weight="light" />
{/if}
</button>
{/if}
<button
class="record-icon-btn"
title="Sync from tracker"
disabled={syncing === record.id}
onclick={() => syncRecord(record)}
>
<ArrowsClockwise size={12} weight="light" class={syncing === record.id ? "anim-spin" : ""} />
</button>
<button
class="record-icon-btn icon-danger"
title="Unlink"
disabled={isBusy}
onclick={() => unbind(record)}
>
<X size={12} weight="bold" />
</button>
</div> </div>
<!-- Chapter progress -->
{#if editingChapter === record.id} {#if editingChapter === record.id}
<div class="chapter-editor"> <div class="chapter-editor">
<div class="chapter-editor-top"> <div class="chapter-editor-top">
<span class="chapter-editor-label">Chapter read</span> <span class="chapter-editor-label">Chapter read</span>
<div class="chapter-input-wrap"> <div class="chapter-input-wrap">
<input <input
type="number" type="number" class="chapter-input"
class="chapter-input" min="0" max={record.totalChapters > 0 ? record.totalChapters : undefined}
min="0" step="0.5" bind:value={chapterDraft}
max={record.totalChapters > 0 ? record.totalChapters : undefined} onkeydown={(e) => { if (e.key === "Enter") submitChapter(record); if (e.key === "Escape") cancelChapterEditor(); }}
step="0.5"
bind:value={chapterDraft}
onkeydown={(e) => {
if (e.key === "Enter") submitChapter(record);
if (e.key === "Escape") cancelChapterEditor();
}}
use:autoFocus use:autoFocus
/> />
{#if record.totalChapters > 0} {#if record.totalChapters > 0}
@@ -392,40 +362,36 @@
</div> </div>
</div> </div>
{#if record.totalChapters > 0} {#if record.totalChapters > 0}
<input <input type="range" class="chapter-slider" min="0" max={record.totalChapters} step="1" bind:value={chapterDraft} />
type="range"
class="chapter-slider"
min="0"
max={record.totalChapters}
step="1"
bind:value={chapterDraft}
/>
{/if} {/if}
<div class="chapter-editor-actions"> <div class="chapter-editor-actions">
<button class="chapter-save-btn" onclick={() => submitChapter(record)}>Save</button>
<button class="chapter-cancel-btn" onclick={cancelChapterEditor}>Cancel</button> <button class="chapter-cancel-btn" onclick={cancelChapterEditor}>Cancel</button>
</div> <button class="chapter-save-btn" onclick={() => submitChapter(record)}>Save</button>
</div>
{:else if record.totalChapters > 0}
<div class="record-progress clickable" role="button" tabindex="0"
onclick={() => openChapterEditor(record)}
onkeydown={(e) => e.key === "Enter" && openChapterEditor(record)}
title="Click to edit"
>
<span class="record-progress-label">Ch. {record.lastChapterRead} / {record.totalChapters} <span class="edit-hint"></span></span>
<div class="record-progress-track">
<div class="record-progress-fill" style="width:{Math.min(100,(record.lastChapterRead/record.totalChapters)*100)}%"></div>
</div> </div>
</div> </div>
{:else} {:else}
<div class="record-progress clickable" role="button" tabindex="0" <div class="record-progress clickable" role="button" tabindex="0"
onclick={() => openChapterEditor(record)} onclick={() => openChapterEditor(record)}
onkeydown={(e) => e.key === "Enter" && openChapterEditor(record)} onkeydown={(e) => e.key === "Enter" && openChapterEditor(record)}
title="Click to set chapter" title="Click to edit"
> >
<div class="record-progress-header">
<span class="record-progress-label"> <span class="record-progress-label">
{record.lastChapterRead > 0 ? `Ch. ${record.lastChapterRead} read` : "Set chapter…"} <span class="edit-hint"></span> {#if record.totalChapters > 0}
Ch. {record.lastChapterRead} / {record.totalChapters}
{:else if record.lastChapterRead > 0}
Ch. {record.lastChapterRead} read
{:else}
Set chapter…
{/if}
</span> </span>
<span class="edit-hint">Edit</span>
</div>
{#if record.totalChapters > 0}
<div class="record-progress-track">
<div class="record-progress-fill" style="width:{Math.min(100,(record.lastChapterRead/record.totalChapters)*100)}%"></div>
</div>
{/if}
</div> </div>
{/if} {/if}
@@ -434,7 +400,6 @@
{/if} {/if}
</div> </div>
<!-- ── Tracker search tab ─────────────────────────────────────────────── -->
{:else} {:else}
{@const tracker = trackerFor(activeTab as number)} {@const tracker = trackerFor(activeTab as number)}
{@const boundRecord = recordFor(activeTab as number)} {@const boundRecord = recordFor(activeTab as number)}
@@ -517,8 +482,8 @@
animation: fadeIn 0.12s ease both; animation: fadeIn 0.12s ease both;
} }
.modal { .modal {
width: min(580px, calc(100vw - 48px)); width: min(560px, calc(100vw - 48px));
max-height: min(680px, calc(100vh - 80px)); max-height: min(660px, calc(100vh - 80px));
display: flex; flex-direction: column; display: flex; flex-direction: column;
background: var(--bg-surface); border: 1px solid var(--border-base); background: var(--bg-surface); border: 1px solid var(--border-base);
border-radius: var(--radius-xl); overflow: hidden; border-radius: var(--radius-xl); overflow: hidden;
@@ -532,9 +497,9 @@
padding: var(--sp-4) var(--sp-5); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; padding: var(--sp-4) var(--sp-5); border-bottom: 1px solid var(--border-dim); flex-shrink: 0;
} }
.header-left { display: flex; flex-direction: column; gap: 2px; min-width: 0; } .header-left { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.modal-title { font-size: var(--text-base); font-weight: var(--weight-medium); color: var(--text-primary); letter-spacing: var(--tracking-tight); } .modal-title { font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-primary); letter-spacing: var(--tracking-tight); }
.modal-subtitle { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .modal-subtitle { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.close-btn { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: var(--radius-sm); color: var(--text-faint); background: none; border: none; cursor: pointer; transition: color var(--t-base), background var(--t-base); flex-shrink: 0; } .close-btn { display: flex; align-items: center; justify-content: center; width: 26px; height: 26px; border-radius: var(--radius-sm); color: var(--text-faint); background: none; border: none; cursor: pointer; transition: color var(--t-base), background var(--t-base); flex-shrink: 0; }
.close-btn:hover { color: var(--text-muted); background: var(--bg-raised); } .close-btn:hover { color: var(--text-muted); background: var(--bg-raised); }
/* States */ /* States */
@@ -544,71 +509,100 @@
.state-hint { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); text-align: center; } .state-hint { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); text-align: center; }
/* Tabs */ /* Tabs */
.tabs { display: flex; align-items: center; gap: 2px; padding: var(--sp-2) var(--sp-3); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; overflow-x: auto; scrollbar-width: none; } .tabs { display: flex; align-items: center; gap: 1px; padding: 0 var(--sp-4); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; overflow-x: auto; scrollbar-width: none; }
.tabs::-webkit-scrollbar { display: none; } .tabs::-webkit-scrollbar { display: none; }
.tab { display: flex; align-items: center; gap: var(--sp-2); position: relative; font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 5px 10px; border-radius: var(--radius-sm); color: var(--text-faint); background: none; border: none; cursor: pointer; white-space: nowrap; transition: color var(--t-base), background var(--t-base); } .tab {
.tab:hover { color: var(--text-muted); background: var(--bg-raised); } display: flex; align-items: center; gap: var(--sp-2); position: relative;
.tab-active { color: var(--text-secondary); background: var(--bg-raised); } font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
.tab-icon { width: 14px; height: 14px; border-radius: 2px; object-fit: contain; } padding: 10px 10px 9px; color: var(--text-faint);
.tab-badge { font-size: 10px; padding: 0 5px; border-radius: var(--radius-full); background: var(--accent-dim); color: var(--accent-fg); min-width: 16px; text-align: center; } background: none; border: none; border-bottom: 2px solid transparent;
.tab-dot { position: absolute; top: 4px; right: 4px; width: 5px; height: 5px; border-radius: 50%; background: var(--accent); } cursor: pointer; white-space: nowrap;
transition: color var(--t-base), border-color var(--t-base);
margin-bottom: -1px;
}
.tab:hover { color: var(--text-muted); }
.tab-active { color: var(--text-secondary); border-bottom-color: var(--accent); }
:global(.tab-icon) { width: 13px; height: 13px; border-radius: 2px; object-fit: contain; }
.tab-badge { font-size: 10px; padding: 0 4px; border-radius: var(--radius-full); background: var(--bg-overlay); color: var(--text-faint); min-width: 16px; text-align: center; line-height: 16px; }
.tab-active .tab-badge { background: var(--accent-muted); color: var(--accent-fg); }
.tab-dot { width: 5px; height: 5px; border-radius: 50%; background: var(--accent); flex-shrink: 0; }
/* Records */ /* Records */
.tab-body { flex: 1; overflow-y: auto; padding: var(--sp-3); scrollbar-width: none; display: flex; flex-direction: column; gap: var(--sp-2); } .tab-body { flex: 1; overflow-y: auto; padding: var(--sp-3); scrollbar-width: none; display: flex; flex-direction: column; gap: var(--sp-2); }
.tab-body::-webkit-scrollbar { display: none; } .tab-body::-webkit-scrollbar { display: none; }
.record-row { display: flex; flex-direction: column; gap: var(--sp-2); padding: var(--sp-4); border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: var(--bg-raised); transition: opacity var(--t-base); }
.record-busy { opacity: 0.5; pointer-events: none; } .record-card {
.record-identity { display: flex; align-items: center; gap: var(--sp-2); min-width: 0; } display: flex; flex-direction: column; gap: var(--sp-3);
.record-tracker-icon { width: 16px; height: 16px; border-radius: 3px; flex-shrink: 0; object-fit: contain; } padding: var(--sp-4);
.record-title { display: inline-flex; align-items: center; gap: 3px; font-size: var(--text-sm); color: var(--accent-fg); text-decoration: none; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; transition: opacity var(--t-base); } border-radius: var(--radius-lg);
.record-title:hover { opacity: 0.75; } border: 1px solid var(--border-dim);
.record-title-plain { font-size: var(--text-sm); color: var(--text-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; } background: var(--bg-raised);
.record-controls { display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap; } transition: opacity var(--t-base), border-color var(--t-base);
.record-select {
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
padding: 4px 28px 4px 8px; border-radius: var(--radius-sm);
border: 1px solid var(--border-strong); background: var(--bg-overlay);
color: var(--text-secondary); outline: none; cursor: pointer;
appearance: none; -webkit-appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%23888' stroke-width='1.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E");
background-repeat: no-repeat; background-position: right 8px center;
transition: border-color var(--t-base);
} }
.record-select:hover:not(:disabled) { border-color: var(--accent-dim); } .record-card:hover { border-color: var(--border-strong); }
.record-select:focus { border-color: var(--accent); outline: none; } .record-busy { opacity: 0.4; pointer-events: none; }
.record-select:disabled { opacity: 0.4; cursor: default; }
.record-head { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-2); }
.record-source { display: flex; align-items: center; gap: var(--sp-2); }
:global(.record-tracker-icon) { width: 14px; height: 14px; border-radius: 2px; flex-shrink: 0; object-fit: contain; opacity: 0.75; }
.record-source-name { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; }
.record-head-actions { display: flex; align-items: center; gap: 2px; }
.record-title { display: inline-flex; align-items: center; gap: 4px; font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-secondary); text-decoration: none; line-height: var(--leading-snug); transition: color var(--t-base); }
.record-title:hover { color: var(--accent-fg); }
.record-title-plain { font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-secondary); line-height: var(--leading-snug); }
.record-selects { display: flex; gap: var(--sp-2); flex-wrap: wrap; }
.record-select {
font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide);
padding: 5px 24px 5px 10px; border-radius: var(--radius-md);
border: 1px solid var(--border-dim); background: var(--bg-surface);
color: var(--text-muted); outline: none; cursor: pointer; flex: 1; min-width: 0;
appearance: none; -webkit-appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%23555' stroke-width='1.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E");
background-repeat: no-repeat; background-position: right 8px center;
transition: border-color var(--t-base), color var(--t-base);
}
.record-select:hover:not(:disabled) { border-color: var(--border-strong); color: var(--text-secondary); }
.record-select:focus { border-color: var(--accent-dim); color: var(--text-secondary); }
.record-select:disabled { opacity: 0.35; cursor: default; }
.record-select option { background: var(--bg-surface); color: var(--text-secondary); } .record-select option { background: var(--bg-surface); color: var(--text-secondary); }
.record-select-score { max-width: 100px; } .record-select-score { flex: 0 0 auto; min-width: 80px; }
.record-icon-btn { display: flex; align-items: center; justify-content: center; width: 26px; height: 26px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); cursor: pointer; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); flex-shrink: 0; } .record-select-status { flex: 1; }
.record-icon-btn:hover:not(:disabled) { color: var(--text-muted); border-color: var(--border-strong); background: var(--bg-overlay); }
.record-icon-btn.icon-active { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); } .record-icon-btn { display: flex; align-items: center; justify-content: center; width: 22px; height: 22px; border-radius: var(--radius-sm); border: none; background: none; color: var(--text-faint); cursor: pointer; transition: color var(--t-base), background var(--t-base); flex-shrink: 0; }
.record-icon-btn.icon-danger:hover:not(:disabled) { color: var(--color-error); border-color: var(--color-error); background: var(--color-error-bg); } .record-icon-btn:hover:not(:disabled) { color: var(--text-muted); background: var(--bg-overlay); }
.record-icon-btn.icon-active { color: var(--accent-fg); }
.record-icon-btn.icon-danger:hover:not(:disabled) { color: var(--color-error); background: var(--color-error-bg); }
.record-icon-btn:disabled { opacity: 0.3; cursor: default; } .record-icon-btn:disabled { opacity: 0.3; cursor: default; }
.record-progress { display: flex; flex-direction: column; gap: 4px; }
.record-progress.clickable { cursor: pointer; border-radius: var(--radius-sm); padding: 3px 4px; margin: -3px -4px; transition: background var(--t-fast); } .record-progress { display: flex; flex-direction: column; gap: 6px; }
.record-progress.clickable { cursor: pointer; border-radius: var(--radius-sm); padding: 4px 6px; margin: -4px -6px; transition: background var(--t-fast); }
.record-progress.clickable:hover { background: var(--bg-overlay); } .record-progress.clickable:hover { background: var(--bg-overlay); }
.record-progress-header { display: flex; align-items: center; justify-content: space-between; }
.record-progress-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); } .record-progress-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
.edit-hint { opacity: 0; font-size: 10px; transition: opacity var(--t-fast); } .edit-hint { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); opacity: 0; transition: opacity var(--t-fast); }
.record-progress.clickable:hover .edit-hint { opacity: 1; } .record-progress.clickable:hover .edit-hint { opacity: 0.6; }
.record-progress-track { height: 2px; background: var(--border-base); border-radius: var(--radius-full); overflow: hidden; } .record-progress-track { height: 2px; background: var(--border-strong); border-radius: var(--radius-full); overflow: hidden; }
.record-progress-fill { height: 100%; background: var(--accent); border-radius: var(--radius-full); transition: width 0.3s ease; } .record-progress-fill { height: 100%; background: var(--accent); border-radius: var(--radius-full); transition: width 0.3s ease; }
/* Chapter editor */ /* Chapter editor */
.chapter-editor { display: flex; flex-direction: column; gap: var(--sp-2); padding: var(--sp-3); border-radius: var(--radius-md); border: 1px solid var(--accent-dim); background: var(--bg-overlay); } .chapter-editor { display: flex; flex-direction: column; gap: var(--sp-2); padding: var(--sp-3); border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: var(--bg-surface); }
.chapter-editor-top { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-3); } .chapter-editor-top { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-3); }
.chapter-editor-label { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); } .chapter-editor-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
.chapter-input-wrap { display: flex; align-items: center; gap: var(--sp-2); } .chapter-input-wrap { display: flex; align-items: center; gap: var(--sp-2); }
.chapter-input { width: 68px; background: var(--bg-raised); border: 1px solid var(--accent-dim); border-radius: var(--radius-sm); padding: 3px 6px; font-family: var(--font-ui); font-size: var(--text-sm); color: var(--text-primary); outline: none; text-align: center; } .chapter-input { width: 64px; background: var(--bg-raised); border: 1px solid var(--border-strong); border-radius: var(--radius-sm); padding: 3px 6px; font-family: var(--font-ui); font-size: var(--text-sm); color: var(--text-primary); outline: none; text-align: center; }
.chapter-input:focus { border-color: var(--accent); } .chapter-input:focus { border-color: var(--accent); }
.chapter-total { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); } .chapter-total { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
.chapter-slider { width: 100%; accent-color: var(--accent); cursor: pointer; height: 4px; } .chapter-slider { width: 100%; accent-color: var(--accent); cursor: pointer; height: 3px; }
.chapter-editor-actions { display: flex; gap: var(--sp-2); justify-content: flex-end; } .chapter-editor-actions { display: flex; gap: var(--sp-2); justify-content: flex-end; }
.chapter-save-btn { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 3px 12px; border-radius: var(--radius-sm); border: 1px solid var(--accent); background: var(--accent-muted); color: var(--accent-fg); cursor: pointer; transition: filter var(--t-base); } .chapter-save-btn { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 4px 12px; border-radius: var(--radius-sm); border: 1px solid var(--accent-dim); background: var(--accent-muted); color: var(--accent-fg); cursor: pointer; transition: filter var(--t-base); }
.chapter-save-btn:hover { filter: brightness(1.15); } .chapter-save-btn:hover { filter: brightness(1.15); }
.chapter-cancel-btn { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 3px 8px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); cursor: pointer; transition: color var(--t-base), border-color var(--t-base); } .chapter-cancel-btn { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 4px 8px; border-radius: var(--radius-sm); border: none; background: none; color: var(--text-faint); cursor: pointer; transition: color var(--t-base); }
.chapter-cancel-btn:hover { color: var(--text-muted); border-color: var(--border-strong); } .chapter-cancel-btn:hover { color: var(--text-muted); }
/* Search */ /* Search */
.search-bar { display: flex; align-items: center; gap: var(--sp-2); padding: var(--sp-3) var(--sp-4); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; } .search-bar { display: flex; align-items: center; gap: var(--sp-2); padding: var(--sp-3) var(--sp-4); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; background: var(--bg-surface); }
:global(.search-icon) { color: var(--text-faint); flex-shrink: 0; } :global(.search-icon) { color: var(--text-faint); flex-shrink: 0; }
.search-input { flex: 1; background: none; border: none; outline: none; font-size: var(--text-sm); color: var(--text-primary); } .search-input { flex: 1; background: none; border: none; outline: none; font-size: var(--text-sm); color: var(--text-primary); }
.search-input::placeholder { color: var(--text-faint); } .search-input::placeholder { color: var(--text-faint); }
@@ -618,17 +612,16 @@
/* Results */ /* Results */
.result-row { display: flex; align-items: flex-start; gap: var(--sp-3); width: 100%; padding: var(--sp-3); border-radius: var(--radius-md); border: none; background: none; text-align: left; cursor: pointer; transition: background var(--t-fast); } .result-row { display: flex; align-items: flex-start; gap: var(--sp-3); width: 100%; padding: var(--sp-3); border-radius: var(--radius-md); border: none; background: none; text-align: left; cursor: pointer; transition: background var(--t-fast); }
.result-row:hover:not(:disabled) { background: var(--bg-raised); } .result-row:hover:not(:disabled) { background: var(--bg-raised); }
.result-row:disabled { opacity: 0.5; cursor: default; } .result-row:disabled { opacity: 0.4; cursor: default; }
.result-bound { background: var(--accent-muted) !important; } .result-bound { background: color-mix(in srgb, var(--accent) 8%, transparent) !important; }
.result-cover { width: 46px; height: 66px; object-fit: cover; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); flex-shrink: 0; } .result-cover { width: 44px; height: 62px; object-fit: cover; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); flex-shrink: 0; }
.result-cover-empty { background: var(--bg-raised); } .result-cover-empty { background: var(--bg-raised); }
.hidden { display: none; }
.result-info { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: var(--sp-1); padding-top: 2px; } .result-info { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: var(--sp-1); padding-top: 2px; }
.result-title { font-size: var(--text-sm); color: var(--text-secondary); line-height: var(--leading-snug); text-align: left; } .result-title { font-size: var(--text-sm); color: var(--text-secondary); line-height: var(--leading-snug); text-align: left; }
.result-meta { display: flex; flex-wrap: wrap; gap: var(--sp-1); } .result-meta { display: flex; flex-wrap: wrap; gap: var(--sp-1); }
.result-tag { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 1px 6px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: var(--bg-raised); color: var(--text-faint); } .result-tag { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 1px 5px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: var(--bg-raised); color: var(--text-faint); }
.result-summary { font-size: var(--text-xs); color: var(--text-faint); line-height: var(--leading-snug); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; text-align: left; } .result-summary { font-size: var(--text-xs); color: var(--text-faint); line-height: var(--leading-snug); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; text-align: left; }
.result-action { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 4px 12px; border-radius: var(--radius-sm); border: 1px solid var(--border-strong); background: none; color: var(--text-faint); flex-shrink: 0; align-self: center; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); } .result-action { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 3px 10px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); flex-shrink: 0; align-self: center; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
.result-row:hover:not(:disabled) .result-action { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); } .result-row:hover:not(:disabled) .result-action { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
.result-action-on { color: var(--accent-fg) !important; border-color: var(--accent-dim) !important; background: var(--accent-muted) !important; } .result-action-on { color: var(--accent-fg) !important; border-color: var(--accent-dim) !important; background: var(--accent-muted) !important; }
File diff suppressed because it is too large Load Diff
+24 -34
View File
@@ -12,8 +12,6 @@
let { editingId = $bindable(null), onClose }: Props = $props(); let { editingId = $bindable(null), onClose }: Props = $props();
// ── Token group definitions ───────────────────────────────────────────────
const TOKEN_GROUPS: { label: string; tokens: (keyof ThemeTokens)[] }[] = [ const TOKEN_GROUPS: { label: string; tokens: (keyof ThemeTokens)[] }[] = [
{ {
label: "Backgrounds", label: "Backgrounds",
@@ -65,8 +63,6 @@
"color-info-bg": "Info background", "color-info-bg": "Info background",
}; };
// ── State ─────────────────────────────────────────────────────────────────
function loadInitial(): { name: string; tokens: ThemeTokens } { function loadInitial(): { name: string; tokens: ThemeTokens } {
if (editingId) { if (editingId) {
const existing = store.settings.customThemes.find(t => t.id === editingId); const existing = store.settings.customThemes.find(t => t.id === editingId);
@@ -81,13 +77,10 @@
let saveStatus: "idle" | "saved" = $state("idle"); let saveStatus: "idle" | "saved" = $state("idle");
let importError: string | null = $state(null); let importError: string | null = $state(null);
// ── CSS vars helper ───────────────────────────────────────────────────────
function toCssVars(t: ThemeTokens): string { function toCssVars(t: ThemeTokens): string {
return Object.entries(t).map(([k, v]) => `--${k}: ${v};`).join(" "); return Object.entries(t).map(([k, v]) => `--${k}: ${v};`).join(" ");
} }
// ── Actions ───────────────────────────────────────────────────────────────
function handleSave() { function handleSave() {
const name = themeName.trim() || "Untitled Theme"; const name = themeName.trim() || "Untitled Theme";
const id = editingId ?? `custom:${Math.random().toString(36).slice(2, 10)}`; const id = editingId ?? `custom:${Math.random().toString(36).slice(2, 10)}`;
@@ -154,17 +147,15 @@
<svelte:window onkeydown={onKey} /> <svelte:window onkeydown={onKey} />
<!-- ── Main editor ────────────────────────────────────────────────────────────── --> <div class="te-backdrop" role="presentation" onclick={onClose} onkeydown={(e) => e.key === "Escape" && onClose()}>
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
<div class="te-backdrop" onclick={onClose} role="presentation">
<div <div
class="te-shell" class="te-shell"
role="dialog" role="dialog"
aria-label="Theme editor" aria-label="Theme editor"
tabindex="0"
onclick={(e) => e.stopPropagation()} onclick={(e) => e.stopPropagation()}
> >
<!-- ── Header ──────────────────────────────────────────────────────── -->
<header class="te-header"> <header class="te-header">
<div class="te-header-left"> <div class="te-header-left">
<button class="te-icon-btn" onclick={onClose} title="Close editor"> <button class="te-icon-btn" onclick={onClose} title="Close editor">
@@ -209,25 +200,17 @@
</div> </div>
</header> </header>
<!-- ── Body ───────────────────────────────────────────────────────── -->
<div class="te-body"> <div class="te-body">
<!-- Left: live preview -->
<aside class="te-preview-pane"> <aside class="te-preview-pane">
<div class="te-pane-label">Live Preview</div> <div class="te-pane-label">Live Preview</div>
<!--
FIX 1: toCssVars scoped only to this element, so only the
preview UI sees the draft tokens — not the editor shell.
-->
<div class="te-preview-ui" style={toCssVars(tokens)}> <div class="te-preview-ui" style={toCssVars(tokens)}>
<!-- Sidebar -->
<div class="prv-sidebar"> <div class="prv-sidebar">
{#each [true, false, false, false] as active} {#each [true, false, false, false] as active}
<div class="prv-sb-dot" class:active></div> <div class="prv-sb-dot" class:active></div>
{/each} {/each}
</div> </div>
<!-- Main -->
<div class="prv-main"> <div class="prv-main">
<div class="prv-titlebar"> <div class="prv-titlebar">
<div class="prv-win-dots"> <div class="prv-win-dots">
@@ -262,7 +245,6 @@
</div> </div>
</div> </div>
<!-- Swatch strip — scoped to draft tokens too -->
<div class="te-swatches" style={toCssVars(tokens)}> <div class="te-swatches" style={toCssVars(tokens)}>
{#each [ {#each [
["bg-base","bg-base"],["bg-surface","bg-surface"], ["bg-base","bg-base"],["bg-surface","bg-surface"],
@@ -279,7 +261,6 @@
</div> </div>
</aside> </aside>
<!-- Right: token editor -->
<div class="te-editor-pane"> <div class="te-editor-pane">
{#each TOKEN_GROUPS as group} {#each TOKEN_GROUPS as group}
<div class="te-group"> <div class="te-group">
@@ -287,7 +268,14 @@
<div class="te-token-list"> <div class="te-token-list">
{#each group.tokens as token} {#each group.tokens as token}
<div class="te-token-row"> <div class="te-token-row">
<span class="te-color-swatch" style="background: {tokens[token]}"></span> <label class="te-color-swatch" style="background: {tokens[token]}" title="Pick colour">
<input
type="color"
class="te-color-picker"
value={tokens[token].length === 7 ? tokens[token] : tokens[token].slice(0,7)}
oninput={(e) => { tokens = { ...tokens, [token]: (e.target as HTMLInputElement).value }; }}
/>
</label>
<span class="te-token-name">{TOKEN_LABELS[token]}</span> <span class="te-token-name">{TOKEN_LABELS[token]}</span>
<span class="te-token-key">{token}</span> <span class="te-token-key">{token}</span>
<input <input
@@ -318,20 +306,16 @@
</div> </div>
<style> <style>
/* ── Backdrop ─────────────────────────────────────────────────────────────── */
.te-backdrop { .te-backdrop {
position: fixed; inset: 0; position: fixed; inset: 0;
background: rgba(0, 0, 0, 0.72); background: rgba(0, 0, 0, 0.72);
z-index: 200; z-index: 200;
/* FIX 2: center the modal instead of stretch */
display: flex; align-items: center; justify-content: center; display: flex; align-items: center; justify-content: center;
animation: teBackdropIn 0.14s ease both; animation: teBackdropIn 0.14s ease both;
} }
@keyframes teBackdropIn { from { opacity: 0; } to { opacity: 1; } } @keyframes teBackdropIn { from { opacity: 0; } to { opacity: 1; } }
/* ── Shell ────────────────────────────────────────────────────────────────── */
.te-shell { .te-shell {
/* FIX 2: constrained dimensions so it doesn't fill the screen */
width: calc(100% - 48px); width: calc(100% - 48px);
max-width: 1100px; max-width: 1100px;
height: calc(100% - 48px); height: calc(100% - 48px);
@@ -348,7 +332,6 @@
to { transform: translateY(0) scale(1); opacity: 1; } to { transform: translateY(0) scale(1); opacity: 1; }
} }
/* ── Header ───────────────────────────────────────────────────────────────── */
.te-header { .te-header {
display: flex; align-items: center; justify-content: space-between; display: flex; align-items: center; justify-content: space-between;
gap: 12px; padding: 0 16px; height: 46px; gap: 12px; padding: 0 16px; height: 46px;
@@ -420,10 +403,8 @@
.te-save-btn:hover { filter: brightness(1.12); } .te-save-btn:hover { filter: brightness(1.12); }
.te-save-btn.saved { background: var(--accent-dim); border-color: var(--accent); } .te-save-btn.saved { background: var(--accent-dim); border-color: var(--accent); }
/* ── Body ─────────────────────────────────────────────────────────────────── */
.te-body { flex: 1; overflow: hidden; display: flex; min-height: 0; } .te-body { flex: 1; overflow: hidden; display: flex; min-height: 0; }
/* ── Preview pane ─────────────────────────────────────────────────────────── */
.te-preview-pane { .te-preview-pane {
width: 260px; flex-shrink: 0; width: 260px; flex-shrink: 0;
border-right: 1px solid var(--border-dim); border-right: 1px solid var(--border-dim);
@@ -439,7 +420,6 @@
flex-shrink: 0; flex-shrink: 0;
} }
/* te-preview-ui receives draft CSS vars via inline style */
.te-preview-ui { .te-preview-ui {
flex: 1; min-height: 0; flex: 1; min-height: 0;
border-radius: 8px; overflow: hidden; border-radius: 8px; overflow: hidden;
@@ -447,7 +427,6 @@
display: flex; background: var(--bg-void); display: flex; background: var(--bg-void);
} }
/* Sidebar strip */
.prv-sidebar { .prv-sidebar {
width: 34px; flex-shrink: 0; width: 34px; flex-shrink: 0;
background: var(--bg-surface); background: var(--bg-surface);
@@ -512,7 +491,6 @@
.prv-toast-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--accent); flex-shrink: 0; } .prv-toast-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--accent); flex-shrink: 0; }
.prv-toast-lines { flex: 1; } .prv-toast-lines { flex: 1; }
/* Swatch strip */
.te-swatches { display: flex; gap: 5px; flex-wrap: wrap; flex-shrink: 0; } .te-swatches { display: flex; gap: 5px; flex-wrap: wrap; flex-shrink: 0; }
.te-swatch { .te-swatch {
width: 22px; height: 22px; border-radius: 4px; width: 22px; height: 22px; border-radius: 4px;
@@ -520,7 +498,6 @@
flex-shrink: 0; cursor: default; flex-shrink: 0; cursor: default;
} }
/* ── Editor pane ──────────────────────────────────────────────────────────── */
.te-editor-pane { .te-editor-pane {
flex: 1; overflow-y: auto; flex: 1; overflow-y: auto;
padding: 16px 20px; padding: 16px 20px;
@@ -552,10 +529,23 @@
.te-token-row:hover { background: var(--bg-raised); } .te-token-row:hover { background: var(--bg-raised); }
.te-color-swatch { .te-color-swatch {
width: 16px; height: 16px; border-radius: 4px; width: 36px; height: 18px; border-radius: 5px;
flex-shrink: 0; flex-shrink: 0;
border: 1px solid rgba(255,255,255,0.12); border: 1px solid rgba(255,255,255,0.12);
box-shadow: 0 0 0 1px rgba(0,0,0,0.2); box-shadow: 0 0 0 1px rgba(0,0,0,0.2);
cursor: pointer;
position: relative;
overflow: hidden;
display: block;
}
.te-color-swatch:hover { box-shadow: 0 0 0 2px var(--border-focus); }
.te-color-picker {
position: absolute; inset: 0;
width: 100%; height: 100%;
opacity: 0;
cursor: pointer;
padding: 0; border: none;
} }
.te-token-name { .te-token-name {
+85 -33
View File
@@ -1,12 +1,13 @@
<script lang="ts"> <script lang="ts">
import { onMount, onDestroy } from "svelte"; import { onMount, onDestroy } from "svelte";
import { X, BookmarkSimple, ArrowSquareOut, Play, CircleNotch, Books, CaretDown, FolderSimplePlus, Folder, LinkSimpleHorizontalBreak } from "phosphor-svelte"; import { X, BookmarkSimple, ArrowSquareOut, Play, CircleNotch, Books, CaretDown, FolderSimplePlus, Folder, LinkSimpleHorizontalBreak } from "phosphor-svelte";
import { gql, thumbUrl } from "../../lib/client"; import { gql } from "../../lib/client";
import { GET_MANGA, GET_CHAPTERS, FETCH_MANGA, FETCH_CHAPTERS, UPDATE_MANGA, ENQUEUE_CHAPTERS_DOWNLOAD } from "../../lib/queries"; import Thumbnail from "../shared/Thumbnail.svelte";
import { GET_MANGA, GET_CHAPTERS, FETCH_MANGA, FETCH_CHAPTERS, UPDATE_MANGA, ENQUEUE_CHAPTERS_DOWNLOAD, GET_CATEGORIES, CREATE_CATEGORY, UPDATE_MANGA_CATEGORIES } from "../../lib/queries";
import { GET_ALL_MANGA } from "../../lib/queries"; import { GET_ALL_MANGA } from "../../lib/queries";
import { cache, CACHE_KEYS } from "../../lib/cache"; import { cache, CACHE_KEYS } from "../../lib/cache";
import { store, openReader, addToast, addFolder, assignMangaToFolder, removeMangaFromFolder, checkAndMarkCompleted, linkManga, unlinkManga, setPreviewManga, setActiveManga, setNavPage, setGenreFilter } from "../../store/state.svelte"; import { store, openReader, addToast, linkManga, unlinkManga, setPreviewManga, setActiveManga, setNavPage, setGenreFilter, checkAndMarkCompleted as storeCheckAndMarkCompleted } from "../../store/state.svelte";
import type { Manga, Chapter } from "../../lib/types"; import type { Manga, Chapter, Category } from "../../lib/types";
let manga: Manga | null = $state(null); let manga: Manga | null = $state(null);
let chapters: Chapter[] = $state([]); let chapters: Chapter[] = $state([]);
@@ -17,6 +18,9 @@
let folderOpen = $state(false); let folderOpen = $state(false);
let newFolderName = $state(""); let newFolderName = $state("");
let creatingFolder = $state(false); let creatingFolder = $state(false);
let allCategories: Category[] = $state([]);
let mangaCategories: Category[] = $state([]);
let catsLoading: boolean = $state(false);
let queueingAll = $state(false); let queueingAll = $state(false);
let fetchError: string|null = $state(null); let fetchError: string|null = $state(null);
let folderRef: HTMLDivElement = $state() as HTMLDivElement; let folderRef: HTMLDivElement = $state() as HTMLDivElement;
@@ -79,7 +83,7 @@
const firstUpload = $derived(uploadDates.length ? new Date(Math.min(...uploadDates)) : null); const firstUpload = $derived(uploadDates.length ? new Date(Math.min(...uploadDates)) : null);
const lastUpload = $derived(uploadDates.length ? new Date(Math.max(...uploadDates)) : null); const lastUpload = $derived(uploadDates.length ? new Date(Math.max(...uploadDates)) : null);
const statusLabel = $derived(displayManga?.status ? displayManga.status.charAt(0) + displayManga.status.slice(1).toLowerCase() : null); const statusLabel = $derived(displayManga?.status ? displayManga.status.charAt(0) + displayManga.status.slice(1).toLowerCase() : null);
const assignedFolders = $derived(store.previewManga ? store.settings.folders.filter((f) => f.mangaIds.includes(store.previewManga!.id)) : []); const assignedFolders = $derived(mangaCategories.filter(c => c.id !== 0));
const continueChapter = $derived.by(() => { const continueChapter = $derived.by(() => {
if (!chapters.length) return null; if (!chapters.length) return null;
@@ -90,7 +94,7 @@
return { ch: chapters[0], label: "Read again" }; return { ch: chapters[0], label: "Read again" };
}); });
$effect(() => { if (store.previewManga) load(store.previewManga.id); }); $effect(() => { if (store.previewManga) { load(store.previewManga.id); loadCategories(store.previewManga.id); } });
async function load(id: number) { async function load(id: number) {
detailAbort?.abort(); chapterAbort?.abort(); detailAbort?.abort(); chapterAbort?.abort();
@@ -171,11 +175,55 @@
close(); close();
} }
function handleFolderCreate() { function loadCategories(mangaId: number) {
catsLoading = true;
gql<{ categories: { nodes: Category[] } }>(GET_CATEGORIES)
.then(d => {
allCategories = d.categories.nodes.filter(c => c.id !== 0);
mangaCategories = allCategories.filter(c => c.mangas?.nodes.some(m => m.id === mangaId));
})
.catch(console.error)
.finally(() => { catsLoading = false; });
}
async function checkAndMarkCompleted(mangaId: number, chaps: Chapter[]) {
await storeCheckAndMarkCompleted(mangaId, chaps, allCategories, gql, UPDATE_MANGA_CATEGORIES, UPDATE_MANGA);
// Sync local mangaCategories state after the mutation
if (chaps.length) {
const allRead = chaps.every(c => c.isRead);
const completed = allCategories.find(c => c.name === "Completed");
if (completed) {
const inCompleted = mangaCategories.some(c => c.id === completed.id);
if (allRead && !inCompleted) mangaCategories = [...mangaCategories, completed];
else if (!allRead && inCompleted) mangaCategories = mangaCategories.filter(c => c.id !== completed.id);
}
}
}
async function toggleCategory(cat: Category) {
if (!store.previewManga) return;
const mangaId = store.previewManga.id;
const inCat = mangaCategories.some(c => c.id === cat.id);
await gql(UPDATE_MANGA_CATEGORIES, {
mangaId,
addTo: inCat ? [] : [cat.id],
removeFrom: inCat ? [cat.id] : [],
}).catch(console.error);
mangaCategories = inCat
? mangaCategories.filter(c => c.id !== cat.id)
: [...mangaCategories, cat];
}
async function handleFolderCreate() {
const name = newFolderName.trim(); const name = newFolderName.trim();
if (!name || !store.previewManga) return; if (!name || !store.previewManga) return;
const id = addFolder(name); try {
assignMangaToFolder(id, store.previewManga.id); const res = await gql<{ createCategory: { category: Category } }>(CREATE_CATEGORY, { name });
const cat = res.createCategory.category;
allCategories = [...allCategories, cat];
await gql(UPDATE_MANGA_CATEGORIES, { mangaId: store.previewManga.id, addTo: [cat.id], removeFrom: [] });
mangaCategories = [...mangaCategories, cat];
} catch (e) { console.error(e); }
newFolderName = ""; creatingFolder = false; newFolderName = ""; creatingFolder = false;
} }
@@ -201,7 +249,7 @@
<div class="cover-col"> <div class="cover-col">
<div class="cover-wrap"> <div class="cover-wrap">
<img src={thumbUrl(store.previewManga.thumbnailUrl)} alt={displayManga?.title} class="cover" /> <Thumbnail src={store.previewManga.thumbnailUrl} alt={displayManga?.title} class="cover" />
{#if loadingDetail} {#if loadingDetail}
<div class="cover-spinner"><CircleNotch size={18} weight="light" class="anim-spin" /></div> <div class="cover-spinner"><CircleNotch size={18} weight="light" class="anim-spin" /></div>
{/if} {/if}
@@ -225,12 +273,15 @@
</button> </button>
{#if folderOpen} {#if folderOpen}
<div class="folder-menu"> <div class="folder-menu">
{#if store.settings.folders.length === 0 && !creatingFolder}<p class="folder-empty">No folders yet</p>{/if} {#if catsLoading}
{#each store.settings.folders as f} <p class="folder-empty">Loading…</p>
{@const isIn = store.previewManga ? f.mangaIds.includes(store.previewManga.id) : false} {:else if allCategories.length === 0 && !creatingFolder}
<button class="folder-item" class:folder-item-on={isIn} <p class="folder-empty">No folders yet</p>
onclick={() => store.previewManga && (isIn ? removeMangaFromFolder(f.id, store.previewManga.id) : assignMangaToFolder(f.id, store.previewManga.id))}> {/if}
<Folder size={12} weight={isIn ? "fill" : "light"} />{isIn ? "✓ " : ""}{f.name} {#each allCategories as cat}
{@const isIn = mangaCategories.some(c => c.id === cat.id)}
<button class="folder-item" class:folder-item-on={isIn} onclick={() => toggleCategory(cat)}>
<Folder size={12} weight={isIn ? "fill" : "light"} />{isIn ? "✓ " : ""}{cat.name}
</button> </button>
{/each} {/each}
<div class="folder-divider"></div> <div class="folder-divider"></div>
@@ -306,7 +357,7 @@
<div class="progress-track"><div class="progress-fill" style="width:{(readCount / totalCount) * 100}%"></div></div> <div class="progress-track"><div class="progress-fill" style="width:{(readCount / totalCount) * 100}%"></div></div>
{/if} {/if}
{#if continueChapter} {#if continueChapter}
<button class="read-btn" onclick={() => { openReader(continueChapter!.ch, chapters); close(); }}> <button class="read-btn" onclick={() => { openReader(continueChapter!.ch, chapters, displayManga); close(); }}>
<Play size={12} weight="fill" />{continueChapter.label} <Play size={12} weight="fill" />{continueChapter.label}
</button> </button>
{/if} {/if}
@@ -343,19 +394,18 @@
{#if !loadingDetail} {#if !loadingDetail}
<div class="meta-table"> <div class="meta-table">
{#if displayManga?.author}<div class="meta-row"><span class="meta-key">Author</span><span class="meta-val">{displayManga.author}</span></div>{/if} <div class="meta-grid">
{#if displayManga?.artist && displayManga.artist !== displayManga.author}<div class="meta-row"><span class="meta-key">Artist</span><span class="meta-val">{displayManga.artist}</span></div>{/if} <div class="meta-col">
{#if statusLabel}<div class="meta-row"><span class="meta-key">Status</span><span class="meta-val">{statusLabel}</span></div>{/if} <div class="meta-row"><span class="meta-key">Status</span><span class="meta-val">{statusLabel ?? "N/A"}</span></div>
{#if displayManga?.source}<div class="meta-row"><span class="meta-key">Source</span><span class="meta-val">{displayManga.source.displayName}</span></div>{/if} <div class="meta-row"><span class="meta-key">Source</span><span class="meta-val">{displayManga?.source?.displayName ?? "N/A"}</span></div>
{#if !loadingChapters && scanlators.length > 0}<div class="meta-row"><span class="meta-key">{scanlators.length === 1 ? "Scanlator" : "Scanlators"}</span><span class="meta-val">{scanlators.join(", ")}</span></div>{/if} <div class="meta-row"><span class="meta-key">Link</span>{#if displayManga?.realUrl}<a href={displayManga.realUrl} target="_blank" rel="noreferrer" class="meta-link">Open <ArrowSquareOut size={11} weight="light" /></a>{:else}<span class="meta-val">N/A</span>{/if}</div>
{#if !loadingChapters && firstUpload && lastUpload} </div>
<div class="meta-row"> <div class="meta-col">
<span class="meta-key">Published</span> <div class="meta-row"><span class="meta-key">Author</span><span class="meta-val">{displayManga?.author ?? "N/A"}</span></div>
<span class="meta-val">{firstUpload.getTime() === lastUpload.getTime() ? formatDate(firstUpload) : `${formatDate(firstUpload)} ${formatDate(lastUpload)}`}</span> <div class="meta-row"><span class="meta-key">Artist</span><span class="meta-val">{displayManga?.artist && displayManga.artist !== displayManga.author ? displayManga.artist : (displayManga?.author ?? "N/A")}</span></div>
<div class="meta-row"><span class="meta-key">Scanlator</span><span class="meta-val">{!loadingChapters && scanlators.length > 0 ? scanlators[0] : "N/A"}</span></div>
</div>
</div> </div>
{/if}
{#if !loadingChapters && downloadedCount > 0}<div class="meta-row"><span class="meta-key">Downloaded</span><span class="meta-val">{downloadedCount} / {totalCount} chapters</span></div>{/if}
{#if displayManga?.realUrl}<div class="meta-row"><span class="meta-key">Link</span><a href={displayManga.realUrl} target="_blank" rel="noreferrer" class="meta-link">Open <ArrowSquareOut size={11} weight="light" /></a></div>{/if}
</div> </div>
{/if} {/if}
</div> </div>
@@ -388,7 +438,7 @@
{#each linkPickerResults as m (m.id)} {#each linkPickerResults as m (m.id)}
{@const isLinked = linkedIds.includes(m.id)} {@const isLinked = linkedIds.includes(m.id)}
<button class="link-row" class:link-row-linked={isLinked} onclick={() => handleLink(m)}> <button class="link-row" class:link-row-linked={isLinked} onclick={() => handleLink(m)}>
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} class="link-thumb" loading="lazy" decoding="async" /> <Thumbnail src={m.thumbnailUrl} alt={m.title} class="link-thumb" />
<div class="link-info"> <div class="link-info">
<span class="link-manga-title">{m.title}</span> <span class="link-manga-title">{m.title}</span>
{#if m.source?.displayName}<span class="link-source">{m.source.displayName}</span>{/if} {#if m.source?.displayName}<span class="link-source">{m.source.displayName}</span>{/if}
@@ -413,7 +463,7 @@
.modal { width: min(800px, calc(100vw - 48px)); height: min(560px, calc(100vh - 80px)); display: flex; background: var(--bg-surface); border: 1px solid var(--border-base); border-radius: var(--radius-xl); overflow: hidden; animation: scaleIn 0.16s ease both; box-shadow: 0 0 0 1px var(--border-dim), 0 24px 64px rgba(0,0,0,0.6); } .modal { width: min(800px, calc(100vw - 48px)); height: min(560px, calc(100vh - 80px)); display: flex; background: var(--bg-surface); border: 1px solid var(--border-base); border-radius: var(--radius-xl); overflow: hidden; animation: scaleIn 0.16s ease both; box-shadow: 0 0 0 1px var(--border-dim), 0 24px 64px rgba(0,0,0,0.6); }
.cover-col { width: 190px; flex-shrink: 0; background: var(--bg-raised); border-right: 1px solid var(--border-dim); display: flex; flex-direction: column; padding: var(--sp-5) var(--sp-4) var(--sp-4); gap: var(--sp-3); overflow: hidden; } .cover-col { width: 190px; flex-shrink: 0; background: var(--bg-raised); border-right: 1px solid var(--border-dim); display: flex; flex-direction: column; padding: var(--sp-5) var(--sp-4) var(--sp-4); gap: var(--sp-3); overflow: hidden; }
.cover-wrap { position: relative; width: 100%; } .cover-wrap { position: relative; width: 100%; }
.cover { width: 100%; aspect-ratio: 2/3; object-fit: cover; border-radius: var(--radius-md); border: 1px solid var(--border-dim); display: block; } :global(.cover) { width: 100%; aspect-ratio: 2/3; object-fit: cover; border-radius: var(--radius-md); border: 1px solid var(--border-dim); display: block; }
.cover-spinner { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; background: rgba(0,0,0,0.35); border-radius: var(--radius-md); color: var(--text-faint); } .cover-spinner { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; background: rgba(0,0,0,0.35); border-radius: var(--radius-md); color: var(--text-faint); }
.cover-actions { display: flex; flex-direction: column; gap: var(--sp-2); } .cover-actions { display: flex; flex-direction: column; gap: var(--sp-2); }
.action-btn { display: flex; align-items: center; gap: var(--sp-2); width: 100%; padding: 7px var(--sp-3); border-radius: var(--radius-md); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); border: 1px solid var(--border-strong); background: none; color: var(--text-muted); cursor: pointer; text-align: left; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); } .action-btn { display: flex; align-items: center; gap: var(--sp-2); width: 100%; padding: 7px var(--sp-3); border-radius: var(--radius-md); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); border: 1px solid var(--border-strong); background: none; color: var(--text-muted); cursor: pointer; text-align: left; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
@@ -478,9 +528,11 @@
.genre-tag { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 3px 8px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: var(--bg-raised); color: var(--text-faint); cursor: pointer; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); } .genre-tag { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 3px 8px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: var(--bg-raised); color: var(--text-faint); cursor: pointer; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
.genre-tag:hover { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); } .genre-tag:hover { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
.meta-table { display: flex; flex-direction: column; gap: 1px; border-top: 1px solid var(--border-dim); padding-top: var(--sp-3); } .meta-table { display: flex; flex-direction: column; gap: 1px; border-top: 1px solid var(--border-dim); padding-top: var(--sp-3); }
.meta-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0 var(--sp-4); }
.meta-col { display: flex; flex-direction: column; }
.meta-row { display: flex; align-items: baseline; gap: var(--sp-3); padding: 5px 0; } .meta-row { display: flex; align-items: baseline; gap: var(--sp-3); padding: 5px 0; }
.meta-key { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); text-transform: uppercase; min-width: 56px; flex-shrink: 0; } .meta-key { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); text-transform: uppercase; min-width: 56px; flex-shrink: 0; }
.meta-val { font-size: var(--text-sm); color: var(--text-secondary); line-height: var(--leading-snug); } .meta-val { font-size: var(--text-sm); color: var(--text-secondary); line-height: var(--leading-snug); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.meta-link { display: inline-flex; align-items: center; gap: 4px; font-size: var(--text-sm); color: var(--accent-fg); text-decoration: none; transition: opacity var(--t-base); } .meta-link { display: inline-flex; align-items: center; gap: 4px; font-size: var(--text-sm); color: var(--accent-fg); text-decoration: none; transition: opacity var(--t-base); }
.meta-link:hover { opacity: 0.75; } .meta-link:hover { opacity: 0.75; }
.link-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.65); z-index: calc(var(--z-settings) + 1); display: flex; align-items: center; justify-content: center; backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px); animation: fadeIn 0.1s ease both; } .link-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.65); z-index: calc(var(--z-settings) + 1); display: flex; align-items: center; justify-content: center; backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px); animation: fadeIn 0.1s ease both; }
@@ -497,7 +549,7 @@
.link-row { display: flex; align-items: center; gap: var(--sp-3); width: 100%; padding: 8px var(--sp-3); border-radius: var(--radius-md); border: none; background: none; text-align: left; cursor: pointer; transition: background var(--t-fast); } .link-row { display: flex; align-items: center; gap: var(--sp-3); width: 100%; padding: 8px var(--sp-3); border-radius: var(--radius-md); border: none; background: none; text-align: left; cursor: pointer; transition: background var(--t-fast); }
.link-row:hover { background: var(--bg-raised); } .link-row:hover { background: var(--bg-raised); }
.link-row-linked { background: var(--accent-muted) !important; } .link-row-linked { background: var(--accent-muted) !important; }
.link-thumb { width: 34px; height: 48px; border-radius: var(--radius-sm); object-fit: cover; flex-shrink: 0; border: 1px solid var(--border-dim); } :global(.link-thumb) { width: 34px; height: 48px; border-radius: var(--radius-sm); object-fit: cover; flex-shrink: 0; border: 1px solid var(--border-dim); }
.link-info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; min-width: 0; } .link-info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; min-width: 0; }
.link-manga-title { font-size: var(--text-sm); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .link-manga-title { font-size: var(--text-sm); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.link-source { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); } .link-source { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
+56 -32
View File
@@ -1,33 +1,36 @@
<script lang="ts"> <script lang="ts">
import { ArrowLeft, MagnifyingGlass, ArrowLeft as Prev, ArrowRight as Next, BookmarkSimple, FolderSimplePlus, Folder } from "phosphor-svelte"; import { ArrowLeft, MagnifyingGlass, ArrowLeft as Prev, ArrowRight as Next, BookmarkSimple, FolderSimplePlus, Folder } from "phosphor-svelte";
import { gql, thumbUrl } from "../../lib/client"; import { gql } from "../../lib/client";
import { FETCH_SOURCE_MANGA, UPDATE_MANGA } from "../../lib/queries"; import Thumbnail from "../shared/Thumbnail.svelte";
import { store, addFolder, assignMangaToFolder, setActiveSource, setActiveManga, setNavPage } from "../../store/state.svelte"; import { FETCH_SOURCE_MANGA, UPDATE_MANGA, GET_CATEGORIES, CREATE_CATEGORY, UPDATE_MANGA_CATEGORIES } from "../../lib/queries";
import type { Manga } from "../../lib/types"; import { store, setActiveSource, setActiveManga, setNavPage } from "../../store/state.svelte";
import type { Manga, Category } from "../../lib/types";
import ContextMenu, { type MenuEntry } from "../shared/ContextMenu.svelte"; import ContextMenu, { type MenuEntry } from "../shared/ContextMenu.svelte";
type BrowseType = "POPULAR" | "LATEST" | "SEARCH"; type BrowseType = "POPULAR" | "LATEST" | "SEARCH";
let mangas: Manga[] = []; let mangas: Manga[] = $state([]);
let loading = true; let loading = $state(true);
let page = 1; let page = $state(1);
let hasNextPage = false; let hasNextPage = $state(false);
let browseType: BrowseType = "POPULAR"; let browseType: BrowseType = $state("POPULAR");
let search = ""; let search = $state("");
let searchInput = ""; let searchInput = $state("");
let ctx: { x: number; y: number; manga: Manga } | null = null; let ctx: { x: number; y: number; manga: Manga } | null = $state(null);
let categories: Category[] = $state([]);
let catsLoaded = false;
async function fetchMangas(type: BrowseType, p: number, q: string) { async function fetchMangas(type: BrowseType, p: number, q: string) {
if (!$store.activeSource) return; if (!store.activeSource) return;
loading = true; mangas = []; loading = true; mangas = [];
gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>( gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>(
FETCH_SOURCE_MANGA, { source: $store.activeSource.id, type, page: p, query: q || null } FETCH_SOURCE_MANGA, { source: store.activeSource.id, type, page: p, query: q || null }
).then((d) => { mangas = d.fetchSourceManga.mangas; hasNextPage = d.fetchSourceManga.hasNextPage; }) ).then((d) => { mangas = d.fetchSourceManga.mangas; hasNextPage = d.fetchSourceManga.hasNextPage; })
.catch(console.error) .catch(console.error)
.finally(() => loading = false); .finally(() => loading = false);
} }
$: if ($store.activeSource) fetchMangas(browseType, page, search); $effect(() => { if (store.activeSource) fetchMangas(browseType, page, search); });
function submitSearch() { function submitSearch() {
search = searchInput.trim(); search = searchInput.trim();
@@ -40,38 +43,58 @@
browseType = mode; search = ""; searchInput = ""; page = 1; browseType = mode; search = ""; searchInput = ""; page = 1;
} }
function openCtx(e: MouseEvent, m: Manga) {
e.preventDefault(); e.stopPropagation();
ctx = { x: e.clientX, y: e.clientY, manga: m };
if (!catsLoaded) {
catsLoaded = true;
gql<{ categories: { nodes: Category[] } }>(GET_CATEGORIES)
.then(d => { categories = d.categories.nodes.filter(c => c.id !== 0); })
.catch(console.error);
}
}
function buildCtxItems(m: Manga): MenuEntry[] { function buildCtxItems(m: Manga): MenuEntry[] {
return [ return [
{ label: m.inLibrary ? "In Library" : "Add to library", icon: BookmarkSimple, disabled: m.inLibrary, { label: m.inLibrary ? "In Library" : "Add to library", icon: BookmarkSimple, disabled: m.inLibrary,
onClick: () => gql(UPDATE_MANGA, { id: m.id, inLibrary: true }) onClick: () => gql(UPDATE_MANGA, { id: m.id, inLibrary: true })
.then(() => mangas = mangas.map((x) => x.id === m.id ? { ...x, inLibrary: true } : x)) .then(() => mangas = mangas.map((x) => x.id === m.id ? { ...x, inLibrary: true } : x))
.catch(console.error) }, .catch(console.error) },
...($store.settings.folders.length > 0 ? [ ...(categories.length > 0 ? [
{ separator: true } as MenuEntry, { separator: true } as MenuEntry,
...$store.settings.folders.map((f): MenuEntry => ({ ...categories.map((cat): MenuEntry => ({
label: f.mangaIds.includes(m.id) ? `✓ ${f.name}` : f.name, icon: Folder, label: (cat.mangas?.nodes ?? []).some(x => x.id === m.id) ? `✓ ${cat.name}` : cat.name, icon: Folder,
onClick: () => assignMangaToFolder(f.id, m.id), onClick: () => gql(UPDATE_MANGA_CATEGORIES, { mangaId: m.id, addTo: [cat.id], removeFrom: [] }).catch(console.error),
})), })),
] : []), ] : []),
{ separator: true }, { separator: true },
{ label: "New folder & add", icon: FolderSimplePlus, onClick: () => { const name = prompt("Folder name:"); if (name?.trim()) { const id = addFolder(name.trim()); assignMangaToFolder(id, m.id); } } }, { label: "New folder & add", icon: FolderSimplePlus, onClick: async () => {
const name = prompt("Folder name:");
if (!name?.trim()) return;
const res = await gql<{ createCategory: { category: Category } }>(CREATE_CATEGORY, { name: name.trim() }).catch(console.error);
if (res) {
const cat = res.createCategory.category;
categories = [...categories, cat];
await gql(UPDATE_MANGA_CATEGORIES, { mangaId: m.id, addTo: [cat.id], removeFrom: [] }).catch(console.error);
}
}},
]; ];
} }
</script> </script>
{#if $store.activeSource} {#if store.activeSource}
<div class="root"> <div class="root">
<div class="header"> <div class="header">
<button class="back" on:click={() => store.activeSource.set(null)}> <button class="back" onclick={() => setActiveSource(null)}>
<ArrowLeft size={13} weight="light" /><span>Sources</span> <ArrowLeft size={13} weight="light" /><span>Sources</span>
</button> </button>
<span class="source-name">{$store.activeSource.displayName}</span> <span class="source-name">{store.activeSource.displayName}</span>
</div> </div>
<div class="toolbar"> <div class="toolbar">
<div class="tabs"> <div class="tabs">
{#each (["POPULAR", "LATEST"] as BrowseType[]) as mode} {#each (["POPULAR", "LATEST"] as BrowseType[]) as mode}
<button class="tab" class:active={browseType === mode && !search} on:click={() => setMode(mode)}> <button class="tab" class:active={browseType === mode && !search} onclick={() => setMode(mode)}>
{mode.charAt(0) + mode.slice(1).toLowerCase()} {mode.charAt(0) + mode.slice(1).toLowerCase()}
</button> </button>
{/each} {/each}
@@ -80,7 +103,7 @@
<div class="search-wrap"> <div class="search-wrap">
<MagnifyingGlass size={12} class="search-icon" weight="light" /> <MagnifyingGlass size={12} class="search-icon" weight="light" />
<input class="search" placeholder="Search source…" bind:value={searchInput} <input class="search" placeholder="Search source…" bind:value={searchInput}
on:keydown={(e) => e.key === "Enter" && submitSearch()} /> onkeydown={(e) => e.key === "Enter" && submitSearch()} />
</div> </div>
</div> </div>
@@ -95,10 +118,10 @@
{:else} {:else}
<div class="grid"> <div class="grid">
{#each mangas as m (m.id)} {#each mangas as m (m.id)}
<button class="card" on:click={() => { store.activeManga.set(m); store.navPage.set("library"); }} <button class="card" onclick={() => { setActiveManga(m); setNavPage("library"); }}
on:contextmenu={(e) => { e.preventDefault(); e.stopPropagation(); ctx = { x: e.clientX, y: e.clientY, manga: m }; }}> oncontextmenu={(e) => openCtx(e, m)}>
<div class="cover-wrap"> <div class="cover-wrap">
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} class="cover" /> <Thumbnail src={m.thumbnailUrl} alt={m.title} class="cover" />
{#if m.inLibrary}<span class="in-library-badge">In Library</span>{/if} {#if m.inLibrary}<span class="in-library-badge">In Library</span>{/if}
</div> </div>
<p class="title">{m.title}</p> <p class="title">{m.title}</p>
@@ -109,11 +132,11 @@
{#if !loading && (page > 1 || hasNextPage)} {#if !loading && (page > 1 || hasNextPage)}
<div class="pagination"> <div class="pagination">
<button class="page-btn" on:click={() => page = Math.max(1, page - 1)} disabled={page === 1}> <button class="page-btn" onclick={() => page = Math.max(1, page - 1)} disabled={page === 1}>
<Prev size={13} weight="light" /> Prev <Prev size={13} weight="light" /> Prev
</button> </button>
<span class="page-num">{page}</span> <span class="page-num">{page}</span>
<button class="page-btn" on:click={() => page++} disabled={!hasNextPage}> <button class="page-btn" onclick={() => page++} disabled={!hasNextPage}>
Next <Next size={13} weight="light" /> Next <Next size={13} weight="light" />
</button> </button>
</div> </div>
@@ -143,10 +166,10 @@
.search:focus { border-color: var(--border-strong); } .search:focus { border-color: var(--border-strong); }
.grid, .loading-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(clamp(100px,14vw,140px),1fr)); gap: var(--sp-4); padding: var(--sp-5) var(--sp-6); overflow-y: auto; flex: 1; align-content: start; will-change: scroll-position; -webkit-overflow-scrolling: touch; contain: layout style; } .grid, .loading-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(clamp(100px,14vw,140px),1fr)); gap: var(--sp-4); padding: var(--sp-5) var(--sp-6); overflow-y: auto; flex: 1; align-content: start; will-change: scroll-position; -webkit-overflow-scrolling: touch; contain: layout style; }
.card { background: none; border: none; padding: 0; cursor: pointer; text-align: left; } .card { background: none; border: none; padding: 0; cursor: pointer; text-align: left; }
.card:hover .cover { filter: brightness(1.06); } .card:hover :global(.cover) { filter: brightness(1.06); }
.card:hover .title { color: var(--text-primary); } .card:hover .title { color: var(--text-primary); }
.cover-wrap { position: relative; aspect-ratio: 2/3; overflow: hidden; border-radius: var(--radius-md); background: var(--bg-raised); border: 1px solid var(--border-dim); transform: translateZ(0); } .cover-wrap { position: relative; aspect-ratio: 2/3; overflow: hidden; border-radius: var(--radius-md); background: var(--bg-raised); border: 1px solid var(--border-dim); transform: translateZ(0); }
.cover { width: 100%; height: 100%; object-fit: cover; transition: filter var(--t-base); will-change: filter; } :global(.cover) { width: 100%; height: 100%; object-fit: cover; transition: filter var(--t-base); will-change: filter; }
.in-library-badge { position: absolute; bottom: var(--sp-1); left: var(--sp-1); font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); text-transform: uppercase; background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim); padding: 2px 5px; border-radius: var(--radius-sm); } .in-library-badge { position: absolute; bottom: var(--sp-1); left: var(--sp-1); font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); text-transform: uppercase; background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim); padding: 2px 5px; border-radius: var(--radius-sm); }
.title { margin-top: var(--sp-2); font-size: var(--text-sm); color: var(--text-secondary); line-height: var(--leading-snug); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; transition: color var(--t-base); } .title { margin-top: var(--sp-2); font-size: var(--text-sm); color: var(--text-secondary); line-height: var(--leading-snug); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; transition: color var(--t-base); }
.card-skeleton { padding: 0; } .card-skeleton { padding: 0; }
@@ -158,4 +181,5 @@
.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-muted); letter-spacing: var(--tracking-wider); min-width: 24px; text-align: center; } .page-num { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); letter-spacing: var(--tracking-wider); min-width: 24px; text-align: center; }
.empty { display: flex; align-items: center; justify-content: center; flex: 1; color: var(--text-muted); 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-muted); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); }
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
</style> </style>
+5 -4
View File
@@ -1,6 +1,8 @@
<script lang="ts"> <script lang="ts">
import { onMount } from "svelte";
import { MagnifyingGlass, CircleNotch, CaretDown, CaretRight } from "phosphor-svelte"; import { MagnifyingGlass, CircleNotch, CaretDown, CaretRight } from "phosphor-svelte";
import { gql, thumbUrl } from "../../lib/client"; import { gql } from "../../lib/client";
import Thumbnail from "../shared/Thumbnail.svelte";
import { GET_SOURCES } from "../../lib/queries"; import { GET_SOURCES } from "../../lib/queries";
import { store } from "../../store/state.svelte"; import { store } from "../../store/state.svelte";
import type { Source } from "../../lib/types"; import type { Source } from "../../lib/types";
@@ -11,7 +13,7 @@
let search = $state(""); let search = $state("");
let expanded = $state(new Set<string>()); let expanded = $state(new Set<string>());
$effect(() => { onMount(() => {
gql<{ sources: { nodes: Source[] } }>(GET_SOURCES) gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
.then((d) => { sources = d.sources.nodes; }) .then((d) => { sources = d.sources.nodes; })
.catch(console.error) .catch(console.error)
@@ -73,8 +75,7 @@
{@const open = expanded.has(g.name)} {@const open = expanded.has(g.name)}
<div> <div>
<button class="row" onclick={() => single ? store.activeSource = g.sources[0] : toggleGroup(g.name)}> <button class="row" onclick={() => single ? store.activeSource = g.sources[0] : toggleGroup(g.name)}>
<img src={thumbUrl(g.icon)} alt={g.name} class="icon" <Thumbnail src={g.icon} alt={g.name} class="icon" onerror={(e) => ((e.target as HTMLImageElement).style.display = "none")} />
onerror={(e) => ((e.target as HTMLImageElement).style.display = "none")} />
<div class="info"> <div class="info">
<span class="name">{g.name}</span> <span class="name">{g.name}</span>
<span class="meta">{single ? `${g.sources[0].lang.toUpperCase()}${g.sources[0].isNsfw ? " · NSFW" : ""}` : `${g.sources.length} languages`}</span> <span class="meta">{single ? `${g.sources[0].lang.toUpperCase()}${g.sources[0].isNsfw ? " · NSFW" : ""}` : `${g.sources.length} languages`}</span>
+43
View File
@@ -0,0 +1,43 @@
<script lang="ts">
import { thumbUrl, plainThumbUrl } from "../../lib/client";
import { store } from "../../store/state.svelte";
import { getBlobUrl } from "../../lib/imageCache";
let {
src,
alt = "",
class: cls = "",
loading = "lazy",
decoding = "async",
priority = 0,
onerror = undefined,
...rest
}: {
src: string;
alt?: string;
class?: string;
loading?: string;
decoding?: string;
priority?: number;
onerror?: ((e: Event) => void) | undefined;
[key: string]: any;
} = $props();
const isAuth = $derived(store.settings.serverAuthMode === "BASIC_AUTH");
let blobUrl = $state("");
$effect(() => {
if (!isAuth || !src) { blobUrl = ""; return; }
getBlobUrl(plainThumbUrl(src), priority)
.then(u => { blobUrl = u; })
.catch(() => { blobUrl = ""; });
});
const resolved = $derived(
isAuth
? (blobUrl || undefined)
: (src ? thumbUrl(src) : undefined)
);
</script>
<img src={resolved} {alt} class={cls} {loading} {decoding} {onerror} {...rest} />
+102
View File
@@ -0,0 +1,102 @@
import { store, updateSettings } from "../store/state.svelte";
export type AuthMode = "NONE" | "BASIC_AUTH" | "SIMPLE_LOGIN" | "UI_LOGIN";
export const authSession = {
clearTokens() {},
hasSession(): boolean { return true; },
};
function getServerBase(): string {
const url = store.settings.serverUrl;
return typeof url === "string" && url.trim() ? url.replace(/\/$/, "") : "http://127.0.0.1:4567";
}
function basicHeader(user: string, pass: string): Record<string, string> {
return { Authorization: `Basic ${btoa(`${user}:${pass}`)}` };
}
export function fetchAuthenticated(
url: string,
init: RequestInit,
signal?: AbortSignal,
): Promise<Response> {
const mode = store.settings.serverAuthMode ?? "NONE";
if (mode === "BASIC_AUTH") {
const user = store.settings.serverAuthUser?.trim() ?? "";
const pass = store.settings.serverAuthPass?.trim() ?? "";
return fetch(url, {
...init,
signal,
credentials: "include",
headers: {
...(init.headers as Record<string, string> ?? {}),
...(user && pass ? basicHeader(user, pass) : {}),
},
});
}
return fetch(url, { ...init, signal });
}
export async function loginBasic(user: string, pass: string): Promise<void> {
const res = await fetch(`${getServerBase()}/api/graphql`, {
method: "POST",
headers: { "Content-Type": "application/json", ...basicHeader(user, pass) },
body: JSON.stringify({ query: "{ __typename }" }),
signal: AbortSignal.timeout(5000),
});
if (!res.ok) throw new Error(`Authentication failed (${res.status})`);
updateSettings({ serverAuthMode: "BASIC_AUTH", serverAuthUser: user, serverAuthPass: pass });
}
export async function logout(): Promise<void> {
updateSettings({ serverAuthPass: "" });
}
export async function probeServer(): Promise<"ok" | "auth_required" | "unsupported_mode" | "unreachable"> {
const base = getServerBase();
const mode = store.settings.serverAuthMode ?? "NONE";
const s = store.settings;
try {
const headers: Record<string, string> = { "Content-Type": "application/json" };
if (mode === "BASIC_AUTH") {
const user = s.serverAuthUser?.trim() ?? "";
const pass = s.serverAuthPass?.trim() ?? "";
if (user && pass) Object.assign(headers, basicHeader(user, pass));
}
const res = await fetch(`${base}/api/graphql`, {
method: "POST",
credentials: "include",
headers,
body: JSON.stringify({ query: "{ __typename }" }),
signal: AbortSignal.timeout(2000),
});
if (res.ok) {
return "ok";
}
if (res.status === 401) {
const wwwAuth = res.headers.get("WWW-Authenticate") ?? "";
if (/basic/i.test(wwwAuth)) {
if (mode !== "BASIC_AUTH") updateSettings({ serverAuthMode: "BASIC_AUTH" });
return "auth_required";
}
if (/bearer/i.test(wwwAuth)) {
if (mode !== "UI_LOGIN") updateSettings({ serverAuthMode: "UI_LOGIN" });
} else if (mode === "NONE") {
updateSettings({ serverAuthMode: "SIMPLE_LOGIN" });
}
return "unsupported_mode";
}
return "unreachable";
} catch { return "unreachable"; }
}
+1
View File
@@ -154,6 +154,7 @@ export const CACHE_GROUPS = {
export const CACHE_KEYS = { export const CACHE_KEYS = {
LIBRARY: "library", LIBRARY: "library",
ALL_MANGA: "all_manga_unfiltered", ALL_MANGA: "all_manga_unfiltered",
CATEGORIES: "categories",
DISCOVER: "discover_all_manga", // Discover's unfiltered fetch — separate from library DISCOVER: "discover_all_manga", // Discover's unfiltered fetch — separate from library
SOURCES: "sources", SOURCES: "sources",
POPULAR: "popular", POPULAR: "popular",
+34
View File
@@ -0,0 +1,34 @@
import type { Chapter } from "./types";
export function buildReaderChapterList(
chapters: Chapter[],
mangaPrefs: { preferredScanlator?: string; scanlatorFilter?: string[] } | undefined,
): Chapter[] {
const preferred = mangaPrefs?.preferredScanlator ?? "";
const filter = mangaPrefs?.scanlatorFilter ?? [];
let base = [...chapters].sort((a, b) => a.sourceOrder - b.sourceOrder);
if (preferred) {
const pref: Chapter[] = [], rest: Chapter[] = [];
for (const c of base) (c.scanlator === preferred ? pref : rest).push(c);
base = [...pref, ...rest];
}
if (filter.length > 0) {
const seen = new Map<number, Chapter>();
for (const ch of base) {
const existing = seen.get(ch.chapterNumber);
if (!existing) {
seen.set(ch.chapterNumber, ch);
} else {
const np = filter.indexOf(ch.scanlator ?? "");
const op = filter.indexOf(existing.scanlator ?? "");
if (np !== -1 && (op === -1 || np < op)) seen.set(ch.chapterNumber, ch);
}
}
base = [...seen.values()].sort((a, b) => a.sourceOrder - b.sourceOrder);
}
return base;
}
+9 -13
View File
@@ -1,4 +1,5 @@
import { store } from "../store/state.svelte"; import { store } from "../store/state.svelte";
import { fetchAuthenticated } from "./auth";
const DEFAULT_URL = "http://127.0.0.1:4567"; const DEFAULT_URL = "http://127.0.0.1:4567";
@@ -7,23 +8,18 @@ function getServerUrl(): string {
return typeof url === "string" && url.trim() ? url.replace(/\/$/, "") : DEFAULT_URL; return typeof url === "string" && url.trim() ? url.replace(/\/$/, "") : DEFAULT_URL;
} }
function getAuthHeader(): Record<string, string> {
const s = store.settings;
if (!s.serverAuthEnabled) return {};
const user = typeof s.serverAuthUser === "string" ? s.serverAuthUser.trim() : "";
const pass = typeof s.serverAuthPass === "string" ? s.serverAuthPass.trim() : "";
if (user && pass) return { Authorization: `Basic ${btoa(`${user}:${pass}`)}` };
return {};
}
function gqlUrl(): string { return `${getServerUrl()}/api/graphql`; } function gqlUrl(): string { return `${getServerUrl()}/api/graphql`; }
export function thumbUrl(path: string): string { export function plainThumbUrl(path: string): string {
if (!path) return ""; if (!path) return "";
if (path.startsWith("http")) return path; if (path.startsWith("http")) return path;
return `${getServerUrl()}${path}`; return `${getServerUrl()}${path}`;
} }
export function thumbUrl(path: string): string {
return plainThumbUrl(path);
}
interface GQLResponse<T> { interface GQLResponse<T> {
data: T; data: T;
errors?: { message: string }[]; errors?: { message: string }[];
@@ -51,12 +47,12 @@ 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 fetch(url, { ...init, signal }); const res = await fetchAuthenticated(url, init, signal);
if (signal?.aborted) throw new DOMException("Aborted", "AbortError"); if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
return res; return res;
} catch (e: any) { } catch (e: any) {
if (e?.authRequired) throw e;
const isAbort = e?.name === "AbortError" || signal?.aborted; const isAbort = e?.name === "AbortError" || signal?.aborted;
if (isAbort) throw new DOMException("Aborted", "AbortError"); if (isAbort) throw new DOMException("Aborted", "AbortError");
if (i === retries - 1) throw e; if (i === retries - 1) throw e;
@@ -73,7 +69,7 @@ export async function gql<T>(
): Promise<T> { ): Promise<T> {
const res = await fetchWithRetry(gqlUrl(), { const res = await fetchWithRetry(gqlUrl(), {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json", ...getAuthHeader() }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ query, variables }), body: JSON.stringify({ query, variables }),
}, signal); }, signal);
+79
View File
@@ -0,0 +1,79 @@
import { connect, disconnect, setActivity, clearActivity } from "tauri-plugin-discord-rpc-api";
import { listen } from '@tauri-apps/api/event'
import type { Manga, Chapter } from './types'
const APP_ID = '1487894643613106298'
const FALLBACK_IMAGE = 'moku_logo'
let sessionStart: number | null = null
let unlisten: (() => void) | null = null
function isPublicUrl(url: string | null | undefined): boolean {
return typeof url === 'string' && url.startsWith('https://')
}
function resolveCoverImage(manga: Manga): string {
return isPublicUrl(manga.thumbnailUrl) ? manga.thumbnailUrl : FALLBACK_IMAGE
}
function trunc(s: string, max = 128): string {
return s.length <= max ? s : `${s.slice(0, max - 1)}`
}
function formatChapter(chapter: Chapter): string {
const n = chapter.chapterNumber
return `Chapter ${Number.isInteger(n) ? n : n.toFixed(1)}`
}
const BUTTONS = [
{ label: 'GitHub', url: 'https://github.com/Youwes09/Moku' },
{ label: 'Discord', url: 'https://discord.gg/Jq3pwuNqPp' },
]
export async function initRpc(): Promise<void> {
sessionStart = Date.now()
unlisten = await listen('discord-rpc://running', ({ payload }) => {
if (payload) setIdle().catch(() => {})
})
await connect(APP_ID).catch(() => {})
}
export async function setReading(manga: Manga, chapter: Chapter): Promise<void> {
await setActivity({
details: trunc(manga.title),
state: `${formatChapter(chapter)} · Reading`,
timestamps: { start: sessionStart ?? Date.now() },
assets: {
largeImage: resolveCoverImage(manga),
largeText: trunc(manga.title),
smallImage: FALLBACK_IMAGE,
smallText: 'Moku',
},
buttons: BUTTONS,
}).catch(() => {})
}
export async function setIdle(): Promise<void> {
await setActivity({
details: 'Browsing',
timestamps: { start: sessionStart ?? Date.now() },
assets: {
largeImage: FALLBACK_IMAGE,
largeText: 'Moku',
},
buttons: BUTTONS,
}).catch(() => {})
}
export async function clearReading(): Promise<void> {
await clearActivity().catch(() => {})
}
export async function destroyRpc(): Promise<void> {
unlisten?.()
unlisten = null
sessionStart = null
await disconnect().catch(() => {})
}
+91
View File
@@ -0,0 +1,91 @@
import { fetch as tauriFetch } from "@tauri-apps/plugin-http";
import { store } from "../store/state.svelte";
const cache = new Map<string, string>();
const inflight = new Map<string, Promise<string>>();
const MAX_CONCURRENT = 14;
let active = 0;
interface QueueEntry {
url: string;
priority: number;
resolve: (v: string) => void;
reject: (e: unknown) => void;
}
const queue: QueueEntry[] = [];
function getAuthHeaders(): Record<string, string> {
const user = store.settings.serverAuthUser?.trim() ?? "";
const pass = store.settings.serverAuthPass?.trim() ?? "";
return user && pass ? { Authorization: `Basic ${btoa(`${user}:${pass}`)}` } : {};
}
async function doFetch(url: string): Promise<string> {
const res = await tauriFetch(url, { method: "GET", headers: getAuthHeaders() });
if (!res.ok) throw new Error(`${res.status}`);
const blobUrl = URL.createObjectURL(await res.blob());
cache.set(url, blobUrl);
return blobUrl;
}
function drain() {
while (active < MAX_CONCURRENT && queue.length > 0) {
queue.sort((a, b) => b.priority - a.priority);
const entry = queue.shift()!;
active++;
doFetch(entry.url)
.then(entry.resolve, entry.reject)
.finally(() => {
inflight.delete(entry.url);
active--;
drain();
});
}
}
function enqueue(url: string, priority: number): Promise<string> {
const promise = new Promise<string>((resolve, reject) => {
queue.push({ url, priority, resolve, reject });
});
inflight.set(url, promise);
drain();
return promise;
}
export function getBlobUrl(url: string, priority = 0): Promise<string> {
if (!url) return Promise.resolve("");
const cached = cache.get(url);
if (cached) return Promise.resolve(cached);
const existing = inflight.get(url);
if (existing) {
const entry = queue.find(e => e.url === url);
if (entry && priority > entry.priority) entry.priority = priority;
return existing;
}
return enqueue(url, priority);
}
export function preloadBlobUrls(urls: string[], basePriority = 0): void {
urls.forEach((url, i) => {
if (!url || cache.has(url) || inflight.has(url)) return;
enqueue(url, basePriority - i);
});
}
export function revokeBlobUrl(url: string): void {
const blob = cache.get(url);
if (blob) {
URL.revokeObjectURL(blob);
cache.delete(url);
}
}
export function clearBlobCache(): void {
cache.forEach(blob => URL.revokeObjectURL(blob));
cache.clear();
}
+6
View File
@@ -12,6 +12,8 @@ export interface Keybinds {
togglePageStyle: string; togglePageStyle: string;
toggleFullscreen: string; toggleFullscreen: string;
openSettings: string; openSettings: string;
toggleBookmark: string;
toggleMarker: string;
} }
export const DEFAULT_KEYBINDS: Keybinds = { export const DEFAULT_KEYBINDS: Keybinds = {
@@ -26,6 +28,8 @@ export const DEFAULT_KEYBINDS: Keybinds = {
togglePageStyle: "q", togglePageStyle: "q",
toggleFullscreen: "f", toggleFullscreen: "f",
openSettings: "o", openSettings: "o",
toggleBookmark: "m",
toggleMarker: "n",
}; };
export const KEYBIND_LABELS: Record<keyof Keybinds, string> = { export const KEYBIND_LABELS: Record<keyof Keybinds, string> = {
@@ -40,6 +44,8 @@ export const KEYBIND_LABELS: Record<keyof Keybinds, string> = {
togglePageStyle: "Toggle page style", togglePageStyle: "Toggle page style",
toggleFullscreen: "Toggle fullscreen", toggleFullscreen: "Toggle fullscreen",
openSettings: "Open settings", openSettings: "Open settings",
toggleBookmark: "Toggle bookmark",
toggleMarker: "Toggle marker",
}; };
export function eventToKeybind(e: KeyboardEvent): string { export function eventToKeybind(e: KeyboardEvent): string {
+125 -2
View File
@@ -187,6 +187,112 @@ export const GET_DOWNLOADS_PATH = `
query GetDownloadsPath { query GetDownloadsPath {
settings { settings {
downloadsPath downloadsPath
localSourcePath
}
}
`;
export const SET_DOWNLOADS_PATH = `
mutation SetDownloadsPath($path: String!) {
setSettings(input: { settings: { downloadsPath: $path } }) {
settings { downloadsPath }
}
}
`;
export const SET_LOCAL_SOURCE_PATH = `
mutation SetLocalSourcePath($path: String!) {
setSettings(input: { settings: { localSourcePath: $path } }) {
settings { localSourcePath }
}
}
`;
// ── Categories ────────────────────────────────────────────────────────────────
export const GET_CATEGORIES = `
query GetCategories {
categories {
nodes {
id
name
order
default
includeInUpdate
includeInDownload
mangas {
nodes {
id
title
thumbnailUrl
inLibrary
downloadCount
unreadCount
}
}
}
}
}
`;
export const CREATE_CATEGORY = `
mutation CreateCategory($name: String!) {
createCategory(input: { name: $name }) {
category {
id
name
order
default
includeInUpdate
includeInDownload
}
}
}
`;
export const UPDATE_CATEGORY = `
mutation UpdateCategory($id: Int!, $name: String) {
updateCategory(input: { id: $id, patch: { name: $name } }) {
category {
id
name
order
}
}
}
`;
export const DELETE_CATEGORY = `
mutation DeleteCategory($id: Int!) {
deleteCategory(input: { categoryId: $id }) {
category {
id
}
}
}
`;
export const UPDATE_CATEGORY_ORDER = `
mutation UpdateCategoryOrder($id: Int!, $position: Int!) {
updateCategoryOrder(input: { id: $id, position: $position }) {
categories {
id
name
order
default
includeInUpdate
includeInDownload
}
}
}
`;
export const UPDATE_MANGA_CATEGORIES = `
mutation UpdateMangaCategories($mangaId: Int!, $addTo: [Int!]!, $removeFrom: [Int!]!) {
updateMangaCategories(input: { id: $mangaId, patch: { addToCategories: $addTo, removeFromCategories: $removeFrom } }) {
manga {
id
}
} }
} }
`; `;
@@ -335,8 +441,8 @@ export const GET_SOURCES = `
`; `;
export const FETCH_SOURCE_MANGA = ` export const FETCH_SOURCE_MANGA = `
mutation FetchSourceManga($source: LongString!, $type: FetchSourceMangaType!, $page: Int!, $query: String) { mutation FetchSourceManga($source: LongString!, $type: FetchSourceMangaType!, $page: Int!, $query: String, $filters: [FilterChangeInput!]) {
fetchSourceManga(input: { source: $source, type: $type, page: $page, query: $query }) { fetchSourceManga(input: { source: $source, type: $type, page: $page, query: $query, filters: $filters }) {
mangas { mangas {
id id
title title
@@ -782,3 +888,20 @@ export const LOGOUT_TRACKER = `
} }
} }
`; `;
export const LOGIN_USER = `
mutation Login($username: String!, $password: String!) {
login(input: { username: $username, password: $password }) {
accessToken
refreshToken
}
}
`;
export const REFRESH_TOKEN = `
mutation RefreshToken {
refreshToken {
accessToken
}
}
`;
+13
View File
@@ -1,3 +1,15 @@
export interface Category {
id: number;
name: string;
order: number;
default: boolean;
includeInUpdate: string;
includeInDownload: string;
mangas?: {
nodes: Manga[];
};
}
export interface Manga { export interface Manga {
id: number; id: number;
title: string; title: string;
@@ -5,6 +17,7 @@ export interface Manga {
inLibrary: boolean; inLibrary: boolean;
downloadCount?: number; downloadCount?: number;
unreadCount?: number; unreadCount?: number;
chapterCount?: number;
description?: string | null; description?: string | null;
status?: string | null; status?: string | null;
author?: string | null; author?: string | null;
+99
View File
@@ -5,6 +5,105 @@ export function cn(...inputs: ClassValue[]) {
return clsx(inputs); return clsx(inputs);
} }
// ── NSFW genre filtering ──────────────────────────────────────────────────────
/**
* Default substrings used when no user-configured list is available.
* The Settings > Content tab lets users add/remove entries from this list,
* which is stored as settings.nsfwFilteredTags.
*/
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",
];
/**
* Returns true if the manga carries at least one genre tag matching any of
* the provided substrings (case-insensitive). Pass settings.nsfwFilteredTags
* as the tag list; falls back to DEFAULT_NSFW_TAGS if omitted.
*/
export function isNsfwManga(
manga: { genre?: string[] | null },
tags: string[] = DEFAULT_NSFW_TAGS,
): boolean {
return (manga.genre ?? []).some((g) => {
const normalized = g.toLowerCase().trim();
return tags.some((sub) => normalized.includes(sub));
});
}
/**
* Single authoritative NSFW gate used by all views.
*
* Returns true when the manga should be HIDDEN. Checks in order:
* 1. showNsfw disabled globally skip everything, hide by source flag or genre match.
* 2. Source is in blockedSourceIds always hide regardless of showNsfw.
* 3. Source is in allowedSourceIds always show (bypasses isNsfw flag only, genre tags still apply).
* 4. Source isNsfw flag hide unless source is allowed.
* 5. Genre tag match hide.
*
* Usage: items.filter(m => !shouldHideNsfw(m, settings))
*/
export function shouldHideNsfw(
manga: {
genre?: string[] | null;
source?: { id?: string; isNsfw?: boolean } | null;
},
settings: {
showNsfw: boolean;
nsfwFilteredTags: string[];
nsfwAllowedSourceIds: string[];
nsfwBlockedSourceIds: string[];
},
): boolean {
const srcId = manga.source?.id;
// Explicit block always wins, even when showNsfw is on
if (srcId && settings.nsfwBlockedSourceIds.includes(srcId)) return true;
// If NSFW is globally allowed, only explicit blocks apply
if (settings.showNsfw) return false;
// Source is explicitly allowed — skip the isNsfw flag check, but still filter genres
const sourceAllowed = !!(srcId && settings.nsfwAllowedSourceIds.includes(srcId));
if (!sourceAllowed && manga.source?.isNsfw) return true;
return isNsfwManga(manga, settings.nsfwFilteredTags);
}
/**
* Gate for Source objects parallel to shouldHideNsfw for manga.
*
* Priority:
* 1. Blocked list always hidden, even when showNsfw is on.
* 2. Allowed list always shown, even if isNsfw is true.
* 3. Fallback hide when showNsfw is off and source.isNsfw is true.
*
* Usage: sources.filter(s => !shouldHideSource(s, settings))
*/
export function shouldHideSource(
source: { id: string; isNsfw: boolean },
settings: {
showNsfw: boolean;
nsfwAllowedSourceIds: string[];
nsfwBlockedSourceIds: string[];
},
): boolean {
if (settings.nsfwBlockedSourceIds.includes(source.id)) return true;
if (settings.nsfwAllowedSourceIds.includes(source.id)) return false;
return !settings.showNsfw && source.isNsfw;
}
// ── Source deduplication ────────────────────────────────────────────────────── // ── Source deduplication ──────────────────────────────────────────────────────
export function dedupeSources(sources: Source[], preferredLang: string): Source[] { export function dedupeSources(sources: Source[], preferredLang: string): Source[] {
+288 -253
View File
@@ -1,4 +1,4 @@
import type { Manga, Chapter, Source } from "../lib/types"; import type { Manga, Chapter, Category, Source } from "../lib/types";
import { DEFAULT_KEYBINDS, type Keybinds } from "../lib/keybinds"; import { DEFAULT_KEYBINDS, type Keybinds } from "../lib/keybinds";
export type PageStyle = "single" | "double" | "longstrip"; export type PageStyle = "single" | "double" | "longstrip";
@@ -8,35 +8,57 @@ export type NavPage = "home" | "library" | "sources" | "explore" | "dow
export type ReadingDirection = "ltr" | "rtl"; export type ReadingDirection = "ltr" | "rtl";
export type ChapterSortDir = "desc" | "asc"; export type ChapterSortDir = "desc" | "asc";
export type ChapterSortMode = "source" | "chapterNumber" | "uploadDate"; export type ChapterSortMode = "source" | "chapterNumber" | "uploadDate";
export type LibrarySortMode =
| "az"
| "unreadCount"
| "totalChapters"
| "recentlyAdded"
| "recentlyRead"
| "latestFetched"
| "latestUploaded";
export type LibrarySortDir = "asc" | "desc";
export type LibraryStatusFilter =
| "ALL"
| "ONGOING"
| "COMPLETED"
| "CANCELLED"
| "HIATUS"
| "UNKNOWN";
export type LibraryContentFilter =
| "unread"
| "started"
| "downloaded"
| "bookmarked"
| "marked";
export type BuiltinTheme = "dark" | "high-contrast" | "light" | "light-contrast" | "midnight" | "warm"; export type BuiltinTheme = "dark" | "high-contrast" | "light" | "light-contrast" | "midnight" | "warm";
export type Theme = BuiltinTheme | string; // custom themes have string IDs like "custom:abc123" export type Theme = BuiltinTheme | string;
export interface ThemeTokens { export interface ThemeTokens {
/* Backgrounds */
"bg-void": string; "bg-void": string;
"bg-base": string; "bg-base": string;
"bg-surface": string; "bg-surface": string;
"bg-raised": string; "bg-raised": string;
"bg-overlay": string; "bg-overlay": string;
"bg-subtle": string; "bg-subtle": string;
/* Borders */
"border-dim": string; "border-dim": string;
"border-base": string; "border-base": string;
"border-strong": string; "border-strong": string;
"border-focus": string; "border-focus": string;
/* Text */
"text-primary": string; "text-primary": string;
"text-secondary": string; "text-secondary": string;
"text-muted": string; "text-muted": string;
"text-faint": string; "text-faint": string;
"text-disabled": string; "text-disabled": string;
/* Accent */
"accent": string; "accent": string;
"accent-dim": string; "accent-dim": string;
"accent-muted": string; "accent-muted": string;
"accent-fg": string; "accent-fg": string;
"accent-bright": string; "accent-bright": string;
/* Semantic */
"color-error": string; "color-error": string;
"color-error-bg": string; "color-error-bg": string;
"color-success": string; "color-success": string;
@@ -45,7 +67,7 @@ export interface ThemeTokens {
} }
export interface CustomTheme { export interface CustomTheme {
id: string; // "custom:abc123" id: string;
name: string; name: string;
tokens: ThemeTokens; tokens: ThemeTokens;
} }
@@ -78,29 +100,46 @@ export const DEFAULT_THEME_TOKENS: ThemeTokens = {
"color-info-bg": "#121a1f", "color-info-bg": "#121a1f",
}; };
export const COMPLETED_FOLDER_ID = "completed";
export interface HistoryEntry { export interface HistoryEntry {
mangaId: number; mangaId: number;
mangaTitle: string; mangaTitle: string;
thumbnailUrl: string; thumbnailUrl: string;
chapterId: number; chapterId: number;
chapterName: string; chapterName: string;
pageNumber: number;
readAt: number; readAt: number;
} }
/** export interface BookmarkEntry {
* ReadLogEntry append-only record of every chapter-completion event. mangaId: number;
* Unlike HistoryEntry (which dedupes per chapter for the "continue" UI), mangaTitle: string;
* this log never overwrites existing entries. It is the source of truth thumbnailUrl: string;
* for all reading stats. chapterId: number;
*/ chapterName: string;
pageNumber: number;
savedAt: number;
label?: string;
}
export type MarkerColor = "yellow" | "red" | "blue" | "green" | "purple";
export interface MarkerEntry {
id: string;
mangaId: number;
mangaTitle: string;
thumbnailUrl: string;
chapterId: number;
chapterName: string;
pageNumber: number;
note: string;
color: MarkerColor;
createdAt: number;
updatedAt?: number;
}
export interface ReadLogEntry { export interface ReadLogEntry {
mangaId: number; mangaId: number;
chapterId: number; chapterId: number;
readAt: number; readAt: number;
/** Minutes spent on this chapter (estimated from page count or default). */
minutes: number; minutes: number;
} }
@@ -115,7 +154,7 @@ export interface ReadingStats {
lastStreakDate: string; lastStreakDate: string;
} }
const AVG_MIN_PER_CHAPTER = 5; // fallback when no page count is available const AVG_MIN_PER_CHAPTER = 5;
export const DEFAULT_READING_STATS: ReadingStats = { export const DEFAULT_READING_STATS: ReadingStats = {
totalChaptersRead: 0, totalChaptersRead: 0,
@@ -142,19 +181,35 @@ export interface ActiveDownload {
progress: number; progress: number;
} }
export interface Folder { export interface MangaPrefs {
id: string; autoDownload: boolean;
name: string; downloadAhead: number;
mangaIds: number[]; deleteOnRead: boolean;
showTab: boolean; deleteDelayHours: number;
system?: boolean; maxKeepChapters: number;
pauseUpdates: boolean;
refreshInterval: "global" | "daily" | "weekly" | "manual";
preferredScanlator: string;
scanlatorFilter: string[];
} }
export const DEFAULT_MANGA_PREFS: MangaPrefs = {
autoDownload: false,
downloadAhead: 0,
deleteOnRead: false,
deleteDelayHours: 0,
maxKeepChapters: 0,
pauseUpdates: false,
refreshInterval: "global",
preferredScanlator: "",
scanlatorFilter: [],
};
export interface Settings { export interface Settings {
pageStyle: PageStyle; pageStyle: PageStyle;
readingDirection: ReadingDirection; readingDirection: ReadingDirection;
fitMode: FitMode; fitMode: FitMode;
maxPageWidth: number; readerZoom: number;
pageGap: boolean; pageGap: boolean;
optimizeContrast: boolean; optimizeContrast: boolean;
offsetDoubleSpreads: boolean; offsetDoubleSpreads: boolean;
@@ -164,10 +219,11 @@ export interface Settings {
libraryCropCovers: boolean; libraryCropCovers: boolean;
libraryPageSize: number; libraryPageSize: number;
showNsfw: boolean; showNsfw: boolean;
discordRpc: boolean;
chapterSortDir: ChapterSortDir; chapterSortDir: ChapterSortDir;
chapterSortMode: ChapterSortMode; chapterSortMode: ChapterSortMode;
chapterPageSize: number; chapterPageSize: number;
uiScale: number; uiZoom: number;
compactSidebar: boolean; compactSidebar: boolean;
gpuAcceleration: boolean; gpuAcceleration: boolean;
serverUrl: string; serverUrl: string;
@@ -178,17 +234,18 @@ export interface Settings {
idleTimeoutMin?: number; idleTimeoutMin?: number;
splashCards?: boolean; splashCards?: boolean;
storageLimitGb: number | null; storageLimitGb: number | null;
folders: Folder[];
markReadOnNext: boolean; markReadOnNext: boolean;
readerDebounceMs: number; readerDebounceMs: number;
autoBookmark: boolean;
theme: Theme; theme: Theme;
libraryBranches: boolean; libraryBranches: boolean;
renderLimit: number; renderLimit: number;
heroSlots: (number | null)[]; heroSlots: (number | null)[];
mangaLinks: Record<number, number[]>; mangaLinks: Record<number, number[]>;
mangaPrefs: Record<number, Partial<MangaPrefs>>;
serverAuthUser: string; serverAuthUser: string;
serverAuthPass: string; serverAuthPass: string;
serverAuthEnabled: boolean; serverAuthMode: "NONE" | "BASIC_AUTH" | "SIMPLE_LOGIN" | "UI_LOGIN";
socksProxyEnabled: boolean; socksProxyEnabled: boolean;
socksProxyHost: string; socksProxyHost: string;
socksProxyPort: string; socksProxyPort: string;
@@ -204,21 +261,26 @@ export interface Settings {
appLockEnabled: boolean; appLockEnabled: boolean;
appLockPin: string; appLockPin: string;
customThemes: CustomTheme[]; customThemes: CustomTheme[];
hiddenCategoryIds: number[];
defaultLibraryCategoryId: number | null;
nsfwFilteredTags: string[];
nsfwAllowedSourceIds: string[];
nsfwBlockedSourceIds: string[];
libraryTabSort: Record<string, { mode: LibrarySortMode; dir: LibrarySortDir }>;
libraryTabStatus: Record<string, LibraryStatusFilter>;
libraryTabFilters: Record<string, Partial<Record<LibraryContentFilter, boolean>>>;
maxPageWidth?: number;
uiScale?: number;
extraScanDirs: string[];
serverDownloadsPath: string;
serverLocalSourcePath: string;
} }
const COMPLETED_FOLDER_DEFAULT: Folder = {
id: COMPLETED_FOLDER_ID,
name: "Completed",
mangaIds: [],
showTab: true,
system: true,
};
export const DEFAULT_SETTINGS: Settings = { export const DEFAULT_SETTINGS: Settings = {
pageStyle: "longstrip", pageStyle: "longstrip",
readingDirection: "ltr", readingDirection: "ltr",
fitMode: "width", fitMode: "width",
maxPageWidth: 900, readerZoom: 1.0,
pageGap: true, pageGap: true,
optimizeContrast: false, optimizeContrast: false,
offsetDoubleSpreads: false, offsetDoubleSpreads: false,
@@ -228,10 +290,11 @@ export const DEFAULT_SETTINGS: Settings = {
libraryCropCovers: true, libraryCropCovers: true,
libraryPageSize: 48, libraryPageSize: 48,
showNsfw: false, showNsfw: false,
discordRpc: false,
chapterSortDir: "desc", chapterSortDir: "desc",
chapterSortMode: "source", chapterSortMode: "source",
chapterPageSize: 25, chapterPageSize: 25,
uiScale: 100, uiZoom: 1.0,
compactSidebar: false, compactSidebar: false,
gpuAcceleration: true, gpuAcceleration: true,
serverUrl: "http://localhost:4567", serverUrl: "http://localhost:4567",
@@ -242,17 +305,18 @@ export const DEFAULT_SETTINGS: Settings = {
idleTimeoutMin: 5, idleTimeoutMin: 5,
splashCards: true, splashCards: true,
storageLimitGb: null, storageLimitGb: null,
folders: [COMPLETED_FOLDER_DEFAULT],
markReadOnNext: true, markReadOnNext: true,
readerDebounceMs: 120, readerDebounceMs: 120,
autoBookmark: true,
theme: "dark", theme: "dark",
libraryBranches: true, libraryBranches: true,
renderLimit: 48, renderLimit: 48,
heroSlots: [null, null, null, null], heroSlots: [null, null, null, null],
mangaLinks: {}, mangaLinks: {},
mangaPrefs: {},
serverAuthUser: "", serverAuthUser: "",
serverAuthPass: "", serverAuthPass: "",
serverAuthEnabled: false, serverAuthMode: "NONE",
socksProxyEnabled: false, socksProxyEnabled: false,
socksProxyHost: "", socksProxyHost: "",
socksProxyPort: "1080", socksProxyPort: "1080",
@@ -268,16 +332,25 @@ export const DEFAULT_SETTINGS: Settings = {
appLockEnabled: false, appLockEnabled: false,
appLockPin: "", appLockPin: "",
customThemes: [], customThemes: [],
hiddenCategoryIds: [],
defaultLibraryCategoryId: null,
nsfwFilteredTags: ["adult", "mature", "hentai", "ecchi", "erotic", "pornograph", "18+", "smut", "lemon", "explicit", "sexual violence"],
nsfwAllowedSourceIds: [],
nsfwBlockedSourceIds: [],
libraryTabSort: {},
libraryTabStatus: {},
libraryTabFilters: {},
extraScanDirs: [],
serverDownloadsPath: "",
serverLocalSourcePath: "",
}; };
// ── Persistence ─────────────────────────────────────────────────────────────── const STORE_VERSION = 3;
const STORE_VERSION = 2;
// Fields reset to their DEFAULT_SETTINGS value on each version bump.
// Add a key here whenever its default changes meaning between releases.
const RESET_ON_UPGRADE: (keyof Settings)[] = [ const RESET_ON_UPGRADE: (keyof Settings)[] = [
"serverBinary", "serverBinary",
"readerZoom",
"uiZoom",
]; ];
function loadPersisted(): any { function loadPersisted(): any {
@@ -318,177 +391,170 @@ const saved = (() => {
})(); })();
function mergeSettings(saved: any): Settings { function mergeSettings(saved: any): Settings {
const userFolders: Folder[] = saved?.settings?.folders ?? [];
const existingCompleted = userFolders.find(f => f.id === COMPLETED_FOLDER_ID);
const completedFolder: Folder = existingCompleted
? { ...COMPLETED_FOLDER_DEFAULT, mangaIds: existingCompleted.mangaIds }
: COMPLETED_FOLDER_DEFAULT;
const otherFolders = userFolders.filter(f => f.id !== COMPLETED_FOLDER_ID);
return { return {
...DEFAULT_SETTINGS, ...DEFAULT_SETTINGS,
...saved?.settings, ...saved?.settings,
folders: [completedFolder, ...otherFolders],
keybinds: { ...DEFAULT_KEYBINDS, ...saved?.settings?.keybinds }, keybinds: { ...DEFAULT_KEYBINDS, ...saved?.settings?.keybinds },
heroSlots: saved?.settings?.heroSlots ?? [null, null, null, null], heroSlots: saved?.settings?.heroSlots ?? [null, null, null, null],
mangaLinks: saved?.settings?.mangaLinks ?? {}, mangaLinks: saved?.settings?.mangaLinks ?? {},
mangaPrefs: saved?.settings?.mangaPrefs ?? {},
customThemes: saved?.settings?.customThemes ?? [], customThemes: saved?.settings?.customThemes ?? [],
hiddenCategoryIds: saved?.settings?.hiddenCategoryIds ?? [],
nsfwFilteredTags: saved?.settings?.nsfwFilteredTags ?? DEFAULT_SETTINGS.nsfwFilteredTags,
nsfwAllowedSourceIds: saved?.settings?.nsfwAllowedSourceIds ?? [],
nsfwBlockedSourceIds: saved?.settings?.nsfwBlockedSourceIds ?? [],
libraryTabSort: saved?.settings?.libraryTabSort ?? {},
libraryTabStatus: saved?.settings?.libraryTabStatus ?? {},
libraryTabFilters: saved?.settings?.libraryTabFilters ?? {},
extraScanDirs: saved?.settings?.extraScanDirs ?? [],
}; };
} }
function mergeStats(saved: any): ReadingStats {
return { ...DEFAULT_READING_STATS, ...saved?.readingStats };
}
function todayStr(): string {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
}
const genId = () => Math.random().toString(36).slice(2, 10);
// ── Store ─────────────────────────────────────────────────────────────────────
class Store { class Store {
navPage: NavPage = $state(saved?.navPage ?? "home");
libraryFilter: LibraryFilter = $state(saved?.libraryFilter ?? "library");
history: HistoryEntry[] = $state(saved?.history ?? []);
/**
* readLog append-only, never deduped. Every chapter completion/progress
* event lands here. This is the authoritative source for all reading stats.
* Capped at 5 000 entries; oldest are trimmed first.
*/
readLog: ReadLogEntry[] = $state(saved?.readLog ?? []);
readingStats: ReadingStats = $state(mergeStats(saved));
settings: Settings = $state(mergeSettings(saved)); settings: Settings = $state(mergeSettings(saved));
/**
* Bumped each time the reader closes. Home.svelte watches this to know
* when to re-fetch library data and refresh the hero section.
*/
readerSessionId: number = $state(0);
genreFilter: string = $state("");
searchPrefill: string = $state("");
activeManga: Manga | null = $state(null); activeManga: Manga | null = $state(null);
previewManga: Manga | null = $state(null); previewManga: Manga | null = $state(null);
activeSource: Source | null = $state(null);
pageUrls: string[] = $state([]);
pageNumber: number = $state(1);
libraryTagFilter: string[] = $state([]);
settingsOpen: boolean = $state(false);
activeDownloads: ActiveDownload[] = $state([]);
toasts: Toast[] = $state([]);
activeChapter: Chapter | null = $state(null); activeChapter: Chapter | null = $state(null);
activeChapterList: Chapter[] = $state([]); activeChapterList: Chapter[] = $state([]);
// UI-only: synced from Tauri window events in App.svelte. Not persisted. pageUrls: string[] = $state([]);
isFullscreen: boolean = $state(false); pageNumber: number = $state(1);
navPage: NavPage = $state("home");
// ── Discover session cache ──────────────────────────────────────────────── libraryFilter: LibraryFilter = $state("all");
// Survives navigation within a session but is never persisted to localStorage. genreFilter: string = $state("");
// Key format: "<sourceId>|<type>|<genre>" or "local|<genre>" searchPrefill: string = $state("");
discoverCache: Map<string, Manga[]> = $state(new Map()); toasts: Toast[] = $state([]);
categories: Category[] = $state([]);
activeDownloads: ActiveDownload[] = $state([]);
activeSource: Source | null = $state(null);
libraryTagFilter: string[] = $state([]);
settingsOpen: boolean = $state(false);
history: HistoryEntry[] = $state(saved?.history ?? []);
bookmarks: BookmarkEntry[] = $state(saved?.bookmarks ?? []);
markers: MarkerEntry[] = $state(saved?.markers ?? []);
readLog: ReadLogEntry[] = $state(saved?.readLog ?? []);
readingStats: ReadingStats = $state(saved?.readingStats ?? { ...DEFAULT_READING_STATS });
discoverCache: Map<string, any> = $state(new Map());
discoverLibraryIds: Set<number> = $state(new Set()); discoverLibraryIds: Set<number> = $state(new Set());
discoverSrcOffset: number = $state(0); discoverSrcOffset: number = $state(0);
constructor() { constructor() {
$effect.root(() => { $effect.root(() => {
$effect(() => { persist({ storeVersion: STORE_VERSION }); }); $effect(() => {
$effect(() => { persist({ navPage: this.navPage }); }); persist({
$effect(() => { persist({ libraryFilter: this.libraryFilter }); }); settings: this.settings,
$effect(() => { persist({ history: this.history }); }); history: this.history,
$effect(() => { persist({ readLog: this.readLog }); }); bookmarks: this.bookmarks,
$effect(() => { persist({ readingStats: this.readingStats }); }); markers: this.markers,
$effect(() => { persist({ settings: this.settings }); }); readLog: this.readLog,
readingStats: this.readingStats,
storeVersion: STORE_VERSION,
});
});
}); });
} }
openReader(chapter: Chapter, chapterList: Chapter[]) { openReader(chapter: Chapter, chapterList: Chapter[], manga?: Manga | null) {
this.activeChapter = chapter; this.activeChapter = chapter;
this.activeChapterList = chapterList; this.activeChapterList = chapterList;
this.pageUrls = []; if (manga !== undefined) this.activeManga = manga;
this.pageNumber = 1;
} }
closeReader() { closeReader() {
// Null activeChapter FIRST so the history $effect in Reader can't fire
// one last time with stale chapter + pageNumber=1, overwriting the real
// last-read position with page 1.
this.activeChapter = null; this.activeChapter = null;
this.activeChapterList = []; this.activeChapterList = [];
this.pageUrls = []; this.pageUrls = [];
this.pageNumber = 1; this.pageNumber = 1;
this.readerSessionId += 1; // signals Home to refresh
} }
/** addHistory(entry: HistoryEntry, completed = false, minutes?: number) {
* Record a reading event. const filtered = this.history.filter(h => h.chapterId !== entry.chapterId);
* this.history = [entry, ...filtered].slice(0, 500);
* @param entry - The history entry for the "continue reading" UI.
* @param completed - True when the chapter was fully read (triggers stat
* accrual). False for mid-chapter progress updates.
* @param minutes - Actual minutes to credit; defaults to AVG_MIN_PER_CHAPTER.
*/
addHistory(entry: HistoryEntry, completed = false, minutes = AVG_MIN_PER_CHAPTER) {
// ── 1. Update the deduped "continue reading" history ──────────────────
// Always keep the latest position for each chapter at the top.
if (this.history[0]?.chapterId === entry.chapterId) {
this.history[0] = { ...this.history[0], pageNumber: entry.pageNumber, readAt: entry.readAt };
} else {
this.history = [entry, ...this.history.filter(x => x.chapterId !== entry.chapterId)].slice(0, 300);
}
// ── 2. Append to the read log (only on completion) ────────────────────
// This is append-only — every completed chapter read lands here,
// including re-reads. We cap at 5 000 to keep storage bounded.
if (completed) { if (completed) {
const logEntry: ReadLogEntry = { const existing = this.readLog.find(e => e.chapterId === entry.chapterId);
mangaId: entry.mangaId, if (!existing) {
chapterId: entry.chapterId, const mins = minutes ?? AVG_MIN_PER_CHAPTER;
readAt: entry.readAt, this.readLog = [...this.readLog, { mangaId: entry.mangaId, chapterId: entry.chapterId, readAt: entry.readAt, minutes: mins }];
minutes, const uniqueChapters = new Set(this.readLog.map(e => e.chapterId));
}; const uniqueManga = new Set(this.readLog.map(e => e.mangaId));
this.readLog = [...this.readLog, logEntry].slice(-5000); const totalMinutes = this.readLog.reduce((sum, e) => sum + e.minutes, 0);
const now = new Date();
const todayStr = now.toISOString().slice(0, 10);
const lastDate = this.readingStats.lastStreakDate;
const yesterday = new Date(now); yesterday.setDate(yesterday.getDate() - 1);
const yesterdayStr = yesterday.toISOString().slice(0, 10);
let streak = this.readingStats.currentStreakDays;
if (lastDate === todayStr) {
} else if (lastDate === yesterdayStr) {
streak++;
} else {
streak = 1;
} }
const longest = Math.max(this.readingStats.longestStreakDays, streak);
// ── 3. Recompute stats from the read log ──────────────────────────────
// Use the log as ground truth so stats are always accurate even after
// history is cleared or entries are back-filled.
const log = completed
? [...this.readLog] // already updated above
: this.readLog;
const uniqueChapters = new Set(log.map(e => e.chapterId));
const uniqueManga = new Set(log.map(e => e.mangaId));
const totalMinutes = log.reduce((sum, e) => sum + e.minutes, 0);
const today = todayStr();
let { currentStreakDays, longestStreakDays, lastStreakDate } = this.readingStats;
if (lastStreakDate !== today) {
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
const yStr = `${yesterday.getFullYear()}-${String(yesterday.getMonth() + 1).padStart(2, "0")}-${String(yesterday.getDate()).padStart(2, "0")}`;
currentStreakDays = lastStreakDate === yStr ? currentStreakDays + 1 : 1;
longestStreakDays = Math.max(longestStreakDays, currentStreakDays);
lastStreakDate = today;
}
this.readingStats = { this.readingStats = {
totalChaptersRead: uniqueChapters.size, totalChaptersRead: uniqueChapters.size,
totalMangaRead: uniqueManga.size, totalMangaRead: uniqueManga.size,
totalMinutesRead: totalMinutes, totalMinutesRead: totalMinutes,
firstReadAt: this.readingStats.firstReadAt === 0 ? entry.readAt : this.readingStats.firstReadAt, firstReadAt: this.readingStats.firstReadAt || entry.readAt,
lastReadAt: entry.readAt, lastReadAt: entry.readAt,
currentStreakDays, currentStreakDays: streak,
longestStreakDays, longestStreakDays: longest,
lastStreakDate, lastStreakDate: todayStr,
}; };
} }
}
}
addBookmark(entry: Omit<BookmarkEntry, "savedAt">, label?: string) {
const filtered = this.bookmarks.filter(b => b.chapterId !== entry.chapterId);
this.bookmarks = [{ ...entry, savedAt: Date.now(), label }, ...filtered].slice(0, 200);
}
removeBookmark(chapterId: number) {
this.bookmarks = this.bookmarks.filter(b => b.chapterId !== chapterId);
}
clearBookmarks() { this.bookmarks = []; }
getBookmark(chapterId: number): BookmarkEntry | undefined {
return this.bookmarks.find(b => b.chapterId === chapterId);
}
addMarker(entry: Omit<MarkerEntry, "id" | "createdAt">): string {
const id = Math.random().toString(36).slice(2);
this.markers = [...this.markers, { ...entry, id, createdAt: Date.now() }];
return id;
}
updateMarker(id: string, patch: Partial<Pick<MarkerEntry, "note" | "color">>) {
this.markers = this.markers.map(m => m.id === id ? { ...m, ...patch, updatedAt: Date.now() } : m);
}
removeMarker(id: string) {
this.markers = this.markers.filter(m => m.id !== id);
}
getMarkersForPage(chapterId: number, page: number): MarkerEntry[] {
return this.markers.filter(m => m.chapterId === chapterId && m.pageNumber === page);
}
getMarkersForChapter(chapterId: number): MarkerEntry[] {
return this.markers.filter(m => m.chapterId === chapterId);
}
getMarkersForManga(mangaId: number): MarkerEntry[] {
return this.markers.filter(m => m.mangaId === mangaId);
}
clearMarkersForManga(mangaId: number) {
this.markers = this.markers.filter(m => m.mangaId !== mangaId);
}
clearHistory() { this.history = []; this.readLog = []; } clearHistory() { this.history = []; this.readLog = []; }
clearHistoryForManga(mangaId: number) { clearHistoryForManga(mangaId: number) {
this.history = this.history.filter(x => x.mangaId !== mangaId); this.history = this.history.filter(x => x.mangaId !== mangaId);
this.readLog = this.readLog.filter(x => x.mangaId !== mangaId); this.readLog = this.readLog.filter(x => x.mangaId !== mangaId);
// Recompute stats after removal
const uniqueChapters = new Set(this.readLog.map(e => e.chapterId)); const uniqueChapters = new Set(this.readLog.map(e => e.chapterId));
const uniqueManga = new Set(this.readLog.map(e => e.mangaId)); const uniqueManga = new Set(this.readLog.map(e => e.mangaId));
const totalMinutes = this.readLog.reduce((sum, e) => sum + e.minutes, 0); const totalMinutes = this.readLog.reduce((sum, e) => sum + e.minutes, 0);
@@ -503,31 +569,9 @@ class Store {
wipeAllData() { wipeAllData() {
this.history = []; this.history = [];
this.readLog = []; this.readLog = [];
this.markers = [];
this.readingStats = { ...DEFAULT_READING_STATS }; this.readingStats = { ...DEFAULT_READING_STATS };
this.settings = { ...this.settings, folders: [COMPLETED_FOLDER_DEFAULT], heroSlots: [null, null, null, null], mangaLinks: {} }; this.settings = { ...this.settings, heroSlots: [null, null, null, null], mangaLinks: {} };
}
markMangaCompleted(mangaId: number) {
const folder = this.settings.folders.find(f => f.id === COMPLETED_FOLDER_ID);
if (!folder) return;
if (!folder.mangaIds.includes(mangaId))
folder.mangaIds = [...folder.mangaIds, mangaId];
}
unmarkMangaCompleted(mangaId: number) {
const folder = this.settings.folders.find(f => f.id === COMPLETED_FOLDER_ID);
if (!folder) return;
folder.mangaIds = folder.mangaIds.filter(id => id !== mangaId);
}
isCompleted(mangaId: number): boolean {
return this.settings.folders.find(f => f.id === COMPLETED_FOLDER_ID)?.mangaIds.includes(mangaId) ?? false;
}
checkAndMarkCompleted(mangaId: number, chapters: Chapter[]) {
if (!chapters.length) return;
if (chapters.every(c => c.isRead)) this.markMangaCompleted(mangaId);
else this.unmarkMangaCompleted(mangaId);
} }
linkManga(idA: number, idB: number) { linkManga(idA: number, idB: number) {
@@ -560,6 +604,7 @@ class Store {
} }
dismissToast(id: string) { this.toasts = this.toasts.filter(x => x.id !== id); } dismissToast(id: string) { this.toasts = this.toasts.filter(x => x.id !== id); }
setCategories(cats: Category[]) { this.categories = cats; }
setActiveDownloads(next: ActiveDownload[]) { this.activeDownloads = next; } setActiveDownloads(next: ActiveDownload[]) { this.activeDownloads = next; }
setNavPage(next: NavPage) { this.navPage = next; } setNavPage(next: NavPage) { this.navPage = next; }
setLibraryFilter(next: LibraryFilter) { this.libraryFilter = next; } setLibraryFilter(next: LibraryFilter) { this.libraryFilter = next; }
@@ -575,54 +620,6 @@ class Store {
updateSettings(patch: Partial<Settings>) { this.settings = { ...this.settings, ...patch }; } updateSettings(patch: Partial<Settings>) { this.settings = { ...this.settings, ...patch }; }
resetKeybinds() { this.settings = { ...this.settings, keybinds: DEFAULT_KEYBINDS }; } resetKeybinds() { this.settings = { ...this.settings, keybinds: DEFAULT_KEYBINDS }; }
addFolder(name: string): string {
const id = genId();
this.settings = { ...this.settings, folders: [...this.settings.folders, { id, name: name.trim(), mangaIds: [], showTab: false }] };
return id;
}
removeFolder(id: string) {
this.settings = { ...this.settings, folders: this.settings.folders.filter(f => f.id !== id || f.system) };
}
renameFolder(id: string, name: string) {
this.settings = {
...this.settings,
folders: this.settings.folders.map(f => f.id === id && !f.system ? { ...f, name: name.trim() } : f),
};
}
toggleFolderTab(id: string) {
this.settings = {
...this.settings,
folders: this.settings.folders.map(f => f.id === id ? { ...f, showTab: !f.showTab } : f),
};
}
assignMangaToFolder(folderId: string, mangaId: number) {
this.settings = {
...this.settings,
folders: this.settings.folders.map(f =>
f.id === folderId && !f.mangaIds.includes(mangaId)
? { ...f, mangaIds: [...f.mangaIds, mangaId] }
: f
),
};
}
removeMangaFromFolder(folderId: string, mangaId: number) {
this.settings = {
...this.settings,
folders: this.settings.folders.map(f =>
f.id === folderId ? { ...f, mangaIds: f.mangaIds.filter(id => id !== mangaId) } : f
),
};
}
getMangaFolders(mangaId: number): Folder[] {
return this.settings.folders.filter(f => f.mangaIds.includes(mangaId));
}
saveCustomTheme(theme: CustomTheme) { saveCustomTheme(theme: CustomTheme) {
const existing = this.settings.customThemes.findIndex(t => t.id === theme.id); const existing = this.settings.customThemes.findIndex(t => t.id === theme.id);
const next = existing >= 0 const next = existing >= 0
@@ -637,6 +634,34 @@ class Store {
this.settings = { ...this.settings, customThemes: next, theme: wasActive ? "dark" : this.settings.theme }; this.settings = { ...this.settings, customThemes: next, theme: wasActive ? "dark" : this.settings.theme };
} }
async checkAndMarkCompleted(
mangaId: number,
chaps: Chapter[],
categories: Category[],
gqlFn: (query: string, vars: Record<string, unknown>) => Promise<unknown>,
UPDATE_MANGA_CATEGORIES: string,
UPDATE_MANGA?: string,
): Promise<void> {
if (!chaps.length) return;
const allRead = chaps.every(c => c.isRead);
const completed = categories.find(c => c.name === "Completed");
if (!completed) return;
if (allRead) {
await gqlFn(UPDATE_MANGA_CATEGORIES, { mangaId, addTo: [completed.id], removeFrom: [] }).catch(console.error);
if (UPDATE_MANGA) {
await gqlFn(UPDATE_MANGA, { id: mangaId, inLibrary: true }).catch(console.error);
}
} else {
await gqlFn(UPDATE_MANGA_CATEGORIES, { mangaId, addTo: [], removeFrom: [completed.id] }).catch(console.error);
}
}
toggleHiddenCategory(id: number) {
const ids = this.settings.hiddenCategoryIds ?? [];
const next = ids.includes(id) ? ids.filter(x => x !== id) : [...ids, id];
this.settings = { ...this.settings, hiddenCategoryIds: next };
}
clearDiscoverCache() { clearDiscoverCache() {
this.discoverCache = new Map(); this.discoverCache = new Map();
this.discoverLibraryIds = new Set(); this.discoverLibraryIds = new Set();
@@ -646,24 +671,19 @@ class Store {
export const store = new Store(); export const store = new Store();
// ── Function re-exports — zero call-site changes for actions ────────────────── export function openReader(chapter: Chapter, chapterList: Chapter[], manga?: Manga | null) { store.openReader(chapter, chapterList, manga); }
export function openReader(chapter: Chapter, chapterList: Chapter[]) { store.openReader(chapter, chapterList); }
export function closeReader() { store.closeReader(); } export function closeReader() { store.closeReader(); }
export function addHistory(entry: HistoryEntry, completed?: boolean, minutes?: number) { store.addHistory(entry, completed, minutes); } export function addHistory(entry: HistoryEntry, completed?: boolean, minutes?: number) { store.addHistory(entry, completed, minutes); }
export function clearHistory() { store.clearHistory(); } export function clearHistory() { store.clearHistory(); }
export function clearHistoryForManga(mangaId: number) { store.clearHistoryForManga(mangaId); } export function clearHistoryForManga(mangaId: number) { store.clearHistoryForManga(mangaId); }
export function wipeAllData() { store.wipeAllData(); } export function wipeAllData() { store.wipeAllData(); }
export function markMangaCompleted(mangaId: number) { store.markMangaCompleted(mangaId); }
export function unmarkMangaCompleted(mangaId: number) { store.unmarkMangaCompleted(mangaId); }
export function isCompleted(mangaId: number) { return store.isCompleted(mangaId); }
export function checkAndMarkCompleted(mangaId: number, c: Chapter[]) { store.checkAndMarkCompleted(mangaId, c); }
export function linkManga(idA: number, idB: number) { store.linkManga(idA, idB); } export function linkManga(idA: number, idB: number) { store.linkManga(idA, idB); }
export function unlinkManga(idA: number, idB: number) { store.unlinkManga(idA, idB); } export function unlinkManga(idA: number, idB: number) { store.unlinkManga(idA, idB); }
export function getLinkedMangaIds(mangaId: number) { return store.getLinkedMangaIds(mangaId); } export function getLinkedMangaIds(mangaId: number) { return store.getLinkedMangaIds(mangaId); }
export function setHeroSlot(i: 1|2|3, mangaId: number | null) { store.setHeroSlot(i, mangaId); } export function setHeroSlot(i: 1|2|3, mangaId: number | null) { store.setHeroSlot(i, mangaId); }
export function addToast(toast: Omit<Toast, "id">) { store.addToast(toast); } export function addToast(toast: Omit<Toast, "id">) { store.addToast(toast); }
export function dismissToast(id: string) { store.dismissToast(id); } export function dismissToast(id: string) { store.dismissToast(id); }
export function setCategories(cats: Category[]) { store.setCategories(cats); }
export function setActiveDownloads(next: ActiveDownload[]) { store.setActiveDownloads(next); } export function setActiveDownloads(next: ActiveDownload[]) { store.setActiveDownloads(next); }
export function setNavPage(next: NavPage) { store.setNavPage(next); } export function setNavPage(next: NavPage) { store.setNavPage(next); }
export function setLibraryFilter(next: LibraryFilter) { store.setLibraryFilter(next); } export function setLibraryFilter(next: LibraryFilter) { store.setLibraryFilter(next); }
@@ -678,13 +698,28 @@ export function setLibraryTagFilter(next: string[]) { store
export function setSettingsOpen(next: boolean) { store.setSettingsOpen(next); } export function setSettingsOpen(next: boolean) { store.setSettingsOpen(next); }
export function updateSettings(patch: Partial<Settings>) { store.updateSettings(patch); } export function updateSettings(patch: Partial<Settings>) { store.updateSettings(patch); }
export function resetKeybinds() { store.resetKeybinds(); } export function resetKeybinds() { store.resetKeybinds(); }
export function addFolder(name: string) { return store.addFolder(name); }
export function removeFolder(id: string) { store.removeFolder(id); }
export function renameFolder(id: string, name: string) { store.renameFolder(id, name); }
export function toggleFolderTab(id: string) { store.toggleFolderTab(id); }
export function assignMangaToFolder(folderId: string, mangaId: number) { store.assignMangaToFolder(folderId, mangaId); }
export function removeMangaFromFolder(folderId: string, mangaId: number) { store.removeMangaFromFolder(folderId, mangaId); }
export function getMangaFolders(mangaId: number) { return store.getMangaFolders(mangaId); }
export function clearDiscoverCache() { store.clearDiscoverCache(); } export function clearDiscoverCache() { store.clearDiscoverCache(); }
export function addBookmark(entry: Omit<BookmarkEntry, "savedAt">, label?: string) { store.addBookmark(entry, label); }
export function removeBookmark(chapterId: number) { store.removeBookmark(chapterId); }
export function clearBookmarks() { store.clearBookmarks(); }
export function getBookmark(chapterId: number) { return store.getBookmark(chapterId); }
export function addMarker(entry: Omit<MarkerEntry, "id" | "createdAt">): string { return store.addMarker(entry); }
export function updateMarker(id: string, patch: Partial<Pick<MarkerEntry, "note" | "color">>) { store.updateMarker(id, patch); }
export function removeMarker(id: string) { store.removeMarker(id); }
export function getMarkersForPage(chapterId: number, page: number) { return store.getMarkersForPage(chapterId, page); }
export function getMarkersForChapter(chapterId: number) { return store.getMarkersForChapter(chapterId); }
export function getMarkersForManga(mangaId: number) { return store.getMarkersForManga(mangaId); }
export function clearMarkersForManga(mangaId: number) { store.clearMarkersForManga(mangaId); }
export function toggleHiddenCategory(id: number) { store.toggleHiddenCategory(id); }
export function saveCustomTheme(theme: CustomTheme) { store.saveCustomTheme(theme); } export function saveCustomTheme(theme: CustomTheme) { store.saveCustomTheme(theme); }
export function deleteCustomTheme(id: string) { store.deleteCustomTheme(id); } export function deleteCustomTheme(id: string) { store.deleteCustomTheme(id); }
export async function checkAndMarkCompleted(
mangaId: number,
chaps: Chapter[],
categories: Category[],
gqlFn: (query: string, vars: Record<string, unknown>) => Promise<unknown>,
UPDATE_MANGA_CATEGORIES: string,
UPDATE_MANGA?: string,
): Promise<void> {
return store.checkAndMarkCompleted(mangaId, chaps, categories, gqlFn, UPDATE_MANGA_CATEGORIES, UPDATE_MANGA);
}