Compare commits

..

39 Commits

Author SHA1 Message Date
Youwes09 514910667b Chore: Update Tags for v0.9.0 2026-04-24 21:45:06 -05:00
Youwes09 2e9939c4a9 Feat: Per-Manga Reader Settings + Settings Access (#42 & #46) 2026-04-24 21:09:05 -05:00
Youwes09 581aea5694 Feat: Pin Sources on SourceTab (#48) 2026-04-23 22:37:42 -05:00
Youwes09 72a88b10c8 Feat: Always Display Library Stats & Library Stats Overhaul (#47) 2026-04-23 21:44:11 -05:00
Youwes09 371b4af73f Feat: Settings Button in Reader + Dropdown Overhaul + Settings Listener (#46) 2026-04-23 21:35:33 -05:00
Youwes09 634d32f372 Feat: Download Queue Move to Top/Bottom + Tooltip (#38) 2026-04-23 20:54:57 -05:00
Youwes09 4e6be5d9f5 Feat: Import & Export Store + Update Trigger 2026-04-23 20:09:50 -05:00
Youwes09 bb7256c4f8 Feat: BulkAutomationPanel & Z-Index Issue (#39 & #44) 2026-04-23 16:03:36 -05:00
Youwes09 b12ff4cbaa Fix: Derive Auto-Download List from Filter (#39) 2026-04-23 11:27:54 -05:00
Youwes09 63a829ddca Fix: Chapter Nodes in LibraryUpdater 2026-04-23 10:52:15 -05:00
Youwes09 94b14fb7f6 Fix: Patch Cargo to remove TPU (Windows Installer Patch #41) 2026-04-22 23:33:33 -05:00
Youwes09 bd2fd7a6d7 Fix: Windows Auto-Installer (WIP) 2026-04-23 03:58:20 -05:00
Youwes09 6634ad56d2 Fix: Attempt at Windows-Installer without TPU 2026-04-22 22:25:24 -05:00
Youwes09 2eb8a7662e Feat: Home Re-Design & MacOS Detection Fix 2026-04-22 22:15:13 -05:00
Youwes09 7dd4f52308 Fix: Attempt to Fix Tab Boundaries 2026-04-22 10:57:03 -05:00
Youwes09 690f59c602 Fix: Dark-Theme on Settings Slider 2026-04-22 10:34:09 -05:00
Youwes09 c025336a7e Fix: Download Notifications + Control & ContextMenu Icons (#38) 2026-04-21 11:41:02 -05:00
Youwes09 86c78689df Feat: Extension of Download Features, Batch Select, Error/Retry (#38) 2026-04-21 10:57:29 -05:00
Youwes09 2d3a4d0e57 Feat: History Page Revamp 2026-04-20 23:43:57 -05:00
Youwes09 1a5c63a607 Fix: QOL Animation on Extensions 2026-04-20 21:44:30 -05:00
Youwes09 3f7102556b Fix: Attempt at fixing Library Refresh 2026-04-20 21:29:53 -05:00
Youwes09 f5a66ab5d1 Fix: Local Source & QOL Animations 2026-04-20 20:59:42 -05:00
Youwes09 e41e8011be Fix: Dropdown in Settings 2026-04-20 11:35:41 -05:00
Youwes09 044c93a790 Fix: Zoom Values turning to NaN in Reader 2026-04-20 10:59:49 -05:00
Shozikan e49df4501f Chore: Update copyright year and owner in LICENSE file 2026-04-20 08:47:48 -05:00
Youwes09 4b97f4a6c9 Feat: Reworked ENTIRE Project for Readability 2026-04-20 00:19:22 -05:00
Youwes09 005680394e Feat: Re-did Layout & Sidebar 2026-04-17 20:43:18 -05:00
Youwes09 ecb4748414 Fix: Attempted to Patch Filesystem Issues (#32) 2026-04-16 23:14:39 -05:00
Youwes09 f0dc3446b2 Feat: QOL Animations P1 2026-04-16 22:40:22 -05:00
Youwes09 8507c34b21 Fix: MacOS Directory Scan (Patch 1) 2026-04-16 13:05:33 -05:00
Youwes09 78da5915df Fix: Optimizations for Reader 2026-04-16 11:12:08 -05:00
Youwes09 c0c486a53e Feat: Double-Tap for Reader Bar (#29) 2026-04-16 00:29:46 -05:00
Youwes09 236d6bcf08 Chore: Description for Notifications on ChapterRefresh 2026-04-16 00:19:36 -05:00
Youwes09 2b140ae022 Feat: Notifications on Source Install/Uninstall (#31) 2026-04-16 00:13:29 -05:00
Youwes09 38d407092f Chore: Descriptions for Settings 2026-04-16 00:06:04 -05:00
Shozikan 12191dfcdf Merge pull request #35 from kannachi323/dev
fix(ui): slow thumbnail loading
2026-04-15 23:53:58 -05:00
Youwes09 13a2f9ecb7 Chore: Flathub Support (Software-Level Fixed) 2026-04-15 23:53:24 -05:00
Youwes09 1aad4a1ff0 Fix: Removed Show-More for Preferential Loading using PR Methods 2026-04-15 17:30:14 -05:00
Matthew Chen 68a9331b6f fix(ui): slow thumbnail loading 2026-04-15 01:58:24 -07:00
214 changed files with 23643 additions and 24418 deletions
+34 -25
View File
@@ -7,6 +7,9 @@ on:
description: "Version to build (e.g. 0.4.0)" description: "Version to build (e.g. 0.4.0)"
required: true required: true
permissions:
contents: write
jobs: jobs:
frontend: frontend:
name: Build frontend name: Build frontend
@@ -40,9 +43,6 @@ jobs:
name: Tauri (macOS) name: Tauri (macOS)
needs: frontend needs: frontend
runs-on: macos-latest runs-on: macos-latest
permissions:
contents: write
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@@ -138,7 +138,6 @@ jobs:
run: | run: |
sed -i '' 's/"beforeBuildCommand": "pnpm build"/"beforeBuildCommand": ""/' src-tauri/tauri.conf.json sed -i '' 's/"beforeBuildCommand": "pnpm build"/"beforeBuildCommand": ""/' src-tauri/tauri.conf.json
# ── aarch64 build ──────────────────────────────────────────────────────
- name: Swap bundle for aarch64 - name: Swap bundle for aarch64
run: | run: |
rm -rf src-tauri/binaries/suwayomi-bundle rm -rf src-tauri/binaries/suwayomi-bundle
@@ -148,16 +147,8 @@ jobs:
- name: Build Tauri app (aarch64) - name: Build Tauri app (aarch64)
run: pnpm tauri build --target aarch64-apple-darwin --config src-tauri/tauri.macos.conf.json run: pnpm tauri build --target aarch64-apple-darwin --config src-tauri/tauri.macos.conf.json
env: env:
# Ad-hoc signing ("-") ships without a Developer ID.
# Gatekeeper will quarantine the app on other Macs — users must run:
# xattr -rd com.apple.quarantine Moku.app
# To fix this properly, set APPLE_SIGNING_IDENTITY to your
# "Developer ID Application: ..." cert name and add
# APPLE_CERTIFICATE / APPLE_CERTIFICATE_PASSWORD / APPLE_ID /
# APPLE_TEAM_ID / APPLE_APP_SPECIFIC_PASSWORD secrets for notarisation.
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY || '-' }} APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY || '-' }}
# ── x86_64 build ───────────────────────────────────────────────────────
- name: Swap bundle for x86_64 - name: Swap bundle for x86_64
run: | run: |
rm -rf src-tauri/binaries/suwayomi-bundle rm -rf src-tauri/binaries/suwayomi-bundle
@@ -169,17 +160,35 @@ jobs:
env: env:
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY || '-' }} APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY || '-' }}
# ── upload artifacts ─────────────────────────────────────────────────── - name: Upload macOS artifacts to release
- name: Upload arm64 .dmg env:
uses: actions/upload-artifact@v4 GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with: VERSION: ${{ github.event.inputs.version }}
name: moku-macos-arm64-${{ github.event.inputs.version }} run: |
path: src-tauri/target/aarch64-apple-darwin/release/bundle/dmg/*.dmg # Wait for the Windows workflow to have created the draft release
retention-days: 7 for i in $(seq 1 12); do
RELEASE_ID=$(curl -s -H "Authorization: Bearer $GITHUB_TOKEN" "https://api.github.com/repos/Youwes09/Moku/releases" | jq -r '.[] | select(.tag_name == "v'"$VERSION"'") | .id' | head -1)
if [ -n "$RELEASE_ID" ]; then break; fi
echo "Waiting for release to exist... attempt $i"
sleep 15
done
- name: Upload x64 .dmg if [ -z "$RELEASE_ID" ]; then
uses: actions/upload-artifact@v4 echo "ERROR: Could not find release for v$VERSION after waiting"
with: exit 1
name: moku-macos-x64-${{ github.event.inputs.version }} fi
path: src-tauri/target/x86_64-apple-darwin/release/bundle/dmg/*.dmg
retention-days: 7 echo "Found release ID: $RELEASE_ID"
upload_asset() {
local file="$1"
local name="$2"
echo "Uploading $name..."
curl -s -X POST -H "Authorization: Bearer $GITHUB_TOKEN" -H "Content-Type: application/octet-stream" --data-binary @"$file" "https://uploads.github.com/repos/Youwes09/Moku/releases/$RELEASE_ID/assets?name=$name"
}
ARM64_DMG=$(find src-tauri/target/aarch64-apple-darwin/release/bundle/dmg -name "*.dmg" | head -1)
X64_DMG=$(find src-tauri/target/x86_64-apple-darwin/release/bundle/dmg -name "*.dmg" | head -1)
[ -n "$ARM64_DMG" ] && upload_asset "$ARM64_DMG" "moku-macos-arm64-${VERSION}.dmg"
[ -n "$X64_DMG" ] && upload_asset "$X64_DMG" "moku-macos-x64-${VERSION}.dmg"
+6 -2
View File
@@ -155,8 +155,12 @@ jobs:
tagName: v${{ github.event.inputs.version }} tagName: v${{ github.event.inputs.version }}
releaseName: Moku v${{ github.event.inputs.version }} releaseName: Moku v${{ github.event.inputs.version }}
releaseBody: | releaseBody: |
Windows installer for Moku v${{ github.event.inputs.version }}. Moku v${{ github.event.inputs.version }}
Download the `.exe` file below to install or update.
**Windows:** Download `Moku_${{ github.event.inputs.version }}_x64-setup.exe`
**macOS arm64:** Download `moku-macos-arm64-${{ github.event.inputs.version }}.dmg`
**macOS x64:** Download `moku-macos-x64-${{ github.event.inputs.version }}.dmg`
**Linux:** Download `moku.flatpak`
releaseDraft: true releaseDraft: true
prerelease: false prerelease: false
args: --target x86_64-pc-windows-msvc --config src-tauri/tauri.windows.conf.json args: --target x86_64-pc-windows-msvc --config src-tauri/tauri.windows.conf.json
+1 -1
View File
@@ -186,7 +186,7 @@
same "printed page" as the copyright notice for easier same "printed page" as the copyright notice for easier
identification within third-party archives. identification within third-party archives.
Copyright [yyyy] [name of copyright owner] Copyright [2026] [@Youwes09]
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
+17 -23
View File
@@ -1,45 +1,39 @@
Major Revisions: Major Revisions:
- Moku-Share to Easily Migrate/Share Manga (Investigate Usecase/Feasibility) - Moku-Share to Easily Migrate/Share Manga (Investigate Usecase/Feasibility)
- Moku-Share allows exporting of Manga
- Compressed Format (Storage)
- Import as Local-Source
- Takes existing Local-Source or Creates Own
Minor Revisions: Minor Revisions:
- Improve Deduplication Algorithm with Advanced Option Settings (Implement Chapter & Author-based Comparisons, Resource Intensive)
- 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) - 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) - 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
- Integrate Download Directory Changes (Settings)
Priority Bugs: Priority Bugs:
- Fix Library Build not Updating - Fix Library-Refresh System (TESTING)
- Loading Buffer for Pictures (Due to Auth Lag)
- Suwayomi RESET
General/Misc Bugs: - Allow User to Wipe Suwayomi (Scratch)
- Fix Highlightable Elements - If Possible, Component based Wipe (Library, Etc)
- Investigate "egl:failed to create dri2 screen"
- Check Fonts/Design on Flatpak
- Fix Delete-All Crash (Deletes All but Cripples App)
- Investigate Prod vs. Dev Directory Change/Data Load (Caused by Library Algorithm Revision?)
In-Progress: In-Progress:
- Enable Cloudflare Bypass (Suwayomi Config) (Requires Patching) - Enable Cloudflare Bypass (Suwayomi Config) (Requires Patching)
- Working on 3D Display Cards - Working on 3D Display Cards
- Chapter refresh Notification Looks bad (Series Detail) - Add Flathub Support (Pending Video)
- Fix Discover Workout - QOL Animations & Revamps
- Fix CSS on Saved State for Search - Tracking Revamp
- Fix State & Cache names (Mapped to Discover hence needs Renaming) - Completely Revamp Tracking
- Completely Remove Discover
- Add Small QOL Animations where Appropriate
- Fix Tracking Login
- Pasting OAuth URL is not User-Friendly, Look for Alternatives
Testing: Notes from last time:
- Currently working on #42, just need to mount panel and fix button in reader
+2 -2
View File
@@ -18,7 +18,7 @@
perSystem = { system, lib, ... }: perSystem = { system, lib, ... }:
let let
version = "0.8.0"; version = "0.9.0";
pkgs = import inputs.nixpkgs { pkgs = import inputs.nixpkgs {
inherit system; inherit system;
@@ -195,7 +195,7 @@ EOF
echo "Done" echo "Done"
echo " Repacking frontend-dist.tar.gz " echo " Repacking frontend-dist.tar.gz "
tar -czf "$REPO/packaging/frontend-dist.tar.gz" -C "$REPO/dist" . tar -czf "$REPO/packaging/frontend-dist.tar.gz" -C "$REPO" dist
FRONTEND_SHA=$(sha256sum "$REPO/packaging/frontend-dist.tar.gz" | awk '{print $1}') FRONTEND_SHA=$(sha256sum "$REPO/packaging/frontend-dist.tar.gz" | awk '{print $1}')
echo "sha256: $FRONTEND_SHA" echo "sha256: $FRONTEND_SHA"
+2 -2
View File
@@ -180,10 +180,10 @@ modules:
- type: git - type: git
url: https://github.com/Youwes09/Moku.git url: https://github.com/Youwes09/Moku.git
tag: v0.8.0 tag: v0.8.0
commit: ff5fcc4fc0dd97e187fac15480406993bc4231da commit: c573c543187cbd1ca1455b25d6bce0fc62666341
- type: file - type: file
path: packaging/frontend-dist.tar.gz path: packaging/frontend-dist.tar.gz
sha256: f21034da4b8da42d8084978b60e429162aabb28808fa019ffb786e877c4ae95b sha256: d547893e1b76f1678df131d46b0964e9ef34e54e8571d5c435a22cef7316f75a
- packaging/cargo-sources.json - packaging/cargo-sources.json
- type: inline - type: inline
dest: src-tauri/.cargo dest: src-tauri/.cargo
File diff suppressed because it is too large Load Diff
+76 -256
View File
@@ -47,15 +47,6 @@ version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
dependencies = [
"derive_arbitrary",
]
[[package]] [[package]]
name = "atk" name = "atk"
version = "0.18.2" version = "0.18.2"
@@ -268,9 +259,9 @@ dependencies = [
[[package]] [[package]]
name = "cc" name = "cc"
version = "1.2.60" version = "1.2.61"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20" checksum = "d16d90359e986641506914ba71350897565610e87ce0ad9e6f28569db3dd5c6d"
dependencies = [ dependencies = [
"find-msvc-tools", "find-msvc-tools",
"shlex", "shlex",
@@ -290,7 +281,7 @@ checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f"
dependencies = [ dependencies = [
"byteorder", "byteorder",
"fnv", "fnv",
"uuid 1.23.0", "uuid 1.23.1",
] ]
[[package]] [[package]]
@@ -584,17 +575,6 @@ dependencies = [
"serde_core", "serde_core",
] ]
[[package]]
name = "derive_arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]] [[package]]
name = "derive_more" name = "derive_more"
version = "0.99.20" version = "0.99.20"
@@ -678,7 +658,7 @@ dependencies = [
"libc", "libc",
"option-ext", "option-ext",
"redox_users 0.5.2", "redox_users 0.5.2",
"windows-sys 0.59.0", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -810,14 +790,14 @@ checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
[[package]] [[package]]
name = "embed-resource" name = "embed-resource"
version = "3.0.8" version = "3.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63a1d0de4f2249aa0ff5884d7080814f446bb241a559af6c170a41e878ed2d45" checksum = "c31a88c8d26de40ed18fe748c547845aa39de1db3afd958f8cb91579f3644bcb"
dependencies = [ dependencies = [
"cc", "cc",
"memchr", "memchr",
"rustc_version", "rustc_version",
"toml 0.9.12+spec-1.1.0", "toml 1.1.2+spec-1.1.0",
"vswhom", "vswhom",
"winreg", "winreg",
] ]
@@ -861,7 +841,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [ dependencies = [
"libc", "libc",
"windows-sys 0.52.0", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -889,17 +869,6 @@ dependencies = [
"rustc_version", "rustc_version",
] ]
[[package]]
name = "filetime"
version = "0.2.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db"
dependencies = [
"cfg-if",
"libc",
"libredox",
]
[[package]] [[package]]
name = "find-msvc-tools" name = "find-msvc-tools"
version = "0.1.9" version = "0.1.9"
@@ -1969,9 +1938,9 @@ dependencies = [
[[package]] [[package]]
name = "libc" name = "libc"
version = "0.2.185" version = "0.2.186"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
[[package]] [[package]]
name = "libloading" name = "libloading"
@@ -1989,10 +1958,7 @@ version = "0.1.16"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c"
dependencies = [ dependencies = [
"bitflags 2.11.1",
"libc", "libc",
"plain",
"redox_syscall 0.7.4",
] ]
[[package]] [[package]]
@@ -2103,12 +2069,6 @@ version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "minisign-verify"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e"
[[package]] [[package]]
name = "miniz_oxide" name = "miniz_oxide"
version = "0.8.9" version = "0.8.9"
@@ -2132,7 +2092,7 @@ dependencies = [
[[package]] [[package]]
name = "moku" name = "moku"
version = "0.8.0" version = "0.9.0"
dependencies = [ dependencies = [
"dirs 5.0.1", "dirs 5.0.1",
"reqwest 0.12.28", "reqwest 0.12.28",
@@ -2147,7 +2107,6 @@ dependencies = [
"tauri-plugin-os", "tauri-plugin-os",
"tauri-plugin-process", "tauri-plugin-process",
"tauri-plugin-shell", "tauri-plugin-shell",
"tauri-plugin-updater",
"tokio", "tokio",
"urlencoding", "urlencoding",
"walkdir", "walkdir",
@@ -2440,18 +2399,6 @@ dependencies = [
"objc2-core-foundation", "objc2-core-foundation",
] ]
[[package]]
name = "objc2-osa-kit"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0"
dependencies = [
"bitflags 2.11.1",
"objc2",
"objc2-app-kit",
"objc2-foundation",
]
[[package]] [[package]]
name = "objc2-quartz-core" name = "objc2-quartz-core"
version = "0.3.2" version = "0.3.2"
@@ -2517,9 +2464,9 @@ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]] [[package]]
name = "open" name = "open"
version = "5.3.3" version = "5.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43bb73a7fa3799b198970490a51174027ba0d4ec504b03cd08caf513d40024bc" checksum = "9f3bab717c29a857abf75fcef718d441ec7cb2725f937343c734740a985d37fd"
dependencies = [ dependencies = [
"dunce", "dunce",
"is-wsl", "is-wsl",
@@ -2529,9 +2476,9 @@ dependencies = [
[[package]] [[package]]
name = "openssl" name = "openssl"
version = "0.10.77" version = "0.10.78"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bfe4646e360ec77dff7dde40ed3d6c5fee52d156ef4a62f53973d38294dad87f" checksum = "f38c4372413cdaaf3cc79dd92d29d7d9f5ab09b51b10dded508fb90bb70b9222"
dependencies = [ dependencies = [
"bitflags 2.11.1", "bitflags 2.11.1",
"cfg-if", "cfg-if",
@@ -2561,9 +2508,9 @@ checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
[[package]] [[package]]
name = "openssl-sys" name = "openssl-sys"
version = "0.9.113" version = "0.9.114"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ad2f2c0eba47118757e4c6d2bff2838f3e0523380021356e7875e858372ce644" checksum = "13ce1245cd07fcc4cfdb438f7507b0c7e4f3849a69fd84d52374c66d83741bb6"
dependencies = [ dependencies = [
"cc", "cc",
"libc", "libc",
@@ -2600,21 +2547,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
dependencies = [ dependencies = [
"libc", "libc",
"windows-sys 0.45.0", "windows-sys 0.61.2",
]
[[package]]
name = "osakit"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b"
dependencies = [
"objc2",
"objc2-foundation",
"objc2-osa-kit",
"serde",
"serde_json",
"thiserror 2.0.18",
] ]
[[package]] [[package]]
@@ -2660,7 +2593,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"libc", "libc",
"redox_syscall 0.5.18", "redox_syscall",
"smallvec", "smallvec",
"windows-link 0.2.1", "windows-link 0.2.1",
] ]
@@ -2765,7 +2698,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d5285893bb5eb82e6aaf5d59ee909a06a16737a8970984dd7746ba9283498d6" checksum = "5d5285893bb5eb82e6aaf5d59ee909a06a16737a8970984dd7746ba9283498d6"
dependencies = [ dependencies = [
"phf_shared 0.10.0", "phf_shared 0.10.0",
"rand 0.8.5", "rand 0.8.6",
] ]
[[package]] [[package]]
@@ -2775,7 +2708,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
dependencies = [ dependencies = [
"phf_shared 0.11.3", "phf_shared 0.11.3",
"rand 0.8.5", "rand 0.8.6",
] ]
[[package]] [[package]]
@@ -2876,12 +2809,6 @@ version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
[[package]]
name = "plain"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
[[package]] [[package]]
name = "plist" name = "plist"
version = "1.8.0" version = "1.8.0"
@@ -3133,9 +3060,9 @@ dependencies = [
[[package]] [[package]]
name = "rand" name = "rand"
version = "0.8.5" version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
dependencies = [ dependencies = [
"libc", "libc",
"rand_chacha 0.3.1", "rand_chacha 0.3.1",
@@ -3262,15 +3189,6 @@ dependencies = [
"bitflags 2.11.1", "bitflags 2.11.1",
] ]
[[package]]
name = "redox_syscall"
version = "0.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f450ad9c3b1da563fb6948a8e0fb0fb9269711c9c73d9ea1de5058c79c8d643a"
dependencies = [
"bitflags 2.11.1",
]
[[package]] [[package]]
name = "redox_users" name = "redox_users"
version = "0.4.6" version = "0.4.6"
@@ -3404,20 +3322,15 @@ dependencies = [
"http-body", "http-body",
"http-body-util", "http-body-util",
"hyper", "hyper",
"hyper-rustls",
"hyper-util", "hyper-util",
"js-sys", "js-sys",
"log", "log",
"percent-encoding", "percent-encoding",
"pin-project-lite", "pin-project-lite",
"rustls",
"rustls-pki-types",
"rustls-platform-verifier",
"serde", "serde",
"serde_json", "serde_json",
"sync_wrapper", "sync_wrapper",
"tokio", "tokio",
"tokio-rustls",
"tokio-util", "tokio-util",
"tower", "tower",
"tower-http", "tower-http",
@@ -3492,14 +3405,14 @@ dependencies = [
"errno", "errno",
"libc", "libc",
"linux-raw-sys", "linux-raw-sys",
"windows-sys 0.52.0", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
name = "rustls" name = "rustls"
version = "0.23.38" version = "0.23.39"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69f9466fb2c14ea04357e91413efb882e2a6d4a406e625449bc0a5d360d53a21" checksum = "7c2c118cb077cca2822033836dfb1b975355dfb784b5e8da48f7b6c5db74e60e"
dependencies = [ dependencies = [
"once_cell", "once_cell",
"ring", "ring",
@@ -3509,60 +3422,21 @@ dependencies = [
"zeroize", "zeroize",
] ]
[[package]]
name = "rustls-native-certs"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63"
dependencies = [
"openssl-probe",
"rustls-pki-types",
"schannel",
"security-framework",
]
[[package]] [[package]]
name = "rustls-pki-types" name = "rustls-pki-types"
version = "1.14.0" version = "1.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9"
dependencies = [ dependencies = [
"web-time", "web-time",
"zeroize", "zeroize",
] ]
[[package]]
name = "rustls-platform-verifier"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784"
dependencies = [
"core-foundation 0.10.1",
"core-foundation-sys",
"jni",
"log",
"once_cell",
"rustls",
"rustls-native-certs",
"rustls-platform-verifier-android",
"rustls-webpki",
"security-framework",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.52.0",
]
[[package]]
name = "rustls-platform-verifier-android"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
[[package]] [[package]]
name = "rustls-webpki" name = "rustls-webpki"
version = "0.103.12" version = "0.103.13"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8279bb85272c9f10811ae6a6c547ff594d6a7f3c6c6b02ee9726d1d0dcfcdd06" checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
dependencies = [ dependencies = [
"ring", "ring",
"rustls-pki-types", "rustls-pki-types",
@@ -3611,7 +3485,7 @@ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
"url", "url",
"uuid 1.23.0", "uuid 1.23.1",
] ]
[[package]] [[package]]
@@ -4001,7 +3875,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
dependencies = [ dependencies = [
"libc", "libc",
"windows-sys 0.60.2", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -4019,7 +3893,7 @@ dependencies = [
"objc2-foundation", "objc2-foundation",
"objc2-quartz-core", "objc2-quartz-core",
"raw-window-handle", "raw-window-handle",
"redox_syscall 0.5.18", "redox_syscall",
"tracing", "tracing",
"wasm-bindgen", "wasm-bindgen",
"web-sys", "web-sys",
@@ -4292,17 +4166,6 @@ dependencies = [
"syn 2.0.117", "syn 2.0.117",
] ]
[[package]]
name = "tar"
version = "0.4.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973"
dependencies = [
"filetime",
"libc",
"xattr",
]
[[package]] [[package]]
name = "target-lexicon" name = "target-lexicon"
version = "0.12.16" version = "0.12.16"
@@ -4405,7 +4268,7 @@ dependencies = [
"thiserror 2.0.18", "thiserror 2.0.18",
"time", "time",
"url", "url",
"uuid 1.23.0", "uuid 1.23.1",
"walkdir", "walkdir",
] ]
@@ -4572,39 +4435,6 @@ dependencies = [
"tokio", "tokio",
] ]
[[package]]
name = "tauri-plugin-updater"
version = "2.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "806d9dac662c2e4594ff03c647a552f2c9bd544e7d0f683ec58f872f952ce4af"
dependencies = [
"base64 0.22.1",
"dirs 6.0.0",
"flate2",
"futures-util",
"http",
"infer",
"log",
"minisign-verify",
"osakit",
"percent-encoding",
"reqwest 0.13.2",
"rustls",
"semver",
"serde",
"serde_json",
"tar",
"tauri",
"tauri-plugin",
"tempfile",
"thiserror 2.0.18",
"time",
"tokio",
"url",
"windows-sys 0.60.2",
"zip",
]
[[package]] [[package]]
name = "tauri-runtime" name = "tauri-runtime"
version = "2.10.1" version = "2.10.1"
@@ -4690,7 +4520,7 @@ dependencies = [
"toml 0.9.12+spec-1.1.0", "toml 0.9.12+spec-1.1.0",
"url", "url",
"urlpattern", "urlpattern",
"uuid 1.23.0", "uuid 1.23.1",
"walkdir", "walkdir",
] ]
@@ -4715,7 +4545,7 @@ dependencies = [
"getrandom 0.4.2", "getrandom 0.4.2",
"once_cell", "once_cell",
"rustix", "rustix",
"windows-sys 0.52.0", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -4837,9 +4667,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]] [[package]]
name = "tokio" name = "tokio"
version = "1.51.1" version = "1.52.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f66bf9585cda4b724d3e78ab34b73fb2bbaba9011b9bfdf69dc836382ea13b8c" checksum = "b67dee974fe86fd92cc45b7a95fdd2f99a36a6d7b0d431a231178d3d670bbcc6"
dependencies = [ dependencies = [
"bytes", "bytes",
"libc", "libc",
@@ -4921,6 +4751,21 @@ dependencies = [
"winnow 0.7.15", "winnow 0.7.15",
] ]
[[package]]
name = "toml"
version = "1.1.2+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee"
dependencies = [
"indexmap 2.14.0",
"serde_core",
"serde_spanned 1.1.1",
"toml_datetime 1.1.1+spec-1.1.0",
"toml_parser",
"toml_writer",
"winnow 1.0.2",
]
[[package]] [[package]]
name = "toml_datetime" name = "toml_datetime"
version = "0.6.3" version = "0.6.3"
@@ -4981,7 +4826,7 @@ dependencies = [
"indexmap 2.14.0", "indexmap 2.14.0",
"toml_datetime 1.1.1+spec-1.1.0", "toml_datetime 1.1.1+spec-1.1.0",
"toml_parser", "toml_parser",
"winnow 1.0.1", "winnow 1.0.2",
] ]
[[package]] [[package]]
@@ -4990,7 +4835,7 @@ version = "1.1.2+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526"
dependencies = [ dependencies = [
"winnow 1.0.1", "winnow 1.0.2",
] ]
[[package]] [[package]]
@@ -5099,9 +4944,9 @@ checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c"
[[package]] [[package]]
name = "typenum" name = "typenum"
version = "1.19.0" version = "1.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de"
[[package]] [[package]]
name = "unic-char-property" name = "unic-char-property"
@@ -5222,9 +5067,9 @@ dependencies = [
[[package]] [[package]]
name = "uuid" name = "uuid"
version = "1.23.0" version = "1.23.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9" checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76"
dependencies = [ dependencies = [
"getrandom 0.4.2", "getrandom 0.4.2",
"js-sys", "js-sys",
@@ -5303,11 +5148,11 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]] [[package]]
name = "wasip2" name = "wasip2"
version = "1.0.2+wasi-0.2.9" version = "1.0.3+wasi-0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6"
dependencies = [ dependencies = [
"wit-bindgen", "wit-bindgen 0.57.1",
] ]
[[package]] [[package]]
@@ -5316,7 +5161,7 @@ version = "0.4.0+wasi-0.3.0-rc-2026-01-06"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5"
dependencies = [ dependencies = [
"wit-bindgen", "wit-bindgen 0.51.0",
] ]
[[package]] [[package]]
@@ -5443,9 +5288,9 @@ dependencies = [
[[package]] [[package]]
name = "web_atoms" name = "web_atoms"
version = "0.2.3" version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57a9779e9f04d2ac1ce317aee707aa2f6b773afba7b931222bff6983843b1576" checksum = "d7cff6eef815df1834fd250e3a2ff436044d82a9f1bc1980ca1dbdf07effc538"
dependencies = [ dependencies = [
"phf 0.13.1", "phf 0.13.1",
"phf_codegen 0.13.1", "phf_codegen 0.13.1",
@@ -5497,20 +5342,11 @@ dependencies = [
"system-deps", "system-deps",
] ]
[[package]]
name = "webpki-root-certs"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "804f18a4ac2676ffb4e8b5b5fa9ae38af06df08162314f96a68d2a363e21a8ca"
dependencies = [
"rustls-pki-types",
]
[[package]] [[package]]
name = "webpki-roots" name = "webpki-roots"
version = "1.0.6" version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d"
dependencies = [ dependencies = [
"rustls-pki-types", "rustls-pki-types",
] ]
@@ -5573,7 +5409,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [ dependencies = [
"windows-sys 0.48.0", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -6132,9 +5968,9 @@ checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
[[package]] [[package]]
name = "winnow" name = "winnow"
version = "1.0.1" version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5" checksum = "2ee1708bef14716a11bae175f579062d4554d95be2c6829f518df847b7b3fdd0"
dependencies = [ dependencies = [
"memchr", "memchr",
] ]
@@ -6158,6 +5994,12 @@ dependencies = [
"wit-bindgen-rust-macro", "wit-bindgen-rust-macro",
] ]
[[package]]
name = "wit-bindgen"
version = "0.57.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
[[package]] [[package]]
name = "wit-bindgen-core" name = "wit-bindgen-core"
version = "0.51.0" version = "0.51.0"
@@ -6308,16 +6150,6 @@ dependencies = [
"pkg-config", "pkg-config",
] ]
[[package]]
name = "xattr"
version = "1.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
dependencies = [
"libc",
"rustix",
]
[[package]] [[package]]
name = "yoke" name = "yoke"
version = "0.8.2" version = "0.8.2"
@@ -6421,18 +6253,6 @@ dependencies = [
"syn 2.0.117", "syn 2.0.117",
] ]
[[package]]
name = "zip"
version = "4.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1"
dependencies = [
"arbitrary",
"crc32fast",
"indexmap 2.14.0",
"memchr",
]
[[package]] [[package]]
name = "zmij" name = "zmij"
version = "1.0.21" version = "1.0.21"
+1 -2
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "moku" name = "moku"
version = "0.8.0" version = "0.9.0"
edition = "2021" edition = "2021"
[lib] [lib]
@@ -17,7 +17,6 @@ tauri-build = { version = "2.0", features = [] }
[dependencies] [dependencies]
tauri = { version = "2.0", features = [] } tauri = { version = "2.0", features = [] }
tauri-plugin-shell = "2" tauri-plugin-shell = "2"
tauri-plugin-updater = "2"
tauri-plugin-process = "2" tauri-plugin-process = "2"
tauri-plugin-http = "2" tauri-plugin-http = "2"
tauri-plugin-dialog = "2" tauri-plugin-dialog = "2"
-3
View File
@@ -26,9 +26,6 @@
"core:window:allow-inner-position", "core:window:allow-inner-position",
"core:window:allow-outer-position", "core:window:allow-outer-position",
"core:window:allow-scale-factor", "core:window:allow-scale-factor",
"updater:default",
"updater:allow-check",
"updater:allow-download-and-install",
"process:default", "process:default",
"process:allow-restart", "process:allow-restart",
"http:default", "http:default",
+217 -37
View File
@@ -53,12 +53,9 @@ fn strip_unc(path: PathBuf) -> PathBuf {
fn resolve_downloads_path(downloads_path: &str) -> PathBuf { fn resolve_downloads_path(downloads_path: &str) -> PathBuf {
if !downloads_path.trim().is_empty() { if !downloads_path.trim().is_empty() {
return PathBuf::from(downloads_path); return PathBuf::from(downloads_path.trim());
} }
let base = std::env::var("XDG_DATA_HOME") suwayomi_data_dir().join("downloads")
.map(PathBuf::from)
.unwrap_or_else(|_| dirs::data_dir().unwrap_or_else(|| PathBuf::from("/")));
base.join("Tachidesk").join("downloads")
} }
#[tauri::command] #[tauri::command]
@@ -409,30 +406,102 @@ fn resolve_server_binary(
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
{ {
let resource_dir = app.path().resource_dir().unwrap_or_default(); let resource_dir = app.path().resource_dir().unwrap_or_default();
let macos_dir = resource_dir.parent().map(|p| p.join("MacOS")).unwrap_or_default(); let contents_dir = resource_dir
.parent()
.unwrap_or(&resource_dir)
.to_path_buf();
let candidates = [ do_log(log, &format!("[resolve] macOS contents_dir = {:?}", contents_dir));
"suwayomi-server",
const NATIVE_NAMES: &[&str] = &[
"suwayomi-server-aarch64-apple-darwin", "suwayomi-server-aarch64-apple-darwin",
"suwayomi-server-x86_64-apple-darwin", "suwayomi-server-x86_64-apple-darwin",
"suwayomi-server",
"suwayomi-launcher", "suwayomi-launcher",
"suwayomi-launcher.sh", "suwayomi-launcher.sh",
"tachidesk-server", "tachidesk-server",
]; ];
for search_dir in &[&macos_dir, &resource_dir] { let mut found_binary: Option<ServerInvocation> = None;
for name in &candidates { let mut found_java: Option<(PathBuf, PathBuf)> = None;
let p = search_dir.join(name);
do_log(log, &format!("[resolve] macOS candidate: {:?} exists={}", p, p.exists())); 'outer: for depth in 0u8..=8 {
let entries: Vec<PathBuf> = WalkDir::new(&contents_dir)
.min_depth(depth as usize)
.max_depth(depth as usize)
.into_iter()
.filter_map(|e| e.ok())
.filter(|e| e.file_type().is_dir())
.map(|e| e.into_path())
.collect();
for dir in &entries {
do_log(log, &format!("[resolve] scanning depth={} dir={:?}", depth, dir));
for name in NATIVE_NAMES {
let p = dir.join(name);
if p.exists() { if p.exists() {
return Ok(ServerInvocation { do_log(log, &format!("[resolve] found native binary: {:?}", p));
found_binary = Some(ServerInvocation {
bin: p.to_string_lossy().into_owned(), bin: p.to_string_lossy().into_owned(),
args: vec![], args: vec![],
working_dir: None, working_dir: Some(dir.clone()),
});
break 'outer;
}
}
if found_java.is_none() {
let java_exe = dir.join("bin").join("java");
if java_exe.exists() {
do_log(log, &format!("[resolve] found java: {:?}", java_exe));
let mut search = dir.as_path();
'jar: for _ in 0..5 {
if let Ok(rd) = std::fs::read_dir(search) {
for entry in rd.filter_map(|e| e.ok()) {
if entry.file_name().to_string_lossy().ends_with(".jar") {
let jar = entry.path();
do_log(log, &format!("[resolve] found jar: {:?}", jar));
found_java = Some((java_exe.clone(), jar));
break 'jar;
}
}
}
let bin_sibling = search.join("bin");
if let Ok(rd) = std::fs::read_dir(&bin_sibling) {
for entry in rd.filter_map(|e| e.ok()) {
if entry.file_name().to_string_lossy().ends_with(".jar") {
let jar = entry.path();
do_log(log, &format!("[resolve] found jar in bin/: {:?}", jar));
found_java = Some((java_exe.clone(), jar));
break 'jar;
}
}
}
match search.parent() {
Some(p) => search = p,
None => break,
}
}
}
}
}
}
if let Some(inv) = found_binary {
return Ok(inv);
}
if let Some((java, jar)) = found_java {
let working_dir = jar.parent().map(|p| p.to_path_buf());
return Ok(ServerInvocation {
bin: java.to_string_lossy().into_owned(),
args: vec!["-jar".to_string(), jar.to_string_lossy().into_owned()],
working_dir,
}); });
} }
}
} do_log(log, "[resolve] macOS scan found nothing in bundle");
} }
for name in &["suwayomi-server", "tachidesk-server"] { for name in &["suwayomi-server", "tachidesk-server"] {
@@ -552,32 +621,62 @@ async fn list_releases() -> Result<Vec<ReleaseInfo>, String> {
#[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, tag: String) -> Result<(), String> {
#[cfg(not(target_os = "windows"))] #[cfg(not(target_os = "windows"))]
return Err("Native install is Windows-only; open the GitHub release page instead.".into()); return Err("Native install is Windows-only; open the GitHub release page instead.".into());
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
{ {
use tauri_plugin_updater::UpdaterExt; use tauri_plugin_http::reqwest;
use std::io::Write;
let updater = app.updater().map_err(|e| e.to_string())?; let client = reqwest::Client::builder()
let update = updater.check().await.map_err(|e| e.to_string())?; .user_agent("Moku")
.build()
let Some(update) = update else {
return Err("No update available.".into());
};
let app_clone = app.clone();
update
.download_and_install(
move |downloaded, total| {
let _ = app_clone.emit("update-progress", UpdateProgress { downloaded: downloaded as u64, total });
},
|| {},
)
.await
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
let url = format!("https://api.github.com/repos/Youwes09/Moku/releases/tags/{}", tag);
let resp = client.get(&url).send().await.map_err(|e| e.to_string())?;
if !resp.status().is_success() {
return Err(format!("GitHub API returned {} for tag {}", resp.status(), tag));
}
#[derive(serde::Deserialize)]
struct Asset { name: String, browser_download_url: String, size: u64 }
#[derive(serde::Deserialize)]
struct Release { assets: Vec<Asset> }
let body = resp.text().await.map_err(|e| e.to_string())?;
let release: Release = serde_json::from_str(&body).map_err(|e| e.to_string())?;
let asset = release.assets
.into_iter()
.find(|a| a.name.ends_with("_x64-setup.exe"))
.ok_or_else(|| format!("No x64-setup.exe asset found in release {}", tag))?;
let total = if asset.size > 0 { Some(asset.size) } else { None };
let mut resp = client.get(&asset.browser_download_url).send().await.map_err(|e| e.to_string())?;
let tmp_path = std::env::temp_dir().join(&asset.name);
let mut file = std::fs::File::create(&tmp_path).map_err(|e| e.to_string())?;
let mut downloaded: u64 = 0;
while let Some(chunk) = resp.chunk().await.map_err(|e| e.to_string())? {
file.write_all(&chunk).map_err(|e| e.to_string())?;
downloaded += chunk.len() as u64;
let _ = app.emit("update-progress", UpdateProgress { downloaded, total });
}
drop(file);
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x08000000;
std::process::Command::new(&tmp_path)
.creation_flags(CREATE_NO_WINDOW)
.spawn()
.map_err(|e| e.to_string())?;
let _ = app.emit("update-launching", ());
Ok(()) Ok(())
} }
} }
@@ -589,9 +688,9 @@ fn restart_app(app: tauri::AppHandle) {
#[tauri::command] #[tauri::command]
fn open_path(path: String) -> Result<(), String> { fn open_path(path: String) -> Result<(), String> {
let p = std::path::Path::new(path.trim());
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
{ {
let p = strip_unc(std::path::PathBuf::from(path.trim()));
std::process::Command::new("explorer") std::process::Command::new("explorer")
.arg(p) .arg(p)
.spawn() .spawn()
@@ -599,6 +698,7 @@ fn open_path(path: String) -> Result<(), String> {
} }
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
{ {
let p = std::path::Path::new(path.trim());
std::process::Command::new("open") std::process::Command::new("open")
.arg(p) .arg(p)
.spawn() .spawn()
@@ -606,6 +706,7 @@ fn open_path(path: String) -> Result<(), String> {
} }
#[cfg(not(any(target_os = "windows", target_os = "macos")))] #[cfg(not(any(target_os = "windows", target_os = "macos")))]
{ {
let p = std::path::Path::new(path.trim());
std::process::Command::new("xdg-open") std::process::Command::new("xdg-open")
.arg(p) .arg(p)
.spawn() .spawn()
@@ -614,7 +715,6 @@ fn open_path(path: String) -> Result<(), String> {
Ok(()) Ok(())
} }
#[tauri::command] #[tauri::command]
async fn pick_downloads_folder(app: tauri::AppHandle) -> Option<String> { async fn pick_downloads_folder(app: tauri::AppHandle) -> Option<String> {
use tauri_plugin_dialog::DialogExt; use tauri_plugin_dialog::DialogExt;
@@ -625,6 +725,83 @@ async fn pick_downloads_folder(app: tauri::AppHandle) -> Option<String> {
.map(|p| p.to_string()) .map(|p| p.to_string())
} }
fn moku_backup_dir(app: &tauri::AppHandle) -> PathBuf {
app.path().app_data_dir()
.unwrap_or_else(|_| PathBuf::from("."))
.join("backups")
}
#[tauri::command]
async fn export_app_data(app: tauri::AppHandle, json: String) -> Result<String, String> {
use tauri_plugin_dialog::DialogExt;
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let filename = format!("moku-backup-{}.json", now);
let path = app.dialog()
.file()
.set_title("Save Moku app data backup")
.set_file_name(&filename)
.blocking_save_file()
.ok_or("Cancelled")?;
let dest = PathBuf::from(path.to_string());
std::fs::write(&dest, json.as_bytes()).map_err(|e| e.to_string())?;
Ok(dest.to_string_lossy().into_owned())
}
#[tauri::command]
async fn import_app_data(app: tauri::AppHandle) -> Result<String, String> {
use tauri_plugin_dialog::DialogExt;
let path = app.dialog()
.file()
.set_title("Open Moku app data backup")
.blocking_pick_file()
.ok_or("Cancelled")?;
let src = PathBuf::from(path.to_string());
let contents = std::fs::read_to_string(&src).map_err(|e| e.to_string())?;
Ok(contents)
}
#[tauri::command]
fn auto_backup_app_data(app: tauri::AppHandle, json: String) -> Result<(), String> {
let backup_dir = moku_backup_dir(&app);
std::fs::create_dir_all(&backup_dir).map_err(|e| e.to_string())?;
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let dest = backup_dir.join(format!("auto-moku-backup-{}.json", now));
std::fs::write(&dest, json.as_bytes()).map_err(|e| e.to_string())?;
let mut entries: Vec<_> = std::fs::read_dir(&backup_dir)
.map_err(|e| e.to_string())?
.filter_map(|e| e.ok())
.filter(|e| e.file_name().to_string_lossy().starts_with("auto-moku-backup-"))
.collect();
entries.sort_by_key(|e| e.file_name());
for old in entries.iter().take(entries.len().saturating_sub(5)) {
let _ = std::fs::remove_file(old.path());
}
Ok(())
}
#[tauri::command]
fn get_auto_backup_dir(app: tauri::AppHandle) -> String {
moku_backup_dir(&app).to_string_lossy().into_owned()
}
#[cfg_attr(mobile, tauri::mobile_entry_point)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() { pub fn run() {
tauri::Builder::default() tauri::Builder::default()
@@ -634,7 +811,6 @@ pub fn run() {
.plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_http::init()) .plugin(tauri_plugin_http::init())
.plugin(tauri_plugin_process::init()) .plugin(tauri_plugin_process::init())
.plugin(tauri_plugin_updater::Builder::new().build())
.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,
@@ -650,6 +826,10 @@ pub fn run() {
restart_app, restart_app,
open_path, open_path,
pick_downloads_folder, pick_downloads_folder,
export_app_data,
import_app_data,
auto_backup_app_data,
get_auto_backup_dir,
]) ])
.setup(|_app| Ok(())) .setup(|_app| Ok(()))
.on_window_event(|window, event| { .on_window_event(|window, event| {
+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.8.0", "version": "0.9.0",
"identifier": "io.github.Youwes09.Moku.app", "identifier": "io.github.Youwes09.Moku.app",
"build": { "build": {
"frontendDist": "../dist", "frontendDist": "../dist",
+85 -418
View File
@@ -2,59 +2,31 @@
import { onMount } from "svelte"; import { onMount } from "svelte";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event"; import { listen } from "@tauri-apps/api/event";
import { getVersion } from "@tauri-apps/api/app";
import { getCurrentWindow } from "@tauri-apps/api/window"; import { getCurrentWindow } from "@tauri-apps/api/window";
import { platform } from "@tauri-apps/plugin-os"; import { platform } from "@tauri-apps/plugin-os";
import { gql } from "./lib/client"; import { store, setActiveDownloads } from "@store/state.svelte";
import logoUrl from "./assets/moku-icon-splash.svg"; import { downloadStore } from "@features/downloads/store/downloadState.svelte";
import { probeServer, loginBasic, authSession, logout } from "./lib/auth"; import { boot, startProbe, stopProbe, retryBoot, bypassBoot } from "@store/boot.svelte";
import { GET_DOWNLOAD_STATUS } from "./lib/queries"; import { initRpc, setIdle, clearReading, destroyRpc } from "@store/discord";
import { store, addToast, setActiveDownloads, setSettingsOpen } from "./store/state.svelte"; import { applyTheme } from "@core/theme";
import { initRpc, setIdle, clearReading, destroyRpc } from "./lib/discord"; import { applyZoom, mountZoomKey, mountIdleDetection } from "@core/ui";
import type { DownloadStatus, DownloadQueueItem } from "./lib/types"; import { checkForUpdateSilently } from "@core/updater";
import Layout from "./components/chrome/Layout.svelte"; import Layout from "@shared/chrome/Layout.svelte";
import Reader from "./components/reader/Reader.svelte"; import Reader from "@features/reader/components/Reader.svelte";
import Settings from "./components/settings/Settings.svelte"; import Settings from "@features/settings/components/Settings.svelte";
import ThemeEditor from "./components/settings/ThemeEditor.svelte"; import ThemeEditor from "@features/settings/components/ThemeEditor.svelte";
import TitleBar from "./components/chrome/TitleBar.svelte"; import TitleBar from "@shared/chrome/TitleBar.svelte";
import Toaster from "./components/chrome/Toaster.svelte"; import Toaster from "@shared/chrome/Toaster.svelte";
import SplashScreen from "./components/chrome/SplashScreen.svelte"; import SplashScreen from "@shared/chrome/SplashScreen.svelte";
import MangaPreview from "./components/shared/MangaPreview.svelte"; import MangaPreview from "@shared/manga/MangaPreview.svelte";
import AuthGate from "@shared/chrome/AuthGate.svelte";
let themeStyleEl: HTMLStyleElement | null = null; const win = getCurrentWindow();
void platform();
$effect(() => { let appReady = $state(false);
const themeId = store.settings.theme ?? "dark"; let idle = $state(false);
const isCustom = themeId.startsWith("custom:"); let devSplash = $state(false);
if (!isCustom) {
themeStyleEl?.remove();
themeStyleEl = null;
document.documentElement.setAttribute("data-theme", themeId);
return;
}
const custom = store.settings.customThemes?.find(t => t.id === themeId);
if (!custom) {
themeStyleEl?.remove();
themeStyleEl = null;
document.documentElement.setAttribute("data-theme", "dark");
return;
}
const vars = Object.entries(custom.tokens)
.map(([k, v]) => ` --${k}: ${v};`)
.join("\n");
const css = `[data-theme="custom"] {\n${vars}\n}`;
if (!themeStyleEl) {
themeStyleEl = document.createElement("style");
themeStyleEl.id = "moku-custom-theme";
document.head.appendChild(themeStyleEl);
}
themeStyleEl.textContent = css;
document.documentElement.setAttribute("data-theme", "custom");
});
let themeEditorOpen = $state(false); let themeEditorOpen = $state(false);
let themeEditorEditId = $state<string | null>(null); let themeEditorEditId = $state<string | null>(null);
@@ -69,250 +41,16 @@
themeEditorEditId = null; themeEditorEditId = null;
} }
const MAX_ATTEMPTS = 10; $effect(() => { void store.settings.theme; applyTheme(); });
const win = getCurrentWindow(); $effect(() => { void store.settings.uiZoom; applyZoom(); });
const isWindows = platform() === "windows"; $effect(() => mountZoomKey());
let serverProbeOk = $state(false);
let appReady = $state(false);
let failed = $state(false);
let notConfigured = $state(false);
let idle = $state(false);
let devSplash = $state(false);
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() {
const uiZoom = store.settings.uiZoom ?? 1.0;
if (uiZoom === _appliedZoom) return;
_appliedZoom = uiZoom;
const pct = uiZoom * 100;
document.documentElement.style.setProperty("--ui-zoom", String(uiZoom));
document.documentElement.style.setProperty("--ui-scale", String(uiZoom));
document.documentElement.style.zoom = `${pct}%`;
if (_vhRafId !== null) cancelAnimationFrame(_vhRafId);
_vhRafId = requestAnimationFrame(() => {
_vhRafId = null;
document.documentElement.style.setProperty("--visual-vh", `${window.innerHeight / uiZoom}px`);
});
}
let prevQueue: DownloadQueueItem[] = [];
let idleTimer: ReturnType<typeof setTimeout> | null = null;
let pollInterval: ReturnType<typeof setInterval>;
let unlistenDownload: (() => void) | undefined;
function detectCompletions(prev: DownloadQueueItem[], next: DownloadQueueItem[]) {
for (const item of prev) {
if (item.state !== "DOWNLOADING") continue;
if (!next.some(q => q.chapter.id === item.chapter.id)) {
const manga = item.chapter.manga;
addToast({ kind: "success", title: "Chapter downloaded",
body: manga ? `${manga.title}${item.chapter.name}` : item.chapter.name,
duration: 4000 });
}
}
}
function applyQueue(next: DownloadQueueItem[]) {
detectCompletions(prevQueue, next);
prevQueue = next;
setActiveDownloads(next.map(item => ({
chapterId: item.chapter.id, mangaId: item.chapter.mangaId, progress: item.progress,
})));
}
function resetIdle() {
if (idleTimer) clearTimeout(idleTimer);
if (idle) return;
const ms = (store.settings.idleTimeoutMin ?? 5) * 60 * 1000;
if (ms === 0) return;
idleTimer = setTimeout(() => idle = true, ms);
}
const idleEvents = ["mousemove", "mousedown", "keydown", "touchstart", "wheel"] as const;
$effect(() => { $effect(() => {
if (!appReady) return; if (!appReady) return;
idleEvents.forEach(e => window.addEventListener(e, resetIdle, { passive: true })); return mountIdleDetection(
resetIdle(); () => { idle = true; },
return () => idleEvents.forEach(e => window.removeEventListener(e, resetIdle)); () => { if (idle) idle = false; },
}); );
$effect(() => {
void store.settings.uiZoom;
applyZoom();
});
$effect(() => {
if (!appReady) return;
let paused = false;
const poll = () => {
if (paused) return;
gql<{ downloadStatus: DownloadStatus }>(GET_DOWNLOAD_STATUS)
.then(d => applyQueue(d.downloadStatus.queue)).catch(console.error);
};
poll();
pollInterval = setInterval(poll, 2000);
const onVisibility = () => { paused = document.hidden; };
document.addEventListener("visibilitychange", onVisibility);
let unlistenFocus: (() => void) | undefined;
win.onFocusChanged(({ payload: focused }) => {
paused = !focused;
}).then(fn => { unlistenFocus = fn; });
return () => {
clearInterval(pollInterval);
document.removeEventListener("visibilitychange", onVisibility);
unlistenFocus?.();
};
});
async function checkForUpdateSilently() {
try {
const [currentVersion, releases] = await Promise.all([
getVersion(),
invoke<Array<{ tag_name: string; html_url: string }>>("list_releases"),
]);
const valid = releases.filter(r => typeof r.tag_name === "string" && r.tag_name.trim());
if (!valid.length) return;
const parse = (tag: string): number[] =>
tag.replace(/^v/, "").split(".").map(Number);
const compare = (a: number[], b: number[]): number => {
for (let i = 0; i < 3; i++) {
if ((a[i] ?? 0) !== (b[i] ?? 0)) return (b[i] ?? 0) - (a[i] ?? 0);
}
return 0;
};
const latestTag = valid
.map(r => r.tag_name)
.sort((a, b) => compare(parse(a), parse(b)))[0]
.replace(/^v/, "");
const isNewer = compare(parse(latestTag), parse(currentVersion)) < 0;
if (isNewer) {
addToast({
kind: "info",
title: `Update available — v${latestTag}`,
body: "Open Settings → About to install.",
duration: 8000,
});
}
} catch {}
}
let cancelProbe = false;
function startProbe() {
cancelProbe = false;
failed = false;
loginRequired = false;
let tries = 0;
async function probe() {
if (cancelProbe) return;
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 {
await loginBasic(savedUser, savedPass);
loginRequired = false;
return;
} catch {}
}
loginRequired = true;
return;
}
if (result === "unsupported_mode") {
serverProbeOk = true;
unsupportedMode = true;
return;
}
if (tries >= MAX_ATTEMPTS) { failed = true; return; }
setTimeout(probe, 750);
}
setTimeout(probe, 800);
}
onMount(async () => {
document.addEventListener("contextmenu", e => e.preventDefault());
(window as any).__mokuShowSplash = () => devSplash = true;
platformScale = await invoke<number>("get_platform_ui_scale").catch(() => 1.0);
applyZoom();
store.isFullscreen = await win.isFullscreen();
const unlistenResize = await win.onResized(async () => {
store.isFullscreen = await win.isFullscreen();
});
const unlistenScale = await win.onScaleChanged(async (event) => {
platformScale = event.payload.scaleFactor;
applyZoom();
});
if (store.settings.autoStartServer) {
invoke<void>("spawn_server", { binary: store.settings.serverBinary }).catch((err: any) => {
if (err?.kind === "NotConfigured") {
notConfigured = true;
} else {
console.warn("Could not start server:", err);
}
});
}
startProbe();
type P = { chapterId: number; mangaId: number; progress: number }[];
unlistenDownload = await listen<P>("download-progress", e => { setActiveDownloads(e.payload); });
return () => {
cancelProbe = true;
unlistenResize();
unlistenScale();
destroyRpc();
if (store.settings.autoStartServer) invoke("kill_server").catch(() => {});
if (idleTimer) clearTimeout(idleTimer);
if (pollInterval) clearInterval(pollInterval);
unlistenDownload?.();
delete (window as any).__mokuShowSplash;
};
}); });
$effect(() => { $effect(() => {
@@ -331,139 +69,90 @@
}); });
$effect(() => { $effect(() => {
if (!store.activeChapter) { if (!store.activeChapter && store.settings.discordRpc) setIdle();
if (store.settings.discordRpc) setIdle();
}
}); });
function handleZoomKey(e: KeyboardEvent) {
if (!e.ctrlKey) return;
if (e.key === "=" || e.key === "+") {
e.preventDefault();
store.settings.uiZoom = Math.min(2.0, Math.round(((store.settings.uiZoom ?? 1.0) + 0.1) * 10) / 10);
} else if (e.key === "-") {
e.preventDefault();
store.settings.uiZoom = Math.max(0.5, Math.round(((store.settings.uiZoom ?? 1.0) - 0.1) * 10) / 10);
} else if (e.key === "0") {
e.preventDefault();
store.settings.uiZoom = 1.0;
}
}
$effect(() => { $effect(() => {
window.addEventListener("keydown", handleZoomKey); const next = downloadStore.queue.slice();
return () => window.removeEventListener("keydown", handleZoomKey); downloadStore.detectTransitions(next);
}); });
async function handleLogin() { onMount(async () => {
if (!loginUser.trim() || !loginPass.trim()) { document.addEventListener("contextmenu", e => e.preventDefault());
loginError = "Username and password are required"; (window as any).__mokuShowSplash = () => { devSplash = true; };
return;
} applyZoom();
loginBusy = true;
loginError = null; store.isFullscreen = await win.isFullscreen();
try {
await loginBasic(loginUser.trim(), loginPass.trim()); const unlistenResize = await win.onResized(async () => {
loginRequired = false; store.isFullscreen = await win.isFullscreen();
loginPass = ""; });
loginError = null;
appReady = true; const unlistenScale = await win.onScaleChanged(async () => {
} catch (e: any) { applyZoom();
loginError = e?.message ?? "Login failed"; });
} finally {
loginBusy = false; if (store.settings.autoStartServer) {
} invoke<void>("spawn_server", { binary: store.settings.serverBinary }).catch((err: any) => {
if (err?.kind === "NotConfigured") boot.notConfigured = true;
else console.warn("Could not start server:", err);
});
} }
function handleRetry() {
failed = false;
notConfigured = false;
serverProbeOk = false;
loginRequired = false;
unsupportedMode = false;
startProbe(); startProbe();
}
function handleBypass() { const unlistenDownload = await listen<{ chapterId: number; mangaId: number; progress: number }[]>(
cancelProbe = true; "download-progress",
serverProbeOk = true; e => setActiveDownloads(e.payload),
loginRequired = false; );
unsupportedMode = false;
appReady = true; await downloadStore.poll();
} const dlInterval = setInterval(() => downloadStore.poll(), 2000);
return () => {
stopProbe();
clearInterval(dlInterval);
unlistenResize();
unlistenScale();
unlistenDownload();
destroyRpc();
if (store.settings.autoStartServer) invoke("kill_server").catch(() => {});
delete (window as any).__mokuShowSplash;
};
});
</script> </script>
{#if devSplash} {#if devSplash}
<SplashScreen mode="idle" showFps showCards={store.settings.splashCards ?? true} <SplashScreen mode="idle" showFps showCards={store.settings.splashCards ?? true}
onDismiss={() => setTimeout(() => devSplash = false, 340)} /> onDismiss={() => setTimeout(() => devSplash = false, 340)} />
{:else if !appReady && !loginRequired && !unsupportedMode}
<SplashScreen mode="loading" ringFull={serverProbeOk} {failed} {notConfigured} {:else if !appReady && !boot.loginRequired && !boot.unsupportedMode}
<SplashScreen mode="loading" ringFull={boot.serverProbeOk}
failed={boot.failed} notConfigured={boot.notConfigured}
showCards={store.settings.splashCards ?? true} showCards={store.settings.splashCards ?? true}
onReady={() => { appReady = true; }} onReady={() => { appReady = true; }}
onRetry={handleRetry} onRetry={retryBoot}
onBypass={handleBypass} /> onBypass={() => bypassBoot(() => { appReady = true; })} />
{:else if unsupportedMode}
{:else if boot.unsupportedMode || boot.loginRequired}
<SplashScreen mode="loading" ringFull={true} showCards={store.settings.splashCards ?? true} /> <SplashScreen mode="loading" ringFull={true} showCards={store.settings.splashCards ?? true} />
<div class="auth-overlay"> <AuthGate onReady={() => { appReady = true; }} />
<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 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; }} />
{/if} {/if}
<div id="app-shell" class="root">
{#if !store.activeChapter}<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>
{#if store.settingsOpen}<Settings onOpenThemeEditor={openThemeEditor} />{/if} {#if store.settingsOpen}<Settings onOpenThemeEditor={openThemeEditor} />{/if}
{#if themeEditorOpen} {#if themeEditorOpen}
<ThemeEditor <ThemeEditor bind:editingId={themeEditorEditId} onClose={closeThemeEditor} />
bind:editingId={themeEditorEditId}
onClose={closeThemeEditor}
/>
{/if} {/if}
<MangaPreview /> <MangaPreview />
<Toaster /> <Toaster />
@@ -473,26 +162,4 @@
<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 { 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>
+9 -19
View File
@@ -1,5 +1,5 @@
import { store } from "../store/state.svelte"; import { store } from "@store/state.svelte";
import { fetchAuthenticated } from "./auth"; import { fetchAuthenticated } from "../core/auth";
const DEFAULT_URL = "http://127.0.0.1:4567"; const DEFAULT_URL = "http://127.0.0.1:4567";
@@ -8,17 +8,13 @@ function getServerUrl(): string {
return typeof url === "string" && url.trim() ? url.replace(/\/$/, "") : DEFAULT_URL; return typeof url === "string" && url.trim() ? url.replace(/\/$/, "") : DEFAULT_URL;
} }
function gqlUrl(): string { return `${getServerUrl()}/api/graphql`; }
export function plainThumbUrl(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 { export const thumbUrl = plainThumbUrl;
return plainThumbUrl(path);
}
interface GQLResponse<T> { interface GQLResponse<T> {
data: T; data: T;
@@ -44,7 +40,6 @@ async function fetchWithRetry(
delayMs = 300, delayMs = 300,
): Promise<Response> { ): Promise<Response> {
if (signal?.aborted) throw new DOMException("Aborted", "AbortError"); if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
for (let i = 0; i < retries; i++) { for (let i = 0; i < retries; i++) {
if (signal?.aborted) throw new DOMException("Aborted", "AbortError"); if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
try { try {
@@ -53,8 +48,7 @@ async function fetchWithRetry(
return res; return res;
} catch (e: any) { } catch (e: any) {
if (e?.authRequired) throw e; if (e?.authRequired) throw e;
const isAbort = e?.name === "AbortError" || signal?.aborted; if (e?.name === "AbortError" || signal?.aborted) throw new DOMException("Aborted", "AbortError");
if (isAbort) throw new DOMException("Aborted", "AbortError");
if (i === retries - 1) throw e; if (i === retries - 1) throw e;
await abortableSleep(delayMs * Math.pow(1.5, i), signal); await abortableSleep(delayMs * Math.pow(1.5, i), signal);
} }
@@ -67,19 +61,15 @@ export async function gql<T>(
variables?: Record<string, unknown>, variables?: Record<string, unknown>,
signal?: AbortSignal, signal?: AbortSignal,
): Promise<T> { ): Promise<T> {
const res = await fetchWithRetry(gqlUrl(), { const res = await fetchWithRetry(
method: "POST", `${getServerUrl()}/api/graphql`,
headers: { "Content-Type": "application/json" }, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ query, variables }) },
body: JSON.stringify({ query, variables }), signal,
}, signal); );
if (signal?.aborted) throw new DOMException("Aborted", "AbortError"); if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
if (!res.ok) throw new Error(`Suwayomi HTTP ${res.status}`); if (!res.ok) throw new Error(`Suwayomi HTTP ${res.status}`);
const json: GQLResponse<T> = await res.json(); const json: GQLResponse<T> = await res.json();
if (signal?.aborted) throw new DOMException("Aborted", "AbortError"); if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
if (json.errors?.length) throw new Error(json.errors[0].message); if (json.errors?.length) throw new Error(json.errors[0].message);
return json.data; return json.data;
} }
+11
View File
@@ -0,0 +1,11 @@
export * from "./client";
export * from "./queries/manga";
export * from "./queries/chapters";
export * from "./queries/downloads";
export * from "./queries/extensions";
export * from "./queries/tracking";
export * from "./mutations/manga";
export * from "./mutations/chapters";
export * from "./mutations/downloads";
export * from "./mutations/extensions";
export * from "./mutations/tracking";
+48
View File
@@ -0,0 +1,48 @@
export const FETCH_CHAPTERS = `
mutation FetchChapters($mangaId: Int!) {
fetchChapters(input: { mangaId: $mangaId }) {
chapters {
id name chapterNumber sourceOrder isRead isDownloaded isBookmarked
pageCount mangaId uploadDate realUrl lastPageRead scanlator
}
}
}
`;
export const FETCH_CHAPTER_PAGES = `
mutation FetchChapterPages($chapterId: Int!) {
fetchChapterPages(input: { chapterId: $chapterId }) { pages }
}
`;
export const MARK_CHAPTER_READ = `
mutation MarkChapterRead($id: Int!, $isRead: Boolean!) {
updateChapter(input: { id: $id, patch: { isRead: $isRead } }) {
chapter { id isRead }
}
}
`;
export const MARK_CHAPTERS_READ = `
mutation MarkChaptersRead($ids: [Int!]!, $isRead: Boolean!) {
updateChapters(input: { ids: $ids, patch: { isRead: $isRead } }) {
chapters { id isRead }
}
}
`;
export const UPDATE_CHAPTERS_PROGRESS = `
mutation UpdateChaptersProgress($ids: [Int!]!, $isRead: Boolean, $isBookmarked: Boolean, $lastPageRead: Int) {
updateChapters(input: { ids: $ids, patch: { isRead: $isRead, isBookmarked: $isBookmarked, lastPageRead: $lastPageRead } }) {
chapters { id isRead isBookmarked lastPageRead }
}
}
`;
export const DELETE_DOWNLOADED_CHAPTERS = `
mutation DeleteDownloadedChapters($ids: [Int!]!) {
deleteDownloadedChapters(input: { ids: $ids }) {
chapters { id isDownloaded }
}
}
`;
+99
View File
@@ -0,0 +1,99 @@
const QUEUE_FRAGMENT = `
state
queue {
progress state tries
chapter {
id name pageCount mangaId
manga { id title thumbnailUrl }
}
}
`;
export const ENQUEUE_DOWNLOAD = `
mutation EnqueueDownload($chapterId: Int!) {
enqueueChapterDownload(input: { id: $chapterId }) {
downloadStatus { ${QUEUE_FRAGMENT} }
}
}
`;
export const ENQUEUE_CHAPTERS_DOWNLOAD = `
mutation EnqueueChaptersDownload($chapterIds: [Int!]!) {
enqueueChapterDownloads(input: { ids: $chapterIds }) {
downloadStatus { state }
}
}
`;
export const DEQUEUE_DOWNLOAD = `
mutation DequeueDownload($chapterId: Int!) {
dequeueChapterDownload(input: { id: $chapterId }) {
downloadStatus { state }
}
}
`;
export const DEQUEUE_CHAPTERS_DOWNLOAD = `
mutation DequeueChaptersDownload($chapterIds: [Int!]!) {
dequeueChapterDownloads(input: { ids: $chapterIds }) {
downloadStatus { ${QUEUE_FRAGMENT} }
}
}
`;
export const REORDER_DOWNLOAD = `
mutation ReorderDownload($chapterId: Int!, $to: Int!) {
reorderChapterDownload(input: { chapterId: $chapterId, to: $to }) {
downloadStatus { ${QUEUE_FRAGMENT} }
}
}
`;
export const START_DOWNLOADER = `
mutation StartDownloader {
startDownloader(input: {}) {
downloadStatus { ${QUEUE_FRAGMENT} }
}
}
`;
export const STOP_DOWNLOADER = `
mutation StopDownloader {
stopDownloader(input: {}) {
downloadStatus { ${QUEUE_FRAGMENT} }
}
}
`;
export const CLEAR_DOWNLOADER = `
mutation ClearDownloader {
clearDownloader(input: {}) {
downloadStatus { ${QUEUE_FRAGMENT} }
}
}
`;
export const FETCH_SOURCE_MANGA = `
mutation FetchSourceManga($source: LongString!, $type: FetchSourceMangaType!, $page: Int!, $query: String, $filters: [FilterChangeInput!]) {
fetchSourceManga(input: { source: $source, type: $type, page: $page, query: $query, filters: $filters }) {
mangas { id title thumbnailUrl inLibrary }
hasNextPage
}
}
`;
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 }
}
}
`;
+89
View File
@@ -0,0 +1,89 @@
export const FETCH_EXTENSIONS = `
mutation FetchExtensions {
fetchExtensions(input: {}) {
extensions {
apkName pkgName name lang versionName
isInstalled isObsolete hasUpdate iconUrl
}
}
}
`;
export const UPDATE_EXTENSION = `
mutation UpdateExtension($id: String!, $install: Boolean, $uninstall: Boolean, $update: Boolean) {
updateExtension(input: { id: $id, patch: { install: $install, uninstall: $uninstall, update: $update } }) {
extension { apkName pkgName name isInstalled hasUpdate }
}
}
`;
export const INSTALL_EXTERNAL_EXTENSION = `
mutation InstallExternalExtension($url: String!) {
installExternalExtension(input: { extensionUrl: $url }) {
extension { apkName pkgName name isInstalled }
}
}
`;
export const SET_EXTENSION_REPOS = `
mutation SetExtensionRepos($repos: [String!]!) {
setSettings(input: { settings: { extensionRepos: $repos } }) {
settings { extensionRepos }
}
}
`;
export const SET_SERVER_AUTH = `
mutation SetServerAuth($authMode: AuthMode!, $authUsername: String!, $authPassword: String!) {
setSettings(input: { settings: { authMode: $authMode, authUsername: $authUsername, authPassword: $authPassword } }) {
settings { authMode authUsername }
}
}
`;
export const SET_SOCKS_PROXY = `
mutation SetSocksProxy(
$socksProxyEnabled: Boolean!
$socksProxyHost: String!
$socksProxyPort: String!
$socksProxyVersion: Int!
$socksProxyUsername: String!
$socksProxyPassword: String!
) {
setSettings(input: { settings: {
socksProxyEnabled: $socksProxyEnabled
socksProxyHost: $socksProxyHost
socksProxyPort: $socksProxyPort
socksProxyVersion: $socksProxyVersion
socksProxyUsername: $socksProxyUsername
socksProxyPassword: $socksProxyPassword
}}) {
settings { socksProxyEnabled socksProxyHost socksProxyPort socksProxyVersion socksProxyUsername }
}
}
`;
export const SET_FLARESOLVERR = `
mutation SetFlareSolverr(
$flareSolverrEnabled: Boolean!
$flareSolverrUrl: String!
$flareSolverrTimeout: Int!
$flareSolverrSessionName: String!
$flareSolverrSessionTtl: Int!
$flareSolverrAsResponseFallback: Boolean!
) {
setSettings(input: { settings: {
flareSolverrEnabled: $flareSolverrEnabled
flareSolverrUrl: $flareSolverrUrl
flareSolverrTimeout: $flareSolverrTimeout
flareSolverrSessionName: $flareSolverrSessionName
flareSolverrSessionTtl: $flareSolverrSessionTtl
flareSolverrAsResponseFallback: $flareSolverrAsResponseFallback
}}) {
settings {
flareSolverrEnabled flareSolverrUrl flareSolverrTimeout
flareSolverrSessionName flareSolverrSessionTtl flareSolverrAsResponseFallback
}
}
}
`;
+5
View File
@@ -0,0 +1,5 @@
export * from "./manga";
export * from "./chapters";
export * from "./downloads";
export * from "./extensions";
export * from "./tracking";
+91
View File
@@ -0,0 +1,91 @@
export const FETCH_MANGA = `
mutation FetchManga($id: Int!) {
fetchManga(input: { id: $id }) {
manga {
id title description thumbnailUrl status author artist genre inLibrary realUrl
source { id name displayName }
}
}
}
`;
export const UPDATE_MANGA = `
mutation UpdateManga($id: Int!, $inLibrary: Boolean) {
updateManga(input: { id: $id, patch: { inLibrary: $inLibrary } }) {
manga { id inLibrary }
}
}
`;
export const UPDATE_MANGAS = `
mutation UpdateMangas($ids: [Int!]!, $inLibrary: Boolean) {
updateMangas(input: { ids: $ids, patch: { inLibrary: $inLibrary } }) {
mangas { id inLibrary }
}
}
`;
export const UPDATE_MANGA_CATEGORIES = `
mutation UpdateMangaCategories($mangaId: Int!, $addTo: [Int!]!, $removeFrom: [Int!]!) {
updateMangaCategories(input: { id: $mangaId, patch: { addToCategories: $addTo, removeFromCategories: $removeFrom } }) {
manga { id }
}
}
`;
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_LIBRARY = `
mutation UpdateLibrary {
updateLibrary(input: {}) {
updateStatus {
jobsInfo { isRunning finishedJobs totalJobs }
}
}
}
`;
export const CREATE_BACKUP = `
mutation CreateBackup {
createBackup(input: {}) { url }
}
`;
export const RESTORE_BACKUP = `
mutation RestoreBackup($backup: Upload!) {
restoreBackup(input: { backup: $backup }) {
id
status { mangaProgress state totalManga }
}
}
`;
+450
View File
@@ -0,0 +1,450 @@
# Mutations
## Manga (`mutations/manga.ts`)
### `FETCH_MANGA`
Fetches and refreshes manga metadata from its source.
**Variables:**
| Name | Type | Description |
|------|------|-------------|
| `id` | `Int!` | Manga ID |
---
### `UPDATE_MANGA`
Updates a single manga's library membership.
**Variables:**
| Name | Type | Description |
|------|------|-------------|
| `id` | `Int!` | Manga ID |
| `inLibrary` | `Boolean` | Add/remove from library |
---
### `UPDATE_MANGAS`
Bulk-updates library membership for multiple manga.
**Variables:**
| Name | Type | Description |
|------|------|-------------|
| `ids` | `[Int!]!` | Manga IDs |
| `inLibrary` | `Boolean` | Add/remove from library |
---
### `UPDATE_MANGA_CATEGORIES`
Adds or removes a manga from categories.
**Variables:**
| Name | Type | Description |
|------|------|-------------|
| `mangaId` | `Int!` | Manga ID |
| `addTo` | `[Int!]!` | Category IDs to add to |
| `removeFrom` | `[Int!]!` | Category IDs to remove from |
---
### `CREATE_CATEGORY`
Creates a new manga category.
**Variables:**
| Name | Type | Description |
|------|------|-------------|
| `name` | `String!` | Category name |
---
### `UPDATE_CATEGORY`
Updates a category's name.
**Variables:**
| Name | Type | Description |
|------|------|-------------|
| `id` | `Int!` | Category ID |
| `name` | `String` | New name |
---
### `DELETE_CATEGORY`
Deletes a category by ID.
**Variables:**
| Name | Type | Description |
|------|------|-------------|
| `id` | `Int!` | Category ID |
---
### `UPDATE_CATEGORY_ORDER`
Moves a category to a new position.
**Variables:**
| Name | Type | Description |
|------|------|-------------|
| `id` | `Int!` | Category ID |
| `position` | `Int!` | New position index |
---
### `UPDATE_LIBRARY`
Triggers a library-wide metadata refresh and returns job status.
**Variables:** none
---
### `CREATE_BACKUP`
Creates a backup and returns its download URL.
**Variables:** none
---
### `RESTORE_BACKUP`
Restores a backup from an uploaded file and returns restore job status.
**Variables:**
| Name | Type | Description |
|------|------|-------------|
| `backup` | `Upload!` | Backup file |
---
## Chapters (`mutations/chapters.ts`)
### `FETCH_CHAPTERS`
Fetches/refreshes the chapter list for a manga from its source.
**Variables:**
| Name | Type | Description |
|------|------|-------------|
| `mangaId` | `Int!` | Manga ID |
---
### `FETCH_CHAPTER_PAGES`
Fetches the page URLs for a specific chapter.
**Variables:**
| Name | Type | Description |
|------|------|-------------|
| `chapterId` | `Int!` | Chapter ID |
---
### `MARK_CHAPTER_READ`
Marks a single chapter as read or unread.
**Variables:**
| Name | Type | Description |
|------|------|-------------|
| `id` | `Int!` | Chapter ID |
| `isRead` | `Boolean!` | Read state |
---
### `MARK_CHAPTERS_READ`
Bulk-marks multiple chapters as read or unread.
**Variables:**
| Name | Type | Description |
|------|------|-------------|
| `ids` | `[Int!]!` | Chapter IDs |
| `isRead` | `Boolean!` | Read state |
---
### `UPDATE_CHAPTERS_PROGRESS`
Bulk-updates read state, bookmark state, and last page read for multiple chapters.
**Variables:**
| Name | Type | Description |
|------|------|-------------|
| `ids` | `[Int!]!` | Chapter IDs |
| `isRead` | `Boolean` | Read state |
| `isBookmarked` | `Boolean` | Bookmark state |
| `lastPageRead` | `Int` | Last page index read |
---
### `DELETE_DOWNLOADED_CHAPTERS`
Deletes downloaded chapter files for the given chapter IDs.
**Variables:**
| Name | Type | Description |
|------|------|-------------|
| `ids` | `[Int!]!` | Chapter IDs |
---
## Downloads (`mutations/downloads.ts`)
### `ENQUEUE_DOWNLOAD`
Adds a single chapter to the download queue.
**Variables:**
| Name | Type | Description |
|------|------|-------------|
| `chapterId` | `Int!` | Chapter ID |
---
### `ENQUEUE_CHAPTERS_DOWNLOAD`
Adds multiple chapters to the download queue.
**Variables:**
| Name | Type | Description |
|------|------|-------------|
| `chapterIds` | `[Int!]!` | Chapter IDs |
---
### `DEQUEUE_DOWNLOAD`
Removes a chapter from the download queue.
**Variables:**
| Name | Type | Description |
|------|------|-------------|
| `chapterId` | `Int!` | Chapter ID |
---
### `START_DOWNLOADER`
Starts the downloader and returns the current queue state.
**Variables:** none
---
### `STOP_DOWNLOADER`
Stops the downloader and returns the current queue state.
**Variables:** none
---
### `CLEAR_DOWNLOADER`
Clears all items from the download queue.
**Variables:** none
---
### `FETCH_SOURCE_MANGA`
Fetches manga from a source (browse/search), with pagination and optional filters.
**Variables:**
| Name | Type | Description |
|------|------|-------------|
| `source` | `LongString!` | Source ID |
| `type` | `FetchSourceMangaType!` | Browse type (e.g. popular, latest, search) |
| `page` | `Int!` | Page number |
| `query` | `String` | Search query |
| `filters` | `[FilterChangeInput!]` | Source-specific filters |
---
### `SET_DOWNLOADS_PATH`
Sets the downloads directory path in settings.
**Variables:**
| Name | Type | Description |
|------|------|-------------|
| `path` | `String!` | Filesystem path |
---
### `SET_LOCAL_SOURCE_PATH`
Sets the local source directory path in settings.
**Variables:**
| Name | Type | Description |
|------|------|-------------|
| `path` | `String!` | Filesystem path |
---
## Extensions (`mutations/extensions.ts`)
### `FETCH_EXTENSIONS`
Fetches the latest extension list from configured repos.
**Variables:** none
---
### `UPDATE_EXTENSION`
Installs, uninstalls, or updates an extension.
**Variables:**
| Name | Type | Description |
|------|------|-------------|
| `id` | `String!` | Extension package name |
| `install` | `Boolean` | Install the extension |
| `uninstall` | `Boolean` | Uninstall the extension |
| `update` | `Boolean` | Update the extension |
---
### `INSTALL_EXTERNAL_EXTENSION`
Installs an extension from an external APK URL.
**Variables:**
| Name | Type | Description |
|------|------|-------------|
| `url` | `String!` | APK download URL |
---
### `SET_EXTENSION_REPOS`
Sets the list of extension repository URLs.
**Variables:**
| Name | Type | Description |
|------|------|-------------|
| `repos` | `[String!]!` | Repository URLs |
---
### `SET_SERVER_AUTH`
Configures server authentication mode and credentials.
**Variables:**
| Name | Type | Description |
|------|------|-------------|
| `authMode` | `AuthMode!` | Auth mode |
| `authUsername` | `String!` | Username |
| `authPassword` | `String!` | Password |
---
### `SET_SOCKS_PROXY`
Configures SOCKS proxy settings.
**Variables:**
| Name | Type | Description |
|------|------|-------------|
| `socksProxyEnabled` | `Boolean!` | Enable/disable proxy |
| `socksProxyHost` | `String!` | Proxy host |
| `socksProxyPort` | `String!` | Proxy port |
| `socksProxyVersion` | `Int!` | SOCKS version (4 or 5) |
| `socksProxyUsername` | `String!` | Proxy username |
| `socksProxyPassword` | `String!` | Proxy password |
---
### `SET_FLARESOLVERR`
Configures FlareSolverr integration settings.
**Variables:**
| Name | Type | Description |
|------|------|-------------|
| `flareSolverrEnabled` | `Boolean!` | Enable/disable FlareSolverr |
| `flareSolverrUrl` | `String!` | FlareSolverr URL |
| `flareSolverrTimeout` | `Int!` | Request timeout (ms) |
| `flareSolverrSessionName` | `String!` | Session name |
| `flareSolverrSessionTtl` | `Int!` | Session TTL (seconds) |
| `flareSolverrAsResponseFallback` | `Boolean!` | Use as fallback only |
---
## Tracking (`mutations/tracking.ts`)
### `BIND_TRACK`
Binds a manga to a remote tracker entry.
**Variables:**
| Name | Type | Description |
|------|------|-------------|
| `mangaId` | `Int!` | Manga ID |
| `trackerId` | `Int!` | Tracker ID |
| `remoteId` | `LongString!` | Remote entry ID on the tracker |
---
### `UPDATE_TRACK`
Updates tracking progress, status, score, and dates for a track record.
**Variables:**
| Name | Type | Description |
|------|------|-------------|
| `recordId` | `Int!` | Track record ID |
| `status` | `Int` | Reading status |
| `lastChapterRead` | `Float` | Last chapter read |
| `scoreString` | `String` | Score in tracker's format |
| `startDate` | `LongString` | Start date |
| `finishDate` | `LongString` | Finish date |
| `private` | `Boolean` | Mark as private |
---
### `UNBIND_TRACK`
Unbinds a manga from a tracker record.
**Variables:**
| Name | Type | Description |
|------|------|-------------|
| `recordId` | `Int!` | Track record ID |
---
### `FETCH_TRACK`
Refreshes a track record from the remote tracker.
**Variables:**
| Name | Type | Description |
|------|------|-------------|
| `recordId` | `Int!` | Track record ID |
---
### `LOGIN_TRACKER_OAUTH`
Initiates OAuth login for a tracker using a callback URL.
**Variables:**
| Name | Type | Description |
|------|------|-------------|
| `trackerId` | `Int!` | Tracker ID |
| `callbackUrl` | `String!` | OAuth callback URL |
---
### `LOGIN_TRACKER_CREDENTIALS`
Logs into a tracker using username and password.
**Variables:**
| Name | Type | Description |
|------|------|-------------|
| `trackerId` | `Int!` | Tracker ID |
| `username` | `String!` | Username |
| `password` | `String!` | Password |
---
### `LOGOUT_TRACKER`
Logs out of a tracker.
**Variables:**
| Name | Type | Description |
|------|------|-------------|
| `trackerId` | `Int!` | Tracker ID |
---
### `LOGIN_USER`
Authenticates a user and returns access and refresh tokens.
**Variables:**
| Name | Type | Description |
|------|------|-------------|
| `username` | `String!` | Username |
| `password` | `String!` | Password |
---
### `REFRESH_TOKEN`
Refreshes the current access token.
**Variables:** none
+80
View File
@@ -0,0 +1,80 @@
const TRACK_RECORD_FRAGMENT = `
id trackerId remoteId title status score displayScore
lastChapterRead totalChapters remoteUrl startDate finishDate private
`;
export const BIND_TRACK = `
mutation BindTrack($mangaId: Int!, $trackerId: Int!, $remoteId: LongString!) {
bindTrack(input: { mangaId: $mangaId, trackerId: $trackerId, remoteId: $remoteId }) {
trackRecord { ${TRACK_RECORD_FRAGMENT} }
}
}
`;
export const UPDATE_TRACK = `
mutation UpdateTrack($recordId: Int!, $status: Int, $lastChapterRead: Float, $scoreString: String, $startDate: LongString, $finishDate: LongString, $private: Boolean) {
updateTrack(input: { recordId: $recordId, status: $status, lastChapterRead: $lastChapterRead, scoreString: $scoreString, startDate: $startDate, finishDate: $finishDate, private: $private }) {
trackRecord {
id trackerId status score displayScore lastChapterRead totalChapters startDate finishDate private
}
}
}
`;
export const UNBIND_TRACK = `
mutation UnbindTrack($recordId: Int!) {
unbindTrack(input: { recordId: $recordId }) {
trackRecord { id }
}
}
`;
export const FETCH_TRACK = `
mutation FetchTrack($recordId: Int!) {
fetchTrack(input: { recordId: $recordId }) {
trackRecord {
id trackerId status score displayScore lastChapterRead totalChapters startDate finishDate
}
}
}
`;
export const LOGIN_TRACKER_OAUTH = `
mutation LoginTrackerOAuth($trackerId: Int!, $callbackUrl: String!) {
loginTrackerOAuth(input: { trackerId: $trackerId, callbackUrl: $callbackUrl }) {
isLoggedIn
tracker { id name isLoggedIn authUrl }
}
}
`;
export const LOGIN_TRACKER_CREDENTIALS = `
mutation LoginTrackerCredentials($trackerId: Int!, $username: String!, $password: String!) {
loginTrackerCredentials(input: { trackerId: $trackerId, username: $username, password: $password }) {
isLoggedIn
tracker { id name isLoggedIn authUrl }
}
}
`;
export const LOGOUT_TRACKER = `
mutation LogoutTracker($trackerId: Int!) {
logoutTracker(input: { trackerId: $trackerId }) {
tracker { id name isLoggedIn authUrl }
}
}
`;
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 }
}
`;
+22
View File
@@ -0,0 +1,22 @@
export const GET_RECENTLY_UPDATED = `
query GetRecentlyUpdated {
chapters(orderBy: FETCHED_AT, orderByType: DESC, first: 300) {
nodes {
mangaId
fetchedAt
manga { id title thumbnailUrl inLibrary }
}
}
}
`;
export const GET_CHAPTERS = `
query GetChapters($mangaId: Int!) {
chapters(condition: { mangaId: $mangaId }) {
nodes {
id name chapterNumber sourceOrder isRead isDownloaded isBookmarked
pageCount mangaId uploadDate realUrl lastPageRead scanlator
}
}
}
`;
+14
View File
@@ -0,0 +1,14 @@
export const GET_DOWNLOAD_STATUS = `
query GetDownloadStatus {
downloadStatus {
state
queue {
progress state
chapter {
id name pageCount mangaId
manga { id title thumbnailUrl }
}
}
}
}
`;
+43
View File
@@ -0,0 +1,43 @@
export const GET_LOCAL_MANGA = `
query GetLocalManga {
mangas(condition: { sourceId: "0" }) {
nodes { id title thumbnailUrl inLibrary }
}
}
`;
export const GET_EXTENSIONS = `
query GetExtensions {
extensions {
nodes {
apkName pkgName name lang versionName
isInstalled isObsolete hasUpdate iconUrl
}
}
}
`;
export const GET_SOURCES = `
query GetSources {
sources {
nodes { id name lang displayName iconUrl isNsfw }
}
}
`;
export const GET_SETTINGS = `
query GetSettings {
settings { extensionRepos }
}
`;
export const GET_SERVER_SECURITY = `
query GetServerSecurity {
settings {
authMode authUsername
socksProxyEnabled socksProxyHost socksProxyPort socksProxyVersion socksProxyUsername
flareSolverrEnabled flareSolverrUrl flareSolverrTimeout
flareSolverrSessionName flareSolverrSessionTtl flareSolverrAsResponseFallback
}
}
`;
+5
View File
@@ -0,0 +1,5 @@
export * from "./manga";
export * from "./chapters";
export * from "./downloads";
export * from "./extensions";
export * from "./tracking";
+96
View File
@@ -0,0 +1,96 @@
export const GET_LIBRARY = `
query GetLibrary {
mangas(condition: { inLibrary: true }) {
nodes {
id title thumbnailUrl inLibrary downloadCount unreadCount
description status author artist genre
source { id name displayName }
chapters { totalCount }
}
}
}
`;
export const GET_ALL_MANGA = `
query GetAllManga {
mangas {
nodes { id title thumbnailUrl inLibrary downloadCount }
}
}
`;
export const GET_MANGA = `
query GetManga($id: Int!) {
manga(id: $id) {
id title description thumbnailUrl status author artist genre inLibrary realUrl
source { id name displayName }
}
}
`;
export const GET_CATEGORIES = `
query GetCategories {
categories {
nodes {
id name order default includeInUpdate includeInDownload
mangas {
nodes { id title thumbnailUrl inLibrary downloadCount unreadCount }
}
}
}
}
`;
export const GET_DOWNLOADED_CHAPTERS_PAGES = `
query GetDownloadedChaptersPages {
chapters(condition: { isDownloaded: true }) {
nodes { pageCount }
}
}
`;
export const GET_DOWNLOADS_PATH = `
query GetDownloadsPath {
settings { downloadsPath localSourcePath }
}
`;
export const LIBRARY_UPDATE_STATUS = `
query LibraryUpdateStatus {
libraryUpdateStatus {
jobsInfo { isRunning finishedJobs totalJobs skippedMangasCount }
mangaUpdates {
status
manga { id title thumbnailUrl unreadCount }
}
}
}
`;
export const GET_RESTORE_STATUS = `
query GetRestoreStatus($id: String!) {
restoreStatus(id: $id) { mangaProgress state totalManga }
}
`;
export const VALIDATE_BACKUP = `
query ValidateBackup($backup: Upload!) {
validateBackup(input: { backup: $backup }) {
missingSources { id name }
missingTrackers { name }
}
}
`;
export const MANGAS_BY_GENRE = `
query MangasByGenre($filter: MangaFilterInput, $first: Int, $offset: Int) {
mangas(filter: $filter, first: $first, offset: $offset, orderBy: IN_LIBRARY_AT, orderByType: DESC) {
nodes {
id title thumbnailUrl inLibrary genre status
source { id displayName }
}
pageInfo { hasNextPage }
totalCount
}
}
`;
+171
View File
@@ -0,0 +1,171 @@
# Queries
## Manga (`queries/manga.ts`)
### `GET_LIBRARY`
Fetches all manga marked as in-library, including metadata, source info, chapter count, download count, and unread count.
**Variables:** none
---
### `GET_ALL_MANGA`
Fetches all manga (library and non-library) with minimal fields.
**Variables:** none
---
### `GET_MANGA`
Fetches a single manga by ID with full metadata and source info.
**Variables:**
| Name | Type | Description |
|------|------|-------------|
| `id` | `Int!` | Manga ID |
---
### `GET_CATEGORIES`
Fetches all categories with their order, settings, and the manga assigned to each.
**Variables:** none
---
### `GET_DOWNLOADED_CHAPTERS_PAGES`
Fetches page counts for all downloaded chapters.
**Variables:** none
---
### `GET_DOWNLOADS_PATH`
Fetches the configured downloads path and local source path from settings.
**Variables:** none
---
### `LIBRARY_UPDATE_STATUS`
Fetches the current library update job status, including progress and any manga with new chapters.
**Variables:** none
---
### `GET_RESTORE_STATUS`
Fetches the status of a backup restore operation by its job ID.
**Variables:**
| Name | Type | Description |
|------|------|-------------|
| `id` | `String!` | Restore job ID |
---
### `VALIDATE_BACKUP`
Validates a backup file and returns any missing sources or trackers.
**Variables:**
| Name | Type | Description |
|------|------|-------------|
| `backup` | `Upload!` | Backup file |
---
## Chapters (`queries/chapters.ts`)
### `GET_CHAPTERS`
Fetches all chapters for a given manga, including read/download/bookmark state and page info.
**Variables:**
| Name | Type | Description |
|------|------|-------------|
| `mangaId` | `Int!` | Manga ID |
---
## Downloads (`queries/downloads.ts`)
### `GET_DOWNLOAD_STATUS`
Fetches the current downloader state and full queue with chapter and manga info.
**Variables:** none
---
## Extensions (`queries/extensions.ts`)
### `GET_EXTENSIONS`
Fetches all extensions with install status, update availability, and metadata.
**Variables:** none
---
### `GET_SOURCES`
Fetches all available sources with language and NSFW flags.
**Variables:** none
---
### `GET_SETTINGS`
Fetches extension repository settings.
**Variables:** none
---
### `GET_SERVER_SECURITY`
Fetches all server security settings including auth mode, SOCKS proxy config, and FlareSolverr config.
**Variables:** none
---
## Tracking (`queries/tracking.ts`)
### `GET_TRACKERS`
Fetches all trackers with login status, supported scores, statuses, and auth info.
**Variables:** none
---
### `GET_MANGA_TRACK_RECORDS`
Fetches all tracking records for a specific manga across all trackers.
**Variables:**
| Name | Type | Description |
|------|------|-------------|
| `mangaId` | `Int!` | Manga ID |
---
### `SEARCH_TRACKER`
Searches a tracker for manga by query string.
**Variables:**
| Name | Type | Description |
|------|------|-------------|
| `trackerId` | `Int!` | Tracker ID |
| `query` | `String!` | Search query |
---
### `GET_ALL_TRACKER_RECORDS`
Fetches all trackers and their full track records, including associated manga info.
**Variables:** none
---
### `GET_TRACKER_RECORDS`
Fetches track records for a specific tracker.
**Variables:**
| Name | Type | Description |
|------|------|-------------|
| `trackerId` | `Int!` | Tracker ID |
+69
View File
@@ -0,0 +1,69 @@
export const GET_TRACKERS = `
query GetTrackers {
trackers {
nodes {
id name icon isLoggedIn authUrl supportsPrivateTracking scores
statuses { value name }
}
}
}
`;
export const GET_MANGA_TRACK_RECORDS = `
query GetMangaTrackRecords($mangaId: Int!) {
manga(id: $mangaId) {
trackRecords {
nodes {
id trackerId remoteId title status score displayScore
lastChapterRead totalChapters remoteUrl startDate finishDate private
}
}
}
}
`;
export const SEARCH_TRACKER = `
query SearchTracker($trackerId: Int!, $query: String!) {
searchTracker(input: { trackerId: $trackerId, query: $query }) {
trackSearches {
id trackerId remoteId title coverUrl summary
publishingStatus publishingType startDate totalChapters trackingUrl
}
}
}
`;
export const GET_ALL_TRACKER_RECORDS = `
query GetAllTrackerRecords {
trackers {
nodes {
id name icon isLoggedIn scores
statuses { value name }
trackRecords {
nodes {
id trackerId title status displayScore lastChapterRead
totalChapters remoteUrl private
manga { id title thumbnailUrl inLibrary }
}
}
}
}
}
`;
export const GET_TRACKER_RECORDS = `
query GetTrackerRecords($trackerId: Int!) {
trackers(condition: { id: $trackerId }) {
nodes {
id name
statuses { value name }
trackRecords {
nodes {
id title status displayScore lastChapterRead totalChapters remoteUrl
manga { id title thumbnailUrl }
}
}
}
}
}
`;
-42
View File
@@ -1,42 +0,0 @@
<script lang="ts">
import { store } from "../../store/state.svelte";
import Sidebar from "./Sidebar.svelte";
import Home from "../pages/Home.svelte";
import Library from "../pages/Library.svelte";
import SeriesDetail from "../series/SeriesDetail.svelte";
import RecentActivity from "./RecentActivity.svelte";
import Search from "../pages/Search.svelte";
import Downloads from "../pages/Downloads.svelte";
import Extensions from "../pages/Extensions.svelte";
import Tracking from "../pages/Tracking.svelte";
</script>
<div class="root">
<Sidebar />
<main class="main">
{#if store.activeManga}
<SeriesDetail />
{:else if store.navPage === "home"}
<Home />
{:else if store.navPage === "library"}
<Library />
{:else if store.navPage === "search"}
<Search />
{:else if store.navPage === "history"}
<RecentActivity />
{:else if store.navPage === "downloads"}
<Downloads />
{:else if store.navPage === "extensions"}
<Extensions />
{:else if store.navPage === "tracking"}
<Tracking />
{:else}
<Home />
{/if}
</main>
</div>
<style>
.root { display: flex; height: 100%; background: var(--bg-base); overflow: hidden; }
.main { flex: 1; overflow: hidden; background: var(--bg-surface); transform: translateZ(0); contain: layout style; }
</style>
-323
View File
@@ -1,323 +0,0 @@
<script lang="ts">
import { ClockCounterClockwise, Trash, MagnifyingGlass, Play, Books, Fire, BookOpen, Clock, TrendUp } from "phosphor-svelte";
import Thumbnail from "../shared/Thumbnail.svelte";
import { store, clearHistory, openReader, setActiveManga } from "../../store/state.svelte";
import type { HistoryEntry } from "../../store/state.svelte";
let search = $state("");
let confirmClear = $state(false);
function timeAgo(ts: number): string {
const diff = Date.now() - ts, m = Math.floor(diff / 60000);
if (m < 1) return "Just now";
if (m < 60) return `${m}m ago`;
const h = Math.floor(m / 60);
if (h < 24) return `${h}h ago`;
const d = Math.floor(h / 24);
if (d < 7) return `${d}d ago`;
return new Date(ts).toLocaleDateString("en-US", { month: "short", day: "numeric" });
}
function dayLabel(ts: number): string {
const d = new Date(ts), now = new Date();
if (d.toDateString() === now.toDateString()) return "Today";
const yest = new Date(now); yest.setDate(now.getDate() - 1);
if (d.toDateString() === yest.toDateString()) return "Yesterday";
return d.toLocaleDateString("en-US", { weekday: "long", month: "long", day: "numeric" });
}
function formatReadTime(m: number): string {
if (m < 1) return "< 1 min";
if (m < 60) return `${m} min`;
const h = Math.floor(m / 60), r = m % 60;
return r === 0 ? `${h}h` : `${h}h ${r}m`;
}
const SESSION_GAP_MS = 30 * 60 * 1000;
interface Session {
mangaId: number;
mangaTitle: string;
thumbnailUrl: string;
latestChapterId: number;
latestChapterName: string;
latestPageNumber: number;
firstChapterName: string;
chapterCount: number;
readAt: number;
}
function buildSessions(entries: HistoryEntry[]): Session[] {
if (!entries.length) return [];
const sessions: Session[] = [];
let i = 0;
while (i < entries.length) {
const anchor = entries[i];
const group: HistoryEntry[] = [anchor];
let j = i + 1;
while (j < entries.length) {
const next = entries[j];
if (next.mangaId === anchor.mangaId && anchor.readAt - next.readAt <= SESSION_GAP_MS) {
group.push(next); j++;
} else break;
}
const latest = group[0], oldest = group[group.length - 1];
sessions.push({
mangaId: latest.mangaId,
mangaTitle: latest.mangaTitle,
thumbnailUrl: latest.thumbnailUrl,
latestChapterId: latest.chapterId,
latestChapterName: latest.chapterName,
latestPageNumber: latest.pageNumber,
firstChapterName: oldest.chapterName,
chapterCount: group.length,
readAt: latest.readAt,
});
i = j;
}
return sessions;
}
const filtered = $derived(search.trim()
? store.history.filter((e) =>
e.mangaTitle.toLowerCase().includes(search.toLowerCase()) ||
e.chapterName.toLowerCase().includes(search.toLowerCase())
)
: store.history);
const sessions = $derived(buildSessions(filtered));
const groups = $derived.by(() => {
const map = new Map<string, Session[]>();
for (const s of sessions) {
const l = dayLabel(s.readAt);
if (!map.has(l)) map.set(l, []);
map.get(l)!.push(s);
}
return Array.from(map.entries()).map(([label, items]) => ({ label, items }));
});
// Resume: navigate to the manga's SeriesDetail (which will pick up from
// activeChapterList once chapters load). We can't hold a stale chapter list
// here — SeriesDetail fetches fresh chapters itself.
function resume(session: Session) {
setActiveManga({
id: session.mangaId,
title: session.mangaTitle,
thumbnailUrl: session.thumbnailUrl,
inLibrary: false,
} as any);
}
function handleClear() {
if (!confirmClear) { confirmClear = true; setTimeout(() => confirmClear = false, 3000); return; }
clearHistory(); confirmClear = false;
}
</script>
<div class="root">
<div class="header">
<span class="heading">History</span>
<div class="header-right">
<div class="search-wrap">
<MagnifyingGlass size={12} class="search-icon" weight="light" />
<input class="search" placeholder="Search history…" bind:value={search} />
{#if search}<button class="search-clear" onclick={() => search = ""}>×</button>{/if}
</div>
{#if store.history.length > 0}
<button class="clear-btn" class:confirm={confirmClear} onclick={handleClear}
title={confirmClear ? "Click again to confirm" : "Clear history"}>
<Trash size={14} weight="light" />
{#if confirmClear}<span class="clear-label">Confirm?</span>{/if}
</button>
{/if}
</div>
</div>
{#if store.readingStats.totalChaptersRead > 0}
<div class="stats-bar">
<div class="stat-group">
<Fire size={13} weight="fill" class="stat-fire" />
<span class="stat-val accent">{store.readingStats.currentStreakDays}</span>
<span class="stat-label">day streak</span>
</div>
<div class="stat-sep"></div>
<div class="stat-group">
<BookOpen size={13} weight="light" class="stat-icon-neutral" />
<span class="stat-val">{store.readingStats.totalChaptersRead}</span>
<span class="stat-label">chapters</span>
</div>
<div class="stat-sep"></div>
<div class="stat-group">
<Clock size={13} weight="light" class="stat-icon-neutral" />
<span class="stat-val">{formatReadTime(store.readingStats.totalMinutesRead)}</span>
<span class="stat-label">read time</span>
</div>
<div class="stat-sep"></div>
<div class="stat-group">
<TrendUp size={13} weight="light" class="stat-icon-neutral" />
<span class="stat-val">{store.readingStats.totalMangaRead}</span>
<span class="stat-label">series</span>
</div>
<div class="stat-sep"></div>
<div class="stat-group">
<span class="stat-val muted">{store.readingStats.longestStreakDays}d</span>
<span class="stat-label">best streak</span>
</div>
<span class="stats-note">Stats are preserved when you clear the feed</span>
</div>
{/if}
{#if store.history.length === 0}
<div class="empty">
<ClockCounterClockwise size={32} weight="light" class="empty-icon" />
<p class="empty-text">No reading history yet</p>
<p class="empty-hint">Chapters you read will appear here</p>
</div>
{:else if sessions.length === 0}
<div class="empty">
<Books size={28} weight="light" class="empty-icon" />
<p class="empty-text">No results for "{search}"</p>
</div>
{:else}
<div class="timeline">
{#each groups as { label, items }}
<div class="day-group">
<div class="day-label-row">
<span class="day-label">{label}</span>
<div class="day-line"></div>
</div>
<div class="session-list">
{#each items as session (session.latestChapterId)}
<button class="session-row" onclick={() => resume(session)}>
<div class="thumb-wrap">
<Thumbnail src={session.thumbnailUrl} alt={session.mangaTitle} class="thumb" />
{#if session.chapterCount > 1}
<span class="session-count">{session.chapterCount}</span>
{/if}
</div>
<div class="session-info">
<span class="session-title">{session.mangaTitle}</span>
<span class="session-chapter">
{#if session.chapterCount > 1}
{session.firstChapterName}
<span class="ch-arrow"></span>
{session.latestChapterName}
{:else}
{session.latestChapterName}
{#if session.latestPageNumber > 1}
<span class="ch-page">p.{session.latestPageNumber}</span>
{/if}
{/if}
</span>
</div>
<span class="session-time">{timeAgo(session.readAt)}</span>
<div class="play-pill">
<Play size={10} weight="fill" /> Resume
</div>
</button>
{/each}
</div>
</div>
{/each}
</div>
{/if}
</div>
<style>
.root { display: flex; flex-direction: column; height: 100%; overflow: hidden; animation: fadeIn 0.14s ease both; }
.header {
display: flex; align-items: center; justify-content: space-between;
padding: var(--sp-4) var(--sp-6); border-bottom: 1px solid var(--border-dim); flex-shrink: 0;
}
.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-right { display: flex; align-items: center; gap: var(--sp-2); }
.search-wrap { position: relative; display: flex; align-items: center; }
.search-wrap :global(.search-icon) { position: absolute; left: 9px; color: var(--text-faint); pointer-events: none; }
.search { background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 5px 26px; color: var(--text-primary); font-size: var(--text-sm); width: 180px; outline: none; transition: border-color var(--t-base); }
.search::placeholder { color: var(--text-faint); }
.search:focus { border-color: var(--border-strong); }
.search-clear { position: absolute; right: 7px; color: var(--text-faint); font-size: 14px; line-height: 1; background: none; border: none; cursor: pointer; padding: 2px; transition: color var(--t-base); }
.search-clear:hover { color: var(--text-muted); }
.clear-btn {
display: flex; align-items: center; gap: 5px;
height: 28px; padding: 0 var(--sp-2); border-radius: var(--radius-md);
color: var(--text-faint); background: none; border: 1px solid transparent;
cursor: pointer; font-family: var(--font-ui); font-size: var(--text-2xs);
letter-spacing: var(--tracking-wide);
transition: color var(--t-base), background var(--t-base), border-color var(--t-base);
}
.clear-btn:hover { color: var(--color-error); background: var(--color-error-bg); }
.clear-btn.confirm { color: var(--color-error); background: var(--color-error-bg); border-color: var(--color-error); }
.clear-label { font-size: var(--text-2xs); }
.stats-bar {
display: flex; align-items: center; gap: var(--sp-3); flex-wrap: wrap;
padding: var(--sp-3) var(--sp-6); border-bottom: 1px solid var(--border-dim);
background: var(--bg-raised); flex-shrink: 0;
}
.stat-group { display: flex; align-items: center; gap: 5px; }
.stat-sep { width: 1px; height: 14px; background: var(--border-dim); flex-shrink: 0; }
:global(.stat-fire) { color: #f97316; }
:global(.stat-icon-neutral) { color: var(--text-faint); }
.stat-val { font-family: var(--font-ui); font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-secondary); }
.stat-val.accent { color: var(--accent-fg); }
.stat-val.muted { color: var(--text-faint); }
.stat-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
.stats-note { margin-left: auto; font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); opacity: 0.5; letter-spacing: var(--tracking-wide); font-style: italic; }
.timeline { flex: 1; overflow-y: auto; padding: var(--sp-4) var(--sp-6); }
.day-group { margin-bottom: var(--sp-5); }
.day-label-row { display: flex; align-items: center; gap: var(--sp-3); margin-bottom: var(--sp-3); }
.day-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; white-space: nowrap; flex-shrink: 0; }
.day-line { flex: 1; height: 1px; background: var(--border-dim); }
.session-list { display: flex; flex-direction: column; gap: 2px; }
.session-row {
display: flex; align-items: center; gap: var(--sp-3);
width: 100%; padding: 8px var(--sp-3); border-radius: var(--radius-md);
border: 1px solid transparent; background: none; text-align: left; cursor: pointer;
transition: background var(--t-fast), border-color var(--t-fast);
}
.session-row:hover { background: var(--bg-raised); border-color: var(--border-dim); }
.session-row:hover .play-pill { opacity: 1; transform: translateX(0); }
.thumb-wrap { position: relative; flex-shrink: 0; }
:global(.thumb) { width: 38px; height: 54px; border-radius: var(--radius-sm); object-fit: cover; display: block; background: var(--bg-raised); border: 1px solid var(--border-dim); }
.session-count {
position: absolute; bottom: -4px; right: -6px;
background: var(--accent-muted); border: 1px solid var(--accent-dim); color: var(--accent-fg);
font-family: var(--font-ui); font-size: 9px; font-weight: 600;
padding: 1px 4px; border-radius: 6px; line-height: 1.4; pointer-events: none;
}
.session-info { flex: 1; display: flex; flex-direction: column; gap: 3px; overflow: hidden; min-width: 0; }
.session-title { font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.session-chapter { font-size: var(--text-xs); color: var(--text-muted); display: flex; align-items: center; gap: var(--sp-1); min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.ch-arrow { color: var(--text-faint); font-size: 10px; flex-shrink: 0; }
.ch-page { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); flex-shrink: 0; }
.session-time { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); flex-shrink: 0; white-space: nowrap; }
.play-pill {
display: flex; align-items: center; gap: 4px; flex-shrink: 0;
font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide);
color: var(--accent-fg); background: var(--accent-muted); border: 1px solid var(--accent-dim);
padding: 3px 8px; border-radius: var(--radius-full);
opacity: 0; transform: translateX(4px);
transition: opacity var(--t-base), transform var(--t-base);
}
.empty { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: var(--sp-2); }
:global(.empty-icon) { color: var(--text-faint); }
.empty-text { font-size: var(--text-base); color: var(--text-muted); }
.empty-hint { font-size: var(--text-sm); color: var(--text-faint); }
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
</style>
-69
View File
@@ -1,69 +0,0 @@
<script lang="ts">
import { House, Books, MagnifyingGlass, ClockCounterClockwise, DownloadSimple, PuzzlePiece, GearSix, ChartLineUp } from "phosphor-svelte";
import { store, setNavPage, setActiveManga, setActiveSource, setLibraryFilter, setGenreFilter, setSettingsOpen } from "../../store/state.svelte";
import type { NavPage } from "../../store/state.svelte";
const TABS: { id: NavPage; label: string; icon: any }[] = [
{ id: "home", label: "Home", icon: House },
{ id: "library", label: "Library", icon: Books },
{ id: "search", label: "Search", icon: MagnifyingGlass },
{ id: "history", label: "History", icon: ClockCounterClockwise },
{ id: "downloads", label: "Downloads", icon: DownloadSimple },
{ id: "extensions", label: "Extensions", icon: PuzzlePiece },
{ id: "tracking", label: "Tracking", icon: ChartLineUp },
];
function navigate(id: NavPage) {
store.navPage = id;
store.activeManga = null;
store.activeSource = null;
store.genreFilter = "";
}
function goHome() {
store.navPage = "home";
store.activeSource = null;
store.activeManga = null;
store.libraryFilter = "library";
store.genreFilter = "";
}
</script>
<aside class="root">
<button class="logo" onclick={goHome} title="Home" aria-label="Go to Home">
<div class="logo-icon"></div>
</button>
<nav class="nav">
{#each TABS as tab}
<button class="tab" class:active={store.navPage === tab.id}
title={tab.label} onclick={() => navigate(tab.id)}>
<tab.icon size={18} weight="light" />
</button>
{/each}
</nav>
<div class="bottom">
<button class="settings-btn" onclick={() => store.settingsOpen = true} title="Settings">
<GearSix size={18} weight="light" />
</button>
</div>
</aside>
<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; overflow: hidden; min-height: 0; height: 100%; }
.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:active { transform: scale(0.92); }
.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; }
.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; }
.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:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
.tab.active { color: var(--accent-fg); background: var(--accent-muted); }
.tab.active:hover { color: var(--accent-fg); background: var(--accent-muted); }
.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:hover { color: var(--text-muted); background: var(--bg-raised); transform: rotate(30deg); }
.settings-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
</style>
-189
View File
@@ -1,189 +0,0 @@
<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(() => {
const activeIds = new Set(store.toasts.map(t => t.id));
store.toasts.forEach(schedule);
for (const [id, timer] of timers) {
if (!activeIds.has(id)) { clearTimeout(timer); timers.delete(id); }
}
});
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>
<p class="sub">{t.body ?? '\u00a0'}</p>
</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: 5px;
pointer-events: none;
}
.toast {
display: flex;
align-items: center;
gap: 10px;
padding: 12px var(--sp-3) 12px 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;
width: 280px;
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: -5px; }
}
.accent-bar {
width: 3px;
align-self: stretch;
flex-shrink: 0;
border-radius: 0 2px 2px 0;
margin-right: 0;
}
.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: 3px;
}
.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;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sub {
font-family: var(--font-ui);
font-size: var(--text-2xs);
color: var(--text-faint);
letter-spacing: var(--tracking-wide);
line-height: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
</style>
-182
View File
@@ -1,182 +0,0 @@
<script lang="ts">
import { Play, Pause, Trash, CircleNotch, X } from "phosphor-svelte";
import { gql } from "../../lib/client";
import Thumbnail from "../shared/Thumbnail.svelte";
import { GET_DOWNLOAD_STATUS, START_DOWNLOADER, STOP_DOWNLOADER, CLEAR_DOWNLOADER, DEQUEUE_DOWNLOAD } from "../../lib/queries";
import { store, setActiveDownloads } from "../../store/state.svelte";
import type { DownloadStatus } from "../../lib/types";
let status: DownloadStatus | null = $state(null);
let loading = $state(true);
let togglingPlay = $state(false);
let clearing = $state(false);
let dequeueing = $state(new Set<number>());
let interval: ReturnType<typeof setInterval>;
function applyStatus(ds: DownloadStatus) {
status = ds;
setActiveDownloads(ds.queue.map((item) => ({
chapterId: item.chapter.id,
mangaId: item.chapter.mangaId,
progress: item.progress,
})));
}
async function poll() {
gql<{ downloadStatus: DownloadStatus }>(GET_DOWNLOAD_STATUS)
.then((d) => applyStatus(d.downloadStatus))
.catch(console.error)
.finally(() => loading = false);
}
$effect(() => { poll(); interval = setInterval(poll, 2000); return () => clearInterval(interval); });
async function togglePlay() {
if (togglingPlay) return;
togglingPlay = true;
const wasRunning = status?.state === "STARTED";
if (status) status = { ...status, state: wasRunning ? "STOPPED" : "STARTED" };
try {
if (wasRunning) {
const d = await gql<{ stopDownloader: { downloadStatus: DownloadStatus } }>(STOP_DOWNLOADER);
applyStatus(d.stopDownloader.downloadStatus);
} else {
const d = await gql<{ startDownloader: { downloadStatus: DownloadStatus } }>(START_DOWNLOADER);
applyStatus(d.startDownloader.downloadStatus);
}
} catch (e) { console.error(e); poll(); }
finally { togglingPlay = false; }
}
async function clear() {
if (clearing) return;
clearing = true;
if (status) status = { ...status, queue: [] };
setActiveDownloads([]);
try {
const d = await gql<{ clearDownloader: { downloadStatus: DownloadStatus } }>(CLEAR_DOWNLOADER);
applyStatus(d.clearDownloader.downloadStatus);
} catch (e) { console.error(e); poll(); }
finally { clearing = false; }
}
async function dequeue(chapterId: number) {
if (dequeueing.has(chapterId)) return;
dequeueing = new Set(dequeueing).add(chapterId);
if (status) status = { ...status, queue: status.queue.filter((i) => i.chapter.id !== chapterId) };
try { await gql(DEQUEUE_DOWNLOAD, { chapterId }); poll(); }
catch (e) { console.error(e); poll(); }
finally { dequeueing.delete(chapterId); dequeueing = new Set(dequeueing); }
}
let queue = $derived(status?.queue ?? []);
const isRunning = $derived(status?.state === "STARTED");
</script>
<div class="root">
<div class="header">
<h1 class="heading">Downloads</h1>
<div class="header-actions">
<button class="icon-btn" class:loading={togglingPlay} onclick={togglePlay}
disabled={togglingPlay || (queue.length === 0 && !isRunning)} title={isRunning ? "Pause" : "Resume"}>
{#if togglingPlay}<CircleNotch size={14} weight="light" class="anim-spin" />
{:else if isRunning}<Pause size={14} weight="fill" />
{:else}<Play size={14} weight="fill" />{/if}
</button>
<button class="icon-btn" class:loading={clearing} onclick={clear}
disabled={clearing || queue.length === 0} title="Clear queue">
{#if clearing}<CircleNotch size={14} weight="light" class="anim-spin" />
{:else}<Trash size={14} weight="regular" />{/if}
</button>
</div>
</div>
<div class="content">
<div class="status-bar">
<div class="status-dot" class:active={isRunning}></div>
<span class="status-text">
{togglingPlay ? (isRunning ? "Pausing…" : "Starting…") : isRunning ? "Downloading" : "Paused"}
</span>
<span class="status-count">{queue.length} queued</span>
</div>
{#if loading}
<div class="empty"><CircleNotch size={16} weight="light" class="anim-spin" style="color:var(--text-faint)" /></div>
{:else if queue.length === 0}
<div class="empty">Queue is empty.</div>
{:else}
<div class="list">
{#each queue as item, i (item.chapter.id)}
{@const isActive = i === 0 && isRunning}
{@const pages = item.chapter.pageCount ?? 0}
{@const done = Math.round(item.progress * pages)}
{@const manga = item.chapter.manga}
{@const isRemoving = dequeueing.has(item.chapter.id)}
<div class="row" class:row-active={isActive} class:row-removing={isRemoving}>
{#if manga?.thumbnailUrl}
<div class="thumb">
<Thumbnail src={manga.thumbnailUrl} alt={manga?.title} class="thumb-img" />
</div>
{/if}
<div class="info">
{#if manga?.title}<span class="manga-title">{manga.title}</span>{/if}
<span class="chapter-name">{item.chapter.name}</span>
{#if pages > 0}
<span class="pages-label">{isActive ? `${done} / ${pages} pages` : `${pages} pages`}</span>
{/if}
{#if isActive}
<div class="progress-wrap">
<div class="progress-bar" style="width:{Math.round(item.progress * 100)}%"></div>
</div>
{/if}
</div>
<div class="row-right">
<span class="state-label">{item.state}</span>
{#if !isActive}
<button class="remove-btn" onclick={() => dequeue(item.chapter.id)} disabled={isRemoving} title="Remove from queue">
{#if isRemoving}<CircleNotch size={11} weight="light" class="anim-spin" />{:else}<X size={12} weight="light" />{/if}
</button>
{/if}
</div>
</div>
{/each}
</div>
{/if}
</div><!-- .content -->
</div>
<style>
.root { display: flex; flex-direction: column; height: 100%; overflow: hidden; animation: fadeIn 0.14s ease both; }
.header { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-4) var(--sp-6); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
.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-2); }
.content { flex: 1; overflow-y: auto; padding: var(--sp-5) var(--sp-6) var(--sp-6); display: flex; flex-direction: column; gap: var(--sp-4); }
.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); transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
.icon-btn:hover:not(:disabled) { color: var(--text-secondary); border-color: var(--border-strong); background: var(--bg-raised); }
.icon-btn:disabled { opacity: 0.3; cursor: default; }
.icon-btn.loading { border-color: var(--accent-dim); color: var(--accent-fg); background: var(--accent-muted); }
.status-bar { display: flex; align-items: center; gap: var(--sp-3); padding: var(--sp-3); background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); }
.status-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--text-faint); flex-shrink: 0; transition: background var(--t-base); }
.status-dot.active { background: var(--accent); animation: pulse 1.6s ease infinite; }
@keyframes pulse { 0%,100% { opacity: 1 } 50% { opacity: 0.4 } }
.status-text { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); flex: 1; letter-spacing: var(--tracking-wide); }
.status-count { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
.list { display: flex; flex-direction: column; gap: var(--sp-2); }
.row { display: flex; align-items: center; gap: var(--sp-3); padding: var(--sp-3); background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); transition: border-color var(--t-fast), opacity var(--t-base); }
.row.row-active { border-color: var(--accent-dim); }
.row.row-removing { opacity: 0.4; pointer-events: none; }
.thumb { width: 36px; height: 54px; border-radius: var(--radius-sm); overflow: hidden; background: var(--bg-overlay); flex-shrink: 0; border: 1px solid var(--border-dim); }
:global(.thumb-img) { width: 100%; height: 100%; object-fit: cover; }
.info { flex: 1; display: flex; flex-direction: column; gap: 3px; overflow: hidden; min-width: 0; }
.manga-title { font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.chapter-name { font-size: var(--text-xs); color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.pages-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
.progress-wrap { height: 2px; background: var(--border-base); border-radius: var(--radius-full); overflow: hidden; margin-top: 4px; }
.progress-bar { height: 100%; background: var(--accent); border-radius: var(--radius-full); transition: width 0.4s ease; }
.row-right { display: flex; flex-direction: column; align-items: flex-end; gap: var(--sp-1); flex-shrink: 0; }
.state-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; }
.remove-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); }
.remove-btn:hover:not(:disabled) { color: var(--color-error); background: var(--color-error-bg); }
.remove-btn:disabled { opacity: 0.5; cursor: default; }
.empty { display: flex; align-items: center; justify-content: center; height: 160px; color: var(--text-faint); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); }
</style>
-376
View File
@@ -1,376 +0,0 @@
<script lang="ts">
import { untrack } from "svelte";
import { MagnifyingGlass, ArrowsClockwise, Plus, CircleNotch, CaretRight, CaretDown, X, Check, GitBranch } from "phosphor-svelte";
import { gql } from "../../lib/client";
import Thumbnail from "../shared/Thumbnail.svelte";
import { GET_EXTENSIONS, FETCH_EXTENSIONS, UPDATE_EXTENSION, INSTALL_EXTERNAL_EXTENSION, GET_SETTINGS, SET_EXTENSION_REPOS } from "../../lib/queries";
import { store } from "../../store/state.svelte";
import type { Extension } from "../../lib/types";
type Filter = "installed" | "available" | "updates" | "all";
type Panel = null | "apk" | "repos";
function baseName(name: string): string { return name.replace(/\s*\([A-Z0-9-]{2,10}\)\s*$/, "").trim(); }
let extensions: Extension[] = $state([]);
let loading = $state(true);
let refreshing = $state(false);
let filter: Filter = $state("installed");
let search = $state("");
let langFilter = $state<string | null>(null);
let working = $state(new Set<string>());
let expanded = $state(new Set<string>());
let panel: Panel = $state(null);
let externalUrl = $state("");
let installing = $state(false);
let installError: string|null = $state(null);
let installSuccess = $state(false);
let repos: string[] = $state([]);
let reposLoading = $state(false);
let newRepoUrl = $state("");
let repoError: string|null = $state(null);
let savingRepos = $state(false);
async function load() {
return gql<{ extensions: { nodes: Extension[] } }>(GET_EXTENSIONS)
.then((d) => extensions = d.extensions.nodes).catch(console.error);
}
async function fetchFromRepo() {
refreshing = true;
return gql<{ fetchExtensions: { extensions: Extension[] } }>(FETCH_EXTENSIONS)
.then((d) => extensions = d.fetchExtensions.extensions).catch(console.error)
.finally(() => refreshing = false);
}
async function loadRepos() {
reposLoading = true;
try { const d = await gql<{ settings: { extensionRepos: string[] } }>(GET_SETTINGS); repos = d.settings.extensionRepos ?? []; }
catch (e) { console.error(e); } finally { reposLoading = false; }
}
async function saveRepos(updated: string[]) {
savingRepos = true;
try { const d = await gql<{ setSettings: { settings: { extensionRepos: string[] } } }>(SET_EXTENSION_REPOS, { repos: updated }); repos = d.setSettings.settings.extensionRepos; }
catch (e: any) { repoError = e instanceof Error ? e.message : "Failed to save"; } finally { savingRepos = false; }
}
function addRepo() {
const url = newRepoUrl.trim();
if (!url) return;
if (!url.startsWith("http://") && !url.startsWith("https://")) { repoError = "URL must start with http:// or https://"; return; }
if (repos.includes(url)) { repoError = "Repo already added"; return; }
repoError = null; newRepoUrl = "";
saveRepos([...repos, url]);
}
function removeRepo(url: string) { saveRepos(repos.filter((r) => r !== url)); }
async function mutate(fn: () => Promise<unknown>, pkgName: string) {
working = new Set(working).add(pkgName);
await fn().catch(console.error);
await load();
working.delete(pkgName); working = new Set(working);
}
async function installExternal() {
const url = externalUrl.trim();
if (!url) return;
if (!url.startsWith("http://") && !url.startsWith("https://")) { installError = "URL must start with http:// or https://"; return; }
if (!url.endsWith(".apk")) { installError = "URL must point to an .apk file"; return; }
installing = true; installError = null; installSuccess = false;
try {
await gql(INSTALL_EXTERNAL_EXTENSION, { url });
installSuccess = true; externalUrl = "";
await load();
setTimeout(() => { panel = null; installSuccess = false; }, 1500);
} catch (e: any) { installError = e instanceof Error ? e.message : "Install failed"; }
finally { installing = false; }
}
function openPanel(p: Panel) {
panel = panel === p ? null : p;
installError = null; installSuccess = false; externalUrl = "";
repoError = null; newRepoUrl = "";
if (p === "repos") loadRepos();
}
$effect(() => { untrack(() => fetchFromRepo().finally(() => { loading = false; })); });
const filtered = $derived(extensions.filter((e) => {
const q = search.toLowerCase();
const matchSearch = e.name.toLowerCase().includes(q) || e.lang.toLowerCase().includes(q);
const matchFilter = filter === "installed" ? e.isInstalled : filter === "available" ? !e.isInstalled : filter === "updates" ? e.hasUpdate : true;
const matchLang = langFilter === null || e.lang === langFilter;
return matchSearch && matchFilter && matchLang;
}));
const availableLangs = $derived(
[...new Set(extensions
.filter((e) => filter === "installed" ? e.isInstalled : filter === "available" ? !e.isInstalled : filter === "updates" ? e.hasUpdate : true)
.map((e) => e.lang)
)].sort()
);
const groups = $derived.by(() => {
const map = new Map<string, Extension[]>();
for (const ext of filtered) { const key = baseName(ext.name); if (!map.has(key)) map.set(key, []); map.get(key)!.push(ext); }
const preferredLang = store.settings.preferredExtensionLang;
return Array.from(map.entries()).map(([base, all]) => {
const primary = all.find((v) => v.lang === preferredLang) ?? all.find((v) => v.lang === "en") ?? all[0];
return { base, primary, variants: all.filter((v) => v.pkgName !== primary.pkgName) };
});
});
const updateCount = $derived(extensions.filter((e) => e.hasUpdate).length);
const FILTERS: { id: Filter; label: string }[] = [
{ id: "installed", label: "Installed" },
{ id: "available", label: "Available" },
{ id: "updates", label: "Updates" },
{ id: "all", label: "All" },
];
function setFilter(f: Filter) { filter = f; langFilter = null; }
function toggleExpand(base: string) {
const next = new Set(expanded);
next.has(base) ? next.delete(base) : next.add(base);
expanded = next;
}
</script>
<div class="root">
<div class="header">
<h1 class="heading">Extensions</h1>
<div class="tabs">
{#each FILTERS as f}
<button class="tab" class:active={filter === f.id} onclick={() => setFilter(f.id)}>
{f.id === "updates" && updateCount > 0 ? `Updates (${updateCount})` : f.label}
</button>
{/each}
</div>
<div class="header-right">
<div class="search-wrap">
<MagnifyingGlass size={12} class="search-icon" weight="light" />
<input class="search" placeholder="Search" bind:value={search} />
</div>
<button class="icon-btn" class:active={panel === "repos"} onclick={() => openPanel("repos")} title="Manage repos">
<Plus size={14} weight="light" />
</button>
<button class="icon-btn" class:active={panel === "apk"} onclick={() => openPanel("apk")} title="Install from URL">
<GitBranch size={14} weight="light" />
</button>
<button class="icon-btn" onclick={fetchFromRepo} disabled={refreshing} title="Refresh repo">
<ArrowsClockwise size={14} weight="light" class={refreshing ? "anim-spin" : ""} />
</button>
</div>
</div>
{#if availableLangs.length > 1}
<div class="lang-bar">
<button class="lang-pill" class:active={langFilter === null} onclick={() => langFilter = null}>All</button>
{#each availableLangs as lang}
<button class="lang-pill" class:active={langFilter === lang} onclick={() => langFilter = langFilter === lang ? null : lang}>{lang.toUpperCase()}</button>
{/each}
</div>
{/if}
{#if panel === "apk"}
<div class="ext-panel">
<div class="panel-header">
<span class="panel-title">Install from APK URL</span>
<button class="icon-btn" onclick={() => panel = null}><X size={14} weight="light" /></button>
</div>
<div class="ext-row">
<input class="ext-input" class:error={installError} placeholder="https://example.com/extension.apk"
bind:value={externalUrl} disabled={installing}
oninput={() => installError = null}
onkeydown={(e) => e.key === "Enter" && !installing && installExternal()} use:focusOnMount />
<button class="install-btn" class:success={installSuccess} onclick={installExternal} disabled={installing || !externalUrl.trim()}>
{#if installing}<CircleNotch size={13} weight="light" class="anim-spin" />
{:else if installSuccess}<Check size={13} weight="bold" /> Done
{:else}Install{/if}
</button>
</div>
{#if installError}<div class="panel-error">{installError}</div>{/if}
</div>
{/if}
{#if panel === "repos"}
<div class="ext-panel">
<div class="panel-header">
<span class="panel-title">Extension Repositories</span>
<button class="icon-btn" onclick={() => panel = null}><X size={14} weight="light" /></button>
</div>
{#if reposLoading}
<div class="repo-loading"><CircleNotch size={14} weight="light" class="anim-spin" style="color:var(--text-faint)" /></div>
{:else}
{#if repos.length === 0}
<div class="repo-empty">No repos configured.</div>
{:else}
<div class="repo-list">
{#each repos as url}
<div class="repo-row">
<span class="repo-url">{url}</span>
<button class="repo-remove" onclick={() => removeRepo(url)} disabled={savingRepos} title="Remove repo">
{#if savingRepos}<CircleNotch size={12} weight="light" class="anim-spin" />{:else}<X size={12} weight="bold" />{/if}
</button>
</div>
{/each}
</div>
{/if}
<div class="ext-row" style="margin-top:var(--sp-2)">
<input class="ext-input" class:error={repoError} placeholder="https://example.com/index.min.json"
bind:value={newRepoUrl} disabled={savingRepos}
oninput={() => repoError = null}
onkeydown={(e) => e.key === "Enter" && !savingRepos && addRepo()} />
<button class="install-btn" onclick={addRepo} disabled={savingRepos || !newRepoUrl.trim()}>
{#if savingRepos}<CircleNotch size={13} weight="light" class="anim-spin" />{:else}Add{/if}
</button>
</div>
{#if repoError}<div class="panel-error">{repoError}</div>{/if}
{/if}
</div>
{/if}
{#if loading}
<div class="empty"><CircleNotch size={16} weight="light" class="anim-spin" style="color:var(--text-faint)" /></div>
{:else if groups.length === 0}
<div class="empty">No extensions found.</div>
{:else}
<div class="list">
{#each groups as { base, primary, variants }}
{@const isExpanded = expanded.has(base)}
{@const hasVariants = variants.length > 0}
<div class="group">
<div class="row">
<Thumbnail src={primary.iconUrl} alt={primary.name} class="icon" onerror={(e) => (e.target as HTMLImageElement).style.display = "none"} />
<div class="info">
<span class="name">{base}</span>
<span class="meta"><span class="lang-tag">{primary.lang.toUpperCase()}</span> v{primary.versionName}</span>
</div>
{#if working.has(primary.pkgName)}
<CircleNotch size={14} weight="light" class="anim-spin" style="color:var(--text-faint)" />
{:else if primary.hasUpdate}
<div class="row-actions">
<button class="action-btn" onclick={() => mutate(() => gql(UPDATE_EXTENSION, { id: primary.pkgName, update: true }), primary.pkgName)}>Update</button>
<button class="action-btn-dim" onclick={() => mutate(() => gql(UPDATE_EXTENSION, { id: primary.pkgName, uninstall: true }), primary.pkgName)}>Remove</button>
</div>
{:else if primary.isInstalled}
<button class="action-btn-dim" onclick={() => mutate(() => gql(UPDATE_EXTENSION, { id: primary.pkgName, uninstall: true }), primary.pkgName)}>Remove</button>
{:else}
<button class="action-btn" onclick={() => mutate(() => gql(UPDATE_EXTENSION, { id: primary.pkgName, install: true }), primary.pkgName)}>Install</button>
{/if}
{#if hasVariants}
<button class="expand-btn" onclick={() => toggleExpand(base)} title="{variants.length + 1} languages">
{#if isExpanded}<CaretDown size={12} weight="light" />{:else}<CaretRight size={12} weight="light" />{/if}
<span class="expand-count">{variants.length + 1}</span>
</button>
{/if}
</div>
{#if isExpanded && hasVariants}
<div class="variants">
{#each variants as v}
<div class="variant-row">
<span class="lang-tag">{v.lang.toUpperCase()}</span>
<span class="variant-name">{v.name}</span>
<span class="variant-version">v{v.versionName}</span>
{#if v.hasUpdate}<span class="update-badge-small"></span>{/if}
<div class="variant-actions">
{#if working.has(v.pkgName)}
<CircleNotch size={14} weight="light" class="anim-spin" style="color:var(--text-faint)" />
{:else if v.hasUpdate}
<button class="action-btn" onclick={() => mutate(() => gql(UPDATE_EXTENSION, { id: v.pkgName, update: true }), v.pkgName)}>Update</button>
{:else if v.isInstalled}
<button class="action-btn-dim" onclick={() => mutate(() => gql(UPDATE_EXTENSION, { id: v.pkgName, uninstall: true }), v.pkgName)}>Remove</button>
{:else}
<button class="action-btn" onclick={() => mutate(() => gql(UPDATE_EXTENSION, { id: v.pkgName, install: true }), v.pkgName)}>Install</button>
{/if}
</div>
</div>
{/each}
</div>
{/if}
</div>
{/each}
</div>
{/if}
</div>
<style>
.root { display: flex; flex-direction: column; height: 100%; overflow: hidden; animation: fadeIn 0.14s ease both; }
.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; }
.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; }
.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:disabled { opacity: 0.4; }
.icon-btn.active { color: var(--accent-fg); background: var(--accent-muted); }
.ext-panel { display: flex; flex-direction: column; gap: var(--sp-2); padding: 0 var(--sp-6) var(--sp-3); flex-shrink: 0; animation: fadeIn 0.1s ease both; }
.panel-header { display: flex; align-items: center; justify-content: space-between; }
.panel-title { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); letter-spacing: var(--tracking-wide); }
.panel-error { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--color-error); letter-spacing: var(--tracking-wide); padding: 0 2px; }
.ext-row { display: flex; gap: var(--sp-2); }
.ext-input { flex: 1; background: var(--bg-raised); border: 1px solid var(--border-strong); border-radius: var(--radius-md); padding: 6px var(--sp-3); color: var(--text-primary); font-size: var(--text-sm); outline: none; transition: border-color var(--t-base); }
.ext-input:focus { border-color: var(--border-focus); }
.ext-input:disabled { opacity: 0.5; }
.ext-input.error { border-color: var(--color-error) !important; }
.install-btn { display: flex; align-items: center; gap: var(--sp-1); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 6px 14px; border-radius: var(--radius-md); background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim); cursor: pointer; flex-shrink: 0; transition: filter var(--t-base), opacity var(--t-base); white-space: nowrap; }
.install-btn:hover:not(:disabled) { filter: brightness(1.1); }
.install-btn:disabled { opacity: 0.5; cursor: default; }
.install-btn.success { background: rgba(107,143,107,0.2); border-color: var(--accent-fg); color: var(--accent-fg); }
.repo-loading { display: flex; align-items: center; justify-content: center; padding: var(--sp-3); }
.repo-empty { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); padding: var(--sp-1) 2px; }
.repo-list { display: flex; flex-direction: column; gap: 2px; }
.repo-row { display: flex; align-items: center; gap: var(--sp-2); padding: 5px var(--sp-2); border-radius: var(--radius-md); background: var(--bg-raised); border: 1px solid var(--border-dim); }
.repo-url { flex: 1; font-size: var(--text-2xs); color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.repo-remove { display: flex; align-items: center; justify-content: center; width: 20px; height: 20px; border-radius: var(--radius-sm); color: var(--text-faint); flex-shrink: 0; transition: color var(--t-base), background var(--t-base); }
.repo-remove:hover:not(:disabled) { color: var(--color-error); background: var(--bg-overlay); }
.lang-bar { display: flex; align-items: center; gap: 4px; padding: var(--sp-2) var(--sp-6); flex-shrink: 0; flex-wrap: wrap; border-bottom: 1px solid var(--border-dim); }
.lang-pill { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wider); text-transform: uppercase; padding: 3px 9px; border-radius: var(--radius-full); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); cursor: pointer; transition: color var(--t-fast), background var(--t-fast), border-color var(--t-fast); }
.lang-pill:hover { color: var(--text-muted); border-color: var(--border-strong); background: var(--bg-raised); }
.lang-pill.active { background: var(--accent-muted); border-color: var(--accent-dim); color: var(--accent-fg); }
.tabs { display: flex; gap: 2px; background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 2px; }
.tab { display: flex; align-items: center; gap: 5px; font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); text-transform: uppercase; padding: 4px 10px; border-radius: var(--radius-sm); color: var(--text-faint); white-space: nowrap; transition: background var(--t-base), color var(--t-base); }
.tab:hover { color: var(--text-muted); }
.tab.active { background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim); }
.search-wrap { position: relative; display: flex; align-items: center; }
.search-wrap :global(.search-icon) { position: absolute; left: 9px; color: var(--text-faint); pointer-events: none; }
.search { background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 5px 10px 5px 26px; color: var(--text-primary); font-size: var(--text-sm); width: 160px; outline: none; transition: border-color var(--t-base); }
.search::placeholder { color: var(--text-faint); }
.search:focus { border-color: var(--border-strong); }
.list { flex: 1; overflow-y: auto; padding: 0 var(--sp-4) var(--sp-4); display: flex; flex-direction: column; gap: 1px; }
.group { display: flex; flex-direction: column; }
.row { display: flex; align-items: center; gap: var(--sp-3); padding: 8px var(--sp-3); border-radius: var(--radius-md); border: 1px solid transparent; transition: background var(--t-fast), border-color var(--t-fast); }
.row:hover { background: var(--bg-raised); border-color: var(--border-dim); }
:global(.icon) { width: 32px; height: 32px; border-radius: var(--radius-md); object-fit: cover; flex-shrink: 0; background: var(--bg-raised); }
.info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; min-width: 0; }
.name { font-size: var(--text-base); font-weight: var(--weight-medium); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.meta { display: flex; align-items: center; gap: var(--sp-2); font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
.lang-tag { background: var(--bg-overlay); border: 1px solid var(--border-dim); border-radius: var(--radius-sm); padding: 1px 5px; font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-muted); letter-spacing: var(--tracking-wider); }
.update-badge-small { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--accent-fg); flex-shrink: 0; }
.row-actions { display: flex; gap: var(--sp-1); flex-shrink: 0; }
.action-btn { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 4px 10px; border-radius: var(--radius-md); background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim); cursor: pointer; flex-shrink: 0; transition: filter var(--t-base); }
.action-btn:hover { filter: brightness(1.1); }
.action-btn-dim { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 4px 10px; border-radius: var(--radius-md); background: none; color: var(--text-faint); border: 1px solid var(--border-dim); cursor: pointer; flex-shrink: 0; transition: color var(--t-base), border-color var(--t-base); }
.action-btn-dim:hover { color: var(--color-error); border-color: var(--color-error); }
.expand-btn { display: flex; align-items: center; gap: 3px; padding: 4px 6px; border-radius: var(--radius-sm); color: var(--text-faint); flex-shrink: 0; transition: color var(--t-base), background var(--t-base); }
.expand-btn:hover { color: var(--text-muted); background: var(--bg-overlay); }
.expand-count { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); }
.variants { display: flex; flex-direction: column; gap: 1px; margin: 1px 0 2px calc(32px + var(--sp-3) + var(--sp-3)); padding-left: var(--sp-3); border-left: 1px solid var(--border-dim); animation: fadeIn 0.1s ease both; }
.variant-row { display: flex; align-items: center; gap: var(--sp-2); padding: 5px var(--sp-2); border-radius: var(--radius-md); transition: background var(--t-fast); }
.variant-row:hover { background: var(--bg-raised); }
.variant-name { flex: 1; font-size: var(--text-sm); color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.variant-version { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); flex-shrink: 0; }
.variant-actions { flex-shrink: 0; }
.empty { display: flex; align-items: center; justify-content: center; flex: 1; color: var(--text-faint); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); }
</style>
<script module>
function focusOnMount(node: HTMLElement) { node.focus(); }
</script>
-669
View File
@@ -1,669 +0,0 @@
<script lang="ts">
import { onMount, untrack } from "svelte";
import { Play, ArrowRight, ArrowLeft, BookOpen, Clock, Fire, TrendUp, CalendarBlank, CheckCircle, PushPin, X as XIcon, MagnifyingGlass, ListBullets, Bell } from "phosphor-svelte";
import { gql, thumbUrl } from "../../lib/client";
import { getBlobUrl } from "../../lib/imageCache";
import Thumbnail from "../shared/Thumbnail.svelte";
import { GET_LIBRARY, GET_CHAPTERS, GET_MANGA, GET_CATEGORIES } from "../../lib/queries";
import { cache, CACHE_KEYS } from "../../lib/cache";
import { store, openReader, setHeroSlot, setActiveManga, setPreviewManga, setNavPage, setGenreFilter, setLibraryFilter, clearLibraryUpdates } from "../../store/state.svelte";
import type { HistoryEntry } from "../../store/state.svelte";
import type { Manga, Chapter, Category } from "../../lib/types";
import { buildReaderChapterList } from "../../lib/chapterList";
function timeAgo(ts: number): string {
const diff = Date.now() - ts, m = Math.floor(diff / 60000);
if (m < 1) return "Just now";
if (m < 60) return `${m}m ago`;
const h = Math.floor(m / 60);
if (h < 24) return `${h}h ago`;
const d = Math.floor(h / 24);
if (d < 7) return `${d}d ago`;
return new Date(ts).toLocaleDateString("en-US", { month: "short", day: "numeric" });
}
function formatReadTime(mins: number): string {
if (mins < 1) return `${Math.round(mins * 60)}s`;
if (mins < 60) return `${Math.round(mins)}m`;
const h = Math.floor(mins / 60), r = Math.round(mins % 60);
if (h < 24) return r === 0 ? `${h}h` : `${h}h ${r}m`;
const d = Math.floor(h / 24), rh = h % 24;
return rh === 0 ? `${d}d` : `${d}d ${rh}h`;
}
function focusEl(node: HTMLElement) { node.focus(); }
let libraryManga: Manga[] = $state([]);
let extraManga: Manga[] = $state([]);
let loadingLibrary: boolean = $state(true);
onMount(() => {
loadLibrary();
});
function loadLibrary() {
cache.get(CACHE_KEYS.LIBRARY, () =>
gql<{ mangas: { nodes: Manga[] } }>(GET_LIBRARY).then(d => d.mangas.nodes)
)
.then(m => { libraryManga = m; })
.catch(console.error)
.finally(() => loadingLibrary = false);
}
function resetAndReload() {
cache.clear(CACHE_KEYS.LIBRARY);
loadingLibrary = true;
heroChapters = [];
heroAllChapters = [];
heroChaptersFor = null;
loadLibrary();
}
$effect(() => {
if (store.navPage === "home") untrack(() => resetAndReload());
});
$effect(() => {
const sessionId = store.readerSessionId;
if (sessionId === 0) return;
untrack(() => resetAndReload());
});
const continueReading = $derived((() => {
const seen = new Set<number>();
const out: HistoryEntry[] = [];
for (const e of store.history) {
if (seen.has(e.mangaId)) continue;
seen.add(e.mangaId);
out.push(e);
if (out.length >= 10) break;
}
return out;
})());
const TOTAL_SLOTS = 4;
interface HeroSlot { kind: "continue" | "pinned" | "empty"; entry?: HistoryEntry; manga?: Manga; slotIndex: number; }
const resolvedSlots = $derived((() => {
const pins = store.settings.heroSlots ?? [null, null, null, null];
const slots: HeroSlot[] = [];
const first = continueReading[0];
slots.push(first ? { kind: "continue", entry: first, slotIndex: 0 } : { kind: "empty", slotIndex: 0 });
let hi = 1;
for (let i = 1; i < TOTAL_SLOTS; i++) {
const pinId = pins[i];
if (pinId != null) {
const manga = libraryManga.find(m => m.id === pinId);
if (manga) { slots.push({ kind: "pinned", manga, slotIndex: i }); continue; }
}
const entry = continueReading[hi++];
slots.push(entry ? { kind: "continue", entry, slotIndex: i } : { kind: "empty", slotIndex: i });
}
return slots;
})());
let activeIdx = $state(0);
const activeSlot = $derived(resolvedSlots[activeIdx]);
const heroThumbSrc = $derived(
activeSlot?.kind === "pinned" ? (activeSlot.manga?.thumbnailUrl ?? "") :
activeSlot?.kind === "continue" ? (activeSlot.entry?.thumbnailUrl ?? "") : ""
);
let heroThumb = $state("");
$effect(() => {
const path = heroThumbSrc;
const mode = store.settings.serverAuthMode ?? "NONE";
if (!path) { heroThumb = ""; return; }
if (mode !== "BASIC_AUTH") { heroThumb = thumbUrl(path); return; }
// Use tauri-plugin-http backed getBlobUrl which handles auth and bypasses CORS
getBlobUrl(thumbUrl(path))
.then(url => { heroThumb = url; })
.catch(() => { heroThumb = ""; });
});
const heroTitle = $derived(activeSlot?.kind === "pinned" ? (activeSlot.manga?.title ?? "") : activeSlot?.kind === "continue" ? (activeSlot.entry?.mangaTitle ?? "") : "");
const heroManga = $derived(activeSlot?.kind === "pinned" ? activeSlot.manga : activeSlot?.kind === "continue" ? libraryManga.find(m => m.id === activeSlot.entry?.mangaId) : null);
const heroEntry = $derived(activeSlot?.kind === "continue" ? activeSlot.entry : null);
const heroMangaId = $derived(heroEntry?.mangaId ?? heroManga?.id ?? null);
function cycleNext() { activeIdx = (activeIdx + 1) % TOTAL_SLOTS; heroChapters = []; heroAllChapters = []; }
function cyclePrev() { activeIdx = (activeIdx - 1 + TOTAL_SLOTS) % TOTAL_SLOTS; heroChapters = []; heroAllChapters = []; }
function goToSlot(i: number) { if (i !== activeIdx) { activeIdx = i; heroChapters = []; heroAllChapters = []; } }
function onKey(e: KeyboardEvent) {
if (e.target !== document.body && !(e.target instanceof HTMLElement && e.target.closest(".hero-stage"))) return;
if (e.key === "ArrowRight") cycleNext();
if (e.key === "ArrowLeft") cyclePrev();
}
onMount(() => {
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
});
let heroStageH = $state(300);
let heroChapters: Chapter[] = $state([]);
let heroAllChapters: Chapter[] = $state([]);
let loadingHeroChapters = $state(false);
let heroChaptersFor: number | null = null;
$effect(() => {
const id = heroMangaId;
void store.settings.mangaPrefs?.[id!];
if (id) untrack(() => loadHeroChapters(id));
});
async function loadHeroChapters(mangaId: number) {
heroChaptersFor = mangaId;
loadingHeroChapters = true;
heroChapters = [];
heroAllChapters = [];
try {
const d = await gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId });
if (heroChaptersFor !== mangaId) return;
const all = [...d.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder);
heroAllChapters = all;
const filtered = buildReaderChapterList(all, store.settings.mangaPrefs?.[mangaId]);
const lastReadIdx = heroEntry ? filtered.findIndex(c => c.id === heroEntry!.chapterId) : filtered.findLastIndex(c => c.isRead);
const startIdx = Math.max(0, lastReadIdx);
heroChapters = filtered.slice(startIdx, startIdx + 5);
} catch { heroChapters = []; heroAllChapters = []; }
finally { loadingHeroChapters = false; }
}
let resuming = $state(false);
async function openChapter(chapter: Chapter) {
if (!heroMangaId) return;
resuming = true;
try {
let all = heroAllChapters;
if (!all.length) {
const d = await gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId: heroMangaId });
all = [...d.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder);
}
if (all.length) {
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; }
finally { resuming = false; }
}
async function resumeActive() {
if (!heroEntry && heroManga) { store.activeManga = heroManga; return; }
if (!heroEntry) return;
const target = heroAllChapters.find(c => c.id === heroEntry!.chapterId) ?? heroAllChapters[0];
if (target && heroAllChapters.length) { await openChapter(target); return; }
resuming = true;
try {
const d = await gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId: heroEntry.mangaId });
const raw = [...d.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder);
const list = buildReaderChapterList(raw, store.settings.mangaPrefs?.[heroEntry.mangaId]);
const ch = list.find(c => c.id === heroEntry!.chapterId) ?? list[0];
if (ch) {
store.activeManga = 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; }
finally { resuming = false; }
}
async function resumeEntry(entry: HistoryEntry) {
try {
const d = await gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId: entry.mangaId });
const raw = [...d.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder);
const list = buildReaderChapterList(raw, store.settings.mangaPrefs?.[entry.mangaId]);
const ch = list.find(c => c.id === entry.chapterId) ?? list[0];
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; }
}
let pickerOpen = $state(false);
let pickerSlotIndex: 1|2|3|null = $state(null);
let pickerSearch = $state("");
const pickerResults = $derived(pickerSearch.trim()
? libraryManga.filter(m => m.title.toLowerCase().includes(pickerSearch.toLowerCase())).slice(0, 20)
: libraryManga.slice(0, 20));
function openPicker(i: 1|2|3) { pickerSlotIndex = i; pickerOpen = true; pickerSearch = ""; }
function closePicker() { pickerOpen = false; pickerSlotIndex = null; }
function pinManga(m: Manga) { if (pickerSlotIndex !== null) { setHeroSlot(pickerSlotIndex, m.id); closePicker(); } }
function unpinSlot(i: 1|2|3) { setHeroSlot(i, null); }
const recentHistory = $derived(store.history.slice(0, 6));
const stats = $derived(store.readingStats);
const libraryUpdates = $derived(store.libraryUpdates.slice(0, 7));
const lastRefresh = $derived(store.lastLibraryRefresh);
function timeAgoRefresh(ts: number): string {
if (!ts) return "";
const diff = Date.now() - ts, m = Math.floor(diff / 60000);
if (m < 1) return "just now";
if (m < 60) return `${m}m ago`;
const h = Math.floor(m / 60);
if (h < 24) return `${h}h ago`;
return `${Math.floor(h / 24)}d ago`;
}
function handleRowWheel(e: WheelEvent) {
if (Math.abs(e.deltaY) <= Math.abs(e.deltaX)) return;
(e.currentTarget as HTMLElement).scrollLeft += e.deltaY;
e.stopPropagation();
}
</script>
<div class="root">
<div class="body">
<div class="hero-section">
<div class="hero-stage" bind:clientHeight={heroStageH} style="--hero-h:{heroStageH}px">
{#if heroThumb}
<div class="hero-backdrop" style="background-image:url({heroThumb})"></div>
{:else}
<div class="hero-backdrop hero-bd-empty"></div>
{/if}
<div class="hero-scrim"></div>
<button class="hero-cover-col" onclick={resumeActive} disabled={resuming || activeSlot?.kind === "empty"} aria-label={heroTitle ? `Resume ${heroTitle}` : "No manga selected"}>
{#if heroThumb}
<img src={heroThumb} alt={heroTitle} class="hero-cover" loading="eager" decoding="async" />
{#if activeSlot?.kind === "continue"}
<div class="cover-resume-hint"><Play size={18} weight="fill" /></div>
{/if}
{:else}
<div class="hero-cover-empty"><BookOpen size={28} weight="light" /></div>
{/if}
</button>
<div class="hero-details">
{#if activeSlot?.kind === "empty"}
<p class="hero-empty-title">Nothing here yet</p>
<p class="hero-empty-sub">{activeSlot.slotIndex === 0 ? "Read a manga to see it here" : "Pin a manga or keep reading to fill this slot"}</p>
{#if activeSlot.slotIndex !== 0}
<button class="hero-cta" onclick={() => openPicker(activeSlot.slotIndex as 1|2|3)}>
<PushPin size={11} weight="fill" /> Pin manga
</button>
{/if}
{:else}
<div class="hero-tags">
{#if activeSlot?.kind === "continue"}
<span class="hero-tag hero-tag-reading"><Play size={8} weight="fill" /> Reading</span>
{:else}
<span class="hero-tag hero-tag-pinned"><PushPin size={8} weight="fill" /> Pinned</span>
{/if}
{#each (heroManga?.genre ?? []).slice(0, 3) as g}
<button class="hero-tag hero-tag-genre" onclick={() => { setGenreFilter(g); setNavPage("explore"); }}>{g}</button>
{/each}
</div>
<h2 class="hero-title">{heroTitle}</h2>
{#if heroManga?.author}<p class="hero-author">{heroManga.author}</p>{/if}
{#if heroEntry}
<p class="hero-progress">
<Clock size={10} weight="light" />
{heroEntry.chapterName}
{#if heroEntry.pageNumber > 1}<span class="hero-prog-page"> · p.{heroEntry.pageNumber}</span>{/if}
<span class="hero-prog-time">{timeAgo(heroEntry.readAt)}</span>
</p>
{/if}
{#if heroManga?.description}<p class="hero-desc">{heroManga.description}</p>{/if}
<div class="hero-actions">
{#if activeSlot?.kind === "continue"}
<button class="hero-cta" onclick={resumeActive} disabled={resuming}>
<Play size={11} weight="fill" />{resuming ? "Loading…" : "Resume"}
</button>
{:else if heroManga}
<button class="hero-cta" onclick={() => store.previewManga = heroManga!}>
<BookOpen size={11} weight="light" /> View manga
</button>
{/if}
{#if activeSlot?.slotIndex !== 0}
{#if activeSlot?.kind === "pinned"}
<button class="hero-cta-ghost" onclick={() => unpinSlot(activeSlot.slotIndex as 1|2|3)}>
<XIcon size={10} weight="bold" /> Unpin
</button>
{:else}
<button class="hero-cta-ghost" onclick={() => openPicker(activeSlot!.slotIndex as 1|2|3)}>
<PushPin size={10} weight="light" /> Pin
</button>
{/if}
{/if}
</div>
{/if}
<div class="hero-nav-row">
<button class="hero-nav-btn" onclick={cyclePrev} aria-label="Previous"><ArrowLeft size={12} weight="bold" /></button>
<div class="hero-dots">
{#each resolvedSlots as slot, i}
<button class="hero-dot" class:active={activeIdx === i} class:pinned={slot.kind === "pinned"} onclick={() => goToSlot(i)} aria-label="Slot {i + 1}"></button>
{/each}
</div>
<button class="hero-nav-btn" onclick={cycleNext} aria-label="Next"><ArrowRight size={12} weight="bold" /></button>
<span class="hero-counter">{activeIdx + 1}/{TOTAL_SLOTS}</span>
</div>
</div>
<div class="hero-chapters">
<div class="hero-chapters-header"><ListBullets size={11} weight="bold" /><span>Up Next</span></div>
{#if activeSlot?.kind === "empty"}
<p class="hero-chapters-empty">No chapters to show</p>
{:else if loadingHeroChapters}
{#each Array(4) as _}
<div class="chapter-row-sk">
<div class="sk sk-num"></div>
<div class="sk-info"><div class="sk sk-name"></div><div class="sk sk-meta"></div></div>
</div>
{/each}
{:else if heroChapters.length === 0}
<p class="hero-chapters-empty">No chapters available</p>
{:else}
{#each heroChapters as ch (ch.id)}
{@const isCurrent = heroEntry?.chapterId === ch.id}
<button class="chapter-row" class:chapter-row-current={isCurrent} class:chapter-row-read={ch.isRead && !isCurrent} onclick={() => openChapter(ch)}>
<span class="ch-num">Ch.{ch.chapterNumber % 1 === 0 ? Math.floor(ch.chapterNumber) : ch.chapterNumber}</span>
<div class="ch-info">
<span class="ch-name">{ch.name}</span>
{#if isCurrent && heroEntry && heroEntry.pageNumber > 1}
<span class="ch-meta">p.{heroEntry.pageNumber} · in progress</span>
{:else if ch.isRead}
<span class="ch-meta ch-read">Read</span>
{:else if ch.uploadDate}
<span class="ch-meta">{new Date(Number(ch.uploadDate) > 1e10 ? Number(ch.uploadDate) : Number(ch.uploadDate)*1000).toLocaleDateString("en-US",{month:"short",day:"numeric"})}</span>
{/if}
</div>
{#if isCurrent}<Play size={10} weight="fill" class="ch-play-icon" />{/if}
</button>
{/each}
{#if heroManga}
<button class="ch-view-all" onclick={() => { if (heroManga) store.activeManga = heroManga; }}>
All chapters <ArrowRight size={9} weight="bold" />
</button>
{/if}
{/if}
</div>
</div>
</div>
<div class="section">
<div class="section-header">
<span class="section-title"><Clock size={10} weight="bold" /> Recent Activity</span>
{#if recentHistory.length > 0}
<button class="see-all" onclick={() => setNavPage("history")}>Full History <ArrowRight size={9} weight="bold" /></button>
{/if}
</div>
<div class="activity-list">
{#if recentHistory.length > 0}
{#each recentHistory as entry (entry.chapterId)}
<button class="activity-row" onclick={() => resumeEntry(entry)}>
<Thumbnail src={entry.thumbnailUrl} alt={entry.mangaTitle} class="activity-thumb" />
<div class="activity-info">
<span class="activity-title">{entry.mangaTitle}</span>
<span class="activity-sub">{entry.chapterName}{entry.pageNumber > 1 ? ` · p.${entry.pageNumber}` : ""}</span>
</div>
<span class="activity-time">{timeAgo(entry.readAt)}</span>
<span class="activity-play"><Play size={10} weight="fill" /></span>
</button>
{/each}
{:else}
<div class="activity-placeholder">
{#each Array(5) as _, i}
<div class="activity-row activity-row-sk">
<div class="sk-thumb"></div>
<div class="activity-info">
<div class="sk sk-title" style="width: {55 + (i * 7) % 30}%"></div>
<div class="sk sk-sub" style="width: {30 + (i * 11) % 25}%"></div>
</div>
<div class="sk sk-time"></div>
</div>
{/each}
<div class="activity-placeholder-overlay">
<button class="activity-placeholder-cta" onclick={() => setNavPage("library")}>
<BookOpen size={12} weight="light" /> Start reading
</button>
</div>
</div>
{/if}
</div>
</div>
<div class="bottom-row">
<div class="bottom-col">
<div class="bottom-section-hd">
<span class="section-title"><Bell size={10} weight="bold" /> Updates
{#if lastRefresh}<span class="refresh-age">{timeAgoRefresh(lastRefresh)}</span>{/if}
</span>
{#if libraryUpdates.length > 0}
<button class="see-all" onclick={() => { clearLibraryUpdates(); setLibraryFilter("all"); setNavPage("library"); }}>Clear <ArrowRight size={9} weight="bold" /></button>
{/if}
</div>
{#if libraryUpdates.length > 0}
<div class="mini-row" onwheel={(e) => { e.preventDefault(); handleRowWheel(e); }}>
{#each libraryUpdates as u (u.mangaId)}
{@const m = libraryManga.find(x => x.id === u.mangaId)}
<button class="mini-card" onclick={() => { if (m) store.previewManga = m; }}>
<div class="mini-cover-wrap">
<Thumbnail src={u.thumbnailUrl} alt={u.mangaTitle} class="mini-cover" />
<div class="mini-gradient"></div>
<div class="mini-footer">
<p class="mini-card-title">{u.mangaTitle}</p>
<p class="mini-card-source">+{u.newChapters} chapter{u.newChapters !== 1 ? "s" : ""}</p>
</div>
</div>
</button>
{/each}
</div>
{:else}
<p class="bottom-empty">{lastRefresh ? "No new chapters found" : "Check for updates in the library"}</p>
{/if}
</div>
<div class="bottom-divider"></div>
<div class="bottom-col">
<div class="bottom-section-hd">
<span class="section-title"><TrendUp size={10} weight="bold" /> Your Stats</span>
</div>
<div class="stats-grid">
<div class="stat-card"><div class="stat-icon-wrap stat-fire"><Fire size={16} weight="fill" /></div><div class="stat-body"><span class="stat-val">{stats.currentStreakDays}</span><span class="stat-label">Day streak</span></div></div>
<div class="stat-card"><div class="stat-icon-wrap stat-accent"><BookOpen size={16} weight="light" /></div><div class="stat-body"><span class="stat-val">{stats.totalChaptersRead}</span><span class="stat-label">Chapters read</span></div></div>
<div class="stat-card"><div class="stat-icon-wrap stat-neutral"><Clock size={16} weight="light" /></div><div class="stat-body"><span class="stat-val">{formatReadTime(stats.totalMinutesRead)}</span><span class="stat-label">Read time</span></div></div>
<div class="stat-card"><div class="stat-icon-wrap stat-neutral"><TrendUp size={16} weight="light" /></div><div class="stat-body"><span class="stat-val">{stats.totalMangaRead}</span><span class="stat-label">Series started</span></div></div>
<div class="stat-card"><div class="stat-icon-wrap stat-green"><Bell size={16} weight="light" /></div><div class="stat-body"><span class="stat-val">{libraryUpdates.length}</span><span class="stat-label">New updates</span></div></div>
<div class="stat-card"><div class="stat-icon-wrap stat-neutral"><CalendarBlank size={16} weight="light" /></div><div class="stat-body"><span class="stat-val">{stats.longestStreakDays}d</span><span class="stat-label">Best streak</span></div></div>
</div>
</div>
</div>
</div>
</div>
{#if pickerOpen}
<div class="picker-backdrop" role="presentation"
onclick={(e) => { if (e.target === e.currentTarget) closePicker(); }}
onkeydown={(e) => { if (e.key === "Escape") closePicker(); }}>
<div class="picker-modal">
<div class="picker-header">
<span class="picker-title">Pin manga — slot {(pickerSlotIndex ?? 0) + 1}</span>
<button class="picker-close" onclick={closePicker}><XIcon size={13} weight="light" /></button>
</div>
<div class="picker-search-wrap">
<MagnifyingGlass size={12} weight="light" style="color:var(--text-faint);flex-shrink:0" />
<input class="picker-search" placeholder="Search library…" bind:value={pickerSearch} use:focusEl />
</div>
<div class="picker-list">
{#if loadingLibrary}
<p class="picker-empty">Loading…</p>
{:else if pickerResults.length === 0}
<p class="picker-empty">No results</p>
{:else}
{#each pickerResults as m (m.id)}
<button class="picker-row" onclick={() => pinManga(m)}>
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="picker-thumb" />
<div class="picker-info">
<span class="picker-manga-title">{m.title}</span>
{#if m.source?.displayName}<span class="picker-source">{m.source.displayName}</span>{/if}
</div>
</button>
{/each}
{/if}
</div>
</div>
</div>
{/if}
<style>
.root { display: flex; flex-direction: column; height: 100%; overflow: hidden; animation: fadeIn 0.14s ease both; }
.body { flex: 1; display: flex; flex-direction: column; overflow-y: auto; overflow-x: hidden; min-height: 0; }
.hero-section { padding: var(--sp-3) var(--sp-4) var(--sp-2); flex-shrink: 0; display: flex; flex-direction: column; }
.hero-stage { position: relative; display: flex; align-items: stretch; height: 374px; border-radius: var(--radius-xl); overflow: hidden; background: var(--bg-raised); border: 1px solid var(--border-dim); box-shadow: 0 6px 28px rgba(0,0,0,0.28); }
.hero-backdrop { position: absolute; inset: -14px; background-size: cover; background-position: center 25%; filter: blur(20px) saturate(2.2) brightness(0.45); transform: scale(1.07); pointer-events: none; z-index: 0; }
.hero-bd-empty { background: var(--bg-void); filter: none; }
.hero-scrim { position: absolute; inset: 0; z-index: 1; pointer-events: none; background: linear-gradient(100deg, rgba(0,0,0,0.0) 0%, rgba(0,0,0,0.55) 100%); }
.hero-cover-col { position: relative; z-index: 2; flex-shrink: 0; width: 263px; height: 374px; overflow: hidden; cursor: pointer; border-right: 1px solid rgba(255,255,255,0.08); background: var(--bg-raised); }
.hero-cover-col:hover .hero-cover { filter: brightness(1.08); }
.hero-cover-col:hover .cover-resume-hint { opacity: 1; }
.hero-cover { width: 100%; height: 100%; object-fit: cover; display: block; transition: filter 0.2s ease; }
.hero-cover-empty { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; background: var(--bg-overlay); color: var(--text-faint); }
.cover-resume-hint { position: absolute; inset: var(--sp-3); display: flex; align-items: center; justify-content: center; color: #fff; font-size: 36px; background: rgba(0,0,0,0.4); border-radius: var(--radius-lg); opacity: 0; transition: opacity 0.18s ease; pointer-events: none; }
.hero-details { position: relative; z-index: 2; flex: 1; min-width: 0; padding: var(--sp-4) var(--sp-5) var(--sp-3); display: flex; flex-direction: column; gap: var(--sp-2); overflow: hidden; border-right: 1px solid rgba(255,255,255,0.06); }
.hero-tags { display: flex; flex-wrap: wrap; gap: 5px; flex-shrink: 0; }
.hero-tag { font-family: var(--font-ui); font-size: 9px; letter-spacing: var(--tracking-wide); text-transform: uppercase; padding: 2px 7px; border-radius: var(--radius-full); background: rgba(255,255,255,0.1); color: rgba(255,255,255,0.62); border: 1px solid rgba(255,255,255,0.14); }
.hero-tag-reading { background: var(--accent-muted); color: var(--accent-fg); border-color: var(--accent-dim); }
.hero-tag-pinned { background: rgba(168,132,232,0.18); color: #c4a8f0; border-color: rgba(168,132,232,0.28); }
.hero-tag-genre { cursor: pointer; transition: background 0.15s ease, color 0.15s ease; }
.hero-tag-genre:hover { background: rgba(255,255,255,0.18); color: rgba(255,255,255,0.9); }
.hero-title { font-size: var(--text-xl); font-weight: var(--weight-medium); color: #fff; line-height: var(--leading-tight); margin: 0; flex-shrink: 0; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; text-shadow: 0 2px 10px rgba(0,0,0,0.5); }
.hero-author { font-family: var(--font-ui); font-size: var(--text-xs); color: rgba(255,255,255,0.48); letter-spacing: var(--tracking-wide); flex-shrink: 0; }
.hero-progress { display: flex; align-items: center; gap: 5px; flex-shrink: 0; font-family: var(--font-ui); font-size: var(--text-xs); color: rgba(255,255,255,0.58); letter-spacing: var(--tracking-wide); }
.hero-prog-page { color: rgba(255,255,255,0.38); }
.hero-prog-time { margin-left: auto; color: rgba(255,255,255,0.32); }
.hero-desc { font-size: var(--text-xs); color: rgba(255,255,255,0.42); line-height: 1.55; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; flex-shrink: 0; }
.hero-empty-title { font-size: var(--text-base); font-weight: var(--weight-medium); color: rgba(255,255,255,0.5); flex-shrink: 0; }
.hero-empty-sub { font-family: var(--font-ui); font-size: var(--text-xs); color: rgba(255,255,255,0.28); letter-spacing: var(--tracking-wide); line-height: var(--leading-snug); }
.hero-actions { display: flex; gap: var(--sp-2); flex-shrink: 0; flex-wrap: wrap; }
.hero-cta { display: inline-flex; align-items: center; gap: 6px; font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 7px 16px; border-radius: var(--radius-full); background: var(--accent-muted); border: 1px solid var(--accent-dim); color: var(--accent-fg); cursor: pointer; transition: filter var(--t-base); white-space: nowrap; }
.hero-cta:hover:not(:disabled) { filter: brightness(1.15); }
.hero-cta:disabled { opacity: 0.55; cursor: default; }
.hero-cta-ghost { display: inline-flex; align-items: center; gap: 6px; font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 7px 14px; border-radius: var(--radius-full); background: rgba(255,255,255,0.07); border: 1px solid rgba(255,255,255,0.13); color: rgba(255,255,255,0.52); cursor: pointer; transition: background var(--t-base), color var(--t-base); white-space: nowrap; }
.hero-cta-ghost:hover { background: rgba(255,255,255,0.13); color: rgba(255,255,255,0.82); }
.hero-nav-row { display: flex; align-items: center; gap: var(--sp-2); flex-shrink: 0; margin-top: auto; padding-top: var(--sp-2); border-top: 1px solid rgba(255,255,255,0.08); }
.hero-nav-btn { display: flex; align-items: center; justify-content: center; width: 22px; height: 22px; border-radius: 50%; background: rgba(255,255,255,0.1); border: 1px solid rgba(255,255,255,0.12); color: rgba(255,255,255,0.6); cursor: pointer; flex-shrink: 0; transition: background var(--t-base), color var(--t-base); }
.hero-nav-btn:hover { background: rgba(255,255,255,0.2); color: #fff; }
.hero-dots { display: flex; gap: 5px; align-items: center; }
.hero-dot { width: 5px; height: 5px; border-radius: 50%; background: rgba(255,255,255,0.22); border: none; cursor: pointer; padding: 0; transition: background var(--t-base), transform var(--t-base); }
.hero-dot:hover { background: rgba(255,255,255,0.5); }
.hero-dot.active { background: #fff; transform: scale(1.35); }
.hero-dot.pinned { background: rgba(168,132,232,0.55); }
.hero-dot.pinned.active { background: #c4a8f0; }
.hero-counter { font-family: var(--font-ui); font-size: 10px; color: rgba(255,255,255,0.3); letter-spacing: var(--tracking-wide); margin-left: auto; }
.hero-chapters { position: relative; z-index: 2; width: clamp(180px, 32%, 240px); flex-shrink: 0; display: flex; flex-direction: column; padding: var(--sp-4) var(--sp-3); gap: 1px; overflow: hidden; }
.hero-chapters-header { display: flex; align-items: center; gap: var(--sp-2); font-family: var(--font-ui); font-size: var(--text-xs); color: rgba(255,255,255,0.4); letter-spacing: var(--tracking-wider); text-transform: uppercase; padding-bottom: var(--sp-2); margin-bottom: var(--sp-1); border-bottom: 1px solid rgba(255,255,255,0.08); flex-shrink: 0; }
.hero-chapters-empty { font-family: var(--font-ui); font-size: var(--text-xs); color: rgba(255,255,255,0.25); letter-spacing: var(--tracking-wide); padding: var(--sp-3) 0; }
.chapter-row { display: flex; align-items: center; gap: var(--sp-2); width: 100%; padding: 7px var(--sp-2); border-radius: var(--radius-sm); background: none; border: none; text-align: left; cursor: pointer; transition: background var(--t-fast); }
.chapter-row:hover { background: rgba(255,255,255,0.07); }
.chapter-row-current { background: rgba(255,255,255,0.1) !important; }
.ch-num { font-family: var(--font-ui); font-size: var(--text-2xs); color: rgba(255,255,255,0.35); letter-spacing: var(--tracking-wide); flex-shrink: 0; min-width: 36px; }
.chapter-row-current .ch-num { color: var(--accent-fg); }
.ch-info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; min-width: 0; }
.ch-name { font-size: var(--text-xs); color: rgba(255,255,255,0.75); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.chapter-row-read .ch-name { color: rgba(255,255,255,0.35); }
.chapter-row-current .ch-name { color: #fff; font-weight: var(--weight-medium); }
.ch-meta { font-family: var(--font-ui); font-size: 9px; color: rgba(255,255,255,0.28); letter-spacing: var(--tracking-wide); }
.ch-read { color: rgba(255,255,255,0.2); }
:global(.ch-play-icon) { color: var(--accent-fg); flex-shrink: 0; }
.chapter-row-sk { display: flex; gap: var(--sp-2); padding: 7px var(--sp-2); align-items: center; }
.sk-info { flex: 1; display: flex; flex-direction: column; gap: 4px; }
.sk { background: rgba(255,255,255,0.06); border-radius: var(--radius-sm); }
.sk-num { width: 32px; height: 10px; flex-shrink: 0; }
.sk-name { height: 11px; width: 85%; }
.sk-meta { height: 9px; width: 50%; }
.ch-view-all { display: flex; align-items: center; gap: 4px; margin-top: auto; font-family: var(--font-ui); font-size: var(--text-2xs); color: rgba(255,255,255,0.3); letter-spacing: var(--tracking-wide); background: none; border: none; cursor: pointer; padding: var(--sp-2) var(--sp-2) 0; transition: color var(--t-base); }
.ch-view-all:hover { color: var(--accent-fg); }
.section { border-top: 1px solid var(--border-dim); flex-shrink: 0; }
.section-header { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-3) var(--sp-4) var(--sp-2); }
.section-title { display: inline-flex; align-items: center; gap: var(--sp-2); font-family: var(--font-ui); font-size: var(--text-sm); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; }
.see-all { display: flex; align-items: center; gap: 4px; font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); text-transform: uppercase; color: var(--text-faint); background: none; border: none; cursor: pointer; padding: 0; transition: color var(--t-base); }
.see-all:hover { color: var(--accent-fg); }
.activity-list { display: flex; flex-direction: column; padding: 0 var(--sp-3); overflow: hidden; }
.activity-row { display: flex; align-items: center; gap: var(--sp-3); padding: 7px var(--sp-2); border-radius: var(--radius-md); border: 1px solid transparent; background: none; text-align: left; cursor: pointer; width: 100%; transition: background var(--t-fast), border-color var(--t-fast); }
.activity-row:hover { background: var(--bg-raised); border-color: var(--border-dim); }
.activity-row:hover .activity-play { opacity: 1; }
:global(.activity-thumb) { width: 33px; height: 48px; border-radius: var(--radius-sm); object-fit: cover; flex-shrink: 0; border: 1px solid var(--border-dim); }
.activity-info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; min-width: 0; }
.activity-title { font-size: var(--text-base); font-weight: var(--weight-medium); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.activity-sub { font-family: var(--font-ui); font-size: var(--text-sm); color: var(--text-muted); letter-spacing: var(--tracking-wide); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.activity-time { font-family: var(--font-ui); font-size: var(--text-sm); color: var(--text-faint); letter-spacing: var(--tracking-wide); flex-shrink: 0; }
.activity-play { color: var(--accent-fg); flex-shrink: 0; opacity: 0; transition: opacity var(--t-base); }
.bottom-row { display: grid; grid-template-columns: 1fr 1px 1fr; padding: 0 var(--sp-4); border-top: 1px solid var(--border-dim); flex-shrink: 0; }
.bottom-divider { background: var(--border-dim); align-self: stretch; }
.bottom-col { display: flex; flex-direction: column; min-width: 0; padding-top: var(--sp-4); padding-bottom: var(--sp-5); }
.bottom-col:first-child { padding-right: 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-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; }
.refresh-age { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); margin-left: var(--sp-2); }
.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 { flex: 0 0 120px; width: 120px; background: none; border: none; padding: 0; cursor: pointer; text-align: left; }
.mini-card:hover :global(.mini-cover) { filter: brightness(1.08) saturate(1.05); transform: scale(1.02); }
.mini-card:hover { will-change: transform; }
.mini-cover-wrap { position: relative; aspect-ratio: 2/3; overflow: hidden; border-radius: var(--radius-md); background: var(--bg-raised); border: 1px solid var(--border-dim); box-shadow: 0 2px 12px rgba(0,0,0,0.35); }
:global(.mini-cover) { width: 100%; height: 100%; object-fit: cover; display: block; transition: filter 0.15s ease, transform 0.15s ease; }
.mini-gradient { position: absolute; inset: 0; background: linear-gradient(to top, rgba(0,0,0,0.85) 0%, rgba(0,0,0,0.1) 55%, transparent 75%); pointer-events: none; }
.mini-footer { position: absolute; bottom: 0; left: 0; right: 0; padding: var(--sp-2); pointer-events: none; }
.mini-card-title { font-size: var(--text-xs); font-weight: var(--weight-medium); color: rgba(255,255,255,0.92); line-height: var(--leading-snug); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; text-shadow: 0 1px 4px rgba(0,0,0,0.7); }
.mini-card-source { font-family: var(--font-ui); font-size: 9px; color: rgba(255,255,255,0.45); letter-spacing: var(--tracking-wide); margin-top: 1px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.stats-grid { display: grid; grid-template-columns: 1fr 1fr; gap: var(--sp-2); }
.stat-card { display: flex; align-items: center; gap: var(--sp-3); background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: var(--sp-3) var(--sp-3); }
.stat-icon-wrap { display: flex; align-items: center; justify-content: center; width: 34px; height: 34px; border-radius: var(--radius-sm); flex-shrink: 0; }
.stat-fire { background: rgba(251,146,60,0.15); color: #fb923c; }
.stat-accent { background: var(--accent-muted); color: var(--accent-fg); }
.stat-neutral { background: var(--bg-overlay); color: var(--text-faint); }
.stat-green { background: rgba(34,197,94,0.12); color: #22c55e; }
.stat-body { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
.stat-val { font-family: var(--font-ui); font-size: var(--text-lg, 1.05rem); font-weight: var(--weight-medium); color: var(--text-secondary); line-height: 1; }
.stat-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); white-space: nowrap; }
.activity-row-sk { cursor: default; pointer-events: none; }
.sk-thumb { width: 33px; height: 48px; border-radius: var(--radius-sm); background: rgba(255,255,255,0.06); flex-shrink: 0; }
.sk { background: var(--bg-raised); border-radius: var(--radius-sm); }
.sk-title { height: 11px; margin-bottom: 5px; }
.sk-sub { height: 9px; }
.sk-time { width: 32px; height: 9px; flex-shrink: 0; background: rgba(255,255,255,0.06); border-radius: var(--radius-sm); }
.activity-placeholder { position: relative; }
.activity-placeholder-overlay { position: absolute; left: 0; right: 0; top: 0; bottom: -1px; display: flex; align-items: flex-end; justify-content: center; padding-bottom: var(--sp-4); pointer-events: none; background: linear-gradient(to bottom, transparent 0%, rgba(0,0,0,0.6) 100%); }
.activity-placeholder-cta { pointer-events: all; display: inline-flex; align-items: center; gap: 6px; font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 7px 16px; border-radius: var(--radius-full); background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.14); color: rgba(255,255,255,0.65); cursor: pointer; transition: background var(--t-base), color var(--t-base); }
.activity-placeholder-cta:hover { background: rgba(255,255,255,0.13); color: rgba(255,255,255,0.9); }
.picker-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.6); z-index: var(--z-settings); display: flex; align-items: center; justify-content: center; animation: fadeIn 0.1s ease both; backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px); }
.picker-modal { width: min(460px, calc(100vw - 48px)); max-height: 68vh; display: flex; flex-direction: column; background: var(--bg-surface); border: 1px solid var(--border-base); border-radius: var(--radius-xl); overflow: hidden; box-shadow: 0 24px 64px rgba(0,0,0,0.6); animation: scaleIn 0.14s ease both; }
.picker-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; }
.picker-title { font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-secondary); }
.picker-close { display: flex; align-items: center; justify-content: center; width: 24px; height: 24px; border-radius: var(--radius-sm); color: var(--text-faint); background: none; border: none; cursor: pointer; }
.picker-close:hover { color: var(--text-muted); background: var(--bg-raised); }
.picker-search-wrap { display: flex; align-items: center; gap: var(--sp-2); padding: var(--sp-3) var(--sp-4); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
.picker-search { flex: 1; background: none; border: none; outline: none; color: var(--text-primary); font-size: var(--text-sm); }
.picker-search::placeholder { color: var(--text-faint); }
.picker-list { flex: 1; overflow-y: auto; padding: var(--sp-2); scrollbar-width: none; }
.picker-list::-webkit-scrollbar { display: none; }
.picker-empty { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); padding: var(--sp-4) var(--sp-3); text-align: center; }
.picker-row { display: flex; align-items: center; gap: var(--sp-3); width: 100%; padding: 8px var(--sp-3); border-radius: var(--radius-md); border: none; background: none; text-align: left; cursor: pointer; transition: background var(--t-fast); }
.picker-row:hover { background: var(--bg-raised); }
:global(.picker-thumb) { height: 50px; width: 35px; aspect-ratio: 1 / 1.42; border-radius: var(--radius-sm); object-fit: cover; flex-shrink: 0; border: 1px solid var(--border-dim); background: var(--bg-raised); display: block; }
.picker-info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; min-width: 0; }
.picker-manga-title { font-size: var(--text-sm); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.picker-source { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
@keyframes scaleIn { from { opacity: 0; transform: scale(0.97) } to { opacity: 1; transform: scale(1) } }
@keyframes pulse { 0%,100% { opacity: 0.4 } 50% { opacity: 0.7 } }
</style>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-894
View File
@@ -1,894 +0,0 @@
<script lang="ts">
import { CircleNotch, ArrowSquareOut, ArrowsClockwise, X, Lock, MagnifyingGlass, Funnel } from "phosphor-svelte";
import { gql } from "../../lib/client";
import Thumbnail from "../shared/Thumbnail.svelte";
import {
GET_ALL_TRACKER_RECORDS,
UPDATE_TRACK,
UNBIND_TRACK,
FETCH_TRACK,
} from "../../lib/queries";
import { addToast, setActiveManga, setNavPage } from "../../store/state.svelte";
import type { Tracker, TrackRecord } from "../../lib/types";
interface TrackerWithRecords extends Tracker {
trackRecords: { nodes: TrackRecord[] };
}
interface FlatRecord extends TrackRecord {
tracker: Tracker;
}
let trackers: TrackerWithRecords[] = $state([]);
let loading: boolean = $state(true);
let error: string | null = $state(null);
let activeTrackerId: number | "all" = $state("all");
let statusFilter: number | "all" = $state("all");
let searchQuery: string = $state("");
let sortBy: "title" | "status" | "score" | "progress" = $state("title");
let updatingId: number | null = $state(null);
let syncingId: number | null = $state(null);
let editingChapter: number | null = $state(null);
let chapterDraft: number = $state(0);
let confirmUnbindRecord: FlatRecord | null = $state(null);
async function load() {
loading = true; error = null;
try {
const res = await gql<{ trackers: { nodes: TrackerWithRecords[] } }>(GET_ALL_TRACKER_RECORDS);
trackers = res.trackers.nodes;
} catch (e: any) {
error = e?.message ?? "Failed to load tracking data";
} finally {
loading = false;
}
}
$effect(() => { load(); });
const loggedInTrackers = $derived(trackers.filter(t => t.isLoggedIn));
const allRecords: FlatRecord[] = $derived(
loggedInTrackers.flatMap(t =>
t.trackRecords.nodes.map(r => ({
...r,
trackerId: r.trackerId ?? t.id,
tracker: t as Tracker,
}))
)
);
const totalCount = $derived(allRecords.length);
const statusOptions = $derived.by(() => {
if (activeTrackerId === "all") {
const seen = new Map<string, { value: number; name: string }>();
for (const t of loggedInTrackers)
for (const s of t.statuses ?? []) seen.set(`${s.value}:${s.name}`, s);
return [...seen.values()];
}
return loggedInTrackers.find(t => t.id === activeTrackerId)?.statuses ?? [];
});
const filtered = $derived.by(() => {
let list = activeTrackerId === "all"
? allRecords
: allRecords.filter(r => Number(r.trackerId) === Number(activeTrackerId));
if (statusFilter !== "all")
list = list.filter(r => Number(r.status) === Number(statusFilter));
if (searchQuery.trim())
list = list.filter(r =>
r.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
r.manga?.title?.toLowerCase().includes(searchQuery.toLowerCase())
);
return [...list].sort((a, b) => {
if (sortBy === "title") return a.title.localeCompare(b.title);
if (sortBy === "status") return a.status - b.status;
if (sortBy === "score") return parseFloat(b.displayScore ?? "0") - parseFloat(a.displayScore ?? "0");
if (sortBy === "progress") {
const ap = a.totalChapters > 0 ? a.lastChapterRead / a.totalChapters : 0;
const bp = b.totalChapters > 0 ? b.lastChapterRead / b.totalChapters : 0;
return bp - ap;
}
return 0;
});
});
async function updateStatus(record: FlatRecord, status: number) {
updatingId = record.id;
try {
const res = await gql<{ updateTrack: { trackRecord: TrackRecord } }>(
UPDATE_TRACK, { recordId: record.id, status }
);
patchRecord(record.trackerId, res.updateTrack.trackRecord);
} catch (e: any) {
addToast({ kind: "error", title: "Update failed", body: e?.message });
} finally { updatingId = null; }
}
async function updateScore(record: FlatRecord, scoreString: string) {
updatingId = record.id;
try {
const res = await gql<{ updateTrack: { trackRecord: TrackRecord } }>(
UPDATE_TRACK, { recordId: record.id, scoreString }
);
patchRecord(record.trackerId, res.updateTrack.trackRecord);
} catch (e: any) {
addToast({ kind: "error", title: "Update failed", body: e?.message });
} finally { updatingId = null; }
}
async function syncRecord(record: FlatRecord) {
syncingId = record.id;
try {
const res = await gql<{ fetchTrack: { trackRecord: TrackRecord } }>(
FETCH_TRACK, { recordId: record.id }
);
patchRecord(record.trackerId, res.fetchTrack.trackRecord);
addToast({ kind: "success", title: "Synced from tracker" });
} catch (e: any) {
addToast({ kind: "error", title: "Sync failed", body: e?.message });
} finally { syncingId = null; }
}
async function unbind(record: FlatRecord) {
updatingId = record.id;
try {
await gql(UNBIND_TRACK, { recordId: record.id });
trackers = trackers.map(t =>
t.id !== record.trackerId ? t : {
...t,
trackRecords: { nodes: t.trackRecords.nodes.filter(r => r.id !== record.id) }
}
);
addToast({ kind: "info", title: "Unlinked from " + record.tracker.name });
} catch (e: any) {
addToast({ kind: "error", title: "Unbind failed", body: e?.message });
} finally { updatingId = null; }
}
function patchRecord(trackerId: number, updated: Partial<TrackRecord> & { id: number }) {
trackers = trackers.map(t =>
t.id !== trackerId ? t : {
...t,
trackRecords: {
nodes: t.trackRecords.nodes.map(r => r.id === updated.id ? { ...r, ...updated } : r)
}
}
);
}
function openManga(record: FlatRecord) {
if (!record.manga) return;
setActiveManga(record.manga as any);
setNavPage("library");
}
function openChapterEditor(record: FlatRecord) {
editingChapter = record.id;
chapterDraft = record.lastChapterRead;
}
function cancelChapterEditor() { editingChapter = null; }
async function submitChapter(record: FlatRecord) {
const val = Math.max(0, chapterDraft);
editingChapter = null;
if (val === record.lastChapterRead) return;
updatingId = record.id;
try {
const res = await gql<{ updateTrack: { trackRecord: TrackRecord } }>(
UPDATE_TRACK, { recordId: record.id, lastChapterRead: val }
);
patchRecord(record.trackerId, res.updateTrack.trackRecord);
} catch (e: any) {
addToast({ kind: "error", title: "Update failed", body: e?.message });
} finally { updatingId = null; }
}
function requestUnbind(record: FlatRecord) {
confirmUnbindRecord = record;
}
function cancelUnbind() {
confirmUnbindRecord = null;
}
async function confirmAndUnbind() {
if (!confirmUnbindRecord) return;
const record = confirmUnbindRecord;
confirmUnbindRecord = null;
await unbind(record);
}
function scoreToStars(score: string | undefined, scores: string[] | undefined): number {
if (!score || !scores || scores.length === 0) return 0;
const idx = scores.indexOf(score);
if (idx < 0) return 0;
return Math.round((idx / (scores.length - 1)) * 5);
}
</script>
<div class="page">
<div class="header">
<div class="header-top">
<h1 class="heading">Tracking</h1>
<div class="header-actions">
<button class="icon-btn" onclick={load} disabled={loading} title="Refresh all">
<ArrowsClockwise size={14} weight="light" class={loading ? "anim-spin" : ""} />
</button>
</div>
</div>
{#if !loading && loggedInTrackers.length > 0}
<div class="tracker-tabs">
<button
class="tracker-tab"
class:tab-active={activeTrackerId === "all"}
onclick={() => { activeTrackerId = "all"; statusFilter = "all"; }}
>
All
<span class="tab-count">{totalCount}</span>
</button>
{#each loggedInTrackers as t}
{@const count = t.trackRecords.nodes.length}
<button
class="tracker-tab"
class:tab-active={activeTrackerId === t.id}
onclick={() => { activeTrackerId = Number(t.id); statusFilter = "all"; }}
>
<Thumbnail src={t.icon} alt={t.name} class="tab-tracker-icon" />
{t.name}
<span class="tab-count">{count}</span>
</button>
{/each}
</div>
<div class="filter-bar">
<div class="search-wrap">
<MagnifyingGlass size={12} weight="light" class="search-ico" />
<input
class="filter-search"
placeholder="Search titles…"
bind:value={searchQuery}
/>
</div>
<div class="filter-right">
<Funnel size={12} weight="light" style="color:var(--text-faint);flex-shrink:0" />
<select class="filter-select" bind:value={statusFilter}
onchange={(e) => {
const v = (e.target as HTMLSelectElement).value;
statusFilter = v === "all" ? "all" : parseInt(v);
}}>
<option value="all">All statuses</option>
{#each statusOptions as s}
<option value={s.value}>{s.name}</option>
{/each}
</select>
<select class="filter-select" bind:value={sortBy}>
<option value="title">Title</option>
<option value="status">Status</option>
<option value="score">Score</option>
<option value="progress">Progress</option>
</select>
</div>
</div>
{/if}
</div>
<div class="page-body">
{#if loading}
<div class="state-center">
<CircleNotch size={20} weight="light" class="anim-spin" style="color:var(--text-faint)" />
<span class="state-label">Loading…</span>
</div>
{:else if error}
<div class="state-center">
<p class="state-error">{error}</p>
<button class="retry-btn" onclick={load}>Retry</button>
</div>
{:else if loggedInTrackers.length === 0}
<div class="state-center">
<p class="state-text">No trackers connected.</p>
<p class="state-hint">Go to Settings → Tracking to connect AniList, MAL, or others.</p>
</div>
{:else if filtered.length === 0}
<div class="state-center">
<p class="state-text">{searchQuery || statusFilter !== "all" ? "No results." : "Nothing tracked yet."}</p>
{#if searchQuery || statusFilter !== "all"}
<button class="retry-btn" onclick={() => { searchQuery = ""; statusFilter = "all"; }}>Clear filters</button>
{/if}
</div>
{:else}
<div class="records-grid">
{#each filtered as record (record.tracker.id + ":" + record.id)}
{@const tracker = record.tracker}
{@const isBusy = updatingId === record.id}
{@const isSyncing = syncingId === record.id}
{@const progress = record.totalChapters > 0
? Math.min(100, (record.lastChapterRead / record.totalChapters) * 100)
: null}
{@const stars = scoreToStars(record.displayScore, tracker.scores)}
{@const statusName = (tracker.statuses ?? []).find(s => s.value === record.status)?.name ?? "—"}
<div class="record-card" class:record-busy={isBusy}>
<div class="card-cover-wrap">
<div class="card-cover-region"
role="button" tabindex="0"
onclick={() => openManga(record)}
onkeydown={(e) => e.key === "Enter" && openManga(record)}
title="Open in library"
>
{#if record.manga?.thumbnailUrl}
<Thumbnail src={record.manga.thumbnailUrl} alt={record.title} class="card-cover-img" />
{:else}
<div class="card-cover-empty"></div>
{/if}
</div>
<div class="card-top-actions">
{#if record.private}
<span class="card-badge-btn" title="Private"><Lock size={10} weight="fill" /></span>
{/if}
{#if isSyncing}
<span class="card-badge-btn">
<CircleNotch size={10} weight="light" class="anim-spin" />
</span>
{:else}
<button class="card-badge-btn" title="Sync" onclick={() => syncRecord(record)}>
<ArrowsClockwise size={10} weight="light" />
</button>
{/if}
{#if record.remoteUrl}
<a href={record.remoteUrl} target="_blank" rel="noreferrer" class="card-badge-btn" title="Open on {tracker.name}">
<ArrowSquareOut size={10} weight="light" />
</a>
{/if}
<button class="card-badge-btn danger" title="Unlink" onclick={() => requestUnbind(record)} disabled={isBusy}>
<X size={10} weight="bold" />
</button>
</div>
<div class="card-tracker-badge">
<Thumbnail src={tracker.icon} alt={tracker.name} class="tracker-badge-img" />
</div>
</div>
<div class="card-footer">
<div class="card-stars">
{#each Array(5) as _, i}
<span class="star" class:star-filled={i < stars}>★</span>
{/each}
</div>
<div class="card-title-block"
role="button" tabindex="0"
onclick={() => openManga(record)}
onkeydown={(e) => e.key === "Enter" && openManga(record)}
>
<span class="card-title">{record.title}</span>
{#if record.manga?.title && record.manga.title !== record.title}
<span class="card-local-title">{record.manga.title}</span>
{/if}
</div>
<div class="card-meta-row">
<select
class="status-pill"
value={record.status}
disabled={isBusy}
onchange={(e) => updateStatus(record, parseInt((e.target as HTMLSelectElement).value))}
>
{#each (tracker.statuses ?? []) as s}
<option value={s.value}>{s.name}</option>
{/each}
</select>
<select
class="score-select"
value={record.displayScore}
disabled={isBusy}
onchange={(e) => updateScore(record, (e.target as HTMLSelectElement).value)}
>
{#each (tracker.scores ?? []) as s}
<option value={s}> {s}</option>
{/each}
</select>
</div>
{#if editingChapter === record.id}
<div class="chapter-editor" role="presentation" onclick={(e) => e.stopPropagation()}>
<div class="chapter-editor-top">
<span class="chapter-editor-label">Chapter</span>
<div class="chapter-input-wrap">
<input
type="number" class="chapter-input"
min="0" max={record.totalChapters > 0 ? record.totalChapters : undefined}
step="0.5" bind:value={chapterDraft}
onkeydown={(e) => { if (e.key === "Enter") submitChapter(record); if (e.key === "Escape") cancelChapterEditor(); }}
use:focusEl
/>
{#if record.totalChapters > 0}
<span class="chapter-total">/ {record.totalChapters}</span>
{/if}
</div>
</div>
{#if record.totalChapters > 0}
<input type="range" class="chapter-slider" min="0" max={record.totalChapters} step="1" bind:value={chapterDraft} />
{/if}
<div class="chapter-editor-actions">
<button class="chapter-cancel-btn" onclick={cancelChapterEditor}>Cancel</button>
<button class="chapter-save-btn" onclick={() => submitChapter(record)}>Save</button>
</div>
</div>
{:else}
<div class="progress-block clickable"
role="button" tabindex="0"
onclick={() => openChapterEditor(record)}
onkeydown={(e) => e.key === "Enter" && openChapterEditor(record)}
title="Click to edit chapter"
>
<div class="progress-labels">
<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>
{#if progress !== null}
<span class="progress-pct">{Math.round(progress)}%</span>
{/if}
</div>
<div class="progress-track">
<div class="progress-fill" style="width:{progress ?? 0}%"></div>
</div>
</div>
{/if}
</div>
</div>
{/each}
</div>
{/if}
</div>
</div>
{#if confirmUnbindRecord}
{@const r = confirmUnbindRecord}
<div class="modal-backdrop" role="presentation" onclick={cancelUnbind}>
<div class="modal" role="dialog" aria-modal="true" onclick={(e) => e.stopPropagation()}>
<div class="modal-icon">
<X size={18} weight="bold" />
</div>
<p class="modal-title">Unlink from {r.tracker.name}?</p>
<p class="modal-body">
<strong>{r.title}</strong> will be removed from your tracking list. This won't affect your progress on {r.tracker.name} itself.
</p>
<div class="modal-actions">
<button class="modal-cancel" onclick={cancelUnbind}>Cancel</button>
<button class="modal-confirm" onclick={confirmAndUnbind}>Unlink</button>
</div>
</div>
</div>
{/if}
<style>
.page {
display: flex; flex-direction: column; height: 100%; overflow: hidden;
animation: fadeIn 0.16s ease both;
}
.header {
flex-shrink: 0;
border-bottom: 1px solid var(--border-dim);
background: var(--bg-base);
}
.header-top {
display: flex; align-items: center; justify-content: space-between;
padding: var(--sp-4) var(--sp-6) var(--sp-3);
}
.heading {
font-family: var(--font-ui); font-size: var(--text-xs);
font-weight: var(--weight-normal); color: var(--text-faint);
letter-spacing: var(--tracking-wider); text-transform: uppercase;
}
.header-actions { display: flex; align-items: center; gap: var(--sp-2); }
.icon-btn {
display: flex; align-items: center; justify-content: center;
width: 26px; height: 26px; border-radius: var(--radius-sm);
border: none; color: var(--text-faint); background: none;
cursor: pointer; transition: color var(--t-base), background var(--t-base);
}
.icon-btn:hover:not(:disabled) { color: var(--text-muted); background: var(--bg-raised); }
.icon-btn:disabled { opacity: 0.3; cursor: default; }
.tracker-tabs {
display: flex; align-items: center; gap: 1px;
padding: 0 var(--sp-5); overflow-x: auto; scrollbar-width: none;
}
.tracker-tabs::-webkit-scrollbar { display: none; }
.tracker-tab {
display: flex; align-items: center; gap: var(--sp-2);
padding: 9px 10px 8px;
font-family: var(--font-ui); font-size: var(--text-xs);
letter-spacing: var(--tracking-wide); color: var(--text-faint);
background: none; border: none; border-bottom: 2px solid transparent;
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); }
.tab-active { color: var(--text-secondary) !important; border-bottom-color: var(--accent); }
:global(.tab-tracker-icon) { width: 13px; height: 13px; border-radius: 2px; object-fit: contain; opacity: 0.85; }
.tab-count {
font-size: 10px; padding: 0 4px; border-radius: var(--radius-full);
background: var(--bg-overlay); color: var(--text-faint);
min-width: 16px; text-align: center; line-height: 16px;
}
.tab-active .tab-count { background: var(--accent-muted); color: var(--accent-fg); }
.filter-bar {
display: flex; align-items: center; gap: var(--sp-3);
padding: var(--sp-2) var(--sp-5);
border-top: 1px solid var(--border-dim);
}
.search-wrap {
display: flex; align-items: center; gap: var(--sp-2); flex: 1;
background: var(--bg-raised); border: 1px solid var(--border-dim);
border-radius: var(--radius-md); padding: 4px 10px;
}
:global(.search-ico) { color: var(--text-faint); flex-shrink: 0; }
.filter-search {
flex: 1; background: none; border: none; outline: none;
font-size: var(--text-sm); color: var(--text-primary); min-width: 0;
}
.filter-search::placeholder { color: var(--text-faint); }
.filter-right { display: flex; align-items: center; gap: var(--sp-2); flex-shrink: 0; }
.filter-select {
font-family: var(--font-ui); font-size: var(--text-2xs);
letter-spacing: var(--tracking-wide); padding: 4px 22px 4px 8px;
border-radius: var(--radius-sm); border: 1px solid var(--border-dim);
background: var(--bg-raised); color: var(--text-faint);
outline: none; cursor: pointer; appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%23555' stroke-width='1.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E");
background-repeat: no-repeat; background-position: right 6px center;
transition: border-color var(--t-base), color var(--t-base);
}
.filter-select:hover { border-color: var(--border-strong); color: var(--text-muted); }
.filter-select option { background: var(--bg-surface); color: var(--text-secondary); }
.page-body {
flex: 1; overflow-y: auto; padding: var(--sp-5);
scrollbar-width: thin; scrollbar-color: var(--border-strong) transparent;
}
.state-center {
display: flex; flex-direction: column; align-items: center;
justify-content: center; gap: var(--sp-3); height: 100%;
padding: var(--sp-10); text-align: center;
}
.state-label { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
.state-text { font-size: var(--text-sm); color: var(--text-muted); }
.state-hint { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
.state-error { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--color-error); letter-spacing: var(--tracking-wide); }
.retry-btn {
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
padding: 5px 14px; border-radius: var(--radius-md);
border: 1px solid var(--border-dim); background: none;
color: var(--text-faint); cursor: pointer;
transition: color var(--t-base), border-color var(--t-base);
}
.retry-btn:hover { color: var(--accent-fg); border-color: var(--accent-dim); }
.records-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: var(--sp-4);
align-content: start;
}
.record-card {
display: flex; flex-direction: column;
border-radius: var(--radius-lg);
border: 1px solid var(--border-dim);
background: var(--bg-raised);
overflow: hidden;
transition: border-color var(--t-base), opacity var(--t-base), transform var(--t-base);
}
.record-card:hover {
border-color: var(--border-strong);
transform: translateY(-2px);
}
.record-busy { opacity: 0.35; pointer-events: none; }
.card-cover-wrap {
position: relative;
aspect-ratio: 2 / 3;
flex-shrink: 0;
overflow: hidden;
background: var(--bg-overlay);
}
.card-cover-region {
position: absolute; inset: 0;
cursor: pointer;
}
: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::-webkit-outer-spin-button,
.chapter-input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
.chapter-total { font-family: var(--font-ui); font-size: 10px; color: var(--text-faint); letter-spacing: var(--tracking-wide); }
.chapter-slider { width: 100%; accent-color: var(--accent); cursor: pointer; height: 3px; }
.chapter-editor-actions { display: flex; align-items: center; gap: var(--sp-2); justify-content: flex-end; }
.chapter-save-btn {
font-family: var(--font-ui); font-size: 10px; letter-spacing: var(--tracking-wide);
padding: 3px 10px; border-radius: var(--radius-sm);
border: 1px solid var(--accent-dim); background: var(--accent-muted);
color: var(--accent-fg); cursor: pointer; transition: filter var(--t-base);
}
.chapter-save-btn:hover { filter: brightness(1.15); }
.chapter-cancel-btn {
font-family: var(--font-ui); font-size: 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); }
.modal-backdrop {
position: fixed; inset: 0; z-index: 200;
background: rgba(0,0,0,0.55);
backdrop-filter: blur(4px);
display: flex; align-items: center; justify-content: center;
animation: fadeIn 0.12s ease both;
}
.modal {
background: var(--bg-surface);
border: 1px solid var(--border-dim);
border-radius: var(--radius-xl, 14px);
padding: var(--sp-6, 24px);
width: 320px; max-width: calc(100vw - 32px);
display: flex; flex-direction: column; align-items: center; gap: var(--sp-3);
box-shadow: 0 24px 60px rgba(0,0,0,0.5);
animation: modalIn 0.18s cubic-bezier(0.34,1.56,0.64,1) both;
}
.modal-icon {
width: 40px; height: 40px; border-radius: 50%;
background: var(--color-error-bg, rgba(200,50,50,0.12));
border: 1px solid var(--color-error-dim, rgba(200,50,50,0.25));
color: var(--color-error, #e05252);
display: flex; align-items: center; justify-content: center;
flex-shrink: 0;
}
.modal-title {
font-size: var(--text-sm); font-weight: var(--weight-medium);
color: var(--text-primary); text-align: center; margin: 0;
}
.modal-body {
font-family: var(--font-ui); font-size: var(--text-xs);
color: var(--text-muted); text-align: center; line-height: 1.5;
margin: 0;
}
.modal-body strong { color: var(--text-secondary); font-weight: var(--weight-medium); }
.modal-actions {
display: flex; gap: var(--sp-2); width: 100%; margin-top: var(--sp-1);
}
.modal-cancel {
flex: 1;
font-family: var(--font-ui); font-size: var(--text-xs);
letter-spacing: var(--tracking-wide);
padding: 8px 0; border-radius: var(--radius-md);
border: 1px solid var(--border-dim); background: none;
color: var(--text-muted); cursor: pointer;
transition: border-color var(--t-base), color var(--t-base);
}
.modal-cancel:hover { border-color: var(--border-strong); color: var(--text-primary); }
.modal-confirm {
flex: 1;
font-family: var(--font-ui); font-size: var(--text-xs);
letter-spacing: var(--tracking-wide);
padding: 8px 0; border-radius: var(--radius-md);
border: 1px solid var(--color-error-dim, rgba(200,50,50,0.3));
background: var(--color-error-bg, rgba(200,50,50,0.1));
color: var(--color-error, #e05252); cursor: pointer;
transition: filter var(--t-base), background var(--t-base);
}
.modal-confirm:hover { filter: brightness(1.2); background: var(--color-error-bg, rgba(200,50,50,0.18)); }
@keyframes modalIn {
from { opacity: 0; transform: scale(0.92) translateY(8px); }
to { opacity: 1; transform: none; }
}
@keyframes fadeIn { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } }
</style>
<script module>
function focusEl(node: HTMLElement) { setTimeout(() => node.focus(), 0); }
</script>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-575
View File
@@ -1,575 +0,0 @@
<script lang="ts">
import { X, FloppyDisk, UploadSimple, DownloadSimple, ArrowLeft, Trash } from "phosphor-svelte";
import {
store, updateSettings, saveCustomTheme, deleteCustomTheme,
type CustomTheme, type ThemeTokens, DEFAULT_THEME_TOKENS,
} from "../../store/state.svelte";
interface Props {
editingId?: string | null;
onClose: () => void;
}
let { editingId = $bindable(null), onClose }: Props = $props();
const TOKEN_GROUPS: { label: string; tokens: (keyof ThemeTokens)[] }[] = [
{
label: "Backgrounds",
tokens: ["bg-void", "bg-base", "bg-surface", "bg-raised", "bg-overlay", "bg-subtle"],
},
{
label: "Borders",
tokens: ["border-dim", "border-base", "border-strong", "border-focus"],
},
{
label: "Text",
tokens: ["text-primary", "text-secondary", "text-muted", "text-faint", "text-disabled"],
},
{
label: "Accent",
tokens: ["accent", "accent-dim", "accent-muted", "accent-fg", "accent-bright"],
},
{
label: "Semantic",
tokens: ["color-error", "color-error-bg", "color-success", "color-info", "color-info-bg"],
},
];
const TOKEN_LABELS: Record<keyof ThemeTokens, string> = {
"bg-void": "Void (deepest bg)",
"bg-base": "Base",
"bg-surface": "Surface",
"bg-raised": "Raised",
"bg-overlay": "Overlay",
"bg-subtle": "Subtle",
"border-dim": "Dim border",
"border-base": "Base border",
"border-strong": "Strong border",
"border-focus": "Focus ring",
"text-primary": "Primary text",
"text-secondary": "Secondary text",
"text-muted": "Muted text",
"text-faint": "Faint text",
"text-disabled": "Disabled text",
"accent": "Accent",
"accent-dim": "Accent dim",
"accent-muted": "Accent muted",
"accent-fg": "Accent foreground",
"accent-bright": "Accent bright",
"color-error": "Error",
"color-error-bg": "Error background",
"color-success": "Success",
"color-info": "Info",
"color-info-bg": "Info background",
};
function loadInitial(): { name: string; tokens: ThemeTokens } {
if (editingId) {
const existing = store.settings.customThemes.find(t => t.id === editingId);
if (existing) return { name: existing.name, tokens: { ...existing.tokens } };
}
return { name: "My Theme", tokens: { ...DEFAULT_THEME_TOKENS } };
}
const initial = loadInitial();
let themeName: string = $state(initial.name);
let tokens: ThemeTokens = $state(initial.tokens);
let saveStatus: "idle" | "saved" = $state("idle");
let importError: string | null = $state(null);
function toCssVars(t: ThemeTokens): string {
return Object.entries(t).map(([k, v]) => `--${k}: ${v};`).join(" ");
}
function handleSave() {
const name = themeName.trim() || "Untitled Theme";
const id = editingId ?? `custom:${Math.random().toString(36).slice(2, 10)}`;
const theme: CustomTheme = { id, name, tokens: { ...tokens } };
saveCustomTheme(theme);
updateSettings({ theme: id });
editingId = id;
saveStatus = "saved";
setTimeout(() => (saveStatus = "idle"), 1800);
}
function handleDelete() {
if (!editingId) { onClose(); return; }
if (!confirm(`Delete theme "${themeName}"? This cannot be undone.`)) return;
deleteCustomTheme(editingId);
onClose();
}
function handleExport() {
const data: CustomTheme = {
id: editingId ?? "custom:export",
name: themeName.trim() || "Untitled Theme",
tokens: { ...tokens },
};
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${data.name.replace(/[^a-z0-9]/gi, "-").toLowerCase()}-theme.json`;
a.click();
URL.revokeObjectURL(url);
}
function handleImport() {
const inp = document.createElement("input");
inp.type = "file";
inp.accept = ".json";
inp.onchange = async () => {
const file = inp.files?.[0];
if (!file) return;
try {
const text = await file.text();
const data = JSON.parse(text);
if (!data.tokens || typeof data.tokens !== "object") throw new Error("Invalid theme file — missing tokens");
if (typeof data.name === "string") themeName = data.name;
tokens = { ...DEFAULT_THEME_TOKENS, ...data.tokens };
importError = null;
} catch (e: any) {
importError = e.message ?? "Could not parse theme file";
setTimeout(() => (importError = null), 3000);
}
};
inp.click();
}
function resetToDefaults() {
tokens = { ...DEFAULT_THEME_TOKENS };
}
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") onClose();
}
</script>
<svelte:window onkeydown={onKey} />
<div class="te-backdrop" role="presentation" onclick={onClose} onkeydown={(e) => e.key === "Escape" && onClose()}>
<div
class="te-shell"
role="dialog"
aria-label="Theme editor"
tabindex="0"
onclick={(e) => e.stopPropagation()}
>
<header class="te-header">
<div class="te-header-left">
<button class="te-icon-btn" onclick={onClose} title="Close editor">
<ArrowLeft size={14} weight="bold" />
</button>
<input
bind:value={themeName}
class="te-name-input"
placeholder="Theme name"
maxlength={40}
spellcheck={false}
/>
</div>
<div class="te-header-actions">
{#if importError}
<span class="te-import-err">{importError}</span>
{/if}
<button class="te-action-btn" onclick={handleImport} title="Import from JSON">
<UploadSimple size={13} />
<span>Import</span>
</button>
<button class="te-action-btn" onclick={handleExport} title="Export as JSON">
<DownloadSimple size={13} />
<span>Export</span>
</button>
<button class="te-action-btn te-ghost" onclick={resetToDefaults} title="Reset all to dark defaults">
Reset
</button>
{#if editingId}
<button class="te-action-btn te-danger" onclick={handleDelete} title="Delete theme">
<Trash size={13} />
</button>
{/if}
<button class="te-save-btn" class:saved={saveStatus === "saved"} onclick={handleSave}>
<FloppyDisk size={13} />
<span>{saveStatus === "saved" ? "Saved!" : "Save Theme"}</span>
</button>
<button class="te-icon-btn" onclick={onClose} title="Close">
<X size={14} weight="bold" />
</button>
</div>
</header>
<div class="te-body">
<aside class="te-preview-pane">
<div class="te-pane-label">Live Preview</div>
<div class="te-preview-ui" style={toCssVars(tokens)}>
<div class="prv-sidebar">
{#each [true, false, false, false] as active}
<div class="prv-sb-dot" class:active></div>
{/each}
</div>
<div class="prv-main">
<div class="prv-titlebar">
<div class="prv-win-dots">
<span></span><span></span><span></span>
</div>
<div class="prv-win-title">Moku</div>
</div>
<div class="prv-content">
<div class="prv-row">
<div class="prv-bar" style="width:52px;background:var(--text-secondary);opacity:0.45"></div>
<div class="prv-bar" style="width:18px;background:var(--accent)"></div>
</div>
<div class="prv-grid">
{#each Array(6) as _, i}
<div class="prv-card" class:active-card={i === 0}>
<div class="prv-cover"></div>
<div class="prv-card-line"></div>
</div>
{/each}
</div>
<div class="prv-reader">
<div class="prv-page"></div>
</div>
<div class="prv-toast">
<div class="prv-toast-dot"></div>
<div class="prv-toast-lines">
<div class="prv-bar" style="width:80%;background:var(--text-secondary)"></div>
<div class="prv-bar" style="width:55%;background:var(--text-faint);margin-top:3px"></div>
</div>
</div>
</div>
</div>
</div>
<div class="te-swatches" style={toCssVars(tokens)}>
{#each [
["bg-base","bg-base"],["bg-surface","bg-surface"],
["accent","accent"],["accent-fg","accent-fg"],
["text-primary","text-primary"],["text-muted","text-muted"],
["color-error","color-error"],
] as [varName, label]}
<div
class="te-swatch"
style="background: var(--{varName})"
title={label}
></div>
{/each}
</div>
</aside>
<div class="te-editor-pane">
{#each TOKEN_GROUPS as group}
<div class="te-group">
<div class="te-group-label">{group.label}</div>
<div class="te-token-list">
{#each group.tokens as token}
<div class="te-token-row">
<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-key">{token}</span>
<input
type="text"
class="te-hex-input"
value={tokens[token]}
spellcheck={false}
oninput={(e) => {
const v = (e.target as HTMLInputElement).value.trim();
if (/^#[0-9a-fA-F]{3,8}$/.test(v)) tokens = { ...tokens, [token]: v };
}}
onblur={(e) => {
const v = (e.target as HTMLInputElement).value.trim();
if (!/^#[0-9a-fA-F]{3,8}$/.test(v)) {
(e.target as HTMLInputElement).value = tokens[token];
}
}}
/>
</div>
{/each}
</div>
</div>
{/each}
</div>
</div>
</div>
</div>
<style>
.te-backdrop {
position: fixed; inset: 0;
background: rgba(0, 0, 0, 0.72);
z-index: 200;
display: flex; align-items: center; justify-content: center;
animation: teBackdropIn 0.14s ease both;
}
@keyframes teBackdropIn { from { opacity: 0; } to { opacity: 1; } }
.te-shell {
width: calc(100% - 48px);
max-width: 1100px;
height: calc(100% - 48px);
max-height: 760px;
display: flex; flex-direction: column;
background: var(--bg-base);
border: 1px solid var(--border-base);
border-radius: 10px;
animation: teShellIn 0.2s cubic-bezier(0.22, 1, 0.36, 1) both;
overflow: hidden;
}
@keyframes teShellIn {
from { transform: translateY(10px) scale(0.99); opacity: 0; }
to { transform: translateY(0) scale(1); opacity: 1; }
}
.te-header {
display: flex; align-items: center; justify-content: space-between;
gap: 12px; padding: 0 16px; height: 46px;
border-bottom: 1px solid var(--border-dim);
background: var(--bg-surface);
flex-shrink: 0;
}
.te-header-left {
display: flex; align-items: center; gap: 8px; flex: 1; min-width: 0;
}
.te-icon-btn {
display: flex; align-items: center; justify-content: center;
width: 26px; height: 26px; border-radius: 5px;
color: var(--text-muted);
transition: color 0.1s, background 0.1s;
flex-shrink: 0;
}
.te-icon-btn:hover { color: var(--text-primary); background: var(--bg-overlay); }
.te-name-input {
flex: 1; min-width: 0;
background: none; border: none; outline: none;
font-family: var(--font-sans); font-size: 13px; font-weight: 500;
color: var(--text-primary);
border-bottom: 1px solid transparent;
padding: 3px 0;
transition: border-color 0.12s;
}
.te-name-input:focus { border-color: var(--border-focus); }
.te-name-input::placeholder { color: var(--text-faint); }
.te-header-actions {
display: flex; align-items: center; gap: 6px; flex-shrink: 0;
}
.te-import-err {
font-family: var(--font-ui); font-size: 11px; letter-spacing: 0.04em;
color: var(--color-error); flex-shrink: 0;
}
.te-action-btn {
display: flex; align-items: center; gap: 5px;
font-family: var(--font-ui); font-size: 11px; letter-spacing: 0.06em;
padding: 4px 10px; border-radius: 4px;
border: 1px solid var(--border-dim);
background: none; color: var(--text-muted);
cursor: pointer; flex-shrink: 0;
transition: color 0.1s, border-color 0.1s, background 0.1s;
}
.te-action-btn:hover { color: var(--text-secondary); border-color: var(--border-strong); }
.te-ghost { border-color: transparent; }
.te-ghost:hover { border-color: var(--border-dim); }
.te-danger { color: var(--color-error); border-color: transparent; }
.te-danger:hover { background: var(--color-error-bg); border-color: var(--color-error); }
.te-save-btn {
display: flex; align-items: center; gap: 5px;
font-family: var(--font-ui); font-size: 11px; letter-spacing: 0.06em;
padding: 5px 14px; border-radius: 4px;
border: 1px solid var(--accent-dim);
background: var(--accent-muted); color: var(--accent-fg);
cursor: pointer; flex-shrink: 0;
transition: filter 0.1s, background 0.12s;
}
.te-save-btn:hover { filter: brightness(1.12); }
.te-save-btn.saved { background: var(--accent-dim); border-color: var(--accent); }
.te-body { flex: 1; overflow: hidden; display: flex; min-height: 0; }
.te-preview-pane {
width: 260px; flex-shrink: 0;
border-right: 1px solid var(--border-dim);
background: var(--bg-void);
display: flex; flex-direction: column;
padding: 16px; gap: 12px;
}
.te-pane-label {
font-family: var(--font-ui); font-size: 10px;
letter-spacing: 0.1em; text-transform: uppercase;
color: var(--text-faint);
flex-shrink: 0;
}
.te-preview-ui {
flex: 1; min-height: 0;
border-radius: 8px; overflow: hidden;
border: 1px solid var(--border-base);
display: flex; background: var(--bg-void);
}
.prv-sidebar {
width: 34px; flex-shrink: 0;
background: var(--bg-surface);
border-right: 1px solid var(--border-dim);
display: flex; flex-direction: column;
align-items: center; padding: 12px 0; gap: 9px;
}
.prv-sb-dot {
width: 10px; height: 10px; border-radius: 50%;
background: var(--text-faint); opacity: 0.4;
transition: background 0.15s, opacity 0.15s;
}
.prv-sb-dot.active { background: var(--accent); opacity: 1; }
.prv-main { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
.prv-titlebar {
height: 26px; flex-shrink: 0;
background: var(--bg-raised);
border-bottom: 1px solid var(--border-dim);
display: flex; align-items: center; padding: 0 8px; gap: 7px;
}
.prv-win-dots { display: flex; gap: 4px; }
.prv-win-dots span { width: 6px; height: 6px; border-radius: 50%; background: var(--border-strong); }
.prv-win-title { font-family: var(--font-ui); font-size: 9px; letter-spacing: 0.1em; color: var(--text-faint); }
.prv-content {
flex: 1; overflow: hidden;
padding: 8px; display: flex; flex-direction: column; gap: 7px;
background: var(--bg-base);
}
.prv-row { display: flex; align-items: center; gap: 6px; flex-shrink: 0; }
.prv-bar { height: 3px; border-radius: 2px; }
.prv-grid {
display: grid; grid-template-columns: repeat(3, 1fr); gap: 4px; flex-shrink: 0;
}
.prv-card {
border-radius: 4px; border: 1px solid var(--border-dim);
background: var(--bg-raised); overflow: hidden;
transition: border-color 0.15s;
}
.prv-card.active-card { border-color: var(--accent); }
.prv-cover { height: 34px; background: var(--bg-overlay); }
.prv-card-line { height: 3px; margin: 4px 4px; border-radius: 2px; background: var(--text-faint); opacity: 0.5; }
.prv-reader {
flex: 1; min-height: 0;
border-radius: 4px; border: 1px solid var(--border-dim);
background: var(--bg-overlay);
display: flex; align-items: center; justify-content: center;
}
.prv-page { width: 68%; height: 86%; background: var(--bg-subtle); border-radius: 2px; }
.prv-toast {
flex-shrink: 0;
display: flex; align-items: center; gap: 6px;
padding: 6px 8px; border-radius: 5px;
background: var(--bg-overlay); border: 1px solid var(--accent-dim);
}
.prv-toast-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--accent); flex-shrink: 0; }
.prv-toast-lines { flex: 1; }
.te-swatches { display: flex; gap: 5px; flex-wrap: wrap; flex-shrink: 0; }
.te-swatch {
width: 22px; height: 22px; border-radius: 4px;
border: 1px solid rgba(255,255,255,0.07);
flex-shrink: 0; cursor: default;
}
.te-editor-pane {
flex: 1; overflow-y: auto;
padding: 16px 20px;
display: flex; flex-direction: column; gap: 22px;
}
.te-editor-pane::-webkit-scrollbar { width: 4px; }
.te-editor-pane::-webkit-scrollbar-track { background: transparent; }
.te-editor-pane::-webkit-scrollbar-thumb {
background: var(--border-strong); border-radius: 9999px;
}
.te-group { display: flex; flex-direction: column; gap: 2px; }
.te-group-label {
font-family: var(--font-ui); font-size: 10px;
letter-spacing: 0.1em; text-transform: uppercase;
color: var(--text-faint);
padding-bottom: 7px; margin-bottom: 4px;
border-bottom: 1px solid var(--border-dim);
}
.te-token-list { display: flex; flex-direction: column; gap: 1px; }
.te-token-row {
display: flex; align-items: center; gap: 10px;
padding: 5px 8px; border-radius: 5px;
transition: background 0.1s;
}
.te-token-row:hover { background: var(--bg-raised); }
.te-color-swatch {
width: 36px; height: 18px; border-radius: 5px;
flex-shrink: 0;
border: 1px solid rgba(255,255,255,0.12);
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 {
flex: 1; font-size: 12px; color: var(--text-secondary);
}
.te-token-key {
font-family: var(--font-ui); font-size: 10px;
letter-spacing: 0.05em; color: var(--text-faint);
flex-shrink: 0; min-width: 0;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
max-width: 160px;
}
.te-hex-input {
width: 82px; flex-shrink: 0;
font-family: var(--font-ui); font-size: 11px; letter-spacing: 0.05em;
color: var(--text-muted);
background: var(--bg-overlay);
border: 1px solid var(--border-dim);
border-radius: 3px; padding: 3px 7px;
outline: none;
transition: border-color 0.1s, color 0.1s;
}
.te-hex-input:focus { border-color: var(--border-focus); color: var(--text-primary); }
</style>
-618
View File
@@ -1,618 +0,0 @@
<script lang="ts">
import { onMount, onDestroy } from "svelte";
import { X, BookmarkSimple, ArrowSquareOut, Play, CircleNotch, Books, CaretDown, FolderSimplePlus, Folder, LinkSimpleHorizontalBreak } from "phosphor-svelte";
import { gql } from "../../lib/client";
import Thumbnail from "../shared/Thumbnail.svelte";
import { GET_MANGA, GET_CHAPTERS, FETCH_MANGA, FETCH_CHAPTERS, UPDATE_MANGA, ENQUEUE_CHAPTERS_DOWNLOAD, GET_CATEGORIES, CREATE_CATEGORY, UPDATE_MANGA_CATEGORIES } from "../../lib/queries";
import { GET_ALL_MANGA } from "../../lib/queries";
import { cache, CACHE_KEYS } from "../../lib/cache";
import { store, openReader, addToast, linkManga, unlinkManga, setPreviewManga, setActiveManga, setNavPage, setGenreFilter, checkAndMarkCompleted as storeCheckAndMarkCompleted, addBookmark } from "../../store/state.svelte";
import type { Manga, Chapter, Category } from "../../lib/types";
let manga: Manga | null = $state(null);
let chapters: Chapter[] = $state([]);
let loadingDetail = $state(false);
let loadingChapters = $state(false);
let togglingLib = $state(false);
let descExpanded = $state(false);
let folderOpen = $state(false);
let newFolderName = $state("");
let creatingFolder = $state(false);
let allCategories: Category[] = $state([]);
let mangaCategories: Category[] = $state([]);
let catsLoading: boolean = $state(false);
let queueingAll = $state(false);
let fetchError: string|null = $state(null);
let folderRef: HTMLDivElement = $state() as HTMLDivElement;
let linkPickerOpen = $state(false);
let linkSearch = $state("");
let allMangaForLink: Manga[] = $state([]);
let loadingLinkList = $state(false);
const linkedIds = $derived(store.previewManga ? (store.settings.mangaLinks?.[store.previewManga.id] ?? []) : []);
const linkPickerResults = $derived.by(() => {
const others = allMangaForLink.filter((m) => m.id !== store.previewManga?.id);
const q = linkSearch.trim().toLowerCase();
const filtered = q ? others.filter(m => m.title.toLowerCase().includes(q)) : others;
const linked = filtered.filter(m => linkedIds.includes(m.id));
const rest = filtered.filter(m => !linkedIds.includes(m.id)).slice(0, 30);
return [...linked, ...rest];
});
async function openLinkPicker() {
linkPickerOpen = true; linkSearch = "";
if (allMangaForLink.length) return;
loadingLinkList = true;
gql<{ mangas: { nodes: Manga[] } }>(GET_ALL_MANGA)
.then(d => { allMangaForLink = d.mangas.nodes; })
.catch(console.error)
.finally(() => { loadingLinkList = false; });
}
function closeLinkPicker() { linkPickerOpen = false; linkSearch = ""; }
function handleLink(other: Manga) {
if (!store.previewManga) return;
if (linkedIds.includes(other.id)) unlinkManga(store.previewManga.id, other.id);
else linkManga(store.previewManga.id, other.id);
}
let detailAbort: AbortController | null = null;
let chapterAbort: AbortController | null = null;
function close() {
detailAbort?.abort(); chapterAbort?.abort();
setPreviewManga(null);
manga = null; chapters = []; descExpanded = false;
folderOpen = false; creatingFolder = false; newFolderName = ""; fetchError = null;
}
function formatDate(d: Date) { return d.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" }); }
const displayManga = $derived(manga ?? store.previewManga);
const totalCount = $derived(chapters.length);
const readCount = $derived(chapters.filter((c) => c.isRead).length);
const unreadCount = $derived(totalCount - readCount);
const downloadedCount = $derived(chapters.filter((c) => c.isDownloaded).length);
const bookmarkCount = $derived(chapters.filter((c) => c.isBookmarked).length);
const inLibrary = $derived(manga?.inLibrary ?? store.previewManga?.inLibrary ?? false);
const scanlators = $derived([...new Set(chapters.map((c) => c.scanlator).filter((s): s is string => !!s?.trim()))]);
const uploadDates = $derived(chapters.map((c) => c.uploadDate ? new Date(c.uploadDate).getTime() : null).filter((d): d is number => d !== null && !isNaN(d)));
const firstUpload = $derived(uploadDates.length ? new Date(Math.min(...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 assignedFolders = $derived(mangaCategories.filter(c => c.id !== 0));
const continueChapter = $derived.by(() => {
if (!chapters.length) return null;
const asc = [...chapters]; // already sorted by sourceOrder from load()
const anyRead = asc.some(c => c.isRead);
const bookmark = displayManga
? store.bookmarks.find(b => b.mangaId === displayManga!.id)
: null;
if (bookmark) {
const ch = asc.find(c => c.id === bookmark.chapterId);
if (ch) {
const isLastChapter = asc[asc.length - 1]?.id === ch.id;
const allRead = asc.every(c => c.isRead);
// If bookmarked chapter is the last one and everything is read,
// treat as fully finished — fall through to "reread"
if (!(isLastChapter && allRead)) {
return { ch, type: "continue" as const, resumePage: bookmark.pageNumber };
}
}
}
const inProgress = asc.find(c => !c.isRead && (c.lastPageRead ?? 0) > 0);
if (inProgress) return { ch: inProgress, type: "continue" as const, resumePage: inProgress.lastPageRead! };
const firstUnread = asc.find(c => !c.isRead);
if (firstUnread) return { ch: firstUnread, type: (anyRead ? "continue" : "start") as const, resumePage: null };
return { ch: asc[0], type: "reread" as const, resumePage: null };
});
const continueLabel = $derived.by(() => {
if (!continueChapter) return "";
const { ch, type, resumePage } = continueChapter;
if (type === "reread") return "Read again";
if (type === "start") return `Start · Ch.${ch.chapterNumber}`;
return `Continue · Ch.${ch.chapterNumber}${resumePage ? ` p.${resumePage}` : ""}`;
});
$effect(() => { if (store.previewManga) { load(store.previewManga.id); loadCategories(store.previewManga.id); } });
async function load(id: number) {
detailAbort?.abort(); chapterAbort?.abort();
const dCtrl = new AbortController(), cCtrl = new AbortController();
detailAbort = dCtrl; chapterAbort = cCtrl;
manga = store.previewManga as Manga;
chapters = []; descExpanded = false; fetchError = null;
loadingDetail = true; loadingChapters = true;
(async (): Promise<Manga> => {
const key = CACHE_KEYS.MANGA(id);
if (cache.has(key)) return cache.get(key, () => Promise.resolve(store.previewManga as Manga)) as Promise<Manga>;
try {
const d = await gql<{ fetchManga: { manga: Manga } }>(FETCH_MANGA, { id }, dCtrl.signal);
return d.fetchManga.manga;
} catch (e: any) {
if (e?.name === "AbortError") throw e;
const local = await gql<{ manga: Manga }>(GET_MANGA, { id }, dCtrl.signal).then((d) => d.manga);
if (local) return local;
throw new Error("Could not load manga details");
}
})().then((fullManga) => {
if (dCtrl.signal.aborted) return;
if (!cache.has(CACHE_KEYS.MANGA(id))) cache.get(CACHE_KEYS.MANGA(id), () => Promise.resolve(fullManga));
manga = fullManga; loadingDetail = false;
}).catch((e) => {
if (e?.name === "AbortError") return;
manga = store.previewManga as Manga;
fetchError = "Could not load full details — showing cached data";
loadingDetail = false;
});
gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId: id }, cCtrl.signal)
.then(async (d) => {
if (cCtrl.signal.aborted) return;
let nodes = [...d.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder);
if (nodes.length === 0) {
try {
const fetched = await gql<{ fetchChapters: { chapters: Chapter[] } }>(FETCH_CHAPTERS, { mangaId: id }, cCtrl.signal);
if (!cCtrl.signal.aborted) nodes = [...fetched.fetchChapters.chapters].sort((a, b) => a.sourceOrder - b.sourceOrder);
} catch (e: any) { if (e?.name === "AbortError") return; }
}
if (!cCtrl.signal.aborted) {
chapters = nodes;
if (nodes.length > 0) checkAndMarkCompleted(id, nodes);
}
})
.catch(() => {})
.finally(() => { if (!cCtrl.signal.aborted) loadingChapters = false; });
}
async function toggleLibrary() {
if (!manga) return;
togglingLib = true;
const next = !manga.inLibrary;
await gql(UPDATE_MANGA, { id: manga.id, inLibrary: next }).catch(console.error);
manga = { ...manga, inLibrary: next };
cache.clear(CACHE_KEYS.MANGA(manga.id));
cache.get(CACHE_KEYS.MANGA(manga.id), () => Promise.resolve(manga!));
cache.clear(CACHE_KEYS.LIBRARY);
togglingLib = false;
addToast({ kind: "success", title: next ? "Added to library" : "Removed from library" });
}
async function downloadAll() {
const ids = chapters.filter((c) => !c.isDownloaded && !c.isRead).map((c) => c.id);
if (!ids.length) return;
queueingAll = true;
await gql(ENQUEUE_CHAPTERS_DOWNLOAD, { chapterIds: ids }).catch(console.error);
addToast({ kind: "download", title: "Downloading", body: `${ids.length} chapters queued` });
queueingAll = false;
}
function openSeriesDetail() {
if (!displayManga) return;
setActiveManga(displayManga);
setNavPage("library");
close();
}
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[]) {
const mangaStatus = (manga ?? displayManga)?.status;
await storeCheckAndMarkCompleted(mangaId, chaps, allCategories, gql, UPDATE_MANGA_CATEGORIES, UPDATE_MANGA, mangaStatus);
// Sync local mangaCategories state after the mutation.
// Never auto-move an ONGOING series into Completed — user must do that manually.
const isOngoing = mangaStatus === "ONGOING";
if (chaps.length && !isOngoing) {
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);
if (!inCat && !inLibrary) {
await gql(UPDATE_MANGA, { id: mangaId, inLibrary: true }).catch(console.error);
if (manga) manga = { ...manga, inLibrary: true };
cache.clear(CACHE_KEYS.LIBRARY);
}
mangaCategories = inCat
? mangaCategories.filter(c => c.id !== cat.id)
: [...mangaCategories, cat];
}
async function handleFolderCreate() {
const name = newFolderName.trim();
if (!name || !store.previewManga) return;
try {
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: [] });
if (!inLibrary) {
await gql(UPDATE_MANGA, { id: store.previewManga.id, inLibrary: true }).catch(console.error);
if (manga) manga = { ...manga, inLibrary: true };
cache.clear(CACHE_KEYS.LIBRARY);
}
mangaCategories = [...mangaCategories, cat];
} catch (e) { console.error(e); }
newFolderName = ""; creatingFolder = false;
}
function handleFolderOutside(e: MouseEvent) {
if (folderRef && !folderRef.contains(e.target as Node)) { folderOpen = false; creatingFolder = false; newFolderName = ""; }
}
$effect(() => {
if (folderOpen) {
setTimeout(() => document.addEventListener("mousedown", handleFolderOutside), 0);
return () => document.removeEventListener("mousedown", handleFolderOutside);
}
});
function onKey(e: KeyboardEvent) { if (e.key === "Escape") close(); }
onMount(() => window.addEventListener("keydown", onKey));
onDestroy(() => { window.removeEventListener("keydown", onKey); detailAbort?.abort(); chapterAbort?.abort(); });
</script>
{#if store.previewManga}
<div class="backdrop" role="presentation" onclick={(e) => { if (e.target === e.currentTarget) close(); }} onkeydown={(e) => { if (e.key === "Escape") close(); }}>
<div class="modal" role="dialog" aria-label="Manga preview">
<div class="cover-col">
<div class="cover-wrap">
<Thumbnail src={store.previewManga.thumbnailUrl} alt={displayManga?.title} class="cover" />
{#if loadingDetail}
<div class="cover-spinner"><CircleNotch size={18} weight="light" class="anim-spin" /></div>
{/if}
</div>
<div class="cover-actions">
<button class="action-btn" class:active={inLibrary} onclick={toggleLibrary} disabled={togglingLib || loadingDetail}>
<span class="action-icon"><BookmarkSimple size={13} weight={inLibrary ? "fill" : "light"} /></span>
<span class="action-label">{togglingLib ? "…" : inLibrary ? "In Library" : "Add to Library"}</span>
</button>
<button class="action-btn" onclick={openSeriesDetail}>
<span class="action-icon"><Books size={13} weight="light" /></span>
<span class="action-label">Series Detail</span>
</button>
<div class="folder-wrap" bind:this={folderRef}>
<button class="action-btn" class:active={assignedFolders.length > 0} onclick={() => folderOpen = !folderOpen}>
<span class="action-icon"><FolderSimplePlus size={13} weight={assignedFolders.length > 0 ? "fill" : "light"} /></span>
<span class="action-label">{assignedFolders.length > 0 ? assignedFolders.map((f) => f.name).join(", ") : "Add to folder"}</span>
</button>
{#if folderOpen}
<div class="folder-menu">
{#if catsLoading}
<p class="folder-empty">Loading…</p>
{:else if allCategories.length === 0 && !creatingFolder}
<p class="folder-empty">No folders yet</p>
{/if}
{#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>
{/each}
<div class="folder-divider"></div>
{#if creatingFolder}
<div class="folder-create-row">
<input class="folder-input" placeholder="Folder name…" bind:value={newFolderName}
onkeydown={(e) => { if (e.key === "Enter") handleFolderCreate(); if (e.key === "Escape") { creatingFolder = false; newFolderName = ""; } }}
use:focusAction />
<button class="folder-ok" onclick={handleFolderCreate} disabled={!newFolderName.trim()}>Add</button>
</div>
{:else}
<button class="folder-new" onclick={() => creatingFolder = true}>+ New folder</button>
{/if}
</div>
{/if}
</div>
<button class="action-btn" class:active={linkedIds.length > 0} onclick={openLinkPicker}>
<span class="action-icon"><LinkSimpleHorizontalBreak size={13} weight={linkedIds.length > 0 ? "fill" : "light"} /></span>
<span class="action-label">{linkedIds.length > 0 ? `Series Link (${linkedIds.length})` : "Series Link"}</span>
</button>
</div>
</div>
<div class="content">
<div class="content-header">
<div class="title-block">
<h2 class="title">{displayManga?.title}</h2>
{#if loadingDetail}
<div class="sk-byline"></div>
{:else if displayManga?.author || displayManga?.artist}
<p class="byline">{[displayManga?.author, displayManga?.artist].filter(Boolean).filter((v, i, a) => a.indexOf(v) === i).join(" · ")}</p>
{/if}
</div>
<button class="close-btn" onclick={close}><X size={15} weight="light" /></button>
</div>
<div class="content-body">
{#if fetchError}<div class="error-banner">{fetchError}</div>{/if}
{#if loadingDetail}
<div class="sk-row"><div class="sk-badge"></div><div class="sk-badge" style="width:72px"></div></div>
{:else}
<div class="badges">
{#if statusLabel}<span class="badge" class:badge-green={displayManga?.status === "ONGOING"}>{statusLabel}</span>{/if}
{#if displayManga?.source}<span class="badge">{displayManga.source.displayName}</span>{/if}
{#if inLibrary}<span class="badge badge-accent">In Library</span>{/if}
{#if !loadingChapters && unreadCount > 0}<span class="badge badge-unread">{unreadCount} unread</span>{/if}
{#if !loadingChapters && bookmarkCount > 0}<span class="badge">{bookmarkCount} bookmarked</span>{/if}
</div>
{/if}
<div class="chapter-box">
{#if loadingChapters}
<div class="chapter-loading">
<CircleNotch size={13} weight="light" class="anim-spin" style="color:var(--text-faint)" />
<span class="chapter-loading-label">Loading chapters…</span>
</div>
{:else if totalCount > 0}
<div class="chapter-meta">
<span class="chapter-label">
{totalCount} {totalCount === 1 ? "chapter" : "chapters"}{readCount > 0 ? ` · ${readCount} read` : ""}{unreadCount > 0 && readCount > 0 ? ` · ${unreadCount} left` : ""}{downloadedCount > 0 ? ` · ${downloadedCount} dl` : ""}
</span>
{#if unreadCount > 0}
<button class="dl-all-btn" onclick={downloadAll} disabled={queueingAll}>
{#if queueingAll}<CircleNotch size={11} weight="light" class="anim-spin" />{/if}
{queueingAll ? "Queuing…" : "Download unread"}
</button>
{/if}
</div>
{#if readCount > 0}
<div class="progress-track"><div class="progress-fill" style="width:{(readCount / totalCount) * 100}%"></div></div>
{/if}
{#if continueChapter}
<button class="read-btn" onclick={() => {
const { ch, type, resumePage } = continueChapter!;
if (type === "continue" && resumePage && resumePage > 1) {
const existing = store.bookmarks.find(b => b.chapterId === ch.id);
if (!existing || existing.pageNumber < resumePage) {
addBookmark({
mangaId: displayManga!.id,
mangaTitle: displayManga!.title,
thumbnailUrl: displayManga!.thumbnailUrl,
chapterId: ch.id,
chapterName: ch.name,
pageNumber: resumePage,
});
}
}
openReader(ch, chapters, displayManga);
close();
}}>
<Play size={12} weight="fill" />{continueLabel}
</button>
{/if}
{:else if !loadingDetail}
<span class="chapter-label" style="color:var(--text-faint)">No chapters in local library</span>
{/if}
</div>
{#if loadingDetail}
<div class="sk-desc">
<div class="sk-line" style="width:100%"></div>
<div class="sk-line" style="width:88%"></div>
<div class="sk-line" style="width:70%"></div>
</div>
{:else if displayManga?.description}
<div class="desc-block">
<p class="desc" class:desc-open={descExpanded}>{displayManga.description}</p>
{#if displayManga.description.length > 220}
<button class="desc-toggle" onclick={() => descExpanded = !descExpanded}>
{descExpanded ? "Show less" : "Show more"}
<CaretDown size={10} weight="light" style="transform:{descExpanded ? 'rotate(180deg)' : 'none'};transition:transform 0.15s ease" />
</button>
{/if}
</div>
{/if}
{#if !loadingDetail && displayManga?.genre?.length}
<div class="genres">
{#each displayManga.genre as g}
<button class="genre-tag" onclick={() => { setGenreFilter(g); setNavPage("search"); close(); }}>{g}</button>
{/each}
</div>
{/if}
{#if !loadingDetail}
<div class="meta-table">
<div class="meta-grid">
<div class="meta-col">
<div class="meta-row"><span class="meta-key">Status</span><span class="meta-val">{statusLabel ?? "N/A"}</span></div>
<div class="meta-row"><span class="meta-key">Source</span><span class="meta-val">{displayManga?.source?.displayName ?? "N/A"}</span></div>
<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>
</div>
<div class="meta-col">
<div class="meta-row"><span class="meta-key">Author</span><span class="meta-val">{displayManga?.author ?? "N/A"}</span></div>
<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}
</div>
</div>
</div>
</div>
{#if linkPickerOpen}
<div class="link-backdrop" role="presentation"
onclick={(e) => { if (e.target === e.currentTarget) closeLinkPicker(); }}
onkeydown={(e) => e.key === "Escape" && closeLinkPicker()}>
<div class="link-modal">
<div class="link-header">
<span class="link-title">Link as same series</span>
<button class="close-btn" onclick={closeLinkPicker}><X size={14} weight="light" /></button>
</div>
<p class="link-hint">
Mark two manga as the same series so duplicates are merged in search.
Click a linked entry again to unlink.
</p>
<div class="link-search-wrap">
<input class="link-search" placeholder="Search your library…" bind:value={linkSearch} use:focusAction />
</div>
<div class="link-list">
{#if loadingLinkList}
<p class="link-empty">Loading…</p>
{:else if linkPickerResults.length === 0}
<p class="link-empty">No results</p>
{:else}
{#each linkPickerResults as m (m.id)}
{@const isLinked = linkedIds.includes(m.id)}
<button class="link-row" class:link-row-linked={isLinked} onclick={() => handleLink(m)}>
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="link-thumb" />
<div class="link-info">
<span class="link-manga-title">{m.title}</span>
{#if m.source?.displayName}<span class="link-source">{m.source.displayName}</span>{/if}
</div>
<span class="link-status">{isLinked ? "✓ Linked" : "Link"}</span>
</button>
{/each}
{/if}
</div>
</div>
</div>
{/if}
{/if}
<script module>
function focusAction(node: HTMLElement) { node.focus(); }
</script>
<style>
.backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.72); z-index: var(--z-settings); display: flex; align-items: center; justify-content: center; animation: fadeIn 0.12s ease both; backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px); }
.modal { width: min(800px, calc(100vw - 48px)); height: min(560px, calc(100vh - 80px)); display: flex; background: var(--bg-surface); border: 1px solid var(--border-base); border-radius: var(--radius-xl); overflow: hidden; animation: scaleIn 0.16s ease both; box-shadow: 0 0 0 1px var(--border-dim), 0 24px 64px rgba(0,0,0,0.6); }
.cover-col { width: 190px; flex-shrink: 0; background: var(--bg-raised); border-right: 1px solid var(--border-dim); display: flex; flex-direction: column; padding: var(--sp-5) var(--sp-4) var(--sp-4); gap: var(--sp-3); overflow: hidden; }
.cover-wrap { position: relative; width: 100%; }
:global(.cover) { width: 100%; aspect-ratio: 2/3; object-fit: cover; border-radius: var(--radius-md); border: 1px solid var(--border-dim); display: block; }
.cover-spinner { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; background: rgba(0,0,0,0.35); border-radius: var(--radius-md); color: var(--text-faint); }
.cover-actions { display: flex; flex-direction: column; gap: var(--sp-2); }
.action-btn { display: flex; align-items: center; gap: var(--sp-2); width: 100%; padding: 7px var(--sp-3); border-radius: var(--radius-md); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); border: 1px solid var(--border-strong); background: none; color: var(--text-muted); cursor: pointer; text-align: left; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
.action-btn:hover:not(:disabled) { color: var(--accent-fg); border-color: var(--accent); background: var(--accent-muted); }
.action-btn:disabled { opacity: 0.4; cursor: default; }
.action-btn.active { background: var(--accent-muted); border-color: var(--accent-dim); color: var(--accent-fg); }
.action-icon { display: flex; align-items: center; justify-content: center; width: 16px; flex-shrink: 0; }
.action-label { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
.folder-wrap { position: relative; width: 100%; }
.folder-menu { position: absolute; bottom: calc(100% + 4px); left: 0; right: 0; background: var(--bg-raised); border: 1px solid var(--border-base); border-radius: var(--radius-md); padding: var(--sp-1); display: flex; flex-direction: column; gap: 1px; box-shadow: 0 8px 24px rgba(0,0,0,0.5); z-index: 10; animation: scaleIn 0.1s ease both; transform-origin: bottom center; }
.folder-empty { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); padding: var(--sp-2) var(--sp-3); }
.folder-item { display: flex; align-items: center; gap: var(--sp-2); padding: 6px var(--sp-3); border-radius: var(--radius-sm); font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); background: none; border: none; cursor: pointer; text-align: left; transition: background var(--t-fast), color var(--t-fast); }
.folder-item:hover { background: var(--bg-overlay); color: var(--text-primary); }
.folder-item.folder-item-on { color: var(--accent-fg); }
.folder-divider { height: 1px; background: var(--border-dim); margin: var(--sp-1) 0; }
.folder-create-row { display: flex; gap: var(--sp-1); padding: var(--sp-1); }
.folder-input { flex: 1; background: var(--bg-overlay); border: 1px solid var(--border-strong); border-radius: var(--radius-sm); padding: 4px 8px; color: var(--text-secondary); font-family: var(--font-ui); font-size: var(--text-xs); outline: none; min-width: 0; }
.folder-input:focus { border-color: var(--border-focus); }
.folder-ok { font-family: var(--font-ui); font-size: var(--text-xs); padding: 4px 8px; border-radius: var(--radius-sm); border: 1px solid var(--border-strong); background: none; color: var(--text-muted); cursor: pointer; flex-shrink: 0; transition: color var(--t-base); }
.folder-ok:disabled { opacity: 0.4; cursor: default; }
.folder-ok:not(:disabled):hover { color: var(--accent-fg); border-color: var(--accent); }
.folder-new { padding: 6px var(--sp-3); border-radius: var(--radius-sm); font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); background: none; border: none; cursor: pointer; text-align: left; width: 100%; transition: color var(--t-fast); }
.folder-new:hover { color: var(--accent-fg); }
.content { flex: 1; display: flex; flex-direction: column; overflow: hidden; min-width: 0; }
.content-header { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--sp-4); padding: var(--sp-5) var(--sp-6) var(--sp-4); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
.title-block { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: var(--sp-1); }
.title { font-size: var(--text-lg); font-weight: var(--weight-medium); color: var(--text-primary); letter-spacing: var(--tracking-tight); line-height: var(--leading-tight); }
.byline { font-size: var(--text-sm); color: var(--text-muted); line-height: var(--leading-snug); }
.sk-byline { height: 14px; width: 55%; background: var(--bg-overlay); border-radius: var(--radius-sm); animation: pulse 1.4s ease infinite; }
.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; flex-shrink: 0; transition: color var(--t-base), background var(--t-base); }
.close-btn:hover { color: var(--text-muted); background: var(--bg-raised); }
.content-body { flex: 1; overflow-y: auto; padding: var(--sp-5) var(--sp-6); display: flex; flex-direction: column; gap: var(--sp-4); scrollbar-width: none; }
.content-body::-webkit-scrollbar { display: none; }
.error-banner { font-family: var(--font-ui); font-size: var(--text-xs); color: #f59e0b; background: rgba(245,158,11,0.1); border: 1px solid rgba(245,158,11,0.25); border-radius: var(--radius-sm); padding: 6px var(--sp-3); }
.sk-row { display: flex; gap: var(--sp-2); align-items: center; }
.sk-badge { height: 20px; width: 54px; background: var(--bg-overlay); border-radius: var(--radius-sm); animation: pulse 1.4s ease infinite; }
.sk-desc { display: flex; flex-direction: column; gap: 8px; padding: var(--sp-2) 0; }
.sk-line { height: 13px; background: var(--bg-overlay); border-radius: var(--radius-sm); animation: pulse 1.4s ease infinite; }
.badges { display: flex; flex-wrap: wrap; gap: var(--sp-2); }
.badge { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); text-transform: uppercase; padding: 3px 8px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: var(--bg-raised); color: var(--text-faint); }
.badge-green { background: rgba(34,197,94,0.12); border-color: rgba(34,197,94,0.3); color: #22c55e; }
.badge-accent { background: var(--accent-muted); border-color: var(--accent-dim); color: var(--accent-fg); }
.badge-unread { background: rgba(245,158,11,0.12); border-color: rgba(245,158,11,0.3); color: #f59e0b; }
.chapter-box { display: flex; flex-direction: column; gap: var(--sp-3); padding: var(--sp-4); background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); }
.chapter-loading { display: flex; align-items: center; gap: var(--sp-2); }
.chapter-loading-label { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
.chapter-meta { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-3); }
.chapter-label { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); letter-spacing: var(--tracking-wide); }
.dl-all-btn { display: flex; align-items: center; gap: var(--sp-1); 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); cursor: pointer; flex-shrink: 0; transition: color var(--t-base), border-color var(--t-base); }
.dl-all-btn:hover:not(:disabled) { color: var(--text-muted); border-color: var(--border-strong); }
.dl-all-btn:disabled { opacity: 0.5; cursor: default; }
.progress-track { height: 3px; background: var(--bg-overlay); border-radius: var(--radius-full); overflow: hidden; }
.progress-fill { height: 100%; background: var(--accent); border-radius: var(--radius-full); transition: width 0.3s ease; }
.read-btn { display: flex; align-items: center; gap: var(--sp-2); padding: 8px var(--sp-4); border-radius: var(--radius-md); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); background: var(--accent-muted); border: 1px solid var(--accent-dim); color: var(--accent-fg); cursor: pointer; align-self: flex-start; transition: filter var(--t-base); }
.read-btn:hover { filter: brightness(1.1); }
.desc-block { display: flex; flex-direction: column; gap: var(--sp-2); border-top: 1px solid var(--border-dim); padding-top: var(--sp-3); }
.desc { font-size: var(--text-sm); color: var(--text-muted); line-height: var(--leading-base); display: -webkit-box; -webkit-line-clamp: 5; -webkit-box-orient: vertical; overflow: hidden; }
.desc.desc-open { display: block; -webkit-line-clamp: unset; overflow: visible; }
.desc-toggle { display: flex; align-items: center; gap: var(--sp-1); font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); background: none; border: none; cursor: pointer; padding: 0; align-self: flex-start; transition: color var(--t-base); }
.desc-toggle:hover { color: var(--accent-fg); }
.genres { display: flex; flex-wrap: wrap; gap: var(--sp-1); }
.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); }
.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-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); 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: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-modal { width: min(460px, calc(100vw - 48px)); max-height: 70vh; display: flex; flex-direction: column; background: var(--bg-surface); border: 1px solid var(--border-base); border-radius: var(--radius-xl); overflow: hidden; box-shadow: 0 24px 64px rgba(0,0,0,0.6); animation: scaleIn 0.14s ease both; }
.link-header { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-4) var(--sp-5); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
.link-title { font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-secondary); }
.link-hint { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); line-height: var(--leading-snug); padding: var(--sp-3) var(--sp-5) 0; flex-shrink: 0; }
.link-search-wrap { padding: var(--sp-3) var(--sp-4); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
.link-search { width: 100%; background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 6px 10px; color: var(--text-primary); font-size: var(--text-sm); outline: none; transition: border-color var(--t-base); }
.link-search:focus { border-color: var(--border-strong); }
.link-list { flex: 1; overflow-y: auto; padding: var(--sp-2); scrollbar-width: none; }
.link-list::-webkit-scrollbar { display: none; }
.link-empty { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); padding: var(--sp-4) var(--sp-3); text-align: center; letter-spacing: var(--tracking-wide); }
.link-row { display: flex; align-items: center; gap: var(--sp-3); width: 100%; padding: 8px var(--sp-3); border-radius: var(--radius-md); border: none; background: none; text-align: left; cursor: pointer; transition: background var(--t-fast); }
.link-row:hover { background: var(--bg-raised); }
.link-row-linked { background: var(--accent-muted) !important; }
:global(.link-thumb) { width: 34px; height: 48px; border-radius: var(--radius-sm); object-fit: cover; flex-shrink: 0; border: 1px solid var(--border-dim); }
.link-info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; min-width: 0; }
.link-manga-title { font-size: var(--text-sm); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.link-source { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
.link-status { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--text-faint); flex-shrink: 0; padding: 2px 8px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); }
.link-row-linked .link-status { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
@keyframes pulse { 0%,100% { opacity: 0.4 } 50% { opacity: 0.8 } }
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
@keyframes scaleIn { from { opacity: 0; transform: scale(0.97) } to { opacity: 1; transform: scale(1) } }
</style>
+1
View File
@@ -0,0 +1 @@
export * from "./selectPortal";
+40
View File
@@ -0,0 +1,40 @@
import type { Attachment } from "svelte/attachments";
/**
* {@attach selectPortal(triggerEl)}
*
* Moves the decorated element to <body> and positions it below `triggerEl`.
* The element stays reactive Svelte still owns its DOM, we just re-parent it.
*
* The portalled menu element is stored on `triggerEl.__selectMenuEl` so that
* the outside-click guard in Settings.svelte can exclude it from dismissal.
*/
export function selectPortal(triggerEl: HTMLElement & { __selectMenuEl?: HTMLElement | null }): Attachment {
return (menuEl: HTMLElement) => {
// Position & move to body
function position() {
const r = triggerEl.getBoundingClientRect();
menuEl.style.position = "fixed";
menuEl.style.top = `${r.bottom + 4}px`;
menuEl.style.left = `${r.right - menuEl.offsetWidth}px`;
// clamp to viewport left edge
const left = parseFloat(menuEl.style.left);
if (left < 8) menuEl.style.left = "8px";
}
document.body.appendChild(menuEl);
triggerEl.__selectMenuEl = menuEl;
position();
// Reposition on scroll / resize while open
window.addEventListener("scroll", position, true);
window.addEventListener("resize", position);
return () => {
window.removeEventListener("scroll", position, true);
window.removeEventListener("resize", position);
triggerEl.__selectMenuEl = null;
menuEl.remove();
};
};
}
+50
View File
@@ -0,0 +1,50 @@
import type { Manga, Source } from "@types";
import type { Settings } from "@types";
import { shouldHideSource } from "@core/util";
// ── Source deduplication ──────────────────────────────────────────────────────
/**
* Deduplicates sources by name, preferring `preferredLang` when multiple
* sources share a name. The local source (id "0") is always excluded.
*
* When `applyHide` is true, sources that fail the NSFW/block check are
* also removed used in fan-out and cache-build paths where only
* user-visible sources should be queried.
*/
export function dedupeSourcesByLang(
sources: Source[],
preferredLang: string,
settings: Pick<Settings, "showNsfw" | "nsfwAllowedSourceIds" | "nsfwBlockedSourceIds">,
applyHide = false,
): Source[] {
const map = new Map<string, Source>();
for (const s of sources) {
if (s.id === "0") continue;
if (applyHide && shouldHideSource(s, settings)) continue;
const existing = map.get(s.name);
if (!existing) { map.set(s.name, s); continue; }
const existingPref = existing.lang === preferredLang;
const newPref = s.lang === preferredLang;
if (newPref && !existingPref) map.set(s.name, s);
else if (!existingPref && !newPref && s.lang < existing.lang) map.set(s.name, s);
}
return Array.from(map.values());
}
// ── Manga predicate filters ───────────────────────────────────────────────────
/**
* Generic predicate pipeline composes multiple boolean predicates into one.
* All predicates must return true for an item to pass.
*
* Usage:
* const keep = buildFilter<Manga>(
* m => !shouldHideNsfw(m, settings),
* m => m.inLibrary,
* );
* const filtered = items.filter(keep);
*/
export function buildFilter<T>(...predicates: ((item: T) => boolean)[]): (item: T) => boolean {
return (item) => predicates.every((p) => p(item));
}
+5
View File
@@ -0,0 +1,5 @@
export * from './sort';
export * from './filter';
export * from './paginate';
export * from './search';
export * from './queue';
+29
View File
@@ -0,0 +1,29 @@
export interface PaginationState {
visible: number;
}
export interface PaginationResult<T> {
items: T[];
hasMore: boolean;
remaining: number;
}
export function createPaginator<T>(pageSize: number) {
return {
slice(all: T[], visible: number): PaginationResult<T> {
return {
items: all.slice(0, visible),
hasMore: all.length > visible,
remaining: Math.max(0, all.length - visible),
};
},
nextVisible(current: number): number {
return current + pageSize;
},
reset(): number {
return pageSize;
},
};
}
+29
View File
@@ -0,0 +1,29 @@
export interface AsyncQueue<T> {
enqueue(item: T): void;
drain(): void;
clear(): void;
size(): number;
}
export function createAsyncQueue<T>(
worker: (item: T) => Promise<void>,
concurrency = 1,
): AsyncQueue<T> {
const queue: T[] = [];
let active = 0;
function next() {
while (active < concurrency && queue.length > 0) {
const item = queue.shift()!;
active++;
worker(item).finally(() => { active--; next(); });
}
}
return {
enqueue(item) { queue.push(item); next(); },
drain() { next(); },
clear() { queue.length = 0; },
size() { return queue.length; },
};
}
+33
View File
@@ -0,0 +1,33 @@
export interface SearchResult<T> {
item: T;
score: number;
}
export function searchItems<T>(
items: T[],
query: string,
getField: (item: T) => string,
): T[] {
const q = query.trim().toLowerCase();
if (!q) return items;
return items.filter(item => getField(item).toLowerCase().includes(q));
}
export function searchWithScore<T>(
items: T[],
query: string,
getField: (item: T) => string,
): SearchResult<T>[] {
const q = query.trim().toLowerCase();
if (!q) return items.map(item => ({ item, score: 0 }));
return items
.map(item => {
const field = getField(item).toLowerCase();
if (!field.includes(q)) return null;
const score = field === q ? 2 : field.startsWith(q) ? 1 : 0;
return { item, score };
})
.filter((r): r is SearchResult<T> => r !== null)
.sort((a, b) => b.score - a.score);
}
+32
View File
@@ -0,0 +1,32 @@
export type SortDir = "asc" | "desc";
export interface SortField<T> {
key: string;
comparator: (a: T, b: T, context?: Record<string, unknown>) => number;
}
export interface SortConfig<T> {
fields: SortField<T>[];
defaultField: string;
defaultDir: SortDir;
}
export interface Sorter<T> {
sort(items: T[], field: string, dir: SortDir, context?: Record<string, unknown>): T[];
}
export function createSorter<T>(config: SortConfig<T>): Sorter<T> {
const fieldMap = new Map(config.fields.map(f => [f.key, f]));
return {
sort(items, field, dir, context) {
const f = fieldMap.get(field) ?? fieldMap.get(config.defaultField);
if (!f) return [...items];
const d = dir ?? config.defaultDir;
return [...items].sort((a, b) => {
const cmp = f.comparator(a, b, context);
return d === "asc" ? cmp : -cmp;
});
},
};
}
+61
View File
@@ -0,0 +1,61 @@
/**
* Runs an async task over every item in `items`, with at most `concurrency`
* tasks in-flight at once. Respects the provided AbortSignal each worker
* exits early if the signal fires. Errors thrown by individual tasks are
* swallowed so one failure does not cancel the whole batch.
*/
export async function runConcurrent<T>(
items: T[],
fn: (item: T) => Promise<void>,
signal: AbortSignal,
concurrency = 6,
): Promise<void> {
let i = 0;
async function worker() {
while (i < items.length) {
if (signal.aborted) return;
const item = items[i++];
await fn(item).catch(() => {});
}
}
await Promise.all(
Array.from({ length: Math.min(concurrency, items.length) }, worker),
);
}
/**
* Deduplicates in-flight async calls by key.
*
* Two call signatures are supported:
*
* 1. Direct call supply a key and a zero-arg factory each time:
* dedupeRequest("my-key", () => fetchSomething())
* If a request with that key is already pending, the existing Promise is
* returned and the factory is not called again.
*
* 2. Curried wrapper supply a key-based fetcher once, get back a
* single-arg function you can call repeatedly:
* const get = dedupeRequest((key) => fetchSomething(key))
* get("my-key")
*/
const _inflight = new Map<string, Promise<unknown>>();
export function dedupeRequest<T>(key: string, factory: () => Promise<T>): Promise<T>;
export function dedupeRequest<T>(fn: (key: string) => Promise<T>): (key: string) => Promise<T>;
export function dedupeRequest<T>(
keyOrFn: string | ((key: string) => Promise<T>),
factory?: () => Promise<T>,
): Promise<T> | ((key: string) => Promise<T>) {
// Curried wrapper form
if (typeof keyOrFn === 'function') {
const fn = keyOrFn;
return (key: string) => dedupeRequest(key, () => fn(key));
}
// Direct call form
const key = keyOrFn;
if (_inflight.has(key)) return _inflight.get(key) as Promise<T>;
const p = factory!().finally(() => _inflight.delete(key));
_inflight.set(key, p);
return p;
}
+25
View File
@@ -0,0 +1,25 @@
export interface PaginatedQuery<T> {
fetchPage(page: number): Promise<T[]>;
reset(): void;
hasMore(): boolean;
}
export interface PaginatedQueryConfig<T> {
fetcher: (page: number) => Promise<{ items: T[]; hasNextPage: boolean }>;
}
export function createPaginatedQuery<T>(
config: PaginatedQueryConfig<T>,
): PaginatedQuery<T> {
let _hasMore = true;
return {
async fetchPage(page) {
const { items, hasNextPage } = await config.fetcher(page);
_hasMore = hasNextPage;
return items;
},
reset() { _hasMore = true; },
hasMore() { return _hasMore; },
};
}
+31
View File
@@ -0,0 +1,31 @@
export interface RetryOptions {
maxAttempts?: number;
baseDelayMs?: number;
maxDelayMs?: number;
shouldRetry?: (err: unknown, attempt: number) => boolean;
}
export async function fetchWithRetry<T>(
fetcher: () => Promise<T>,
options: RetryOptions = {},
): Promise<T> {
const {
maxAttempts = 3,
baseDelayMs = 500,
maxDelayMs = 10_000,
shouldRetry = () => true,
} = options;
let lastErr: unknown;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await fetcher();
} catch (err) {
lastErr = err;
if (attempt === maxAttempts || !shouldRetry(err, attempt)) throw err;
const delay = Math.min(baseDelayMs * 2 ** (attempt - 1), maxDelayMs);
await new Promise(r => setTimeout(r, delay));
}
}
throw lastErr;
}
+3
View File
@@ -0,0 +1,3 @@
export * from './fetchWithRetry';
export * from './batchRequests';
export * from './createPaginatedQuery';
+7 -29
View File
@@ -1,4 +1,4 @@
import { store, updateSettings } from "../store/state.svelte"; import { store, updateSettings } from "@store/state.svelte";
export type AuthMode = "NONE" | "BASIC_AUTH" | "SIMPLE_LOGIN" | "UI_LOGIN"; export type AuthMode = "NONE" | "BASIC_AUTH" | "SIMPLE_LOGIN" | "UI_LOGIN";
@@ -16,34 +16,22 @@ function basicHeader(user: string, pass: string): Record<string, string> {
return { Authorization: `Basic ${btoa(`${user}:${pass}`)}` }; return { Authorization: `Basic ${btoa(`${user}:${pass}`)}` };
} }
export function fetchAuthenticated( export function fetchAuthenticated(url: string, init: RequestInit, signal?: AbortSignal): Promise<Response> {
url: string,
init: RequestInit,
signal?: AbortSignal,
): Promise<Response> {
const mode = store.settings.serverAuthMode ?? "NONE"; const mode = store.settings.serverAuthMode ?? "NONE";
if (mode === "BASIC_AUTH") { if (mode === "BASIC_AUTH") {
const user = store.settings.serverAuthUser?.trim() ?? ""; const user = store.settings.serverAuthUser?.trim() ?? "";
const pass = store.settings.serverAuthPass?.trim() ?? ""; const pass = store.settings.serverAuthPass?.trim() ?? "";
return fetch(url, { return fetch(url, {
...init, ...init, signal, credentials: "omit",
signal, headers: { ...(init.headers as Record<string, string> ?? {}), ...(user && pass ? basicHeader(user, pass) : {}) },
credentials: "omit",
headers: {
...(init.headers as Record<string, string> ?? {}),
...(user && pass ? basicHeader(user, pass) : {}),
},
}); });
} }
return fetch(url, { ...init, signal, credentials: "omit" }); return fetch(url, { ...init, signal, credentials: "omit" });
} }
export async function loginBasic(user: string, pass: string): Promise<void> { export async function loginBasic(user: string, pass: string): Promise<void> {
const res = await fetch(`${getServerBase()}/api/graphql`, { const res = await fetch(`${getServerBase()}/api/graphql`, {
method: "POST", method: "POST", credentials: "omit",
credentials: "omit",
headers: { "Content-Type": "application/json", ...basicHeader(user, pass) }, headers: { "Content-Type": "application/json", ...basicHeader(user, pass) },
body: JSON.stringify({ query: "{ __typename }" }), body: JSON.stringify({ query: "{ __typename }" }),
signal: AbortSignal.timeout(5000), signal: AbortSignal.timeout(5000),
@@ -60,34 +48,25 @@ export async function probeServer(): Promise<"ok" | "auth_required" | "unsupport
const base = getServerBase(); const base = getServerBase();
const mode = store.settings.serverAuthMode ?? "NONE"; const mode = store.settings.serverAuthMode ?? "NONE";
const s = store.settings; const s = store.settings;
try { try {
const headers: Record<string, string> = { "Content-Type": "application/json" }; const headers: Record<string, string> = { "Content-Type": "application/json" };
if (mode === "BASIC_AUTH") { if (mode === "BASIC_AUTH") {
const user = s.serverAuthUser?.trim() ?? ""; const user = s.serverAuthUser?.trim() ?? "";
const pass = s.serverAuthPass?.trim() ?? ""; const pass = s.serverAuthPass?.trim() ?? "";
if (user && pass) Object.assign(headers, basicHeader(user, pass)); if (user && pass) Object.assign(headers, basicHeader(user, pass));
} }
const res = await fetch(`${base}/api/graphql`, { const res = await fetch(`${base}/api/graphql`, {
method: "POST", method: "POST", credentials: "omit", headers,
credentials: "omit",
headers,
body: JSON.stringify({ query: "{ __typename }" }), body: JSON.stringify({ query: "{ __typename }" }),
signal: AbortSignal.timeout(2000), signal: AbortSignal.timeout(5000),
}); });
if (res.ok) return "ok"; if (res.ok) return "ok";
if (res.status === 401) { if (res.status === 401) {
const wwwAuth = res.headers.get("WWW-Authenticate") ?? ""; const wwwAuth = res.headers.get("WWW-Authenticate") ?? "";
if (/basic/i.test(wwwAuth)) { if (/basic/i.test(wwwAuth)) {
if (mode !== "BASIC_AUTH") updateSettings({ serverAuthMode: "BASIC_AUTH" }); if (mode !== "BASIC_AUTH") updateSettings({ serverAuthMode: "BASIC_AUTH" });
return "auth_required"; return "auth_required";
} }
if (/bearer/i.test(wwwAuth)) { if (/bearer/i.test(wwwAuth)) {
if (mode !== "UI_LOGIN") updateSettings({ serverAuthMode: "UI_LOGIN" }); if (mode !== "UI_LOGIN") updateSettings({ serverAuthMode: "UI_LOGIN" });
} else if (mode === "NONE") { } else if (mode === "NONE") {
@@ -95,7 +74,6 @@ export async function probeServer(): Promise<"ok" | "auth_required" | "unsupport
} }
return "unsupported_mode"; return "unsupported_mode";
} }
return "unreachable"; return "unreachable";
} catch { return "unreachable"; } } catch { return "unreachable"; }
} }
+38
View File
@@ -0,0 +1,38 @@
import { invoke } from "@tauri-apps/api/core";
function collectAppData(): Record<string, string> {
const data: Record<string, string> = {};
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key !== null) data[key] = localStorage.getItem(key) ?? "";
}
return data;
}
function applyAppData(data: Record<string, string>): void {
localStorage.clear();
for (const [key, value] of Object.entries(data)) {
localStorage.setItem(key, value);
}
}
export async function exportAppData(): Promise<void> {
const json = JSON.stringify(collectAppData(), null, 2);
await invoke("export_app_data", { json });
}
export async function importAppData(): Promise<void> {
const json = await invoke<string>("import_app_data");
const data: Record<string, string> = JSON.parse(json);
applyAppData(data);
location.reload();
}
export async function autoBackupAppData(): Promise<void> {
try {
const json = JSON.stringify(collectAppData());
await invoke("auto_backup_app_data", { json });
} catch (e) {
console.warn("[moku] auto-backup failed:", e);
}
}
+17 -17
View File
@@ -1,11 +1,11 @@
import { fetch as tauriFetch } from "@tauri-apps/plugin-http"; import { fetch as tauriFetch } from "@tauri-apps/plugin-http";
import { store } from "../store/state.svelte"; import { store } from "@store/state.svelte";
const cache = new Map<string, string>(); const cache = new Map<string, string>();
const inflight = new Map<string, Promise<string>>(); const inflight = new Map<string, Promise<string>>();
const MAX_CONCURRENT = 6; const MAX_CONCURRENT = 6;
let active = 0; let active = 0;
let drainScheduled = false;
interface QueueEntry { interface QueueEntry {
url: string; url: string;
@@ -41,34 +41,33 @@ function insertSorted(entry: QueueEntry) {
} }
function drain() { function drain() {
drainScheduled = false;
while (active < MAX_CONCURRENT && queue.length > 0) { while (active < MAX_CONCURRENT && queue.length > 0) {
const entry = queue.shift()!; const entry = queue.shift()!;
active++; active++;
doFetch(entry.url) doFetch(entry.url)
.then(entry.resolve, entry.reject) .then(entry.resolve, entry.reject)
.finally(() => { .finally(() => { inflight.delete(entry.url); active--; drain(); });
inflight.delete(entry.url);
active--;
drain();
});
} }
} }
function scheduleDrain() {
if (drainScheduled) return;
drainScheduled = true;
requestAnimationFrame(drain);
}
function enqueue(url: string, priority: number): Promise<string> { function enqueue(url: string, priority: number): Promise<string> {
const promise = new Promise<string>((resolve, reject) => { const promise = new Promise<string>((resolve, reject) => { insertSorted({ url, priority, resolve, reject }); });
insertSorted({ url, priority, resolve, reject });
});
inflight.set(url, promise); inflight.set(url, promise);
drain(); scheduleDrain();
return promise; return promise;
} }
export function getBlobUrl(url: string, priority = 0): Promise<string> { export function getBlobUrl(url: string, priority = 0): Promise<string> {
if (!url) return Promise.resolve(""); if (!url) return Promise.resolve("");
const cached = cache.get(url); const cached = cache.get(url);
if (cached) return Promise.resolve(cached); if (cached) return Promise.resolve(cached);
const existing = inflight.get(url); const existing = inflight.get(url);
if (existing) { if (existing) {
const idx = queue.findIndex(e => e.url === url); const idx = queue.findIndex(e => e.url === url);
@@ -79,7 +78,6 @@ export function getBlobUrl(url: string, priority = 0): Promise<string> {
} }
return existing; return existing;
} }
return enqueue(url, priority); return enqueue(url, priority);
} }
@@ -92,10 +90,12 @@ export function preloadBlobUrls(urls: string[], basePriority = 0): void {
export function revokeBlobUrl(url: string): void { export function revokeBlobUrl(url: string): void {
const blob = cache.get(url); const blob = cache.get(url);
if (blob) { if (blob) { URL.revokeObjectURL(blob); cache.delete(url); }
URL.revokeObjectURL(blob);
cache.delete(url);
} }
export function deprioritizeQueue(): void {
for (const entry of queue) entry.priority = 0;
queue.sort((a, b) => b.priority - a.priority);
} }
export function clearBlobCache(): void { export function clearBlobCache(): void {
+4
View File
@@ -0,0 +1,4 @@
export * from './memoryCache';
export * from './pageCache';
export * from './imageCache';
export * from './queryCache';
View File
+79
View File
@@ -0,0 +1,79 @@
import { gql, plainThumbUrl } from "@api/client";
import { getBlobUrl, preloadBlobUrls } from "@core/cache/imageCache";
import { dedupeRequest } from "@core/async/batchRequests";
import { FETCH_CHAPTER_PAGES } from "@api/mutations/chapters";
const pageCache = new Map<number, string[]>();
const inflight = new Map<number, Promise<string[]>>();
const resolvedUrlCache = new Map<string, Promise<string>>();
const preloadedUrls = new Set<string>();
const aspectCache = new Map<string, number>();
export function resolveUrl(url: string, useBlob: boolean, priority = 0): Promise<string> {
if (!useBlob) return Promise.resolve(url);
if (!resolvedUrlCache.has(url)) resolvedUrlCache.set(url, getBlobUrl(url, priority));
return resolvedUrlCache.get(url)!;
}
export function fetchPages(
chapterId: number,
useBlob: boolean,
signal?: AbortSignal,
priorityPage = 0,
): Promise<string[]> {
const cached = pageCache.get(chapterId);
if (cached) return Promise.resolve(cached);
if (signal?.aborted) return Promise.reject(new DOMException("Aborted", "AbortError"));
if (!inflight.has(chapterId)) {
const p = dedupeRequest(`chapter-pages:${chapterId}`, () =>
gql<{ fetchChapterPages: { pages: string[] } }>(FETCH_CHAPTER_PAGES, { chapterId })
.then(d => {
const urls = d.fetchChapterPages.pages.map(p => plainThumbUrl(p));
if (useBlob) {
if (urls[priorityPage]) getBlobUrl(urls[priorityPage], urls.length + 999);
preloadBlobUrls(urls.filter((_, i) => i !== priorityPage), urls.length);
}
pageCache.set(chapterId, urls);
return urls;
})
).finally(() => inflight.delete(chapterId));
inflight.set(chapterId, p);
}
const base = inflight.get(chapterId)!;
if (!signal) return base;
return new Promise((resolve, reject) => {
signal.addEventListener("abort", () => reject(new DOMException("Aborted", "AbortError")), { once: true });
base.then(resolve, reject);
});
}
export function measureAspect(url: string, useBlob: boolean): Promise<number> {
if (aspectCache.has(url)) return Promise.resolve(aspectCache.get(url)!);
return resolveUrl(url, useBlob).then(src => new Promise(res => {
const img = new Image();
img.onload = () => { const r = img.naturalHeight > 0 ? img.naturalWidth / img.naturalHeight : 0.67; aspectCache.set(url, r); res(r); };
img.onerror = () => res(0.67);
img.src = src;
}));
}
export function preloadImage(url: string, useBlob: boolean): void {
if (preloadedUrls.has(url)) return;
preloadedUrls.add(url);
resolveUrl(url, useBlob).then(src => { new Image().src = src; }).catch(() => {});
}
export function clearPageCache(chapterId?: number): void {
if (chapterId !== undefined) {
pageCache.delete(chapterId);
inflight.delete(chapterId);
} else {
pageCache.clear();
inflight.clear();
resolvedUrlCache.clear();
preloadedUrls.clear();
aspectCache.clear();
}
}
+161
View File
@@ -0,0 +1,161 @@
interface Entry<T> {
promise: Promise<T>;
fetchedAt: number;
}
const store = new Map<string, Entry<unknown>>();
const subs = new Map<string, Set<() => void>>();
const groups = new Map<string, Set<string>>();
export const DEFAULT_TTL_MS = 5 * 60 * 1_000;
function notify(key: string) { subs.get(key)?.forEach(cb => cb()); }
function registerGroups(key: string, group?: string | string[]) {
if (!group) return;
for (const tag of Array.isArray(group) ? group : [group]) {
if (!groups.has(tag)) groups.set(tag, new Set());
groups.get(tag)!.add(key);
}
}
export const cache = {
get<T>(key: string, fetcher: () => Promise<T>, ttl = DEFAULT_TTL_MS, group?: string | string[]): Promise<T> {
const existing = store.get(key) as Entry<T> | undefined;
if (existing && Date.now() - existing.fetchedAt < ttl) return existing.promise;
const promise = fetcher().catch(err => {
if (err?.name !== "AbortError") store.delete(key);
return Promise.reject(err);
}) as Promise<T>;
store.set(key, { promise, fetchedAt: Date.now() });
registerGroups(key, group);
promise.then(() => notify(key)).catch(() => {});
return promise;
},
set<T>(key: string, value: T, group?: string | string[]) {
store.set(key, { promise: Promise.resolve(value), fetchedAt: Date.now() });
registerGroups(key, group);
notify(key);
},
update<T>(key: string, fn: (prev: T) => T) {
const existing = store.get(key) as Entry<T> | undefined;
if (!existing) return;
const next = existing.promise.then(fn);
store.set(key, { promise: next, fetchedAt: Date.now() });
next.then(() => notify(key)).catch(() => {});
},
has(key: string): boolean { return store.has(key); },
ageOf(key: string): number | undefined {
const e = store.get(key);
return e ? Date.now() - e.fetchedAt : undefined;
},
clear(key: string) { store.delete(key); notify(key); },
clearGroup(tag: string) {
const keys = groups.get(tag);
if (!keys) return;
for (const key of keys) { store.delete(key); notify(key); }
groups.delete(tag);
},
clearAll() {
const allKeys = [...store.keys()];
store.clear(); groups.clear();
allKeys.forEach(notify);
},
subscribe(key: string, cb: () => void): () => void {
if (!subs.has(key)) subs.set(key, new Set());
subs.get(key)!.add(cb);
return () => subs.get(key)?.delete(cb);
},
};
export const CACHE_GROUPS = {
LIBRARY: "g:library",
SOURCES: "g:sources",
} as const;
export const CACHE_KEYS = {
LIBRARY: "library",
ALL_MANGA: "all_manga_unfiltered",
CATEGORIES: "categories",
SEARCH: "search_all_manga",
SOURCES: "sources",
POPULAR: "popular",
GENRE: (genre: string) => `genre:${genre}`,
MANGA: (id: number) => `manga:${id}`,
CHAPTERS: (id: number) => `chapters:${id}`,
sourceMangaPages(sourceId: string, type: "POPULAR" | "LATEST" | "SEARCH", query?: string | string[]): string {
const q = Array.isArray(query) ? [...query].sort().join("+") : (query ?? "");
return `pages:${sourceId}:${type}:${q}`;
},
sourceMangaPage(sourceId: string, type: "POPULAR" | "LATEST" | "SEARCH", page: number, query?: string | string[]): string {
const q = Array.isArray(query) ? [...query].sort().join("+") : (query ?? "");
return `page:${sourceId}:${type}:${page}:${q}`;
},
} as const;
const inflight = new Map<string, Promise<unknown>>();
export function deduped<T>(key: string, fetcher: () => Promise<T>): Promise<T> {
if (inflight.has(key)) return inflight.get(key) as Promise<T>;
const p = fetcher().finally(() => inflight.delete(key));
inflight.set(key, p);
return p;
}
const _pageSets = new Map<string, Set<number>>();
export interface PageSet {
add(page: number): void;
pages(): Set<number>;
next(): number;
clear(): void;
}
export function getPageSet(sourceId: string, type: "POPULAR" | "LATEST" | "SEARCH", query?: string | string[]): PageSet {
const key = CACHE_KEYS.sourceMangaPages(sourceId, type, query);
return {
add(page) { if (!_pageSets.has(key)) _pageSets.set(key, new Set()); _pageSets.get(key)!.add(page); },
pages() { return new Set(_pageSets.get(key) ?? []); },
next() { const s = _pageSets.get(key); return s?.size ? Math.max(...s) + 1 : 1; },
clear() { _pageSets.delete(key); },
};
}
const FRECENCY_KEY = "moku-source-frecency";
const MAX_FRECENCY_SOURCES = 4;
type FrecencyMap = Record<string, number>;
function loadFrecency(): FrecencyMap {
try { const r = localStorage.getItem(FRECENCY_KEY); return r ? JSON.parse(r) : {}; }
catch { return {}; }
}
function saveFrecency(map: FrecencyMap) {
try { localStorage.setItem(FRECENCY_KEY, JSON.stringify(map)); } catch {}
}
export function recordSourceAccess(sourceId: string) {
if (!sourceId || sourceId === "0") return;
const map = loadFrecency();
map[sourceId] = (map[sourceId] ?? 0) + 1;
saveFrecency(map);
}
export function getTopSources<T extends { id: string }>(sources: T[]): T[] {
const map = loadFrecency();
const withScore = sources.map(s => ({ s, score: map[s.id] ?? 0 }));
if (withScore.some(x => x.score > 0)) {
return withScore.sort((a, b) => b.score - a.score).slice(0, MAX_FRECENCY_SOURCES).map(x => x.s);
}
return sources.slice(0, MAX_FRECENCY_SOURCES);
}
@@ -1,5 +1,3 @@
import { getCurrentWindow } from "@tauri-apps/api/window";
export interface Keybinds { export interface Keybinds {
turnPageRight: string; turnPageRight: string;
turnPageLeft: string; turnPageLeft: string;
@@ -47,28 +45,3 @@ export const KEYBIND_LABELS: Record<keyof Keybinds, string> = {
toggleBookmark: "Toggle bookmark", toggleBookmark: "Toggle bookmark",
toggleMarker: "Toggle marker", toggleMarker: "Toggle marker",
}; };
export function eventToKeybind(e: KeyboardEvent): string {
if (["Control", "Alt", "Shift", "Meta"].includes(e.key)) return "";
const parts: string[] = [];
if (e.ctrlKey) parts.push("ctrl");
if (e.altKey) parts.push("alt");
if (e.shiftKey) parts.push("shift");
if (e.metaKey) parts.push("meta");
parts.push(e.key);
return parts.join("+");
}
export function matchesKeybind(e: KeyboardEvent, bind: string): boolean {
return eventToKeybind(e) === bind;
}
export async function toggleFullscreen(): Promise<void> {
try {
const win = getCurrentWindow();
const isFs = await win.isFullscreen();
await win.setFullscreen(!isFs);
} catch (e) {
console.warn("toggleFullscreen unavailable:", e);
}
}
+3
View File
@@ -0,0 +1,3 @@
export { eventToKeybind, matchesKeybind, toggleFullscreen } from "./keybindEngine";
export { DEFAULT_KEYBINDS, KEYBIND_LABELS } from "./defaultBinds";
export type { Keybinds } from "./defaultBinds";
+25
View File
@@ -0,0 +1,25 @@
import { getCurrentWindow } from "@tauri-apps/api/window";
export function eventToKeybind(e: KeyboardEvent): string {
if (["Control", "Alt", "Shift", "Meta"].includes(e.key)) return "";
const parts: string[] = [];
if (e.ctrlKey) parts.push("ctrl");
if (e.altKey) parts.push("alt");
if (e.shiftKey) parts.push("shift");
if (e.metaKey) parts.push("meta");
parts.push(e.key);
return parts.join("+");
}
export function matchesKeybind(e: KeyboardEvent, bind: string): boolean {
return eventToKeybind(e) === bind;
}
export async function toggleFullscreen(): Promise<void> {
try {
const win = getCurrentWindow();
await win.setFullscreen(!await win.isFullscreen());
} catch (e) {
console.warn("toggleFullscreen unavailable:", e);
}
}
+36
View File
@@ -0,0 +1,36 @@
import { store } from "@store/state.svelte";
let themeStyleEl: HTMLStyleElement | null = null;
export function applyTheme() {
const themeId = store.settings.theme ?? "dark";
const isCustom = themeId.startsWith("custom:");
if (!isCustom) {
themeStyleEl?.remove();
themeStyleEl = null;
document.documentElement.setAttribute("data-theme", themeId);
return;
}
const custom = store.settings.customThemes?.find(t => t.id === themeId);
if (!custom) {
themeStyleEl?.remove();
themeStyleEl = null;
document.documentElement.setAttribute("data-theme", "dark");
return;
}
const vars = Object.entries(custom.tokens)
.map(([k, v]) => ` --${k}: ${v};`)
.join("\n");
const css = `[data-theme="custom"] {\n${vars}\n}`;
if (!themeStyleEl) {
themeStyleEl = document.createElement("style");
themeStyleEl.id = "moku-custom-theme";
document.head.appendChild(themeStyleEl);
}
themeStyleEl.textContent = css;
document.documentElement.setAttribute("data-theme", "custom");
}
+23
View File
@@ -0,0 +1,23 @@
import { store } from "@store/state.svelte";
const IDLE_EVENTS = ["mousemove", "mousedown", "keydown", "touchstart", "wheel"] as const;
export function mountIdleDetection(onIdle: () => void, onActive: () => void): () => void {
let timer: ReturnType<typeof setTimeout> | null = null;
function reset() {
if (timer) clearTimeout(timer);
const ms = (store.settings.idleTimeoutMin ?? 5) * 60 * 1000;
if (ms === 0) return;
timer = setTimeout(onIdle, ms);
onActive();
}
IDLE_EVENTS.forEach(e => window.addEventListener(e, reset, { passive: true }));
reset();
return () => {
if (timer) clearTimeout(timer);
IDLE_EVENTS.forEach(e => window.removeEventListener(e, reset));
};
}
+2
View File
@@ -0,0 +1,2 @@
export * from './idle';
export * from './zoom';
+61
View File
@@ -0,0 +1,61 @@
import { store } from "@store/state.svelte";
let _appliedZoom: number = -1;
let _vhRafId: number | null = null;
export function applyZoom() {
const uiZoom = store.settings.uiZoom ?? 1.0;
if (uiZoom === _appliedZoom) return;
_appliedZoom = uiZoom;
document.documentElement.style.setProperty("--ui-zoom", String(uiZoom));
document.documentElement.style.setProperty("--ui-scale", String(uiZoom));
document.documentElement.style.zoom = `${uiZoom * 100}%`;
if (_vhRafId !== null) cancelAnimationFrame(_vhRafId);
_vhRafId = requestAnimationFrame(() => {
_vhRafId = null;
document.documentElement.style.setProperty("--visual-vh", `${window.innerHeight / uiZoom}px`);
});
}
export function handleZoomKey(e: KeyboardEvent) {
if (!e.ctrlKey) return;
const current = store.settings.uiZoom ?? 1.0;
if (e.key === "=" || e.key === "+") { e.preventDefault(); store.settings.uiZoom = Math.min(2.0, Math.round((current + 0.1) * 10) / 10); }
else if (e.key === "-") { e.preventDefault(); store.settings.uiZoom = Math.max(0.5, Math.round((current - 0.1) * 10) / 10); }
else if (e.key === "0") { e.preventDefault(); store.settings.uiZoom = 1.0; }
}
export function mountZoomKey(): () => void {
window.addEventListener("keydown", handleZoomKey);
return () => window.removeEventListener("keydown", handleZoomKey);
}
export function clampZoom(z: number, min: number, max: number): number {
return Math.round(Math.min(max, Math.max(min, z)) * 1000) / 1000;
}
export function captureZoomAnchor(
containerEl: HTMLElement | null,
style: string,
out: { el: HTMLElement | null; offset: number },
) {
if (!containerEl || style !== "longstrip") return;
const containerTop = containerEl.getBoundingClientRect().top;
for (const img of containerEl.querySelectorAll<HTMLElement>("img[data-local-page]")) {
const rect = img.getBoundingClientRect();
if (rect.bottom > containerTop) { out.el = img; out.offset = rect.top - containerTop; return; }
}
}
export function restoreZoomAnchor(
containerEl: HTMLElement | null,
out: { el: HTMLElement | null; offset: number },
) {
if (!out.el || !containerEl) return;
const el = out.el;
out.el = null;
requestAnimationFrame(() => {
const containerTop = containerEl!.getBoundingClientRect().top;
containerEl!.scrollTop += (el.getBoundingClientRect().top - containerTop) - out.offset;
});
}
+40
View File
@@ -0,0 +1,40 @@
import { invoke } from "@tauri-apps/api/core";
import { getVersion } from "@tauri-apps/api/app";
import { addToast } from "@store/state.svelte";
function parse(tag: string): number[] {
return tag.replace(/^v/, "").split(".").map(Number);
}
function compare(a: number[], b: number[]): number {
for (let i = 0; i < 3; i++) {
if ((a[i] ?? 0) !== (b[i] ?? 0)) return (b[i] ?? 0) - (a[i] ?? 0);
}
return 0;
}
export async function checkForUpdateSilently(): Promise<void> {
try {
const [currentVersion, releases] = await Promise.all([
getVersion(),
invoke<Array<{ tag_name: string; html_url: string }>>("list_releases"),
]);
const valid = releases.filter(r => typeof r.tag_name === "string" && r.tag_name.trim());
if (!valid.length) return;
const latestTag = valid
.map(r => r.tag_name)
.sort((a, b) => compare(parse(a), parse(b)))[0]
.replace(/^v/, "");
if (compare(parse(latestTag), parse(currentVersion)) < 0) {
addToast({
kind: "info",
title: `Update available — v${latestTag}`,
body: "Open Settings → About to install.",
duration: 8000,
});
}
} catch {}
}
+71 -85
View File
@@ -1,16 +1,43 @@
import { clsx, type ClassValue } from "clsx"; import type { Manga, Source } from "@types";
import type { Source } from "./types"; import type { Settings } from "@types";
export function cn(...inputs: ClassValue[]) { // ── Class utility ─────────────────────────────────────────────────────────────
return clsx(inputs);
export { clsx as cn } from "clsx";
// ── Time / formatting ─────────────────────────────────────────────────────────
export function timeAgo(ts: number): string {
const diff = Date.now() - ts, m = Math.floor(diff / 60000);
if (m < 1) return "Just now";
if (m < 60) return `${m}m ago`;
const h = Math.floor(m / 60);
if (h < 24) return `${h}h ago`;
const d = Math.floor(h / 24);
if (d < 7) return `${d}d ago`;
return new Date(ts).toLocaleDateString("en-US", { month: "short", day: "numeric" });
} }
// ── NSFW genre filtering ────────────────────────────────────────────────────── export function dayLabel(ts: number): string {
const d = new Date(ts), now = new Date();
if (d.toDateString() === now.toDateString()) return "Today";
const yest = new Date(now); yest.setDate(now.getDate() - 1);
if (d.toDateString() === yest.toDateString()) return "Yesterday";
return d.toLocaleDateString("en-US", { weekday: "long", month: "long", day: "numeric" });
}
export function formatReadTime(m: number): string {
if (m < 1) return "< 1 min";
if (m < 60) return `${m} min`;
const h = Math.floor(m / 60), r = m % 60;
return r === 0 ? `${h}h` : `${h}h ${r}m`;
}
// ── NSFW filtering ────────────────────────────────────────────────────────────
/** /**
* Default substrings used when no user-configured list is available. * Default genre substrings used when no user-configured list is available.
* The Settings > Content tab lets users add/remove entries from this list, * Stored as settings.nsfwFilteredTags; editable in Settings > Content.
* which is stored as settings.nsfwFilteredTags.
*/ */
export const DEFAULT_NSFW_TAGS = [ export const DEFAULT_NSFW_TAGS = [
"adult", "adult",
@@ -27,55 +54,39 @@ export const DEFAULT_NSFW_TAGS = [
]; ];
/** /**
* Returns true if the manga carries at least one genre tag matching any of * Returns true if the manga's genre list contains any of the given substrings.
* the provided substrings (case-insensitive). Pass settings.nsfwFilteredTags * Falls back to DEFAULT_NSFW_TAGS if no tag list is provided.
* as the tag list; falls back to DEFAULT_NSFW_TAGS if omitted.
*/ */
export function isNsfwManga( export function isNsfwManga(
manga: { genre?: string[] | null }, manga: { genre?: string[] | null },
tags: string[] = DEFAULT_NSFW_TAGS, tags: string[] = DEFAULT_NSFW_TAGS,
): boolean { ): boolean {
return (manga.genre ?? []).some((g) => { return (manga.genre ?? []).some(g =>
const normalized = g.toLowerCase().trim(); tags.some(sub => g.toLowerCase().trim().includes(sub))
return tags.some((sub) => normalized.includes(sub)); );
});
} }
/** /**
* Single authoritative NSFW gate used by all views. * Single authoritative NSFW gate used by all views.
* * Returns true when the manga should be HIDDEN. Priority order:
* Returns true when the manga should be HIDDEN. Checks in order: * 1. Source in blockedSourceIds always hidden, even when showNsfw is on.
* 1. showNsfw disabled globally skip everything, hide by source flag or genre match. * 2. showNsfw globally enabled only blocked sources are hidden.
* 2. Source is in blockedSourceIds always hide regardless of showNsfw. * 3. Source in allowedSourceIds skip isNsfw flag, but genre tags still apply.
* 3. Source is in allowedSourceIds always show (bypasses isNsfw flag only, genre tags still apply). * 4. source.isNsfw flag hidden.
* 4. Source isNsfw flag hide unless source is allowed. * 5. Genre tag match hidden.
* 5. Genre tag match hide.
* *
* Usage: items.filter(m => !shouldHideNsfw(m, settings)) * Usage: items.filter(m => !shouldHideNsfw(m, settings))
*/ */
export function shouldHideNsfw( export function shouldHideNsfw(
manga: { manga: Pick<Manga, "genre" | "source">,
genre?: string[] | null; settings: Pick<Settings, "showNsfw" | "nsfwFilteredTags" | "nsfwAllowedSourceIds" | "nsfwBlockedSourceIds">,
source?: { id?: string; isNsfw?: boolean } | null;
},
settings: {
showNsfw: boolean;
nsfwFilteredTags: string[];
nsfwAllowedSourceIds: string[];
nsfwBlockedSourceIds: string[];
},
): boolean { ): boolean {
const srcId = manga.source?.id; const srcId = manga.source?.id;
// Explicit block always wins, even when showNsfw is on
if (srcId && settings.nsfwBlockedSourceIds.includes(srcId)) return true; if (srcId && settings.nsfwBlockedSourceIds.includes(srcId)) return true;
// If NSFW is globally allowed, only explicit blocks apply
if (settings.showNsfw) return false; 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)); const sourceAllowed = !!(srcId && settings.nsfwAllowedSourceIds.includes(srcId));
if (!sourceAllowed && manga.source?.isNsfw) return true; if (!sourceAllowed && manga.source?.isNsfw) return true;
return isNsfwManga(manga, settings.nsfwFilteredTags); return isNsfwManga(manga, settings.nsfwFilteredTags);
@@ -83,21 +94,11 @@ export function shouldHideNsfw(
/** /**
* Gate for Source objects parallel to shouldHideNsfw for manga. * 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)) * Usage: sources.filter(s => !shouldHideSource(s, settings))
*/ */
export function shouldHideSource( export function shouldHideSource(
source: { id: string; isNsfw: boolean }, source: Pick<Source, "id" | "isNsfw">,
settings: { settings: Pick<Settings, "showNsfw" | "nsfwAllowedSourceIds" | "nsfwBlockedSourceIds">,
showNsfw: boolean;
nsfwAllowedSourceIds: string[];
nsfwBlockedSourceIds: string[];
},
): boolean { ): boolean {
if (settings.nsfwBlockedSourceIds.includes(source.id)) return true; if (settings.nsfwBlockedSourceIds.includes(source.id)) return true;
if (settings.nsfwAllowedSourceIds.includes(source.id)) return false; if (settings.nsfwAllowedSourceIds.includes(source.id)) return false;
@@ -106,6 +107,11 @@ export function shouldHideSource(
// ── Source deduplication ────────────────────────────────────────────────────── // ── Source deduplication ──────────────────────────────────────────────────────
/**
* Deduplicates sources by name. When multiple sources share a name,
* the preferred language wins; otherwise falls back to alphabetical by lang.
* The local source (id "0") is always excluded.
*/
export function dedupeSources(sources: Source[], preferredLang: string): Source[] { export function dedupeSources(sources: Source[], preferredLang: string): Source[] {
const byName = new Map<string, Source[]>(); const byName = new Map<string, Source[]>();
for (const src of sources) { for (const src of sources) {
@@ -115,7 +121,7 @@ export function dedupeSources(sources: Source[], preferredLang: string): Source[
} }
const picked: Source[] = []; const picked: Source[] = [];
for (const group of byName.values()) { for (const group of byName.values()) {
const preferred = group.find((s) => s.lang === preferredLang); const preferred = group.find(s => s.lang === preferredLang);
picked.push(preferred ?? group.sort((a, b) => a.lang.localeCompare(b.lang))[0]); picked.push(preferred ?? group.sort((a, b) => a.lang.localeCompare(b.lang))[0]);
} }
return picked; return picked;
@@ -123,12 +129,7 @@ export function dedupeSources(sources: Source[], preferredLang: string): Source[
// ── Manga deduplication ─────────────────────────────────────────────────────── // ── Manga deduplication ───────────────────────────────────────────────────────
/** /** Strips punctuation, articles, and source suffixes for fuzzy title matching. */
* Normalizes a title for fuzzy matching.
* Strips punctuation, articles, and common source-specific suffixes so that
* "The Greatest Estate Developer" and "Yeokdaegeum Yeongji Seolgyesa" won't
* match on title alone but their identical descriptions will catch them.
*/
export function normalizeTitle(title: string): string { export function normalizeTitle(title: string): string {
return title return title
.toLowerCase() .toLowerCase()
@@ -139,50 +140,38 @@ export function normalizeTitle(title: string): string {
.trim(); .trim();
} }
/** /** Strips all non-alphanumeric chars and collapses whitespace. */
* Normalizes a string for fingerprinting strip all non-alpha, collapse spaces.
*/
function norm(s: string): string { function norm(s: string): string {
return s.toLowerCase().replace(/[^a-z0-9]/g, " ").replace(/\s+/g, " ").trim(); return s.toLowerCase().replace(/[^a-z0-9]/g, " ").replace(/\s+/g, " ").trim();
} }
/** /**
* Description fingerprint first 200 normalized chars. * First 200 normalized chars of a description reliable cross-source fingerprint.
* Long enough to reliably identify the same series across sources even when * Returns null if too short (< 60 chars) to be a trustworthy signal.
* translations differ in punctuation or minor wording.
* Returns null if too short (< 60 chars) to be a reliable signal.
*/ */
function descFingerprint(desc: string | null | undefined): string | null { function descFingerprint(desc: string | null | undefined): string | null {
if (!desc) return null; if (!desc) return null;
const n = norm(desc); const n = norm(desc);
if (n.length < 60) return null; return n.length >= 60 ? n.slice(0, 200) : null;
return n.slice(0, 200);
} }
/** /**
* Author fingerprint normalized concatenation of author + artist. * Normalized author + artist concatenation for tie-breaking.
* Used as a tie-breaker / additional signal alongside description. * Returns null if no author info available.
* Two manga with the same authors AND same description are almost certainly
* the same series. Returns null if no author info.
*/ */
function authorFingerprint(author?: string | null, artist?: string | null): string | null { function authorFingerprint(author?: string | null, artist?: string | null): string | null {
const parts = [author, artist].filter(Boolean).map(s => norm(s!)); const parts = [author, artist].filter(Boolean).map(s => norm(s!));
if (!parts.length) return null; return parts.length ? parts.sort().join("|") : null;
return parts.sort().join("|");
} }
/** /**
* Deduplicates manga by: * Deduplicates manga across sources using title, description, and author signals,
* 1. Normalized title * plus explicit user-defined links (settings.mangaLinks).
* 2. Description fingerprint (first 200 chars)
* 3. Author + description together
* 4. User-defined links (mangaLinks from store) explicit "same series" overrides
* *
* Pass `links` as `settings.mangaLinks` to honour user-registered pairs. * When two entries match, the better one is kept:
* When two entries match, the PREFERRED one is kept: * - Library membership wins over non-library.
* - Library membership wins * - Otherwise higher downloadCount wins.
* - Otherwise higher downloadCount wins * - Otherwise first occurrence wins.
* - Otherwise first occurrence wins
*/ */
export function dedupeMangaByTitle<T extends { export function dedupeMangaByTitle<T extends {
id: number; id: number;
@@ -196,7 +185,6 @@ export function dedupeMangaByTitle<T extends {
const byTitle = new Map<string, number>(); const byTitle = new Map<string, number>();
const byDesc = new Map<string, number>(); const byDesc = new Map<string, number>();
const byAuthorDesc = new Map<string, number>(); const byAuthorDesc = new Map<string, number>();
// id → index in out[]
const byId = new Map<number, number>(); const byId = new Map<number, number>();
const out: T[] = []; const out: T[] = [];
@@ -205,10 +193,8 @@ export function dedupeMangaByTitle<T extends {
const dk = descFingerprint(m.description); const dk = descFingerprint(m.description);
const ak = (dk && m.author) ? `${authorFingerprint(m.author, m.artist)}||${dk}` : null; const ak = (dk && m.author) ? `${authorFingerprint(m.author, m.artist)}||${dk}` : null;
// Check user-defined links first (explicit override)
const linkedIds = links[m.id] ?? []; const linkedIds = links[m.id] ?? [];
const linkedIdx = linkedIds.map(lid => byId.get(lid)).find(i => i !== undefined); const linkedIdx = linkedIds.map(lid => byId.get(lid)).find(i => i !== undefined);
const existingIdx = const existingIdx =
linkedIdx ?? linkedIdx ??
byTitle.get(tk) ?? byTitle.get(tk) ??
@@ -243,7 +229,7 @@ export function dedupeMangaByTitle<T extends {
} }
/** /**
* Deduplicates manga by id only (lossless). * Lossless deduplication by ID only. Preserves first occurrence.
*/ */
export function dedupeMangaById<T extends { id: number }>(items: T[]): T[] { export function dedupeMangaById<T extends { id: number }>(items: T[]): T[] {
const seen = new Set<number>(); const seen = new Set<number>();
@@ -1,7 +1,3 @@
/*
Moku Animations
*/
@keyframes fadeIn { @keyframes fadeIn {
from { opacity: 0; } from { opacity: 0; }
to { opacity: 1; } to { opacity: 1; }
@@ -37,7 +33,6 @@
to { background-position: 200% 0; } to { background-position: 200% 0; }
} }
/* Utility classes */
.anim-fade-in { animation: fadeIn 0.14s ease both; } .anim-fade-in { animation: fadeIn 0.14s ease both; }
.anim-fade-up { animation: fadeUp 0.18s ease both; } .anim-fade-up { animation: fadeUp 0.18s ease both; }
.anim-fade-down { animation: fadeDown 0.18s ease both; } .anim-fade-down { animation: fadeDown 0.18s ease both; }
@@ -45,14 +40,8 @@
.anim-pulse { animation: pulse 1.6s ease infinite; } .anim-pulse { animation: pulse 1.6s ease infinite; }
.anim-spin { animation: spin 0.7s linear infinite; } .anim-spin { animation: spin 0.7s linear infinite; }
/* Skeleton shimmer */
.skeleton { .skeleton {
background: linear-gradient( background: linear-gradient(90deg, var(--bg-raised) 25%, var(--bg-overlay) 50%, var(--bg-raised) 75%);
90deg,
var(--bg-raised) 25%,
var(--bg-overlay) 50%,
var(--bg-raised) 75%
);
background-size: 200% 100%; background-size: 200% 100%;
animation: shimmer 1.4s ease infinite; animation: shimmer 1.4s ease infinite;
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
+4
View File
@@ -0,0 +1,4 @@
@import "./reset.css";
@import "./animations.css";
@import "./scrollbars.css";
@import "./typography.css";
+41
View File
@@ -0,0 +1,41 @@
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html, body {
height: 100%;
overflow: hidden;
background: var(--bg-void);
color: var(--text-primary);
}
#app {
height: 100%;
}
button {
cursor: pointer;
font: inherit;
color: inherit;
background: none;
border: none;
padding: 0;
}
input, textarea, select {
font: inherit;
color: inherit;
}
a {
color: inherit;
text-decoration: none;
}
ul, ol { list-style: none; }
img, svg { display: block; max-width: 100%; }
p { margin: 0; }
+9
View File
@@ -0,0 +1,9 @@
* {
scrollbar-width: thin;
scrollbar-color: transparent transparent;
}
*::-webkit-scrollbar { width: 4px; height: 4px; }
*::-webkit-scrollbar-track { background: transparent; }
*::-webkit-scrollbar-thumb { background: transparent; border-radius: 99px; }
*::-webkit-scrollbar-thumb:hover { background: transparent; }
+9
View File
@@ -0,0 +1,9 @@
body {
font-family: var(--font-sans);
font-size: var(--text-base);
font-weight: var(--weight-normal);
line-height: var(--leading-base);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
}
+25
View File
@@ -0,0 +1,25 @@
[data-theme="high-contrast"] {
--bg-void: #000000;
--bg-base: #080808;
--bg-surface: #0d0d0d;
--bg-raised: #111111;
--bg-overlay: #171717;
--bg-subtle: #1e1e1e;
--border-dim: #252525;
--border-base: #303030;
--border-strong: #3e3e3e;
--border-focus: #5a7a5a;
--text-primary: #ffffff;
--text-secondary: #e8e6e0;
--text-muted: #b0aea8;
--text-faint: #6e6c68;
--text-disabled: #303030;
--accent: #7aaa7a;
--accent-dim: #2e4a2e;
--accent-muted: #1e2e1e;
--accent-fg: #bcd8bc;
--accent-bright: #9fcf9f;
}
+5
View File
@@ -0,0 +1,5 @@
@import "./high-contrast.css";
@import "./light-contrast.css";
@import "./light.css";
@import "./midnight.css";
@import "./warm.css";
+29
View File
@@ -0,0 +1,29 @@
[data-theme="light-contrast"] {
--bg-void: #d8d4ce;
--bg-base: #e2deda;
--bg-surface: #ece8e2;
--bg-raised: #f5f2ec;
--bg-overlay: #ffffff;
--bg-subtle: #e4e0d8;
--border-dim: #c4c0b8;
--border-base: #b0aca4;
--border-strong: #989490;
--border-focus: #3a5a3a;
--text-primary: #080806;
--text-secondary: #181612;
--text-muted: #38342e;
--text-faint: #706c64;
--text-disabled: #b0aca4;
--accent: #2a5a2a;
--accent-dim: #b0ccb0;
--accent-muted: #c8dcc8;
--accent-fg: #183818;
--accent-bright: #1e4e1e;
--color-error: #8a1a1a;
--color-error-bg: #f8e0e0;
--color-read: #e0dcd4;
}
+32
View File
@@ -0,0 +1,32 @@
[data-theme="light"] {
--bg-void: #e8e6e2;
--bg-base: #eeece8;
--bg-surface: #f4f2ee;
--bg-raised: #faf8f4;
--bg-overlay: #ffffff;
--bg-subtle: #f0ede8;
--border-dim: #dedad4;
--border-base: #d0ccc6;
--border-strong: #bbb6ae;
--border-focus: #5a7a5a;
--text-primary: #1a1916;
--text-secondary: #2e2c28;
--text-muted: #5a5750;
--text-faint: #9a9890;
--text-disabled: #c8c4bc;
--accent: #4a724a;
--accent-dim: #c8dcc8;
--accent-muted: #deeade;
--accent-fg: #2a5a2a;
--accent-bright: #3a6a3a;
--color-error: #a03030;
--color-error-bg: #fce8e8;
--color-success: #2a6a2a;
--color-info: #2a4a7a;
--color-info-bg: #e8eef8;
--color-read: #e8e4dc;
}
+25
View File
@@ -0,0 +1,25 @@
[data-theme="midnight"] {
--bg-void: #050810;
--bg-base: #080c18;
--bg-surface: #0c1020;
--bg-raised: #101428;
--bg-overlay: #151a30;
--bg-subtle: #1a2038;
--border-dim: #1a2035;
--border-base: #222840;
--border-strong: #2c3450;
--border-focus: #4a5c8a;
--text-primary: #eeeef8;
--text-secondary: #c0c4d8;
--text-muted: #808498;
--text-faint: #404860;
--text-disabled: #202840;
--accent: #6a7ab8;
--accent-dim: #252d50;
--accent-muted: #181e38;
--accent-fg: #a8b4e8;
--accent-bright: #8896d0;
}
+25
View File
@@ -0,0 +1,25 @@
[data-theme="warm"] {
--bg-void: #0c0a06;
--bg-base: #100e08;
--bg-surface: #16130c;
--bg-raised: #1c1810;
--bg-overlay: #221e14;
--bg-subtle: #28241a;
--border-dim: #201c10;
--border-base: #2c2818;
--border-strong: #3a3420;
--border-focus: #6a5a30;
--text-primary: #f5f0e0;
--text-secondary: #d8d0b0;
--text-muted: #988c60;
--text-faint: #584e30;
--text-disabled: #302a18;
--accent: #c0902a;
--accent-dim: #3a2c10;
--accent-muted: #261e0c;
--accent-fg: #e0b860;
--accent-bright: #d0a040;
}
+35
View File
@@ -0,0 +1,35 @@
:root {
--bg-void: #080808;
--bg-base: #0c0c0c;
--bg-surface: #101010;
--bg-raised: #151515;
--bg-overlay: #1a1a1a;
--bg-subtle: #202020;
--border-dim: #1c1c1c;
--border-base: #242424;
--border-strong: #2e2e2e;
--border-focus: #4a5c4a;
--text-primary: #f0efec;
--text-secondary: #c8c6c0;
--text-muted: #8a8880;
--text-faint: #4e4d4a;
--text-disabled: #2a2a28;
--accent: #6b8f6b;
--accent-dim: #2a3d2a;
--accent-muted: #1a251a;
--accent-fg: #a8c4a8;
--accent-bright: #8fb88f;
--color-error: #c47a7a;
--color-error-bg: #1f1212;
--color-success: #7aab7a;
--color-info: #7a9ec4;
--color-info-bg: #121a1f;
--color-read: #2e2e2c;
--dot-active: var(--accent);
--dot-inactive: var(--text-faint);
}
+8
View File
@@ -0,0 +1,8 @@
@import "./colors.css";
@import "./typography.css";
@import "./spacing.css";
@import "./radius.css";
@import "./motion.css";
@import "./shadows.css";
@import "./zindex.css";
@import "../themes/index.css";
+5
View File
@@ -0,0 +1,5 @@
:root {
--t-fast: 0.08s ease;
--t-base: 0.14s ease;
--t-slow: 0.22s ease;
}
+8
View File
@@ -0,0 +1,8 @@
:root {
--radius-sm: 3px;
--radius-md: 5px;
--radius-lg: 7px;
--radius-xl: 10px;
--radius-2xl: 14px;
--radius-full: 9999px;
}
+2
View File
@@ -0,0 +1,2 @@
:root {
}
+12
View File
@@ -0,0 +1,12 @@
:root {
--sp-1: 4px;
--sp-2: 8px;
--sp-3: 12px;
--sp-4: 16px;
--sp-5: 20px;
--sp-6: 24px;
--sp-8: 32px;
--sp-10: 40px;
--sidebar-width: 52px;
}
+28
View File
@@ -0,0 +1,28 @@
:root {
--font-ui: "DM Mono", "Fira Mono", ui-monospace, monospace;
--font-sans: "DM Sans", ui-sans-serif, system-ui, sans-serif;
--text-2xs: 10px;
--text-xs: 11px;
--text-sm: 12px;
--text-base: 13px;
--text-md: 14px;
--text-lg: 15px;
--text-xl: 17px;
--text-2xl: 20px;
--text-3xl: 24px;
--weight-normal: 400;
--weight-medium: 500;
--weight-semi: 600;
--leading-none: 1;
--leading-tight: 1.3;
--leading-snug: 1.45;
--leading-base: 1.6;
--tracking-tight: -0.02em;
--tracking-normal: 0;
--tracking-wide: 0.06em;
--tracking-wider: 0.1em;
}
+5
View File
@@ -0,0 +1,5 @@
:root {
--z-reader: 50;
--z-modal: 100;
--z-settings: 150;
}
View File
View File
View File
@@ -1,34 +1,20 @@
<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 } from "../../lib/client"; import { gql } from "@api/client";
import Thumbnail from "../shared/Thumbnail.svelte"; import Thumbnail from "@shared/manga/Thumbnail.svelte";
import { GET_ALL_MANGA, GET_LIBRARY, GET_SOURCES, FETCH_SOURCE_MANGA, UPDATE_MANGA, GET_CATEGORIES, CREATE_CATEGORY, UPDATE_MANGA_CATEGORIES } from "../../lib/queries"; import { GET_ALL_MANGA, GET_LIBRARY, GET_SOURCES, GET_CATEGORIES } from "@api/queries";
import { cache, CACHE_KEYS, getPageSet } from "../../lib/cache"; import { FETCH_SOURCE_MANGA, UPDATE_MANGA, CREATE_CATEGORY, UPDATE_MANGA_CATEGORIES } from "@api/mutations";
import { dedupeSources, dedupeMangaById, shouldHideNsfw } from "../../lib/util"; import { cache, CACHE_KEYS, getPageSet } from "@core/cache";
import { store, setGenreFilter, setPreviewManga, setNavPage } from "../../store/state.svelte"; import { dedupeSources, dedupeMangaById, shouldHideNsfw } from "@core/util";
import type { Manga, Source, Category } from "../../lib/types"; import { store, setGenreFilter, setPreviewManga, setNavPage } from "@store/state.svelte";
import ContextMenu, { type MenuEntry } from "../shared/ContextMenu.svelte"; import type { Manga, Source, Category } from "@types/index";
import ContextMenu, { type MenuEntry } from "@shared/ui/ContextMenu.svelte";
import {
PAGE_SIZE, INITIAL_PAGES, MAX_SOURCES,
parseTags, tagsLabel, matchesAllTags, runConcurrent,
} from "@features/discover/lib/searchFilter";
const PAGE_SIZE = 50;
const INITIAL_PAGES = 3;
const MAX_SOURCES = 12;
const CONCURRENCY = 4;
function parseTags(f: string): string[] { return f.split("+").map((t) => t.trim()).filter(Boolean); }
function tagsLabel(tags: string[]): string {
if (tags.length === 1) return tags[0];
return tags.slice(0, -1).join(", ") + " & " + tags[tags.length - 1];
}
function matchesAllTags(m: Manga, tags: string[]): boolean {
const g = (m.genre ?? []).map((x) => x.toLowerCase());
return tags.every((t) => g.includes(t.toLowerCase()));
}
async function runConcurrent<T>(items: T[], fn: (item: T) => Promise<void>, signal: AbortSignal): Promise<void> {
let i = 0;
async function worker() { while (i < items.length) { if (signal.aborted) return; await fn(items[i++]).catch(() => {}); } }
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, items.length) }, worker));
}
const prevNavPage = store.navPage; const prevNavPage = store.navPage;
const tags = $derived(parseTags(store.genreFilter)); const tags = $derived(parseTags(store.genreFilter));
const primaryTag = $derived(tags[0] ?? ""); const primaryTag = $derived(tags[0] ?? "");
@@ -52,10 +38,12 @@
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) && !shouldHideNsfw(m, store.settings))]); 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);
const hasMoreNetwork = $derived(sources.some((s) => (nextPageMap.get(s.id) ?? -1) > 0)); const hasMoreNetwork = $derived(sources.some((s) => (nextPageMap.get(s.id) ?? -1) > 0));
const hasMore = $derived(hasMoreVisible || hasMoreNetwork); const hasMore = $derived(hasMoreVisible || hasMoreNetwork);
$effect(() => { const f = store.genreFilter; if (f) untrack(() => load(f)); }); $effect(() => { const f = store.genreFilter; if (f) untrack(() => load(f)); });
async function load(filter: string) { async function load(filter: string) {
@@ -73,12 +61,18 @@
const pt = t[0] ?? ""; const pt = t[0] ?? "";
cache.get(CACHE_KEYS.LIBRARY, () => cache.get(CACHE_KEYS.LIBRARY, () =>
Promise.all([gql<{ mangas: { nodes: Manga[] } }>(GET_ALL_MANGA), gql<{ mangas: { nodes: Manga[] } }>(GET_LIBRARY)]) Promise.all([
.then(([all, lib]) => { const m = new Map(lib.mangas.nodes.map((x) => [x.id, x])); return all.mangas.nodes.map((x) => m.get(x.id) ?? x); }) gql<{ mangas: { nodes: Manga[] } }>(GET_ALL_MANGA),
gql<{ mangas: { nodes: Manga[] } }>(GET_LIBRARY),
]).then(([all, lib]) => {
const m = new Map(lib.mangas.nodes.map((x) => [x.id, x]));
return all.mangas.nodes.map((x) => m.get(x.id) ?? x);
}),
).then((manga) => { if (!ctrl.signal.aborted) libraryManga = manga; }).catch(() => {}); ).then((manga) => { if (!ctrl.signal.aborted) libraryManga = manga; }).catch(() => {});
cache.get(CACHE_KEYS.SOURCES, () => cache.get(
gql<{ sources: { nodes: Source[] } }>(GET_SOURCES) CACHE_KEYS.SOURCES,
() => gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
.then((d) => dedupeSources(d.sources.nodes.filter((s) => s.id !== "0"), preferredLang)), .then((d) => dedupeSources(d.sources.nodes.filter((s) => s.id !== "0"), preferredLang)),
Infinity, Infinity,
).then(async (allSources) => { ).then(async (allSources) => {
@@ -95,8 +89,9 @@
const pageKey = CACHE_KEYS.sourceMangaPage(src.id, "SEARCH", page, t); const pageKey = CACHE_KEYS.sourceMangaPage(src.id, "SEARCH", page, t);
const result = await cache.get<{ mangas: Manga[]; hasNextPage: boolean }>( const result = await cache.get<{ mangas: Manga[]; hasNextPage: boolean }>(
pageKey, pageKey,
() => gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>(FETCH_SOURCE_MANGA, { source: src.id, type: "SEARCH", page, query: pt }, ctrl.signal) () => gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>(
.then((d) => d.fetchSourceManga), FETCH_SOURCE_MANGA, { source: src.id, type: "SEARCH", page, query: pt }, ctrl.signal,
).then((d) => d.fetchSourceManga),
).catch(() => null); ).catch(() => null);
if (!result || ctrl.signal.aborted) break; if (!result || ctrl.signal.aborted) break;
ps.add(page); ps.add(page);
@@ -132,8 +127,9 @@
const pageKey = CACHE_KEYS.sourceMangaPage(src.id, "SEARCH", page, tags); const pageKey = CACHE_KEYS.sourceMangaPage(src.id, "SEARCH", page, tags);
const result = await cache.get<{ mangas: Manga[]; hasNextPage: boolean }>( const result = await cache.get<{ mangas: Manga[]; hasNextPage: boolean }>(
pageKey, pageKey,
() => gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>(FETCH_SOURCE_MANGA, { source: src.id, type: "SEARCH", page, query: primaryTag }, ctrl.signal) () => gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>(
.then((d) => d.fetchSourceManga), FETCH_SOURCE_MANGA, { source: src.id, type: "SEARCH", page, query: primaryTag }, ctrl.signal,
).then((d) => d.fetchSourceManga),
).catch(() => { nextPageMap.set(src.id, -1); return null; }); ).catch(() => { nextPageMap.set(src.id, -1); return null; });
if (!result || ctrl.signal.aborted) return; if (!result || ctrl.signal.aborted) return;
ps.add(page); ps.add(page);
@@ -152,36 +148,49 @@
if (!catsLoaded) { if (!catsLoaded) {
catsLoaded = true; catsLoaded = true;
gql<{ categories: { nodes: Category[] } }>(GET_CATEGORIES) gql<{ categories: { nodes: Category[] } }>(GET_CATEGORIES)
.then(d => { categories = d.categories.nodes.filter(c => c.id !== 0); }) .then((d) => { categories = d.categories.nodes.filter((c) => c.id !== 0); })
.catch(console.error); .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, {
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) }, 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),
},
...(categories.length > 0 ? [ ...(categories.length > 0 ? [
{ separator: true } as MenuEntry, { separator: true } as MenuEntry,
...categories.map((cat): MenuEntry => ({ ...categories.map((cat): MenuEntry => ({
label: (cat.mangas?.nodes ?? []).some(x => x.id === m.id) ? `✓ ${cat.name}` : cat.name, icon: Folder, label: (cat.mangas?.nodes ?? []).some((x) => x.id === m.id) ? `✓ ${cat.name}` : cat.name,
icon: Folder,
onClick: () => gql(UPDATE_MANGA_CATEGORIES, { mangaId: m.id, addTo: [cat.id], removeFrom: [] }).catch(console.error), 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: async () => { {
label: "New folder & add",
icon: FolderSimplePlus,
onClick: async () => {
const name = prompt("Folder name:"); const name = prompt("Folder name:");
if (!name?.trim()) return; if (!name?.trim()) return;
const res = await gql<{ createCategory: { category: Category } }>( const res = await gql<{ createCategory: { category: Category } }>(
CREATE_CATEGORY, CREATE_CATEGORY, { name: name.trim() },
{ name: name.trim() }
).catch(console.error); ).catch(console.error);
if (res) { if (res) {
const cat = (res as any).createCategory.category; const cat = res.createCategory.category;
categories = [...categories, cat]; categories = [...categories, cat];
await gql(UPDATE_MANGA_CATEGORIES, { mangaId: m.id, addTo: [cat.id], removeFrom: [] }).catch(console.error); await gql(UPDATE_MANGA_CATEGORIES, { mangaId: m.id, addTo: [cat.id], removeFrom: [] }).catch(console.error);
} }
}}, },
},
]; ];
} }
@@ -215,10 +224,10 @@
<div class="empty">No manga found for "{label}".</div> <div class="empty">No manga found for "{label}".</div>
{:else} {:else}
<div class="grid"> <div class="grid">
{#each visibleItems as m (m.id)} {#each visibleItems as m, i (m.id)}
<button class="card" onclick={() => setPreviewManga(m)} oncontextmenu={(e) => { e.stopPropagation(); openCtx(e, m); }}> <button class="card" onclick={() => setPreviewManga(m)} oncontextmenu={(e) => { e.stopPropagation(); openCtx(e, m); }}>
<div class="cover-wrap"> <div class="cover-wrap">
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="cover" /> <Thumbnail src={m.thumbnailUrl} alt={m.title} class="cover" priority={i < 12 ? 12 - i : 0} />
{#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>
@@ -262,4 +271,5 @@
.show-more-btn { display: flex; align-items: center; gap: var(--sp-2); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 7px 20px; border-radius: var(--radius-md); background: var(--bg-raised); color: var(--text-muted); border: 1px solid var(--border-dim); cursor: pointer; transition: color var(--t-base), border-color var(--t-base); } .show-more-btn { display: flex; align-items: center; gap: var(--sp-2); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 7px 20px; border-radius: var(--radius-md); background: var(--bg-raised); color: var(--text-muted); border: 1px solid var(--border-dim); cursor: pointer; transition: color var(--t-base), border-color var(--t-base); }
.show-more-btn:hover:not(:disabled) { color: var(--text-secondary); border-color: var(--border-strong); } .show-more-btn:hover:not(:disabled) { color: var(--text-secondary); border-color: var(--border-strong); }
.show-more-btn:disabled { opacity: 0.5; cursor: default; } .show-more-btn:disabled { opacity: 0.5; cursor: default; }
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
</style> </style>
@@ -0,0 +1,330 @@
<script lang="ts">
import { onDestroy } from "svelte";
import { gql } from "@api/client";
import { FETCH_SOURCE_MANGA } from "@api/mutations/downloads";
import { runConcurrent } from "@core/async/batchRequests";
import { shouldHideNsfw, shouldHideSource, dedupeMangaById, dedupeMangaByTitle } from "@core/util";
import { store } from "@store/state.svelte";
import Thumbnail from "@shared/manga/Thumbnail.svelte";
import type { Manga, Source } from "@types";
interface Props {
allSources: Source[];
availableLangs: string[];
hasMultipleLangs: boolean;
loadingSources: boolean;
pendingPrefill: string;
popularResults: (Manga & { _priority: number })[];
popularLoading: boolean;
onPrefillConsumed: () => void;
onPreview: (m: Manga) => void;
}
let {
allSources, availableLangs, hasMultipleLangs, loadingSources,
pendingPrefill, popularResults, popularLoading,
onPrefillConsumed, onPreview,
}: Props = $props();
const preferredLang = store.settings?.preferredExtensionLang ?? "en";
let kw_query = $state("");
let kw_results: SourceResult[] = $state([]);
let kw_showAdvanced = $state(false);
let kw_selectedLangs: Set<string> = $state(new Set());
let kw_inputEl: HTMLInputElement | null = $state(null);
let kw_abortCtrl: AbortController | null = null;
let kw_debounceTimer: ReturnType<typeof setTimeout> | null = null;
interface SourceResult {
source: Source;
mangas: Manga[];
loading: boolean;
error: string | null;
}
$effect(() => {
if (allSources.length) {
const available = new Set(allSources.map((s) => s.lang));
kw_selectedLangs = available.has(preferredLang)
? new Set([preferredLang])
: new Set(availableLangs.slice(0, 1));
}
});
$effect(() => {
if (!loadingSources && pendingPrefill && allSources.length) {
const q = pendingPrefill;
onPrefillConsumed();
kw_query = q;
kwDoSearch(q);
}
});
$effect(() => {
const q = kw_query;
if (kw_debounceTimer) clearTimeout(kw_debounceTimer);
if (!q.trim()) { kw_abortCtrl?.abort(); kw_results = []; return; }
kw_debounceTimer = setTimeout(() => kwDoSearch(q), 350);
return () => { if (kw_debounceTimer) clearTimeout(kw_debounceTimer); };
});
function kwGetVisibleSources(): Source[] {
let filtered = allSources;
if (kw_selectedLangs.size > 0)
filtered = filtered.filter((s) => kw_selectedLangs.has(s.lang));
if (!store.settings.showNsfw)
filtered = filtered.filter((s) => !shouldHideSource(s, store.settings));
return filtered;
}
async function kwDoSearch(q: string) {
const trimmed = q.trim();
if (!trimmed) return;
const visible = kwGetVisibleSources();
if (!visible.length) return;
kw_abortCtrl?.abort();
const ctrl = new AbortController();
kw_abortCtrl = ctrl;
const initial: SourceResult[] = visible.map((src) => ({ source: src, mangas: [], loading: true, error: null }));
kw_results = initial;
const indexBySrcId = new Map(visible.map((src, i) => [src.id, i]));
await runConcurrent(visible, async (src) => {
if (ctrl.signal.aborted) return;
const idx = indexBySrcId.get(src.id)!;
try {
const d = await gql<{ fetchSourceManga: { mangas: Manga[] } }>(
FETCH_SOURCE_MANGA,
{ source: src.id, type: "SEARCH", page: 1, query: trimmed },
ctrl.signal,
);
if (ctrl.signal.aborted) return;
const mangas = d.fetchSourceManga.mangas.filter((m) => !shouldHideNsfw(m, store.settings));
const next = [...kw_results];
next[idx] = { ...next[idx], mangas, loading: false };
kw_results = next;
} catch (e: any) {
if (ctrl.signal.aborted || e?.name === "AbortError") return;
const next = [...kw_results];
next[idx] = { ...next[idx], loading: false, error: (e as any).message ?? "Error" };
kw_results = next;
}
}, ctrl.signal);
}
function kwToggleLang(lang: string) {
const next = new Set(kw_selectedLangs);
if (next.has(lang)) { if (next.size === 1) return; next.delete(lang); }
else next.add(lang);
kw_selectedLangs = next;
}
const kw_visibleCount = $derived(kwGetVisibleSources().length);
const kw_hasResults = $derived(kw_results.some((r) => r.mangas.length > 0));
const kw_allDone = $derived(kw_results.length > 0 && kw_results.every((r) => !r.loading));
const kw_anyLoading = $derived(kw_results.some((r) => r.loading));
const kw_flatResults = $derived.by(() => {
const all = kw_results.flatMap((r) =>
r.mangas.map((m) => ({ ...m, _sourceName: r.source.displayName }))
);
const deduped = dedupeMangaByTitle(dedupeMangaById(all), store.settings.mangaLinks) as (Manga & { _sourceName?: string; _priority: number })[];
return deduped.map((m, i) => ({ ...m, _priority: i < 12 ? 12 - i : 0 }));
});
onDestroy(() => {
kw_abortCtrl?.abort();
if (kw_debounceTimer) clearTimeout(kw_debounceTimer);
});
</script>
<div class="keywordBar">
<div class="searchBar">
<svg width="14" height="14" viewBox="0 0 256 256" fill="currentColor" class="searchIcon" aria-hidden="true">
<path d="M229.66,218.34l-50.07-50.07a88,88,0,1,0-11.31,11.31l50.06,50.07a8,8,0,0,0,11.32-11.31ZM40,112a72,72,0,1,1,72,72A72.08,72.08,0,0,1,40,112Z"/>
</svg>
<input
bind:this={kw_inputEl}
bind:value={kw_query}
class="searchInput"
placeholder="Search across sources…"
use:focusOnMount
/>
{#if kw_anyLoading}
<svg width="13" height="13" viewBox="0 0 256 256" fill="currentColor" class="anim-spin" style="color:var(--text-faint);flex-shrink:0" aria-hidden="true">
<path d="M232,128a104,104,0,0,1-208,0c0-41,23.81-78.36,60.66-95.27a8,8,0,0,1,6.68,14.54C60.15,61.59,40,93.27,40,128a88,88,0,0,0,176,0c0-34.73-20.15-66.41-51.34-80.73a8,8,0,0,1,6.68-14.54C208.19,49.64,232,87,232,128Z"/>
</svg>
{:else if kw_query}
<button class="clearBtn" title="Clear" onclick={() => { kw_query = ""; kw_results = []; kw_inputEl?.focus(); }}>×</button>
{/if}
{#if hasMultipleLangs}
<button
class="advancedBtn"
class:advancedBtnActive={kw_showAdvanced}
title="Language & filter options"
onclick={() => (kw_showAdvanced = !kw_showAdvanced)}
>
<svg width="13" height="13" viewBox="0 0 256 256" fill="currentColor" aria-hidden="true">
<path d="M40,88H73a32,32,0,0,0,62,0h81a8,8,0,0,0,0-16H135a32,32,0,0,0-62,0H40a8,8,0,0,0,0,16Zm64-24A16,16,0,1,1,88,80,16,16,0,0,1,104,64ZM216,168H183a32,32,0,0,0-62,0H40a8,8,0,0,0,0,16h81a32,32,0,0,0,62,0h33a8,8,0,0,0,0-16Zm-64,24a16,16,0,1,1,16-16A16,16,0,0,1,152,192Z"/>
</svg>
</button>
{/if}
</div>
{#if hasMultipleLangs && kw_showAdvanced}
<div class="advancedPanel">
<div class="advancedHeader">
<span class="advancedTitle">Languages</span>
<div class="advancedActions">
<button class="advancedLink" onclick={() => (kw_selectedLangs = new Set(availableLangs))}>All</button>
<button class="advancedLink" onclick={() => (kw_selectedLangs = new Set([preferredLang]))}>Reset</button>
</div>
</div>
<div class="langGrid">
{#each availableLangs as lang (lang)}
<button class="langChip" class:langChipActive={kw_selectedLangs.has(lang)} onclick={() => kwToggleLang(lang)}>
{lang === preferredLang ? `${lang.toUpperCase()} ` : lang.toUpperCase()}
</button>
{/each}
</div>
<div class="advancedDivider"></div>
<div class="advancedFooter">
Searching <strong>{kw_visibleCount}</strong> source{kw_visibleCount !== 1 ? "s" : ""}
</div>
</div>
{/if}
</div>
{#if !kw_query.trim()}
{#if popularLoading && popularResults.length === 0}
<div class="searchGrid">
{#each Array(24) as _, i (i)}<div class="skCard"><div class="skeleton skCover"></div></div>{/each}
</div>
{:else if popularResults.length > 0}
<div class="searchHeader">
<span class="searchLabel">Popular right now</span>
</div>
<div class="searchGrid">
{#each popularResults as m (m.id)}
<button class="srchCard" onclick={() => onPreview(m)}>
<div class="srchCoverWrap">
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="cover" priority={m._priority} />
<div class="srchGradient"></div>
{#if m.inLibrary}<span class="inLibBadge">Saved</span>{/if}
<div class="srchFooter">
<p class="srchTitle">{m.title}</p>
{#if m.source?.displayName}<p class="srchSource">{m.source.displayName}</p>{/if}
</div>
</div>
</button>
{/each}
{#if popularLoading}
{#each Array(12) as _, i (i)}<div class="skCard"><div class="skeleton skCover"></div></div>{/each}
{/if}
</div>
{:else}
<div class="empty">
<svg width="36" height="36" viewBox="0 0 256 256" fill="currentColor" class="emptyIcon" aria-hidden="true">
<path d="M229.66,218.34l-50.07-50.07a88,88,0,1,0-11.31,11.31l50.06,50.07a8,8,0,0,0,11.32-11.31ZM40,112a72,72,0,1,1,72,72A72.08,72.08,0,0,1,40,112Z"/>
</svg>
<p class="emptyText">Search across sources</p>
<p class="emptyHint">
{#if hasMultipleLangs}
{kw_visibleCount} source{kw_visibleCount !== 1 ? "s" : ""} · {kw_selectedLangs.size} language{kw_selectedLangs.size !== 1 ? "s" : ""}
{:else}
{kw_visibleCount} source{kw_visibleCount !== 1 ? "s" : ""}
{/if}
</p>
</div>
{/if}
{:else}
{#if kw_flatResults.length > 0}
<div class="searchHeader">
<span class="searchLabel">{kw_flatResults.length} result{kw_flatResults.length !== 1 ? "s" : ""}</span>
</div>
<div class="searchGrid">
{#each kw_flatResults as m (m.id)}
<button class="srchCard" onclick={() => onPreview(m)}>
<div class="srchCoverWrap">
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="cover" priority={m._priority} />
<div class="srchGradient"></div>
{#if m.inLibrary}<span class="inLibBadge">Saved</span>{/if}
<div class="srchFooter">
<p class="srchTitle">{m.title}</p>
{#if (m as any)._sourceName}<p class="srchSource">{(m as any)._sourceName}</p>{/if}
</div>
</div>
</button>
{/each}
{#if kw_anyLoading}
{#each Array(6) as _, i (i)}<div class="skCard"><div class="skeleton skCover"></div></div>{/each}
{/if}
</div>
{:else if kw_anyLoading}
<div class="searchGrid">
{#each Array(12) as _, i (i)}<div class="skCard"><div class="skeleton skCover"></div></div>{/each}
</div>
{:else if kw_allDone && !kw_hasResults}
<div class="empty">
<p class="emptyText">No results for "{kw_query.trim()}"</p>
<p class="emptyHint">Try a different spelling or fewer words</p>
</div>
{/if}
{/if}
<script module>
function focusOnMount(node: HTMLElement) { node.focus(); }
</script>
<style>
.keywordBar { padding: var(--sp-3) var(--sp-4) var(--sp-2); flex-shrink: 0; display: flex; flex-direction: column; gap: var(--sp-2); }
.searchBar { display: flex; align-items: center; gap: var(--sp-2); background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-lg); padding: var(--sp-2) var(--sp-3); transition: border-color var(--t-base); }
.searchBar:focus-within { border-color: var(--border-strong); }
.searchIcon { color: var(--text-faint); flex-shrink: 0; }
.searchInput { flex: 1; background: none; border: none; outline: none; font-size: var(--text-sm); color: var(--text-primary); min-width: 0; }
.searchInput::placeholder { color: var(--text-faint); }
.clearBtn { color: var(--text-faint); font-size: 16px; line-height: 1; background: none; border: none; cursor: pointer; padding: 2px; transition: color var(--t-base); }
.clearBtn:hover { color: var(--text-muted); }
.advancedBtn { display: flex; align-items: center; padding: 4px; border-radius: var(--radius-sm); border: 1px solid transparent; background: none; color: var(--text-faint); cursor: pointer; transition: color var(--t-base), background var(--t-base), border-color var(--t-base); }
.advancedBtn:hover { color: var(--text-muted); background: var(--bg-overlay); }
.advancedBtnActive { color: var(--accent-fg); background: var(--accent-muted); border-color: var(--accent-dim); }
.advancedPanel { background: var(--bg-surface); border: 1px solid var(--border-dim); border-radius: var(--radius-lg); padding: var(--sp-3); display: flex; flex-direction: column; gap: var(--sp-2); }
.advancedHeader { display: flex; align-items: center; justify-content: space-between; }
.advancedTitle { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); letter-spacing: var(--tracking-wide); }
.advancedActions { display: flex; gap: var(--sp-2); }
.advancedLink { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--accent-fg); background: none; border: none; cursor: pointer; padding: 0; transition: opacity var(--t-base); }
.advancedLink:hover { opacity: 0.75; }
.langGrid { display: flex; flex-wrap: wrap; gap: var(--sp-1); }
.langChip { padding: 3px 8px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: none; font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--text-faint); cursor: pointer; transition: color var(--t-base), background var(--t-base), border-color var(--t-base); }
.langChip:hover { color: var(--text-muted); background: var(--bg-raised); }
.langChipActive { color: var(--accent-fg); background: var(--accent-muted); border-color: var(--accent-dim); }
.advancedDivider { height: 1px; background: var(--border-dim); }
.advancedFooter { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); }
.searchHeader { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-3) var(--sp-4) var(--sp-1); flex-shrink: 0; }
.searchLabel { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; }
.searchGrid { display: grid; grid-template-columns: repeat(auto-fill, minmax(clamp(90px, 11vw, 130px), 1fr)); gap: var(--sp-2); padding: var(--sp-2) var(--sp-4) var(--sp-6); overflow-y: auto; flex: 1; align-content: start; will-change: scroll-position; }
.srchCard { background: none; border: none; padding: 0; cursor: pointer; text-align: left; }
.srchCard:hover .srchCoverWrap { filter: brightness(1.08) saturate(1.05); }
.srchCoverWrap { position: relative; aspect-ratio: 2/3; overflow: hidden; border-radius: var(--radius-md); background: var(--bg-raised); border: 1px solid var(--border-dim); transform: translateZ(0); transition: filter var(--t-base); contain: layout style; }
.srchGradient { position: absolute; inset: 0; background: linear-gradient(to top, rgba(0,0,0,0.82) 0%, rgba(0,0,0,0.15) 50%, transparent 72%); pointer-events: none; }
.srchFooter { position: absolute; bottom: 0; left: 0; right: 0; padding: var(--sp-2); pointer-events: none; }
.srchTitle { font-size: var(--text-xs); font-weight: var(--weight-medium); color: rgba(255,255,255,0.92); line-height: var(--leading-snug); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; text-shadow: 0 1px 4px rgba(0,0,0,0.7); }
.srchSource { font-family: var(--font-ui); font-size: 9px; color: rgba(255,255,255,0.45); letter-spacing: var(--tracking-wide); margin-top: 1px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.inLibBadge { position: absolute; top: var(--sp-2); left: var(--sp-2); font-family: var(--font-ui); font-size: 9px; letter-spacing: var(--tracking-wide); background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim); border-radius: var(--radius-sm); padding: 1px 5px; }
.skCard { display: flex; flex-direction: column; gap: var(--sp-2); flex-shrink: 0; width: 100%; }
@keyframes shimmer { from { background-position: -200% 0 } to { background-position: 200% 0 } }
.skeleton { border-radius: var(--radius-sm); background: linear-gradient(90deg, var(--bg-raised) 25%, var(--bg-overlay, color-mix(in srgb, var(--bg-raised) 80%, var(--text-primary) 6%)) 50%, var(--bg-raised) 75%); background-size: 200% 100%; animation: shimmer 1.6s ease-in-out infinite; }
.skCover { aspect-ratio: 2 / 3; width: 100%; border-radius: var(--radius-md); }
.empty { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: var(--sp-2); padding: var(--sp-8); }
.emptyIcon { color: var(--text-faint); opacity: 0.5; }
.emptyText { font-size: var(--text-sm); color: var(--text-muted); font-weight: var(--weight-medium); margin: 0; }
.emptyHint { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); margin: 0; }
@keyframes anim-spin { from { transform: rotate(0deg) } to { transform: rotate(360deg) } }
.anim-spin { animation: anim-spin 0.8s linear infinite; }
</style>
@@ -0,0 +1,324 @@
<script lang="ts">
import { onDestroy, untrack } from "svelte";
import { gql } from "@api/client";
import { GET_SOURCES } from "@api/queries/extensions";
import { FETCH_SOURCE_MANGA } from "@api/mutations/downloads";
import { FETCH_MANGA } from "@api/mutations/manga";
import { runConcurrent } from "@core/async/batchRequests";
import { deprioritizeQueue } from "@core/cache/imageCache";
import { dedupeSourcesByLang }from "@core/algorithms/filter";
import { shouldHideNsfw } from "@core/util";
import { store, setSearchPrefill, setPreviewManga } from "@store/state.svelte";
import {
toCachedManga,
type CachedManga,
} from "@features/discover/lib/searchFilter";
import type { Manga, Source } from "@types";
import KeywordTab from "./KeywordTab.svelte";
import TagTab from "./TagTab.svelte";
import SourceTab from "./SourceTab.svelte";
const anims = $derived(store.settings.qolAnimations ?? true);
const TABS = ["keyword", "tag", "source"] as const;
let tabsEl = $state<HTMLDivElement | undefined>(undefined);
let tabIndicator = $state({ left: 0, width: 0 });
function updateIndicator() {
if (!tabsEl) return;
const active = tabsEl.querySelector<HTMLElement>(".tab.tabActive");
if (!active) return;
tabIndicator = { left: active.offsetLeft, width: active.offsetWidth };
}
$effect(() => { tab; if (anims) requestAnimationFrame(() => requestAnimationFrame(updateIndicator)); });
const SEARCH_PAGES = 3;
const SEARCH_LIMIT = 200;
const SEARCH_BATCH = 20;
const POPULAR_CACHE_PAGES = 3;
type SearchTab = "keyword" | "tag" | "source";
let tab: SearchTab = $state("keyword");
let pendingPrefill = $state("");
$effect(() => {
if (store.searchPrefill) {
const prefill = store.searchPrefill;
untrack(() => {
pendingPrefill = prefill;
tab = "keyword";
setSearchPrefill("");
});
}
});
let allSources: Source[] = $state([]);
let localSource: Source | null = $state(null);
let loadingSources = $state(false);
const preferredLang = store.settings?.preferredExtensionLang ?? "en";
const availableLangs = $derived(Array.from(new Set<string>(allSources.map((s) => s.lang))).sort());
const hasMultipleLangs = $derived(availableLangs.length > 1);
loadingSources = true;
gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
.then((d) => {
const nodes = d.sources.nodes;
localSource = nodes.find((src: Source) => src.id === "0") ?? null;
allSources = nodes.filter((src: Source) => src.id !== "0");
startSourceCacheBuild();
popularStart(allSources);
})
.catch(console.error)
.finally(() => { loadingSources = false; });
let popular_raw: Manga[] = $state([]);
let popular_loading = $state(false);
let popular_moreLoading = $state(false);
let popular_abortCtrl: AbortController | null = null;
let popular_sourcePool: Source[] = $state([]);
let popular_sourceCursor = $state(0);
let popular_hasMore = $state(false);
let popular_seenIds = new Set<number>();
let popular_seenTitles = new Set<string>();
const popular_results: (Manga & { _priority: number })[] = $derived(
popular_raw.map((m, i) => ({ ...m, _priority: Math.max(0, 50 - i) }))
);
function popular_push(incoming: Manga[]) {
const toAdd: Manga[] = [];
for (const m of incoming) {
if (shouldHideNsfw(m, store.settings)) continue;
if (popular_seenIds.has(m.id)) continue;
const norm = m.title.toLowerCase().replace(/[^a-z0-9\s]/g, " ").trim();
if (popular_seenTitles.has(norm)) continue;
popular_seenIds.add(m.id);
popular_seenTitles.add(norm);
toAdd.push(m);
}
if (!toAdd.length) return;
popular_raw = [...popular_raw, ...toAdd].slice(0, SEARCH_LIMIT);
}
async function popular_fanOut(signal: AbortSignal) {
const batch = popular_sourcePool.slice(popular_sourceCursor, popular_sourceCursor + SEARCH_BATCH);
if (!batch.length) { popular_hasMore = false; return; }
await runConcurrent(batch, async (src) => {
for (let page = 1; page <= SEARCH_PAGES; page++) {
if (signal.aborted) return;
const key = `${src.id}|POPULAR|All:p${page}`;
let mangas: Manga[];
if (store.searchCache?.has(key)) {
mangas = store.searchCache.get(key)!;
} else {
const result = await gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>(
FETCH_SOURCE_MANGA,
{ source: src.id, type: "POPULAR", page, query: null },
signal,
).then((d) => d.fetchSourceManga).catch(() => null);
if (!result || signal.aborted) break;
mangas = result.mangas;
store.searchCache?.set(key, mangas);
if (!result.hasNextPage) { popular_push(mangas); break; }
}
popular_push(mangas);
}
}, signal);
popular_sourceCursor += batch.length;
popular_hasMore = popular_sourceCursor < popular_sourcePool.length;
}
function popularStart(sources: Source[]) {
if (popular_raw.length > 0) return;
popular_abortCtrl?.abort();
const ctrl = new AbortController();
popular_abortCtrl = ctrl;
popular_seenIds.clear();
popular_seenTitles.clear();
popular_raw = [];
popular_sourcePool = dedupeSourcesByLang(sources, preferredLang, store.settings, true);
popular_sourceCursor = 0;
popular_hasMore = false;
popular_moreLoading = false;
popular_loading = true;
(async () => {
try {
while (!ctrl.signal.aborted && popular_sourceCursor < popular_sourcePool.length) {
await popular_fanOut(ctrl.signal);
}
} catch {}
if (!ctrl.signal.aborted) popular_loading = false;
})();
}
export const sourceCache = new Map<number, CachedManga>();
let sourceCacheReady = $state(false);
let sourceCacheLoading = $state(false);
let sourceCacheEnriching = $state(false);
let sourceCacheAbort: AbortController | null = null;
async function buildSourceCache(sources: Source[], signal: AbortSignal) {
const tasks: { src: Source; page: number }[] = [];
for (const src of sources) {
for (let p = 1; p <= POPULAR_CACHE_PAGES; p++) tasks.push({ src, page: p });
}
await runConcurrent(tasks, async ({ src, page }) => {
if (signal.aborted) return;
try {
const cacheKey = `${src.id}|POPULAR|All:p${page}`;
let mangas: Manga[];
if (store.searchCache?.has(cacheKey)) {
mangas = store.searchCache.get(cacheKey)!;
} else {
const d = await gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>(
FETCH_SOURCE_MANGA,
{ source: src.id, type: "POPULAR", page },
signal,
);
if (signal.aborted) return;
mangas = d.fetchSourceManga.mangas;
store.searchCache?.set(cacheKey, mangas);
}
for (const m of mangas) {
if (!sourceCache.has(m.id)) sourceCache.set(m.id, toCachedManga(m as any, src.id));
}
} catch (e: any) {
if (e?.name === "AbortError") return;
}
}, signal);
}
async function enrichGenres(signal: AbortSignal) {
const unenriched = [...sourceCache.values()].filter((m) => !m.genreEnriched);
if (!unenriched.length) return;
sourceCacheEnriching = true;
await runConcurrent(unenriched, async (entry) => {
if (signal.aborted) return;
try {
const d = await gql<{ fetchManga: { manga: Manga & { genre: string[]; status: string } } }>(
FETCH_MANGA, { id: entry.id }, signal,
);
if (signal.aborted) return;
const updated = sourceCache.get(entry.id);
if (updated) {
updated.genre = d.fetchManga.manga.genre ?? [];
updated.status = d.fetchManga.manga.status ?? updated.status;
updated.lowerGenres = updated.genre.map((g) => g.toLowerCase());
updated.genreEnriched = true;
}
} catch (e: any) {
if (e?.name === "AbortError") return;
const updated = sourceCache.get(entry.id);
if (updated) updated.genreEnriched = true;
}
}, signal);
if (!signal.aborted) sourceCacheEnriching = false;
}
function startSourceCacheBuild() {
if (sourceCacheLoading || sourceCacheReady) return;
sourceCacheAbort?.abort();
const ctrl = new AbortController();
sourceCacheAbort = ctrl;
sourceCacheLoading = true;
sourceCache.clear();
const dedupedSources = dedupeSourcesByLang(allSources, preferredLang, store.settings, true);
buildSourceCache(dedupedSources, ctrl.signal)
.then(() => {
if (ctrl.signal.aborted) return;
sourceCacheReady = true;
sourceCacheLoading = false;
enrichGenres(ctrl.signal);
})
.catch((e) => {
if (e?.name !== "AbortError") console.error(e);
sourceCacheLoading = false;
});
}
onDestroy(() => {
popular_abortCtrl?.abort();
sourceCacheAbort?.abort();
});
</script>
<div class="root anim-fade-in">
<div class="header">
<span class="heading">Search</span>
<div class="tabs" class:tabs-anims={anims} bind:this={tabsEl}>
{#if anims && tabIndicator.width > 0}
<div class="tab-slide-indicator" style="left:{tabIndicator.left}px;width:{tabIndicator.width}px" aria-hidden="true"></div>
{/if}
<button class="tab" class:tabActive={tab === "keyword"} onclick={() => { deprioritizeQueue(); tab = "keyword"; }}>
<svg width="11" height="11" viewBox="0 0 256 256" fill="currentColor" aria-hidden="true">
<path d="M229.66,218.34l-50.07-50.07a88,88,0,1,0-11.31,11.31l50.06,50.07a8,8,0,0,0,11.32-11.31ZM40,112a72,72,0,1,1,72,72A72.08,72.08,0,0,1,40,112Z"/>
</svg>
Keyword
</button>
<button class="tab" class:tabActive={tab === "tag"} onclick={() => { deprioritizeQueue(); tab = "tag"; }}>
<svg width="11" height="11" viewBox="0 0 256 256" fill="currentColor" aria-hidden="true">
<path d="M224,104H200l8-48a8,8,0,0,0-15.79-2.67L183.79,104H136l8-48a8,8,0,0,0-15.79-2.67L119.79,104H72a8,8,0,0,0,0,16h45.33L105.6,200H56a8,8,0,0,0,0,16H103l-8,48a8,8,0,0,0,15.79,2.67L119.21,216H168l-8,48a8,8,0,0,0,15.79,2.67L184.21,216H232a8,8,0,0,0,0-16H186.67l11.73-80H224a8,8,0,0,0,0-16Zm-69.33,96H101.6L113.33,120h53.07Z"/>
</svg>
Tags
</button>
<button class="tab" class:tabActive={tab === "source"} onclick={() => { deprioritizeQueue(); tab = "source"; }}>
<svg width="11" height="11" viewBox="0 0 256 256" fill="currentColor" aria-hidden="true">
<path d="M224,128a8,8,0,0,1-8,8H40a8,8,0,0,1,0-16H216A8,8,0,0,1,224,128ZM40,72H216a8,8,0,0,0,0-16H40a8,8,0,0,0,0,16ZM216,184H40a8,8,0,0,0,0,16H216a8,8,0,0,0,0-16Z"/>
</svg>
Sources
</button>
</div>
</div>
{#if tab === "keyword"}
<KeywordTab
{allSources}
{availableLangs}
{hasMultipleLangs}
{loadingSources}
{pendingPrefill}
popularResults={popular_results}
popularLoading={popular_loading}
onPrefillConsumed={() => (pendingPrefill = "")}
onPreview={setPreviewManga}
/>
{:else if tab === "tag"}
<TagTab
{allSources}
{sourceCache}
{sourceCacheReady}
{sourceCacheLoading}
{sourceCacheEnriching}
onPreview={setPreviewManga}
/>
{:else}
<SourceTab
{allSources}
{availableLangs}
{loadingSources}
{localSource}
onPreview={setPreviewManga}
/>
{/if}
</div>
<style>
.root { display: flex; flex-direction: column; height: 100%; overflow: hidden; }
.header { position: relative; z-index: 100; display: flex; align-items: center; gap: var(--sp-4); padding: var(--sp-4) var(--sp-6); flex-shrink: 0; border-bottom: 1px solid var(--border-dim); }
.heading { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; flex-shrink: 0; }
.tabs { margin-left: auto; display: flex; gap: 2px; background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 2px; position: relative; }
.tab-slide-indicator { position: absolute; top: 2px; bottom: 2px; border-radius: var(--radius-sm); background: var(--accent-muted); border: 1px solid var(--accent-dim); pointer-events: none; z-index: 0; transition: left 0.22s cubic-bezier(0.16,1,0.3,1), width 0.22s cubic-bezier(0.16,1,0.3,1); }
.tab { position: relative; z-index: 1; display: flex; align-items: center; gap: 5px; font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); text-transform: uppercase; padding: 4px 10px; border-radius: var(--radius-sm); color: var(--text-faint); white-space: nowrap; transition: background var(--t-base), color var(--t-base); cursor: pointer; border: 1px solid transparent; }
.tab:hover { color: var(--text-muted); }
.tabActive { color: var(--accent-fg); background: var(--accent-muted); border-color: var(--accent-dim); }
.tabs-anims .tabActive { background: transparent; border-color: transparent; }
.tabActive:hover { color: var(--accent-fg); }
</style>
@@ -0,0 +1,369 @@
<script lang="ts">
import { onDestroy } from "svelte";
import { gql } from "@api/client";
import { FETCH_SOURCE_MANGA } from "@api/mutations/downloads";
import { shouldHideNsfw, shouldHideSource } from "@core/util";
import { store } from "@store/state.svelte";
import Thumbnail from "@shared/manga/Thumbnail.svelte";
import ContextMenu from "@shared/ui/ContextMenu.svelte";
import { PushPin, PushPinSlash, ArrowRight } from "phosphor-svelte";
import type { Manga, Source } from "@types";
interface Props {
allSources: Source[];
availableLangs: string[];
loadingSources: boolean;
localSource: Source | null;
onPreview: (m: Manga) => void;
}
let { allSources, availableLangs, loadingSources, localSource, onPreview }: Props = $props();
const preferredLang = store.settings?.preferredExtensionLang ?? "en";
let src_selectedLang = $state(preferredLang || "all");
let src_activeSource: Source | null = $state(null);
let src_browseResults: Manga[] = $state([]);
let src_loadingBrowse = $state(false);
let src_browseQuery = $state("");
let src_submitted = $state("");
let src_hasNextPage = $state(false);
let src_currentPage = $state(1);
let src_abortCtrl: AbortController | null = null;
let ctx_x = $state(0);
let ctx_y = $state(0);
let ctx_source: Source | null = $state(null);
const pinnedIds = $derived(store.settings.pinnedSourceIds ?? []);
const pinnedSources = $derived(
pinnedIds
.map(id => allSources.find(s => s.id === id))
.filter((s): s is Source => !!s)
);
$effect(() => {
if (!allSources.length) return;
const langs = new Set(allSources.map((s) => s.lang));
if (src_selectedLang !== "all" && !langs.has(src_selectedLang)) {
src_selectedLang = langs.has(preferredLang) ? preferredLang : "all";
}
});
const src_visibleSources = $derived.by(() => {
const hide = (s: Source) => shouldHideSource(s, store.settings);
if (src_selectedLang !== "all") {
return allSources.filter((s) => s.lang === src_selectedLang && !hide(s));
}
const map = new Map<string, Source>();
for (const s of allSources) {
if (hide(s)) continue;
const existing = map.get(s.name);
if (!existing) { map.set(s.name, s); continue; }
const existingPref = existing.lang === preferredLang;
const newPref = s.lang === preferredLang;
if (newPref && !existingPref) map.set(s.name, s);
else if (!existingPref && !newPref && s.lang < existing.lang) map.set(s.name, s);
}
return Array.from(map.values());
});
async function srcFetchBrowse(src: Source, type: "POPULAR" | "SEARCH", q?: string, page = 1) {
src_abortCtrl?.abort();
const ctrl = new AbortController();
src_abortCtrl = ctrl;
if (page === 1) { src_loadingBrowse = true; src_browseResults = []; }
try {
const d = await gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>(
FETCH_SOURCE_MANGA,
{ source: src.id, type, page, query: q ?? null },
ctrl.signal,
);
if (ctrl.signal.aborted) return;
const incoming = d.fetchSourceManga.mangas.filter((m) => !shouldHideNsfw(m, store.settings));
src_browseResults = page === 1 ? incoming : [...src_browseResults, ...incoming];
src_hasNextPage = d.fetchSourceManga.hasNextPage;
src_currentPage = page;
} catch (e: any) {
if (e?.name !== "AbortError") console.error(e);
} finally {
if (!ctrl.signal.aborted) src_loadingBrowse = false;
}
}
function srcSelectSource(src: Source) {
src_activeSource = src; src_browseQuery = ""; src_submitted = "";
srcFetchBrowse(src, "POPULAR");
}
function srcHandleSearch() {
if (!src_activeSource || !src_browseQuery.trim()) return;
src_submitted = src_browseQuery.trim();
srcFetchBrowse(src_activeSource, "SEARCH", src_browseQuery.trim());
}
function srcClearSearch() {
src_browseQuery = ""; src_submitted = "";
if (src_activeSource) srcFetchBrowse(src_activeSource, "POPULAR");
}
function openCtx(e: MouseEvent, src: Source) {
e.preventDefault();
ctx_x = e.clientX; ctx_y = e.clientY; ctx_source = src;
}
function closeCtx() { ctx_source = null; }
onDestroy(() => { src_abortCtrl?.abort(); });
</script>
<div class="splitRoot">
<div class="splitSidebar">
<div class="srcLangRow">
<span class="langPocketLabel">Language</span>
<select class="langSelect" bind:value={src_selectedLang}>
<option value="all">All</option>
{#each availableLangs as lang (lang)}
<option value={lang}>{lang.toUpperCase()}{lang === preferredLang ? " ★" : ""}</option>
{/each}
</select>
</div>
{#if loadingSources}
<div class="splitLoading">
<svg width="16" height="16" viewBox="0 0 256 256" fill="currentColor" class="anim-spin" style="color:var(--text-faint)" aria-hidden="true">
<path d="M232,128a104,104,0,0,1-208,0c0-41,23.81-78.36,60.66-95.27a8,8,0,0,1,6.68,14.54C60.15,61.59,40,93.27,40,128a88,88,0,0,0,176,0c0-34.73-20.15-66.41-51.34-80.73a8,8,0,0,1,6.68-14.54C208.19,49.64,232,87,232,128Z"/>
</svg>
</div>
{:else}
<div class="splitList">
{#if localSource}
<button
class="splitItem splitItemSource"
class:splitItemActive={src_activeSource?.id === localSource.id}
onclick={() => srcSelectSource(localSource)}
oncontextmenu={(e) => openCtx(e, localSource)}
>
<div class="localSourceIcon">
<svg width="12" height="12" viewBox="0 0 256 256" fill="currentColor" aria-hidden="true">
<path d="M128,20A108,108,0,1,0,236,128,108.12,108.12,0,0,0,128,20Zm0,192a84,84,0,1,1,84-84A84.09,84.09,0,0,1,128,212Zm44-84a44,44,0,1,1-44-44A44.05,44.05,0,0,1,172,128Z"/>
</svg>
</div>
<span class="splitItemLabel">Local Source</span>
</button>
<div class="localDivider"></div>
{/if}
{#if pinnedSources.length > 0}
<p class="sectionLabel">Pinned</p>
{#each pinnedSources as src (src.id)}
<button
class="splitItem splitItemSource"
class:splitItemActive={src_activeSource?.id === src.id}
onclick={() => srcSelectSource(src)}
oncontextmenu={(e) => openCtx(e, src)}
>
<Thumbnail src={src.iconUrl} alt="" class="splitSourceIcon" onerror={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
<span class="splitItemLabel">{src.name}</span>
<span class="pinIndicator" title="Pinned">
<PushPin size={9} weight="fill" />
</span>
{#if src.isNsfw}<span class="nsfwBadge">18+</span>{/if}
</button>
{/each}
<div class="localDivider"></div>
<p class="sectionLabel">All Sources</p>
{/if}
{#each src_visibleSources as src (src.id)}
<button
class="splitItem splitItemSource"
class:splitItemActive={src_activeSource?.id === src.id}
onclick={() => srcSelectSource(src)}
oncontextmenu={(e) => openCtx(e, src)}
>
<Thumbnail src={src.iconUrl} alt="" class="splitSourceIcon" onerror={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
<span class="splitItemLabel">{src.name}</span>
{#if src_selectedLang === "all"}
<span class="sourceLang">{src.lang.toUpperCase()}</span>
{/if}
{#if src.isNsfw}<span class="nsfwBadge">18+</span>{/if}
</button>
{/each}
{#if src_visibleSources.length === 0}
<p class="splitEmpty">No sources for this language</p>
{/if}
</div>
{/if}
</div>
<div class="splitContent">
{#if !src_activeSource}
<div class="empty">
<svg width="32" height="32" viewBox="0 0 256 256" fill="currentColor" class="emptyIcon" aria-hidden="true">
<path d="M224,128a8,8,0,0,1-8,8H40a8,8,0,0,1,0-16H216A8,8,0,0,1,224,128ZM40,72H216a8,8,0,0,0,0-16H40a8,8,0,0,0,0,16ZM216,184H40a8,8,0,0,0,0,16H216a8,8,0,0,0,0-16Z"/>
</svg>
<p class="emptyText">Browse a source</p>
<p class="emptyHint">Select a source to see its popular titles, or search within it.</p>
</div>
{:else}
<div class="splitContentHeader">
<div class="splitSourceTitle">
<Thumbnail src={src_activeSource.iconUrl} alt="" class="splitSourceIcon" onerror={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
<span class="splitContentTitle">{src_activeSource.displayName}</span>
{#if src_loadingBrowse}
<svg width="13" height="13" viewBox="0 0 256 256" fill="currentColor" class="anim-spin" style="color:var(--text-faint)" aria-hidden="true">
<path d="M232,128a104,104,0,0,1-208,0c0-41,23.81-78.36,60.66-95.27a8,8,0,0,1,6.68,14.54C60.15,61.59,40,93.27,40,128a88,88,0,0,0,176,0c0-34.73-20.15-66.41-51.34-80.73a8,8,0,0,1,6.68-14.54C208.19,49.64,232,87,232,128Z"/>
</svg>
{:else if src_browseResults.length > 0}
<span class="splitResultCount">{src_browseResults.length} results</span>
{/if}
</div>
</div>
<div class="sourceBrowseBar">
<div class="searchBar" style="flex:1">
<svg width="12" height="12" viewBox="0 0 256 256" fill="currentColor" class="searchIcon" aria-hidden="true">
<path d="M229.66,218.34l-50.07-50.07a88,88,0,1,0-11.31,11.31l50.06,50.07a8,8,0,0,0,11.32-11.31ZM40,112a72,72,0,1,1,72,72A72.08,72.08,0,0,1,40,112Z"/>
</svg>
<input
bind:value={src_browseQuery}
class="searchInput"
placeholder="Search {src_activeSource.displayName}…"
onkeydown={(e) => e.key === "Enter" && srcHandleSearch()}
/>
{#if src_submitted}
<button class="clearBtn" title="Clear search" onclick={srcClearSearch}>×</button>
{/if}
</div>
<button class="searchBtn" onclick={srcHandleSearch} disabled={!src_browseQuery.trim() || src_loadingBrowse}>Search</button>
</div>
{#if src_loadingBrowse && src_browseResults.length === 0}
<div class="tagGrid">
{#each Array(18) as _, i (i)}
<div class="skCard"><div class="skeleton skCover"></div><div class="skeleton skTitle"></div></div>
{/each}
</div>
{:else if src_browseResults.length > 0}
<div class="tagGrid">
{#each src_browseResults as m, i (m.id)}
<button class="card" onclick={() => onPreview(m)}>
<div class="coverWrap">
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="cover" priority={i < 12 ? 12 - i : 0} />
{#if m.inLibrary}<span class="inLibBadge">Saved</span>{/if}
</div>
<p class="cardTitle">{m.title}</p>
</button>
{/each}
{#if src_hasNextPage}
<div class="showMoreCell">
<button
class="showMoreBtn"
disabled={src_loadingBrowse}
onclick={() => src_activeSource && srcFetchBrowse(src_activeSource, src_submitted ? "SEARCH" : "POPULAR", src_submitted || undefined, src_currentPage + 1)}
>
{src_loadingBrowse ? "Loading…" : "Load more"}
</button>
</div>
{/if}
</div>
{:else if !src_loadingBrowse}
<div class="empty">
<p class="emptyText">No results</p>
<p class="emptyHint">Try a different search term.</p>
</div>
{/if}
{/if}
</div>
</div>
{#if ctx_source}
{@const isPinned = pinnedIds.includes(ctx_source.id)}
<ContextMenu
x={ctx_x}
y={ctx_y}
onClose={closeCtx}
items={[
{
label: isPinned ? "Unpin source" : "Pin source",
icon: isPinned ? PushPinSlash : PushPin,
onClick: () => { store.togglePinnedSource(ctx_source!.id); },
},
{ separator: true },
{
label: "Browse source",
icon: ArrowRight,
onClick: () => { srcSelectSource(ctx_source!); },
},
]}
/>
{/if}
<style>
.splitRoot { flex: 1; display: flex; overflow: hidden; }
.splitSidebar { width: 180px; flex-shrink: 0; border-right: 1px solid var(--border-dim); overflow: hidden; display: flex; flex-direction: column; }
.srcLangRow { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-2) var(--sp-3); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; gap: var(--sp-2); }
.langPocketLabel { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; }
.langSelect { appearance: none; -webkit-appearance: none; background: var(--bg-overlay); border: 1px solid var(--border-dim); border-radius: var(--radius-sm); color: var(--text-secondary); font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 4px 24px 4px 8px; cursor: pointer; max-width: 110px; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8' viewBox='0 0 256 256'%3E%3Cpath fill='%23888' d='M213.66,101.66l-80,80a8,8,0,0,1-11.32,0l-80-80A8,8,0,0,1,53.66,90.34L128,164.69l74.34-74.35a8,8,0,0,1,11.32,11.32Z'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: right 7px center; transition: border-color var(--t-base), background var(--t-base), color var(--t-base); }
.langSelect:hover { border-color: var(--border-strong); background-color: var(--bg-raised); color: var(--text-primary); }
.langSelect:focus { outline: none; border-color: var(--accent-dim); color: var(--text-primary); }
.langSelect option { background: var(--bg-surface); color: var(--text-secondary); }
.splitLoading { flex: 1; display: flex; align-items: center; justify-content: center; padding: var(--sp-6); }
.splitList { flex: 1; overflow-y: auto; padding: var(--sp-1); scrollbar-width: thin; scrollbar-color: var(--border-dim) transparent; }
.localSourceIcon { width: 20px; height: 20px; border-radius: var(--radius-sm); background: var(--accent-muted); border: 1px solid var(--accent-dim); display: flex; align-items: center; justify-content: center; color: var(--accent-fg); flex-shrink: 0; }
.localDivider { height: 1px; background: var(--border-dim); margin: var(--sp-1) var(--sp-2); }
.splitItem { display: flex; align-items: center; gap: var(--sp-2); width: 100%; padding: 7px var(--sp-3); border-radius: var(--radius-md); border: 1px solid transparent; background: none; text-align: left; cursor: pointer; transition: background var(--t-fast), border-color var(--t-fast); }
.splitItem:hover { background: var(--bg-raised); border-color: var(--border-dim); }
.splitItemActive { background: var(--accent-muted); border-color: var(--accent-dim); }
.splitItemActive:hover { background: var(--accent-muted); }
.splitItemSource { gap: var(--sp-2); }
.splitItemLabel { font-size: var(--text-xs); color: var(--text-muted); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.splitItemActive .splitItemLabel { color: var(--accent-fg); font-weight: var(--weight-medium); }
.splitEmpty { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); padding: var(--sp-3); margin: 0; }
.sectionLabel { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; padding: var(--sp-2) var(--sp-3) var(--sp-1); margin: 0; }
.pinIndicator { display: flex; align-items: center; color: var(--accent-fg); opacity: 0.7; flex-shrink: 0; margin-left: auto; margin-right: 2px; }
.sourceLang { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); margin-left: auto; margin-right: 4px; }
.nsfwBadge { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--color-error); background: var(--color-error-bg, rgba(180,60,60,0.08)); border: 1px solid rgba(180,60,60,0.25); border-radius: var(--radius-sm); padding: 1px 5px; margin-left: auto; flex-shrink: 0; }
.splitContent { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
.splitContentHeader { 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); }
.splitSourceTitle { display: flex; align-items: center; gap: var(--sp-2); flex: 1; min-width: 0; }
.splitContentTitle { font-size: var(--text-base); font-weight: var(--weight-medium); color: var(--text-secondary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; letter-spacing: var(--tracking-tight); }
.splitResultCount { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); flex-shrink: 0; }
:global(.splitSourceIcon) { width: 20px; height: 20px; border-radius: var(--radius-sm); object-fit: cover; flex-shrink: 0; background: var(--bg-raised); }
.sourceBrowseBar { display: flex; align-items: center; gap: var(--sp-2); padding: var(--sp-2) var(--sp-4); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
.searchBar { display: flex; align-items: center; gap: var(--sp-2); background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-lg); padding: var(--sp-2) var(--sp-3); transition: border-color var(--t-base); }
.searchBar:focus-within { border-color: var(--border-strong); }
.searchIcon { color: var(--text-faint); flex-shrink: 0; }
.searchInput { flex: 1; background: none; border: none; outline: none; font-size: var(--text-sm); color: var(--text-primary); min-width: 0; }
.searchInput::placeholder { color: var(--text-faint); }
.clearBtn { color: var(--text-faint); font-size: 16px; line-height: 1; background: none; border: none; cursor: pointer; padding: 2px; transition: color var(--t-base); }
.clearBtn:hover { color: var(--text-muted); }
.searchBtn { padding: 6px 14px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: var(--bg-raised); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); color: var(--text-muted); cursor: pointer; transition: background var(--t-base), color var(--t-base), border-color var(--t-base); flex-shrink: 0; }
.searchBtn:hover:not(:disabled) { background: var(--bg-overlay); color: var(--text-secondary); border-color: var(--border-strong); }
.searchBtn:disabled { opacity: 0.4; cursor: default; }
.tagGrid { display: grid; grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); gap: var(--sp-4); padding: var(--sp-4); overflow-y: auto; flex: 1; align-content: start; }
.card { background: none; border: none; padding: 0; cursor: pointer; text-align: left; display: flex; flex-direction: column; gap: var(--sp-2); }
.coverWrap { position: relative; aspect-ratio: 2/3; overflow: hidden; border-radius: var(--radius-md); background: var(--bg-raised); border: 1px solid var(--border-dim); }
.cardTitle { font-size: var(--text-sm); color: var(--text-secondary); line-height: var(--leading-snug); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.inLibBadge { position: absolute; top: var(--sp-2); left: var(--sp-2); font-family: var(--font-ui); font-size: 9px; letter-spacing: var(--tracking-wide); background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim); border-radius: var(--radius-sm); padding: 1px 5px; }
.showMoreCell { grid-column: 1 / -1; display: flex; justify-content: center; padding: var(--sp-2) 0; }
.showMoreBtn { display: inline-flex; align-items: center; gap: var(--sp-1); padding: 5px 12px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: none; font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); color: var(--text-muted); cursor: pointer; transition: background var(--t-base), color var(--t-base), border-color var(--t-base); }
.showMoreBtn:hover:not(:disabled) { background: var(--bg-raised); color: var(--text-secondary); border-color: var(--border-strong); }
.showMoreBtn:disabled { opacity: 0.4; cursor: default; }
.skCard { display: flex; flex-direction: column; gap: var(--sp-2); }
@keyframes shimmer { from { background-position: -200% 0 } to { background-position: 200% 0 } }
.skeleton { border-radius: var(--radius-sm); background: linear-gradient(90deg, var(--bg-raised) 25%, var(--bg-overlay, color-mix(in srgb, var(--bg-raised) 80%, var(--text-primary) 6%)) 50%, var(--bg-raised) 75%); background-size: 200% 100%; animation: shimmer 1.6s ease-in-out infinite; }
.skCover { aspect-ratio: 2/3; width: 100%; border-radius: var(--radius-md); }
.skTitle { height: 10px; width: 80%; }
.empty { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: var(--sp-2); padding: var(--sp-8); }
.emptyIcon { color: var(--text-faint); opacity: 0.5; }
.emptyText { font-size: var(--text-sm); color: var(--text-muted); font-weight: var(--weight-medium); margin: 0; }
.emptyHint { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); margin: 0; }
@keyframes anim-spin { from { transform: rotate(0deg) } to { transform: rotate(360deg) } }
.anim-spin { animation: anim-spin 0.8s linear infinite; }
</style>
@@ -0,0 +1,474 @@
<script lang="ts">
import { onDestroy, untrack } from "svelte";
import { gql } from "@api/client";
import { FETCH_SOURCE_MANGA } from "@api/mutations/downloads";
import { MANGAS_BY_GENRE } from "@api/queries/manga";
import { runConcurrent } from "@core/async/batchRequests";
import { dedupeSourcesByLang }from "@core/algorithms/filter";
import { shouldHideNsfw, dedupeMangaById, dedupeMangaByTitle, normalizeTitle } from "@core/util";
import { store } from "@store/state.svelte";
import Thumbnail from "@shared/manga/Thumbnail.svelte";
import {
buildTagFilter,
filterSourceCache,
COMMON_GENRES,
MANGA_STATUSES,
type TagMode,
type CachedManga,
} from "@features/discover/lib/searchFilter";
import type { Manga, Source } from "@types";
interface Props {
allSources: Source[];
sourceCache: Map<number, CachedManga>;
sourceCacheReady: boolean;
sourceCacheLoading: boolean;
sourceCacheEnriching: boolean;
onPreview: (m: Manga) => void;
}
let {
allSources, sourceCache,
sourceCacheReady, sourceCacheLoading, sourceCacheEnriching,
onPreview,
}: Props = $props();
const SEARCH_LIMIT = 200;
const preferredLang = store.settings?.preferredExtensionLang ?? "en";
let tag_activeTags: string[] = $state([]);
let tag_activeStatuses: string[] = $state([]);
let tag_tagMode: TagMode = $state("AND");
let tag_tagFilter = $state("");
const tag_filteredGenres = $derived.by(() => {
const q = tag_tagFilter.trim().toLowerCase();
return q ? COMMON_GENRES.filter((g) => g.toLowerCase().includes(q)) : [...COMMON_GENRES];
});
const tag_hasActiveFilters = $derived(tag_activeTags.length > 0 || tag_activeStatuses.length > 0);
let tag_localResults: Manga[] = $state([]);
let tag_totalCount = $state(0);
let tag_loadingLocal = $state(false);
let tag_loadingMoreLocal = $state(false);
let tag_localOffset = $state(0);
let tag_localHasNext = $state(false);
let tag_abortLocal: AbortController | null = null;
$effect(() => {
const _tags = tag_activeTags;
const _mode = tag_tagMode;
const _statuses = tag_activeStatuses;
untrack(() => tagFetchLocal(_tags, _mode, _statuses));
});
$effect(() => {
if (tag_localHasNext && !tag_loadingMoreLocal && !tag_loadingLocal) tagLoadMoreLocal();
});
async function tagFetchLocal(activeTags: string[], tagMode: TagMode, activeStatuses: string[]) {
if (activeTags.length === 0 && activeStatuses.length === 0) {
tag_localResults = []; tag_totalCount = 0; tag_localHasNext = false; tag_localOffset = 0;
return;
}
tag_abortLocal?.abort();
const ctrl = new AbortController();
tag_abortLocal = ctrl;
tag_localResults = []; tag_totalCount = 0; tag_localOffset = 0; tag_localHasNext = false;
tag_loadingLocal = true;
gql<{ mangas: { nodes: Manga[]; pageInfo: { hasNextPage: boolean }; totalCount: number } }>(
MANGAS_BY_GENRE,
{ filter: buildTagFilter(activeTags, tagMode, activeStatuses), first: (store.settings.renderLimit ?? 48), offset: 0 },
ctrl.signal,
).then((d) => {
if (ctrl.signal.aborted) return;
const nsfwFilter = (m: Manga) => !shouldHideNsfw(m, store.settings);
tag_localResults = d.mangas.nodes.filter(nsfwFilter);
tag_totalCount = d.mangas.totalCount;
tag_localHasNext = d.mangas.pageInfo.hasNextPage;
tag_localOffset = (store.settings.renderLimit ?? 48);
}).catch((e: any) => {
if (e?.name !== "AbortError") console.error(e);
}).finally(() => {
if (!ctrl.signal.aborted) tag_loadingLocal = false;
});
}
async function tagLoadMoreLocal() {
if (tag_loadingMoreLocal || !tag_localHasNext) return;
tag_loadingMoreLocal = true;
tag_abortLocal?.abort();
const ctrl = new AbortController();
tag_abortLocal = ctrl;
try {
const d = await gql<{ mangas: { nodes: Manga[]; pageInfo: { hasNextPage: boolean } } }>(
MANGAS_BY_GENRE,
{ filter: buildTagFilter(tag_activeTags, tag_tagMode, tag_activeStatuses), first: (store.settings.renderLimit ?? 48), offset: tag_localOffset },
ctrl.signal,
);
if (ctrl.signal.aborted) return;
const nsfwFilter = (m: Manga) => !shouldHideNsfw(m, store.settings);
tag_localResults = [...tag_localResults, ...d.mangas.nodes.filter(nsfwFilter)];
tag_localHasNext = d.mangas.pageInfo.hasNextPage;
tag_localOffset += (store.settings.renderLimit ?? 48);
} catch (e: any) {
if (e?.name !== "AbortError") console.error(e);
} finally {
if (!ctrl.signal.aborted) tag_loadingMoreLocal = false;
}
}
let tag_searchSources = $state(false);
let tag_sourceFiltered: CachedManga[] = $state([]);
let tag_sourceFanOut: Manga[] = $state([]);
let tag_fanOutLoading = $state(false);
let tag_fanOutAbort: AbortController | null = null;
$effect(() => {
const _tags = tag_activeTags;
const _mode = tag_tagMode;
const _statuses = tag_activeStatuses;
const _ready = sourceCacheReady;
const _search = tag_searchSources;
untrack(() => {
if (_search && _ready && (_tags.length > 0 || _statuses.length > 0)) {
tag_sourceFiltered = filterSourceCache(sourceCache, _tags, _mode, _statuses, store.settings);
} else {
tag_sourceFiltered = [];
}
});
});
$effect(() => {
const _tags = tag_activeTags;
const _search = tag_searchSources;
untrack(() => {
if (_search && _tags.length === 1 && tag_activeStatuses.length === 0) {
tagStartFanOut(_tags[0]);
} else {
tag_fanOutAbort?.abort();
tag_fanOutAbort = null;
tag_sourceFanOut = [];
tag_fanOutLoading = false;
}
});
});
async function tagStartFanOut(genre: string) {
tag_fanOutAbort?.abort();
const ctrl = new AbortController();
tag_fanOutAbort = ctrl;
tag_sourceFanOut = [];
tag_fanOutLoading = true;
const seenIds = new Set<number>();
const seenTitles = new Set<string>();
const genreLower = genre.toLowerCase();
const srcs = dedupeSourcesByLang(allSources, preferredLang, store.settings, true);
await runConcurrent(srcs, async (src) => {
for (let page = 1; page <= 2; page++) {
if (ctrl.signal.aborted) return;
const cacheKey = `${src.id}|SEARCH|${genre}:p${page}`;
let mangas: Manga[];
let hasNextPage = false;
if (store.searchCache?.has(cacheKey)) {
mangas = store.searchCache.get(cacheKey)!;
} else {
const result = await gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>(
FETCH_SOURCE_MANGA,
{ source: src.id, type: "SEARCH", page, query: genre },
ctrl.signal,
).then((d) => d.fetchSourceManga).catch(() => null);
if (!result || ctrl.signal.aborted) return;
mangas = result.mangas;
hasNextPage = result.hasNextPage;
store.searchCache?.set(cacheKey, mangas);
}
if (ctrl.signal.aborted) return;
const matching = mangas.filter((m) =>
((m as any).genre ?? []).some((g: string) => g.toLowerCase() === genreLower)
);
const candidates = (matching.length ? matching : mangas).filter(
(m) => !shouldHideNsfw(m, store.settings)
);
const toAdd: Manga[] = [];
for (const m of candidates) {
if (seenIds.has(m.id)) continue;
const norm = normalizeTitle(m.title);
if (seenTitles.has(norm)) continue;
seenIds.add(m.id);
seenTitles.add(norm);
toAdd.push(m);
}
if (toAdd.length) {
tag_sourceFanOut = [...tag_sourceFanOut, ...toAdd].slice(0, SEARCH_LIMIT);
}
if (!hasNextPage) return;
}
}, ctrl.signal);
if (!ctrl.signal.aborted) tag_fanOutLoading = false;
}
let tag_autoSearchFired = $state(false);
$effect(() => {
const _tags = tag_activeTags;
const _statuses = tag_activeStatuses;
untrack(() => { tag_autoSearchFired = false; });
if (!tag_loadingLocal && tag_hasActiveFilters && !tag_autoSearchFired && !tag_searchSources && sourceCacheReady) {
if (tag_localResults.length < 20) {
untrack(() => { tag_autoSearchFired = true; tag_searchSources = true; });
}
}
});
const tag_localIds = $derived(new Set(tag_localResults.map((m) => m.id)));
const tag_mergedResults = $derived.by(() => {
const fanOutMapped = tag_sourceFanOut.filter((m) => !tag_localIds.has(m.id));
const cacheMapped: Manga[] = tag_sourceFiltered
.filter((m) => !tag_localIds.has(m.id) && !fanOutMapped.some((f) => f.id === m.id))
.map((m) => ({ id: m.id, title: m.title, thumbnailUrl: m.thumbnailUrl, inLibrary: m.inLibrary, genre: m.genre, status: m.status } as Manga));
return dedupeMangaByTitle(
dedupeMangaById([...tag_localResults, ...fanOutMapped, ...cacheMapped]),
store.settings.mangaLinks,
);
});
const tag_totalVisible = $derived(tag_mergedResults.length);
function tagToggleTag(tag: string) {
tag_activeTags = tag_activeTags.includes(tag)
? tag_activeTags.filter((t) => t !== tag)
: [...tag_activeTags, tag];
}
function tagToggleStatus(status: string) {
tag_activeStatuses = tag_activeStatuses.includes(status)
? tag_activeStatuses.filter((s) => s !== status)
: [...tag_activeStatuses, status];
}
onDestroy(() => {
tag_abortLocal?.abort();
tag_fanOutAbort?.abort();
});
</script>
<div class="splitRoot">
<div class="splitSidebar">
<div class="splitSearchWrap">
<svg width="12" height="12" viewBox="0 0 256 256" fill="currentColor" class="splitSearchIcon" aria-hidden="true">
<path d="M229.66,218.34l-50.07-50.07a88,88,0,1,0-11.31,11.31l50.06,50.07a8,8,0,0,0,11.32-11.31ZM40,112a72,72,0,1,1,72,72A72.08,72.08,0,0,1,40,112Z"/>
</svg>
<input bind:value={tag_tagFilter} class="splitSearchInput" placeholder="Filter genres…" />
{#if tag_tagFilter}
<button class="splitSearchClear" title="Clear" onclick={() => (tag_tagFilter = "")}>×</button>
{/if}
</div>
<div class="splitList">
<div class="splitSectionLabel">Status</div>
{#each MANGA_STATUSES as { value, label } (value)}
<button class="splitItem" class:splitItemActive={tag_activeStatuses.includes(value)} onclick={() => tagToggleStatus(value)}>
<span class="splitItemLabel">{label}</span>
{#if tag_activeStatuses.includes(value)}<span class="tagCheckMark"></span>{/if}
</button>
{/each}
<div class="splitSectionLabel splitSectionLabelSpaced">Genre</div>
{#each tag_filteredGenres as tag (tag)}
<button class="splitItem" class:splitItemActive={tag_activeTags.includes(tag)} onclick={() => tagToggleTag(tag)}>
<span class="splitItemLabel">{tag}</span>
{#if tag_activeTags.includes(tag)}<span class="tagCheckMark"></span>{/if}
</button>
{/each}
{#if tag_filteredGenres.length === 0}
<p class="splitEmpty">No matching genres</p>
{/if}
</div>
</div>
<div class="splitContent">
{#if !tag_hasActiveFilters}
<div class="empty">
<svg width="32" height="32" viewBox="0 0 256 256" fill="currentColor" class="emptyIcon" aria-hidden="true">
<path d="M224,104H200l8-48a8,8,0,0,0-15.79-2.67L183.79,104H136l8-48a8,8,0,0,0-15.79-2.67L119.79,104H72a8,8,0,0,0,0,16h45.33L105.6,200H56a8,8,0,0,0,0,16H103l-8,48a8,8,0,0,0,15.79,2.67L119.21,216H168l-8,48a8,8,0,0,0,15.79,2.67L184.21,216H232a8,8,0,0,0,0-16H186.67l11.73-80H224a8,8,0,0,0,0-16Zm-69.33,96H101.6L113.33,120h53.07Z"/>
</svg>
<p class="emptyText">Browse by tag</p>
<p class="emptyHint">Select a status or genre to find matching manga.</p>
</div>
{:else}
<div class="tagActiveBar">
<div class="tagPillRow">
{#each tag_activeStatuses as status (status)}
<span class="tagPill tagPillStatus">
{MANGA_STATUSES.find((s) => s.value === status)?.label ?? status}
<button class="tagPillRemove" title="Remove {status}" onclick={() => tagToggleStatus(status)}>×</button>
</span>
{/each}
{#each tag_activeTags as tag (tag)}
<span class="tagPill">
{tag}
<button class="tagPillRemove" title="Remove {tag}" onclick={() => tagToggleTag(tag)}>×</button>
</span>
{/each}
</div>
<div class="tagBarRight">
{#if tag_activeTags.length > 1}
<div class="tagModeToggle">
<button class="tagModeBtn" class:tagModeBtnActive={tag_tagMode === "AND"} title="Match ALL tags" onclick={() => (tag_tagMode = "AND")}>AND</button>
<button class="tagModeBtn" class:tagModeBtnActive={tag_tagMode === "OR"} title="Match ANY tag" onclick={() => (tag_tagMode = "OR")}>OR</button>
</div>
{/if}
<button
class="tagModeBtn"
class:tagModeBtnActive={tag_searchSources}
title={sourceCacheLoading ? "Building source cache…" : sourceCacheReady ? "Search across sources" : "Sources unavailable"}
disabled={!sourceCacheReady && !sourceCacheLoading}
onclick={() => (tag_searchSources = !tag_searchSources)}
>
{#if sourceCacheLoading || tag_fanOutLoading}
<svg width="11" height="11" viewBox="0 0 256 256" fill="currentColor" class="anim-spin" aria-hidden="true">
<path d="M232,128a104,104,0,0,1-208,0c0-41,23.81-78.36,60.66-95.27a8,8,0,0,1,6.68,14.54C60.15,61.59,40,93.27,40,128a88,88,0,0,0,176,0c0-34.73-20.15-66.41-51.34-80.73a8,8,0,0,1,6.68-14.54C208.19,49.64,232,87,232,128Z"/>
</svg>
{:else}
<svg width="11" height="11" viewBox="0 0 256 256" fill="currentColor" style="margin-right:3px;vertical-align:middle" aria-hidden="true">
<path d="M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24ZM101.63,168h52.74C149,186.34,140,202.87,128,215.89,116,202.87,107,186.34,101.63,168ZM98,152a145.72,145.72,0,0,1,0-48h60a145.72,145.72,0,0,1,0,48ZM40,128a87.61,87.61,0,0,1,3.33-24H81.79a161.79,161.79,0,0,0,0,48H43.33A87.61,87.61,0,0,1,40,128ZM154.37,88H101.63C107,69.66,116,53.13,128,40.11,140,53.13,149,69.66,154.37,88ZM174.21,104h38.46a88.15,88.15,0,0,1,0,48H174.21a161.79,161.79,0,0,0,0-48Zm32.32-16H170.71a133.32,133.32,0,0,0-22.7-45.8A88.21,88.21,0,0,1,206.53,88ZM108,42.2A133.32,133.32,0,0,0,85.29,88H49.47A88.21,88.21,0,0,1,108,42.2ZM49.47,168H85.29A133.32,133.32,0,0,0,108,213.8,88.21,88.21,0,0,1,49.47,168Zm98.53,45.8A133.32,133.32,0,0,0,170.71,168h35.82A88.21,88.21,0,0,1,148,213.8Z"/>
</svg>
{/if}
Sources{sourceCacheEnriching ? " ·" : ""}
</button>
<button class="tagClearAll" onclick={() => { tag_activeTags = []; tag_activeStatuses = []; }}>Clear all</button>
</div>
</div>
<div class="splitContentHeader">
<span class="splitContentTitle">
{#if tag_activeStatuses.length > 0 && tag_activeTags.length === 0}
{tag_activeStatuses.map((s) => MANGA_STATUSES.find((x) => x.value === s)?.label ?? s).join(" · ")}
{:else if tag_activeTags.length === 1 && tag_activeStatuses.length === 0}
{tag_activeTags[0]}
{:else}
{[...tag_activeStatuses.map((s) => MANGA_STATUSES.find((x) => x.value === s)?.label ?? s), ...tag_activeTags].join(` ${tag_tagMode} `)}
{/if}
{#if tag_searchSources}
<span style="margin-left:6px;font-weight:400;opacity:0.55;font-size:0.9em">+ sources</span>
{/if}
</span>
{#if tag_loadingLocal}
<svg width="13" height="13" viewBox="0 0 256 256" fill="currentColor" class="anim-spin" style="color:var(--text-faint)" aria-hidden="true">
<path d="M232,128a104,104,0,0,1-208,0c0-41,23.81-78.36,60.66-95.27a8,8,0,0,1,6.68,14.54C60.15,61.59,40,93.27,40,128a88,88,0,0,0,176,0c0-34.73-20.15-66.41-51.34-80.73a8,8,0,0,1,6.68-14.54C208.19,49.64,232,87,232,128Z"/>
</svg>
{:else}
<span class="splitResultCount">
{tag_totalVisible}{tag_localHasNext ? "+" : ""} results
{#if tag_searchSources && sourceCacheReady}
· {sourceCache.size} cached
{/if}
</span>
{/if}
</div>
{#if tag_loadingLocal}
<div class="tagGrid">
{#each Array(48) as _, i (i)}
<div class="skCard"><div class="skeleton skCover"></div><div class="skeleton skTitle"></div></div>
{/each}
</div>
{:else if tag_mergedResults.length > 0}
<div class="tagGrid">
{#each tag_mergedResults as m, i (m.id)}
<button class="card" onclick={() => onPreview(m)}>
<div class="coverWrap">
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="cover" priority={i < 12 ? 12 - i : 0} />
{#if m.inLibrary}<span class="inLibBadge">Saved</span>{/if}
</div>
<p class="cardTitle">{m.title}</p>
</button>
{/each}
{#if tag_loadingMoreLocal}
{#each Array(12) as _, i (i)}
<div class="skCard"><div class="skeleton skCover"></div><div class="skeleton skTitle"></div></div>
{/each}
{/if}
</div>
{:else}
<div class="empty">
<p class="emptyText">No results</p>
<p class="emptyHint">
{#if tag_searchSources}Try OR mode or broader tags.
{:else}Try OR mode, enable Sources, or check your library.
{/if}
</p>
</div>
{/if}
{/if}
</div>
</div>
<style>
.splitRoot { flex: 1; display: flex; overflow: hidden; }
.splitSidebar { width: 180px; flex-shrink: 0; border-right: 1px solid var(--border-dim); overflow: hidden; display: flex; flex-direction: column; }
.splitSearchWrap { display: flex; align-items: center; gap: var(--sp-1); padding: var(--sp-2) var(--sp-3); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
.splitSearchIcon { color: var(--text-faint); flex-shrink: 0; }
.splitSearchInput { flex: 1; background: none; border: none; outline: none; font-size: var(--text-xs); color: var(--text-primary); font-family: var(--font-ui); min-width: 0; }
.splitSearchInput::placeholder { color: var(--text-faint); }
.splitSearchClear { color: var(--text-faint); font-size: 13px; line-height: 1; background: none; border: none; cursor: pointer; padding: 2px; transition: color var(--t-base); }
.splitSearchClear:hover { color: var(--text-muted); }
.splitList { flex: 1; overflow-y: auto; padding: var(--sp-1); scrollbar-width: thin; scrollbar-color: var(--border-dim) transparent; }
.splitSectionLabel { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wider); text-transform: uppercase; color: var(--text-faint); padding: var(--sp-2) var(--sp-3) var(--sp-1); pointer-events: none; user-select: none; }
.splitSectionLabelSpaced { margin-top: var(--sp-2); border-top: 1px solid var(--border-dim); padding-top: var(--sp-3); }
.splitItem { display: flex; align-items: center; gap: var(--sp-2); width: 100%; padding: 7px var(--sp-3); border-radius: var(--radius-md); border: 1px solid transparent; background: none; text-align: left; cursor: pointer; transition: background var(--t-fast), border-color var(--t-fast); }
.splitItem:hover { background: var(--bg-raised); border-color: var(--border-dim); }
.splitItemActive { background: var(--accent-muted); border-color: var(--accent-dim); }
.splitItemActive:hover { background: var(--accent-muted); }
.splitItemLabel { font-size: var(--text-xs); color: var(--text-muted); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.splitItemActive .splitItemLabel { color: var(--accent-fg); font-weight: var(--weight-medium); }
.splitEmpty { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); padding: var(--sp-3); margin: 0; }
.splitContent { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
.splitContentHeader { 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); }
.splitContentTitle { font-size: var(--text-base); font-weight: var(--weight-medium); color: var(--text-secondary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; letter-spacing: var(--tracking-tight); }
.splitResultCount { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); flex-shrink: 0; }
.tagActiveBar { display: flex; align-items: flex-start; gap: var(--sp-2); padding: var(--sp-2) var(--sp-4); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; flex-wrap: wrap; }
.tagPillRow { display: flex; flex-wrap: wrap; gap: var(--sp-1); flex: 1; min-width: 0; }
.tagPill { display: inline-flex; align-items: center; gap: 4px; padding: 2px 7px; background: var(--accent-muted); border: 1px solid var(--accent-dim); border-radius: var(--radius-sm); font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--accent-fg); }
.tagPillStatus { background: color-mix(in srgb, var(--color-info, #4a90d9) 12%, transparent); border-color: color-mix(in srgb, var(--color-info, #4a90d9) 30%, transparent); color: var(--color-info, #4a90d9); }
.tagPillRemove { color: currentColor; opacity: 0.6; font-size: 13px; line-height: 1; background: none; border: none; cursor: pointer; padding: 0; transition: opacity var(--t-base); }
.tagPillRemove:hover { opacity: 1; }
.tagBarRight { display: flex; align-items: center; gap: 4px; flex-shrink: 0; }
.tagModeToggle { display: flex; border: 1px solid var(--border-dim); border-radius: var(--radius-md); overflow: hidden; }
.tagModeBtn { display: flex; align-items: center; gap: 4px; padding: 4px 8px; font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--text-faint); background: none; border: none; border-right: 1px solid var(--border-dim); cursor: pointer; transition: color var(--t-base), background var(--t-base); }
.tagModeBtn:last-child { border-right: none; }
.tagModeBtn:hover { color: var(--text-muted); background: var(--bg-raised); }
.tagModeBtnActive { color: var(--accent-fg); background: var(--accent-muted); }
.tagModeBtnActive:hover { color: var(--accent-fg); background: var(--accent-muted); }
.tagClearAll { display: flex; align-items: center; font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--text-faint); padding: 4px 8px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: none; cursor: pointer; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
.tagClearAll:hover { color: var(--color-error); border-color: color-mix(in srgb, var(--color-error) 40%, transparent); background: var(--color-error-bg, color-mix(in srgb, var(--color-error) 8%, transparent)); }
.tagCheckMark { font-size: var(--text-xs); color: var(--accent-fg); margin-left: auto; }
.tagGrid { display: grid; grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); gap: var(--sp-4); padding: var(--sp-4); overflow-y: auto; flex: 1; align-content: start; will-change: scroll-position; }
.card { background: none; border: none; padding: 0; cursor: pointer; text-align: left; display: flex; flex-direction: column; gap: var(--sp-2); }
.coverWrap { position: relative; aspect-ratio: 2/3; overflow: hidden; border-radius: var(--radius-md); background: var(--bg-raised); border: 1px solid var(--border-dim); }
.cardTitle { font-size: var(--text-sm); color: var(--text-secondary); line-height: var(--leading-snug); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; transition: color var(--t-base); }
.inLibBadge { position: absolute; top: var(--sp-2); left: var(--sp-2); font-family: var(--font-ui); font-size: 9px; letter-spacing: var(--tracking-wide); background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim); border-radius: var(--radius-sm); padding: 1px 5px; }
.skCard { display: flex; flex-direction: column; gap: var(--sp-2); }
@keyframes shimmer { from { background-position: -200% 0 } to { background-position: 200% 0 } }
.skeleton { border-radius: var(--radius-sm); background: linear-gradient(90deg, var(--bg-raised) 25%, var(--bg-overlay, color-mix(in srgb, var(--bg-raised) 80%, var(--text-primary) 6%)) 50%, var(--bg-raised) 75%); background-size: 200% 100%; animation: shimmer 1.6s ease-in-out infinite; }
.skCover { aspect-ratio: 2/3; width: 100%; border-radius: var(--radius-md); }
.skTitle { height: 10px; width: 80%; }
.empty { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: var(--sp-2); padding: var(--sp-8); }
.emptyIcon { color: var(--text-faint); opacity: 0.5; }
.emptyText { font-size: var(--text-sm); color: var(--text-muted); font-weight: var(--weight-medium); margin: 0; }
.emptyHint { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); margin: 0; }
@keyframes anim-spin { from { transform: rotate(0deg) } to { transform: rotate(360deg) } }
.anim-spin { animation: anim-spin 0.8s linear infinite; }
</style>
+2
View File
@@ -0,0 +1,2 @@
export { default as Search } from "./components/Search.svelte";
export * from "./lib/searchFilter";

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