mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 01:09:56 -05:00
Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 514910667b | |||
| 2e9939c4a9 | |||
| 581aea5694 | |||
| 72a88b10c8 | |||
| 371b4af73f | |||
| 634d32f372 | |||
| 4e6be5d9f5 | |||
| bb7256c4f8 | |||
| b12ff4cbaa | |||
| 63a829ddca | |||
| 94b14fb7f6 | |||
| bd2fd7a6d7 | |||
| 6634ad56d2 | |||
| 2eb8a7662e | |||
| 7dd4f52308 | |||
| 690f59c602 |
@@ -7,6 +7,9 @@ on:
|
||||
description: "Version to build (e.g. 0.4.0)"
|
||||
required: true
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
frontend:
|
||||
name: Build frontend
|
||||
@@ -40,9 +43,6 @@ jobs:
|
||||
name: Tauri (macOS)
|
||||
needs: frontend
|
||||
runs-on: macos-latest
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -138,7 +138,6 @@ jobs:
|
||||
run: |
|
||||
sed -i '' 's/"beforeBuildCommand": "pnpm build"/"beforeBuildCommand": ""/' src-tauri/tauri.conf.json
|
||||
|
||||
# ── aarch64 build ──────────────────────────────────────────────────────
|
||||
- name: Swap bundle for aarch64
|
||||
run: |
|
||||
rm -rf src-tauri/binaries/suwayomi-bundle
|
||||
@@ -148,16 +147,8 @@ jobs:
|
||||
- name: Build Tauri app (aarch64)
|
||||
run: pnpm tauri build --target aarch64-apple-darwin --config src-tauri/tauri.macos.conf.json
|
||||
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 || '-' }}
|
||||
|
||||
# ── x86_64 build ───────────────────────────────────────────────────────
|
||||
- name: Swap bundle for x86_64
|
||||
run: |
|
||||
rm -rf src-tauri/binaries/suwayomi-bundle
|
||||
@@ -169,17 +160,35 @@ jobs:
|
||||
env:
|
||||
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY || '-' }}
|
||||
|
||||
# ── upload artifacts ───────────────────────────────────────────────────
|
||||
- name: Upload arm64 .dmg
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: moku-macos-arm64-${{ github.event.inputs.version }}
|
||||
path: src-tauri/target/aarch64-apple-darwin/release/bundle/dmg/*.dmg
|
||||
retention-days: 7
|
||||
- name: Upload macOS artifacts to release
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
VERSION: ${{ github.event.inputs.version }}
|
||||
run: |
|
||||
# Wait for the Windows workflow to have created the draft release
|
||||
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
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: moku-macos-x64-${{ github.event.inputs.version }}
|
||||
path: src-tauri/target/x86_64-apple-darwin/release/bundle/dmg/*.dmg
|
||||
retention-days: 7
|
||||
if [ -z "$RELEASE_ID" ]; then
|
||||
echo "ERROR: Could not find release for v$VERSION after waiting"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
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"
|
||||
@@ -155,8 +155,12 @@ jobs:
|
||||
tagName: v${{ github.event.inputs.version }}
|
||||
releaseName: Moku v${{ github.event.inputs.version }}
|
||||
releaseBody: |
|
||||
Windows installer for Moku v${{ github.event.inputs.version }}.
|
||||
Download the `.exe` file below to install or update.
|
||||
Moku v${{ github.event.inputs.version }}
|
||||
|
||||
**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
|
||||
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,5 +1,9 @@
|
||||
Major Revisions:
|
||||
- Moku-Share to Easily Migrate/Share Manga (Investigate Usecase/Feasibility)
|
||||
- Moku-Share allows exporting of Manga
|
||||
- Compressed Format (Storage)
|
||||
- Import as Local-Source
|
||||
- Takes existing Local-Source or Creates Own
|
||||
|
||||
Minor Revisions:
|
||||
- Investigate feasibility of Multi-Page Screenshot (Reader)
|
||||
@@ -8,16 +12,12 @@ Minor Revisions:
|
||||
- Look at how Manga are Organized in WebUI and Implement into Series-Detail (Chapter Display is Off)
|
||||
|
||||
|
||||
|
||||
Priority Bugs:
|
||||
- Fix Library-Refresh System (TESTING)
|
||||
|
||||
General/Misc Bugs:
|
||||
- Fix Highlightable Elements
|
||||
- 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?)
|
||||
- Suwayomi RESET
|
||||
- Allow User to Wipe Suwayomi (Scratch)
|
||||
- If Possible, Component based Wipe (Library, Etc)
|
||||
|
||||
|
||||
In-Progress:
|
||||
@@ -26,25 +26,14 @@ In-Progress:
|
||||
- Add Flathub Support (Pending Video)
|
||||
|
||||
- QOL Animations & Revamps
|
||||
- Extensions QOL Animations
|
||||
- Folders Slide
|
||||
- Dropdown Formatting (Repositories, Etc)
|
||||
- Extensions Revamps
|
||||
- Fix Pill-Shaped Language Filter
|
||||
- Fix ALL ALL EN Tag Issue
|
||||
- Search QOL Animations
|
||||
- Languages Dropdown Animations
|
||||
- Search Revamps
|
||||
- Custom Language Selector Modal
|
||||
- Change Tab Selector to match Extensions & Library Folders (Design)
|
||||
- Filter Genre should Filter Tags as well
|
||||
- Tracking Revamp
|
||||
- Completely Revamp Tracking
|
||||
|
||||
- Fix Search Folder Tabs (Right-Align)
|
||||
|
||||
- Fix Tracking Login
|
||||
- Pasting OAuth URL is not User-Friendly, Look for Alternatives
|
||||
|
||||
|
||||
Testing Bugs:
|
||||
- Reader Zoom does not work (Dropdown Slider, Value Adjustment); Goes to NaN
|
||||
- Fix Library Folders (Uneven Padding + Bleed into Other Folders); Appears Constraints are Off
|
||||
-
|
||||
|
||||
Notes from last time:
|
||||
- Currently working on #42, just need to mount panel and fix button in reader
|
||||
@@ -18,7 +18,7 @@
|
||||
|
||||
perSystem = { system, lib, ... }:
|
||||
let
|
||||
version = "0.8.0";
|
||||
version = "0.9.0";
|
||||
|
||||
pkgs = import inputs.nixpkgs {
|
||||
inherit system;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Generated
+76
-256
@@ -47,15 +47,6 @@ version = "1.0.102"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "atk"
|
||||
version = "0.18.2"
|
||||
@@ -268,9 +259,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.2.60"
|
||||
version = "1.2.61"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20"
|
||||
checksum = "d16d90359e986641506914ba71350897565610e87ce0ad9e6f28569db3dd5c6d"
|
||||
dependencies = [
|
||||
"find-msvc-tools",
|
||||
"shlex",
|
||||
@@ -290,7 +281,7 @@ checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
"fnv",
|
||||
"uuid 1.23.0",
|
||||
"uuid 1.23.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -584,17 +575,6 @@ dependencies = [
|
||||
"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]]
|
||||
name = "derive_more"
|
||||
version = "0.99.20"
|
||||
@@ -678,7 +658,7 @@ dependencies = [
|
||||
"libc",
|
||||
"option-ext",
|
||||
"redox_users 0.5.2",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -810,14 +790,14 @@ checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
|
||||
|
||||
[[package]]
|
||||
name = "embed-resource"
|
||||
version = "3.0.8"
|
||||
version = "3.0.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "63a1d0de4f2249aa0ff5884d7080814f446bb241a559af6c170a41e878ed2d45"
|
||||
checksum = "c31a88c8d26de40ed18fe748c547845aa39de1db3afd958f8cb91579f3644bcb"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"memchr",
|
||||
"rustc_version",
|
||||
"toml 0.9.12+spec-1.1.0",
|
||||
"toml 1.1.2+spec-1.1.0",
|
||||
"vswhom",
|
||||
"winreg",
|
||||
]
|
||||
@@ -861,7 +841,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -889,17 +869,6 @@ dependencies = [
|
||||
"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]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.9"
|
||||
@@ -1969,9 +1938,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.185"
|
||||
version = "0.2.186"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f"
|
||||
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
|
||||
|
||||
[[package]]
|
||||
name = "libloading"
|
||||
@@ -1989,10 +1958,7 @@ version = "0.1.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"libc",
|
||||
"plain",
|
||||
"redox_syscall 0.7.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2103,12 +2069,6 @@ version = "0.3.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
||||
|
||||
[[package]]
|
||||
name = "minisign-verify"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e"
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.8.9"
|
||||
@@ -2132,7 +2092,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "moku"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"dirs 5.0.1",
|
||||
"reqwest 0.12.28",
|
||||
@@ -2147,7 +2107,6 @@ dependencies = [
|
||||
"tauri-plugin-os",
|
||||
"tauri-plugin-process",
|
||||
"tauri-plugin-shell",
|
||||
"tauri-plugin-updater",
|
||||
"tokio",
|
||||
"urlencoding",
|
||||
"walkdir",
|
||||
@@ -2440,18 +2399,6 @@ dependencies = [
|
||||
"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]]
|
||||
name = "objc2-quartz-core"
|
||||
version = "0.3.2"
|
||||
@@ -2517,9 +2464,9 @@ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
|
||||
[[package]]
|
||||
name = "open"
|
||||
version = "5.3.3"
|
||||
version = "5.3.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "43bb73a7fa3799b198970490a51174027ba0d4ec504b03cd08caf513d40024bc"
|
||||
checksum = "9f3bab717c29a857abf75fcef718d441ec7cb2725f937343c734740a985d37fd"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"is-wsl",
|
||||
@@ -2529,9 +2476,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openssl"
|
||||
version = "0.10.77"
|
||||
version = "0.10.78"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bfe4646e360ec77dff7dde40ed3d6c5fee52d156ef4a62f53973d38294dad87f"
|
||||
checksum = "f38c4372413cdaaf3cc79dd92d29d7d9f5ab09b51b10dded508fb90bb70b9222"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"cfg-if",
|
||||
@@ -2561,9 +2508,9 @@ checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
|
||||
|
||||
[[package]]
|
||||
name = "openssl-sys"
|
||||
version = "0.9.113"
|
||||
version = "0.9.114"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ad2f2c0eba47118757e4c6d2bff2838f3e0523380021356e7875e858372ce644"
|
||||
checksum = "13ce1245cd07fcc4cfdb438f7507b0c7e4f3849a69fd84d52374c66d83741bb6"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"libc",
|
||||
@@ -2600,21 +2547,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.45.0",
|
||||
]
|
||||
|
||||
[[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",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2660,7 +2593,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"redox_syscall 0.5.18",
|
||||
"redox_syscall",
|
||||
"smallvec",
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
@@ -2765,7 +2698,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d5285893bb5eb82e6aaf5d59ee909a06a16737a8970984dd7746ba9283498d6"
|
||||
dependencies = [
|
||||
"phf_shared 0.10.0",
|
||||
"rand 0.8.5",
|
||||
"rand 0.8.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2775,7 +2708,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
|
||||
dependencies = [
|
||||
"phf_shared 0.11.3",
|
||||
"rand 0.8.5",
|
||||
"rand 0.8.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2876,12 +2809,6 @@ version = "0.3.33"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
|
||||
|
||||
[[package]]
|
||||
name = "plain"
|
||||
version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
|
||||
|
||||
[[package]]
|
||||
name = "plist"
|
||||
version = "1.8.0"
|
||||
@@ -3133,9 +3060,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.8.5"
|
||||
version = "0.8.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
|
||||
checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"rand_chacha 0.3.1",
|
||||
@@ -3262,15 +3189,6 @@ dependencies = [
|
||||
"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]]
|
||||
name = "redox_users"
|
||||
version = "0.4.6"
|
||||
@@ -3404,20 +3322,15 @@ dependencies = [
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-rustls",
|
||||
"hyper-util",
|
||||
"js-sys",
|
||||
"log",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"rustls-platform-verifier",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
"tower-http",
|
||||
@@ -3492,14 +3405,14 @@ dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.23.38"
|
||||
version = "0.23.39"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "69f9466fb2c14ea04357e91413efb882e2a6d4a406e625449bc0a5d360d53a21"
|
||||
checksum = "7c2c118cb077cca2822033836dfb1b975355dfb784b5e8da48f7b6c5db74e60e"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"ring",
|
||||
@@ -3509,60 +3422,21 @@ dependencies = [
|
||||
"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]]
|
||||
name = "rustls-pki-types"
|
||||
version = "1.14.0"
|
||||
version = "1.14.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd"
|
||||
checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9"
|
||||
dependencies = [
|
||||
"web-time",
|
||||
"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]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.12"
|
||||
version = "0.103.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8279bb85272c9f10811ae6a6c547ff594d6a7f3c6c6b02ee9726d1d0dcfcdd06"
|
||||
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
|
||||
dependencies = [
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
@@ -3611,7 +3485,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"url",
|
||||
"uuid 1.23.0",
|
||||
"uuid 1.23.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4001,7 +3875,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4019,7 +3893,7 @@ dependencies = [
|
||||
"objc2-foundation",
|
||||
"objc2-quartz-core",
|
||||
"raw-window-handle",
|
||||
"redox_syscall 0.5.18",
|
||||
"redox_syscall",
|
||||
"tracing",
|
||||
"wasm-bindgen",
|
||||
"web-sys",
|
||||
@@ -4292,17 +4166,6 @@ dependencies = [
|
||||
"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]]
|
||||
name = "target-lexicon"
|
||||
version = "0.12.16"
|
||||
@@ -4405,7 +4268,7 @@ dependencies = [
|
||||
"thiserror 2.0.18",
|
||||
"time",
|
||||
"url",
|
||||
"uuid 1.23.0",
|
||||
"uuid 1.23.1",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
@@ -4572,39 +4435,6 @@ dependencies = [
|
||||
"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]]
|
||||
name = "tauri-runtime"
|
||||
version = "2.10.1"
|
||||
@@ -4690,7 +4520,7 @@ dependencies = [
|
||||
"toml 0.9.12+spec-1.1.0",
|
||||
"url",
|
||||
"urlpattern",
|
||||
"uuid 1.23.0",
|
||||
"uuid 1.23.1",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
@@ -4715,7 +4545,7 @@ dependencies = [
|
||||
"getrandom 0.4.2",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4837,9 +4667,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
|
||||
|
||||
[[package]]
|
||||
name = "tokio"
|
||||
version = "1.51.1"
|
||||
version = "1.52.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f66bf9585cda4b724d3e78ab34b73fb2bbaba9011b9bfdf69dc836382ea13b8c"
|
||||
checksum = "b67dee974fe86fd92cc45b7a95fdd2f99a36a6d7b0d431a231178d3d670bbcc6"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"libc",
|
||||
@@ -4921,6 +4751,21 @@ dependencies = [
|
||||
"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]]
|
||||
name = "toml_datetime"
|
||||
version = "0.6.3"
|
||||
@@ -4981,7 +4826,7 @@ dependencies = [
|
||||
"indexmap 2.14.0",
|
||||
"toml_datetime 1.1.1+spec-1.1.0",
|
||||
"toml_parser",
|
||||
"winnow 1.0.1",
|
||||
"winnow 1.0.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4990,7 +4835,7 @@ version = "1.1.2+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526"
|
||||
dependencies = [
|
||||
"winnow 1.0.1",
|
||||
"winnow 1.0.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5099,9 +4944,9 @@ checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c"
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.19.0"
|
||||
version = "1.20.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
|
||||
checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de"
|
||||
|
||||
[[package]]
|
||||
name = "unic-char-property"
|
||||
@@ -5222,9 +5067,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "uuid"
|
||||
version = "1.23.0"
|
||||
version = "1.23.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9"
|
||||
checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76"
|
||||
dependencies = [
|
||||
"getrandom 0.4.2",
|
||||
"js-sys",
|
||||
@@ -5303,11 +5148,11 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
||||
|
||||
[[package]]
|
||||
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"
|
||||
checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5"
|
||||
checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6"
|
||||
dependencies = [
|
||||
"wit-bindgen",
|
||||
"wit-bindgen 0.57.1",
|
||||
]
|
||||
|
||||
[[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"
|
||||
checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5"
|
||||
dependencies = [
|
||||
"wit-bindgen",
|
||||
"wit-bindgen 0.51.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5443,9 +5288,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "web_atoms"
|
||||
version = "0.2.3"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "57a9779e9f04d2ac1ce317aee707aa2f6b773afba7b931222bff6983843b1576"
|
||||
checksum = "d7cff6eef815df1834fd250e3a2ff436044d82a9f1bc1980ca1dbdf07effc538"
|
||||
dependencies = [
|
||||
"phf 0.13.1",
|
||||
"phf_codegen 0.13.1",
|
||||
@@ -5497,20 +5342,11 @@ dependencies = [
|
||||
"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]]
|
||||
name = "webpki-roots"
|
||||
version = "1.0.6"
|
||||
version = "1.0.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed"
|
||||
checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
@@ -5573,7 +5409,7 @@ version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
dependencies = [
|
||||
"windows-sys 0.48.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6132,9 +5968,9 @@ checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
|
||||
|
||||
[[package]]
|
||||
name = "winnow"
|
||||
version = "1.0.1"
|
||||
version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5"
|
||||
checksum = "2ee1708bef14716a11bae175f579062d4554d95be2c6829f518df847b7b3fdd0"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
@@ -6158,6 +5994,12 @@ dependencies = [
|
||||
"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]]
|
||||
name = "wit-bindgen-core"
|
||||
version = "0.51.0"
|
||||
@@ -6308,16 +6150,6 @@ dependencies = [
|
||||
"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]]
|
||||
name = "yoke"
|
||||
version = "0.8.2"
|
||||
@@ -6421,18 +6253,6 @@ dependencies = [
|
||||
"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]]
|
||||
name = "zmij"
|
||||
version = "1.0.21"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "moku"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
@@ -17,7 +17,6 @@ tauri-build = { version = "2.0", features = [] }
|
||||
[dependencies]
|
||||
tauri = { version = "2.0", features = [] }
|
||||
tauri-plugin-shell = "2"
|
||||
tauri-plugin-updater = "2"
|
||||
tauri-plugin-process = "2"
|
||||
tauri-plugin-http = "2"
|
||||
tauri-plugin-dialog = "2"
|
||||
|
||||
@@ -26,9 +26,6 @@
|
||||
"core:window:allow-inner-position",
|
||||
"core:window:allow-outer-position",
|
||||
"core:window:allow-scale-factor",
|
||||
"updater:default",
|
||||
"updater:allow-check",
|
||||
"updater:allow-download-and-install",
|
||||
"process:default",
|
||||
"process:allow-restart",
|
||||
"http:default",
|
||||
|
||||
+131
-35
@@ -405,17 +405,14 @@ fn resolve_server_binary(
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
// Root of Moku.app/Contents/ — scan every subdirectory level by level.
|
||||
let resource_dir = app.path().resource_dir().unwrap_or_default();
|
||||
let contents_dir = resource_dir
|
||||
.parent() // Moku.app/Contents/
|
||||
.parent()
|
||||
.unwrap_or(&resource_dir)
|
||||
.to_path_buf();
|
||||
|
||||
do_log(log, &format!("[resolve] macOS contents_dir = {:?}", contents_dir));
|
||||
|
||||
// Native-binary names we recognise (most specific first so arch-specific
|
||||
// names win over the generic "suwayomi-server" if both somehow exist).
|
||||
const NATIVE_NAMES: &[&str] = &[
|
||||
"suwayomi-server-aarch64-apple-darwin",
|
||||
"suwayomi-server-x86_64-apple-darwin",
|
||||
@@ -425,11 +422,8 @@ fn resolve_server_binary(
|
||||
"tachidesk-server",
|
||||
];
|
||||
|
||||
// Collect every directory inside Contents/, grouped by depth so we
|
||||
// search shallower levels first (BFS order via WalkDir min/max_depth).
|
||||
// We go up to depth 8 which is more than enough for any real bundle.
|
||||
let mut found_binary: Option<ServerInvocation> = None;
|
||||
let mut found_java: Option<(PathBuf, PathBuf)> = None; // (java_exe, jar)
|
||||
let mut found_java: Option<(PathBuf, PathBuf)> = None;
|
||||
|
||||
'outer: for depth in 0u8..=8 {
|
||||
let entries: Vec<PathBuf> = WalkDir::new(&contents_dir)
|
||||
@@ -444,7 +438,6 @@ fn resolve_server_binary(
|
||||
for dir in &entries {
|
||||
do_log(log, &format!("[resolve] scanning depth={} dir={:?}", depth, dir));
|
||||
|
||||
// 1. Look for a native server binary in this directory.
|
||||
for name in NATIVE_NAMES {
|
||||
let p = dir.join(name);
|
||||
if p.exists() {
|
||||
@@ -458,15 +451,10 @@ fn resolve_server_binary(
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Look for a JRE java binary paired with a .jar in the same
|
||||
// or sibling directories. We record the first hit and keep
|
||||
// scanning natives; if no native is ever found we fall back
|
||||
// to this.
|
||||
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));
|
||||
// Search upward from the JRE dir for a .jar file.
|
||||
let mut search = dir.as_path();
|
||||
'jar: for _ in 0..5 {
|
||||
if let Ok(rd) = std::fs::read_dir(search) {
|
||||
@@ -479,7 +467,6 @@ fn resolve_server_binary(
|
||||
}
|
||||
}
|
||||
}
|
||||
// Also look in a sibling `bin/` directory.
|
||||
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()) {
|
||||
@@ -634,32 +621,62 @@ async fn list_releases() -> Result<Vec<ReleaseInfo>, String> {
|
||||
|
||||
#[tauri::command]
|
||||
#[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"))]
|
||||
return Err("Native install is Windows-only; open the GitHub release page instead.".into());
|
||||
|
||||
#[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 update = updater.check().await.map_err(|e| e.to_string())?;
|
||||
|
||||
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
|
||||
let client = reqwest::Client::builder()
|
||||
.user_agent("Moku")
|
||||
.build()
|
||||
.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(())
|
||||
}
|
||||
}
|
||||
@@ -698,7 +715,6 @@ fn open_path(path: String) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
#[tauri::command]
|
||||
async fn pick_downloads_folder(app: tauri::AppHandle) -> Option<String> {
|
||||
use tauri_plugin_dialog::DialogExt;
|
||||
@@ -709,6 +725,83 @@ async fn pick_downloads_folder(app: tauri::AppHandle) -> Option<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)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
@@ -718,7 +811,6 @@ pub fn run() {
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.plugin(tauri_plugin_http::init())
|
||||
.plugin(tauri_plugin_process::init())
|
||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||
.manage(ServerState(Mutex::new(None)))
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
get_storage_info,
|
||||
@@ -734,6 +826,10 @@ pub fn run() {
|
||||
restart_app,
|
||||
open_path,
|
||||
pick_downloads_folder,
|
||||
export_app_data,
|
||||
import_app_data,
|
||||
auto_backup_app_data,
|
||||
get_auto_backup_dir,
|
||||
])
|
||||
.setup(|_app| Ok(()))
|
||||
.on_window_event(|window, event| {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Moku",
|
||||
"version": "0.8.0",
|
||||
"version": "0.9.0",
|
||||
"identifier": "io.github.Youwes09.Moku.app",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
|
||||
+2
-2
@@ -58,7 +58,7 @@ export async function probeServer(): Promise<"ok" | "auth_required" | "unsupport
|
||||
const res = await fetch(`${base}/api/graphql`, {
|
||||
method: "POST", credentials: "omit", headers,
|
||||
body: JSON.stringify({ query: "{ __typename }" }),
|
||||
signal: AbortSignal.timeout(2000),
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
if (res.ok) return "ok";
|
||||
if (res.status === 401) {
|
||||
@@ -76,4 +76,4 @@ export async function probeServer(): Promise<"ok" | "auth_required" | "unsupport
|
||||
}
|
||||
return "unreachable";
|
||||
} catch { return "unreachable"; }
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -30,17 +30,10 @@
|
||||
if (!tabsEl) return;
|
||||
const active = tabsEl.querySelector<HTMLElement>(".tab.tabActive");
|
||||
if (!active) return;
|
||||
const containerLeft = tabsEl.getBoundingClientRect().left;
|
||||
tabIndicator = {
|
||||
left: active.getBoundingClientRect().left - containerLeft,
|
||||
width: active.offsetWidth,
|
||||
};
|
||||
tabIndicator = { left: active.offsetLeft, width: active.offsetWidth };
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
tab; // reactive on tab change
|
||||
if (anims) requestAnimationFrame(updateIndicator);
|
||||
});
|
||||
$effect(() => { tab; if (anims) requestAnimationFrame(() => requestAnimationFrame(updateIndicator)); });
|
||||
|
||||
const SEARCH_PAGES = 3;
|
||||
const SEARCH_LIMIT = 200;
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
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 {
|
||||
@@ -28,6 +30,17 @@
|
||||
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));
|
||||
@@ -93,11 +106,16 @@
|
||||
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>
|
||||
@@ -122,6 +140,7 @@
|
||||
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">
|
||||
@@ -132,11 +151,34 @@
|
||||
</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>
|
||||
@@ -235,6 +277,28 @@
|
||||
</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; }
|
||||
@@ -256,6 +320,8 @@
|
||||
.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; }
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { CircleNotch, ArrowUp, ArrowDown, ArrowClockwise, X } from "phosphor-svelte";
|
||||
import { CircleNotch, ArrowUp, ArrowDown, ArrowLineUp, ArrowLineDown, ArrowClockwise, X } from "phosphor-svelte";
|
||||
import Thumbnail from "@shared/manga/Thumbnail.svelte";
|
||||
import ContextMenu from "@shared/ui/ContextMenu.svelte";
|
||||
import type { MenuEntry } from "@shared/ui/ContextMenu.svelte";
|
||||
@@ -20,18 +20,20 @@
|
||||
onRemove: (chapterId: number) => void;
|
||||
onRetry: (chapterId: number) => void;
|
||||
onReorder: (chapterId: number, dir: "up" | "down") => void;
|
||||
onReorderEdge: (chapterId: number, edge: "top" | "bottom") => void;
|
||||
onSelect: (chapterId: number, e: MouseEvent | { shiftKey: boolean; ctrlKey: boolean; metaKey: boolean }) => void;
|
||||
onBatchRemove: () => void;
|
||||
onBatchRetry: () => void;
|
||||
onBatchReorder: (dir: "up" | "down") => void;
|
||||
onClearSelect: () => void;
|
||||
onBatchRemove: () => void;
|
||||
onBatchRetry: () => void;
|
||||
onBatchReorder: (dir: "up" | "down") => void;
|
||||
onBatchReorderEdge: (edge: "top" | "bottom") => void;
|
||||
onClearSelect: () => void;
|
||||
}
|
||||
|
||||
const {
|
||||
item, index, isActive, isFirst, isLast, isRemoving,
|
||||
isSelected, selectedCount, selectedErrorCount, batchWorking,
|
||||
onRemove, onRetry, onReorder, onSelect,
|
||||
onBatchRemove, onBatchRetry, onBatchReorder, onClearSelect,
|
||||
onRemove, onRetry, onReorder, onReorderEdge, onSelect,
|
||||
onBatchRemove, onBatchRetry, onBatchReorder, onBatchReorderEdge, onClearSelect,
|
||||
}: Props = $props();
|
||||
|
||||
const manga = $derived(item.chapter.manga);
|
||||
@@ -87,6 +89,19 @@
|
||||
const entries: MenuEntry[] = [];
|
||||
|
||||
if (inBatch) {
|
||||
entries.push({
|
||||
label: `Move to top (${selectedCount})`,
|
||||
icon: ArrowLineUp,
|
||||
onClick: () => onBatchReorderEdge("top"),
|
||||
disabled: batchWorking,
|
||||
});
|
||||
entries.push({
|
||||
label: `Move to bottom (${selectedCount})`,
|
||||
icon: ArrowLineDown,
|
||||
onClick: () => onBatchReorderEdge("bottom"),
|
||||
disabled: batchWorking,
|
||||
});
|
||||
entries.push({ separator: true });
|
||||
entries.push({
|
||||
label: `Move up (${selectedCount})`,
|
||||
icon: ArrowUp,
|
||||
@@ -127,6 +142,19 @@
|
||||
});
|
||||
entries.push({ separator: true });
|
||||
}
|
||||
entries.push({
|
||||
label: "Move to top",
|
||||
icon: ArrowLineUp,
|
||||
onClick: () => onReorderEdge(item.chapter.id, "top"),
|
||||
disabled: isFirst || isActive,
|
||||
});
|
||||
entries.push({
|
||||
label: "Move to bottom",
|
||||
icon: ArrowLineDown,
|
||||
onClick: () => onReorderEdge(item.chapter.id, "bottom"),
|
||||
disabled: isLast || isActive,
|
||||
});
|
||||
entries.push({ separator: true });
|
||||
entries.push({
|
||||
label: "Move up",
|
||||
icon: ArrowUp,
|
||||
|
||||
@@ -4,26 +4,28 @@
|
||||
import type { DownloadQueueItem } from "@types/index";
|
||||
|
||||
interface Props {
|
||||
queue: DownloadQueueItem[];
|
||||
loading: boolean;
|
||||
isRunning: boolean;
|
||||
dequeueing: Set<number>;
|
||||
selected: Set<number>;
|
||||
batchWorking: boolean;
|
||||
onRemove: (chapterId: number) => void;
|
||||
onRetry: (chapterId: number) => void;
|
||||
onReorder: (chapterId: number, dir: "up" | "down") => void;
|
||||
onSelect: (chapterId: number, e: MouseEvent) => void;
|
||||
onClearSelect: () => void;
|
||||
onBatchRemove: () => void;
|
||||
onBatchRetry: () => void;
|
||||
onBatchReorder: (dir: "up" | "down") => void;
|
||||
queue: DownloadQueueItem[];
|
||||
loading: boolean;
|
||||
isRunning: boolean;
|
||||
dequeueing: Set<number>;
|
||||
selected: Set<number>;
|
||||
batchWorking: boolean;
|
||||
onRemove: (chapterId: number) => void;
|
||||
onRetry: (chapterId: number) => void;
|
||||
onReorder: (chapterId: number, dir: "up" | "down") => void;
|
||||
onReorderEdge: (chapterId: number, edge: "top" | "bottom") => void;
|
||||
onSelect: (chapterId: number, e: MouseEvent) => void;
|
||||
onClearSelect: () => void;
|
||||
onBatchRemove: () => void;
|
||||
onBatchRetry: () => void;
|
||||
onBatchReorder: (dir: "up" | "down") => void;
|
||||
onBatchReorderEdge: (edge: "top" | "bottom") => void;
|
||||
}
|
||||
|
||||
const {
|
||||
queue, loading, isRunning, dequeueing, selected, batchWorking,
|
||||
onRemove, onRetry, onReorder, onSelect, onClearSelect,
|
||||
onBatchRemove, onBatchRetry, onBatchReorder,
|
||||
onRemove, onRetry, onReorder, onReorderEdge, onSelect, onClearSelect,
|
||||
onBatchRemove, onBatchRetry, onBatchReorder, onBatchReorderEdge,
|
||||
}: Props = $props();
|
||||
|
||||
const selectedErrorCount = $derived(
|
||||
@@ -39,6 +41,22 @@
|
||||
<div class="empty">Queue is empty.</div>
|
||||
{:else}
|
||||
<div class="list">
|
||||
<div class="list-header">
|
||||
<div class="info-wrap">
|
||||
<button class="info-btn" tabindex="-1" aria-label="Selection help">
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true">
|
||||
<circle cx="6" cy="6" r="5.25" stroke="currentColor" stroke-width="1.5"/>
|
||||
<rect x="5.25" y="5" width="1.5" height="3.5" rx="0.75" fill="currentColor"/>
|
||||
<circle cx="6" cy="3.25" r="0.85" fill="currentColor"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="info-popover" role="tooltip">
|
||||
<span>Click to select</span>
|
||||
<span>Shift+click to range select</span>
|
||||
<span>Ctrl+click to toggle</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{#each queue as item, i (item.chapter.id)}
|
||||
<DownloadItem
|
||||
{item}
|
||||
@@ -54,11 +72,13 @@
|
||||
{onRemove}
|
||||
{onRetry}
|
||||
{onReorder}
|
||||
{onReorderEdge}
|
||||
{onSelect}
|
||||
{onClearSelect}
|
||||
{onBatchRemove}
|
||||
{onBatchRetry}
|
||||
{onBatchReorder}
|
||||
{onBatchReorderEdge}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -71,6 +91,59 @@
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.list-header {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: 0 var(--sp-1);
|
||||
}
|
||||
|
||||
.info-wrap {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.info-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: none;
|
||||
background: none;
|
||||
padding: 0;
|
||||
cursor: default;
|
||||
color: var(--text-faint);
|
||||
border-radius: var(--radius-sm);
|
||||
transition: color var(--t-base);
|
||||
}
|
||||
.info-btn:hover { color: var(--text-muted); }
|
||||
|
||||
.info-popover {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: calc(100% + 6px);
|
||||
background: var(--bg-overlay);
|
||||
border: 1px solid var(--border-dim);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--sp-2) var(--sp-3);
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
white-space: nowrap;
|
||||
z-index: 50;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.info-popover span {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-2xs);
|
||||
color: var(--text-faint);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
}
|
||||
|
||||
.info-wrap:hover .info-popover { display: flex; }
|
||||
|
||||
.empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -121,11 +121,13 @@
|
||||
onRemove={(id) => downloadStore.dequeue(id)}
|
||||
onRetry={(id) => downloadStore.retryOne(id)}
|
||||
onReorder={(id, dir) => downloadStore.reorder(id, dir)}
|
||||
onReorderEdge={(id, edge) => downloadStore.reorderToEdge(id, edge)}
|
||||
onSelect={handleSelect}
|
||||
onClearSelect={() => { downloadStore.clearSelection(); selectAnchor = null; }}
|
||||
onBatchRemove={() => downloadStore.dequeueSelected()}
|
||||
onBatchRetry={() => downloadStore.retrySelected()}
|
||||
onBatchReorder={(dir) => downloadStore.reorderSelected(dir)}
|
||||
onBatchReorderEdge={(edge) => downloadStore.reorderSelectedToEdge(edge)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -55,6 +55,16 @@ export function estimateEta(pagesPerSec: number, queue: DownloadQueueItem[]): nu
|
||||
return remaining / pagesPerSec;
|
||||
}
|
||||
|
||||
export function reorderSelectedToEdge(
|
||||
queue: DownloadQueueItem[],
|
||||
selected: Set<number>,
|
||||
edge: "top" | "bottom",
|
||||
): DownloadQueueItem[] {
|
||||
const pinned = queue.filter((i) => selected.has(i.chapter.id));
|
||||
const rest = queue.filter((i) => !selected.has(i.chapter.id));
|
||||
return edge === "top" ? [...pinned, ...rest] : [...rest, ...pinned];
|
||||
}
|
||||
|
||||
export function formatEta(seconds: number): string {
|
||||
if (seconds < 60) return `~${Math.ceil(seconds)}s`;
|
||||
if (seconds < 3600) return `~${Math.ceil(seconds / 60)}m`;
|
||||
|
||||
@@ -231,6 +231,56 @@ class DownloadStore {
|
||||
finally { this.batchWorking = false; }
|
||||
}
|
||||
|
||||
async reorderToEdge(chapterId: number, edge: "top" | "bottom") {
|
||||
const idx = this.queue.findIndex((i) => i.chapter.id === chapterId);
|
||||
if (idx === -1) return;
|
||||
const first = this.isRunning ? 1 : 0;
|
||||
const last = this.queue.length - 1;
|
||||
const to = edge === "top" ? first : last;
|
||||
if (idx === to) return;
|
||||
const newQueue = [...this.queue];
|
||||
newQueue.splice(idx, 1);
|
||||
newQueue.splice(to, 0, this.queue[idx]);
|
||||
if (this.status) this.status = { ...this.status, queue: newQueue };
|
||||
try {
|
||||
const d = await gql<{ reorderChapterDownload: { downloadStatus: DownloadStatus } }>(
|
||||
REORDER_DOWNLOAD, { chapterId, to },
|
||||
);
|
||||
this.applyStatus(d.reorderChapterDownload.downloadStatus);
|
||||
} catch (e) { console.error(e); this.poll(); }
|
||||
}
|
||||
|
||||
async reorderSelectedToEdge(edge: "top" | "bottom") {
|
||||
if (this.batchWorking || this.selected.size === 0) return;
|
||||
this.batchWorking = true;
|
||||
|
||||
const pinned = this.queue.filter((i) => this.selected.has(i.chapter.id));
|
||||
const rest = this.queue.filter((i) => !this.selected.has(i.chapter.id));
|
||||
const newQueue = edge === "top" ? [...pinned, ...rest] : [...rest, ...pinned];
|
||||
if (this.status) this.status = { ...this.status, queue: newQueue };
|
||||
|
||||
const first = this.isRunning ? 1 : 0;
|
||||
const last = this.queue.length - 1;
|
||||
|
||||
try {
|
||||
if (edge === "top") {
|
||||
for (const item of [...pinned].reverse()) {
|
||||
await gql<{ reorderChapterDownload: { downloadStatus: DownloadStatus } }>(
|
||||
REORDER_DOWNLOAD, { chapterId: item.chapter.id, to: first },
|
||||
);
|
||||
}
|
||||
} else {
|
||||
for (const item of pinned) {
|
||||
await gql<{ reorderChapterDownload: { downloadStatus: DownloadStatus } }>(
|
||||
REORDER_DOWNLOAD, { chapterId: item.chapter.id, to: last },
|
||||
);
|
||||
}
|
||||
}
|
||||
this.poll();
|
||||
} catch (e) { console.error(e); this.poll(); }
|
||||
finally { this.batchWorking = false; }
|
||||
}
|
||||
|
||||
selectOnly(chapterId: number) { this.selected = new Set([chapterId]); }
|
||||
toggleSelect(chapterId: number) {
|
||||
const next = new Set(this.selected);
|
||||
|
||||
@@ -18,8 +18,7 @@
|
||||
if (!tabsEl) return;
|
||||
const active = tabsEl.querySelector<HTMLElement>(".tab.active");
|
||||
if (!active) return;
|
||||
const containerLeft = tabsEl.getBoundingClientRect().left;
|
||||
tabIndicator = { left: active.getBoundingClientRect().left - containerLeft, width: active.offsetWidth };
|
||||
tabIndicator = { left: active.offsetLeft, width: active.offsetWidth };
|
||||
}
|
||||
|
||||
let extensions: Extension[] = $state([]);
|
||||
@@ -33,7 +32,7 @@
|
||||
let expanded = $state(new Set<string>());
|
||||
let panel = $state<Panel>(null);
|
||||
|
||||
$effect(() => { filter; if (anims) requestAnimationFrame(() => requestAnimationFrame(updateIndicator)); });
|
||||
$effect(() => { filter; extensions; if (anims) requestAnimationFrame(() => requestAnimationFrame(updateIndicator)); });
|
||||
|
||||
let externalUrl = $state("");
|
||||
let installing = $state(false);
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
<script lang="ts">
|
||||
let {
|
||||
dailyReadCounts,
|
||||
}: {
|
||||
dailyReadCounts: Record<string, number>;
|
||||
} = $props();
|
||||
|
||||
function intensity(count: number): 0 | 1 | 2 | 3 | 4 {
|
||||
if (count === 0) return 0;
|
||||
if (count === 1) return 1;
|
||||
if (count <= 3) return 2;
|
||||
if (count <= 6) return 3;
|
||||
return 4;
|
||||
}
|
||||
|
||||
let tip: { text: string; x: number; y: number } | null = $state(null);
|
||||
|
||||
function showTip(e: MouseEvent, cell: { dateStr: string; count: number }) {
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
const label = cell.count === 0
|
||||
? `No chapters — ${fmtDate(cell.dateStr)}`
|
||||
: `${cell.count} chapter${cell.count !== 1 ? "s" : ""} — ${fmtDate(cell.dateStr)}`;
|
||||
tip = { text: label, x: rect.left + rect.width / 2, y: rect.top - 6 };
|
||||
}
|
||||
|
||||
function hideTip() { tip = null; }
|
||||
|
||||
function fmtDate(d: string): string {
|
||||
return new Date(d + "T00:00:00").toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
||||
}
|
||||
|
||||
let wrapEl: HTMLElement;
|
||||
let cellSize = $state(12);
|
||||
let numWeeks = $state(26);
|
||||
|
||||
const GAP = 3;
|
||||
const DAY_GUTTER = 28;
|
||||
const LEGEND_H = 20;
|
||||
const MONTH_H = 14;
|
||||
const ROWS = 7;
|
||||
|
||||
$effect(() => {
|
||||
if (!wrapEl) return;
|
||||
const obs = new ResizeObserver(() => {
|
||||
const h = wrapEl.clientHeight;
|
||||
const w = wrapEl.clientWidth;
|
||||
const cs = Math.max(8, Math.floor((h - LEGEND_H - MONTH_H - 2 * GAP - (ROWS - 1) * GAP) / ROWS));
|
||||
cellSize = cs;
|
||||
numWeeks = Math.max(4, Math.floor((w - DAY_GUTTER - GAP * 3) / (cs + GAP)));
|
||||
});
|
||||
obs.observe(wrapEl);
|
||||
return () => obs.disconnect();
|
||||
});
|
||||
|
||||
const visibleWeeks = $derived((() => {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const todayStr = today.toISOString().slice(0, 10);
|
||||
const endDow = today.getDay(); // 0=Sun ... 6=Sat
|
||||
const weekEnd = new Date(today);
|
||||
weekEnd.setDate(weekEnd.getDate() + (6 - endDow)); // advance to Saturday
|
||||
|
||||
const weeks: { dateStr: string; count: number; isToday: boolean; isFuture: boolean }[][] = [];
|
||||
for (let wi = numWeeks - 1; wi >= 0; wi--) {
|
||||
const week: typeof weeks[0] = [];
|
||||
for (let di = 0; di < 7; di++) {
|
||||
const d = new Date(weekEnd);
|
||||
d.setDate(d.getDate() - wi * 7 - (6 - di));
|
||||
const dateStr = d.toISOString().slice(0, 10);
|
||||
week.push({ dateStr, count: dailyReadCounts[dateStr] ?? 0, isToday: dateStr === todayStr, isFuture: d > today });
|
||||
}
|
||||
weeks.push(week);
|
||||
}
|
||||
return weeks;
|
||||
})());
|
||||
|
||||
const monthLabels = $derived((() => {
|
||||
const labels: { label: string; colIndex: number }[] = [];
|
||||
let lastMonth = -1;
|
||||
visibleWeeks.forEach((week, ci) => {
|
||||
const first = week[0];
|
||||
if (!first) return;
|
||||
const m = new Date(first.dateStr + "T00:00:00").getMonth();
|
||||
if (m !== lastMonth) {
|
||||
labels.push({ label: new Date(first.dateStr + "T00:00:00").toLocaleDateString("en-US", { month: "short" }), colIndex: ci });
|
||||
lastMonth = m;
|
||||
}
|
||||
});
|
||||
return labels;
|
||||
})());
|
||||
|
||||
const DAY_LABELS = ["Sun", "", "Tue", "", "Thu", "", "Sat"];
|
||||
</script>
|
||||
|
||||
<div class="heatmap-wrap" bind:this={wrapEl} style="--cell:{cellSize}px; --cols:{numWeeks};">
|
||||
|
||||
<div class="month-row">
|
||||
<div class="day-gutter"></div>
|
||||
<div class="month-cells">
|
||||
{#each visibleWeeks as _week, ci}
|
||||
{@const lbl = monthLabels.find(l => l.colIndex === ci)}
|
||||
<div class="month-label">{lbl?.label ?? ""}</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid-row">
|
||||
<div class="day-labels">
|
||||
{#each DAY_LABELS as d}
|
||||
<span class="day-label">{d}</span>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="cell-grid">
|
||||
{#each visibleWeeks as week}
|
||||
<div class="week-col">
|
||||
{#each week as cell}
|
||||
<!-- svelte-ignore a11y_mouse_events_have_key_events -->
|
||||
<button
|
||||
class="cell intensity-{intensity(cell.count)}"
|
||||
class:cell-today={cell.isToday}
|
||||
class:cell-future={cell.isFuture}
|
||||
onmouseover={(e) => showTip(e, cell)}
|
||||
onmouseleave={hideTip}
|
||||
aria-label="{cell.count} chapters on {cell.dateStr}"
|
||||
></button>
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="legend">
|
||||
<span class="legend-label">Less</span>
|
||||
{#each [0, 1, 2, 3, 4] as lvl}
|
||||
<div class="legend-cell intensity-{lvl}"></div>
|
||||
{/each}
|
||||
<span class="legend-label">More</span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{#if tip}
|
||||
<div class="heatmap-tip" style="left:{tip.x}px; top:{tip.y}px;">{tip.text}</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.heatmap-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.month-row {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.day-gutter { width: 28px; flex-shrink: 0; }
|
||||
.month-cells {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(var(--cols), var(--cell));
|
||||
gap: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.month-label {
|
||||
font-family: var(--font-ui);
|
||||
font-size: 9px;
|
||||
color: var(--text-faint);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
padding-left: 1px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.grid-row {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: flex-start;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.day-labels {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
flex-shrink: 0;
|
||||
width: 28px;
|
||||
}
|
||||
.day-label {
|
||||
font-family: var(--font-ui);
|
||||
font-size: 8px;
|
||||
color: var(--text-faint);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
height: var(--cell);
|
||||
line-height: var(--cell);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.cell-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(var(--cols), var(--cell));
|
||||
gap: 3px;
|
||||
overflow: visible;
|
||||
padding: 4px;
|
||||
margin: -4px;
|
||||
}
|
||||
.week-col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.cell {
|
||||
width: var(--cell);
|
||||
height: var(--cell);
|
||||
border-radius: 3px;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
transition: filter var(--t-fast), transform var(--t-fast);
|
||||
}
|
||||
.cell:hover:not(.cell-future) {
|
||||
filter: brightness(1.5);
|
||||
transform: scale(1.2);
|
||||
z-index: 1;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.intensity-0 { background: var(--bg-subtle); border: 1px solid var(--border-dim); }
|
||||
.intensity-1 { background: var(--accent-muted); border: 1px solid var(--accent-dim); }
|
||||
.intensity-2 { background: var(--accent-dim); border: 1px solid var(--accent); opacity: 0.7; }
|
||||
.intensity-3 { background: var(--accent); border: 1px solid var(--accent-bright); opacity: 0.85; }
|
||||
.intensity-4 { background: var(--accent-bright); border: 1px solid var(--accent-fg); }
|
||||
|
||||
.cell-today { outline: 1.5px solid var(--accent-fg); outline-offset: 1px; }
|
||||
.cell-future { opacity: 0.2; cursor: default; pointer-events: none; }
|
||||
|
||||
.legend {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
justify-content: flex-end;
|
||||
flex-shrink: 0;
|
||||
padding-top: 2px;
|
||||
}
|
||||
.legend-cell {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 3px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.legend-label {
|
||||
font-family: var(--font-ui);
|
||||
font-size: 9px;
|
||||
color: var(--text-faint);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
}
|
||||
|
||||
.heatmap-tip {
|
||||
position: fixed;
|
||||
transform: translate(-50%, -100%);
|
||||
background: var(--bg-overlay);
|
||||
border: 1px solid var(--border-base);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 4px 8px;
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-2xs);
|
||||
color: var(--text-secondary);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
z-index: 9999;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
</style>
|
||||
@@ -12,7 +12,8 @@
|
||||
import HeroStage from "./HeroStage.svelte";
|
||||
import HeroSlotPicker from "./HeroSlotPicker.svelte";
|
||||
import ActivityFeed from "./ActivityFeed.svelte";
|
||||
import UpdatesRow from "./UpdatesRow.svelte";
|
||||
import ActivityHeatmap from "./ActivityHeatmap.svelte";
|
||||
import RecsRow from "./RecsRow.svelte";
|
||||
import StatsGrid from "./StatsGrid.svelte";
|
||||
|
||||
let libraryManga: Manga[] = $state([]);
|
||||
@@ -223,44 +224,59 @@
|
||||
<div class="root">
|
||||
<div class="body">
|
||||
|
||||
<HeroStage
|
||||
{resolvedSlots}
|
||||
bind:activeIdx
|
||||
{heroThumb}
|
||||
{heroTitle}
|
||||
{heroManga}
|
||||
{heroEntry}
|
||||
{heroMangaId}
|
||||
{heroChapters}
|
||||
{loadingHeroChapters}
|
||||
{resuming}
|
||||
onresume={resumeActive}
|
||||
onopenchapter={openChapter}
|
||||
oncyclenext={cycleNext}
|
||||
oncycleprev={cyclePrev}
|
||||
ongotoslot={goToSlot}
|
||||
onopenpicker={openPicker}
|
||||
onunpin={unpinSlot}
|
||||
onviewall={() => { if (heroManga) store.activeManga = heroManga; }}
|
||||
/>
|
||||
<div class="hero-shrink-guard">
|
||||
<HeroStage
|
||||
{resolvedSlots}
|
||||
bind:activeIdx
|
||||
{heroThumb}
|
||||
{heroTitle}
|
||||
{heroManga}
|
||||
{heroEntry}
|
||||
{heroMangaId}
|
||||
{heroChapters}
|
||||
{loadingHeroChapters}
|
||||
{resuming}
|
||||
onresume={resumeActive}
|
||||
onopenchapter={openChapter}
|
||||
oncyclenext={cycleNext}
|
||||
oncycleprev={cyclePrev}
|
||||
ongotoslot={goToSlot}
|
||||
onopenpicker={openPicker}
|
||||
onunpin={unpinSlot}
|
||||
onviewall={() => { if (heroManga) store.activeManga = heroManga; }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ActivityFeed
|
||||
entries={recentHistory}
|
||||
onresume={resumeEntry}
|
||||
onviewhistory={() => setNavPage("history")}
|
||||
onopenlibrary={() => setNavPage("library")}
|
||||
/>
|
||||
<div class="scroll-body">
|
||||
<div class="mid-row">
|
||||
<div class="mid-left">
|
||||
<ActivityFeed
|
||||
entries={recentHistory}
|
||||
onresume={resumeEntry}
|
||||
onviewhistory={() => setNavPage("history")}
|
||||
onopenlibrary={() => setNavPage("library")}
|
||||
/>
|
||||
</div>
|
||||
<div class="mid-divider"></div>
|
||||
<div class="mid-right">
|
||||
<RecsRow
|
||||
{libraryManga}
|
||||
history={store.history}
|
||||
onopenrecommended={(m) => { store.previewManga = m; }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bottom-row">
|
||||
<UpdatesRow
|
||||
updates={libraryUpdates}
|
||||
{libraryManga}
|
||||
{lastRefresh}
|
||||
onopen={(m) => { if (m) store.previewManga = m; }}
|
||||
onclear={() => { clearLibraryUpdates(); setLibraryFilter("all"); setNavPage("library"); }}
|
||||
/>
|
||||
<div class="bottom-heatmap">
|
||||
<span class="bottom-label">Activity</span>
|
||||
<ActivityHeatmap dailyReadCounts={store.dailyReadCounts} />
|
||||
</div>
|
||||
<div class="bottom-divider"></div>
|
||||
<StatsGrid {stats} updateCount={libraryUpdates.length} />
|
||||
<div class="bottom-stats">
|
||||
<StatsGrid {stats} updateCount={libraryUpdates.length} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -288,19 +304,65 @@
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
}
|
||||
.hero-shrink-guard { flex-shrink: 0; }
|
||||
.scroll-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
min-height: 0;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.scroll-body::-webkit-scrollbar { display: none; }
|
||||
|
||||
.mid-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1px 1.4fr;
|
||||
border-top: 1px solid var(--border-dim);
|
||||
flex-shrink: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
.mid-left {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
/* suppress ActivityFeed's own border-top — mid-row provides it */
|
||||
.mid-left :global(.section) { border-top: none; }
|
||||
.mid-divider { background: var(--border-dim); align-self: stretch; }
|
||||
.mid-right {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
padding: var(--sp-3) var(--sp-4) var(--sp-4);
|
||||
}
|
||||
|
||||
.bottom-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1px 1fr;
|
||||
padding: var(--sp-4) var(--sp-4) var(--sp-5);
|
||||
border-top: 1px solid var(--border-dim);
|
||||
gap: var(--sp-4);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.bottom-divider { background: var(--border-dim); align-self: stretch; }
|
||||
.bottom-heatmap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-2);
|
||||
padding: var(--sp-4) var(--sp-4) var(--sp-5);
|
||||
min-width: 0;
|
||||
}
|
||||
.bottom-stats {
|
||||
padding: var(--sp-4) var(--sp-4) var(--sp-5);
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.bottom-label {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-2xs);
|
||||
color: var(--text-faint);
|
||||
letter-spacing: var(--tracking-wider);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(6px); }
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
<script lang="ts">
|
||||
import { ArrowLeft, ArrowRight, Sparkle } from "phosphor-svelte";
|
||||
import Thumbnail from "@shared/manga/Thumbnail.svelte";
|
||||
import type { Manga } from "@types";
|
||||
import type { HistoryEntry } from "@store/state.svelte";
|
||||
import { fetchRecommendations, topGenres } from "../lib/recommendations";
|
||||
import type { RecommendedManga } from "../lib/recommendations";
|
||||
|
||||
let {
|
||||
libraryManga,
|
||||
history,
|
||||
onopenrecommended,
|
||||
}: {
|
||||
libraryManga: Manga[];
|
||||
history: HistoryEntry[];
|
||||
onopenrecommended: (m: Manga) => void;
|
||||
} = $props();
|
||||
|
||||
const CARD_MIN_WIDTH = 100;
|
||||
const GAP = 12;
|
||||
const ROWS = 2;
|
||||
|
||||
let containerEl: HTMLDivElement | undefined = $state();
|
||||
let containerWidth = $state(0);
|
||||
|
||||
$effect(() => {
|
||||
if (!containerEl) return;
|
||||
const ro = new ResizeObserver(([entry]) => {
|
||||
containerWidth = entry.contentRect.width;
|
||||
});
|
||||
ro.observe(containerEl);
|
||||
return () => ro.disconnect();
|
||||
});
|
||||
|
||||
const cols = $derived(containerWidth > 0 ? Math.max(1, Math.floor((containerWidth + GAP) / (CARD_MIN_WIDTH + GAP))) : 6);
|
||||
const visibleCount = $derived(cols * ROWS);
|
||||
const gridStyle = $derived(`grid-template-columns: repeat(${cols}, 1fr);`);
|
||||
|
||||
let allRecs: RecommendedManga[] = $state([]);
|
||||
let loading = $state(false);
|
||||
let _ctrl: AbortController | null = null;
|
||||
|
||||
$effect(() => {
|
||||
const _history = history;
|
||||
const _library = libraryManga;
|
||||
if (!_history.length || !_library.length) { allRecs = []; return; }
|
||||
_ctrl?.abort();
|
||||
const ctrl = new AbortController();
|
||||
_ctrl = ctrl;
|
||||
loading = true;
|
||||
fetchRecommendations(_history, _library, ctrl.signal)
|
||||
.then(r => { if (!ctrl.signal.aborted) { allRecs = r; loading = false; } })
|
||||
.catch(() => { if (!ctrl.signal.aborted) loading = false; });
|
||||
});
|
||||
|
||||
const genres = $derived(topGenres(history, libraryManga));
|
||||
|
||||
let genreIdx = $state(0);
|
||||
|
||||
const activeGenre = $derived(genres[genreIdx] ?? null);
|
||||
|
||||
const visibleRecs = $derived(
|
||||
(activeGenre
|
||||
? allRecs.filter(r => r.matchedGenres.some(g => g.toLowerCase() === activeGenre.toLowerCase()))
|
||||
: allRecs
|
||||
).slice(0, visibleCount)
|
||||
);
|
||||
|
||||
function prev() { genreIdx = (genreIdx - 1 + genres.length) % genres.length; }
|
||||
function next() { genreIdx = (genreIdx + 1) % genres.length; }
|
||||
</script>
|
||||
|
||||
<div class="col">
|
||||
<div class="col-header">
|
||||
<span class="col-title">
|
||||
<Sparkle size={10} weight="bold" /> Recommended
|
||||
</span>
|
||||
{#if genres.length > 1}
|
||||
<div class="genre-switcher">
|
||||
<button class="nav-btn" onclick={prev}><ArrowLeft size={9} weight="bold" /></button>
|
||||
<span class="genre-label">{activeGenre}</span>
|
||||
<button class="nav-btn" onclick={next}><ArrowRight size={9} weight="bold" /></button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="grid-container" bind:this={containerEl}>
|
||||
{#if loading}
|
||||
<p class="empty-msg">Loading…</p>
|
||||
{:else if visibleRecs.length > 0}
|
||||
<div class="card-grid" style={gridStyle}>
|
||||
{#each visibleRecs as r (r.manga.id)}
|
||||
<button class="card" onclick={() => onopenrecommended(r.manga)}>
|
||||
<div class="card-cover-wrap">
|
||||
<Thumbnail src={r.manga.thumbnailUrl} alt={r.manga.title} class="card-cover" />
|
||||
<div class="card-gradient"></div>
|
||||
<div class="card-footer">
|
||||
<p class="card-title">{r.manga.title}</p>
|
||||
<p class="card-badge">{r.matchedGenres.slice(0, 2).join(" · ")}</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="empty-msg">No recommendations found</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.col { display: flex; flex-direction: column; min-width: 0; height: 100%; }
|
||||
|
||||
.col-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding-bottom: var(--sp-2);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.col-title {
|
||||
display: inline-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-wider);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.genre-switcher {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
.genre-label {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-2xs);
|
||||
color: var(--text-faint);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
text-transform: uppercase;
|
||||
min-width: 48px;
|
||||
text-align: center;
|
||||
}
|
||||
.nav-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 2px;
|
||||
color: var(--text-faint);
|
||||
transition: color var(--t-base);
|
||||
}
|
||||
.nav-btn:hover { color: var(--accent-fg); }
|
||||
|
||||
.grid-container {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card-grid {
|
||||
display: grid;
|
||||
grid-template-rows: repeat(2, auto);
|
||||
grid-auto-rows: 0;
|
||||
overflow: hidden;
|
||||
gap: var(--sp-3);
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.card {
|
||||
width: 100%;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
.card:hover :global(.card-cover) { filter: brightness(1.1) saturate(1.05); transform: scale(1.02); }
|
||||
|
||||
.card-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 14px rgba(0, 0, 0, 0.38);
|
||||
}
|
||||
:global(.card-cover) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
transition: filter 0.15s ease, transform 0.15s ease;
|
||||
}
|
||||
.card-gradient {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(to top, rgba(0,0,0,0.88) 0%, rgba(0,0,0,0.08) 55%, transparent 75%);
|
||||
pointer-events: none;
|
||||
}
|
||||
.card-footer {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: var(--sp-2);
|
||||
pointer-events: none;
|
||||
}
|
||||
.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);
|
||||
}
|
||||
.card-badge {
|
||||
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;
|
||||
}
|
||||
|
||||
.empty-msg {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-faint);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
padding: var(--sp-1) 0;
|
||||
}
|
||||
</style>
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { Fire, BookOpen, Clock, TrendUp, Bell, CalendarBlank } from "phosphor-svelte";
|
||||
import { formatReadTime } from "../lib/homeHelpers";
|
||||
|
||||
let {
|
||||
stats,
|
||||
updateCount,
|
||||
@@ -129,4 +130,4 @@
|
||||
letter-spacing: var(--tracking-wide);
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
@@ -1,187 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { Bell, ArrowRight } from "phosphor-svelte";
|
||||
import Thumbnail from "@shared/manga/Thumbnail.svelte";
|
||||
import type { Manga } from "@types";
|
||||
import { timeAgoRefresh, handleRowWheel } from "../lib/homeHelpers";
|
||||
|
||||
interface LibraryUpdate {
|
||||
mangaId: number;
|
||||
mangaTitle: string;
|
||||
thumbnailUrl: string;
|
||||
newChapters: number;
|
||||
}
|
||||
|
||||
let {
|
||||
updates,
|
||||
libraryManga,
|
||||
lastRefresh,
|
||||
onopen,
|
||||
onclear,
|
||||
}: {
|
||||
updates: LibraryUpdate[];
|
||||
libraryManga: Manga[];
|
||||
lastRefresh: number;
|
||||
onopen: (m: Manga | undefined) => void;
|
||||
onclear: () => void;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<div class="col">
|
||||
<div class="col-header">
|
||||
<span class="col-title">
|
||||
<Bell size={10} weight="bold" /> Updates
|
||||
{#if lastRefresh}<span class="refresh-age">{timeAgoRefresh(lastRefresh)}</span>{/if}
|
||||
</span>
|
||||
{#if updates.length > 0}
|
||||
<button class="action-btn" onclick={onclear}>
|
||||
Clear <ArrowRight size={9} weight="bold" />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if updates.length > 0}
|
||||
<div class="scroll-row" onwheel={(e) => { e.preventDefault(); handleRowWheel(e); }}>
|
||||
{#each updates as u (u.mangaId)}
|
||||
{@const m = libraryManga.find(x => x.id === u.mangaId)}
|
||||
<button class="card" onclick={() => onopen(m)}>
|
||||
<div class="card-cover-wrap">
|
||||
<Thumbnail src={u.thumbnailUrl} alt={u.mangaTitle} class="card-cover" />
|
||||
<div class="card-gradient"></div>
|
||||
<div class="card-footer">
|
||||
<p class="card-title">{u.mangaTitle}</p>
|
||||
<p class="card-badge">+{u.newChapters} chapter{u.newChapters !== 1 ? "s" : ""}</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="empty-msg">{lastRefresh ? "No new chapters found" : "Check for updates in the library"}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.col { display: flex; flex-direction: column; min-width: 0; }
|
||||
|
||||
.col-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding-bottom: var(--sp-2);
|
||||
}
|
||||
.col-title {
|
||||
display: inline-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-wider);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.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);
|
||||
}
|
||||
.action-btn {
|
||||
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);
|
||||
}
|
||||
.action-btn:hover { color: var(--accent-fg); }
|
||||
|
||||
.scroll-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: var(--sp-3);
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
scrollbar-width: none;
|
||||
padding-bottom: var(--sp-1);
|
||||
}
|
||||
.scroll-row::-webkit-scrollbar { display: none; }
|
||||
|
||||
.card {
|
||||
flex: 0 0 112px;
|
||||
width: 112px;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
.card:hover :global(.card-cover) { filter: brightness(1.1) saturate(1.05); transform: scale(1.02); }
|
||||
|
||||
.card-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 14px rgba(0, 0, 0, 0.38);
|
||||
}
|
||||
:global(.card-cover) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
transition: filter 0.15s ease, transform 0.15s ease;
|
||||
}
|
||||
.card-gradient {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(to top, rgba(0,0,0,0.88) 0%, rgba(0,0,0,0.08) 55%, transparent 75%);
|
||||
pointer-events: none;
|
||||
}
|
||||
.card-footer {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: var(--sp-2);
|
||||
pointer-events: none;
|
||||
}
|
||||
.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);
|
||||
}
|
||||
.card-badge {
|
||||
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;
|
||||
}
|
||||
|
||||
.empty-msg {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-faint);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
padding: var(--sp-1) 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,93 @@
|
||||
import { gql } from "@api/client";
|
||||
import { MANGAS_BY_GENRE } from "@api/queries/manga";
|
||||
import { buildTagFilter } from "@features/discover/lib/searchFilter";
|
||||
import type { Manga } from "@types";
|
||||
import type { HistoryEntry } from "@store/state.svelte";
|
||||
|
||||
export interface RecommendedManga {
|
||||
manga: Manga;
|
||||
matchedGenres: string[];
|
||||
}
|
||||
|
||||
const TOP_GENRES = 6;
|
||||
const PAGE_SIZE = 100;
|
||||
const MAX_PAGES = 5;
|
||||
|
||||
export function topGenres(history: HistoryEntry[], libraryManga: Manga[]): string[] {
|
||||
const byId = new Map(libraryManga.map(m => [m.id, m]));
|
||||
const tally = new Map<string, { count: number; original: string }>();
|
||||
|
||||
for (const entry of history) {
|
||||
const manga = byId.get(entry.mangaId);
|
||||
if (!manga?.genre?.length) continue;
|
||||
for (const g of manga.genre) {
|
||||
const key = g.toLowerCase();
|
||||
const existing = tally.get(key);
|
||||
if (existing) { existing.count++; }
|
||||
else { tally.set(key, { count: 1, original: g }); }
|
||||
}
|
||||
}
|
||||
|
||||
return [...tally.values()]
|
||||
.sort((a, b) => b.count - a.count)
|
||||
.slice(0, TOP_GENRES)
|
||||
.map(e => e.original);
|
||||
}
|
||||
|
||||
type Result = { mangas: { nodes: Manga[] } };
|
||||
|
||||
async function fetchGenrePages(genre: string, signal?: AbortSignal): Promise<Manga[]> {
|
||||
const filter = {
|
||||
and: [
|
||||
buildTagFilter([genre], "OR", []),
|
||||
{ inLibrary: { equalTo: false } },
|
||||
],
|
||||
};
|
||||
|
||||
const pages = await Promise.all(
|
||||
Array.from({ length: MAX_PAGES }, (_, i) =>
|
||||
gql<Result>(MANGAS_BY_GENRE, { filter, first: PAGE_SIZE, offset: i * PAGE_SIZE }, signal)
|
||||
.then(d => d.mangas.nodes)
|
||||
.catch(() => [] as Manga[])
|
||||
)
|
||||
);
|
||||
|
||||
const seen = new Set<number>();
|
||||
const nodes: Manga[] = [];
|
||||
for (const page of pages) {
|
||||
if (!page.length) break;
|
||||
for (const m of page) {
|
||||
if (!seen.has(m.id)) { seen.add(m.id); nodes.push(m); }
|
||||
}
|
||||
if (page.length < PAGE_SIZE) break;
|
||||
}
|
||||
return nodes;
|
||||
}
|
||||
|
||||
export async function fetchRecommendations(
|
||||
history: HistoryEntry[],
|
||||
libraryManga: Manga[],
|
||||
signal?: AbortSignal,
|
||||
): Promise<RecommendedManga[]> {
|
||||
if (!history.length || !libraryManga.length) return [];
|
||||
|
||||
const genres = topGenres(history, libraryManga);
|
||||
if (!genres.length) return [];
|
||||
|
||||
const perGenre = await Promise.all(genres.map(g => fetchGenrePages(g, signal)));
|
||||
|
||||
const seen = new Set<number>();
|
||||
const merged: Manga[] = [];
|
||||
for (const page of perGenre) {
|
||||
for (const m of page) {
|
||||
if (!seen.has(m.id)) { seen.add(m.id); merged.push(m); }
|
||||
}
|
||||
}
|
||||
|
||||
return merged.map(m => ({
|
||||
manga: m,
|
||||
matchedGenres: (m.genre ?? []).filter(g =>
|
||||
genres.some(tg => tg.toLowerCase() === g.toLowerCase())
|
||||
),
|
||||
}));
|
||||
}
|
||||
@@ -21,9 +21,10 @@
|
||||
import type { Manga, Category, Chapter } from "@types";
|
||||
import { checkAndMarkCompleted as storeCheckAndMarkCompleted } from "@store/state.svelte";
|
||||
|
||||
import LibraryToolbar from "./LibraryToolbar.svelte";
|
||||
import LibraryGrid from "./LibraryGrid.svelte";
|
||||
import LibraryFilters from "./LibraryFilters.svelte";
|
||||
import LibraryToolbar from "./LibraryToolbar.svelte";
|
||||
import LibraryGrid from "./LibraryGrid.svelte";
|
||||
import LibraryFilters from "./LibraryFilters.svelte";
|
||||
import BulkAutomationPanel from "../panels/BulkAutomationPanel.svelte";
|
||||
import ContextMenu, { type MenuEntry } from "@shared/ui/ContextMenu.svelte";
|
||||
|
||||
import { Books, DownloadSimple, Folder, FolderSimple, FolderSimplePlus, Trash, Star, CheckSquare, ArrowSquareOut } from "phosphor-svelte";
|
||||
@@ -48,10 +49,11 @@
|
||||
|
||||
let tabIndicator: { left: number; width: number } = $state({ left: 0, width: 0 });
|
||||
|
||||
let selectedIds: Set<number> = $state(new Set());
|
||||
let selectMode: boolean = $state(false);
|
||||
let bulkWorking: boolean = $state(false);
|
||||
let bulkMoveOpen: boolean = $state(false);
|
||||
let selectedIds: Set<number> = $state(new Set());
|
||||
let selectMode: boolean = $state(false);
|
||||
let bulkWorking: boolean = $state(false);
|
||||
let bulkMoveOpen: boolean = $state(false);
|
||||
let bulkAutomateOpen: boolean = $state(false);
|
||||
|
||||
let sortPanelOpen: boolean = $state(false);
|
||||
let filterPanelOpen: boolean = $state(false);
|
||||
@@ -166,7 +168,7 @@
|
||||
if (!store.categories.some(c => c.id === id)) untrack(() => { store.libraryFilter = "library"; });
|
||||
});
|
||||
$effect(() => { tab; untrack(() => exitSelectMode()); });
|
||||
$effect(() => { tab; setTimeout(updateTabIndicator); });
|
||||
$effect(() => { tab; counts; requestAnimationFrame(updateTabIndicator); });
|
||||
|
||||
let prevChapterId: number | null = null;
|
||||
$effect(() => {
|
||||
@@ -179,9 +181,7 @@
|
||||
if (!tabsEl) return;
|
||||
const active = tabsEl.querySelector<HTMLElement>(".tab.active");
|
||||
if (!active) return;
|
||||
const parent = tabsEl.getBoundingClientRect();
|
||||
const rect = active.getBoundingClientRect();
|
||||
tabIndicator = { left: rect.left - parent.left, width: rect.width };
|
||||
tabIndicator = { left: active.offsetLeft, width: active.offsetWidth };
|
||||
}
|
||||
|
||||
function enterSelectMode(id?: number) { selectMode = true; if (id !== undefined) selectedIds = new Set([id]); }
|
||||
@@ -310,6 +310,11 @@
|
||||
finally { bulkWorking = false; exitSelectMode(); }
|
||||
}
|
||||
|
||||
function bulkAutomate() {
|
||||
if (selectedIds.size === 0) return;
|
||||
bulkAutomateOpen = true;
|
||||
}
|
||||
|
||||
function sanitize(s: string) { return s.replace(/[\/\\?%*:|"<>]/g, "_"); }
|
||||
|
||||
async function openMangaFolder(m: Manga) {
|
||||
@@ -459,7 +464,7 @@
|
||||
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
document.addEventListener("mousedown", onDocMouseDown, true);
|
||||
updateTabIndicator();
|
||||
requestAnimationFrame(updateTabIndicator);
|
||||
|
||||
return () => {
|
||||
ro.disconnect(); unsub();
|
||||
@@ -509,6 +514,7 @@
|
||||
</div>
|
||||
{:else}
|
||||
<LibraryToolbar
|
||||
onclick={(e: MouseEvent) => { if (selectMode && !(e.target as HTMLElement).closest("button, input")) exitSelectMode(); }}
|
||||
{tab}
|
||||
{tabSortMode}
|
||||
{tabSortDir}
|
||||
@@ -567,6 +573,7 @@
|
||||
{remainingCount}
|
||||
renderLimit={store.settings.renderLimit ?? 48}
|
||||
cropCovers={store.settings.libraryCropCovers}
|
||||
statsAlways={store.settings.libraryStatsAlways ?? false}
|
||||
libraryFilter={tab}
|
||||
onCardClick={onCardClick}
|
||||
onCardContextMenu={openCtx}
|
||||
@@ -579,6 +586,7 @@
|
||||
onSelectAll={selectAll}
|
||||
onBulkMove={(cat) => { bulkMoveOpen = !bulkMoveOpen; }}
|
||||
onBulkRemove={bulkRemoveFromLibrary}
|
||||
onBulkAutomate={bulkAutomate}
|
||||
{bulkWorking}
|
||||
{bulkMoveOpen}
|
||||
{visibleCategories}
|
||||
@@ -593,6 +601,12 @@
|
||||
{#if emptyCtx}
|
||||
<ContextMenu x={emptyCtx.x} y={emptyCtx.y} items={buildEmptyCtx()} onClose={() => emptyCtx = null} />
|
||||
{/if}
|
||||
{#if bulkAutomateOpen}
|
||||
<BulkAutomationPanel
|
||||
ids={selectedIds}
|
||||
onClose={() => { bulkAutomateOpen = false; exitSelectMode(); }}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.root { position: relative; display: flex; flex-direction: column; height: 100%; overflow: visible; animation: fadeIn 0.14s ease both; }
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { Folder, Trash, CheckSquare, X } from "phosphor-svelte";
|
||||
import { Folder, Trash, CheckSquare, Robot } from "phosphor-svelte";
|
||||
import Thumbnail from "@shared/manga/Thumbnail.svelte";
|
||||
import type { Manga, Category } from "@types";
|
||||
|
||||
@@ -15,9 +15,9 @@
|
||||
remainingCount: number;
|
||||
renderLimit: number;
|
||||
cropCovers: boolean;
|
||||
statsAlways: boolean;
|
||||
libraryFilter: string;
|
||||
bulkWorking: boolean;
|
||||
bulkMoveOpen: boolean;
|
||||
visibleCategories: Category[];
|
||||
onCardClick: (e: MouseEvent, m: Manga) => void;
|
||||
onCardContextMenu: (e: MouseEvent, m: Manga) => void;
|
||||
@@ -30,42 +30,51 @@
|
||||
onSelectAll: () => void;
|
||||
onBulkMove: (cat: Category) => void;
|
||||
onBulkRemove: () => void;
|
||||
onCategoryMove: (cat: Category) => void;
|
||||
onBulkAutomate: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
visibleManga, filtered, loading, cols, anims, selectMode, selectedIds,
|
||||
hasMore, remainingCount, renderLimit, cropCovers, libraryFilter,
|
||||
bulkWorking, bulkMoveOpen, visibleCategories,
|
||||
hasMore, remainingCount, renderLimit, cropCovers, statsAlways, libraryFilter,
|
||||
bulkWorking, visibleCategories,
|
||||
onCardClick, onCardContextMenu, onCardPointerDown, onCardPointerUp, onCardPointerLeave,
|
||||
onLoadMore, onRetry, onExitSelectMode, onSelectAll, onBulkMove, onBulkRemove, onCategoryMove,
|
||||
onLoadMore, onRetry, onExitSelectMode, onSelectAll, onBulkMove, onBulkRemove, onBulkAutomate,
|
||||
}: Props = $props();
|
||||
|
||||
let bulkMoveOpen: boolean = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
if (!bulkMoveOpen) return;
|
||||
function onOutside(e: MouseEvent) {
|
||||
if (!(e.target as HTMLElement).closest(".bulk-move-wrap")) bulkMoveOpen = false;
|
||||
}
|
||||
setTimeout(() => document.addEventListener("mousedown", onOutside, true), 0);
|
||||
return () => document.removeEventListener("mousedown", onOutside, true);
|
||||
});
|
||||
|
||||
$effect(() => { if (!selectMode) bulkMoveOpen = false; });
|
||||
</script>
|
||||
|
||||
{#if selectMode}
|
||||
<div class="select-bar">
|
||||
<div class="select-bar-left">
|
||||
<button class="sel-btn sel-cancel" onclick={onExitSelectMode} title="Cancel (Esc)">
|
||||
<X size={13} weight="bold" />
|
||||
</button>
|
||||
<span class="sel-count">{selectedIds.size} selected</span>
|
||||
<button class="sel-btn sel-all" onclick={onSelectAll} title="Select all (⌘A)">Select all</button>
|
||||
</div>
|
||||
<span class="sel-count">{selectedIds.size} selected</span>
|
||||
<button class="sel-text-btn" onclick={onSelectAll} title="Select all (⌘A)">Select all</button>
|
||||
|
||||
<div class="select-bar-right">
|
||||
{#if visibleCategories.length}
|
||||
<div class="bulk-move-wrap">
|
||||
<button
|
||||
class="sel-btn sel-move"
|
||||
class="sel-action-btn"
|
||||
disabled={selectedIds.size === 0 || bulkWorking}
|
||||
onclick={() => onBulkMove(visibleCategories[0])}
|
||||
onclick={() => bulkMoveOpen = !bulkMoveOpen}
|
||||
>
|
||||
<Folder size={13} weight="bold" />
|
||||
Move to folder
|
||||
Move
|
||||
</button>
|
||||
{#if bulkMoveOpen}
|
||||
<div class="bulk-folder-list">
|
||||
{#each visibleCategories as cat}
|
||||
<button class="bulk-folder-item" onclick={() => onCategoryMove(cat)}>
|
||||
<button class="bulk-folder-item" onclick={() => { onBulkMove(cat); bulkMoveOpen = false; }}>
|
||||
<Folder size={11} weight="bold" />
|
||||
{cat.name}
|
||||
</button>
|
||||
@@ -74,7 +83,11 @@
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<button class="sel-btn sel-remove" disabled={selectedIds.size === 0 || bulkWorking} onclick={onBulkRemove}>
|
||||
<button class="sel-action-btn" disabled={selectedIds.size === 0 || bulkWorking} onclick={onBulkAutomate}>
|
||||
<Robot size={13} weight="bold" />
|
||||
Automate
|
||||
</button>
|
||||
<button class="sel-action-btn sel-action-danger" disabled={selectedIds.size === 0 || bulkWorking} onclick={onBulkRemove}>
|
||||
<Trash size={13} weight="bold" />
|
||||
Remove
|
||||
</button>
|
||||
@@ -82,7 +95,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="content">
|
||||
<div class="content" onclick={(e) => { if (selectMode && !(e.target as HTMLElement).closest(".card")) onExitSelectMode(); }}>
|
||||
{#if loading}
|
||||
<div class="grid">
|
||||
{#each Array(12) as _}
|
||||
@@ -116,23 +129,17 @@
|
||||
>
|
||||
<div class="cover-wrap" class:completed={isCompleted}>
|
||||
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="cover" style="object-fit:{cropCovers ? 'cover' : 'contain'}" draggable="false" />
|
||||
<div class="card-info-overlay" class:anim={anims} class:instant={!anims}>
|
||||
{#if isCompleted}
|
||||
<span class="info-chip info-chip-done">✓ complete</span>
|
||||
{:else if m.unreadCount}
|
||||
<span class="info-chip info-chip-unread">
|
||||
<span class="info-chip-dot"></span>
|
||||
{m.unreadCount} unread
|
||||
</span>
|
||||
{:else}
|
||||
<span></span>
|
||||
{/if}
|
||||
{#if m.downloadCount}
|
||||
<span class="info-chip info-chip-dl">
|
||||
<span class="info-chip-dot"></span>
|
||||
{m.downloadCount}
|
||||
</span>
|
||||
{/if}
|
||||
<div class="card-info-overlay" class:anim={anims} class:instant={!anims} class:always={statsAlways}>
|
||||
<div class="overlay-badges">
|
||||
{#if isCompleted}
|
||||
<span class="badge badge-done">✓ Done</span>
|
||||
{:else if m.unreadCount}
|
||||
<span class="badge badge-unread">{m.unreadCount} new</span>
|
||||
{/if}
|
||||
{#if m.downloadCount}
|
||||
<span class="badge badge-dl">↓ {m.downloadCount}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{#if selectMode}
|
||||
<div class="select-overlay" aria-hidden="true">
|
||||
@@ -163,22 +170,17 @@
|
||||
|
||||
<style>
|
||||
.content { flex: 1; overflow-y: auto; padding: var(--sp-5) var(--sp-6) var(--sp-6); will-change: scroll-position; -webkit-overflow-scrolling: touch; }
|
||||
.select-bar { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-3); padding: var(--sp-2) var(--sp-6); background: var(--bg-raised); border-bottom: 1px solid var(--accent-dim); flex-shrink: 0; animation: fadeIn 0.1s ease both; }
|
||||
.select-bar-left { display: flex; align-items: center; gap: var(--sp-3); }
|
||||
.select-bar-right { display: flex; align-items: center; gap: var(--sp-2); position: relative; }
|
||||
.sel-count { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--accent-fg); letter-spacing: var(--tracking-wide); }
|
||||
.sel-btn { display: flex; align-items: center; gap: 5px; font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); text-transform: uppercase; padding: 4px 10px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: var(--bg-base); color: var(--text-muted); cursor: pointer; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); white-space: nowrap; }
|
||||
.sel-btn:hover:not(:disabled) { color: var(--text-primary); border-color: var(--border-strong); }
|
||||
.sel-btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
.sel-cancel { border-color: transparent; background: transparent; }
|
||||
.sel-cancel:hover { background: var(--bg-raised); border-color: var(--border-dim); }
|
||||
.sel-move { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
|
||||
.sel-move:hover:not(:disabled) { background: var(--accent-dim); }
|
||||
.sel-remove { color: var(--color-error, #e05c5c); border-color: color-mix(in srgb, var(--color-error, #e05c5c) 30%, transparent); }
|
||||
.sel-remove:hover:not(:disabled) { background: color-mix(in srgb, var(--color-error, #e05c5c) 12%, transparent); }
|
||||
.sel-all { border-color: transparent; background: transparent; }
|
||||
.select-bar { display: flex; align-items: center; gap: var(--sp-2); padding: var(--sp-2) var(--sp-6); background: var(--bg-raised); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; animation: fadeIn 0.1s ease both; position: relative; z-index: 10; }
|
||||
.select-bar-right { display: flex; align-items: center; gap: var(--sp-2); margin-left: auto; position: relative; }
|
||||
.sel-count { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-secondary); letter-spacing: var(--tracking-wide); white-space: nowrap; }
|
||||
.sel-text-btn { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); background: none; border: none; cursor: pointer; padding: 2px 4px; border-radius: var(--radius-sm); transition: color var(--t-base); }
|
||||
.sel-text-btn:hover { color: var(--text-primary); }
|
||||
.sel-action-btn { display: flex; align-items: center; gap: 5px; font-family: var(--font-ui); font-size: var(--text-xs); padding: 5px 10px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: var(--bg-raised); color: var(--text-muted); cursor: pointer; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); white-space: nowrap; }
|
||||
.sel-action-btn:hover:not(:disabled) { color: var(--text-primary); border-color: var(--border-strong); }
|
||||
.sel-action-btn:disabled { opacity: 0.35; cursor: not-allowed; }
|
||||
.sel-action-danger:hover:not(:disabled) { color: var(--color-error, #e05c5c); border-color: color-mix(in srgb, var(--color-error, #e05c5c) 40%, transparent); background: color-mix(in srgb, var(--color-error, #e05c5c) 8%, transparent); }
|
||||
.bulk-move-wrap { position: relative; }
|
||||
.bulk-folder-list { position: absolute; top: calc(100% + 4px); right: 0; z-index: 200; background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 4px; min-width: 160px; box-shadow: 0 8px 24px rgba(0,0,0,0.35); animation: fadeIn 0.1s ease both; }
|
||||
.bulk-folder-list { position: absolute; top: calc(100% + 4px); right: 0; z-index: 9999; background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 4px; min-width: 160px; box-shadow: 0 8px 24px rgba(0,0,0,0.35); animation: fadeIn 0.1s ease both; }
|
||||
.bulk-folder-item { display: flex; align-items: center; gap: 6px; width: 100%; padding: 6px 10px; border-radius: var(--radius-sm); border: none; background: transparent; color: var(--text-muted); font-family: var(--font-ui); font-size: var(--text-xs); cursor: pointer; text-align: left; transition: background var(--t-base), color var(--t-base); }
|
||||
.bulk-folder-item:hover { background: var(--bg-hover, var(--bg-base)); color: var(--text-primary); }
|
||||
.grid { position: relative; z-index: 1; isolation: isolate; display: grid; grid-template-columns: repeat(var(--cols, auto-fill), minmax(130px, 1fr)); gap: var(--sp-4); }
|
||||
@@ -193,15 +195,16 @@
|
||||
.card.anims .cover-wrap { transition: transform 0.18s cubic-bezier(0.16,1,0.3,1), border-color var(--t-base), box-shadow 0.18s cubic-bezier(0.16,1,0.3,1); }
|
||||
.cover-wrap.completed { box-shadow: inset 0 -2px 0 0 var(--accent); }
|
||||
.card.anims .cover { transition: filter var(--t-base); }
|
||||
.card-info-overlay { position: absolute; bottom: 0; left: 0; right: 0; display: flex; align-items: flex-end; justify-content: space-between; padding: 20px 5px 5px; background: linear-gradient(to top, rgba(0,0,0,0.72) 0%, rgba(0,0,0,0.3) 55%, transparent 100%); opacity: 0; transform: translateY(3px); pointer-events: none; }
|
||||
.card-info-overlay.anim { transition: opacity 0.18s ease, transform 0.18s cubic-bezier(0.16,1,0.3,1); }
|
||||
.card-info-overlay { position: absolute; bottom: -4px; left: 0; right: 0; padding: 32px 6px 10px; background: linear-gradient(to top, rgba(0,0,0,0.88) 0%, rgba(0,0,0,0.5) 50%, transparent 100%); opacity: 0; pointer-events: none; }
|
||||
.card-info-overlay.anim { transition: opacity 0.18s ease; }
|
||||
.card-info-overlay.instant { transition: none; }
|
||||
.card:not(.select-mode):hover .card-info-overlay { opacity: 1; transform: translateY(0); }
|
||||
.info-chip { display: flex; align-items: center; gap: 4px; font-size: 10px; font-weight: 700; letter-spacing: 0.03em; line-height: 1; padding: 3px 6px; border-radius: 4px; background: rgba(0,0,0,0.52); backdrop-filter: blur(6px); }
|
||||
.info-chip-unread { color: #fff; }
|
||||
.info-chip-done { color: var(--accent-fg); font-size: 9px; letter-spacing: 0.06em; text-transform: uppercase; }
|
||||
.info-chip-dl { color: var(--accent-fg); }
|
||||
.info-chip-dot { width: 4px; height: 4px; border-radius: 50%; background: currentColor; flex-shrink: 0; }
|
||||
.card-info-overlay.always { opacity: 1; }
|
||||
.card:not(.select-mode):hover .card-info-overlay { opacity: 1; }
|
||||
.overlay-badges { display: flex; align-items: flex-end; justify-content: space-between; gap: 4px; flex-wrap: wrap; }
|
||||
.badge { font-family: var(--font-ui); font-size: 9.5px; font-weight: 700; letter-spacing: 0.04em; line-height: 1; padding: 3px 7px; border-radius: 20px; white-space: nowrap; }
|
||||
.badge-unread { background: var(--accent); color: #fff; box-shadow: 0 1px 8px rgba(0,0,0,0.5); }
|
||||
.badge-done { background: rgba(255,255,255,0.18); color: rgba(255,255,255,0.9); border: 1px solid rgba(255,255,255,0.25); }
|
||||
.badge-dl { background: rgba(0,0,0,0.55); color: rgba(255,255,255,0.8); border: 1px solid rgba(255,255,255,0.18); margin-left: auto; }
|
||||
.select-overlay { position: absolute; inset: 0; background: rgba(0,0,0,0.18); display: flex; align-items: flex-start; justify-content: flex-end; padding: 6px; pointer-events: none; }
|
||||
.select-check { color: var(--text-faint); opacity: 0.7; transition: color var(--t-base), opacity var(--t-base); }
|
||||
.select-check.checked { color: var(--accent-fg); opacity: 1; }
|
||||
@@ -217,4 +220,4 @@
|
||||
.load-more-count { color: var(--text-faint); font-size: var(--text-2xs); }
|
||||
.center { position: relative; z-index: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; height: 60%; color: var(--text-muted); font-size: var(--text-sm); gap: var(--sp-2); text-align: center; line-height: var(--leading-base); }
|
||||
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
|
||||
</style>
|
||||
</style>
|
||||
@@ -202,8 +202,8 @@
|
||||
.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: grab; }
|
||||
.tab:hover { color: var(--text-muted); }
|
||||
.tab.active { background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim); }
|
||||
.tabs-anims .tab.active { background: transparent; border-color: transparent; }
|
||||
.tab.active { background: var(--accent-muted); color: var(--accent-fg); border: 1px solid transparent; }
|
||||
.tabs-anims .tab.active { background: transparent; }
|
||||
.tab-dragging { opacity: 0.4; cursor: grabbing; }
|
||||
.tab-drop-target { background: var(--accent-muted) !important; color: var(--accent-fg) !important; outline: 1px dashed var(--accent); }
|
||||
.tab-insert-bar { width: 2px; height: 22px; background: var(--accent); border-radius: 2px; flex-shrink: 0; box-shadow: 0 0 6px var(--accent); pointer-events: none; }
|
||||
|
||||
@@ -4,8 +4,8 @@ import { UPDATE_LIBRARY } from "@api/mutations/manga";
|
||||
import { GET_RECENTLY_UPDATED } from "@api/queries/chapters";
|
||||
import type { LibraryUpdateEntry } from "@store/state.svelte";
|
||||
|
||||
const POLL_INTERVAL_MS = 3000;
|
||||
const POLL_INITIAL_MS = 2000;
|
||||
const POLL_INTERVAL_MS = 3000;
|
||||
const POLL_INITIAL_MS = 2000;
|
||||
|
||||
export interface UpdateProgress {
|
||||
finished: number;
|
||||
@@ -64,7 +64,13 @@ export function startLibraryUpdate(callbacks: LibraryUpdaterCallbacks): () => vo
|
||||
|
||||
if (!jobsInfo.isRunning && seenWork) {
|
||||
const recent = await gql<{
|
||||
chapters: { nodes: { mangaId: number; mangaTitle: string; thumbnailUrl: string; fetchedAt: string }[] }
|
||||
chapters: {
|
||||
nodes: {
|
||||
mangaId: number;
|
||||
fetchedAt: string;
|
||||
manga: { id: number; title: string; thumbnailUrl: string; inLibrary: boolean };
|
||||
}[]
|
||||
}
|
||||
}>(GET_RECENTLY_UPDATED, {}).catch(() => ({ chapters: { nodes: [] } }));
|
||||
|
||||
if (cancelled) return;
|
||||
@@ -79,8 +85,8 @@ export function startLibraryUpdate(callbacks: LibraryUpdaterCallbacks): () => vo
|
||||
} else {
|
||||
byManga.set(ch.mangaId, {
|
||||
mangaId: ch.mangaId,
|
||||
mangaTitle: ch.mangaTitle,
|
||||
thumbnailUrl: ch.thumbnailUrl,
|
||||
mangaTitle: ch.manga.title,
|
||||
thumbnailUrl: ch.manga.thumbnailUrl,
|
||||
newChapters: 1,
|
||||
checkedAt: Date.now(),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
<script lang="ts">
|
||||
import { X } from "phosphor-svelte";
|
||||
import { setPref } from "@features/series/lib/mangaPrefs";
|
||||
import { store } from "@store/state.svelte";
|
||||
import { DEFAULT_MANGA_PREFS } from "@store/state.svelte";
|
||||
import type { MangaPrefs } from "@store/state.svelte";
|
||||
|
||||
let { ids, onClose }: {
|
||||
ids: Set<number>;
|
||||
onClose: () => void;
|
||||
} = $props();
|
||||
|
||||
const DOWNLOAD_AHEAD_OPTIONS = [
|
||||
{ value: 0, label: "Off" },
|
||||
{ value: 2, label: "2" },
|
||||
{ value: 5, label: "5" },
|
||||
{ value: 10, label: "10" },
|
||||
];
|
||||
|
||||
const MAX_KEEP_OPTIONS = [
|
||||
{ value: 0, label: "Off" },
|
||||
{ value: 5, label: "5" },
|
||||
{ value: 10, label: "10" },
|
||||
{ value: 25, label: "25" },
|
||||
];
|
||||
|
||||
const DELETE_DELAY_OPTIONS = [
|
||||
{ value: 0, label: "Now" },
|
||||
{ value: 24, label: "1 day" },
|
||||
{ value: 168, label: "1 week" },
|
||||
];
|
||||
|
||||
const REFRESH_INTERVAL_OPTIONS = [
|
||||
{ value: "global", label: "Default" },
|
||||
{ value: "daily", label: "Daily" },
|
||||
{ value: "weekly", label: "Weekly" },
|
||||
{ value: "manual", label: "Manual" },
|
||||
];
|
||||
|
||||
let draft: MangaPrefs = $state({ ...DEFAULT_MANGA_PREFS });
|
||||
|
||||
const get = <K extends keyof MangaPrefs>(key: K): MangaPrefs[K] => draft[key];
|
||||
const set = <K extends keyof MangaPrefs>(key: K, value: MangaPrefs[K]) => { draft = { ...draft, [key]: value }; };
|
||||
|
||||
function apply() {
|
||||
for (const id of ids) {
|
||||
for (const key of Object.keys(draft) as (keyof MangaPrefs)[]) {
|
||||
setPref(id, key, draft[key] as any);
|
||||
}
|
||||
}
|
||||
onClose();
|
||||
}
|
||||
|
||||
function onBackdrop(e: MouseEvent) {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="backdrop" role="presentation" tabindex="-1" onmousedown={onBackdrop}>
|
||||
<div class="modal" role="dialog" aria-modal="true" aria-label="Bulk Automation">
|
||||
|
||||
<div class="modal-header">
|
||||
<div class="header-left">
|
||||
<span class="modal-title">Automation</span>
|
||||
<span class="modal-subtitle">{ids.size} series selected</span>
|
||||
</div>
|
||||
<button class="close-btn" onclick={onClose} aria-label="Close"><X size={16} weight="light" /></button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
|
||||
<p class="section-label">Downloads</p>
|
||||
|
||||
<div class="auto-row">
|
||||
<div class="auto-info">
|
||||
<span class="auto-label">Auto-download new chapters</span>
|
||||
<span class="auto-desc">Queue new chapters when this series refreshes</span>
|
||||
</div>
|
||||
<button
|
||||
role="switch"
|
||||
aria-checked={get("autoDownload")}
|
||||
aria-label="Auto-download new chapters"
|
||||
class="auto-toggle"
|
||||
class:auto-toggle-on={get("autoDownload")}
|
||||
onclick={() => set("autoDownload", !get("autoDownload"))}
|
||||
><span class="auto-toggle-thumb"></span></button>
|
||||
</div>
|
||||
|
||||
<div class="auto-row">
|
||||
<div class="auto-info">
|
||||
<span class="auto-label">Download ahead</span>
|
||||
<span class="auto-desc">Pre-fetch chapters while reading</span>
|
||||
</div>
|
||||
<div class="auto-chip-group">
|
||||
{#each DOWNLOAD_AHEAD_OPTIONS as opt}
|
||||
<button
|
||||
class="auto-chip"
|
||||
class:auto-chip-on={get("downloadAhead") === opt.value}
|
||||
onclick={() => set("downloadAhead", opt.value)}
|
||||
>{opt.label}</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="auto-row">
|
||||
<div class="auto-info">
|
||||
<span class="auto-label">Max chapters to keep</span>
|
||||
<span class="auto-desc">Delete oldest downloads when limit is exceeded</span>
|
||||
</div>
|
||||
<div class="auto-chip-group">
|
||||
{#each MAX_KEEP_OPTIONS as opt}
|
||||
<button
|
||||
class="auto-chip"
|
||||
class:auto-chip-on={get("maxKeepChapters") === opt.value}
|
||||
onclick={() => set("maxKeepChapters", opt.value)}
|
||||
>{opt.label}</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<p class="section-label">On Read</p>
|
||||
|
||||
<div class="auto-row">
|
||||
<div class="auto-info">
|
||||
<span class="auto-label">Delete after reading</span>
|
||||
<span class="auto-desc">Remove download when chapter is marked read</span>
|
||||
</div>
|
||||
<button
|
||||
role="switch"
|
||||
aria-checked={get("deleteOnRead")}
|
||||
aria-label="Delete after reading"
|
||||
class="auto-toggle"
|
||||
class:auto-toggle-on={get("deleteOnRead")}
|
||||
onclick={() => set("deleteOnRead", !get("deleteOnRead"))}
|
||||
><span class="auto-toggle-thumb"></span></button>
|
||||
</div>
|
||||
|
||||
{#if get("deleteOnRead")}
|
||||
<div class="auto-row auto-row-sub">
|
||||
<span class="auto-label">Delete delay</span>
|
||||
<div class="auto-chip-group">
|
||||
{#each DELETE_DELAY_OPTIONS as opt}
|
||||
<button
|
||||
class="auto-chip"
|
||||
class:auto-chip-on={get("deleteDelayHours") === opt.value}
|
||||
onclick={() => set("deleteDelayHours", opt.value)}
|
||||
>{opt.label}</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<p class="section-label">Updates</p>
|
||||
|
||||
<div class="auto-row">
|
||||
<div class="auto-info">
|
||||
<span class="auto-label">Pause updates</span>
|
||||
<span class="auto-desc">Skip this series during global refresh</span>
|
||||
</div>
|
||||
<button
|
||||
role="switch"
|
||||
aria-checked={get("pauseUpdates")}
|
||||
aria-label="Pause updates"
|
||||
class="auto-toggle"
|
||||
class:auto-toggle-on={get("pauseUpdates")}
|
||||
onclick={() => set("pauseUpdates", !get("pauseUpdates"))}
|
||||
><span class="auto-toggle-thumb"></span></button>
|
||||
</div>
|
||||
|
||||
<div class="auto-row">
|
||||
<div class="auto-info">
|
||||
<span class="auto-label">Refresh interval</span>
|
||||
<span class="auto-desc">How often to check for new chapters</span>
|
||||
</div>
|
||||
<div class="auto-chip-group">
|
||||
{#each REFRESH_INTERVAL_OPTIONS as opt}
|
||||
<button
|
||||
class="auto-chip"
|
||||
class:auto-chip-on={get("refreshInterval") === opt.value}
|
||||
onclick={() => set("refreshInterval", opt.value as MangaPrefs["refreshInterval"])}
|
||||
>{opt.label}</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button class="apply-btn" onclick={apply}>Apply to {ids.size} series</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.backdrop {
|
||||
position: fixed; inset: 0; z-index: 300;
|
||||
background: rgba(0,0,0,0.6);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
animation: fadeIn 0.1s ease both;
|
||||
}
|
||||
|
||||
.modal {
|
||||
width: 420px; max-width: calc(100vw - var(--sp-6));
|
||||
max-height: 80vh;
|
||||
display: flex; flex-direction: column;
|
||||
background: var(--bg-surface); border: 1px solid var(--border-base);
|
||||
border-radius: var(--radius-xl); overflow: hidden;
|
||||
box-shadow: 0 0 0 1px var(--border-dim), 0 24px 64px rgba(0,0,0,0.6);
|
||||
animation: scaleIn 0.15s ease both;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: var(--sp-4) var(--sp-5); border-bottom: 1px solid var(--border-dim); flex-shrink: 0;
|
||||
}
|
||||
.header-left { display: flex; flex-direction: column; gap: 2px; }
|
||||
.modal-title { font-size: var(--text-base); font-weight: var(--weight-medium); color: var(--text-primary); letter-spacing: var(--tracking-tight); }
|
||||
.modal-subtitle { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||
.close-btn { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: var(--radius-sm); color: var(--text-faint); background: none; border: none; cursor: pointer; transition: color var(--t-base), background var(--t-base); flex-shrink: 0; }
|
||||
.close-btn:hover { color: var(--text-muted); background: var(--bg-raised); }
|
||||
|
||||
.modal-body {
|
||||
flex: 1; overflow-y: auto; scrollbar-width: none;
|
||||
display: flex; flex-direction: column; gap: var(--sp-3);
|
||||
padding: var(--sp-4) var(--sp-5);
|
||||
}
|
||||
.modal-body::-webkit-scrollbar { display: none; }
|
||||
|
||||
.modal-footer {
|
||||
padding: var(--sp-3) var(--sp-5); border-top: 1px solid var(--border-dim); flex-shrink: 0;
|
||||
}
|
||||
.apply-btn {
|
||||
width: 100%; padding: 8px; border-radius: var(--radius-md);
|
||||
border: 1px solid var(--accent-dim); background: var(--accent-muted);
|
||||
color: var(--accent-fg); font-family: var(--font-ui); font-size: var(--text-xs);
|
||||
letter-spacing: var(--tracking-wide); cursor: pointer;
|
||||
transition: background var(--t-base), border-color var(--t-base);
|
||||
}
|
||||
.apply-btn:hover { background: var(--accent-dim); border-color: var(--accent); }
|
||||
|
||||
.section-label {
|
||||
font-family: var(--font-ui); font-size: var(--text-2xs);
|
||||
letter-spacing: var(--tracking-widest); color: var(--text-faint);
|
||||
text-transform: uppercase; margin: 0;
|
||||
}
|
||||
|
||||
.divider { height: 1px; background: var(--border-dim); margin: var(--sp-1) 0; }
|
||||
|
||||
.auto-row { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-3); }
|
||||
.auto-row-sub { padding-left: var(--sp-3); border-left: 2px solid var(--border-dim); }
|
||||
.auto-info { display: flex; flex-direction: column; gap: 2px; flex: 1; min-width: 0; }
|
||||
.auto-label { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); letter-spacing: var(--tracking-wide); }
|
||||
.auto-desc { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); line-height: var(--leading-snug); }
|
||||
|
||||
.auto-toggle { width: 28px; height: 16px; border-radius: var(--radius-full); border: 1px solid var(--border-strong); background: var(--bg-overlay); cursor: pointer; padding: 0; flex-shrink: 0; position: relative; transition: background var(--t-base), border-color var(--t-base); }
|
||||
.auto-toggle-on { background: var(--accent); border-color: var(--accent); }
|
||||
.auto-toggle-thumb { position: absolute; top: 1px; left: 1px; width: 12px; height: 12px; border-radius: 50%; background: var(--text-faint); transition: transform var(--t-base), background var(--t-base); }
|
||||
.auto-toggle-on .auto-toggle-thumb { transform: translateX(12px); background: var(--bg-base); }
|
||||
|
||||
.auto-chip-group { display: flex; flex-direction: row; gap: 4px; flex-shrink: 0; flex-wrap: wrap; justify-content: flex-end; }
|
||||
.auto-chip { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 2px 7px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); cursor: pointer; white-space: nowrap; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
|
||||
.auto-chip:hover { color: var(--text-muted); border-color: var(--border-strong); background: var(--bg-raised); }
|
||||
.auto-chip-on { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
|
||||
|
||||
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
|
||||
@keyframes scaleIn { from { opacity: 0; transform: scale(0.97) } to { opacity: 1; transform: scale(1) } }
|
||||
</style>
|
||||
@@ -5,7 +5,8 @@
|
||||
import { ENQUEUE_CHAPTERS_DOWNLOAD } from "@api/mutations/downloads";
|
||||
import { store, updateSettings, openReader, closeReader, addHistory,
|
||||
addBookmark, removeBookmark, addMarker, updateMarker, removeMarker,
|
||||
setSettingsOpen } from "@store/state.svelte";
|
||||
setSettingsOpen, setMangaReaderSettings, clearMangaReaderSettings,
|
||||
saveReaderPreset, updateReaderPreset, deleteReaderPreset } from "@store/state.svelte";
|
||||
import { setReading } from "@store/discord";
|
||||
import { DEFAULT_KEYBINDS } from "@core/keybinds/defaultBinds";
|
||||
import { readerState, PAGE_STYLES } from "../store/readerState.svelte";
|
||||
@@ -21,17 +22,27 @@
|
||||
import PageView from "./PageView.svelte";
|
||||
import ReaderProgressBar from "./ReaderProgressBar.svelte";
|
||||
import ReaderOverlay from "./ReaderOverlay.svelte";
|
||||
import ReaderPresetPanel from "./ReaderPresetPanel.svelte";
|
||||
|
||||
const win = getCurrentWindow();
|
||||
const useBlob = $derived((store.settings.serverAuthMode ?? "NONE") === "BASIC_AUTH");
|
||||
const rtl = $derived(store.settings.readingDirection === "rtl");
|
||||
const fit = $derived((store.settings.fitMode ?? "width") as FitMode);
|
||||
const style = $derived((store.settings.pageStyle ?? "single") as typeof PAGE_STYLES[number]);
|
||||
const zoom = $derived(store.settings.readerZoom ?? 1.0);
|
||||
|
||||
const effectiveReaderSettings = $derived.by(() => {
|
||||
const mangaId = store.activeManga?.id;
|
||||
const override = mangaId != null ? (store.settings.mangaReaderSettings ?? {})[mangaId] : undefined;
|
||||
return override ? { ...store.settings, ...override } : store.settings;
|
||||
});
|
||||
|
||||
const rtl = $derived(effectiveReaderSettings.readingDirection === "rtl");
|
||||
const fit = $derived((effectiveReaderSettings.fitMode ?? "width") as FitMode);
|
||||
const style = $derived((effectiveReaderSettings.pageStyle ?? "single") as typeof PAGE_STYLES[number]);
|
||||
const zoom = $derived(effectiveReaderSettings.readerZoom ?? 1.0);
|
||||
const autoNext = $derived(store.settings.autoNextChapter ?? false);
|
||||
const markOnNext = $derived(store.settings.markReadOnNext ?? true);
|
||||
const overlayBars = $derived(store.settings.overlayBars ?? false);
|
||||
const tapToToggleBar = $derived(store.settings.tapToToggleBar ?? false);
|
||||
const barPosition = $derived((store.settings.barPosition ?? "top") as "top" | "left" | "right");
|
||||
const isVerticalBar = $derived(barPosition === "left" || barPosition === "right");
|
||||
const lastPage = $derived(store.pageUrls.length);
|
||||
const effectiveWidth = $derived(readerState.containerWidth > 0 ? Math.round(readerState.containerWidth * zoom) : undefined);
|
||||
const zoomPct = $derived(Math.round(zoom * 100));
|
||||
@@ -84,7 +95,7 @@
|
||||
fit === "height" && "fit-height",
|
||||
fit === "screen" && "fit-screen",
|
||||
fit === "original" && "fit-original",
|
||||
store.settings.optimizeContrast && "optimize-contrast",
|
||||
effectiveReaderSettings.optimizeContrast && "optimize-contrast",
|
||||
].filter(Boolean).join(" "));
|
||||
|
||||
const fitLabel = $derived({ width: "Fit W", height: "Fit H", screen: "Fit Screen", original: "1:1" }[fit]);
|
||||
@@ -119,6 +130,11 @@
|
||||
const sliderPctRaw = $derived(sliderMax > 1 ? ((sliderPage - 1) / (sliderMax - 1)) * 100 : 0);
|
||||
const sliderPct = $derived(rtl ? 100 - sliderPctRaw : sliderPctRaw);
|
||||
|
||||
const perMangaEnabled = $derived(
|
||||
store.activeManga?.id != null &&
|
||||
!!(store.settings.mangaReaderSettings ?? {})[store.activeManga.id]
|
||||
);
|
||||
|
||||
let containerEl: HTMLDivElement | null = null;
|
||||
let pageViewRef: PageView;
|
||||
let zoomAnchor = { el: null as HTMLElement | null, offset: 0 };
|
||||
@@ -184,7 +200,7 @@
|
||||
e.preventDefault();
|
||||
captureZoomAnchor(containerEl, style, zoomAnchor);
|
||||
const ZOOM_STEP = 0.05;
|
||||
updateSettings({ readerZoom: clampZoom(zoom + (e.deltaY < 0 ? ZOOM_STEP : -ZOOM_STEP)) });
|
||||
applySettings({ readerZoom: clampZoom(zoom + (e.deltaY < 0 ? ZOOM_STEP : -ZOOM_STEP)) });
|
||||
restoreZoomAnchor(containerEl, zoomAnchor);
|
||||
}
|
||||
|
||||
@@ -202,10 +218,10 @@
|
||||
closeReader,
|
||||
goToPage: (p) => jumpToPage(p, style, lastPage, containerEl),
|
||||
lastPage: () => lastPage,
|
||||
adjustZoom: (d) => { captureZoomAnchor(containerEl, style, zoomAnchor); updateSettings({ readerZoom: clampZoom(zoom + d) }); restoreZoomAnchor(containerEl, zoomAnchor); },
|
||||
resetZoom: () => { captureZoomAnchor(containerEl, style, zoomAnchor); updateSettings({ readerZoom: 1.0 }); restoreZoomAnchor(containerEl, zoomAnchor); },
|
||||
cycleStyle: () => { const idx = PAGE_STYLES.indexOf(style); updateSettings({ pageStyle: PAGE_STYLES[(idx + 1) % PAGE_STYLES.length] }); },
|
||||
toggleDirection: () => updateSettings({ readingDirection: rtl ? "ltr" : "rtl" }),
|
||||
adjustZoom: (d) => { captureZoomAnchor(containerEl, style, zoomAnchor); applySettings({ readerZoom: clampZoom(zoom + d) }); restoreZoomAnchor(containerEl, zoomAnchor); },
|
||||
resetZoom: () => { captureZoomAnchor(containerEl, style, zoomAnchor); applySettings({ readerZoom: 1.0 }); restoreZoomAnchor(containerEl, zoomAnchor); },
|
||||
cycleStyle: () => { const idx = PAGE_STYLES.indexOf(style); applySettings({ pageStyle: PAGE_STYLES[(idx + 1) % PAGE_STYLES.length] }); },
|
||||
toggleDirection: () => applySettings({ readingDirection: rtl ? "ltr" : "rtl" }),
|
||||
openSettings: () => setSettingsOpen(true),
|
||||
toggleBookmark: () => toggleBookmark(displayChapter, store.pageNumber),
|
||||
toggleMarker: () => {
|
||||
@@ -230,6 +246,54 @@
|
||||
|
||||
function bindContainer(el: HTMLDivElement) { containerEl = el; }
|
||||
|
||||
function captureCurrentReaderSettings() {
|
||||
return {
|
||||
pageStyle: style,
|
||||
fitMode: fit,
|
||||
readingDirection: (store.settings.readingDirection ?? "ltr") as import("@store/state.svelte").ReadingDirection,
|
||||
readerZoom: zoom,
|
||||
pageGap: effectiveReaderSettings.pageGap ?? true,
|
||||
optimizeContrast: effectiveReaderSettings.optimizeContrast ?? false,
|
||||
offsetDoubleSpreads: effectiveReaderSettings.offsetDoubleSpreads ?? false,
|
||||
} satisfies import("@store/state.svelte").ReaderSettings;
|
||||
}
|
||||
|
||||
function applySettings(patch: Parameters<typeof updateSettings>[0]) {
|
||||
const mangaId = store.activeManga?.id;
|
||||
if (mangaId != null && (store.settings.mangaReaderSettings ?? {})[mangaId]) {
|
||||
setMangaReaderSettings(mangaId, { ...(store.settings.mangaReaderSettings ?? {})[mangaId]!, ...patch });
|
||||
} else {
|
||||
updateSettings(patch);
|
||||
}
|
||||
}
|
||||
|
||||
function handleTogglePerManga() {
|
||||
const mangaId = store.activeManga?.id;
|
||||
if (mangaId == null) return;
|
||||
if ((store.settings.mangaReaderSettings ?? {})[mangaId]) {
|
||||
clearMangaReaderSettings(mangaId);
|
||||
} else {
|
||||
setMangaReaderSettings(mangaId, captureCurrentReaderSettings());
|
||||
}
|
||||
}
|
||||
|
||||
function handleSavePreset(name: string) {
|
||||
saveReaderPreset(name, captureCurrentReaderSettings());
|
||||
}
|
||||
|
||||
function handleApplyPreset(settings: import("@store/state.svelte").ReaderSettings) {
|
||||
const mangaId = store.activeManga?.id;
|
||||
if (mangaId != null && (store.settings.mangaReaderSettings ?? {})[mangaId]) {
|
||||
setMangaReaderSettings(mangaId, settings);
|
||||
} else {
|
||||
updateSettings(settings);
|
||||
}
|
||||
}
|
||||
|
||||
function handleBarPositionChange(pos: "top" | "left" | "right") {
|
||||
updateSettings({ barPosition: pos });
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
const chapter = displayChapter;
|
||||
const manga = store.activeManga;
|
||||
@@ -346,7 +410,7 @@
|
||||
const snap = store.pageUrls;
|
||||
Promise.all(snap.map(url => measureAspect(url, useBlob))).then(aspects => {
|
||||
if (cancelled || snap !== store.pageUrls) return;
|
||||
readerState.pageGroups = buildPageGroups(snap, aspects, store.settings.offsetDoubleSpreads ?? false);
|
||||
readerState.pageGroups = buildPageGroups(snap, aspects, effectiveReaderSettings.offsetDoubleSpreads ?? false);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
} else { readerState.pageGroups = []; }
|
||||
@@ -446,17 +510,26 @@
|
||||
<div
|
||||
class="root"
|
||||
class:overlay-bars={overlayBars}
|
||||
class:bar-left={barPosition === "left"}
|
||||
class:bar-right={barPosition === "right"}
|
||||
role="presentation"
|
||||
onmousemove={(e) => { if (!tapToToggleBar && (e.clientY < 60 || window.innerHeight - e.clientY < 60)) showUi(); }}
|
||||
onmousemove={(e) => {
|
||||
if (!tapToToggleBar) {
|
||||
if (barPosition === "top" && (e.clientY < 60 || window.innerHeight - e.clientY < 60)) showUi();
|
||||
if (barPosition === "left" && e.clientX < 60) showUi();
|
||||
if (barPosition === "right" && window.innerWidth - e.clientX < 60) showUi();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ReaderControls
|
||||
{displayChapter} {adjacent} {visibleChunkLastPage}
|
||||
{fit} {fitLabel} {style} {rtl} {zoom} {zoomPct}
|
||||
{zoom} {zoomPct}
|
||||
isFullscreen={readerState.isFullscreen}
|
||||
{isBookmarked} {hasMarkerOnPage} {currentPageMarkers}
|
||||
{autoNext} {markOnNext}
|
||||
uiVisible={readerState.uiVisible}
|
||||
{hideTimer}
|
||||
{barPosition}
|
||||
progressBar={isVerticalBar ? progressBarSnippet : undefined}
|
||||
onCaptureZoomAnchor={() => captureZoomAnchor(containerEl, style, zoomAnchor)}
|
||||
onRestoreZoomAnchor={() => restoreZoomAnchor(containerEl, zoomAnchor)}
|
||||
onMaybeMarkRead={maybeMarkCurrentRead}
|
||||
@@ -464,10 +537,31 @@
|
||||
onCommitMarker={commitMarker}
|
||||
onDeleteMarker={deleteCurrentMarker}
|
||||
onClampZoom={clampZoom}
|
||||
onApplySettings={applySettings}
|
||||
onDlOpen={() => readerState.dlOpen = true}
|
||||
onSettingsOpen={() => setSettingsOpen(true)}
|
||||
{perMangaEnabled}
|
||||
{win}
|
||||
/>
|
||||
|
||||
{#if readerState.presetOpen}
|
||||
<ReaderPresetPanel
|
||||
{fit} {style} {rtl} {zoom} {zoomPct}
|
||||
{perMangaEnabled}
|
||||
{barPosition}
|
||||
onBarPositionChange={handleBarPositionChange}
|
||||
onTogglePerManga={handleTogglePerManga}
|
||||
onApplySettings={applySettings}
|
||||
onSavePreset={handleSavePreset}
|
||||
onApplyPreset={handleApplyPreset}
|
||||
onUpdatePreset={updateReaderPreset}
|
||||
onDeletePreset={deleteReaderPreset}
|
||||
onCaptureZoomAnchor={() => captureZoomAnchor(containerEl, style, zoomAnchor)}
|
||||
onRestoreZoomAnchor={() => restoreZoomAnchor(containerEl, zoomAnchor)}
|
||||
onClampZoom={clampZoom}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<ReaderOverlay
|
||||
{showResumeBanner}
|
||||
resumePage={readerState.resumePage}
|
||||
@@ -493,21 +587,42 @@
|
||||
{bindContainer}
|
||||
/>
|
||||
|
||||
<ReaderProgressBar
|
||||
{style}
|
||||
loading={readerState.loading}
|
||||
{rtl} {sliderPage} {sliderMax} {sliderPct} {lastPage}
|
||||
{displayChapter} {currentBookmark} {activeChapterMarkers} {adjacent}
|
||||
uiVisible={readerState.uiVisible}
|
||||
onGoPrev={goPrev}
|
||||
onGoNext={goNext}
|
||||
onJumpToPage={(p) => jumpToPage(p, style, lastPage, containerEl)}
|
||||
/>
|
||||
{#snippet progressBarSnippet()}
|
||||
<ReaderProgressBar
|
||||
{style}
|
||||
loading={readerState.loading}
|
||||
{rtl} {sliderPage} {sliderMax} {sliderPct} {lastPage}
|
||||
{displayChapter} {currentBookmark} {activeChapterMarkers} {adjacent}
|
||||
uiVisible={readerState.uiVisible}
|
||||
{barPosition}
|
||||
onGoPrev={goPrev}
|
||||
onGoNext={goNext}
|
||||
onJumpToPage={(p) => jumpToPage(p, style, lastPage, containerEl)}
|
||||
/>
|
||||
{/snippet}
|
||||
|
||||
{#if !isVerticalBar}
|
||||
<ReaderProgressBar
|
||||
{style}
|
||||
loading={readerState.loading}
|
||||
{rtl} {sliderPage} {sliderMax} {sliderPct} {lastPage}
|
||||
{displayChapter} {currentBookmark} {activeChapterMarkers} {adjacent}
|
||||
uiVisible={readerState.uiVisible}
|
||||
{barPosition}
|
||||
onGoPrev={goPrev}
|
||||
onGoNext={goNext}
|
||||
onJumpToPage={(p) => jumpToPage(p, style, lastPage, containerEl)}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.root { position: fixed; inset: 0; background: #000; display: flex; flex-direction: column; z-index: var(--z-reader); transform: translateZ(0); will-change: transform; }
|
||||
|
||||
.root.overlay-bars :global(.topbar) { position: absolute; top: 0; left: 0; right: 0; z-index: 10; }
|
||||
.root.overlay-bars :global(.bottombar) { position: absolute; bottom: 0; left: 0; right: 0; z-index: 10; }
|
||||
.root.overlay-bars :global(.viewer) { height: 100%; }
|
||||
|
||||
.root.bar-left :global(.viewer) { margin-left: 40px; }
|
||||
.root.bar-right :global(.viewer) { margin-right: 40px; }
|
||||
</style>
|
||||
@@ -1,83 +1,95 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
X, CaretLeft, CaretRight,
|
||||
Square, Rows, BookOpen, MonitorPlay,
|
||||
ArrowsLeftRight, ArrowsIn, ArrowsOut, ArrowsVertical,
|
||||
X, CaretLeft, CaretRight, CaretUp, CaretDown,
|
||||
MagnifyingGlassMinus, MagnifyingGlassPlus,
|
||||
Bookmark, MapPin, Download, Check,
|
||||
Bookmark, MapPin, Download, Check, GearSix, Sliders,
|
||||
} from "phosphor-svelte";
|
||||
import { store, updateSettings } from "@store/state.svelte";
|
||||
import { openReader, closeReader } from "@store/state.svelte";
|
||||
import { readerState, MARKER_COLORS, MARKER_COLOR_HEX, ZOOM_STEP, ZOOM_MIN, ZOOM_MAX, PAGE_STYLES } from "../store/readerState.svelte";
|
||||
import type { FitMode } from "@store/state.svelte";
|
||||
import type { Chapter } from "@types";
|
||||
import { store, updateSettings } from "@store/state.svelte";
|
||||
import { openReader, closeReader } from "@store/state.svelte";
|
||||
import { readerState, MARKER_COLORS, MARKER_COLOR_HEX, ZOOM_STEP, ZOOM_MIN, ZOOM_MAX } from "../store/readerState.svelte";
|
||||
import { fly } from "svelte/transition";
|
||||
import { cubicOut, cubicIn } from "svelte/easing";
|
||||
import type { Chapter } from "@types";
|
||||
|
||||
import type { Snippet } from "svelte";
|
||||
|
||||
interface Props {
|
||||
displayChapter: Chapter | null;
|
||||
adjacent: { prev: Chapter | null; next: Chapter | null };
|
||||
displayChapter: Chapter | null;
|
||||
adjacent: { prev: Chapter | null; next: Chapter | null };
|
||||
visibleChunkLastPage: number;
|
||||
fit: FitMode;
|
||||
fitLabel: string;
|
||||
style: string;
|
||||
rtl: boolean;
|
||||
zoom: number;
|
||||
zoomPct: number;
|
||||
isFullscreen: boolean;
|
||||
isBookmarked: boolean;
|
||||
hasMarkerOnPage: boolean;
|
||||
currentPageMarkers: { id: string; color: import("@store/state.svelte").MarkerColor; note: string }[];
|
||||
autoNext: boolean;
|
||||
markOnNext: boolean;
|
||||
uiVisible: boolean;
|
||||
hideTimer: ReturnType<typeof setTimeout> | null;
|
||||
zoom: number;
|
||||
zoomPct: number;
|
||||
isFullscreen: boolean;
|
||||
isBookmarked: boolean;
|
||||
hasMarkerOnPage: boolean;
|
||||
currentPageMarkers: { id: string; color: import("@store/state.svelte").MarkerColor; note: string }[];
|
||||
uiVisible: boolean;
|
||||
hideTimer: ReturnType<typeof setTimeout> | null;
|
||||
barPosition: "top" | "left" | "right";
|
||||
progressBar?: Snippet;
|
||||
onCaptureZoomAnchor: () => void;
|
||||
onRestoreZoomAnchor: () => void;
|
||||
onMaybeMarkRead: () => void;
|
||||
onToggleBookmark: () => void;
|
||||
onCommitMarker: () => void;
|
||||
onDeleteMarker: () => void;
|
||||
onClampZoom: (z: number) => number;
|
||||
onDlOpen: () => void;
|
||||
win: import("@tauri-apps/api/window").Window;
|
||||
onMaybeMarkRead: () => void;
|
||||
onToggleBookmark: () => void;
|
||||
onCommitMarker: () => void;
|
||||
onDeleteMarker: () => void;
|
||||
onClampZoom: (z: number) => number;
|
||||
onApplySettings: (patch: Parameters<typeof updateSettings>[0]) => void;
|
||||
onDlOpen: () => void;
|
||||
onSettingsOpen: () => void;
|
||||
hasMangaOverride: boolean;
|
||||
win: import("@tauri-apps/api/window").Window;
|
||||
}
|
||||
|
||||
const {
|
||||
displayChapter, adjacent, visibleChunkLastPage,
|
||||
fit, fitLabel, style, rtl, zoom, zoomPct,
|
||||
isFullscreen, isBookmarked, hasMarkerOnPage, currentPageMarkers,
|
||||
autoNext, markOnNext, uiVisible, hideTimer,
|
||||
zoom, zoomPct, isFullscreen,
|
||||
isBookmarked, hasMarkerOnPage, currentPageMarkers,
|
||||
uiVisible, hideTimer,
|
||||
barPosition, progressBar,
|
||||
onCaptureZoomAnchor, onRestoreZoomAnchor,
|
||||
onMaybeMarkRead, onToggleBookmark, onCommitMarker, onDeleteMarker,
|
||||
onClampZoom, onDlOpen, win,
|
||||
onClampZoom, onApplySettings, onDlOpen, onSettingsOpen,
|
||||
hasMangaOverride, win,
|
||||
}: Props = $props();
|
||||
|
||||
const isVertical = $derived(barPosition === "left" || barPosition === "right");
|
||||
const popoverSide = $derived(
|
||||
barPosition === "left" ? "right" :
|
||||
barPosition === "right" ? "left" :
|
||||
"bottom"
|
||||
);
|
||||
|
||||
function adjustZoom(delta: number) {
|
||||
onCaptureZoomAnchor();
|
||||
updateSettings({ readerZoom: onClampZoom(zoom + delta) });
|
||||
onApplySettings({ readerZoom: onClampZoom(zoom + delta) });
|
||||
onRestoreZoomAnchor();
|
||||
}
|
||||
|
||||
function resetZoom() {
|
||||
onCaptureZoomAnchor();
|
||||
updateSettings({ readerZoom: 1.0 });
|
||||
onApplySettings({ readerZoom: 1.0 });
|
||||
onRestoreZoomAnchor();
|
||||
}
|
||||
|
||||
function cycleStyle() {
|
||||
const idx = PAGE_STYLES.indexOf(style as typeof PAGE_STYLES[number]);
|
||||
updateSettings({ pageStyle: PAGE_STYLES[(idx + 1) % PAGE_STYLES.length] });
|
||||
}
|
||||
|
||||
function cycleFit() {
|
||||
const opts: FitMode[] = ["width", "height", "screen", "original"];
|
||||
updateSettings({ fitMode: opts[(opts.indexOf(fit) + 1) % opts.length] });
|
||||
}
|
||||
|
||||
function keepUiAlive() {
|
||||
readerState.uiVisible = true;
|
||||
if (hideTimer) clearTimeout(hideTimer);
|
||||
}
|
||||
|
||||
let wcTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function wcResetTimer() {
|
||||
if (wcTimer) clearTimeout(wcTimer);
|
||||
wcTimer = setTimeout(() => { readerState.winOpen = false; }, 1500);
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (readerState.winOpen) wcResetTimer();
|
||||
else if (wcTimer) { clearTimeout(wcTimer); wcTimer = null; }
|
||||
return () => { if (wcTimer) clearTimeout(wcTimer); };
|
||||
});
|
||||
|
||||
function openMarkerPopover() {
|
||||
if (currentPageMarkers.length > 0) {
|
||||
const first = currentPageMarkers[0];
|
||||
@@ -86,105 +98,115 @@
|
||||
readerState.openMarker("", "", "yellow");
|
||||
}
|
||||
}
|
||||
|
||||
let chapterHover = $state(false);
|
||||
let chapterHoverTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function showChapterPopover() {
|
||||
if (chapterHoverTimer) clearTimeout(chapterHoverTimer);
|
||||
chapterHover = true;
|
||||
}
|
||||
|
||||
function hideChapterPopover() {
|
||||
chapterHoverTimer = setTimeout(() => { chapterHover = false; }, 120);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="topbar" class:hidden={!uiVisible}>
|
||||
|
||||
<div class="topbar-left">
|
||||
<div
|
||||
class="bar"
|
||||
class:bar-top={barPosition === "top"}
|
||||
class:bar-left={barPosition === "left"}
|
||||
class:bar-right={barPosition === "right"}
|
||||
class:hidden={!uiVisible}
|
||||
>
|
||||
<div class="bar-start">
|
||||
<button class="icon-btn" onclick={closeReader} title="Close reader"><X size={15} weight="light" /></button>
|
||||
|
||||
<button class="icon-btn"
|
||||
onclick={() => { if (adjacent.prev) { onMaybeMarkRead(); openReader(adjacent.prev, store.activeChapterList); } }}
|
||||
disabled={!adjacent.prev}>
|
||||
<CaretLeft size={14} weight="light" />
|
||||
{#if isVertical}
|
||||
<CaretUp size={14} weight="light" />
|
||||
{:else}
|
||||
<CaretLeft size={14} weight="light" />
|
||||
{/if}
|
||||
</button>
|
||||
<span class="ch-label">
|
||||
<span class="ch-title">{store.activeManga?.title}</span>
|
||||
<span class="ch-sep">/</span>
|
||||
<span>{displayChapter?.name}</span>
|
||||
</span>
|
||||
|
||||
<div
|
||||
class="ch-hover-wrap"
|
||||
onmouseenter={showChapterPopover}
|
||||
onmouseleave={hideChapterPopover}
|
||||
role="presentation"
|
||||
>
|
||||
<button class="ch-pill" title="{store.activeManga?.title} / {displayChapter?.name}">
|
||||
{#if isVertical}
|
||||
<span class="ch-info"></span>
|
||||
{:else}
|
||||
<span class="ch-title">{store.activeManga?.title}</span>
|
||||
<span class="ch-sep">/</span>
|
||||
<span class="ch-name">{displayChapter?.name}</span>
|
||||
<span class="ch-page">{store.pageNumber} / {visibleChunkLastPage || "…"}</span>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
{#if chapterHover && isVertical}
|
||||
<div class="ch-popover ch-popover-{popoverSide}">
|
||||
<span class="ch-pop-title">{store.activeManga?.title}</span>
|
||||
<span class="ch-pop-sep">/</span>
|
||||
<span class="ch-pop-name">{displayChapter?.name}</span>
|
||||
<span class="ch-pop-page">{store.pageNumber} / {visibleChunkLastPage || "…"}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<button class="icon-btn"
|
||||
onclick={() => { if (adjacent.next) { onMaybeMarkRead(); openReader(adjacent.next, store.activeChapterList); } }}
|
||||
disabled={!adjacent.next}>
|
||||
<CaretRight size={14} weight="light" />
|
||||
{#if isVertical}
|
||||
<CaretDown size={14} weight="light" />
|
||||
{:else}
|
||||
<CaretRight size={14} weight="light" />
|
||||
{/if}
|
||||
</button>
|
||||
<span class="page-label">{store.pageNumber} / {visibleChunkLastPage || "…"}</span>
|
||||
|
||||
{#if !isVertical}
|
||||
<span class="bar-sep"></span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="topbar-right">
|
||||
<div class="top-sep"></div>
|
||||
|
||||
<button class="mode-btn" onclick={cycleFit}>
|
||||
{#if fit === "width"}<ArrowsLeftRight size={14} weight="light" />
|
||||
{:else if fit === "height"}<ArrowsVertical size={14} weight="light" />
|
||||
{:else if fit === "screen"}<ArrowsIn size={14} weight="light" />
|
||||
{:else}<ArrowsOut size={14} weight="light" />{/if}
|
||||
<span class="mode-label">{fitLabel}</span>
|
||||
</button>
|
||||
{#if isVertical && progressBar}
|
||||
<div class="bar-middle">
|
||||
{@render progressBar()}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="bar-end">
|
||||
<div class="zoom-wrap">
|
||||
<div class="zoom-inline">
|
||||
<button class="zoom-step-btn" onclick={() => adjustZoom(-ZOOM_STEP)} title="Zoom out" disabled={zoom <= ZOOM_MIN}>
|
||||
<button class="icon-btn zoom-icon-btn" onclick={() => adjustZoom(-ZOOM_STEP)} title="Zoom out" disabled={zoom <= ZOOM_MIN}>
|
||||
<MagnifyingGlassMinus size={13} weight="light" />
|
||||
</button>
|
||||
<div class="zoom-divider"></div>
|
||||
<button class="zoom-pct-btn" onclick={() => readerState.zoomOpen = !readerState.zoomOpen} title="Click to adjust zoom">
|
||||
{zoomPct}%
|
||||
</button>
|
||||
<button class="zoom-step-btn" onclick={() => adjustZoom(ZOOM_STEP)} title="Zoom in" disabled={zoom >= ZOOM_MAX}>
|
||||
<div class="zoom-divider"></div>
|
||||
<button class="icon-btn zoom-icon-btn" onclick={() => adjustZoom(ZOOM_STEP)} title="Zoom in" disabled={zoom >= ZOOM_MAX}>
|
||||
<MagnifyingGlassPlus size={13} weight="light" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if readerState.zoomOpen}
|
||||
<div class="zoom-popover">
|
||||
<div class="popover zoom-popover popover-{popoverSide}">
|
||||
<div class="zoom-slider-row">
|
||||
<input type="range" class="zoom-slider" min={10} max={100} step={5} value={zoomPct}
|
||||
oninput={(e) => { onCaptureZoomAnchor(); updateSettings({ readerZoom: onClampZoom(Number(e.currentTarget.value) / 100) }); onRestoreZoomAnchor(); }} />
|
||||
oninput={(e) => { onCaptureZoomAnchor(); onApplySettings({ readerZoom: onClampZoom(Number(e.currentTarget.value) / 100) }); onRestoreZoomAnchor(); }} />
|
||||
</div>
|
||||
<button class="zoom-reset" onclick={resetZoom} disabled={zoom === 1.0}>Reset</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<button class="mode-btn" class:active={rtl} onclick={() => updateSettings({ readingDirection: rtl ? "ltr" : "rtl" })}>
|
||||
<ArrowsLeftRight size={14} weight="light" /><span class="mode-label">{rtl ? "RTL" : "LTR"}</span>
|
||||
</button>
|
||||
|
||||
<button class="mode-btn" onclick={cycleStyle} title="Cycle view mode">
|
||||
{#if style === "single"}<Square size={14} weight="light" />
|
||||
{:else if style === "fade"}<MonitorPlay size={14} weight="light" />
|
||||
{:else if style === "double"}<BookOpen size={14} weight="light" />
|
||||
{:else}<Rows size={14} weight="light" />{/if}
|
||||
<span class="mode-label">{style}</span>
|
||||
</button>
|
||||
|
||||
<div class="mode-extras">
|
||||
{#if style === "double"}
|
||||
<button class="mode-btn" class:active={store.settings.offsetDoubleSpreads}
|
||||
onclick={() => updateSettings({ offsetDoubleSpreads: !store.settings.offsetDoubleSpreads })}>
|
||||
<span class="mode-label">Offset</span>
|
||||
</button>
|
||||
{/if}
|
||||
{#if style === "longstrip"}
|
||||
<button class="mode-btn" class:active={store.settings.pageGap}
|
||||
onclick={() => updateSettings({ pageGap: !store.settings.pageGap })}>
|
||||
<span class="mode-label">Gap</span>
|
||||
</button>
|
||||
<button class="mode-btn" class:active={autoNext}
|
||||
onclick={() => updateSettings({ autoNextChapter: !autoNext })}>
|
||||
<span class="mode-label">Auto</span>
|
||||
</button>
|
||||
{/if}
|
||||
{#if !autoNext}
|
||||
<button class="mode-btn" class:active={markOnNext}
|
||||
onclick={() => updateSettings({ markReadOnNext: !markOnNext })}>
|
||||
<span class="mode-label">Mk.Read</span>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<button class="mode-btn" onclick={onDlOpen}>
|
||||
<Download size={14} weight="light" />
|
||||
</button>
|
||||
|
||||
<div class="marker-wrap">
|
||||
<button
|
||||
class="icon-btn"
|
||||
@@ -198,7 +220,7 @@
|
||||
</button>
|
||||
|
||||
{#if readerState.markerOpen}
|
||||
<div class="marker-popover" role="presentation"
|
||||
<div class="popover marker-popover popover-{popoverSide}" role="presentation"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
onmouseenter={keepUiAlive}
|
||||
>
|
||||
@@ -254,6 +276,20 @@
|
||||
<Bookmark size={15} weight={isBookmarked ? "fill" : "regular"} />
|
||||
</button>
|
||||
|
||||
<button class="icon-btn" onclick={onDlOpen}>
|
||||
<Download size={14} weight="light" />
|
||||
</button>
|
||||
|
||||
<button class="icon-btn" class:active={hasMangaOverride}
|
||||
onclick={() => { readerState.presetOpen = true; readerState.markerOpen = false; readerState.zoomOpen = false; readerState.dlOpen = false; }}
|
||||
title="Reader settings">
|
||||
<Sliders size={13} weight="regular" />
|
||||
</button>
|
||||
|
||||
<button class="icon-btn" onclick={onSettingsOpen} title="Settings">
|
||||
<GearSix size={13} weight="regular" />
|
||||
</button>
|
||||
|
||||
<div class="wc-wrap">
|
||||
<button
|
||||
class="icon-btn"
|
||||
@@ -268,45 +304,98 @@
|
||||
</svg>
|
||||
</button>
|
||||
{#if readerState.winOpen}
|
||||
<div class="wc-dropdown" role="presentation" onclick={(e) => e.stopPropagation()}>
|
||||
<button class="wc-btn" onclick={() => { readerState.winOpen = false; win.minimize(); }}>
|
||||
<svg width="10" height="1" viewBox="0 0 10 1"><line x1="0" y1="0.5" x2="10" y2="0.5" stroke="currentColor" stroke-width="1.5"/></svg>
|
||||
<span>Minimize</span>
|
||||
</button>
|
||||
<button class="wc-btn" onclick={() => { readerState.winOpen = false; win.toggleMaximize(); }}>
|
||||
{#if isFullscreen}
|
||||
<div
|
||||
class="wc-clip wc-clip-{popoverSide}"
|
||||
onmouseenter={wcResetTimer}
|
||||
onmousemove={wcResetTimer}
|
||||
>
|
||||
<div
|
||||
class="wc-bar"
|
||||
role="presentation"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
in:fly={isVertical
|
||||
? (barPosition === "left" ? { x: '-100%', duration: 200, easing: cubicOut } : { x: '100%', duration: 200, easing: cubicOut })
|
||||
: { y: '-100%', duration: 200, easing: cubicOut }}
|
||||
out:fly={isVertical
|
||||
? (barPosition === "left" ? { x: '-100%', duration: 150, easing: cubicIn } : { x: '100%', duration: 150, easing: cubicIn })
|
||||
: { y: '-100%', duration: 150, easing: cubicIn }}
|
||||
>
|
||||
<button class="wc-icon-btn" onclick={() => { readerState.winOpen = false; win.minimize(); }} title="Minimize">
|
||||
<svg width="10" height="2" viewBox="0 0 10 2"><line x1="0" y1="1" x2="10" y2="1" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
|
||||
</button>
|
||||
<button class="wc-icon-btn" onclick={() => { readerState.winOpen = false; win.toggleMaximize(); }} title={isFullscreen ? "Exit Fullscreen" : "Fullscreen"}>
|
||||
{#if isFullscreen}
|
||||
<svg width="11" height="11" viewBox="0 0 11 11">
|
||||
<polyline points="1,4 1,1 4,1" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<polyline points="7,1 10,1 10,4" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<polyline points="10,7 10,10 7,10" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<polyline points="4,10 1,10 1,7" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
{:else}
|
||||
<svg width="10" height="10" viewBox="0 0 10 10"><rect x="0.75" y="0.75" width="8.5" height="8.5" rx="1" fill="none" stroke="currentColor" stroke-width="1.5"/></svg>
|
||||
{/if}
|
||||
</button>
|
||||
<button class="wc-icon-btn wc-icon-close" onclick={() => { readerState.winOpen = false; win.close(); }} title="Close">
|
||||
<svg width="10" height="10" viewBox="0 0 10 10">
|
||||
<polyline points="1,4 1,1 4,1" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<polyline points="6,1 9,1 9,4" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<polyline points="9,6 9,9 6,9" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<polyline points="4,9 1,9 1,6" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<line x1="1" y1="1" x2="9" y2="9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||
<line x1="9" y1="1" x2="1" y2="9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||
</svg>
|
||||
{:else}
|
||||
<svg width="9" height="9" viewBox="0 0 9 9"><rect x="0.75" y="0.75" width="7.5" height="7.5" rx="1" fill="none" stroke="currentColor" stroke-width="1.5"/></svg>
|
||||
{/if}
|
||||
<span>{isFullscreen ? "Exit Fullscreen" : "Fullscreen"}</span>
|
||||
</button>
|
||||
<button class="wc-btn wc-close" onclick={() => { readerState.winOpen = false; win.close(); }}>
|
||||
<svg width="10" height="10" viewBox="0 0 10 10">
|
||||
<line x1="1" y1="1" x2="9" y2="9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||
<line x1="9" y1="1" x2="1" y2="9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||
</svg>
|
||||
<span>Close</span>
|
||||
</button>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.topbar { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-1); padding: 0 var(--sp-3); height: 40px; background: var(--bg-void); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; position: relative; z-index: 2; transition: opacity 0.25s ease; }
|
||||
.topbar.hidden { opacity: 0; pointer-events: none; }
|
||||
.bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-1);
|
||||
background: var(--bg-void);
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
transition: opacity 0.25s ease;
|
||||
overflow: visible;
|
||||
}
|
||||
.bar.hidden { opacity: 0; pointer-events: none; }
|
||||
|
||||
.topbar-left { display: flex; align-items: center; gap: var(--sp-1); min-width: 0; flex: 1; overflow: hidden; }
|
||||
.topbar-right { display: flex; align-items: center; gap: var(--sp-1); flex-shrink: 0; }
|
||||
.mode-extras { display: flex; align-items: center; gap: var(--sp-1); min-width: 0; }
|
||||
.bar-top {
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
padding: 0 var(--sp-3);
|
||||
height: 40px;
|
||||
border-bottom: 1px solid var(--border-dim);
|
||||
}
|
||||
|
||||
.bar-left, .bar-right {
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
padding: var(--sp-3) 0;
|
||||
width: 40px;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
z-index: 2;
|
||||
border-bottom: none;
|
||||
}
|
||||
.bar-left { left: 0; border-right: 1px solid var(--border-dim); }
|
||||
.bar-right { right: 0; border-left: 1px solid var(--border-dim); }
|
||||
|
||||
.bar-start, .bar-end {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-1);
|
||||
}
|
||||
.bar-top .bar-start { flex: 1; overflow: hidden; }
|
||||
.bar-left .bar-start,
|
||||
.bar-left .bar-end,
|
||||
.bar-right .bar-start,
|
||||
.bar-right .bar-end {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.icon-btn { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: var(--radius-sm); color: var(--text-muted); flex-shrink: 0; transition: color var(--t-base), background var(--t-base); }
|
||||
.icon-btn:hover:not(:disabled) { color: var(--text-primary); background: var(--bg-raised); }
|
||||
@@ -314,25 +403,104 @@
|
||||
.icon-btn.active { color: var(--accent-fg); }
|
||||
.marker-btn-has { color: var(--marker-color, var(--accent-fg)) !important; }
|
||||
|
||||
.ch-label { display: flex; align-items: center; gap: var(--sp-2); font-size: var(--text-sm); color: var(--text-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
|
||||
.ch-hover-wrap { position: relative; min-width: 0; }
|
||||
|
||||
.ch-pill {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
min-width: 0;
|
||||
padding: 2px 4px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: none;
|
||||
cursor: default;
|
||||
transition: background var(--t-fast);
|
||||
}
|
||||
.bar-left .ch-pill, .bar-right .ch-pill {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
}
|
||||
.ch-info { font-size: 15px; line-height: 1; color: var(--text-faint); flex-shrink: 0; }
|
||||
.ch-title { color: var(--text-secondary); font-weight: var(--weight-medium); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.ch-sep { color: var(--text-faint); flex-shrink: 0; }
|
||||
.page-label { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); letter-spacing: var(--tracking-wide); flex-shrink: 0; }
|
||||
.top-sep { width: 1px; height: 16px; background: var(--border-dim); flex-shrink: 0; margin: 0 var(--sp-1); }
|
||||
.ch-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.ch-page { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); letter-spacing: var(--tracking-wide); flex-shrink: 0; }
|
||||
|
||||
.mode-btn { display: flex; align-items: center; gap: 4px; padding: 4px var(--sp-2); border-radius: var(--radius-sm); color: var(--text-muted); flex-shrink: 0; font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); transition: color var(--t-base), background var(--t-base); }
|
||||
.mode-btn:hover { color: var(--text-primary); background: var(--bg-raised); }
|
||||
.mode-btn.active { color: var(--accent-fg); background: var(--accent-muted); }
|
||||
.mode-label { text-transform: capitalize; }
|
||||
.ch-popover {
|
||||
position: absolute;
|
||||
background: var(--bg-raised);
|
||||
border: 1px solid var(--border-base);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--sp-2) var(--sp-3);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
white-space: nowrap;
|
||||
z-index: 100;
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,0.5);
|
||||
font-size: var(--text-sm);
|
||||
pointer-events: none;
|
||||
animation: scaleIn 0.1s ease both;
|
||||
}
|
||||
.ch-popover-right { left: calc(100% + 8px); top: 50%; translate: 0 -50%; transform-origin: left center; }
|
||||
.ch-popover-left { right: calc(100% + 8px); top: 50%; translate: 0 -50%; transform-origin: right center; }
|
||||
.ch-pop-title { color: var(--text-secondary); font-weight: var(--weight-medium); }
|
||||
.ch-pop-sep { color: var(--text-faint); }
|
||||
.ch-pop-name { color: var(--text-muted); }
|
||||
.ch-pop-page { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||
|
||||
.zoom-wrap { position: relative; flex-shrink: 0; }
|
||||
.zoom-inline { display: flex; align-items: center; gap: 1px; background: var(--bg-overlay); border: 1px solid var(--border-base); border-radius: var(--radius-sm); overflow: hidden; }
|
||||
.zoom-step-btn { display: flex; align-items: center; justify-content: center; width: 22px; height: 24px; color: var(--text-muted); transition: color var(--t-base), background var(--t-base); }
|
||||
.zoom-step-btn:hover:not(:disabled) { color: var(--text-primary); background: var(--bg-raised); }
|
||||
.zoom-step-btn:disabled { opacity: 0.25; cursor: default; }
|
||||
.zoom-pct-btn { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--text-secondary); padding: 0 var(--sp-2); height: 24px; min-width: 38px; text-align: center; transition: color var(--t-base), background var(--t-base); border-left: 1px solid var(--border-dim); border-right: 1px solid var(--border-dim); }
|
||||
.bar-sep { width: 1px; height: 16px; background: var(--border-dim); flex-shrink: 0; margin: 0 var(--sp-1); }
|
||||
|
||||
.zoom-wrap { position: relative; flex-shrink: 0; }
|
||||
.zoom-inline { display: flex; align-items: center; }
|
||||
.bar-left .zoom-inline, .bar-right .zoom-inline { flex-direction: column; }
|
||||
|
||||
.zoom-icon-btn { width: 28px; height: 28px; }
|
||||
.zoom-divider {
|
||||
background: var(--border-dim);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.bar-top .zoom-divider { width: 1px; height: 16px; }
|
||||
.bar-left .zoom-divider,
|
||||
.bar-right .zoom-divider { height: 1px; width: 16px; }
|
||||
|
||||
.zoom-pct-btn {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-2xs);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
color: var(--text-secondary);
|
||||
height: 28px;
|
||||
min-width: 38px;
|
||||
text-align: center;
|
||||
transition: color var(--t-base), background var(--t-base);
|
||||
padding: 0 var(--sp-1);
|
||||
border-radius: 0;
|
||||
}
|
||||
.bar-left .zoom-pct-btn,
|
||||
.bar-right .zoom-pct-btn { height: 24px; min-width: unset; width: 28px; writing-mode: vertical-rl; font-size: 9px; padding: var(--sp-1) 0; }
|
||||
.zoom-pct-btn:hover { color: var(--text-primary); background: var(--bg-raised); }
|
||||
.zoom-popover { position: absolute; top: calc(100% + 6px); left: 50%; translate: -50% 0; background: var(--bg-raised); border: 1px solid var(--border-base); border-radius: var(--radius-lg); padding: var(--sp-3); display: flex; flex-direction: column; gap: var(--sp-2); box-shadow: 0 8px 24px rgba(0,0,0,0.5); z-index: 100; min-width: 180px; animation: scaleIn 0.1s ease both; transform-origin: top center; }
|
||||
|
||||
.popover {
|
||||
position: absolute;
|
||||
background: var(--bg-raised);
|
||||
border: 1px solid var(--border-base);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,0.5);
|
||||
z-index: 100;
|
||||
animation: scaleIn 0.1s ease both;
|
||||
}
|
||||
.popover-bottom { top: calc(100% + 6px); left: 50%; translate: -50% 0; transform-origin: top center; }
|
||||
.popover-right { left: calc(100% + 8px); top: 50%; translate: 0 -50%; transform-origin: left center; }
|
||||
.popover-left { right: calc(100% + 8px); top: 50%; translate: 0 -50%; transform-origin: right center; }
|
||||
|
||||
.zoom-popover { padding: var(--sp-3); display: flex; flex-direction: column; gap: var(--sp-2); min-width: 180px; }
|
||||
.zoom-slider-row { display: flex; align-items: center; gap: var(--sp-2); }
|
||||
.zoom-slider { flex: 1; height: 3px; appearance: none; -webkit-appearance: none; background: var(--border-strong); border-radius: 2px; outline: none; cursor: pointer; }
|
||||
.zoom-slider::-webkit-slider-thumb { -webkit-appearance: none; width: 12px; height: 12px; border-radius: 50%; background: var(--accent-fg); cursor: pointer; }
|
||||
@@ -341,7 +509,7 @@
|
||||
.zoom-reset:disabled { opacity: 0.3; cursor: default; }
|
||||
|
||||
.marker-wrap { position: relative; flex-shrink: 0; }
|
||||
.marker-popover { position: absolute; top: calc(100% + 8px); right: 0; width: 240px; background: var(--bg-surface); border: 1px solid var(--border-base); border-radius: var(--radius-lg); padding: var(--sp-3); display: flex; flex-direction: column; gap: var(--sp-3); box-shadow: 0 12px 32px rgba(0,0,0,0.6), 0 2px 8px rgba(0,0,0,0.4); z-index: 100; animation: scaleIn 0.1s ease both; transform-origin: top right; }
|
||||
.marker-popover { width: 240px; padding: var(--sp-3); display: flex; flex-direction: column; gap: var(--sp-3); }
|
||||
.marker-pop-header { display: flex; align-items: center; justify-content: space-between; }
|
||||
.marker-pop-title { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; }
|
||||
.marker-delete-btn { display: flex; align-items: center; justify-content: center; width: 20px; height: 20px; border-radius: var(--radius-sm); border: none; background: none; color: var(--text-faint); cursor: pointer; transition: color var(--t-fast), background var(--t-fast); }
|
||||
@@ -362,12 +530,68 @@
|
||||
.marker-cancel-btn { flex: 1; padding: 6px 8px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); cursor: pointer; transition: color var(--t-base), border-color var(--t-base); text-align: center; }
|
||||
.marker-cancel-btn:hover { color: var(--text-muted); border-color: var(--border-strong); }
|
||||
|
||||
.wc-wrap { position: relative; flex-shrink: 0; }
|
||||
.wc-dropdown { position: absolute; top: calc(100% + 6px); right: 0; background: var(--bg-raised); border: 1px solid var(--border-base); border-radius: var(--radius-lg); padding: var(--sp-1); display: flex; flex-direction: column; gap: 2px; box-shadow: 0 8px 24px rgba(0,0,0,0.5); z-index: 100; min-width: 148px; animation: scaleIn 0.1s ease both; transform-origin: top right; }
|
||||
.wc-btn { display: flex; align-items: center; gap: var(--sp-2); padding: 6px var(--sp-2); border-radius: var(--radius-sm); background: none; border: none; color: var(--text-muted); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); cursor: pointer; width: 100%; transition: color var(--t-base), background var(--t-base); }
|
||||
.wc-btn svg { flex-shrink: 0; opacity: 0.75; }
|
||||
.wc-btn:hover { color: var(--text-primary); background: var(--bg-overlay); }
|
||||
.wc-close:hover { color: #fff; background: #c0392b; }
|
||||
.wc-wrap { position: static; flex-shrink: 0; }
|
||||
.wc-clip {
|
||||
position: absolute;
|
||||
z-index: 100;
|
||||
}
|
||||
.wc-clip-bottom {
|
||||
top: 100%;
|
||||
right: var(--sp-3);
|
||||
clip-path: inset(0 -20px -20px -20px);
|
||||
}
|
||||
.wc-clip-right {
|
||||
left: calc(100% + 1px);
|
||||
top: auto;
|
||||
bottom: var(--sp-3);
|
||||
clip-path: inset(-20px -20px -20px 0);
|
||||
}
|
||||
.wc-clip-left {
|
||||
right: calc(100% + 1px);
|
||||
top: auto;
|
||||
bottom: var(--sp-3);
|
||||
clip-path: inset(-20px 0 -20px -20px);
|
||||
}
|
||||
.wc-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1px;
|
||||
padding: 3px 10px 4px;
|
||||
background: var(--bg-raised);
|
||||
border: 1px solid var(--border-base);
|
||||
box-shadow: 0 6px 16px rgba(0,0,0,0.45);
|
||||
}
|
||||
.wc-clip-bottom .wc-bar { border-top: none; border-radius: 0 0 8px 8px; flex-direction: row; }
|
||||
.wc-clip-right .wc-bar { border-left: none; border-radius: 0 8px 8px 0; flex-direction: column; padding: 10px 4px; }
|
||||
.wc-clip-left .wc-bar { border-right: none; border-radius: 8px 0 0 8px; flex-direction: column; padding: 10px 4px; }
|
||||
|
||||
.wc-icon-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 26px;
|
||||
height: 24px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
transition: color var(--t-base), background var(--t-base);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.wc-icon-btn:hover { color: var(--text-primary); background: var(--bg-overlay); }
|
||||
.wc-icon-close:hover { color: #fff; background: #c0392b; }
|
||||
|
||||
@keyframes scaleIn { from { opacity: 0; transform: scale(0.97) } to { opacity: 1; transform: scale(1) } }
|
||||
|
||||
.bar-middle {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
padding: var(--sp-1) 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,731 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
X, Check, Trash, FloppyDisk,
|
||||
Square, Rows, BookOpen, MonitorPlay,
|
||||
ArrowsLeftRight, ArrowsIn, ArrowsOut, ArrowsVertical,
|
||||
ArrowsHorizontal,
|
||||
SidebarSimple,
|
||||
} from "phosphor-svelte";
|
||||
import type { ReaderSettings, ReaderPreset, FitMode } from "@store/state.svelte";
|
||||
import { store, updateSettings } from "@store/state.svelte";
|
||||
import { readerState, PAGE_STYLES, ZOOM_MIN, ZOOM_MAX } from "../store/readerState.svelte";
|
||||
import { fade, fly } from "svelte/transition";
|
||||
import { cubicOut } from "svelte/easing";
|
||||
|
||||
interface Props {
|
||||
fit: FitMode;
|
||||
style: string;
|
||||
rtl: boolean;
|
||||
zoom: number;
|
||||
zoomPct: number;
|
||||
perMangaEnabled: boolean;
|
||||
onTogglePerManga: () => void;
|
||||
onSavePreset: (name: string) => void;
|
||||
onApplyPreset: (settings: ReaderSettings) => void;
|
||||
onUpdatePreset: (id: string, patch: Partial<Pick<ReaderPreset, "name" | "settings">>) => void;
|
||||
onDeletePreset: (id: string) => void;
|
||||
onApplySettings: (patch: Partial<ReaderSettings>) => void;
|
||||
onCaptureZoomAnchor: () => void;
|
||||
onRestoreZoomAnchor: () => void;
|
||||
onClampZoom: (z: number) => number;
|
||||
barPosition: "top" | "left" | "right";
|
||||
onBarPositionChange: (pos: "top" | "left" | "right") => void;
|
||||
}
|
||||
|
||||
const {
|
||||
fit, style, rtl, zoom, zoomPct,
|
||||
perMangaEnabled, onTogglePerManga,
|
||||
onSavePreset, onApplyPreset, onUpdatePreset, onDeletePreset,
|
||||
onApplySettings,
|
||||
onCaptureZoomAnchor, onRestoreZoomAnchor, onClampZoom,
|
||||
barPosition, onBarPositionChange,
|
||||
}: Props = $props();
|
||||
|
||||
const presets = $derived(store.settings.readerPresets ?? []);
|
||||
const effectiveSettings = $derived.by(() => {
|
||||
const mangaId = store.activeManga?.id;
|
||||
const override = mangaId != null ? (store.settings.mangaReaderSettings ?? {})[mangaId] : undefined;
|
||||
return override ? { ...store.settings, ...override } : store.settings;
|
||||
});
|
||||
|
||||
let presetSaving = $state(false);
|
||||
let presetNameInput = $state("");
|
||||
let presetEditId = $state<string | null>(null);
|
||||
let presetEditName = $state("");
|
||||
|
||||
function close() {
|
||||
readerState.presetOpen = false;
|
||||
presetSaving = false;
|
||||
presetNameInput = "";
|
||||
presetEditId = null;
|
||||
}
|
||||
|
||||
function commitSavePreset() {
|
||||
if (!presetNameInput.trim()) return;
|
||||
onSavePreset(presetNameInput.trim());
|
||||
presetSaving = false;
|
||||
presetNameInput = "";
|
||||
}
|
||||
|
||||
function commitRenamePreset() {
|
||||
if (!presetEditId || !presetEditName.trim()) return;
|
||||
onUpdatePreset(presetEditId, { name: presetEditName.trim() });
|
||||
presetEditId = null;
|
||||
presetEditName = "";
|
||||
}
|
||||
|
||||
function describeSettings(s: ReaderSettings): string {
|
||||
const parts = [s.pageStyle ?? "single", s.fitMode ?? "width", (s.readingDirection ?? "ltr") === "rtl" ? "RTL" : "LTR"];
|
||||
if ((s.readerZoom ?? 1) !== 1.0) parts.push(`${Math.round((s.readerZoom ?? 1) * 100)}%`);
|
||||
if (!s.pageGap) parts.push("no gap");
|
||||
return parts.join(" · ");
|
||||
}
|
||||
|
||||
function setZoom(v: number) {
|
||||
onCaptureZoomAnchor();
|
||||
onApplySettings({ readerZoom: onClampZoom(v) });
|
||||
onRestoreZoomAnchor();
|
||||
}
|
||||
|
||||
const fitOptions: { value: FitMode; label: string; icon: any }[] = [
|
||||
{ value: "width", label: "Fit Width", icon: ArrowsLeftRight },
|
||||
{ value: "height", label: "Fit Height", icon: ArrowsVertical },
|
||||
{ value: "screen", label: "Fit Screen", icon: ArrowsIn },
|
||||
{ value: "original", label: "Original", icon: ArrowsOut },
|
||||
];
|
||||
|
||||
const styleOptions: { value: string; label: string; icon: any }[] = [
|
||||
{ value: "single", label: "Single", icon: Square },
|
||||
{ value: "double", label: "Double", icon: BookOpen },
|
||||
{ value: "fade", label: "Fade", icon: MonitorPlay },
|
||||
{ value: "longstrip", label: "Long Strip", icon: Rows },
|
||||
];
|
||||
|
||||
const barOptions: { value: "top" | "left" | "right"; label: string }[] = [
|
||||
{ value: "left", label: "Left" },
|
||||
{ value: "top", label: "Top" },
|
||||
{ value: "right", label: "Right" },
|
||||
];
|
||||
</script>
|
||||
|
||||
<div class="backdrop" role="presentation" onclick={close} transition:fade={{ duration: 150 }}></div>
|
||||
|
||||
<div
|
||||
class="panel"
|
||||
role="dialog"
|
||||
aria-label="Reader settings & presets"
|
||||
transition:fly={{ x: 320, duration: 220, easing: cubicOut }}
|
||||
>
|
||||
<div class="panel-header">
|
||||
<span class="panel-title">Reader Settings</span>
|
||||
{#if store.activeManga}
|
||||
<span class="panel-manga">{store.activeManga.title}</span>
|
||||
{/if}
|
||||
<button class="close-btn" onclick={close}><X size={14} weight="light" /></button>
|
||||
</div>
|
||||
|
||||
<div class="panel-body">
|
||||
|
||||
<section class="section">
|
||||
<p class="section-label">Page Style</p>
|
||||
<div class="option-grid">
|
||||
{#each styleOptions as o}
|
||||
<button
|
||||
class="option-tile"
|
||||
class:active={style === o.value}
|
||||
onclick={() => onApplySettings({ pageStyle: o.value as typeof PAGE_STYLES[number] })}
|
||||
>
|
||||
<div class="tile-icon"><svelte:component this={o.icon} size={18} weight={style === o.value ? "fill" : "light"} /></div>
|
||||
<span class="tile-label">{o.label}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if style === "double"}
|
||||
<label class="toggle-row">
|
||||
<span class="toggle-label">Offset double spreads</span>
|
||||
<button
|
||||
class="toggle"
|
||||
class:on={effectiveSettings.offsetDoubleSpreads}
|
||||
onclick={() => onApplySettings({ offsetDoubleSpreads: !effectiveSettings.offsetDoubleSpreads })}
|
||||
role="switch"
|
||||
aria-checked={effectiveSettings.offsetDoubleSpreads}
|
||||
><span class="toggle-knob"></span></button>
|
||||
</label>
|
||||
{/if}
|
||||
{#if style === "longstrip"}
|
||||
<label class="toggle-row">
|
||||
<span class="toggle-label">Gap between pages</span>
|
||||
<button
|
||||
class="toggle"
|
||||
class:on={effectiveSettings.pageGap ?? true}
|
||||
onclick={() => onApplySettings({ pageGap: !(effectiveSettings.pageGap ?? true) })}
|
||||
role="switch"
|
||||
aria-checked={effectiveSettings.pageGap ?? true}
|
||||
><span class="toggle-knob"></span></button>
|
||||
</label>
|
||||
<label class="toggle-row">
|
||||
<span class="toggle-label">Auto next chapter</span>
|
||||
<button
|
||||
class="toggle"
|
||||
class:on={store.settings.autoNextChapter ?? false}
|
||||
onclick={() => updateSettings({ autoNextChapter: !(store.settings.autoNextChapter ?? false) })}
|
||||
role="switch"
|
||||
aria-checked={store.settings.autoNextChapter ?? false}
|
||||
><span class="toggle-knob"></span></button>
|
||||
</label>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<p class="section-label">Fit Mode</p>
|
||||
<div class="option-grid">
|
||||
{#each fitOptions as o}
|
||||
<button
|
||||
class="option-tile"
|
||||
class:active={fit === o.value}
|
||||
onclick={() => onApplySettings({ fitMode: o.value })}
|
||||
>
|
||||
<div class="tile-icon"><svelte:component this={o.icon} size={18} weight={fit === o.value ? "fill" : "light"} /></div>
|
||||
<span class="tile-label">{o.label}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<p class="section-label">Reading Direction</p>
|
||||
<div class="dir-row">
|
||||
<button
|
||||
class="dir-btn"
|
||||
class:active={!rtl}
|
||||
onclick={() => onApplySettings({ readingDirection: "ltr" })}
|
||||
>
|
||||
<ArrowsHorizontal size={14} weight="light" />
|
||||
<span>Left to Right</span>
|
||||
</button>
|
||||
<button
|
||||
class="dir-btn"
|
||||
class:active={rtl}
|
||||
onclick={() => onApplySettings({ readingDirection: "rtl" })}
|
||||
>
|
||||
<ArrowsHorizontal size={14} weight="light" style="transform:scaleX(-1)" />
|
||||
<span>Right to Left</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<p class="section-label">Bar Position</p>
|
||||
<div class="bar-grid">
|
||||
{#each barOptions as o}
|
||||
<button
|
||||
class="bar-tile"
|
||||
class:active={barPosition === o.value}
|
||||
onclick={() => onBarPositionChange(o.value)}
|
||||
>
|
||||
<div class="bar-tile-preview bar-preview-{o.value}">
|
||||
<div class="bar-preview-strip"></div>
|
||||
<div class="bar-preview-content"></div>
|
||||
</div>
|
||||
<span class="tile-label">{o.label}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<div class="section-header-row">
|
||||
<p class="section-label" style="margin:0">Zoom</p>
|
||||
<span class="zoom-readout">{zoomPct}%</span>
|
||||
</div>
|
||||
<div class="zoom-row">
|
||||
<button class="zoom-step" onclick={() => setZoom(zoom - 0.1)} disabled={zoom <= ZOOM_MIN}>−</button>
|
||||
<input
|
||||
type="range"
|
||||
class="zoom-slider"
|
||||
min={Math.round(ZOOM_MIN * 100)}
|
||||
max={Math.round(ZOOM_MAX * 100)}
|
||||
step={5}
|
||||
value={zoomPct}
|
||||
oninput={(e) => setZoom(Number(e.currentTarget.value) / 100)}
|
||||
/>
|
||||
<button class="zoom-step" onclick={() => setZoom(zoom + 0.1)} disabled={zoom >= ZOOM_MAX}>+</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<p class="section-label">Image</p>
|
||||
<label class="toggle-row">
|
||||
<span class="toggle-label">Optimize contrast</span>
|
||||
<button
|
||||
class="toggle"
|
||||
class:on={effectiveSettings.optimizeContrast}
|
||||
onclick={() => onApplySettings({ optimizeContrast: !effectiveSettings.optimizeContrast })}
|
||||
role="switch"
|
||||
aria-checked={effectiveSettings.optimizeContrast}
|
||||
><span class="toggle-knob"></span></button>
|
||||
</label>
|
||||
<label class="toggle-row">
|
||||
<span class="toggle-label">Mark read on chapter advance</span>
|
||||
<button
|
||||
class="toggle"
|
||||
class:on={store.settings.markReadOnNext ?? true}
|
||||
onclick={() => updateSettings({ markReadOnNext: !(store.settings.markReadOnNext ?? true) })}
|
||||
role="switch"
|
||||
aria-checked={store.settings.markReadOnNext ?? true}
|
||||
><span class="toggle-knob"></span></button>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
{#if store.activeManga}
|
||||
<section class="section">
|
||||
<label class="toggle-row">
|
||||
<span class="toggle-label">Per-manga settings</span>
|
||||
<button
|
||||
class="toggle"
|
||||
class:on={perMangaEnabled}
|
||||
onclick={onTogglePerManga}
|
||||
role="switch"
|
||||
aria-checked={perMangaEnabled}
|
||||
><span class="toggle-knob"></span></button>
|
||||
</label>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<section class="section">
|
||||
<div class="section-header-row">
|
||||
<p class="section-label" style="margin:0">Saved Presets</p>
|
||||
{#if !presetSaving}
|
||||
<button class="new-preset-btn" onclick={() => { presetSaving = true; presetNameInput = ""; }}>+ New</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if presetSaving}
|
||||
<div class="preset-name-row">
|
||||
<input
|
||||
class="preset-name-input"
|
||||
placeholder="Preset name…"
|
||||
bind:value={presetNameInput}
|
||||
onkeydown={(e) => { if (e.key === "Enter") commitSavePreset(); if (e.key === "Escape") presetSaving = false; }}
|
||||
/>
|
||||
<button class="small-btn" disabled={!presetNameInput.trim()} onclick={commitSavePreset}><Check size={12} weight="bold" /></button>
|
||||
<button class="small-btn" onclick={() => presetSaving = false}><X size={12} weight="light" /></button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if presets.length === 0 && !presetSaving}
|
||||
<p class="empty-hint">No presets saved yet. Save the current settings to create one.</p>
|
||||
{:else}
|
||||
<div class="preset-list">
|
||||
{#each presets as p (p.id)}
|
||||
{#if presetEditId === p.id}
|
||||
<div class="preset-name-row">
|
||||
<input
|
||||
class="preset-name-input"
|
||||
bind:value={presetEditName}
|
||||
onkeydown={(e) => { if (e.key === "Enter") commitRenamePreset(); if (e.key === "Escape") presetEditId = null; }}
|
||||
/>
|
||||
<button class="small-btn" disabled={!presetEditName.trim()} onclick={commitRenamePreset}><Check size={12} weight="bold" /></button>
|
||||
<button class="small-btn" onclick={() => presetEditId = null}><X size={12} weight="light" /></button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="preset-row">
|
||||
<button class="preset-apply" onclick={() => { onApplyPreset(p.settings); close(); }}>
|
||||
<span class="preset-name">{p.name}</span>
|
||||
<span class="preset-desc">{describeSettings(p.settings)}</span>
|
||||
</button>
|
||||
<button class="small-btn" title="Rename" onclick={() => { presetEditId = p.id; presetEditName = p.name; }}>
|
||||
<FloppyDisk size={12} weight="regular" />
|
||||
</button>
|
||||
<button class="small-btn danger" title="Delete" onclick={() => onDeletePreset(p.id)}>
|
||||
<Trash size={12} weight="regular" />
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: calc(var(--z-reader) + 20);
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.panel {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 320px;
|
||||
z-index: calc(var(--z-reader) + 21);
|
||||
background: var(--bg-surface);
|
||||
border-left: 1px solid var(--border-base);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: -12px 0 40px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
padding: 0 var(--sp-4);
|
||||
height: 48px;
|
||||
border-bottom: 1px solid var(--border-dim);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--weight-medium);
|
||||
color: var(--text-primary);
|
||||
letter-spacing: var(--tracking-tight);
|
||||
}
|
||||
|
||||
.panel-manga {
|
||||
flex: 1;
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-faint);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
transition: color var(--t-base), background var(--t-base);
|
||||
}
|
||||
.close-btn:hover { color: var(--text-primary); background: var(--bg-overlay); }
|
||||
|
||||
.panel-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: var(--sp-3) var(--sp-4);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-4);
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--border-dim) transparent;
|
||||
}
|
||||
|
||||
.section { display: flex; flex-direction: column; gap: var(--sp-2); }
|
||||
|
||||
.section-label {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-2xs);
|
||||
color: var(--text-faint);
|
||||
letter-spacing: var(--tracking-wider);
|
||||
text-transform: uppercase;
|
||||
margin: 0 0 var(--sp-1);
|
||||
}
|
||||
|
||||
.section-header-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: var(--sp-1);
|
||||
}
|
||||
|
||||
.option-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: var(--sp-1);
|
||||
}
|
||||
|
||||
.option-tile {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: var(--sp-2) var(--sp-1);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border-dim);
|
||||
background: var(--bg-overlay);
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
transition: color var(--t-fast), background var(--t-fast), border-color var(--t-fast);
|
||||
}
|
||||
.option-tile:hover { color: var(--text-primary); background: var(--bg-raised); border-color: var(--border-strong); }
|
||||
.option-tile.active { color: var(--accent-fg); background: var(--accent-muted); border-color: var(--accent-dim); }
|
||||
|
||||
.tile-icon { display: flex; align-items: center; justify-content: center; }
|
||||
.tile-label { font-family: var(--font-ui); font-size: 9px; letter-spacing: var(--tracking-wide); text-transform: capitalize; line-height: 1; }
|
||||
|
||||
.bar-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: var(--sp-1);
|
||||
}
|
||||
|
||||
.bar-tile {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: var(--sp-2) var(--sp-1);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border-dim);
|
||||
background: var(--bg-overlay);
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
transition: color var(--t-fast), background var(--t-fast), border-color var(--t-fast);
|
||||
}
|
||||
.bar-tile:hover { color: var(--text-primary); background: var(--bg-raised); border-color: var(--border-strong); }
|
||||
.bar-tile.active { color: var(--accent-fg); background: var(--accent-muted); border-color: var(--accent-dim); }
|
||||
|
||||
.bar-tile-preview {
|
||||
width: 32px;
|
||||
height: 22px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid currentColor;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
opacity: 0.7;
|
||||
display: flex;
|
||||
}
|
||||
.bar-tile.active .bar-tile-preview { opacity: 1; }
|
||||
|
||||
.bar-preview-strip {
|
||||
background: currentColor;
|
||||
opacity: 0.5;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.bar-preview-content {
|
||||
flex: 1;
|
||||
background: color-mix(in srgb, currentColor 8%, transparent);
|
||||
}
|
||||
|
||||
.bar-preview-top { flex-direction: column; }
|
||||
.bar-preview-left { flex-direction: row; }
|
||||
.bar-preview-right { flex-direction: row-reverse; }
|
||||
|
||||
.bar-preview-top .bar-preview-strip { height: 5px; width: 100%; }
|
||||
.bar-preview-left .bar-preview-strip { width: 5px; height: 100%; }
|
||||
.bar-preview-right .bar-preview-strip { width: 5px; height: 100%; }
|
||||
|
||||
.toggle-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--sp-1) 0;
|
||||
}
|
||||
|
||||
.toggle-label {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.toggle {
|
||||
position: relative;
|
||||
width: 32px;
|
||||
height: 18px;
|
||||
border-radius: 9px;
|
||||
background: var(--border-strong);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: background var(--t-base);
|
||||
}
|
||||
.toggle.on { background: var(--accent-fg); }
|
||||
.toggle-knob {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
transition: left var(--t-base);
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.3);
|
||||
}
|
||||
.toggle.on .toggle-knob { left: 16px; }
|
||||
|
||||
.dir-row { display: flex; gap: var(--sp-2); }
|
||||
|
||||
.dir-btn {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--sp-2);
|
||||
padding: var(--sp-2) var(--sp-3);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border-dim);
|
||||
background: var(--bg-overlay);
|
||||
color: var(--text-muted);
|
||||
font-size: var(--text-xs);
|
||||
cursor: pointer;
|
||||
transition: color var(--t-fast), background var(--t-fast), border-color var(--t-fast);
|
||||
}
|
||||
.dir-btn:hover { color: var(--text-primary); background: var(--bg-raised); border-color: var(--border-strong); }
|
||||
.dir-btn.active { color: var(--accent-fg); background: var(--accent-muted); border-color: var(--accent-dim); }
|
||||
|
||||
.zoom-readout {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-secondary);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
}
|
||||
|
||||
.zoom-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.zoom-step {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border-dim);
|
||||
background: var(--bg-overlay);
|
||||
color: var(--text-muted);
|
||||
font-size: var(--text-base);
|
||||
line-height: 1;
|
||||
flex-shrink: 0;
|
||||
transition: color var(--t-fast), background var(--t-fast);
|
||||
}
|
||||
.zoom-step:hover:not(:disabled) { color: var(--text-primary); background: var(--bg-raised); }
|
||||
.zoom-step:disabled { opacity: 0.25; cursor: default; }
|
||||
|
||||
.zoom-slider {
|
||||
flex: 1;
|
||||
height: 3px;
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
background: var(--border-strong);
|
||||
border-radius: 2px;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.zoom-slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent-fg);
|
||||
cursor: pointer;
|
||||
box-shadow: 0 0 0 2px rgba(0,0,0,0.3);
|
||||
}
|
||||
|
||||
.new-preset-btn {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-2xs);
|
||||
color: var(--accent-fg);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 2px var(--sp-1);
|
||||
border-radius: var(--radius-sm);
|
||||
transition: background var(--t-fast);
|
||||
}
|
||||
.new-preset-btn:hover { background: var(--accent-muted); }
|
||||
|
||||
.preset-name-row { display: flex; align-items: center; gap: var(--sp-1); }
|
||||
|
||||
.preset-name-input {
|
||||
flex: 1;
|
||||
background: var(--bg-raised);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 5px 8px;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-primary);
|
||||
outline: none;
|
||||
font-family: inherit;
|
||||
transition: border-color var(--t-base);
|
||||
}
|
||||
.preset-name-input:focus { border-color: var(--accent-dim); }
|
||||
|
||||
.preset-list { display: flex; flex-direction: column; gap: 2px; }
|
||||
|
||||
.preset-row { display: flex; align-items: center; gap: var(--sp-1); }
|
||||
|
||||
.preset-apply {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 2px;
|
||||
padding: 7px var(--sp-2);
|
||||
border-radius: var(--radius-md);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: background var(--t-fast);
|
||||
min-width: 0;
|
||||
}
|
||||
.preset-apply:hover { background: var(--bg-overlay); }
|
||||
|
||||
.preset-name {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-secondary);
|
||||
font-weight: var(--weight-medium);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 200px;
|
||||
}
|
||||
|
||||
.preset-desc {
|
||||
font-family: var(--font-ui);
|
||||
font-size: 10px;
|
||||
color: var(--text-faint);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 200px;
|
||||
}
|
||||
|
||||
.small-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--text-faint);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: color var(--t-fast), background var(--t-fast);
|
||||
}
|
||||
.small-btn:hover:not(:disabled) { color: var(--text-primary); background: var(--bg-overlay); }
|
||||
.small-btn:disabled { opacity: 0.25; cursor: default; }
|
||||
.small-btn.danger:hover { color: var(--color-error); background: var(--color-error-bg); }
|
||||
|
||||
.empty-hint {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-faint);
|
||||
margin: 0;
|
||||
padding: var(--sp-2) 0;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
@@ -17,6 +17,7 @@
|
||||
activeChapterMarkers: MarkerEntry[];
|
||||
adjacent: { prev: Chapter | null; next: Chapter | null };
|
||||
uiVisible: boolean;
|
||||
barPosition: "top" | "left" | "right";
|
||||
onGoPrev: () => void;
|
||||
onGoNext: () => void;
|
||||
onJumpToPage: (page: number) => void;
|
||||
@@ -25,71 +26,126 @@
|
||||
const {
|
||||
style, loading, rtl, sliderPage, sliderMax, sliderPct, lastPage,
|
||||
displayChapter, currentBookmark, activeChapterMarkers, adjacent, uiVisible,
|
||||
barPosition,
|
||||
onGoPrev, onGoNext, onJumpToPage,
|
||||
}: Props = $props();
|
||||
|
||||
const isVertical = $derived(barPosition === "left" || barPosition === "right");
|
||||
</script>
|
||||
|
||||
<div class="bottombar" class:hidden={!uiVisible}>
|
||||
<button class="nav-btn" onclick={onGoPrev}
|
||||
disabled={loading || (style === "longstrip" ? !adjacent.prev : (sliderPage === 1 && !adjacent.prev))}>
|
||||
<ArrowLeft size={13} weight="light" />
|
||||
</button>
|
||||
{#if !isVertical}
|
||||
<div class="bottombar" class:hidden={!uiVisible}>
|
||||
<button class="nav-btn" onclick={onGoPrev}
|
||||
disabled={loading || (style === "longstrip" ? !adjacent.prev : (sliderPage === 1 && !adjacent.prev))}>
|
||||
<ArrowLeft size={13} weight="light" />
|
||||
</button>
|
||||
|
||||
{#if sliderMax > 1}
|
||||
<div
|
||||
class="slider-wrap"
|
||||
class:dragging={readerState.sliderDragging}
|
||||
role="slider"
|
||||
aria-valuenow={sliderPage}
|
||||
aria-valuemin={1}
|
||||
aria-valuemax={sliderMax}
|
||||
tabindex="-1"
|
||||
onmouseenter={() => readerState.sliderHover = true}
|
||||
onmouseleave={() => { readerState.sliderHover = false; readerState.sliderDragging = false; }}
|
||||
onmousedown={(e) => {
|
||||
readerState.sliderDragging = true;
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
|
||||
onJumpToPage(Math.round(1 + (rtl ? 1 - ratio : ratio) * (sliderMax - 1)));
|
||||
}}
|
||||
onmousemove={(e) => {
|
||||
if (!readerState.sliderDragging) return;
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
|
||||
onJumpToPage(Math.round(1 + (rtl ? 1 - ratio : ratio) * (sliderMax - 1)));
|
||||
}}
|
||||
onmouseup={() => readerState.sliderDragging = false}
|
||||
>
|
||||
<div class="slider-track-bg">
|
||||
<div class="slider-fill" style={rtl ? `width:${100 - sliderPct}%;margin-left:auto` : `width:${sliderPct}%`}></div>
|
||||
</div>
|
||||
<div class="slider-thumb" style="left:{sliderPct}%"></div>
|
||||
|
||||
{#if currentBookmark && currentBookmark.chapterId === displayChapter?.id}
|
||||
{@const bOrd = rtl ? lastPage - currentBookmark.pageNumber + 1 : currentBookmark.pageNumber}
|
||||
{@const bPct = lastPage > 1 ? ((bOrd - 1) / (lastPage - 1)) * 100 : 0}
|
||||
<div class="slider-checkpoint bookmark-checkpoint" style="left:{bPct}%" title="Bookmark: Page {currentBookmark.pageNumber}"></div>
|
||||
{/if}
|
||||
|
||||
{#each activeChapterMarkers as m (m.id)}
|
||||
{@const mOrd = rtl ? lastPage - m.pageNumber + 1 : m.pageNumber}
|
||||
{@const mPct = lastPage > 1 ? ((mOrd - 1) / (lastPage - 1)) * 100 : 0}
|
||||
<div class="slider-checkpoint marker-checkpoint" style="left:{mPct}%;background:{MARKER_COLOR_HEX[m.color]}" title="{m.note ? m.note : 'Marker'} · Page {m.pageNumber}"></div>
|
||||
{/each}
|
||||
|
||||
{#if readerState.sliderHover || readerState.sliderDragging}
|
||||
<div class="slider-tooltip" style="left:{sliderPct}%">
|
||||
{sliderPage} / {sliderMax}
|
||||
{#if sliderMax > 1}
|
||||
<div
|
||||
class="slider-wrap"
|
||||
class:dragging={readerState.sliderDragging}
|
||||
role="slider"
|
||||
aria-valuenow={sliderPage}
|
||||
aria-valuemin={1}
|
||||
aria-valuemax={sliderMax}
|
||||
tabindex="-1"
|
||||
onmouseenter={() => readerState.sliderHover = true}
|
||||
onmouseleave={() => { readerState.sliderHover = false; readerState.sliderDragging = false; }}
|
||||
onmousedown={(e) => {
|
||||
readerState.sliderDragging = true;
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
|
||||
onJumpToPage(Math.round(1 + (rtl ? 1 - ratio : ratio) * (sliderMax - 1)));
|
||||
}}
|
||||
onmousemove={(e) => {
|
||||
if (!readerState.sliderDragging) return;
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
|
||||
onJumpToPage(Math.round(1 + (rtl ? 1 - ratio : ratio) * (sliderMax - 1)));
|
||||
}}
|
||||
onmouseup={() => readerState.sliderDragging = false}
|
||||
>
|
||||
<div class="slider-track-bg">
|
||||
<div class="slider-fill" style={rtl ? `width:${100 - sliderPct}%;margin-left:auto` : `width:${sliderPct}%`}></div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="slider-thumb" style="left:{sliderPct}%"></div>
|
||||
|
||||
<button class="nav-btn" onclick={onGoNext}
|
||||
disabled={loading || (style === "longstrip" ? !adjacent.next : (sliderPage === sliderMax && !adjacent.next))}>
|
||||
<ArrowRight size={13} weight="light" />
|
||||
</button>
|
||||
</div>
|
||||
{#if currentBookmark && currentBookmark.chapterId === displayChapter?.id}
|
||||
{@const bOrd = rtl ? sliderMax - currentBookmark.pageNumber + 1 : currentBookmark.pageNumber}
|
||||
{@const bPct = sliderMax > 1 ? ((bOrd - 1) / (sliderMax - 1)) * 100 : 0}
|
||||
<div class="slider-checkpoint bookmark-checkpoint" style="left:{bPct}%" title="Bookmark: Page {currentBookmark.pageNumber}"></div>
|
||||
{/if}
|
||||
|
||||
{#each activeChapterMarkers as m (m.id)}
|
||||
{@const mOrd = rtl ? sliderMax - m.pageNumber + 1 : m.pageNumber}
|
||||
{@const mPct = sliderMax > 1 ? ((mOrd - 1) / (sliderMax - 1)) * 100 : 0}
|
||||
<div class="slider-checkpoint marker-checkpoint" style="left:{mPct}%;background:{MARKER_COLOR_HEX[m.color]}" title="{m.note ? m.note : 'Marker'} · Page {m.pageNumber}"></div>
|
||||
{/each}
|
||||
|
||||
{#if readerState.sliderHover || readerState.sliderDragging}
|
||||
<div class="slider-tooltip" style="left:{sliderPct}%">
|
||||
{sliderPage} / {sliderMax}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<button class="nav-btn" onclick={onGoNext}
|
||||
disabled={loading || (style === "longstrip" ? !adjacent.next : (sliderPage === sliderMax && !adjacent.next))}>
|
||||
<ArrowRight size={13} weight="light" />
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="vbar-progress" class:hidden={!uiVisible} class:vbar-right={barPosition === "right"}>
|
||||
{#if sliderMax > 1}
|
||||
<div
|
||||
class="vslider-wrap"
|
||||
class:dragging={readerState.sliderDragging}
|
||||
role="slider"
|
||||
aria-valuenow={sliderPage}
|
||||
aria-valuemin={1}
|
||||
aria-valuemax={sliderMax}
|
||||
tabindex="-1"
|
||||
onmouseenter={() => readerState.sliderHover = true}
|
||||
onmouseleave={() => { readerState.sliderHover = false; readerState.sliderDragging = false; }}
|
||||
onmousedown={(e) => {
|
||||
readerState.sliderDragging = true;
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
const ratio = Math.max(0, Math.min(1, (e.clientY - rect.top) / rect.height));
|
||||
onJumpToPage(Math.round(1 + ratio * (sliderMax - 1)));
|
||||
}}
|
||||
onmousemove={(e) => {
|
||||
if (!readerState.sliderDragging) return;
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
const ratio = Math.max(0, Math.min(1, (e.clientY - rect.top) / rect.height));
|
||||
onJumpToPage(Math.round(1 + ratio * (sliderMax - 1)));
|
||||
}}
|
||||
onmouseup={() => readerState.sliderDragging = false}
|
||||
>
|
||||
<div class="vslider-track-bg">
|
||||
<div class="vslider-fill" style="height:{sliderPct}%"></div>
|
||||
</div>
|
||||
<div class="vslider-thumb" style="top:{sliderPct}%"></div>
|
||||
|
||||
{#if currentBookmark && currentBookmark.chapterId === displayChapter?.id}
|
||||
{@const bPct = sliderMax > 1 ? ((currentBookmark.pageNumber - 1) / (sliderMax - 1)) * 100 : 0}
|
||||
<div class="vslider-checkpoint bookmark-checkpoint" style="top:{bPct}%" title="Bookmark: Page {currentBookmark.pageNumber}"></div>
|
||||
{/if}
|
||||
|
||||
{#each activeChapterMarkers as m (m.id)}
|
||||
{@const mPct = sliderMax > 1 ? ((m.pageNumber - 1) / (sliderMax - 1)) * 100 : 0}
|
||||
<div class="vslider-checkpoint marker-checkpoint" style="top:{mPct}%;background:{MARKER_COLOR_HEX[m.color]}" title="{m.note ? m.note : 'Marker'} · Page {m.pageNumber}"></div>
|
||||
{/each}
|
||||
|
||||
{#if readerState.sliderHover || readerState.sliderDragging}
|
||||
<div class="vslider-tooltip" style="top:{sliderPct}%" class:tooltip-right={barPosition === "right"}>
|
||||
{sliderPage} / {sliderMax}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.bottombar { display: flex; align-items: center; gap: var(--sp-3); padding: var(--sp-2) var(--sp-3); border-top: 1px solid var(--border-dim); background: var(--bg-void); flex-shrink: 0; transition: opacity 0.25s ease; }
|
||||
@@ -109,4 +165,91 @@
|
||||
.marker-checkpoint { opacity: 0.85; }
|
||||
.slider-tooltip { position: absolute; bottom: calc(100% + 2px); transform: translateX(-50%); background: var(--bg-raised); border: 1px solid var(--border-base); border-radius: var(--radius-sm); padding: 2px 6px; font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-secondary); white-space: nowrap; pointer-events: none; z-index: 10; letter-spacing: var(--tracking-wide); }
|
||||
.slider-wrap:hover .slider-track-bg, .slider-wrap.dragging .slider-track-bg { height: 5px; }
|
||||
|
||||
.vbar-progress {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
padding: var(--sp-2) 0;
|
||||
transition: opacity 0.25s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
.vbar-progress.hidden { opacity: 0; }
|
||||
|
||||
.vslider-wrap {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: 36px;
|
||||
cursor: pointer;
|
||||
pointer-events: all;
|
||||
margin: var(--sp-1) 0;
|
||||
}
|
||||
.vslider-track-bg {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 5px;
|
||||
background: var(--border-strong);
|
||||
border-radius: 3px;
|
||||
pointer-events: none;
|
||||
left: 50%;
|
||||
translate: -50% 0;
|
||||
}
|
||||
.vslider-fill {
|
||||
width: 100%;
|
||||
background: var(--accent-fg);
|
||||
border-radius: 3px;
|
||||
transition: height 0.05s linear;
|
||||
}
|
||||
.vslider-thumb {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent-fg);
|
||||
pointer-events: none;
|
||||
z-index: 2;
|
||||
box-shadow: 0 0 0 2px rgba(0,0,0,0.5);
|
||||
transition: transform var(--t-fast);
|
||||
}
|
||||
.vslider-wrap:hover .vslider-thumb, .vslider-wrap.dragging .vslider-thumb { transform: translate(-50%, -50%) scale(1.3); }
|
||||
.vslider-wrap:hover .vslider-track-bg, .vslider-wrap.dragging .vslider-track-bg { width: 7px; }
|
||||
.vslider-checkpoint {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 12px;
|
||||
height: 5px;
|
||||
border-radius: 2px;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
.vslider-tooltip {
|
||||
position: absolute;
|
||||
left: calc(100% + 6px);
|
||||
transform: translateY(-50%);
|
||||
background: var(--bg-raised);
|
||||
border: 1px solid var(--border-base);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 2px 6px;
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-2xs);
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
letter-spacing: var(--tracking-wide);
|
||||
}
|
||||
.vslider-tooltip.tooltip-right {
|
||||
left: auto;
|
||||
right: calc(100% + 6px);
|
||||
}
|
||||
</style>
|
||||
@@ -56,4 +56,4 @@ export function createReaderKeyHandler(actions: ReaderKeyActions): (e: KeyboardE
|
||||
else if (matchesKeybind(e, kb.toggleBookmark)) { e.preventDefault(); actions.toggleBookmark(); }
|
||||
else if (matchesKeybind(e, kb.toggleMarker)) { e.preventDefault(); actions.toggleMarker(); }
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,8 @@ class ReaderState {
|
||||
dlOpen = $state(false);
|
||||
zoomOpen = $state(false);
|
||||
winOpen = $state(false);
|
||||
presetOpen = $state(false);
|
||||
presetNameInput = $state("");
|
||||
nextN = $state(5);
|
||||
dlBusy = $state(false);
|
||||
|
||||
@@ -80,10 +82,11 @@ class ReaderState {
|
||||
}
|
||||
|
||||
closeAllPopovers(): boolean {
|
||||
if (this.markerOpen) { this.markerOpen = false; return true; }
|
||||
if (this.zoomOpen) { this.zoomOpen = false; return true; }
|
||||
if (this.dlOpen) { this.dlOpen = false; return true; }
|
||||
if (this.winOpen) { this.winOpen = false; return true; }
|
||||
if (this.markerOpen) { this.markerOpen = false; return true; }
|
||||
if (this.zoomOpen) { this.zoomOpen = false; return true; }
|
||||
if (this.dlOpen) { this.dlOpen = false; return true; }
|
||||
if (this.winOpen) { this.winOpen = false; return true; }
|
||||
if (this.presetOpen) { this.presetOpen = false; return true; }
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -104,4 +107,4 @@ class ReaderState {
|
||||
}
|
||||
}
|
||||
|
||||
export const readerState = new ReaderState();
|
||||
export const readerState = new ReaderState();
|
||||
@@ -173,7 +173,7 @@
|
||||
</div>
|
||||
|
||||
<div class="list-header-right">
|
||||
<!-- Jump to chapter -->
|
||||
|
||||
<div class="jump-wrap">
|
||||
<button class="icon-btn" class:active={jumpOpen} onclick={() => { jumpOpen = !jumpOpen; jumpInput = ""; }} title="Jump to chapter">
|
||||
<MagnifyingGlass size={14} weight="light" />
|
||||
@@ -191,7 +191,7 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Scanlator filter -->
|
||||
|
||||
{#if availableScanlators.length > 1}
|
||||
<div class="scan-filter-wrap">
|
||||
<button class="icon-btn" class:active={scanlatorFilter.length > 0 || scanlatorBlacklist.length > 0} onclick={() => scanFilterOpen = !scanFilterOpen} title="Filter by scanlator">
|
||||
@@ -245,12 +245,12 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Refresh -->
|
||||
|
||||
<button class="icon-btn" onclick={onRefresh} disabled={refreshing}>
|
||||
<ArrowsClockwise size={14} weight="light" class={refreshing ? "anim-spin" : ""} />
|
||||
</button>
|
||||
|
||||
<!-- Folder picker -->
|
||||
|
||||
<div class="fp-wrap" bind:this={folderPickerRef}>
|
||||
<button class="icon-btn" class:active={hasFolders} onclick={() => folderPickerOpen = !folderPickerOpen}>
|
||||
<FolderSimplePlus size={14} weight={hasFolders ? "fill" : "light"} />
|
||||
@@ -283,7 +283,7 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Download dropdown -->
|
||||
|
||||
{#if chapters.length > 0}
|
||||
<div class="dl-wrap" bind:this={dlDropRef}>
|
||||
<button class="icon-btn dl-unified-btn" class:active={dlOpen} class:dl-has-count={downloadedCount > 0} onclick={() => dlOpen = !dlOpen} title="Download options">
|
||||
@@ -343,7 +343,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Top pagination -->
|
||||
|
||||
{#if totalPages > 1}
|
||||
<div class="pagination">
|
||||
<button class="page-btn" onclick={() => onPageChange(Math.max(1, chapterPage - 1))} disabled={chapterPage === 1}>←</button>
|
||||
@@ -355,7 +355,6 @@
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* ─── Header bar ──────────────────────────────────────────── */
|
||||
.list-header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: var(--sp-3) var(--sp-4); border-bottom: 1px solid var(--border-dim);
|
||||
@@ -364,7 +363,6 @@
|
||||
.list-header-left,
|
||||
.list-header-right { display: flex; align-items: center; gap: var(--sp-1); }
|
||||
|
||||
/* ─── Sort ────────────────────────────────────────────────── */
|
||||
.sort-btn {
|
||||
display: flex; align-items: center; gap: 5px;
|
||||
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
|
||||
@@ -390,7 +388,6 @@
|
||||
.sort-option.active { color: var(--accent-fg); }
|
||||
.sort-divider { height: 1px; background: var(--border-dim); margin: var(--sp-1) var(--sp-2); }
|
||||
|
||||
/* ─── Icon buttons ────────────────────────────────────────── */
|
||||
.icon-btn {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
width: 28px; height: 28px; border-radius: var(--radius-md);
|
||||
@@ -402,7 +399,6 @@
|
||||
.icon-btn.active { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
|
||||
.icon-btn:disabled { opacity: 0.3; cursor: default; }
|
||||
|
||||
/* ─── Jump ────────────────────────────────────────────────── */
|
||||
.jump-wrap { position: relative; }
|
||||
.jump-popover {
|
||||
position: absolute; top: calc(100% + 4px); right: 0; width: 220px;
|
||||
@@ -429,7 +425,6 @@
|
||||
.jump-go:hover { background: var(--accent); border-color: var(--accent); color: var(--accent-contrast, #fff); }
|
||||
.jump-none { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); padding: 4px var(--sp-1); letter-spacing: var(--tracking-wide); }
|
||||
|
||||
/* ─── Folder picker ───────────────────────────────────────── */
|
||||
.fp-wrap { position: relative; }
|
||||
.fp-menu {
|
||||
position: absolute; top: calc(100% + 4px); right: 0; min-width: 180px;
|
||||
@@ -476,7 +471,6 @@
|
||||
}
|
||||
.fp-new:hover { color: var(--text-secondary); background: var(--bg-overlay); }
|
||||
|
||||
/* ─── Download dropdown ───────────────────────────────────── */
|
||||
.dl-wrap { position: relative; }
|
||||
.dl-dropdown {
|
||||
position: absolute; top: calc(100% + 4px); right: 0; min-width: 220px;
|
||||
@@ -545,7 +539,6 @@
|
||||
.dl-unified-btn.dl-has-count:hover { background: var(--accent-muted); border-color: var(--accent); opacity: 0.9; }
|
||||
.dl-unified-btn.active { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
|
||||
|
||||
/* ─── Pagination (top) ────────────────────────────────────── */
|
||||
.pagination { display: flex; align-items: center; gap: var(--sp-2); }
|
||||
.page-btn {
|
||||
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
|
||||
@@ -557,7 +550,6 @@
|
||||
.page-btn:disabled { opacity: 0.3; cursor: default; }
|
||||
.page-num { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||
|
||||
/* ─── Selection toolbar ───────────────────────────────────── */
|
||||
.sel-count {
|
||||
font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted);
|
||||
letter-spacing: var(--tracking-wide); padding: 0 var(--sp-1);
|
||||
@@ -572,7 +564,6 @@
|
||||
.sel-action-danger { color: var(--color-error) !important; }
|
||||
.sel-action-danger:hover { background: var(--color-error-bg) !important; border-color: var(--color-error) !important; }
|
||||
|
||||
/* ─── Scanlator filter ────────────────────────────────────── */
|
||||
.scan-filter-wrap { position: relative; }
|
||||
.scan-filter-panel {
|
||||
position: absolute; top: calc(100% + 6px); right: 0; z-index: 200; min-width: 200px;
|
||||
@@ -637,6 +628,5 @@
|
||||
.scan-filter-item-block { color: var(--color-error) !important; background: color-mix(in srgb, var(--color-error) 8%, transparent) !important; }
|
||||
.scan-filter-item-block:hover { background: color-mix(in srgb, var(--color-error) 14%, transparent) !important; }
|
||||
|
||||
/* ─── Shared animation (used by dropdowns/popovers) ───────── */
|
||||
@keyframes scaleIn { from { opacity: 0; transform: scale(0.97) } to { opacity: 1; transform: scale(1) } }
|
||||
</style>
|
||||
</style>
|
||||
@@ -160,7 +160,14 @@
|
||||
|
||||
function applyChapters(nodes: Chapter[]) {
|
||||
if (get("autoDownload") && _prevChapterIds.size > 0) {
|
||||
const newChapters = nodes.filter(c => !_prevChapterIds.has(c.id) && !c.isDownloaded);
|
||||
const filtered = buildChapterList(nodes, {
|
||||
sortMode, sortDir,
|
||||
preferredScanlator: get("preferredScanlator") as string,
|
||||
scanlatorFilter: scanlatorFilter as string[],
|
||||
scanlatorBlacklist: scanlatorBlacklist as string[],
|
||||
scanlatorForce: scanlatorForce as boolean,
|
||||
});
|
||||
const newChapters = filtered.filter(c => !_prevChapterIds.has(c.id) && !c.isDownloaded);
|
||||
if (newChapters.length) enqueueMultiple(newChapters.map(c => c.id));
|
||||
}
|
||||
_prevChapterIds = new Set(nodes.map(c => c.id));
|
||||
|
||||
@@ -382,6 +382,42 @@
|
||||
flex: 1;
|
||||
accent-color: var(--accent);
|
||||
cursor: pointer;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: var(--border-strong);
|
||||
outline: none;
|
||||
}
|
||||
.s-slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
box-shadow: 0 1px 4px rgba(0,0,0,0.4);
|
||||
transition: transform var(--t-fast), box-shadow var(--t-fast);
|
||||
}
|
||||
.s-slider::-webkit-slider-thumb:hover {
|
||||
transform: scale(1.15);
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.5);
|
||||
}
|
||||
.s-slider::-moz-range-thumb {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
box-shadow: 0 1px 4px rgba(0,0,0,0.4);
|
||||
}
|
||||
.s-slider::-moz-range-track {
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: var(--border-strong);
|
||||
}
|
||||
|
||||
.s-slider-val {
|
||||
|
||||
@@ -65,9 +65,9 @@
|
||||
let listeningKey: keyof Keybinds | null = $state(null);
|
||||
|
||||
$effect(() => {
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape" && !listeningKey) close(); };
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape" && !listeningKey) { e.stopPropagation(); close(); } };
|
||||
window.addEventListener("keydown", onKey, true);
|
||||
return () => window.removeEventListener("keydown", onKey, true);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
@@ -118,7 +118,7 @@
|
||||
|
||||
<div class="s-backdrop" role="presentation" tabindex="-1"
|
||||
onclick={(e) => { if (e.target === e.currentTarget) close(); }}
|
||||
onkeydown={(e) => { if (e.key === "Escape") close(); }}>
|
||||
onkeydown={(e) => { if (e.key === "Escape") { e.stopPropagation(); close(); } }}>
|
||||
<div class="s-modal" role="dialog" aria-label="Settings">
|
||||
|
||||
<div class="s-sidebar">
|
||||
|
||||
@@ -3,9 +3,10 @@
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { getVersion } from "@tauri-apps/api/app";
|
||||
import { open as openUrl } from "@tauri-apps/plugin-shell";
|
||||
import { autoBackupAppData } from "@core/backup";
|
||||
|
||||
interface ReleaseInfo { tag_name: string; name: string; body: string; published_at: string; html_url: string; }
|
||||
type UpdatePhase = "idle" | "downloading" | "ready" | "error";
|
||||
type UpdatePhase = "idle" | "downloading" | "launching" | "ready" | "error";
|
||||
const IS_WINDOWS = navigator.userAgent.includes("Windows");
|
||||
|
||||
let appVersion = $state("…");
|
||||
@@ -33,6 +34,13 @@
|
||||
return () => unlisten?.();
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
let unlisten: (() => void) | undefined;
|
||||
listen("update-launching", () => { updatePhase = "launching"; })
|
||||
.then(fn => { unlisten = fn; });
|
||||
return () => unlisten?.();
|
||||
});
|
||||
|
||||
async function loadReleases() {
|
||||
releasesLoading = true; releasesError = null;
|
||||
try {
|
||||
@@ -80,8 +88,9 @@
|
||||
targetTag = release.tag_name; updatePhase = "downloading"; updateError = null; dlBytes = 0; dlTotal = null;
|
||||
try {
|
||||
if (IS_WINDOWS) {
|
||||
await autoBackupAppData();
|
||||
try { await invoke("kill_server"); } catch {}
|
||||
await invoke("download_and_install_update");
|
||||
await invoke("download_and_install_update", { tag: release.tag_name });
|
||||
updatePhase = "ready";
|
||||
} else {
|
||||
await openUrl(release.html_url);
|
||||
@@ -134,6 +143,11 @@
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{#if updatePhase === "launching"}
|
||||
<div class="s-update-ready">
|
||||
<span class="s-update-ready-label">Launching installer for {targetTag}…</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if updatePhase === "ready"}
|
||||
<div class="s-update-ready">
|
||||
<span class="s-update-ready-label">{targetTag} downloaded — restart to finish installing.</span>
|
||||
|
||||
@@ -19,6 +19,10 @@
|
||||
<div class="s-section">
|
||||
<p class="s-section-title">Display</p>
|
||||
<div class="s-section-body">
|
||||
<label class="s-row">
|
||||
<div class="s-row-info"><span class="s-label">Always show card stats</span><span class="s-desc">Show unread and download counts without needing to hover</span></div>
|
||||
<button role="switch" aria-checked={store.settings.libraryStatsAlways ?? false} aria-label="Always show card stats" class="s-toggle" class:on={store.settings.libraryStatsAlways ?? false} onclick={() => updateSettings({ libraryStatsAlways: !(store.settings.libraryStatsAlways ?? false) })}><span class="s-toggle-thumb"></span></button>
|
||||
</label>
|
||||
<label class="s-row">
|
||||
<div class="s-row-info"><span class="s-label">Crop cover images</span><span class="s-desc">Fills the card with the cover art instead of letterboxing</span></div>
|
||||
<button role="switch" aria-checked={store.settings.libraryCropCovers} aria-label="Crop cover images" class="s-toggle" class:on={store.settings.libraryCropCovers} onclick={() => updateSettings({ libraryCropCovers: !store.settings.libraryCropCovers })}><span class="s-toggle-thumb"></span></button>
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import { SET_DOWNLOADS_PATH, SET_LOCAL_SOURCE_PATH } from "@api/mutations/downloads";
|
||||
import { untrack } from "svelte";
|
||||
import { store, updateSettings, addToast } from "@store/state.svelte";
|
||||
import { exportAppData, importAppData } from "@core/backup";
|
||||
|
||||
interface StorageInfo { manga_bytes: number; total_bytes: number; free_bytes: number; path: string; }
|
||||
|
||||
@@ -52,8 +53,9 @@
|
||||
let extraScanDirs = $state<string[]>([...(store.settings.extraScanDirs ?? [])]);
|
||||
let newScanDir = $state("");
|
||||
let multiStorageInfos = $state<(StorageInfo & { label: string })[]>([]);
|
||||
let advStorageOpen = $state(false);
|
||||
let backupSectionOpen = $state(false);
|
||||
let advStorageOpen = $state(false);
|
||||
let backupSectionOpen = $state(false);
|
||||
let appDataSectionOpen = $state(false);
|
||||
|
||||
async function fetchStorage() {
|
||||
storageLoading = true; storageError = null;
|
||||
@@ -324,6 +326,39 @@
|
||||
finally { validateLoading = false; }
|
||||
}
|
||||
|
||||
let appDataExporting = $state(false);
|
||||
let appDataImporting = $state(false);
|
||||
let appDataError = $state<string | null>(null);
|
||||
let appDataMsg = $state<string | null>(null);
|
||||
let appDataBackupDir = $state<string | null>(null);
|
||||
|
||||
$effect(() => {
|
||||
invoke<string>("get_auto_backup_dir").then(d => { appDataBackupDir = d; }).catch(() => {});
|
||||
});
|
||||
|
||||
async function handleExportAppData() {
|
||||
appDataExporting = true; appDataError = null; appDataMsg = null;
|
||||
try {
|
||||
await exportAppData();
|
||||
appDataMsg = "Backup saved.";
|
||||
setTimeout(() => appDataMsg = null, 3000);
|
||||
} catch (e: any) {
|
||||
if (String(e).includes("Cancelled")) return;
|
||||
appDataError = e?.message ?? String(e);
|
||||
} finally { appDataExporting = false; }
|
||||
}
|
||||
|
||||
async function handleImportAppData() {
|
||||
appDataImporting = true; appDataError = null; appDataMsg = null;
|
||||
try {
|
||||
await importAppData();
|
||||
} catch (e: any) {
|
||||
if (String(e).includes("Cancelled")) { appDataImporting = false; return; }
|
||||
appDataError = e?.message ?? String(e);
|
||||
appDataImporting = false;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => { untrack(() => { loadBackupList(); fetchStorage(); }); });
|
||||
$effect(() => { return () => stopRestorePoll(); });
|
||||
</script>
|
||||
@@ -512,7 +547,6 @@
|
||||
{#if !isExternalServer}
|
||||
<button class="s-btn" onclick={browseExtraScanDir}>Browse</button>
|
||||
{/if}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -638,4 +672,56 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="s-section">
|
||||
<button class="s-collapsible-trigger" onclick={() => appDataSectionOpen = !appDataSectionOpen}>
|
||||
<span class="s-label">App-Data Backup</span>
|
||||
<svg class="s-collapsible-caret" class:open={appDataSectionOpen} width="10" height="6" viewBox="0 0 10 6"><path d="M0 0l5 6 5-6" fill="currentColor"/></svg>
|
||||
</button>
|
||||
{#if appDataSectionOpen}
|
||||
<div class="s-collapsible-body">
|
||||
|
||||
<div class="s-row">
|
||||
<div class="s-row-info">
|
||||
<span class="s-label">Export settings</span>
|
||||
<span class="s-desc">Save all Moku app settings to a JSON file via a native save dialog.</span>
|
||||
</div>
|
||||
<button class="s-btn s-btn-accent" onclick={handleExportAppData} disabled={appDataExporting}>
|
||||
{appDataExporting ? "Saving…" : "Export"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="s-row">
|
||||
<div class="s-row-info">
|
||||
<span class="s-label">Import settings</span>
|
||||
<span class="s-desc">Restore from a previously exported JSON file. Reloads the app immediately.</span>
|
||||
</div>
|
||||
<button class="s-btn" onclick={handleImportAppData} disabled={appDataImporting}>
|
||||
{appDataImporting ? "Importing…" : "Import"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if appDataError}
|
||||
<div class="s-banner s-banner-error">{appDataError}</div>
|
||||
{/if}
|
||||
|
||||
{#if appDataMsg}
|
||||
<div class="s-row">
|
||||
<span class="s-desc" style="color:var(--color-success,#4caf50)">{appDataMsg}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if appDataBackupDir}
|
||||
<div class="s-row">
|
||||
<div class="s-row-info">
|
||||
<span class="s-label">Auto-backup location</span>
|
||||
<span class="s-desc">Pre-update snapshots are kept here (last 5).</span>
|
||||
</div>
|
||||
<button class="s-btn" onclick={() => invoke("open_path", { path: appDataBackupDir })}>Open folder</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -113,7 +113,11 @@
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!ringFull) return;
|
||||
if (!ringFull) {
|
||||
exitLock = false;
|
||||
exiting = false;
|
||||
return;
|
||||
}
|
||||
cancelAnimationFrame(animFrame);
|
||||
ringProg = 1;
|
||||
if (lockEnabled && !pinUnlocked) {
|
||||
@@ -163,8 +167,6 @@
|
||||
return () => clearInterval(dotsInterval);
|
||||
});
|
||||
|
||||
// ── Canvas card animation ─────────────────────────────────────────────────
|
||||
|
||||
interface CardDef { cx: number; w: number; h: number; lines: number; alpha: number; speed: number; cycleSec: number; phase: number; travel: number; yStart: number; angleStart: number; tilt: number; }
|
||||
interface CardTrig { cosA: number; sinA: number; tiltRad: number; }
|
||||
interface RenderState { cards: CardDef[]; trigs: CardTrig[]; stamps: HTMLCanvasElement[]; vignette: HTMLCanvasElement; CW: number; CH: number; scale: number; }
|
||||
@@ -177,7 +179,6 @@
|
||||
|
||||
const BUF = 80, COLS = 14;
|
||||
|
||||
// Deterministic per-index hash — no random(), same layout every mount
|
||||
function hash(n: number): number {
|
||||
let x = Math.imul(n ^ (n >>> 16), 0x45d9f3b);
|
||||
x = Math.imul(x ^ (x >>> 16), 0x45d9f3b);
|
||||
@@ -275,7 +276,6 @@
|
||||
for (let i = 0; i < cards.length; i++) {
|
||||
const c = cards[i];
|
||||
const p = ((t / c.cycleSec) + c.phase) % 1;
|
||||
// Fade in at entry, fade out at exit
|
||||
const alpha = p < 0.07 ? (p / 0.07) * c.alpha : p > 0.86 ? ((1 - p) / 0.14) * c.alpha : c.alpha;
|
||||
if (alpha < 0.005) continue;
|
||||
const cy = c.yStart - p * c.travel;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { store } from "@store/state.svelte";
|
||||
import { probeServer, loginBasic } from "@core/auth";
|
||||
|
||||
const MAX_ATTEMPTS = 10;
|
||||
const MAX_ATTEMPTS = 40;
|
||||
|
||||
export const boot = $state({
|
||||
serverProbeOk: false,
|
||||
@@ -15,20 +15,20 @@ export const boot = $state({
|
||||
loginBusy: false,
|
||||
});
|
||||
|
||||
let cancelProbe = false;
|
||||
let probeGeneration = 0;
|
||||
|
||||
export function startProbe() {
|
||||
cancelProbe = false;
|
||||
const gen = ++probeGeneration;
|
||||
boot.failed = false;
|
||||
boot.loginRequired = false;
|
||||
boot.unsupportedMode = false;
|
||||
let tries = 0;
|
||||
|
||||
async function probe() {
|
||||
if (cancelProbe) return;
|
||||
if (gen !== probeGeneration) return;
|
||||
tries++;
|
||||
const result = await probeServer();
|
||||
if (cancelProbe) return;
|
||||
if (gen !== probeGeneration) return;
|
||||
|
||||
if (result === "ok") {
|
||||
boot.serverProbeOk = true;
|
||||
@@ -59,14 +59,15 @@ export function startProbe() {
|
||||
}
|
||||
|
||||
if (tries >= MAX_ATTEMPTS) { boot.failed = true; return; }
|
||||
setTimeout(probe, 750);
|
||||
const delay = Math.min(750 + tries * 250, 3000);
|
||||
setTimeout(probe, delay);
|
||||
}
|
||||
|
||||
setTimeout(probe, 800);
|
||||
setTimeout(probe, 2000);
|
||||
}
|
||||
|
||||
export function stopProbe() {
|
||||
cancelProbe = true;
|
||||
probeGeneration++;
|
||||
}
|
||||
|
||||
export async function submitLogin(onSuccess: () => void) {
|
||||
@@ -99,7 +100,7 @@ export function retryBoot() {
|
||||
}
|
||||
|
||||
export function bypassBoot(onReady: () => void) {
|
||||
cancelProbe = true;
|
||||
probeGeneration++;
|
||||
boot.serverProbeOk = true;
|
||||
boot.loginRequired = false;
|
||||
boot.unsupportedMode = false;
|
||||
|
||||
@@ -85,14 +85,33 @@ export interface MangaPrefs {
|
||||
deleteDelayHours: number; maxKeepChapters: number; pauseUpdates: boolean;
|
||||
refreshInterval: "global" | "daily" | "weekly" | "manual";
|
||||
preferredScanlator: string; scanlatorFilter: string[];
|
||||
autoDownloadScanlators: string[];
|
||||
}
|
||||
|
||||
export const DEFAULT_MANGA_PREFS: MangaPrefs = {
|
||||
autoDownload: false, downloadAhead: 0, deleteOnRead: false,
|
||||
deleteDelayHours: 0, maxKeepChapters: 0, pauseUpdates: false,
|
||||
refreshInterval: "global", preferredScanlator: "", scanlatorFilter: [],
|
||||
autoDownloadScanlators: [],
|
||||
};
|
||||
|
||||
export interface ReaderSettings {
|
||||
pageStyle: PageStyle;
|
||||
fitMode: FitMode;
|
||||
readingDirection: ReadingDirection;
|
||||
readerZoom: number;
|
||||
pageGap: boolean;
|
||||
optimizeContrast: boolean;
|
||||
offsetDoubleSpreads: boolean;
|
||||
barPosition?: "top" | "left" | "right";
|
||||
}
|
||||
|
||||
export interface ReaderPreset {
|
||||
id: string;
|
||||
name: string;
|
||||
settings: ReaderSettings;
|
||||
}
|
||||
|
||||
export interface Settings {
|
||||
pageStyle: PageStyle; readingDirection: ReadingDirection; fitMode: FitMode;
|
||||
readerZoom: number; pageGap: boolean; optimizeContrast: boolean;
|
||||
@@ -125,6 +144,10 @@ export interface Settings {
|
||||
maxPageWidth?: number; uiScale?: number;
|
||||
extraScanDirs: string[]; serverDownloadsPath: string; serverLocalSourcePath: string;
|
||||
qolAnimations: boolean;
|
||||
pinnedSourceIds: string[];
|
||||
readerPresets: ReaderPreset[];
|
||||
mangaReaderSettings: Record<number, ReaderSettings>;
|
||||
barPosition?: "top" | "left" | "right";
|
||||
}
|
||||
|
||||
export const DEFAULT_READING_STATS: ReadingStats = {
|
||||
@@ -159,6 +182,9 @@ export const DEFAULT_SETTINGS: Settings = {
|
||||
libraryTabSort: {}, libraryTabStatus: {}, libraryTabFilters: {},
|
||||
extraScanDirs: [], serverDownloadsPath: "", serverLocalSourcePath: "",
|
||||
qolAnimations: true,
|
||||
pinnedSourceIds: [],
|
||||
readerPresets: [],
|
||||
mangaReaderSettings: {},
|
||||
};
|
||||
|
||||
const STORE_VERSION = 3;
|
||||
@@ -203,7 +229,10 @@ function mergeSettings(saved: any): Settings {
|
||||
libraryTabSort: saved?.settings?.libraryTabSort ?? {},
|
||||
libraryTabStatus: saved?.settings?.libraryTabStatus ?? {},
|
||||
libraryTabFilters: saved?.settings?.libraryTabFilters ?? {},
|
||||
extraScanDirs: saved?.settings?.extraScanDirs ?? [],
|
||||
extraScanDirs: saved?.settings?.extraScanDirs ?? [],
|
||||
pinnedSourceIds: saved?.settings?.pinnedSourceIds ?? [],
|
||||
readerPresets: saved?.settings?.readerPresets ?? [],
|
||||
mangaReaderSettings: saved?.settings?.mangaReaderSettings ?? {},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -224,6 +253,7 @@ class Store {
|
||||
markers: MarkerEntry[] = $state(saved?.markers ?? []);
|
||||
readLog: ReadLogEntry[] = $state(saved?.readLog ?? []);
|
||||
readingStats: ReadingStats = $state(saved?.readingStats ?? { ...DEFAULT_READING_STATS });
|
||||
dailyReadCounts: Record<string, number> = $state(saved?.dailyReadCounts ?? {});
|
||||
searchCache: Map<string, any> = $state(new Map());
|
||||
searchLibraryIds: Set<number> = $state(new Set());
|
||||
searchSrcOffset: number = $state(0);
|
||||
@@ -250,6 +280,7 @@ class Store {
|
||||
settings: this.settings, history: this.history,
|
||||
bookmarks: this.bookmarks, markers: this.markers,
|
||||
readLog: this.readLog, readingStats: this.readingStats,
|
||||
dailyReadCounts: this.dailyReadCounts,
|
||||
libraryUpdates: this.libraryUpdates,
|
||||
lastLibraryRefresh: this.lastLibraryRefresh,
|
||||
acknowledgedUpdateIds: [...this.acknowledgedUpdates],
|
||||
@@ -288,6 +319,8 @@ class Store {
|
||||
lastReadAt: entry.readAt, currentStreakDays: streak,
|
||||
longestStreakDays: Math.max(this.readingStats.longestStreakDays, streak), lastStreakDate: todayStr,
|
||||
};
|
||||
const dayKey = new Date().toISOString().slice(0, 10);
|
||||
this.dailyReadCounts = { ...this.dailyReadCounts, [dayKey]: (this.dailyReadCounts[dayKey] ?? 0) + 1 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -314,7 +347,7 @@ class Store {
|
||||
getMarkersForChapter(chapterId: number) { return this.markers.filter(m => m.chapterId === chapterId); }
|
||||
getMarkersForManga(mangaId: number) { return this.markers.filter(m => m.mangaId === mangaId); }
|
||||
clearMarkersForManga(mangaId: number) { this.markers = this.markers.filter(m => m.mangaId !== mangaId); }
|
||||
clearHistory() { this.history = []; this.readLog = []; }
|
||||
clearHistory() { this.history = []; this.readLog = []; this.dailyReadCounts = {}; }
|
||||
|
||||
clearHistoryForManga(mangaId: number) {
|
||||
this.history = this.history.filter(x => x.mangaId !== mangaId);
|
||||
@@ -329,6 +362,7 @@ class Store {
|
||||
|
||||
wipeAllData() {
|
||||
this.history = []; this.readLog = []; this.markers = [];
|
||||
this.dailyReadCounts = {};
|
||||
this.readingStats = { ...DEFAULT_READING_STATS };
|
||||
this.settings = { ...this.settings, heroSlots: [null, null, null, null], mangaLinks: {} };
|
||||
}
|
||||
@@ -404,6 +438,35 @@ class Store {
|
||||
}
|
||||
}
|
||||
|
||||
togglePinnedSource(sourceId: string) {
|
||||
const pins = this.settings.pinnedSourceIds ?? [];
|
||||
this.settings = { ...this.settings, pinnedSourceIds: pins.includes(sourceId) ? pins.filter(id => id !== sourceId) : [...pins, sourceId] };
|
||||
}
|
||||
|
||||
saveReaderPreset(name: string, settings: ReaderSettings): string {
|
||||
const id = Math.random().toString(36).slice(2);
|
||||
this.settings = { ...this.settings, readerPresets: [...(this.settings.readerPresets ?? []), { id, name: name.trim() || "Preset", settings }] };
|
||||
return id;
|
||||
}
|
||||
|
||||
updateReaderPreset(id: string, patch: Partial<Pick<ReaderPreset, "name" | "settings">>) {
|
||||
this.settings = { ...this.settings, readerPresets: (this.settings.readerPresets ?? []).map(p => p.id === id ? { ...p, ...patch } : p) };
|
||||
}
|
||||
|
||||
deleteReaderPreset(id: string) {
|
||||
this.settings = { ...this.settings, readerPresets: (this.settings.readerPresets ?? []).filter(p => p.id !== id) };
|
||||
}
|
||||
|
||||
setMangaReaderSettings(mangaId: number, settings: ReaderSettings) {
|
||||
this.settings = { ...this.settings, mangaReaderSettings: { ...(this.settings.mangaReaderSettings ?? {}), [mangaId]: settings } };
|
||||
}
|
||||
|
||||
clearMangaReaderSettings(mangaId: number) {
|
||||
const next = { ...(this.settings.mangaReaderSettings ?? {}) };
|
||||
delete next[mangaId];
|
||||
this.settings = { ...this.settings, mangaReaderSettings: next };
|
||||
}
|
||||
|
||||
setCategories(cats: Category[]) { this.categories = cats; }
|
||||
setActiveManga(next: Manga | null) { this.activeManga = next; }
|
||||
setPreviewManga(next: Manga | null) { this.previewManga = next; }
|
||||
@@ -436,6 +499,12 @@ export function setPageUrls(next: string[])
|
||||
export function setPageNumber(next: number) { store.setPageNumber(next); }
|
||||
export function setLibraryFilter(next: LibraryFilter) { store.setLibraryFilter(next); }
|
||||
export function setLibraryTagFilter(next: string[]) { store.setLibraryTagFilter(next); }
|
||||
export function togglePinnedSource(sourceId: string) { store.togglePinnedSource(sourceId); }
|
||||
export function saveReaderPreset(name: string, settings: ReaderSettings): string { return store.saveReaderPreset(name, settings); }
|
||||
export function updateReaderPreset(id: string, patch: Partial<Pick<ReaderPreset, "name" | "settings">>) { store.updateReaderPreset(id, patch); }
|
||||
export function deleteReaderPreset(id: string) { store.deleteReaderPreset(id); }
|
||||
export function setMangaReaderSettings(mangaId: number, settings: ReaderSettings) { store.setMangaReaderSettings(mangaId, settings); }
|
||||
export function clearMangaReaderSettings(mangaId: number) { store.clearMangaReaderSettings(mangaId); }
|
||||
export function updateSettings(patch: Partial<Settings>) { store.updateSettings(patch); }
|
||||
export function resetKeybinds() { store.resetKeybinds(); }
|
||||
export function clearSearchCache() { store.clearSearchCache(); }
|
||||
|
||||
Reference in New Issue
Block a user