Compare commits
317 Commits
v0.4.3
...
b170a151f0
| Author | SHA1 | Date | |
|---|---|---|---|
| b170a151f0 | |||
| 6a84280db0 | |||
| be38d87bec | |||
| ab9305e6ab | |||
| ceb9ba12d7 | |||
| 2fa33bc928 | |||
| 5c703bdba5 | |||
| a041b182e5 | |||
| 9dad1fb329 | |||
| 31a19687ce | |||
| 437b52fd8b | |||
| 1e159bbd73 | |||
| cb3d8d64fa | |||
| c0a95ff899 | |||
| ddaca9d126 | |||
| 77b28e97a4 | |||
| f10b343108 | |||
| a8ad9034fc | |||
| f99fa60e8e | |||
| 915ff66b2f | |||
| abd60f261f | |||
| fc20835dde | |||
| 04631d93ef | |||
| 5c09cd15ad | |||
| 7af69fd77c | |||
| 0b6372bd17 | |||
| 32bdeb92ff | |||
| 22c4a222d8 | |||
| 26cb16ec0f | |||
| 8c2917b698 | |||
| 6d33fb7ae1 | |||
| 0e7ff1a27c | |||
| 685bd9b9da | |||
| 3926b5d064 | |||
| 9f6996dcdb | |||
| 294865fe9d | |||
| 13e760594d | |||
| b44b12ba86 | |||
| 3b8c8dea38 | |||
| 615fa1e92f | |||
| 248b046627 | |||
| 79e5548879 | |||
| ed4c11ca7e | |||
| 5dfbc80bbe | |||
| 8aa92e6b54 | |||
| b55dd16d0d | |||
| 7c6aeb8f4c | |||
| 3e4d322fb7 | |||
| db8a984270 | |||
| 18027baee1 | |||
| c5243ba30c | |||
| 13f2a483ca | |||
| 6de5207ce7 | |||
| 8c250021a0 | |||
| 584b917f98 | |||
| e9929747d2 | |||
| cbdf9e8be1 | |||
| d9a9427e3b | |||
| ae5d9748c7 | |||
| 6c39ef538f | |||
| 081becdd60 | |||
| c891cb349c | |||
| 8cef74bb98 | |||
| bf071dcfc7 | |||
| da788e90ba | |||
| b0efb183e8 | |||
| 745b6993de | |||
| bd79169f71 | |||
| 6fccf02614 | |||
| fa7cfdc4e6 | |||
| 9c614b38f8 | |||
| 30e50b5a1b | |||
| 8ef0a14363 | |||
| 4e2ad6cae7 | |||
| 9e56b1176c | |||
| d025d07e07 | |||
| f988641446 | |||
| 3dad4bc729 | |||
| 1af21efebd | |||
| b7197a09a7 | |||
| 50dd8d7e35 | |||
| b2eaea6552 | |||
| 35aae6d85a | |||
| 28e5f5625e | |||
| b99e4d9a3d | |||
| d5f50c6495 | |||
| 89cfa50aff | |||
| 5e591411e4 | |||
| 8aaaf2451a | |||
| 75cc767b58 | |||
| d30c623200 | |||
| 017e9bc6da | |||
| 3b8088a2bf | |||
| 2c5320dd1f | |||
| 1e35f304b6 | |||
| 61339ea006 | |||
| f161fc08a2 | |||
| 239960683b | |||
| 3b5efc85d0 | |||
| 7df3846e75 | |||
| 01f123f5be | |||
| 0e2371096b | |||
| 47ae80a7d2 | |||
| d98547d540 | |||
| 897ecfd316 | |||
| e3abc72f1b | |||
| 6b56db7cf2 | |||
| 93cedca6b5 | |||
| 9f8bf6ffc1 | |||
| 39f813b4d7 | |||
| 18ac38e888 | |||
| 1e2e923eab | |||
| d3a40b9152 | |||
| b1444582a3 | |||
| bee8117aac | |||
| 0bea9c22cb | |||
| bf3f68b996 | |||
| 4b728ad5b7 | |||
| f3f91f1555 | |||
| 062662781a | |||
| cbf8a7fe13 | |||
| 5af80213c7 | |||
| 17d739a1cd | |||
| 2867dc9612 | |||
| a9dc047b44 | |||
| ef190ae66f | |||
| 6d921944ac | |||
| 244447da9b | |||
| f05f781b5b | |||
| f7c5aebf29 | |||
| e09ae9d2e7 | |||
| 7b2ae74c02 | |||
| 0d53e3f102 | |||
| 093b395cc1 | |||
| efdd8ff95d | |||
| c0f0ff9bd3 | |||
| 3f6049c12d | |||
| 5451a2654b | |||
| e625755c5e | |||
| bd95bf4eb1 | |||
| b4d680ddd1 | |||
| d1b7429b5d | |||
| 000195be89 | |||
| 399d429142 | |||
| b79ee99e8a | |||
| 80c4b9d9be | |||
| 4584e6e69e | |||
| 83711c155d | |||
| 3702a25813 | |||
| a71cc719ba | |||
| 1801fecdbb | |||
| 0cd799f450 | |||
| 5dab7761bc | |||
| 552a11a517 | |||
| c8ec6d6b90 | |||
| daaeae00fe | |||
| 79cb2f7c56 | |||
| 4d3dfdbec6 | |||
| 78573eacb1 | |||
| 1bb7da3b22 | |||
| dd0cf9372d | |||
| 50928c6343 | |||
| 170493aa71 | |||
| c009bd71fc | |||
| 4df7f416a7 | |||
| 63209cb828 | |||
| 2c1391c378 | |||
| 41fd4a820c | |||
| 9f3c6d2ac3 | |||
| f5b3f76b5d | |||
| 528f966b1f | |||
| 75e8bc5986 | |||
| 8123053a40 | |||
| e33464b05b | |||
| 6f15e8fbc2 | |||
| 86c6558bab | |||
| c041f99c75 | |||
| 84c2a82c2c | |||
| dc174bee4a | |||
| 72496a25e2 | |||
| 8b074e4b97 | |||
| 743f14f561 | |||
| d9ae94f0ff | |||
| 045bcc5bc4 | |||
| 4004a49cfb | |||
| ee72e345bd | |||
| 336ab0a24f | |||
| c3b015f00f | |||
| 22e3095cf5 | |||
| 7bc2050971 | |||
| d26f0b85e3 | |||
| e8e6f18851 | |||
| 50c5131477 | |||
| c0efbba4df | |||
| 361a145702 | |||
| 1c004d7e5c | |||
| fb72e45817 | |||
| 5c2e2b6866 | |||
| 4b313512d4 | |||
| 63258b2aa1 | |||
| b5f96a3a5c | |||
| 4eef03cbb1 | |||
| f6118077fb | |||
| 544792a7ad | |||
| e063369dfb | |||
| 514910667b | |||
| 2e9939c4a9 | |||
| 581aea5694 | |||
| 72a88b10c8 | |||
| 371b4af73f | |||
| 634d32f372 | |||
| 4e6be5d9f5 | |||
| bb7256c4f8 | |||
| b12ff4cbaa | |||
| 63a829ddca | |||
| 94b14fb7f6 | |||
| bd2fd7a6d7 | |||
| 6634ad56d2 | |||
| 2eb8a7662e | |||
| 7dd4f52308 | |||
| 690f59c602 | |||
| c025336a7e | |||
| 86c78689df | |||
| 2d3a4d0e57 | |||
| 1a5c63a607 | |||
| 3f7102556b | |||
| f5a66ab5d1 | |||
| e41e8011be | |||
| 044c93a790 | |||
| e49df4501f | |||
| 4b97f4a6c9 | |||
| 005680394e | |||
| ecb4748414 | |||
| f0dc3446b2 | |||
| 8507c34b21 | |||
| 78da5915df | |||
| c0c486a53e | |||
| 236d6bcf08 | |||
| 2b140ae022 | |||
| 38d407092f | |||
| 12191dfcdf | |||
| 13a2f9ecb7 | |||
| c573c54318 | |||
| ff5fcc4fc0 | |||
| 1aad4a1ff0 | |||
| 68a9331b6f | |||
| 64f63ceaa2 | |||
| 6d835914ef | |||
| 10f5936dbd | |||
| 5ddbfdbd6d | |||
| 0ff148f720 | |||
| d98ca76036 | |||
| 35650481b0 | |||
| 8b16537c35 | |||
| 96639d2152 | |||
| 1c135a79ca | |||
| 6c11a9d53e | |||
| 5a2f88b806 | |||
| 75430305e6 | |||
| ea76b5fc26 | |||
| d5d9ff8b6e | |||
| 7c9182eb4b | |||
| 4d6ebe8804 | |||
| 49562c3f76 | |||
| 4a299f60ac | |||
| de397f2462 | |||
| af29cffdff | |||
| f840ae6413 | |||
| 6b8d4fc05f | |||
| 15079f7755 | |||
| 1a08d2415f | |||
| 7917491389 | |||
| 0b6e9fbbbb | |||
| 023b23288b | |||
| 67a9f0b944 | |||
| 56392e2427 | |||
| 843e205072 | |||
| ee708d85d0 | |||
| 8005c82654 | |||
| d989b2d67e | |||
| 6446a19b2d | |||
| 5cd96abc0c | |||
| db44afc4dc | |||
| 4248e344ab | |||
| 8941bfef10 | |||
| 11cd6ff870 | |||
| 15adb02be3 | |||
| 51bb6cdab9 | |||
| 454a674ada | |||
| f146de5c02 | |||
| 04f680c3bb | |||
| f49f7e7ac1 | |||
| a62512bf42 | |||
| d91ed2e6d1 | |||
| 61e3c4ee2f | |||
| 9151820843 | |||
| 63c890dadf | |||
| 51a33679d5 | |||
| 82f8a9a36b | |||
| 4decce9a7f | |||
| a69d5eacc5 | |||
| 4959722759 | |||
| 35ba0171c7 | |||
| d26fa50e76 | |||
| fd9d216325 | |||
| 581eb2adb0 | |||
| 8aa2dc2547 | |||
| 0a11fe3982 | |||
| f6786def87 | |||
| 262027d9f9 | |||
| d407359973 | |||
| a77572a8d4 | |||
| 32d2fffdc5 | |||
| e850cbac1e | |||
| eebd1b6446 | |||
| 5ed072211b | |||
| 62e41e5f07 |
@@ -0,0 +1,78 @@
|
|||||||
|
name: Bug Report
|
||||||
|
description: Something isn't working as expected
|
||||||
|
labels: ["bug"]
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
Thanks for taking the time to report a bug. The more detail you include, the faster it gets fixed.
|
||||||
|
You can use the **Report a Bug** button in **Settings → About** to pre-fill most of this automatically.
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: description
|
||||||
|
attributes:
|
||||||
|
label: Description
|
||||||
|
description: What's broken? A clear, concise summary.
|
||||||
|
placeholder: "e.g. Library card stats don't appear even with 'Always show' enabled"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: steps
|
||||||
|
attributes:
|
||||||
|
label: Steps to Reproduce
|
||||||
|
description: Exact steps to trigger the bug.
|
||||||
|
placeholder: |
|
||||||
|
1. Open Settings → Library
|
||||||
|
2. Enable "Always show card stats"
|
||||||
|
3. Return to Library
|
||||||
|
4. Unread counts are not visible
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: expected
|
||||||
|
attributes:
|
||||||
|
label: Expected Behavior
|
||||||
|
placeholder: "Unread and download counts should be permanently visible on manga cards"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: actual
|
||||||
|
attributes:
|
||||||
|
label: Actual Behavior
|
||||||
|
placeholder: "Counts only appear on hover, or not at all"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: environment
|
||||||
|
attributes:
|
||||||
|
label: Environment
|
||||||
|
description: Copy this from Settings → About → Report a Bug, or fill in manually.
|
||||||
|
placeholder: |
|
||||||
|
- Moku Version: v0.9.4
|
||||||
|
- Platform: Windows / macOS / Linux / Web
|
||||||
|
- OS Version: Windows 11 24H2
|
||||||
|
- Server: Suwayomi v2.2.2196
|
||||||
|
- Server URL: localhost:4567
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: settings
|
||||||
|
attributes:
|
||||||
|
label: Relevant Settings
|
||||||
|
description: Settings related to the bug (auto-filled by the in-app reporter, or paste manually).
|
||||||
|
placeholder: |
|
||||||
|
libraryStatsAlways: true
|
||||||
|
libraryCropCovers: true
|
||||||
|
libraryPageSize: 48
|
||||||
|
render: yaml
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: additional
|
||||||
|
attributes:
|
||||||
|
label: Additional Context
|
||||||
|
description: Screenshots, screen recordings, console errors, anything else helpful.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
blank_issues_enabled: false
|
||||||
|
contact_links:
|
||||||
|
- name: Discussions (Questions & Support)
|
||||||
|
url: https://github.com/moku-project/Moku/discussions
|
||||||
|
about: Not a bug? Ask questions and get help here.
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
name: Feature Request
|
||||||
|
description: Suggest an improvement or new feature
|
||||||
|
labels: ["enhancement"]
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
Got an idea? Describe what you want and why it would be useful.
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: problem
|
||||||
|
attributes:
|
||||||
|
label: Problem / Motivation
|
||||||
|
description: What's the gap or frustration this would address?
|
||||||
|
placeholder: "e.g. There's no way to bulk-mark chapters as read without opening each series"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: solution
|
||||||
|
attributes:
|
||||||
|
label: Proposed Solution
|
||||||
|
description: What would you like to see?
|
||||||
|
placeholder: "A 'Mark all read' option in the series long-press context menu"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: alternatives
|
||||||
|
attributes:
|
||||||
|
label: Alternatives Considered
|
||||||
|
description: Any workarounds you've tried, or other ways this could be solved.
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: environment
|
||||||
|
attributes:
|
||||||
|
label: Environment
|
||||||
|
description: Optional — useful if this is platform-specific.
|
||||||
|
placeholder: |
|
||||||
|
- Moku Version: v0.9.4
|
||||||
|
- Platform: Windows / macOS / Linux / Web
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: additional
|
||||||
|
attributes:
|
||||||
|
label: Additional Context
|
||||||
|
description: Mockups, references, examples from other apps, etc.
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# Sourced by CI jobs that need versions from nix/versions.nix.
|
||||||
|
# Usage: source .github/read_versions.sh
|
||||||
|
# Exports: MOKU_VERSION SUWA_VERSION SUWA_HASH_LINUX SUWA_HASH_MACOS_ARM64 SUWA_HASH_MACOS_X64 SUWA_HASH_WINDOWS
|
||||||
|
#
|
||||||
|
# Uses only POSIX -E grep (no -P) so this works on both GNU grep (Linux/Windows)
|
||||||
|
# and BSD grep (macOS), which does not support -P/PCRE.
|
||||||
|
|
||||||
|
_nix="$( cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd )/nix/versions.nix"
|
||||||
|
_t=$(cat "$_nix")
|
||||||
|
|
||||||
|
# Match `key = "value"` with -E, then strip the surrounding quotes.
|
||||||
|
_pick() { echo "$_t" | grep -oE "${1}"'[[:space:]]*=[[:space:]]*"[^"]+"' | grep -oE '"[^"]+"' | tr -d '"'; }
|
||||||
|
|
||||||
|
export MOKU_VERSION=$(_pick "moku")
|
||||||
|
export SUWA_VERSION=$(_pick "version")
|
||||||
|
export SUWA_HASH_WINDOWS=$(_pick "windowsHash")
|
||||||
|
export SUWA_HASH_LINUX=$(_pick "linuxHash")
|
||||||
|
export SUWA_HASH_MACOS_ARM64=$(_pick "macosArm64Hash")
|
||||||
|
export SUWA_HASH_MACOS_X64=$(_pick "macosX64Hash")
|
||||||
|
|
||||||
|
unset _nix _t
|
||||||
|
unset -f _pick
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
name: Build Flatpak
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
version:
|
||||||
|
description: "Version to build (e.g. 0.9.0)"
|
||||||
|
required: true
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
flatpak:
|
||||||
|
name: Build Flatpak bundle
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Free up disk space
|
||||||
|
run: |
|
||||||
|
sudo rm -rf /usr/local/lib/android /opt/ghc /usr/share/dotnet /opt/hostedtoolcache/CodeQL
|
||||||
|
sudo docker image prune -af || true
|
||||||
|
|
||||||
|
- uses: pnpm/action-setup@v4
|
||||||
|
with: { version: 10 }
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: pnpm
|
||||||
|
|
||||||
|
- name: Build frontend and pack tarball
|
||||||
|
run: |
|
||||||
|
pnpm install --frozen-lockfile
|
||||||
|
pnpm build:static
|
||||||
|
tar -czf packaging/frontend-dist.tar.gz -C dist .
|
||||||
|
|
||||||
|
- name: Compute frontend-dist sha256
|
||||||
|
run: |
|
||||||
|
SHA=$(sha256sum packaging/frontend-dist.tar.gz | awk '{print $1}')
|
||||||
|
echo "FRONTEND_SHA=$SHA" >> $GITHUB_ENV
|
||||||
|
echo "frontend-dist.tar.gz sha256: $SHA"
|
||||||
|
|
||||||
|
- name: Patch frontend-dist sha256 in flatpak manifest
|
||||||
|
run: |
|
||||||
|
python3 -c "
|
||||||
|
import re, pathlib, os
|
||||||
|
p = pathlib.Path('io.github.moku_project.Moku.yml')
|
||||||
|
content = p.read_text()
|
||||||
|
# Replace the sha256 line that follows the frontend-dist.tar.gz source entry
|
||||||
|
content = re.sub(
|
||||||
|
r'(path: packaging/frontend-dist\.tar\.gz\n\s+sha256: )[0-9a-f]{64}',
|
||||||
|
r'\g<1>' + os.environ['FRONTEND_SHA'],
|
||||||
|
content
|
||||||
|
)
|
||||||
|
p.write_text(content)
|
||||||
|
"
|
||||||
|
|
||||||
|
- name: Install flatpak tooling
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y flatpak flatpak-builder
|
||||||
|
flatpak --user remote-add --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo
|
||||||
|
|
||||||
|
- name: Cache flatpak runtimes/SDKs
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: ~/.local/share/flatpak
|
||||||
|
key: flatpak-runtimes-gnome48-rust-stable
|
||||||
|
|
||||||
|
- name: Install runtime and SDK
|
||||||
|
run: |
|
||||||
|
flatpak --user install -y --noninteractive flathub \
|
||||||
|
org.gnome.Platform//48 \
|
||||||
|
org.gnome.Sdk//48
|
||||||
|
|
||||||
|
- name: Build flatpak
|
||||||
|
run: |
|
||||||
|
rm -rf build-dir repo
|
||||||
|
flatpak-builder \
|
||||||
|
--user \
|
||||||
|
--install-deps-from=flathub \
|
||||||
|
--repo=repo \
|
||||||
|
--force-clean \
|
||||||
|
build-dir \
|
||||||
|
io.github.moku_project.Moku.yml
|
||||||
|
|
||||||
|
- name: Bundle flatpak
|
||||||
|
run: |
|
||||||
|
flatpak build-bundle \
|
||||||
|
--runtime-repo=https://flathub.org/repo/flathub.flatpakrepo \
|
||||||
|
repo \
|
||||||
|
moku.flatpak \
|
||||||
|
io.github.moku_project.Moku
|
||||||
|
|
||||||
|
- name: Upload Flatpak artifact to release
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
# Poll for up to 10 minutes — the release is created by the Windows workflow
|
||||||
|
# which may still be building when the flatpak bundle finishes.
|
||||||
|
for i in $(seq 1 40); do
|
||||||
|
RELEASE_ID=$(curl -s -H "Authorization: Bearer $GITHUB_TOKEN" \
|
||||||
|
"https://api.github.com/repos/moku-project/Moku/releases" \
|
||||||
|
| jq -r '.[] | select(.tag_name == "v${{ github.event.inputs.version }}") | .id' | head -1)
|
||||||
|
[ -n "$RELEASE_ID" ] && break
|
||||||
|
echo "Waiting for release... attempt $i/40"; sleep 15
|
||||||
|
done
|
||||||
|
[ -z "$RELEASE_ID" ] && { echo "ERROR: release not found after polling"; exit 1; }
|
||||||
|
|
||||||
|
curl -s -X POST \
|
||||||
|
-H "Authorization: Bearer $GITHUB_TOKEN" \
|
||||||
|
-H "Content-Type: application/octet-stream" \
|
||||||
|
--data-binary @"moku.flatpak" \
|
||||||
|
"https://uploads.github.com/repos/moku-project/Moku/releases/$RELEASE_ID/assets?name=moku.flatpak"
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
name: Build Linux
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
version:
|
||||||
|
description: "Version to build (e.g. 0.9.0)"
|
||||||
|
required: true
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
frontend:
|
||||||
|
name: Build frontend
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: pnpm/action-setup@v4
|
||||||
|
with: { version: latest }
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: pnpm
|
||||||
|
- run: pnpm install --frozen-lockfile
|
||||||
|
- run: pnpm build:static
|
||||||
|
- uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: frontend-dist-linux
|
||||||
|
path: dist/
|
||||||
|
retention-days: 1
|
||||||
|
|
||||||
|
tauri:
|
||||||
|
name: Tauri (Linux x64)
|
||||||
|
needs: frontend
|
||||||
|
runs-on: ubuntu-22.04
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/download-artifact@v4
|
||||||
|
with: { name: frontend-dist-linux, path: dist/ }
|
||||||
|
|
||||||
|
- name: Read versions
|
||||||
|
run: |
|
||||||
|
source .github/read_versions.sh
|
||||||
|
echo "MOKU_VERSION=$MOKU_VERSION" >> $GITHUB_ENV
|
||||||
|
echo "SUWA_VERSION=$SUWA_VERSION" >> $GITHUB_ENV
|
||||||
|
echo "SUWA_HASH=$SUWA_HASH_LINUX" >> $GITHUB_ENV
|
||||||
|
|
||||||
|
- name: Install system dependencies
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y \
|
||||||
|
libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libfuse2
|
||||||
|
|
||||||
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
|
with: { targets: x86_64-unknown-linux-gnu }
|
||||||
|
|
||||||
|
- uses: Swatinem/rust-cache@v2
|
||||||
|
with: { workspaces: src-tauri }
|
||||||
|
|
||||||
|
- uses: pnpm/action-setup@v4
|
||||||
|
with: { version: latest }
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: pnpm
|
||||||
|
- run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Download Suwayomi (Linux x64)
|
||||||
|
run: |
|
||||||
|
curl -fsSL \
|
||||||
|
"https://github.com/Suwayomi/Suwayomi-Server-preview/releases/download/v${SUWA_VERSION}/Suwayomi-Server-v${SUWA_VERSION}-linux-x64.tar.gz" \
|
||||||
|
-o suwayomi-linux.tar.gz
|
||||||
|
echo "${SUWA_HASH} suwayomi-linux.tar.gz" | sha256sum -c -
|
||||||
|
mkdir -p suwayomi-extracted
|
||||||
|
tar -xzf suwayomi-linux.tar.gz -C suwayomi-extracted --strip-components=1
|
||||||
|
|
||||||
|
- name: Stage Suwayomi bundle
|
||||||
|
run: |
|
||||||
|
mkdir -p src-tauri/binaries
|
||||||
|
for f in suwayomi-extracted/bin/Suwayomi-Server.jar \
|
||||||
|
suwayomi-extracted/jre/bin/java \
|
||||||
|
suwayomi-extracted/bin/catch_abort.so; do
|
||||||
|
[ -e "$f" ] || { echo "ERROR: missing $f"; find suwayomi-extracted -type f | head -40; exit 1; }
|
||||||
|
done
|
||||||
|
cp -r suwayomi-extracted src-tauri/binaries/suwayomi-bundle
|
||||||
|
chmod +x src-tauri/binaries/suwayomi-bundle/jre/bin/java
|
||||||
|
|
||||||
|
- name: Stage Linux launcher sidecar
|
||||||
|
run: |
|
||||||
|
cp src-tauri/binaries/suwayomi-launcher-linux.sh \
|
||||||
|
src-tauri/binaries/suwayomi-launcher-linux-x86_64-unknown-linux-gnu
|
||||||
|
chmod +x src-tauri/binaries/suwayomi-launcher-linux-x86_64-unknown-linux-gnu
|
||||||
|
|
||||||
|
- name: Patch tauri.conf.json for CI
|
||||||
|
run: |
|
||||||
|
sed -i 's/"beforeBuildCommand": "pnpm build"/"beforeBuildCommand": ""/' src-tauri/tauri.conf.json
|
||||||
|
|
||||||
|
- name: Build Tauri app
|
||||||
|
run: pnpm tauri build --target x86_64-unknown-linux-gnu --config src-tauri/tauri.linux.conf.json --verbose
|
||||||
|
env: { NO_STRIP: "true" }
|
||||||
|
|
||||||
|
- name: Upload Linux artifacts to release
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
for i in $(seq 1 12); do
|
||||||
|
RELEASE_ID=$(curl -s -H "Authorization: Bearer $GITHUB_TOKEN" \
|
||||||
|
"https://api.github.com/repos/moku-project/Moku/releases" \
|
||||||
|
| jq -r '.[] | select(.tag_name == "v${{ github.event.inputs.version }}") | .id' | head -1)
|
||||||
|
[ -n "$RELEASE_ID" ] && break
|
||||||
|
echo "Waiting for release... attempt $i"; sleep 15
|
||||||
|
done
|
||||||
|
[ -z "$RELEASE_ID" ] && { echo "ERROR: release not found"; exit 1; }
|
||||||
|
|
||||||
|
upload() {
|
||||||
|
curl -s -X POST \
|
||||||
|
-H "Authorization: Bearer $GITHUB_TOKEN" \
|
||||||
|
-H "Content-Type: application/octet-stream" \
|
||||||
|
--data-binary @"$1" \
|
||||||
|
"https://uploads.github.com/repos/moku-project/Moku/releases/$RELEASE_ID/assets?name=$2"
|
||||||
|
}
|
||||||
|
|
||||||
|
APPIMAGE=$(find src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage -name "*.AppImage" | head -1)
|
||||||
|
DEB=$(find src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/deb -name "*.deb" | head -1)
|
||||||
|
[ -n "$APPIMAGE" ] && upload "$APPIMAGE" "moku-linux-x64-${{ github.event.inputs.version }}.AppImage"
|
||||||
|
[ -n "$DEB" ] && upload "$DEB" "moku-linux-x64-${{ github.event.inputs.version }}.deb"
|
||||||
@@ -4,182 +4,139 @@ on:
|
|||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
version:
|
version:
|
||||||
description: "Version to build (e.g. 0.4.0)"
|
description: "Version to build (e.g. 0.9.0)"
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
frontend:
|
frontend:
|
||||||
name: Build frontend
|
name: Build frontend
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- uses: pnpm/action-setup@v4
|
- uses: pnpm/action-setup@v4
|
||||||
with:
|
with: { version: 10 }
|
||||||
version: latest
|
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
cache: pnpm
|
cache: pnpm
|
||||||
|
- run: pnpm install --frozen-lockfile
|
||||||
- name: Install dependencies
|
- run: pnpm build:static
|
||||||
run: pnpm install --frozen-lockfile
|
- uses: actions/upload-artifact@v4
|
||||||
|
with: { name: frontend-dist, path: dist/, retention-days: 1 }
|
||||||
- name: Build
|
|
||||||
run: pnpm build
|
|
||||||
|
|
||||||
- name: Upload dist
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: frontend-dist
|
|
||||||
path: dist/
|
|
||||||
retention-days: 1
|
|
||||||
|
|
||||||
tauri:
|
tauri:
|
||||||
name: Tauri (macOS)
|
name: Tauri (macOS)
|
||||||
needs: frontend
|
needs: frontend
|
||||||
runs-on: macos-latest
|
runs-on: macos-latest
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Download frontend dist
|
- uses: actions/download-artifact@v4
|
||||||
uses: actions/download-artifact@v4
|
with: { name: frontend-dist, path: dist/ }
|
||||||
with:
|
|
||||||
name: frontend-dist
|
|
||||||
path: dist/
|
|
||||||
|
|
||||||
- name: Install Rust
|
- name: Read versions
|
||||||
uses: dtolnay/rust-toolchain@stable
|
run: |
|
||||||
with:
|
source .github/read_versions.sh
|
||||||
targets: aarch64-apple-darwin,x86_64-apple-darwin
|
echo "SUWA_VERSION=$SUWA_VERSION" >> $GITHUB_ENV
|
||||||
|
echo "SUWA_HASH_ARM64=$SUWA_HASH_MACOS_ARM64" >> $GITHUB_ENV
|
||||||
|
echo "SUWA_HASH_X64=$SUWA_HASH_MACOS_X64" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: Rust cache
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
uses: Swatinem/rust-cache@v2
|
|
||||||
with:
|
with:
|
||||||
workspaces: src-tauri
|
targets: "aarch64-apple-darwin,x86_64-apple-darwin"
|
||||||
|
|
||||||
|
- uses: Swatinem/rust-cache@v2
|
||||||
|
with: { workspaces: src-tauri }
|
||||||
|
|
||||||
- uses: pnpm/action-setup@v4
|
- uses: pnpm/action-setup@v4
|
||||||
with:
|
with: { version: 10 }
|
||||||
version: latest
|
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
cache: pnpm
|
cache: pnpm
|
||||||
|
- run: pnpm install --frozen-lockfile
|
||||||
- name: Install JS dependencies
|
|
||||||
run: pnpm install --frozen-lockfile
|
|
||||||
|
|
||||||
- name: Download Suwayomi binaries
|
- name: Download Suwayomi binaries
|
||||||
run: |
|
run: |
|
||||||
download_suwayomi() {
|
dl() {
|
||||||
local asset="$1" sha="$2" outdir="$3"
|
local asset="$1" sha="$2" outdir="$3"
|
||||||
curl -fsSL \
|
curl -fsSL \
|
||||||
"https://github.com/Suwayomi/Suwayomi-Server/releases/download/v2.1.1867/${asset}" \
|
"https://github.com/Suwayomi/Suwayomi-Server-preview/releases/download/v${SUWA_VERSION}/${asset}" \
|
||||||
-o "${outdir}.tar.gz"
|
-o "${outdir}.tar.gz"
|
||||||
echo "${sha} ${outdir}.tar.gz" | shasum -a 256 -c -
|
echo "${sha} ${outdir}.tar.gz" | shasum -a 256 -c -
|
||||||
mkdir -p "${outdir}"
|
mkdir -p "${outdir}"
|
||||||
tar -xzf "${outdir}.tar.gz" -C "${outdir}" --strip-components=1
|
tar -xzf "${outdir}.tar.gz" -C "${outdir}" --strip-components=1
|
||||||
}
|
}
|
||||||
|
dl "Suwayomi-Server-v${SUWA_VERSION}-macOS-arm64.tar.gz" "$SUWA_HASH_ARM64" suwayomi-arm64
|
||||||
download_suwayomi \
|
dl "Suwayomi-Server-v${SUWA_VERSION}-macOS-x64.tar.gz" "$SUWA_HASH_X64" suwayomi-x64
|
||||||
"Suwayomi-Server-v2.1.1867-macOS-arm64.tar.gz" \
|
|
||||||
"c80abdbba29f7895e9556c6c9481368557d5f930b5f69bcb30639ba498925f3c" \
|
|
||||||
"suwayomi-arm64"
|
|
||||||
|
|
||||||
download_suwayomi \
|
|
||||||
"Suwayomi-Server-v2.1.1867-macOS-x64.tar.gz" \
|
|
||||||
"c7590aeb645dd7135a05b9f3ea1fee384a4abeb465c0b3638d5b738d20dfe174" \
|
|
||||||
"suwayomi-x64"
|
|
||||||
|
|
||||||
- name: Stage Suwayomi sidecars
|
- name: Stage Suwayomi sidecars
|
||||||
run: |
|
run: |
|
||||||
mkdir -p src-tauri/binaries
|
mkdir -p src-tauri/binaries
|
||||||
|
stage() {
|
||||||
stage_arch() {
|
local srcdir="$1" arch="$2"
|
||||||
local srcdir="$1"
|
|
||||||
local arch="$2"
|
|
||||||
local sidecar="src-tauri/binaries/suwayomi-server-${arch}"
|
|
||||||
local bundle_dest="src-tauri/binaries/suwayomi-bundle-${arch}"
|
|
||||||
|
|
||||||
JAR=$(find "$srcdir" -name "Suwayomi-Server.jar" | head -1)
|
JAR=$(find "$srcdir" -name "Suwayomi-Server.jar" | head -1)
|
||||||
JAVA=$(find "$srcdir" -path "*/jre/bin/java" | head -1)
|
JAVA=$(find "$srcdir" -path "*/jre/bin/java" | head -1)
|
||||||
|
[ -z "$JAR" ] && { echo "ERROR: jar not found in $srcdir"; find "$srcdir" -type f | head -30; exit 1; }
|
||||||
if [ -z "$JAR" ]; then
|
[ -z "$JAVA" ] && { echo "ERROR: java not found in $srcdir"; find "$srcdir" -type f | head -30; exit 1; }
|
||||||
echo "ERROR: Suwayomi-Server.jar not found in $srcdir"
|
cp -r "$srcdir" "src-tauri/binaries/suwayomi-bundle-${arch}"
|
||||||
find "$srcdir" -type f | head -30
|
cp src-tauri/binaries/suwayomi-launcher.sh "src-tauri/binaries/suwayomi-server-${arch}"
|
||||||
exit 1
|
chmod +x "src-tauri/binaries/suwayomi-server-${arch}"
|
||||||
fi
|
|
||||||
if [ -z "$JAVA" ]; then
|
|
||||||
echo "ERROR: jre/bin/java not found in $srcdir"
|
|
||||||
find "$srcdir" -type f | head -30
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "${arch}: jar=${JAR} java=${JAVA}"
|
|
||||||
|
|
||||||
cp -r "$srcdir" "$bundle_dest"
|
|
||||||
|
|
||||||
# The launcher script is committed at src-tauri/binaries/suwayomi-launcher.sh
|
|
||||||
# to avoid embedding a heredoc in YAML (which breaks GitHub Actions parsing).
|
|
||||||
cp src-tauri/binaries/suwayomi-launcher.sh "$sidecar"
|
|
||||||
chmod +x "$sidecar"
|
|
||||||
echo "Staged sidecar: $sidecar"
|
|
||||||
}
|
}
|
||||||
|
stage suwayomi-arm64 aarch64-apple-darwin
|
||||||
stage_arch suwayomi-arm64 aarch64-apple-darwin
|
stage suwayomi-x64 x86_64-apple-darwin
|
||||||
stage_arch suwayomi-x64 x86_64-apple-darwin
|
|
||||||
|
|
||||||
- name: Patch tauri.conf.json for CI
|
- name: Patch tauri.conf.json for CI
|
||||||
run: |
|
run: |
|
||||||
sed -i '' 's/"beforeBuildCommand": "pnpm build"/"beforeBuildCommand": ""/' src-tauri/tauri.conf.json
|
python3 -c "
|
||||||
|
import json, pathlib
|
||||||
# ── aarch64 build ──────────────────────────────────────────────────────
|
p = pathlib.Path('src-tauri/tauri.conf.json')
|
||||||
- name: Swap bundle for aarch64
|
c = json.loads(p.read_text())
|
||||||
run: |
|
c.setdefault('build', {})['beforeBuildCommand'] = ''
|
||||||
rm -rf src-tauri/binaries/suwayomi-bundle
|
p.write_text(json.dumps(c, indent=2))
|
||||||
cp -r src-tauri/binaries/suwayomi-bundle-aarch64-apple-darwin \
|
"
|
||||||
src-tauri/binaries/suwayomi-bundle
|
|
||||||
|
|
||||||
- name: Build Tauri app (aarch64)
|
- name: Build Tauri app (aarch64)
|
||||||
run: pnpm tauri build --target aarch64-apple-darwin --config src-tauri/tauri.macos.conf.json
|
|
||||||
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: |
|
run: |
|
||||||
rm -rf src-tauri/binaries/suwayomi-bundle
|
rm -rf src-tauri/binaries/suwayomi-bundle
|
||||||
cp -r src-tauri/binaries/suwayomi-bundle-x86_64-apple-darwin \
|
cp -r src-tauri/binaries/suwayomi-bundle-aarch64-apple-darwin src-tauri/binaries/suwayomi-bundle
|
||||||
src-tauri/binaries/suwayomi-bundle
|
pnpm tauri build --target aarch64-apple-darwin --config src-tauri/tauri.macos.conf.json
|
||||||
|
|
||||||
- name: Build Tauri app (x86_64)
|
|
||||||
run: pnpm tauri build --target x86_64-apple-darwin --config src-tauri/tauri.macos.conf.json
|
|
||||||
env:
|
env:
|
||||||
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY || '-' }}
|
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY || '-' }}
|
||||||
|
|
||||||
# ── upload artifacts ───────────────────────────────────────────────────
|
- name: Build Tauri app (x86_64)
|
||||||
- name: Upload arm64 .dmg
|
run: |
|
||||||
uses: actions/upload-artifact@v4
|
rm -rf src-tauri/binaries/suwayomi-bundle
|
||||||
with:
|
cp -r src-tauri/binaries/suwayomi-bundle-x86_64-apple-darwin src-tauri/binaries/suwayomi-bundle
|
||||||
name: moku-macos-arm64-${{ github.event.inputs.version }}
|
pnpm tauri build --target x86_64-apple-darwin --config src-tauri/tauri.macos.conf.json
|
||||||
path: src-tauri/target/aarch64-apple-darwin/release/bundle/dmg/*.dmg
|
env:
|
||||||
retention-days: 7
|
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY || '-' }}
|
||||||
|
|
||||||
- name: Upload x64 .dmg
|
- name: Upload macOS artifacts to release
|
||||||
uses: actions/upload-artifact@v4
|
env:
|
||||||
with:
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
name: moku-macos-x64-${{ github.event.inputs.version }}
|
run: |
|
||||||
path: src-tauri/target/x86_64-apple-darwin/release/bundle/dmg/*.dmg
|
for i in $(seq 1 12); do
|
||||||
retention-days: 7
|
RELEASE_ID=$(curl -s -H "Authorization: Bearer $GITHUB_TOKEN" \
|
||||||
|
"https://api.github.com/repos/moku-project/Moku/releases" \
|
||||||
|
| jq -r '.[] | select(.tag_name == "v${{ github.event.inputs.version }}") | .id' | head -1)
|
||||||
|
[ -n "$RELEASE_ID" ] && break
|
||||||
|
echo "Waiting for release... attempt $i"; sleep 15
|
||||||
|
done
|
||||||
|
[ -z "$RELEASE_ID" ] && { echo "ERROR: release not found"; exit 1; }
|
||||||
|
|
||||||
|
upload() {
|
||||||
|
curl -s -X POST \
|
||||||
|
-H "Authorization: Bearer $GITHUB_TOKEN" \
|
||||||
|
-H "Content-Type: application/octet-stream" \
|
||||||
|
--data-binary @"$1" \
|
||||||
|
"https://uploads.github.com/repos/moku-project/Moku/releases/$RELEASE_ID/assets?name=$2"
|
||||||
|
}
|
||||||
|
|
||||||
|
ARM64=$(find src-tauri/target/aarch64-apple-darwin/release/bundle/dmg -name "*.dmg" | head -1)
|
||||||
|
X64=$(find src-tauri/target/x86_64-apple-darwin/release/bundle/dmg -name "*.dmg" | head -1)
|
||||||
|
[ -n "$ARM64" ] && upload "$ARM64" "moku-macos-arm64-${{ github.event.inputs.version }}.dmg"
|
||||||
|
[ -n "$X64" ] && upload "$X64" "moku-macos-x64-${{ github.event.inputs.version }}.dmg"
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
name: Build Static WebUI
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
version:
|
||||||
|
description: "Version to build (e.g. 0.9.0)"
|
||||||
|
required: true
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
name: Build static frontend
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: pnpm/action-setup@v4
|
||||||
|
with: { version: 10 }
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: pnpm
|
||||||
|
|
||||||
|
- run: pnpm install --frozen-lockfile
|
||||||
|
- run: pnpm build:static
|
||||||
|
|
||||||
|
- name: Zip static build
|
||||||
|
run: |
|
||||||
|
cd dist
|
||||||
|
zip -r "../moku-webui-${{ github.event.inputs.version }}.zip" .
|
||||||
|
|
||||||
|
- name: Upload WebUI artifact to release
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
for i in $(seq 1 12); do
|
||||||
|
RELEASE_ID=$(curl -s -H "Authorization: Bearer $GITHUB_TOKEN" \
|
||||||
|
"https://api.github.com/repos/moku-project/Moku/releases" \
|
||||||
|
| jq -r '.[] | select(.tag_name == "v${{ github.event.inputs.version }}") | .id' | head -1)
|
||||||
|
[ -n "$RELEASE_ID" ] && break
|
||||||
|
echo "Waiting for release... attempt $i"; sleep 15
|
||||||
|
done
|
||||||
|
[ -z "$RELEASE_ID" ] && { echo "ERROR: release not found"; exit 1; }
|
||||||
|
|
||||||
|
curl -s -X POST \
|
||||||
|
-H "Authorization: Bearer $GITHUB_TOKEN" \
|
||||||
|
-H "Content-Type: application/zip" \
|
||||||
|
--data-binary @"moku-webui-${{ github.event.inputs.version }}.zip" \
|
||||||
|
"https://uploads.github.com/repos/moku-project/Moku/releases/$RELEASE_ID/assets?name=moku-webui-${{ github.event.inputs.version }}.zip"
|
||||||
@@ -4,7 +4,7 @@ on:
|
|||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
version:
|
version:
|
||||||
description: "Version to build (e.g. 0.4.0)"
|
description: "Version to build (e.g. 0.9.0)"
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
@@ -16,147 +16,115 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- uses: pnpm/action-setup@v4
|
- uses: pnpm/action-setup@v4
|
||||||
with:
|
with: { version: 10 }
|
||||||
version: latest
|
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
cache: pnpm
|
cache: pnpm
|
||||||
|
- run: pnpm install --frozen-lockfile
|
||||||
- name: Install dependencies
|
- run: pnpm build:static
|
||||||
run: pnpm install --frozen-lockfile
|
- uses: actions/upload-artifact@v4
|
||||||
|
with: { name: frontend-dist-windows, path: dist/, retention-days: 1 }
|
||||||
- name: Build
|
|
||||||
run: pnpm build
|
|
||||||
|
|
||||||
- name: Upload dist
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: frontend-dist-windows
|
|
||||||
path: dist/
|
|
||||||
retention-days: 1
|
|
||||||
|
|
||||||
tauri:
|
tauri:
|
||||||
name: Tauri (Windows x64)
|
name: Tauri (Windows x64)
|
||||||
needs: frontend
|
needs: frontend
|
||||||
runs-on: windows-latest
|
runs-on: windows-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Download frontend dist
|
- uses: actions/download-artifact@v4
|
||||||
uses: actions/download-artifact@v4
|
with: { name: frontend-dist-windows, path: dist/ }
|
||||||
with:
|
|
||||||
name: frontend-dist-windows
|
|
||||||
path: dist/
|
|
||||||
|
|
||||||
- name: Install Rust
|
- name: Read versions
|
||||||
uses: dtolnay/rust-toolchain@stable
|
shell: bash
|
||||||
with:
|
run: |
|
||||||
targets: x86_64-pc-windows-msvc
|
source .github/read_versions.sh
|
||||||
|
echo "SUWA_VERSION=$SUWA_VERSION" >> $GITHUB_ENV
|
||||||
|
echo "SUWA_HASH=$SUWA_HASH_WINDOWS" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: Rust cache
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
uses: Swatinem/rust-cache@v2
|
with: { targets: x86_64-pc-windows-msvc }
|
||||||
with:
|
|
||||||
workspaces: src-tauri
|
- uses: Swatinem/rust-cache@v2
|
||||||
|
with: { workspaces: src-tauri }
|
||||||
|
|
||||||
- uses: pnpm/action-setup@v4
|
- uses: pnpm/action-setup@v4
|
||||||
with:
|
with: { version: 10 }
|
||||||
version: latest
|
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
cache: pnpm
|
cache: pnpm
|
||||||
|
- run: pnpm install --frozen-lockfile
|
||||||
- name: Install JS dependencies
|
|
||||||
run: pnpm install --frozen-lockfile
|
|
||||||
|
|
||||||
- name: Download Suwayomi (Windows x64)
|
- name: Download Suwayomi (Windows x64)
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
curl -fsSL \
|
curl -fsSL \
|
||||||
"https://github.com/Suwayomi/Suwayomi-Server/releases/download/v2.1.1867/Suwayomi-Server-v2.1.1867-windows-x64.zip" \
|
"https://github.com/Suwayomi/Suwayomi-Server-preview/releases/download/v${SUWA_VERSION}/Suwayomi-Server-v${SUWA_VERSION}-windows-x64.zip" \
|
||||||
-o suwayomi-windows.zip
|
-o suwayomi-windows.zip
|
||||||
echo "ab6687d278e0dd0984f67abbc853511a7e764f84b126a35d09bfd9b0307321ff suwayomi-windows.zip" | sha256sum -c -
|
echo "${SUWA_HASH} suwayomi-windows.zip" | sha256sum -c -
|
||||||
unzip -q suwayomi-windows.zip -d suwayomi-raw
|
unzip -q suwayomi-windows.zip -d suwayomi-raw
|
||||||
|
|
||||||
- name: Extract Suwayomi bundle
|
- name: Stage Suwayomi bundle
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
mkdir -p suwayomi-extracted
|
mkdir -p suwayomi-extracted
|
||||||
TOP_DIRS=$(find suwayomi-raw -mindepth 1 -maxdepth 1 -type d | wc -l)
|
TOP_DIRS=$(find suwayomi-raw -mindepth 1 -maxdepth 1 -type d | wc -l)
|
||||||
TOP_FILES=$(find suwayomi-raw -mindepth 1 -maxdepth 1 -type f | wc -l)
|
TOP_FILES=$(find suwayomi-raw -mindepth 1 -maxdepth 1 -type f | wc -l)
|
||||||
if [ "$TOP_DIRS" -eq 1 ] && [ "$TOP_FILES" -eq 0 ]; then
|
if [ "$TOP_DIRS" -eq 1 ] && [ "$TOP_FILES" -eq 0 ]; then
|
||||||
INNER=$(find suwayomi-raw -mindepth 1 -maxdepth 1 -type d | head -1)
|
cp -r "$(find suwayomi-raw -mindepth 1 -maxdepth 1 -type d | head -1)"/. suwayomi-extracted/
|
||||||
cp -r "$INNER"/. suwayomi-extracted/
|
|
||||||
else
|
else
|
||||||
cp -r suwayomi-raw/. suwayomi-extracted/
|
cp -r suwayomi-raw/. suwayomi-extracted/
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Stage Suwayomi bundle
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
mkdir -p src-tauri/binaries
|
mkdir -p src-tauri/binaries
|
||||||
JAVA=$(find suwayomi-extracted -path "*/jre/bin/java.exe" | head -1)
|
find suwayomi-extracted -path "*/jre/bin/java.exe" | grep -q . \
|
||||||
JAR=$(find suwayomi-extracted -name "Suwayomi-Server.jar" | head -1)
|
|| { echo "ERROR: java.exe not found"; find suwayomi-extracted -type f | head -50; exit 1; }
|
||||||
if [ -z "$JAVA" ]; then
|
find suwayomi-extracted -name "Suwayomi-Server.jar" | grep -q . \
|
||||||
echo "ERROR: jre/bin/java.exe not found"
|
|| { echo "ERROR: Suwayomi-Server.jar not found"; find suwayomi-extracted -type f | head -50; exit 1; }
|
||||||
find suwayomi-extracted -type f | head -50
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
if [ -z "$JAR" ]; then
|
|
||||||
echo "ERROR: Suwayomi-Server.jar not found"
|
|
||||||
find suwayomi-extracted -type f | head -50
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
cp -r suwayomi-extracted src-tauri/binaries/suwayomi-bundle
|
cp -r suwayomi-extracted src-tauri/binaries/suwayomi-bundle
|
||||||
|
|
||||||
- name: Validate staging
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
find src-tauri/binaries/suwayomi-bundle -path "*/jre/bin/java.exe" \
|
|
||||||
| grep -q . || (echo "ERROR: jre/bin/java.exe missing" && exit 1)
|
|
||||||
find src-tauri/binaries/suwayomi-bundle -name "Suwayomi-Server.jar" \
|
|
||||||
| grep -q . || (echo "ERROR: Suwayomi-Server.jar missing" && exit 1)
|
|
||||||
echo "Staging OK"
|
|
||||||
|
|
||||||
- name: Patch tauri.conf.json for CI
|
- name: Patch tauri.conf.json for CI
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
sed -i 's/"beforeBuildCommand": "pnpm build"/"beforeBuildCommand": ""/' src-tauri/tauri.conf.json
|
python3 -c "
|
||||||
|
import json, pathlib
|
||||||
|
p = pathlib.Path('src-tauri/tauri.conf.json')
|
||||||
|
c = json.loads(p.read_text())
|
||||||
|
c.setdefault('build', {})['beforeBuildCommand'] = ''
|
||||||
|
p.write_text(json.dumps(c, indent=2))
|
||||||
|
"
|
||||||
|
|
||||||
- name: Delete existing draft release if present
|
- name: Delete existing draft release
|
||||||
shell: bash
|
shell: bash
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
RELEASE_ID=$(curl -s -H "Authorization: Bearer $GITHUB_TOKEN" "https://api.github.com/repos/Youwes09/Moku/releases" | jq -r '.[] | select(.tag_name == "v${{ github.event.inputs.version }}" and .draft == true) | .id')
|
RELEASE_ID=$(curl -s -H "Authorization: Bearer $GITHUB_TOKEN" \
|
||||||
|
"https://api.github.com/repos/moku-project/Moku/releases" \
|
||||||
|
| jq -r '.[] | select(.tag_name == "v${{ github.event.inputs.version }}" and .draft == true) | .id')
|
||||||
if [ -n "$RELEASE_ID" ]; then
|
if [ -n "$RELEASE_ID" ]; then
|
||||||
echo "Deleting existing draft release $RELEASE_ID"
|
curl -s -X DELETE -H "Authorization: Bearer $GITHUB_TOKEN" \
|
||||||
curl -s -X DELETE -H "Authorization: Bearer $GITHUB_TOKEN" "https://api.github.com/repos/Youwes09/Moku/releases/$RELEASE_ID"
|
"https://api.github.com/repos/moku-project/Moku/releases/$RELEASE_ID"
|
||||||
# Also delete the tag so tauri-action can recreate it
|
curl -s -X DELETE -H "Authorization: Bearer $GITHUB_TOKEN" \
|
||||||
curl -s -X DELETE -H "Authorization: Bearer $GITHUB_TOKEN" "https://api.github.com/repos/Youwes09/Moku/git/refs/tags/v${{ github.event.inputs.version }}"
|
"https://api.github.com/repos/moku-project/Moku/git/refs/tags/v${{ github.event.inputs.version }}"
|
||||||
echo "Deleted draft release and tag"
|
|
||||||
else
|
|
||||||
echo "No existing draft release found"
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Build Tauri app + create draft release
|
- name: Build Tauri app + create draft release
|
||||||
uses: tauri-apps/tauri-action@v0
|
uses: tauri-apps/tauri-action@v0
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
|
||||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
|
||||||
with:
|
with:
|
||||||
tagName: v${{ github.event.inputs.version }}
|
tagName: v${{ github.event.inputs.version }}
|
||||||
releaseName: Moku v${{ github.event.inputs.version }}
|
releaseName: Moku v${{ github.event.inputs.version }}
|
||||||
releaseBody: |
|
releaseBody: |
|
||||||
Windows installer for Moku v${{ github.event.inputs.version }}.
|
Moku v${{ github.event.inputs.version }}
|
||||||
Download the `.exe` file below to install or update.
|
|
||||||
|
**Windows:** `Moku_${{ github.event.inputs.version }}_x64-setup.exe`
|
||||||
|
**macOS arm64:** `moku-macos-arm64-${{ github.event.inputs.version }}.dmg`
|
||||||
|
**macOS x64:** `moku-macos-x64-${{ github.event.inputs.version }}.dmg`
|
||||||
|
**Linux:** `moku.flatpak`
|
||||||
releaseDraft: true
|
releaseDraft: true
|
||||||
prerelease: false
|
prerelease: false
|
||||||
args: --target x86_64-pc-windows-msvc --config src-tauri/tauri.windows.conf.json
|
args: --target x86_64-pc-windows-msvc --config src-tauri/tauri.windows.conf.json
|
||||||
|
|||||||
@@ -1,26 +1,33 @@
|
|||||||
# --- Build Artifacts ---
|
|
||||||
node_modules/
|
node_modules/
|
||||||
|
suwayomi-raw/
|
||||||
|
suwayomi-windows.zip
|
||||||
dist/
|
dist/
|
||||||
dist-tauri/
|
dist-tauri/
|
||||||
target/
|
target/
|
||||||
bin/
|
bin/
|
||||||
out/
|
out/
|
||||||
|
|
||||||
# --- Nix ---
|
|
||||||
.direnv/
|
.direnv/
|
||||||
result
|
result
|
||||||
result-*
|
result-*
|
||||||
|
|
||||||
# --- Logs ---
|
|
||||||
*.log
|
*.log
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
yarn-debug.log*
|
yarn-debug.log*
|
||||||
yarn-error.log*
|
|
||||||
.env
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
!.env.test
|
||||||
.env.local
|
.env.local
|
||||||
.env.*.local
|
.env.*.local
|
||||||
|
|
||||||
# --- IDEs & OS ---
|
.output
|
||||||
|
.vercel
|
||||||
|
.netlify
|
||||||
|
.wrangler
|
||||||
|
/.svelte-kit
|
||||||
|
/build
|
||||||
|
|
||||||
.vscode/
|
.vscode/
|
||||||
.idea/
|
.idea/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
@@ -30,14 +37,19 @@ yarn-error.log*
|
|||||||
*.sln
|
*.sln
|
||||||
*.swp
|
*.swp
|
||||||
|
|
||||||
# --- Tauri specific ---
|
|
||||||
src-tauri/target/
|
src-tauri/target/
|
||||||
|
src-tauri/binaries/
|
||||||
src-tauri/gen/
|
src-tauri/gen/
|
||||||
|
|
||||||
# --- Flatpak build artifacts ---
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
vite.config.js.timestamp-*
|
||||||
|
vite.config.ts.timestamp-*
|
||||||
|
|
||||||
build-dir/
|
build-dir/
|
||||||
repo/
|
repo/
|
||||||
dist/
|
dist/
|
||||||
packaging/frontend-dist.tar.gz
|
packaging/frontend-dist.tar.gz
|
||||||
*.flatpak
|
*.flatpak
|
||||||
.flatpak-builder/\n# --- Staged sidecar binaries (platform-specific, never commit) ---\nsrc-tauri/binaries/
|
./flatpak-builder
|
||||||
|
|||||||
@@ -186,7 +186,7 @@
|
|||||||
same "printed page" as the copyright notice for easier
|
same "printed page" as the copyright notice for easier
|
||||||
identification within third-party archives.
|
identification within third-party archives.
|
||||||
|
|
||||||
Copyright [yyyy] [name of copyright owner]
|
Copyright [2026] [@Youwes09]
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0 (the "License");
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
you may not use this file except in compliance with the License.
|
you may not use this file except in compliance with the License.
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
pkgname=moku
|
pkgname=moku
|
||||||
pkgver=0.5.0
|
pkgver=0.10.0
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc="Native Linux manga reader frontend for Suwayomi-Server"
|
pkgdesc="Native Linux manga reader frontend for Suwayomi-Server"
|
||||||
arch=('x86_64')
|
arch=('x86_64')
|
||||||
url="https://github.com/Youwes09/Moku"
|
url="https://github.com/moku-project/Moku"
|
||||||
license=('Apache 2.0')
|
license=('Apache-2.0')
|
||||||
depends=(
|
depends=(
|
||||||
'webkit2gtk-4.1'
|
'webkit2gtk-4.1'
|
||||||
'gtk3'
|
'gtk3'
|
||||||
@@ -13,28 +13,46 @@ depends=(
|
|||||||
)
|
)
|
||||||
makedepends=(
|
makedepends=(
|
||||||
'rust'
|
'rust'
|
||||||
'cargo'
|
|
||||||
'nodejs'
|
|
||||||
'pnpm'
|
'pnpm'
|
||||||
)
|
)
|
||||||
source=(
|
optdepends=(
|
||||||
"$pkgname-$pkgver.tar.gz::https://github.com/Youwes09/Moku/archive/refs/tags/v$pkgver.tar.gz"
|
'discord: Discord rich presence'
|
||||||
"suwayomi-server.jar::https://github.com/Suwayomi/Suwayomi-Server/releases/download/v2.1.1867/suwayomi-server-v2.1.1867.jar"
|
)
|
||||||
"jdk.tar.gz::https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21.0.3%2B9/OpenJDK21U-jre_x64_linux_hotspot_21.0.3_9.tar.gz"
|
options=('!strip')
|
||||||
|
source=(
|
||||||
|
"$pkgname-$pkgver.tar.gz::https://github.com/moku-project/Moku/archive/refs/tags/v$pkgver.tar.gz"
|
||||||
|
"Suwayomi-Server-v2.1.2087.jar::https://github.com/Suwayomi/Suwayomi-Server-preview/releases/download/v2.1.2087/Suwayomi-Server-v2.1.2087.jar"
|
||||||
|
)
|
||||||
|
noextract=("Suwayomi-Server-v2.1.2087.jar")
|
||||||
|
sha256sums=(
|
||||||
|
'589b389b356a48d54ad4022e68eaac165a1de654d0b98edec79ebbab2c4a1275'
|
||||||
|
'f589a422674252394c13b289a9c8be691905bf583efb7f4d5f1501ae5e91e6b3'
|
||||||
|
)
|
||||||
|
b2sums=(
|
||||||
|
'SKIP'
|
||||||
|
'SKIP'
|
||||||
)
|
)
|
||||||
sha256sums=('2475d4bb4c7e8527384f7fcf9b0ace1c8a6354416f3af31398b844e35953fb73'
|
|
||||||
'51e307c2581e4e1a002991ab3e3a77503c8b074c42695987a984a7382d0ac5af'
|
|
||||||
'f1af100c4afca2035f446967323230150cfe5872b5a664d98c86963e5c066e0d')
|
|
||||||
|
|
||||||
prepare() {
|
prepare() {
|
||||||
cd "Moku-$pkgver"
|
cd "Moku-$pkgver"
|
||||||
pnpm install --frozen-lockfile
|
pnpm install --frozen-lockfile
|
||||||
|
sed -i 's/^lto\s*=\s*true/lto = "thin"/' src-tauri/Cargo.toml
|
||||||
|
mkdir -p src-tauri/.cargo
|
||||||
|
cat > src-tauri/.cargo/config.toml << 'EOF'
|
||||||
|
[target.x86_64-unknown-linux-gnu]
|
||||||
|
linker = "x86_64-linux-gnu-gcc"
|
||||||
|
EOF
|
||||||
}
|
}
|
||||||
|
|
||||||
build() {
|
build() {
|
||||||
cd "Moku-$pkgver"
|
cd "Moku-$pkgver"
|
||||||
pnpm build
|
pnpm build
|
||||||
tar -czf packaging/frontend-dist.tar.gz -C dist .
|
local fixed_cflags="${CFLAGS/-march=native/-march=x86-64}"
|
||||||
|
fixed_cflags="${fixed_cflags/-flto=auto/}"
|
||||||
|
local fixed_cxxflags="${CXXFLAGS/-march=native/-march=x86-64}"
|
||||||
|
fixed_cxxflags="${fixed_cxxflags/-flto=auto/}"
|
||||||
|
CFLAGS="$fixed_cflags" \
|
||||||
|
CXXFLAGS="$fixed_cxxflags" \
|
||||||
TAURI_SKIP_DEVSERVER_CHECK=true cargo build \
|
TAURI_SKIP_DEVSERVER_CHECK=true cargo build \
|
||||||
--release \
|
--release \
|
||||||
--manifest-path src-tauri/Cargo.toml
|
--manifest-path src-tauri/Cargo.toml
|
||||||
@@ -46,14 +64,11 @@ package() {
|
|||||||
install -Dm755 src-tauri/target/release/moku \
|
install -Dm755 src-tauri/target/release/moku \
|
||||||
"$pkgdir/usr/bin/moku"
|
"$pkgdir/usr/bin/moku"
|
||||||
|
|
||||||
install -dm755 "$pkgdir/usr/lib/moku/jre"
|
install -Dm644 "$srcdir/Suwayomi-Server-v2.1.2087.jar" \
|
||||||
tar -xf "$srcdir/jdk.tar.gz" -C "$pkgdir/usr/lib/moku/jre" --strip-components=1
|
|
||||||
|
|
||||||
install -Dm644 "$srcdir/suwayomi-server.jar" \
|
|
||||||
"$pkgdir/usr/lib/moku/tachidesk/Suwayomi-Server.jar"
|
"$pkgdir/usr/lib/moku/tachidesk/Suwayomi-Server.jar"
|
||||||
|
|
||||||
install -dm755 "$pkgdir/usr/lib/moku/tachidesk/default-conf"
|
install -dm755 "$pkgdir/usr/lib/moku/tachidesk/default-conf"
|
||||||
cat > "$pkgdir/usr/lib/moku/tachidesk/default-conf/server.conf" << 'EOF'
|
cat > "$pkgdir/usr/lib/moku/tachidesk/default-conf/server.conf" << 'CONF'
|
||||||
server.ip = "127.0.0.1"
|
server.ip = "127.0.0.1"
|
||||||
server.port = 4567
|
server.port = 4567
|
||||||
server.webUIEnabled = false
|
server.webUIEnabled = false
|
||||||
@@ -64,11 +79,11 @@ server.autoDownloadNewChapters = false
|
|||||||
server.globalUpdateInterval = 12
|
server.globalUpdateInterval = 12
|
||||||
server.maxSourcesInParallel = 6
|
server.maxSourcesInParallel = 6
|
||||||
server.extensionRepos = []
|
server.extensionRepos = []
|
||||||
EOF
|
CONF
|
||||||
|
|
||||||
install -Dm755 /dev/stdin "$pkgdir/usr/bin/tachidesk-server" << 'EOF'
|
install -Dm755 /dev/stdin "$pkgdir/usr/bin/moku-suwayomi" << 'LAUNCHER'
|
||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
DATA_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/moku/tachidesk"
|
DATA_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/Tachidesk"
|
||||||
mkdir -p "$DATA_DIR"
|
mkdir -p "$DATA_DIR"
|
||||||
|
|
||||||
if [ ! -f "$DATA_DIR/server.conf" ]; then
|
if [ ! -f "$DATA_DIR/server.conf" ]; then
|
||||||
@@ -90,25 +105,25 @@ unset WAYLAND_DISPLAY
|
|||||||
export _JAVA_OPTIONS="-Djava.awt.headless=true"
|
export _JAVA_OPTIONS="-Djava.awt.headless=true"
|
||||||
export JAVA_TOOL_OPTIONS="-Djava.awt.headless=true"
|
export JAVA_TOOL_OPTIONS="-Djava.awt.headless=true"
|
||||||
|
|
||||||
exec /usr/lib/moku/jre/bin/java \
|
exec java \
|
||||||
-Djava.awt.headless=true \
|
-Djava.awt.headless=true \
|
||||||
-Dapple.awt.UIElement=true \
|
-Dapple.awt.UIElement=true \
|
||||||
-Dsun.java2d.noddraw=true \
|
-Dsun.java2d.noddraw=true \
|
||||||
-Dsun.awt.disablegui=true \
|
-Dsun.awt.disablegui=true \
|
||||||
-Dsuwayomi.tachidesk.config.server.rootDir="$DATA_DIR" \
|
-Dsuwayomi.tachidesk.config.server.rootDir="$DATA_DIR" \
|
||||||
-jar /usr/lib/moku/tachidesk/Suwayomi-Server.jar
|
-jar /usr/lib/moku/tachidesk/Suwayomi-Server.jar
|
||||||
EOF
|
LAUNCHER
|
||||||
|
|
||||||
install -Dm644 packaging/dev.moku.app.desktop \
|
install -Dm644 packaging/io.github.moku_project.Moku.desktop \
|
||||||
"$pkgdir/usr/share/applications/dev.moku.app.desktop"
|
"$pkgdir/usr/share/applications/io.github.moku_project.Moku.desktop"
|
||||||
install -Dm644 src-tauri/icons/32x32.png \
|
install -Dm644 src-tauri/icons/32x32.png \
|
||||||
"$pkgdir/usr/share/icons/hicolor/32x32/apps/dev.moku.app.png"
|
"$pkgdir/usr/share/icons/hicolor/32x32/apps/io.github.moku_project.Moku.png"
|
||||||
install -Dm644 src-tauri/icons/128x128.png \
|
install -Dm644 src-tauri/icons/128x128.png \
|
||||||
"$pkgdir/usr/share/icons/hicolor/128x128/apps/dev.moku.app.png"
|
"$pkgdir/usr/share/icons/hicolor/128x128/apps/io.github.moku_project.Moku.png"
|
||||||
install -Dm644 src-tauri/icons/128x128@2x.png \
|
install -Dm644 src-tauri/icons/128x128@2x.png \
|
||||||
"$pkgdir/usr/share/icons/hicolor/256x256/apps/dev.moku.app.png"
|
"$pkgdir/usr/share/icons/hicolor/256x256/apps/io.github.moku_project.Moku.png"
|
||||||
install -Dm644 packaging/dev.moku.app.metainfo.xml \
|
install -Dm644 packaging/io.github.moku_project.Moku.metainfo.xml \
|
||||||
"$pkgdir/usr/share/metainfo/dev.moku.app.metainfo.xml"
|
"$pkgdir/usr/share/metainfo/io.github.moku_project.Moku.metainfo.xml"
|
||||||
|
install -Dm644 LICENSE \
|
||||||
install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
|
"$pkgdir/usr/share/licenses/$pkgname/LICENSE"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,10 +4,10 @@
|
|||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
[](https://github.com/Youwes09/Moku/releases/latest)
|
[](https://github.com/moku-project/Moku/releases/latest)
|
||||||
[](https://github.com/Youwes09/Moku/releases/latest)
|

|
||||||
[](./LICENSE)
|
[](https://github.com/moku-project/Moku)
|
||||||
[](https://discord.gg/cfncTbJ2)
|
[](https://discord.gg/x97hj8zR72)
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -20,12 +20,16 @@ Moku is a fast, minimal manga reader frontend for [Suwayomi-Server](https://gith
|
|||||||
## Screenshots
|
## Screenshots
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
<img src="docs/screenshots/Moku-Home.png" width="49%" alt="Home" />
|
<img src="docs/screenshots/Moku-Home.png" width="100%" alt="Home" />
|
||||||
<img src="docs/screenshots/Moku-Discover.png" width="49%" alt="Discover" />
|
</div>
|
||||||
<img src="docs/screenshots/Moku-Reader.png" width="49%" alt="Reader" />
|
|
||||||
<img src="docs/screenshots/Moku-Preview.png" width="49%" alt="Preview" />
|
<div align="center">
|
||||||
<img src="docs/screenshots/Moku-Tracker.png" width="49%" alt="Tracker" />
|
<img src="docs/screenshots/Moku-Search.png" width="49%" alt="Search" />
|
||||||
|
<img src="docs/screenshots/Moku-TagSearch.png" width="49%" alt="Tag Search" />
|
||||||
<img src="docs/screenshots/Moku-Settings.png" width="49%" alt="Settings" />
|
<img src="docs/screenshots/Moku-Settings.png" width="49%" alt="Settings" />
|
||||||
|
<img src="docs/screenshots/Moku-Preview.png" width="49%" alt="Preview" />
|
||||||
|
<img src="docs/screenshots/Moku-Downloads.png" width="49%" alt="Downloads" />
|
||||||
|
<img src="docs/screenshots/Moku-ReaderSettings.png" width="49%" alt="Reader Settings" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
@@ -37,48 +41,71 @@ Moku is a fast, minimal manga reader frontend for [Suwayomi-Server](https://gith
|
|||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Library management** — organize manga into folders, track unread counts, filter by genre
|
- **Library management** — organize manga into folders, track unread counts, filter by genre
|
||||||
|
- **Per-folder sorting & filtering** — each folder has its own independent sort (unread, A–Z, recently read, latest chapter, and more) and publication status filter (Ongoing, Completed, Hiatus, etc.)
|
||||||
- **Built-in reader** — single page, long strip, configurable fit modes, customizable keybinds
|
- **Built-in reader** — single page, long strip, configurable fit modes, customizable keybinds
|
||||||
|
- **Markers** — pin color-coded notes to any page while reading; markers appear as dots on the progress bar and are browseable under Series Detail → Manage → Markers
|
||||||
- **Extension support** — install and manage Suwayomi extensions directly from the app
|
- **Extension support** — install and manage Suwayomi extensions directly from the app
|
||||||
- **Download management** — queue and monitor chapter downloads with progress toasts
|
- **Download management** — queue and monitor chapter downloads with progress toasts
|
||||||
- **Tracker integration** — sync reading progress with AniList, MyAnimeList, Kitsu, and more
|
- **Automation** — pre-download titles automatically and optionally delete chapters after reading (accessible from Series Detail)
|
||||||
|
- **Discord Rich Presence** — shows manga title, current chapter, and elapsed timer in your Discord status; configurable in Settings → General
|
||||||
- **Auto-start server** — optionally launch Suwayomi in the background on startup
|
- **Auto-start server** — optionally launch Suwayomi in the background on startup
|
||||||
- **Multiple themes** — Dark, Light, Midnight, Warm, High Contrast, and more
|
- **Multiple themes** — Dark, Light, Midnight, Warm, High Contrast, and more
|
||||||
- **Auto-updates** — in-app update checker with silent background notifications
|
- **Auto-updates** — in-app update checker with silent background notifications
|
||||||
|
- **Improved NSFW filtering** — expanded tag parser gives the Hide NSFW setting better coverage across sources
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
### Flatpak (Linux, recommended)
|
<div align="center">
|
||||||
|
|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
### Windows
|
||||||
|
|
||||||
|
**winget:**
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
winget install Moku.Moku
|
||||||
|
```
|
||||||
|
|
||||||
|
> Thanks to [@frozenKelp](https://github.com/frozenKelp) for setting up and maintaining the winget package through v0.9.0.
|
||||||
|
|
||||||
|
Or download the `.exe` installer from the [releases page](https://github.com/moku-project/Moku/releases/latest). Suwayomi-Server and a JRE are bundled.
|
||||||
|
|
||||||
|
### Linux (Flatpak, recommended)
|
||||||
|
|
||||||
Suwayomi-Server and a bundled JRE are included — no separate install needed.
|
Suwayomi-Server and a bundled JRE are included — no separate install needed.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
flatpak install moku.flatpak
|
flatpak install io.github.moku_app.Moku
|
||||||
flatpak run dev.moku.app
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Download the latest `moku.flatpak` from the [releases page](https://github.com/Youwes09/Moku/releases/latest).
|
Or download the latest `moku.flatpak` from the [releases page](https://github.com/moku-project/Moku/releases/latest) and install manually:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
flatpak install moku.flatpak
|
||||||
|
```
|
||||||
|
|
||||||
### Nix
|
### Nix
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
nix run github:Youwes09/Moku
|
nix run github:moku-project/Moku
|
||||||
```
|
```
|
||||||
|
|
||||||
Add to your flake:
|
Add to your flake:
|
||||||
|
|
||||||
```nix
|
```nix
|
||||||
inputs.moku.url = "github:Youwes09/Moku";
|
inputs.moku.url = "github:moku-project/Moku";
|
||||||
```
|
```
|
||||||
|
|
||||||
### Windows
|
|
||||||
|
|
||||||
Download the `.exe` installer from the [releases page](https://github.com/Youwes09/Moku/releases/latest). Suwayomi-Server and a JRE are bundled.
|
|
||||||
|
|
||||||
### macOS
|
### macOS
|
||||||
|
|
||||||
Download the `.dmg` from the [releases page](https://github.com/Youwes09/Moku/releases/latest).
|
Download the `.dmg` from the [releases page](https://github.com/moku-project/Moku/releases/latest).
|
||||||
|
|
||||||
> **Note:** Builds are ad-hoc signed. On first launch you may need to run:
|
> **Note:** Builds are ad-hoc signed. On first launch you may need to run:
|
||||||
> ```bash
|
> ```bash
|
||||||
@@ -100,7 +127,7 @@ You can point Moku at any Suwayomi instance — local or remote — via **Settin
|
|||||||
**Prerequisites:** [Rust](https://rustup.rs), [Node.js](https://nodejs.org), [pnpm](https://pnpm.io), and [Tauri v2 prerequisites](https://tauri.app/start/prerequisites/).
|
**Prerequisites:** [Rust](https://rustup.rs), [Node.js](https://nodejs.org), [pnpm](https://pnpm.io), and [Tauri v2 prerequisites](https://tauri.app/start/prerequisites/).
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/Youwes09/Moku
|
git clone https://github.com/moku-project/Moku
|
||||||
cd Moku
|
cd Moku
|
||||||
pnpm install
|
pnpm install
|
||||||
pnpm tauri:dev
|
pnpm tauri:dev
|
||||||
@@ -121,9 +148,9 @@ pnpm tauri:dev
|
|||||||
| | |
|
| | |
|
||||||
|---|---|
|
|---|---|
|
||||||
| [Tauri v2](https://tauri.app) | Native app shell |
|
| [Tauri v2](https://tauri.app) | Native app shell |
|
||||||
| [Svelte 5](https://svelte.dev) + [TypeScript](https://www.typescriptlang.org) | UI |
|
| [Svelte 5](https://svelte.dev) + [SvelteKit 2](https://kit.svelte.dev) + [TypeScript](https://www.typescriptlang.org) | UI |
|
||||||
| [Vite](https://vitejs.dev) | Frontend bundler |
|
| [Vite 8](https://vitejs.dev) | Frontend bundler |
|
||||||
| [Crane](https://github.com/ipetkov/crane) | Nix Rust builds |
|
| [Nixpkgs stdenv](https://nixos.org/manual/nixpkgs/stable/) | Nix builds |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -131,7 +158,7 @@ pnpm tauri:dev
|
|||||||
|
|
||||||
Questions, feedback, or just want to hang out — join the Discord.
|
Questions, feedback, or just want to hang out — join the Discord.
|
||||||
|
|
||||||
[](https://discord.gg/cfncTbJ2)
|
[](https://discord.gg/x97hj8zR72)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,42 +1,4 @@
|
|||||||
Major Revisions:
|
Revival of the TODO List!!!!!
|
||||||
- Moku + Crossplatform Support (MacOS Remaining)
|
|
||||||
- Contemplate Anime Support, Add Novel Support (Consumet API)
|
|
||||||
- Enable Cloudflare Bypass (Suwayomi Config)
|
|
||||||
- Moku-Share to Easily Migrate/Share Manga (Investigate Usecase/Feasibility)
|
|
||||||
- Adjustment in Settings for Theme Editor:
|
|
||||||
- Allow User to Edit/Create Themes
|
|
||||||
- Allow For Command-Line IPC for Temporary (Apply Once) & Permanent Themes OR External Methodology for Matugen Colors
|
|
||||||
|
|
||||||
Minor Revisions:
|
|
||||||
- Improve Deduplication Algorithm with Advanced Option Settings (Implement Chapter & Author-based Comparisons, Resource Intensive)
|
|
||||||
- Integrate Download Directory Changes (Settings)
|
|
||||||
- Investigate feasibility of Multi-Page Screenshot (Reader)
|
|
||||||
- Add Hover Info on Library (Make sure doesn't conflict with additional clicks)
|
|
||||||
|
|
||||||
Priority Bugs:
|
|
||||||
- Cache ALL Cover Pictures & Details for Manga in Library
|
|
||||||
- MacOS Full-Screen & UI Compatability (TitleBar)
|
|
||||||
|
|
||||||
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?)
|
|
||||||
|
|
||||||
In-Progress:`
|
|
||||||
- Fix Reader Chapter Shifts (Glitched Sentinel)
|
|
||||||
- Still Shifts Down after reading ~8+ Chapters?
|
|
||||||
- Identify When Chapters are Unloaded, How to Preserve Structure
|
|
||||||
|
|
||||||
|
|
||||||
Important Commands:
|
- Reminder to Completely Test Settings
|
||||||
cd ~/Projects/Manga/Moku
|
|
||||||
pnpm build
|
|
||||||
tar -czf packaging/frontend-dist.tar.gz -C dist .
|
|
||||||
sha256sum packaging/frontend-dist.tar.gz | awk '{print $1}'
|
|
||||||
|
|
||||||
|
|
||||||
nix-shell -p "python311.withPackages(ps: [ ps.aiohttp ps.tomlkit ])" --run "python3 packaging/flatpak-cargo-generator.py src-tauri/Cargo.lock -o packaging/cargo-sources.json"
|
|
||||||
nix shell nixpkgs#appstream nixpkgs#flatpak-builder --command flatpak-builder --repo=repo --force-clean build-dir dev.moku.app.yml
|
|
||||||
flatpak build-bundle repo moku.flatpak dev.moku.app
|
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
#Requires -Version 7
|
||||||
|
param(
|
||||||
|
[switch]$SkipFrontend,
|
||||||
|
[switch]$SkipSuwayomi
|
||||||
|
)
|
||||||
|
|
||||||
|
Set-StrictMode -Version Latest
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
function Step($msg) { Write-Host "`n==> $msg" -ForegroundColor Cyan }
|
||||||
|
function Need($cmd) {
|
||||||
|
if (-not (Get-Command $cmd -ErrorAction SilentlyContinue)) {
|
||||||
|
Write-Error "Required tool not found: $cmd"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$Root = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||||
|
Set-Location $Root
|
||||||
|
|
||||||
|
Step "Reading nix/versions.nix"
|
||||||
|
$nix = Get-Content "$Root\nix\versions.nix" -Raw
|
||||||
|
$MOKU_VERSION = if ($nix -match 'moku\s*=\s*"([^"]+)"') { $Matches[1] } else { Write-Error "moku version not found" }
|
||||||
|
$SUWA_VERSION = if ($nix -match 'version\s*=\s*"([^"]+)"') { $Matches[1] } else { Write-Error "suwayomi version not found" }
|
||||||
|
$SUWA_HASH = if ($nix -match 'windowsHash\s*=\s*"([^"]+)"') { $Matches[1] } else { Write-Error "windowsHash not found" }
|
||||||
|
Write-Host " moku=$MOKU_VERSION suwayomi=$SUWA_VERSION"
|
||||||
|
|
||||||
|
Need "pnpm"; Need "cargo"; Need "node"
|
||||||
|
|
||||||
|
if (-not $SkipFrontend) {
|
||||||
|
Step "pnpm install"
|
||||||
|
pnpm install --frozen-lockfile
|
||||||
|
if ($LASTEXITCODE -ne 0) { Write-Error "pnpm install failed" }
|
||||||
|
|
||||||
|
Step "Frontend build"
|
||||||
|
$env:MOKU_TARGET = "static"
|
||||||
|
pnpm build:static
|
||||||
|
if ($LASTEXITCODE -ne 0) { Write-Error "Frontend build failed" }
|
||||||
|
}
|
||||||
|
|
||||||
|
$BundleDir = "$Root\src-tauri\binaries\suwayomi-bundle"
|
||||||
|
$ZipPath = "$env:TEMP\suwayomi-windows-$SUWA_VERSION.zip"
|
||||||
|
$ExtractDir = "$env:TEMP\suwayomi-extracted-$SUWA_VERSION"
|
||||||
|
|
||||||
|
if (-not $SkipSuwayomi) {
|
||||||
|
Step "Downloading Suwayomi v$SUWA_VERSION"
|
||||||
|
$ZipUrl = "https://github.com/Suwayomi/Suwayomi-Server-preview/releases/download/v${SUWA_VERSION}/Suwayomi-Server-v${SUWA_VERSION}-windows-x64.zip"
|
||||||
|
|
||||||
|
if (-not (Test-Path $ZipPath)) {
|
||||||
|
Invoke-WebRequest -Uri $ZipUrl -OutFile $ZipPath -UseBasicParsing
|
||||||
|
}
|
||||||
|
|
||||||
|
$actual = (Get-FileHash $ZipPath -Algorithm SHA256).Hash.ToLower()
|
||||||
|
if ($actual -ne $SUWA_HASH.ToLower()) {
|
||||||
|
Write-Error "Hash mismatch`n expected: $SUWA_HASH`n got: $actual"
|
||||||
|
}
|
||||||
|
|
||||||
|
Step "Staging bundle"
|
||||||
|
if (Test-Path $ExtractDir) { Remove-Item $ExtractDir -Recurse -Force }
|
||||||
|
Expand-Archive -Path $ZipPath -DestinationPath $ExtractDir
|
||||||
|
|
||||||
|
$topDirs = @(Get-ChildItem $ExtractDir -Directory)
|
||||||
|
$topFiles = @(Get-ChildItem $ExtractDir -File)
|
||||||
|
$SrcDir = if ($topDirs.Count -eq 1 -and $topFiles.Count -eq 0) { $topDirs[0].FullName } else { $ExtractDir }
|
||||||
|
|
||||||
|
if (Test-Path $BundleDir) { Remove-Item $BundleDir -Recurse -Force }
|
||||||
|
Copy-Item $SrcDir $BundleDir -Recurse
|
||||||
|
|
||||||
|
$java = Get-ChildItem $BundleDir -Recurse -Filter "java.exe" | Where-Object { $_.FullName -match "jre.bin" } | Select-Object -First 1
|
||||||
|
$jar = Get-ChildItem $BundleDir -Recurse -Filter "Suwayomi-Server.jar" | Select-Object -First 1
|
||||||
|
if (-not $java) { Write-Error "java.exe not found in staged bundle" }
|
||||||
|
if (-not $jar) { Write-Error "Suwayomi-Server.jar not found in staged bundle" }
|
||||||
|
Write-Host " java: $($java.FullName)"
|
||||||
|
Write-Host " jar: $($jar.FullName)"
|
||||||
|
} elseif (-not (Test-Path $BundleDir)) {
|
||||||
|
Write-Error "Bundle dir missing at $BundleDir — run without -SkipSuwayomi first"
|
||||||
|
}
|
||||||
|
|
||||||
|
Step "Patching tauri.conf.json"
|
||||||
|
$tauriConf = "$Root\src-tauri\tauri.conf.json"
|
||||||
|
$original = Get-Content $tauriConf -Raw
|
||||||
|
Set-Content $tauriConf ($original -replace '"beforeBuildCommand":\s*"pnpm build"', '"beforeBuildCommand": ""') -NoNewline
|
||||||
|
|
||||||
|
Step "Tauri build"
|
||||||
|
$env:TAURI_SKIP_DEVSERVER_CHECK = "true"
|
||||||
|
pnpm tauri build --target x86_64-pc-windows-msvc --config src-tauri/tauri.windows.conf.json
|
||||||
|
$buildExit = $LASTEXITCODE
|
||||||
|
|
||||||
|
Set-Content $tauriConf $original -NoNewline
|
||||||
|
|
||||||
|
if ($buildExit -ne 0) { Write-Error "Tauri build failed (exit $buildExit)" }
|
||||||
|
|
||||||
|
Step "Artifacts"
|
||||||
|
$out = "$Root\src-tauri\target\x86_64-pc-windows-msvc\release\bundle"
|
||||||
|
$msi = Get-ChildItem "$out\msi" -Filter "*.msi" -ErrorAction SilentlyContinue | Select-Object -First 1
|
||||||
|
$exe = Get-ChildItem "$out\nsis" -Filter "*.exe" -ErrorAction SilentlyContinue | Select-Object -First 1
|
||||||
|
Write-Host "`nDone — Moku $MOKU_VERSION" -ForegroundColor Green
|
||||||
|
if ($msi) { Write-Host " MSI: $($msi.FullName)" -ForegroundColor Yellow }
|
||||||
|
if ($exe) { Write-Host " EXE: $($exe.FullName)" -ForegroundColor Yellow }
|
||||||
|
if (-not $msi -and -not $exe) { Write-Host " No artifacts found in $out" -ForegroundColor Red }
|
||||||
|
Before Width: | Height: | Size: 7.1 MiB |
|
After Width: | Height: | Size: 140 KiB |
|
Before Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 914 KiB After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 2.0 MiB After Width: | Height: | Size: 1.6 MiB |
|
Before Width: | Height: | Size: 648 KiB After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 6.0 MiB |
|
Before Width: | Height: | Size: 609 KiB After Width: | Height: | Size: 287 KiB |
|
After Width: | Height: | Size: 4.3 MiB |
|
Before Width: | Height: | Size: 151 KiB |
@@ -1,30 +1,15 @@
|
|||||||
{
|
{
|
||||||
"nodes": {
|
"nodes": {
|
||||||
"crane": {
|
|
||||||
"locked": {
|
|
||||||
"lastModified": 1773857772,
|
|
||||||
"narHash": "sha256-5xsK26KRHf0WytBtsBnQYC/lTWDhQuT57HJ7SzuqZcM=",
|
|
||||||
"owner": "ipetkov",
|
|
||||||
"repo": "crane",
|
|
||||||
"rev": "b556d7bbae5ff86e378451511873dfd07e4504cd",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
"original": {
|
|
||||||
"owner": "ipetkov",
|
|
||||||
"repo": "crane",
|
|
||||||
"type": "github"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"flake-parts": {
|
"flake-parts": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"nixpkgs-lib": "nixpkgs-lib"
|
"nixpkgs-lib": "nixpkgs-lib"
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1772408722,
|
"lastModified": 1778716662,
|
||||||
"narHash": "sha256-rHuJtdcOjK7rAHpHphUb1iCvgkU3GpfvicLMwwnfMT0=",
|
"narHash": "sha256-m1Yf0wZ8j1OHjTc2UwHwyQRSnNeSgLJOd7q5Y45hzi4=",
|
||||||
"owner": "hercules-ci",
|
"owner": "hercules-ci",
|
||||||
"repo": "flake-parts",
|
"repo": "flake-parts",
|
||||||
"rev": "f20dc5d9b8027381c474144ecabc9034d6a839a3",
|
"rev": "f7c1a2d347e4c52d5fb8d10cb4d94b5884e546fb",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -35,11 +20,11 @@
|
|||||||
},
|
},
|
||||||
"nixpkgs": {
|
"nixpkgs": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1773821835,
|
"lastModified": 1780243769,
|
||||||
"narHash": "sha256-TJ3lSQtW0E2JrznGVm8hOQGVpXjJyXY2guAxku2O9A4=",
|
"narHash": "sha256-x5UQuRsH3MqI0U9afaXSNqzTPSeZlRLvFAav2Ux1pNw=",
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "b40629efe5d6ec48dd1efba650c797ddbd39ace0",
|
"rev": "331800de5053fcebacf6813adb5db9c9dca22a0c",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -51,11 +36,11 @@
|
|||||||
},
|
},
|
||||||
"nixpkgs-lib": {
|
"nixpkgs-lib": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1772328832,
|
"lastModified": 1777168982,
|
||||||
"narHash": "sha256-e+/T/pmEkLP6BHhYjx6GmwP5ivonQQn0bJdH9YrRB+Q=",
|
"narHash": "sha256-GOkGPcboWE9BmGCRMLX3worL4EMnsnG8MyKmXNeYuhQ=",
|
||||||
"owner": "nix-community",
|
"owner": "nix-community",
|
||||||
"repo": "nixpkgs.lib",
|
"repo": "nixpkgs.lib",
|
||||||
"rev": "c185c7a5e5dd8f9add5b2f8ebeff00888b070742",
|
"rev": "f5901329dade4a6ea039af1433fb087bd9c1fe14",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -66,7 +51,6 @@
|
|||||||
},
|
},
|
||||||
"root": {
|
"root": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"crane": "crane",
|
|
||||||
"flake-parts": "flake-parts",
|
"flake-parts": "flake-parts",
|
||||||
"nixpkgs": "nixpkgs",
|
"nixpkgs": "nixpkgs",
|
||||||
"rust-overlay": "rust-overlay"
|
"rust-overlay": "rust-overlay"
|
||||||
@@ -79,11 +63,11 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1773975983,
|
"lastModified": 1780543271,
|
||||||
"narHash": "sha256-zrRVwdfhDdohANqEhzY/ydeza6EXEi8AG6cyMRNYT9Q=",
|
"narHash": "sha256-oPJ7eJN1sM37v92Rp/eyQL7/rUm0BOvXEBAoq/zN0cM=",
|
||||||
"owner": "oxalica",
|
"owner": "oxalica",
|
||||||
"repo": "rust-overlay",
|
"repo": "rust-overlay",
|
||||||
"rev": "cc80954a95f6f356c303ed9f08d0b63ca86216ac",
|
"rev": "c30ca201c5093540cf792f6982f81ba1aa0f3514",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|||||||
@@ -4,7 +4,6 @@
|
|||||||
inputs = {
|
inputs = {
|
||||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||||
flake-parts.url = "github:hercules-ci/flake-parts";
|
flake-parts.url = "github:hercules-ci/flake-parts";
|
||||||
crane.url = "github:ipetkov/crane";
|
|
||||||
rust-overlay = {
|
rust-overlay = {
|
||||||
url = "github:oxalica/rust-overlay";
|
url = "github:oxalica/rust-overlay";
|
||||||
inputs.nixpkgs.follows = "nixpkgs";
|
inputs.nixpkgs.follows = "nixpkgs";
|
||||||
@@ -12,13 +11,18 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
outputs =
|
outputs =
|
||||||
inputs@{ flake-parts, crane, rust-overlay, ... }:
|
inputs@{ flake-parts, rust-overlay, ... }:
|
||||||
flake-parts.lib.mkFlake { inherit inputs; } {
|
flake-parts.lib.mkFlake { inherit inputs; } {
|
||||||
systems = [ "x86_64-linux" "aarch64-linux" ];
|
systems = [
|
||||||
|
"x86_64-linux"
|
||||||
|
"aarch64-linux"
|
||||||
|
];
|
||||||
|
|
||||||
perSystem = { system, lib, ... }:
|
perSystem =
|
||||||
|
{ system, lib, ... }:
|
||||||
let
|
let
|
||||||
version = "0.5.0";
|
versions = import ./nix/versions.nix;
|
||||||
|
version = versions.moku;
|
||||||
|
|
||||||
pkgs = import inputs.nixpkgs {
|
pkgs = import inputs.nixpkgs {
|
||||||
inherit system;
|
inherit system;
|
||||||
@@ -26,11 +30,12 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
rustToolchain = pkgs.rust-bin.stable.latest.default.override {
|
rustToolchain = pkgs.rust-bin.stable.latest.default.override {
|
||||||
extensions = [ "rust-src" "rust-analyzer" ];
|
extensions = [
|
||||||
|
"rust-src"
|
||||||
|
"rust-analyzer"
|
||||||
|
];
|
||||||
};
|
};
|
||||||
|
|
||||||
craneLib = (crane.mkLib pkgs).overrideToolchain rustToolchain;
|
|
||||||
|
|
||||||
runtimeLibs = with pkgs; [
|
runtimeLibs = with pkgs; [
|
||||||
webkitgtk_4_1
|
webkitgtk_4_1
|
||||||
gtk3
|
gtk3
|
||||||
@@ -46,236 +51,54 @@
|
|||||||
gsettings-desktop-schemas
|
gsettings-desktop-schemas
|
||||||
];
|
];
|
||||||
|
|
||||||
frontendSrc = lib.cleanSourceWith {
|
src = lib.cleanSourceWith {
|
||||||
src = ./.;
|
src = ./.;
|
||||||
filter = path: type:
|
filter =
|
||||||
let base = builtins.baseNameOf path;
|
path: type:
|
||||||
|
let
|
||||||
|
base = builtins.baseNameOf path;
|
||||||
in
|
in
|
||||||
(lib.hasInfix "/src" path)
|
(lib.hasInfix "/src" path)
|
||||||
|
|| (lib.hasInfix "/src-tauri/src" path)
|
||||||
|
|| (lib.hasInfix "/src-tauri/icons" path)
|
||||||
|
|| (lib.hasInfix "/src-tauri/capabilities" path)
|
||||||
|
|| (lib.hasInfix "/static" path)
|
||||||
|| base == "index.html"
|
|| base == "index.html"
|
||||||
|| base == "package.json"
|
|| base == "package.json"
|
||||||
|| base == "pnpm-lock.yaml"
|
|| base == "pnpm-lock.yaml"
|
||||||
|
|| base == "pnpm-workspace.yaml"
|
||||||
|| base == "tsconfig.json"
|
|| base == "tsconfig.json"
|
||||||
|| base == "vite.config.ts";
|
|| base == "vite.config.ts"
|
||||||
|
|| base == "svelte.config.js"
|
||||||
|
|| base == "Cargo.toml"
|
||||||
|
|| base == "Cargo.lock"
|
||||||
|
|| base == "build.rs"
|
||||||
|
|| base == "tauri.conf.json";
|
||||||
};
|
};
|
||||||
|
|
||||||
frontend = pkgs.stdenv.mkDerivation {
|
suwayomiServer = pkgs.callPackage ./nix/server.nix { inherit versions; };
|
||||||
pname = "moku-frontend";
|
|
||||||
inherit version;
|
|
||||||
src = frontendSrc;
|
|
||||||
|
|
||||||
nativeBuildInputs = with pkgs; [ nodejs_22 pnpm pnpmConfigHook ];
|
moku = pkgs.callPackage ./nix/moku.nix {
|
||||||
|
inherit lib pkgs rustToolchain runtimeLibs suwayomiServer version src versions;
|
||||||
pnpmDeps = pkgs.fetchPnpmDeps {
|
appIcon = ./src/lib/assets/moku-icon.svg;
|
||||||
pname = "moku-frontend";
|
|
||||||
inherit version;
|
|
||||||
src = frontendSrc;
|
|
||||||
fetcherVersion = 1;
|
|
||||||
hash = "sha256-4QUSgWgMu7FGn44+TGmACheokPhaBdHvA/055SqUs0Q=";
|
|
||||||
};
|
};
|
||||||
|
|
||||||
buildPhase = "pnpm build";
|
scripts = import ./nix/scripts.nix { inherit pkgs rustToolchain version versions; };
|
||||||
installPhase = "cp -r dist $out";
|
|
||||||
};
|
|
||||||
|
|
||||||
cargoSrc = lib.cleanSourceWith {
|
|
||||||
src = ./src-tauri;
|
|
||||||
filter = path: type:
|
|
||||||
(craneLib.filterCargoSources path type)
|
|
||||||
|| (lib.hasInfix "/icons/" path)
|
|
||||||
|| (lib.hasInfix "/capabilities/" path)
|
|
||||||
|| (builtins.baseNameOf path == "tauri.conf.json");
|
|
||||||
};
|
|
||||||
|
|
||||||
commonArgs = {
|
|
||||||
src = cargoSrc;
|
|
||||||
cargoToml = ./src-tauri/Cargo.toml;
|
|
||||||
cargoLock = ./src-tauri/Cargo.lock;
|
|
||||||
strictDeps = true;
|
|
||||||
buildInputs = runtimeLibs;
|
|
||||||
nativeBuildInputs = with pkgs; [ pkg-config wrapGAppsHook3 ];
|
|
||||||
preBuild = ''
|
|
||||||
cp -r ${frontend} ../dist
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
|
|
||||||
cargoArtifacts = craneLib.buildDepsOnly commonArgs;
|
|
||||||
|
|
||||||
moku = craneLib.buildPackage (commonArgs // {
|
|
||||||
inherit cargoArtifacts;
|
|
||||||
meta.mainProgram = "moku";
|
|
||||||
postInstall = ''
|
|
||||||
mkdir -p "$out/share/applications"
|
|
||||||
cat > "$out/share/applications/moku.desktop" << EOF
|
|
||||||
[Desktop Entry]
|
|
||||||
Version=1.0
|
|
||||||
Type=Application
|
|
||||||
Name=Moku
|
|
||||||
Comment=Manga reader frontend for Suwayomi
|
|
||||||
Exec=$out/bin/moku
|
|
||||||
Icon=moku
|
|
||||||
Terminal=false
|
|
||||||
Categories=Graphics;Viewer;
|
|
||||||
Keywords=manga;comic;reader;suwayomi;
|
|
||||||
StartupWMClass=moku
|
|
||||||
EOF
|
|
||||||
|
|
||||||
for size in 32x32 128x128 256x256 512x512; do
|
|
||||||
src="icons/$size.png"
|
|
||||||
[ -f "$src" ] && install -Dm644 "$src" \
|
|
||||||
"$out/share/icons/hicolor/$size/apps/moku.png"
|
|
||||||
done
|
|
||||||
|
|
||||||
for size in 128x128 256x256; do
|
|
||||||
src="icons/''${size}@2x.png"
|
|
||||||
[ -f "$src" ] && install -Dm644 "$src" \
|
|
||||||
"$out/share/icons/hicolor/''${size}@2/apps/moku.png"
|
|
||||||
done
|
|
||||||
|
|
||||||
install -Dm644 "${./src/assets/moku-icon.svg}" \
|
|
||||||
"$out/share/icons/hicolor/scalable/apps/moku.svg"
|
|
||||||
|
|
||||||
wrapProgram $out/bin/moku \
|
|
||||||
--prefix XDG_DATA_DIRS : "${lib.makeSearchPath "share/gsettings-schemas" [
|
|
||||||
pkgs.gsettings-desktop-schemas
|
|
||||||
pkgs.gtk3
|
|
||||||
]}" \
|
|
||||||
--prefix LD_LIBRARY_PATH : "${lib.makeLibraryPath runtimeLibs}" \
|
|
||||||
--prefix PATH : "${lib.makeBinPath [ pkgs.suwayomi-server ]}" \
|
|
||||||
--set GDK_BACKEND wayland \
|
|
||||||
--set WEBKIT_DISABLE_SANDBOX_THIS_IS_DANGEROUS 1
|
|
||||||
'';
|
|
||||||
});
|
|
||||||
|
|
||||||
bumpScript = pkgs.writeShellApplication {
|
|
||||||
name = "moku-bump";
|
|
||||||
runtimeInputs = with pkgs; [ gnused coreutils git ];
|
|
||||||
text = ''
|
|
||||||
[[ $# -lt 1 ]] && { echo "Usage: nix run .#bump -- <version>"; exit 1; }
|
|
||||||
VERSION="$1"
|
|
||||||
REPO="$(git rev-parse --show-toplevel)"
|
|
||||||
sed -i "s/\"version\": \"[^\"]*\"/\"version\": \"$VERSION\"/" \
|
|
||||||
"$REPO/src-tauri/tauri.conf.json"
|
|
||||||
sed -i "0,/^version = \"[^\"]*\"/s//version = \"$VERSION\"/" \
|
|
||||||
"$REPO/src-tauri/Cargo.toml"
|
|
||||||
sed -i "s/version = \"[^\"]*\";/version = \"$VERSION\";/g" \
|
|
||||||
"$REPO/flake.nix"
|
|
||||||
echo "Bumped to $VERSION"
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
|
|
||||||
flatpakScript = pkgs.writeShellApplication {
|
|
||||||
name = "moku-flatpak";
|
|
||||||
runtimeInputs = with pkgs; [
|
|
||||||
gnused coreutils git
|
|
||||||
nodejs_22 pnpm
|
|
||||||
appstream flatpak-builder flatpak
|
|
||||||
(python3.withPackages (ps: [ ps.aiohttp ps.tomlkit ]))
|
|
||||||
];
|
|
||||||
text = ''
|
|
||||||
[[ $# -lt 1 ]] && { echo "Usage: nix run .#flatpak -- <version>"; exit 1; }
|
|
||||||
VERSION="$1"
|
|
||||||
REPO="$(git rev-parse --show-toplevel)"
|
|
||||||
MANIFEST="$REPO/dev.moku.app.yml"
|
|
||||||
|
|
||||||
echo "── Bumping versions ──"
|
|
||||||
sed -i "s/\"version\": \"[^\"]*\"/\"version\": \"$VERSION\"/" \
|
|
||||||
"$REPO/src-tauri/tauri.conf.json"
|
|
||||||
sed -i "0,/^version = \"[^\"]*\"/s//version = \"$VERSION\"/" \
|
|
||||||
"$REPO/src-tauri/Cargo.toml"
|
|
||||||
sed -i "s/version = \"[^\"]*\";/version = \"$VERSION\";/g" \
|
|
||||||
"$REPO/flake.nix"
|
|
||||||
echo "Done"
|
|
||||||
|
|
||||||
echo "── Building frontend ──"
|
|
||||||
cd "$REPO"
|
|
||||||
pnpm install --frozen-lockfile
|
|
||||||
pnpm build
|
|
||||||
echo "Done"
|
|
||||||
|
|
||||||
echo "── Repacking frontend-dist.tar.gz ──"
|
|
||||||
tar -czf "$REPO/packaging/frontend-dist.tar.gz" -C "$REPO/dist" .
|
|
||||||
FRONTEND_SHA=$(sha256sum "$REPO/packaging/frontend-dist.tar.gz" | awk '{print $1}')
|
|
||||||
echo "sha256: $FRONTEND_SHA"
|
|
||||||
|
|
||||||
echo "── Patching manifest sha256 ──"
|
|
||||||
python3 - "$MANIFEST" "$FRONTEND_SHA" <<'PYEOF'
|
|
||||||
import re, sys
|
|
||||||
path, sha = sys.argv[1], sys.argv[2]
|
|
||||||
text = open(path).read()
|
|
||||||
updated, n = re.subn(
|
|
||||||
r'(path:\s*packaging/frontend-dist\.tar\.gz\s*\n\s*sha256:\s*)[0-9a-f]+',
|
|
||||||
r'\g<1>' + sha, text)
|
|
||||||
if n == 0:
|
|
||||||
sys.exit("ERROR: could not find frontend-dist sha256 in manifest")
|
|
||||||
open(path, 'w').write(updated)
|
|
||||||
PYEOF
|
|
||||||
echo "Done"
|
|
||||||
|
|
||||||
echo "── Regenerating cargo-sources.json ──"
|
|
||||||
python3 "$REPO/packaging/flatpak-cargo-generator.py" \
|
|
||||||
"$REPO/src-tauri/Cargo.lock" \
|
|
||||||
-o "$REPO/packaging/cargo-sources.json"
|
|
||||||
echo "Done"
|
|
||||||
|
|
||||||
echo "── Building flatpak ──"
|
|
||||||
rm -rf "$REPO/build-dir" "$REPO/repo"
|
|
||||||
flatpak-builder \
|
|
||||||
--repo="$REPO/repo" \
|
|
||||||
--force-clean \
|
|
||||||
"$REPO/build-dir" \
|
|
||||||
"$MANIFEST"
|
|
||||||
flatpak build-bundle "$REPO/repo" "$REPO/moku.flatpak" dev.moku.app
|
|
||||||
rm -rf "$REPO/build-dir" "$REPO/repo"
|
|
||||||
echo "moku.flatpak created"
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "Done — v$VERSION"
|
|
||||||
echo " -> $REPO/moku.flatpak"
|
|
||||||
echo ""
|
|
||||||
echo "After pushing the tag, run:"
|
|
||||||
echo " nix run .#pkgbuild-bump -- $VERSION"
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
|
|
||||||
pkgbuildBumpScript = pkgs.writeShellApplication {
|
|
||||||
name = "moku-pkgbuild-bump";
|
|
||||||
runtimeInputs = with pkgs; [ gnused curl coreutils git ];
|
|
||||||
text = ''
|
|
||||||
[[ $# -lt 1 ]] && { echo "Usage: nix run .#pkgbuild-bump -- <version>"; exit 1; }
|
|
||||||
VERSION="$1"
|
|
||||||
REPO="$(git rev-parse --show-toplevel)"
|
|
||||||
PKGBUILD="$REPO/PKGBUILD"
|
|
||||||
[[ -f "$PKGBUILD" ]] || { echo "PKGBUILD not found"; exit 1; }
|
|
||||||
|
|
||||||
TARBALL_URL="https://github.com/Youwes09/Moku/archive/refs/tags/v$VERSION.tar.gz"
|
|
||||||
echo "Fetching tarball sha256..."
|
|
||||||
TARBALL_SHA=$(curl -fsSL "$TARBALL_URL" | sha256sum | awk '{print $1}')
|
|
||||||
|
|
||||||
sed -i "s/^pkgver=.*/pkgver=$VERSION/" "$PKGBUILD"
|
|
||||||
sed -i "s/^pkgrel=.*/pkgrel=1/" "$PKGBUILD"
|
|
||||||
sed -i "s/\(sha256sums=('\)[0-9a-f]\{64\}/\1$TARBALL_SHA/" "$PKGBUILD"
|
|
||||||
|
|
||||||
grep -q "$TARBALL_SHA" "$PKGBUILD" \
|
|
||||||
|| { echo "ERROR: sha256 replacement failed"; exit 1; }
|
|
||||||
|
|
||||||
echo "PKGBUILD -> $VERSION ($TARBALL_SHA)"
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
|
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
|
packages = {
|
||||||
|
inherit moku suwayomiServer;
|
||||||
|
default = moku;
|
||||||
|
};
|
||||||
|
|
||||||
apps = {
|
apps = {
|
||||||
default = { type = "app"; program = "${moku}/bin/moku"; };
|
default = { type = "app"; program = "${moku}/bin/moku"; };
|
||||||
moku = { type = "app"; program = "${moku}/bin/moku"; };
|
moku = { type = "app"; program = "${moku}/bin/moku"; };
|
||||||
bump = { type = "app"; program = "${bumpScript}/bin/moku-bump"; };
|
bump = { type = "app"; program = "${scripts.bump}/bin/moku-bump"; };
|
||||||
flatpak = { type = "app"; program = "${flatpakScript}/bin/moku-flatpak"; };
|
update = { type = "app"; program = "${scripts.update}/bin/moku-update"; };
|
||||||
pkgbuild-bump = { type = "app"; program = "${pkgbuildBumpScript}/bin/moku-pkgbuild-bump"; };
|
flatpak = { type = "app"; program = "${scripts.flatpak}/bin/moku-flatpak"; };
|
||||||
};
|
tunnel = { type = "app"; program = "${scripts.tunnel}/bin/moku-tunnel"; };
|
||||||
|
|
||||||
packages = {
|
|
||||||
inherit moku frontend;
|
|
||||||
default = moku;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
devShells.default = pkgs.mkShell {
|
devShells.default = pkgs.mkShell {
|
||||||
@@ -286,20 +109,27 @@ EOF
|
|||||||
wrapGAppsHook3
|
wrapGAppsHook3
|
||||||
nodejs_22
|
nodejs_22
|
||||||
pnpm
|
pnpm
|
||||||
suwayomi-server
|
suwayomiServer
|
||||||
|
cloudflared
|
||||||
xdg-utils
|
xdg-utils
|
||||||
|
(python3.withPackages (ps: [
|
||||||
|
ps.aiohttp
|
||||||
|
ps.tomlkit
|
||||||
|
]))
|
||||||
];
|
];
|
||||||
shellHook = ''
|
shellHook = ''
|
||||||
export NO_STRIP=true
|
export NO_STRIP=true
|
||||||
export PKG_CONFIG_PATH="${pkgs.openssl.dev}/lib/pkgconfig''${PKG_CONFIG_PATH:+:$PKG_CONFIG_PATH}"
|
export PKG_CONFIG_PATH="${pkgs.openssl.dev}/lib/pkgconfig''${PKG_CONFIG_PATH:+:$PKG_CONFIG_PATH}"
|
||||||
export XDG_DATA_DIRS="${pkgs.gsettings-desktop-schemas}/share/gsettings-schemas/${pkgs.gsettings-desktop-schemas.name}:${pkgs.gtk3}/share/gsettings-schemas/${pkgs.gtk3.name}''${XDG_DATA_DIRS:+:$XDG_DATA_DIRS}"
|
export XDG_DATA_DIRS="${pkgs.gsettings-desktop-schemas}/share/gsettings-schemas/${pkgs.gsettings-desktop-schemas.name}:${pkgs.gtk3}/share/gsettings-schemas/${pkgs.gtk3.name}''${XDG_DATA_DIRS:+:$XDG_DATA_DIRS}"
|
||||||
|
export LD_LIBRARY_PATH="${pkgs.lib.makeLibraryPath runtimeLibs}''${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
|
||||||
|
|
||||||
echo "Moku dev shell — pnpm install && pnpm tauri:dev"
|
echo "Moku dev shell — pnpm install && pnpm tauri:dev"
|
||||||
echo ""
|
echo ""
|
||||||
echo "Release:"
|
echo " nix run .#bump -- <ver> bump version + rebuild frontend"
|
||||||
echo " nix run .#bump -- <ver> bump versions only"
|
echo " git commit && git tag && git push"
|
||||||
echo " nix run .#flatpak -- <ver> full flatpak build"
|
echo " nix run .#update -- <ver> fetch hashes + patch all configs"
|
||||||
echo " nix run .#pkgbuild-bump -- <ver> patch PKGBUILD (after tag push)"
|
echo " nix run .#flatpak build flatpak bundle"
|
||||||
|
echo " nix run .#tunnel -- [port] expose local server via cloudflare"
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
app-id: dev.moku.app
|
app-id: io.github.moku_project.Moku
|
||||||
runtime: org.gnome.Platform
|
runtime: org.gnome.Platform
|
||||||
runtime-version: '48'
|
runtime-version: '48'
|
||||||
sdk: org.gnome.Sdk
|
sdk: org.gnome.Sdk
|
||||||
@@ -9,16 +9,22 @@ separate-locales: false
|
|||||||
|
|
||||||
finish-args:
|
finish-args:
|
||||||
- --socket=wayland
|
- --socket=wayland
|
||||||
- --socket=x11
|
|
||||||
- --socket=fallback-x11
|
- --socket=fallback-x11
|
||||||
- --share=ipc
|
- --share=ipc
|
||||||
- --device=dri
|
- --device=dri
|
||||||
- --share=network
|
- --share=network
|
||||||
- --socket=session-bus
|
|
||||||
- --socket=system-bus
|
- --talk-name=org.freedesktop.Notifications
|
||||||
- --filesystem=home
|
- --talk-name=org.freedesktop.portal.Desktop
|
||||||
|
- --talk-name=org.freedesktop.portal.FileTransfer
|
||||||
|
|
||||||
|
- --talk-name=org.kde.StatusNotifierWatcher
|
||||||
|
- --talk-name=com.canonical.AppMenu.Registrar
|
||||||
|
- --talk-name=com.canonical.indicator.application
|
||||||
|
|
||||||
|
- --filesystem=xdg-run/discord-ipc-0:ro
|
||||||
- --filesystem=xdg-data/moku:create
|
- --filesystem=xdg-data/moku:create
|
||||||
- --talk-name=org.freedesktop.Flatpak
|
- --filesystem=xdg-download
|
||||||
|
|
||||||
build-options:
|
build-options:
|
||||||
append-path: /usr/lib/sdk/rust-stable/bin
|
append-path: /usr/lib/sdk/rust-stable/bin
|
||||||
@@ -26,6 +32,83 @@ build-options:
|
|||||||
CARGO_HOME: /run/build/moku/cargo
|
CARGO_HOME: /run/build/moku/cargo
|
||||||
|
|
||||||
modules:
|
modules:
|
||||||
|
- name: intltool
|
||||||
|
buildsystem: autotools
|
||||||
|
sources:
|
||||||
|
- type: archive
|
||||||
|
url: https://launchpad.net/intltool/trunk/0.51.0/+download/intltool-0.51.0.tar.gz
|
||||||
|
sha256: 67c74d94196b153b774ab9f89b2fa6c6ba79352407037c8c14d5aeb334e959cd
|
||||||
|
|
||||||
|
- name: libdbusmenu
|
||||||
|
buildsystem: autotools
|
||||||
|
build-options:
|
||||||
|
cflags: -Wno-error
|
||||||
|
env:
|
||||||
|
HAVE_VALGRIND_FALSE: '#'
|
||||||
|
HAVE_VALGRIND_TRUE: ''
|
||||||
|
config-opts:
|
||||||
|
- --with-gtk=3
|
||||||
|
- --disable-static
|
||||||
|
- --disable-dumper
|
||||||
|
- --disable-tests
|
||||||
|
- --disable-gtk-doc
|
||||||
|
- --disable-vala
|
||||||
|
- --disable-introspection
|
||||||
|
cleanup:
|
||||||
|
- /include
|
||||||
|
- /libexec
|
||||||
|
- /lib/pkgconfig
|
||||||
|
- /lib/*.la
|
||||||
|
- /share/doc
|
||||||
|
- /share/libdbusmenu
|
||||||
|
- /share/gtk-doc
|
||||||
|
- /share/gir-1.0
|
||||||
|
sources:
|
||||||
|
- type: archive
|
||||||
|
url: https://launchpad.net/libdbusmenu/16.04/16.04.0/+download/libdbusmenu-16.04.0.tar.gz
|
||||||
|
sha256: b9cc4a2acd74509435892823607d966d424bd9ad5d0b00938f27240a1bfa878a
|
||||||
|
|
||||||
|
- name: libayatana-ido
|
||||||
|
buildsystem: cmake-ninja
|
||||||
|
config-opts:
|
||||||
|
- -DENABLE_TESTS=OFF
|
||||||
|
- -DGSETTINGS_COMPILE=OFF
|
||||||
|
sources:
|
||||||
|
- type: git
|
||||||
|
url: https://github.com/AyatanaIndicators/ayatana-ido.git
|
||||||
|
tag: 0.10.3
|
||||||
|
|
||||||
|
- name: libayatana-indicator
|
||||||
|
buildsystem: cmake-ninja
|
||||||
|
build-options:
|
||||||
|
env:
|
||||||
|
PKG_CONFIG_PATH: /app/lib/pkgconfig:/app/share/pkgconfig
|
||||||
|
config-opts:
|
||||||
|
- -DENABLE_TESTS=OFF
|
||||||
|
- -DGSETTINGS_COMPILE=OFF
|
||||||
|
sources:
|
||||||
|
- type: git
|
||||||
|
url: https://github.com/AyatanaIndicators/libayatana-indicator.git
|
||||||
|
tag: 0.9.4
|
||||||
|
|
||||||
|
- name: libayatana-appindicator
|
||||||
|
buildsystem: cmake-ninja
|
||||||
|
build-options:
|
||||||
|
env:
|
||||||
|
PKG_CONFIG_PATH: /app/lib/pkgconfig:/app/share/pkgconfig
|
||||||
|
config-opts:
|
||||||
|
- -DENABLE_TESTS=OFF
|
||||||
|
- -DENABLE_BINDINGS_MONO=OFF
|
||||||
|
- -DENABLE_BINDINGS_VALA=OFF
|
||||||
|
- -DGSETTINGS_COMPILE=OFF
|
||||||
|
sources:
|
||||||
|
- type: git
|
||||||
|
url: https://github.com/AyatanaIndicators/libayatana-appindicator.git
|
||||||
|
tag: 0.5.93
|
||||||
|
- type: shell
|
||||||
|
commands:
|
||||||
|
- sed -i '/add_subdirectory(docs)/d' CMakeLists.txt
|
||||||
|
|
||||||
- name: openjdk
|
- name: openjdk
|
||||||
buildsystem: simple
|
buildsystem: simple
|
||||||
build-commands:
|
build-commands:
|
||||||
@@ -33,13 +116,10 @@ modules:
|
|||||||
- tar -xf jdk.tar.gz -C /app/jre --strip-components=1
|
- tar -xf jdk.tar.gz -C /app/jre --strip-components=1
|
||||||
sources:
|
sources:
|
||||||
- type: file
|
- type: file
|
||||||
url: https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21.0.3%2B9/OpenJDK21U-jre_x64_linux_hotspot_21.0.3_9.tar.gz
|
url: https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21.0.5%2B11/OpenJDK21U-jre_x64_linux_hotspot_21.0.5_11.tar.gz
|
||||||
sha256: f1af100c4afca2035f446967323230150cfe5872b5a664d98c86963e5c066e0d
|
sha256: 553dda64b3b1c3c16f8afe402377ffebe64fb4a1721a46ed426a91fd18185e62
|
||||||
dest-filename: jdk.tar.gz
|
dest-filename: jdk.tar.gz
|
||||||
|
|
||||||
# catch_abort.so — intercepts SIGTRAP/SIGILL from KCEF's CEF subprocess and
|
|
||||||
# exits just that thread instead of killing the whole JVM. Official Suwayomi
|
|
||||||
# fix for headless environments. Source inlined to avoid upstream drift.
|
|
||||||
- name: catch-abort
|
- name: catch-abort
|
||||||
buildsystem: simple
|
buildsystem: simple
|
||||||
build-commands:
|
build-commands:
|
||||||
@@ -49,9 +129,6 @@ modules:
|
|||||||
- type: inline
|
- type: inline
|
||||||
dest-filename: catch_abort.c
|
dest-filename: catch_abort.c
|
||||||
contents: |
|
contents: |
|
||||||
// Linux only:
|
|
||||||
// Attempts to catch SIGTRAP and exit the thread instead of bringing down the whole process
|
|
||||||
|
|
||||||
#define _GNU_SOURCE
|
#define _GNU_SOURCE
|
||||||
#include <stdio.h>
|
#include <stdio.h>
|
||||||
#include <dlfcn.h>
|
#include <dlfcn.h>
|
||||||
@@ -92,12 +169,12 @@ modules:
|
|||||||
cat > /app/tachidesk/default-conf/server.conf << 'EOF'
|
cat > /app/tachidesk/default-conf/server.conf << 'EOF'
|
||||||
server.ip = "127.0.0.1"
|
server.ip = "127.0.0.1"
|
||||||
server.port = 4567
|
server.port = 4567
|
||||||
server.webUIEnabled = false
|
server.webUIEnabled = true
|
||||||
server.initialOpenInBrowserEnabled = false
|
server.initialOpenInBrowserEnabled = false
|
||||||
server.systemTrayEnabled = false
|
server.systemTrayEnabled = false
|
||||||
server.webUIInterface = "browser"
|
server.webUIInterface = "browser"
|
||||||
server.webUIFlavor = "WebUI"
|
server.webUIFlavor = "WebUI"
|
||||||
server.webUIChannel = "stable"
|
server.webUIChannel = "PREVIEW"
|
||||||
server.electronPath = ""
|
server.electronPath = ""
|
||||||
server.debugLogsEnabled = false
|
server.debugLogsEnabled = false
|
||||||
server.downloadAsCbz = true
|
server.downloadAsCbz = true
|
||||||
@@ -111,24 +188,20 @@ modules:
|
|||||||
cat > /app/bin/tachidesk-server << 'EOF'
|
cat > /app/bin/tachidesk-server << 'EOF'
|
||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
|
|
||||||
DATA_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/moku/tachidesk"
|
DATA_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/Tachidesk"
|
||||||
mkdir -p "$DATA_DIR"
|
mkdir -p "$DATA_DIR"
|
||||||
|
|
||||||
# Seed conf on first run
|
|
||||||
if [ ! -f "$DATA_DIR/server.conf" ]; then
|
if [ ! -f "$DATA_DIR/server.conf" ]; then
|
||||||
cp /app/tachidesk/default-conf/server.conf "$DATA_DIR/server.conf"
|
cp /app/tachidesk/default-conf/server.conf "$DATA_DIR/server.conf"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Force-patch the three keys that cause JCEF/GUI crashes every launch.
|
|
||||||
# Suwayomi ignores -D JVM flags when a conf file exists on disk.
|
|
||||||
sed -i \
|
sed -i \
|
||||||
-e 's|server\.webUIEnabled.*|server.webUIEnabled = false|' \
|
-e 's|server\.webUIEnabled.*|server.webUIEnabled = false|' \
|
||||||
-e 's|server\.initialOpenInBrowserEnabled.*|server.initialOpenInBrowserEnabled = false|' \
|
-e 's|server\.initialOpenInBrowserEnabled.*|server.initialOpenInBrowserEnabled = false|' \
|
||||||
-e 's|server\.systemTrayEnabled.*|server.systemTrayEnabled = false|' \
|
-e 's|server\.systemTrayEnabled.*|server.systemTrayEnabled = false|' \
|
||||||
"$DATA_DIR/server.conf"
|
"$DATA_DIR/server.conf"
|
||||||
|
|
||||||
# Append keys if absent
|
grep -q 'server\.webUIEnabled' "$DATA_DIR/server.conf" || echo 'server.webUIEnabled = true' >> "$DATA_DIR/server.conf"
|
||||||
grep -q 'server\.webUIEnabled' "$DATA_DIR/server.conf" || echo 'server.webUIEnabled = false' >> "$DATA_DIR/server.conf"
|
|
||||||
grep -q 'server\.initialOpenInBrowserEnabled' "$DATA_DIR/server.conf" || echo 'server.initialOpenInBrowserEnabled = false' >> "$DATA_DIR/server.conf"
|
grep -q 'server\.initialOpenInBrowserEnabled' "$DATA_DIR/server.conf" || echo 'server.initialOpenInBrowserEnabled = false' >> "$DATA_DIR/server.conf"
|
||||||
grep -q 'server\.systemTrayEnabled' "$DATA_DIR/server.conf" || echo 'server.systemTrayEnabled = false' >> "$DATA_DIR/server.conf"
|
grep -q 'server\.systemTrayEnabled' "$DATA_DIR/server.conf" || echo 'server.systemTrayEnabled = false' >> "$DATA_DIR/server.conf"
|
||||||
|
|
||||||
@@ -138,8 +211,6 @@ modules:
|
|||||||
export _JAVA_OPTIONS="-Djava.awt.headless=true"
|
export _JAVA_OPTIONS="-Djava.awt.headless=true"
|
||||||
export JAVA_TOOL_OPTIONS="-Djava.awt.headless=true"
|
export JAVA_TOOL_OPTIONS="-Djava.awt.headless=true"
|
||||||
|
|
||||||
# Intercepts SIGTRAP/SIGILL from KCEF's CEF subprocess, exits just
|
|
||||||
# that thread instead of crashing the whole JVM process.
|
|
||||||
export LD_PRELOAD="/app/lib/catch_abort.so"
|
export LD_PRELOAD="/app/lib/catch_abort.so"
|
||||||
|
|
||||||
exec /app/jre/bin/java \
|
exec /app/jre/bin/java \
|
||||||
@@ -155,8 +226,8 @@ modules:
|
|||||||
|
|
||||||
sources:
|
sources:
|
||||||
- type: file
|
- type: file
|
||||||
url: https://github.com/Suwayomi/Suwayomi-Server/releases/download/v2.1.1867/suwayomi-server-v2.1.1867.jar
|
url: https://github.com/Suwayomi/Suwayomi-Server-preview/releases/download/v2.2.2196/Suwayomi-Server-v2.2.2196.jar
|
||||||
sha256: 51e307c2581e4e1a002991ab3e3a77503c8b074c42695987a984a7382d0ac5af
|
sha256: 8e7244c269456661a87705f746f0d87275770aa976bab7c6920e4d513e97c3f6
|
||||||
dest-filename: Suwayomi-Server.jar
|
dest-filename: Suwayomi-Server.jar
|
||||||
|
|
||||||
- name: moku
|
- name: moku
|
||||||
@@ -166,22 +237,24 @@ modules:
|
|||||||
CARGO_HOME: /run/build/moku/cargo
|
CARGO_HOME: /run/build/moku/cargo
|
||||||
XDG_DATA_HOME: /run/build/moku/xdg-data
|
XDG_DATA_HOME: /run/build/moku/xdg-data
|
||||||
TAURI_SKIP_DEVSERVER_CHECK: 'true'
|
TAURI_SKIP_DEVSERVER_CHECK: 'true'
|
||||||
PKG_CONFIG_PATH: /usr/lib/pkgconfig:/usr/share/pkgconfig
|
PKG_CONFIG_PATH: /usr/lib/pkgconfig:/usr/share/pkgconfig:/app/lib/pkgconfig
|
||||||
build-commands:
|
build-commands:
|
||||||
- tar -xzf frontend-dist.tar.gz
|
- tar -xzf frontend-dist.tar.gz
|
||||||
- . /usr/lib/sdk/rust-stable/enable.sh && PKG_CONFIG_PATH=/usr/lib/pkgconfig:/usr/share/pkgconfig cargo build --release --manifest-path src-tauri/Cargo.toml
|
- . /usr/lib/sdk/rust-stable/enable.sh && PKG_CONFIG_PATH=/usr/lib/pkgconfig:/usr/share/pkgconfig:/app/lib/pkgconfig cargo build --release --manifest-path src-tauri/Cargo.toml
|
||||||
- install -Dm755 src-tauri/target/release/moku /app/bin/moku
|
- install -Dm755 src-tauri/target/release/moku /app/bin/moku
|
||||||
- install -Dm644 packaging/dev.moku.app.desktop /app/share/applications/dev.moku.app.desktop
|
- install -Dm644 packaging/io.github.moku_project.Moku.desktop /app/share/applications/io.github.moku_project.Moku.desktop
|
||||||
- install -Dm644 src-tauri/icons/32x32.png /app/share/icons/hicolor/32x32/apps/dev.moku.app.png
|
- install -Dm644 src-tauri/icons/32x32.png /app/share/icons/hicolor/32x32/apps/io.github.moku_project.Moku.png
|
||||||
- install -Dm644 src-tauri/icons/128x128.png /app/share/icons/hicolor/128x128/apps/dev.moku.app.png
|
- install -Dm644 src-tauri/icons/128x128.png /app/share/icons/hicolor/128x128/apps/io.github.moku_project.Moku.png
|
||||||
- install -Dm644 src-tauri/icons/128x128@2x.png /app/share/icons/hicolor/256x256/apps/dev.moku.app.png
|
- install -Dm644 src-tauri/icons/128x128@2x.png /app/share/icons/hicolor/256x256/apps/io.github.moku_project.Moku.png
|
||||||
- install -Dm644 packaging/dev.moku.app.metainfo.xml /app/share/metainfo/dev.moku.app.metainfo.xml
|
- install -Dm644 packaging/io.github.moku_project.Moku.metainfo.xml /app/share/metainfo/io.github.moku_project.Moku.metainfo.xml
|
||||||
sources:
|
sources:
|
||||||
- type: dir
|
- type: git
|
||||||
path: .
|
url: https://github.com/moku-project/Moku.git
|
||||||
|
tag: v0.10.0
|
||||||
|
commit: 5c703bdba5f61cedea90a803a5f533e805070d59
|
||||||
- type: file
|
- type: file
|
||||||
path: packaging/frontend-dist.tar.gz
|
path: packaging/frontend-dist.tar.gz
|
||||||
sha256: 3ac5d822ac1840473333510b5e45220298702e6d1435e2cdd4b5c2f7195d764f
|
sha256: 676ec2273ffd9a69248849c5d51dc4d59a5d5b68fbba7a4fe7e7b572a5f25f14
|
||||||
- packaging/cargo-sources.json
|
- packaging/cargo-sources.json
|
||||||
- type: inline
|
- type: inline
|
||||||
dest: src-tauri/.cargo
|
dest: src-tauri/.cargo
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
{
|
||||||
|
lib,
|
||||||
|
pkgs,
|
||||||
|
rustToolchain,
|
||||||
|
runtimeLibs,
|
||||||
|
suwayomiServer,
|
||||||
|
version,
|
||||||
|
versions,
|
||||||
|
src,
|
||||||
|
appIcon,
|
||||||
|
}:
|
||||||
|
|
||||||
|
pkgs.stdenv.mkDerivation {
|
||||||
|
pname = "moku";
|
||||||
|
inherit version src;
|
||||||
|
|
||||||
|
nativeBuildInputs = with pkgs; [
|
||||||
|
rustToolchain
|
||||||
|
nodejs_22
|
||||||
|
pnpm
|
||||||
|
pnpmConfigHook
|
||||||
|
pkg-config
|
||||||
|
wrapGAppsHook3
|
||||||
|
rustPlatform.cargoSetupHook
|
||||||
|
];
|
||||||
|
|
||||||
|
buildInputs = runtimeLibs;
|
||||||
|
|
||||||
|
pnpmDeps = pkgs.fetchPnpmDeps {
|
||||||
|
pname = "moku";
|
||||||
|
inherit version src;
|
||||||
|
fetcherVersion = 3;
|
||||||
|
hash = versions.frontend.pnpmHash;
|
||||||
|
};
|
||||||
|
|
||||||
|
cargoDeps = pkgs.rustPlatform.importCargoLock {
|
||||||
|
lockFile = ../src-tauri/Cargo.lock;
|
||||||
|
outputHashes = {
|
||||||
|
"tauri-plugin-discord-rpc-0.1.0" = versions.gitDeps.tauri-plugin-discord-rpc;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
env = {
|
||||||
|
PKG_CONFIG_PATH = "${pkgs.openssl.dev}/lib/pkgconfig";
|
||||||
|
TAURI_SKIP_DEVSERVER_CHECK = "true";
|
||||||
|
cargoRoot = "src-tauri";
|
||||||
|
};
|
||||||
|
|
||||||
|
buildPhase = ''
|
||||||
|
export HOME=$(mktemp -d)
|
||||||
|
pnpm tauri:build
|
||||||
|
'';
|
||||||
|
|
||||||
|
installPhase = ''
|
||||||
|
install -Dm755 src-tauri/target/release/moku $out/bin/moku
|
||||||
|
|
||||||
|
mkdir -p "$out/share/applications"
|
||||||
|
cat > "$out/share/applications/moku.desktop" << EOF2
|
||||||
|
[Desktop Entry]
|
||||||
|
Version=1.0
|
||||||
|
Type=Application
|
||||||
|
Name=Moku
|
||||||
|
Comment=Manga reader frontend for Suwayomi
|
||||||
|
Exec=$out/bin/moku
|
||||||
|
Icon=moku
|
||||||
|
Terminal=false
|
||||||
|
Categories=Graphics;Viewer;
|
||||||
|
Keywords=manga;comic;reader;suwayomi;
|
||||||
|
StartupWMClass=moku
|
||||||
|
EOF2
|
||||||
|
|
||||||
|
for size in 32x32 128x128 256x256 512x512; do
|
||||||
|
f="src-tauri/icons/$size.png"
|
||||||
|
[ -f "$f" ] && install -Dm644 "$f" \
|
||||||
|
"$out/share/icons/hicolor/$size/apps/moku.png"
|
||||||
|
done
|
||||||
|
|
||||||
|
for size in 128x128 256x256; do
|
||||||
|
f="src-tauri/icons/''${size}@2x.png"
|
||||||
|
[ -f "$f" ] && install -Dm644 "$f" \
|
||||||
|
"$out/share/icons/hicolor/''${size}@2/apps/moku.png"
|
||||||
|
done
|
||||||
|
|
||||||
|
install -Dm644 "${appIcon}" \
|
||||||
|
"$out/share/icons/hicolor/scalable/apps/moku.svg"
|
||||||
|
|
||||||
|
wrapProgram $out/bin/moku \
|
||||||
|
--prefix XDG_DATA_DIRS : "${lib.makeSearchPath "share/gsettings-schemas" [
|
||||||
|
pkgs.gsettings-desktop-schemas
|
||||||
|
pkgs.gtk3
|
||||||
|
]}" \
|
||||||
|
--prefix LD_LIBRARY_PATH : "${lib.makeLibraryPath runtimeLibs}" \
|
||||||
|
--prefix PATH : "${lib.makeBinPath [ suwayomiServer ]}" \
|
||||||
|
--set GDK_BACKEND wayland \
|
||||||
|
--set WEBKIT_DISABLE_SANDBOX_THIS_IS_DANGEROUS 1
|
||||||
|
'';
|
||||||
|
|
||||||
|
meta.mainProgram = "moku";
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
{ pkgs, rustToolchain, version, versions }:
|
||||||
|
|
||||||
|
{
|
||||||
|
bump = pkgs.writeShellApplication {
|
||||||
|
name = "moku-bump";
|
||||||
|
runtimeInputs = with pkgs; [
|
||||||
|
gnused
|
||||||
|
coreutils
|
||||||
|
git
|
||||||
|
xxd
|
||||||
|
rustToolchain
|
||||||
|
nodejs_22
|
||||||
|
pnpm
|
||||||
|
(python3.withPackages (ps: [ ps.aiohttp ps.tomlkit ]))
|
||||||
|
];
|
||||||
|
text = ''
|
||||||
|
[[ $# -lt 1 ]] && { echo "Usage: nix run .#bump -- <version>"; exit 1; }
|
||||||
|
VERSION="$1"
|
||||||
|
REPO="$(git rev-parse --show-toplevel)"
|
||||||
|
|
||||||
|
sed -i "s/moku = \"[^\"]*\"/moku = \"$VERSION\"/" "$REPO/nix/versions.nix"
|
||||||
|
sed -i "s/\"version\": \"[^\"]*\"/\"version\": \"$VERSION\"/" "$REPO/src-tauri/tauri.conf.json"
|
||||||
|
sed -i "0,/^version = \"[^\"]*\"/s//version = \"$VERSION\"/" "$REPO/src-tauri/Cargo.toml"
|
||||||
|
sed -i "s/^pkgver=.*/pkgver=$VERSION/" "$REPO/PKGBUILD"
|
||||||
|
sed -i "s/^pkgrel=.*/pkgrel=1/" "$REPO/PKGBUILD"
|
||||||
|
|
||||||
|
(cd "$REPO/src-tauri" && cargo generate-lockfile)
|
||||||
|
|
||||||
|
cd "$REPO"
|
||||||
|
pnpm install --frozen-lockfile
|
||||||
|
pnpm build:static
|
||||||
|
|
||||||
|
tar -czf "$REPO/packaging/frontend-dist.tar.gz" -C "$REPO" dist
|
||||||
|
FRONTEND_SHA_HEX=$(sha256sum "$REPO/packaging/frontend-dist.tar.gz" | awk '{print $1}')
|
||||||
|
FRONTEND_SHA_SRI=$(echo "$FRONTEND_SHA_HEX" | xxd -r -p | base64 -w0 | sed 's/^/sha256-/')
|
||||||
|
|
||||||
|
sed -i "s|distHash = \"[^\"]*\"|distHash = \"$FRONTEND_SHA_HEX\"|" "$REPO/nix/versions.nix"
|
||||||
|
sed -i "s|distHashSri = \"[^\"]*\"|distHashSri = \"$FRONTEND_SHA_SRI\"|" "$REPO/nix/versions.nix"
|
||||||
|
|
||||||
|
MANIFEST="$REPO/io.github.moku_project.Moku.yml"
|
||||||
|
sed -i "s/tag: v[^[:space:]]*/tag: v$VERSION/" "$MANIFEST"
|
||||||
|
python3 - "$MANIFEST" "$FRONTEND_SHA_HEX" <<'PYEOF'
|
||||||
|
import re, sys
|
||||||
|
path, sha = sys.argv[1], sys.argv[2]
|
||||||
|
text = open(path).read()
|
||||||
|
updated, n = re.subn(
|
||||||
|
r'(path:\s*packaging/frontend-dist\.tar\.gz\s*\n\s*sha256:\s*)[0-9a-f]+',
|
||||||
|
r'\g<1>' + sha, text)
|
||||||
|
if n == 0:
|
||||||
|
sys.exit("ERROR: could not find frontend-dist sha256 in manifest")
|
||||||
|
open(path, 'w').write(updated)
|
||||||
|
PYEOF
|
||||||
|
|
||||||
|
python3 "$REPO/packaging/flatpak-cargo-generator.py" \
|
||||||
|
"$REPO/src-tauri/Cargo.lock" \
|
||||||
|
-o "$REPO/packaging/cargo-sources.json"
|
||||||
|
|
||||||
|
echo "Bumped to v$VERSION — commit, tag, push, then: nix run .#update -- $VERSION"
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
update = pkgs.writeShellApplication {
|
||||||
|
name = "moku-update";
|
||||||
|
runtimeInputs = with pkgs; [ gnused coreutils git curl nix xxd ];
|
||||||
|
text = ''
|
||||||
|
REPO="$(git rev-parse --show-toplevel)"
|
||||||
|
VERSIONS="$REPO/nix/versions.nix"
|
||||||
|
MANIFEST="$REPO/io.github.moku_project.Moku.yml"
|
||||||
|
PKGBUILD="$REPO/PKGBUILD"
|
||||||
|
|
||||||
|
if [[ $# -ge 1 ]]; then
|
||||||
|
VERSION="$1"
|
||||||
|
else
|
||||||
|
VERSION=$(grep 'moku = "' "$VERSIONS" | head -1 | sed 's/.*"\(.*\)".*/\1/')
|
||||||
|
fi
|
||||||
|
|
||||||
|
COMMIT=$(git ls-remote https://github.com/moku-project/Moku.git "refs/tags/v$VERSION" | awk '{print $1}')
|
||||||
|
[[ -z "$COMMIT" ]] && { echo "ERROR: tag v$VERSION not found on remote"; exit 1; }
|
||||||
|
sed -i "s/gitCommit = \"[^\"]*\"/gitCommit = \"$COMMIT\"/" "$VERSIONS"
|
||||||
|
sed -i "s/commit: [0-9a-f]\{40\}/commit: $COMMIT/" "$MANIFEST"
|
||||||
|
|
||||||
|
TARBALL_URL="https://github.com/moku-project/Moku/archive/refs/tags/v$VERSION.tar.gz"
|
||||||
|
TARBALL_SHA=$(curl -fsSL "$TARBALL_URL" | sha256sum | awk '{print $1}')
|
||||||
|
sed -i "s/tarballHash = \"[^\"]*\"/tarballHash = \"$TARBALL_SHA\"/" "$VERSIONS"
|
||||||
|
sed -i "/sha256sums=/,/)/{ 0,/'/s/'[^']*'/'$TARBALL_SHA'/ }" "$PKGBUILD"
|
||||||
|
|
||||||
|
if [[ $# -ge 2 ]]; then
|
||||||
|
SUWA_VER="$2"
|
||||||
|
BASE="https://github.com/Suwayomi/Suwayomi-Server-preview/releases/download/v''${SUWA_VER}"
|
||||||
|
|
||||||
|
echo "Fetching Suwayomi v''${SUWA_VER} hashes (5 downloads)..."
|
||||||
|
|
||||||
|
sha_of() { curl -fsSL "$1" | sha256sum | awk '{print $1}'; }
|
||||||
|
to_sri() { echo "$1" | xxd -r -p | base64 -w0 | sed 's/^/sha256-/'; }
|
||||||
|
|
||||||
|
JAR_SHA=$(sha_of "''${BASE}/Suwayomi-Server-v''${SUWA_VER}.jar")
|
||||||
|
WIN_SHA=$(sha_of "''${BASE}/Suwayomi-Server-v''${SUWA_VER}-windows-x64.zip")
|
||||||
|
LINUX_SHA=$(sha_of "''${BASE}/Suwayomi-Server-v''${SUWA_VER}-linux-x64.tar.gz")
|
||||||
|
ARM64_SHA=$(sha_of "''${BASE}/Suwayomi-Server-v''${SUWA_VER}-macOS-arm64.tar.gz")
|
||||||
|
X64_SHA=$(sha_of "''${BASE}/Suwayomi-Server-v''${SUWA_VER}-macOS-x64.tar.gz")
|
||||||
|
|
||||||
|
JAR_SRI=$(to_sri "$JAR_SHA")
|
||||||
|
|
||||||
|
sed -i "s/version = \"[^\"]*\"/version = \"''${SUWA_VER}\"/" "$VERSIONS"
|
||||||
|
sed -i "s|hash = \"sha256-[^\"]*\"|hash = \"''${JAR_SRI}\"|" "$VERSIONS"
|
||||||
|
sed -i "s|windowsHash = \"[^\"]*\"|windowsHash = \"''${WIN_SHA}\"|" "$VERSIONS"
|
||||||
|
sed -i "s|linuxHash = \"[^\"]*\"|linuxHash = \"''${LINUX_SHA}\"|" "$VERSIONS"
|
||||||
|
sed -i "s|macosArm64Hash = \"[^\"]*\"|macosArm64Hash = \"''${ARM64_SHA}\"|" "$VERSIONS"
|
||||||
|
sed -i "s|macosX64Hash = \"[^\"]*\"|macosX64Hash = \"''${X64_SHA}\"|" "$VERSIONS"
|
||||||
|
|
||||||
|
sed -i "s|Suwayomi-Server-preview/releases/download/v[^/]*/|Suwayomi-Server-preview/releases/download/v''${SUWA_VER}/|" "$MANIFEST"
|
||||||
|
sed -i "s|Suwayomi-Server-v[0-9.]*\.jar|Suwayomi-Server-v''${SUWA_VER}.jar|g" "$MANIFEST"
|
||||||
|
python3 - "$MANIFEST" "$JAR_SHA" <<'PYEOF'
|
||||||
|
import re, sys
|
||||||
|
path, sha = sys.argv[1], sys.argv[2]
|
||||||
|
text = open(path).read()
|
||||||
|
updated, n = re.subn(
|
||||||
|
r'(dest-filename:\s*Suwayomi-Server\.jar\s*\n\s*sha256:\s*)[0-9a-f]+',
|
||||||
|
r'\g<1>' + sha, text)
|
||||||
|
if n == 0:
|
||||||
|
sys.exit("ERROR: could not find Suwayomi jar sha256 in manifest")
|
||||||
|
open(path, 'w').write(updated)
|
||||||
|
PYEOF
|
||||||
|
|
||||||
|
echo "Suwayomi hashes written."
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Done — versions.nix, flatpak manifest, and PKGBUILD patched for v$VERSION"
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
flatpak = pkgs.writeShellApplication {
|
||||||
|
name = "moku-flatpak";
|
||||||
|
runtimeInputs = with pkgs; [ coreutils git appstream flatpak-builder flatpak ];
|
||||||
|
text = ''
|
||||||
|
REPO="$(git rev-parse --show-toplevel)"
|
||||||
|
rm -rf "$REPO/build-dir" "$REPO/repo"
|
||||||
|
flatpak-builder \
|
||||||
|
--repo="$REPO/repo" \
|
||||||
|
--force-clean \
|
||||||
|
"$REPO/build-dir" \
|
||||||
|
"$REPO/io.github.moku_project.Moku.yml"
|
||||||
|
flatpak build-bundle "$REPO/repo" "$REPO/moku.flatpak" io.github.moku_project.Moku
|
||||||
|
rm -rf "$REPO/build-dir" "$REPO/repo"
|
||||||
|
echo "moku.flatpak created"
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
tunnel = pkgs.writeShellApplication {
|
||||||
|
name = "moku-tunnel";
|
||||||
|
runtimeInputs = with pkgs; [ cloudflared ];
|
||||||
|
text = ''
|
||||||
|
PORT="''${1:-4567}"
|
||||||
|
cloudflared tunnel --url "http://localhost:$PORT"
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
{
|
||||||
|
lib,
|
||||||
|
stdenvNoCC,
|
||||||
|
fetchurl,
|
||||||
|
makeWrapper,
|
||||||
|
jdk21_headless,
|
||||||
|
versions,
|
||||||
|
}:
|
||||||
|
let
|
||||||
|
jdk = jdk21_headless;
|
||||||
|
ver = versions.suwayomi;
|
||||||
|
in
|
||||||
|
stdenvNoCC.mkDerivation {
|
||||||
|
pname = "suwayomi-server";
|
||||||
|
version = ver.version;
|
||||||
|
|
||||||
|
src = fetchurl {
|
||||||
|
url = "https://github.com/Suwayomi/Suwayomi-Server-preview/releases/download/v${ver.version}/Suwayomi-Server-v${ver.version}.jar";
|
||||||
|
hash = ver.hash;
|
||||||
|
};
|
||||||
|
|
||||||
|
nativeBuildInputs = [ makeWrapper ];
|
||||||
|
|
||||||
|
dontUnpack = true;
|
||||||
|
|
||||||
|
buildPhase = ''
|
||||||
|
runHook preBuild
|
||||||
|
|
||||||
|
install -Dm644 $src $out/share/suwayomi-server/suwayomi-server.jar
|
||||||
|
|
||||||
|
makeWrapper ${jdk}/bin/java $out/bin/suwayomi-server \
|
||||||
|
--add-flags "-Dsuwayomi.tachidesk.config.server.initialOpenInBrowserEnabled=false" \
|
||||||
|
--add-flags "-jar $out/share/suwayomi-server/suwayomi-server.jar"
|
||||||
|
|
||||||
|
runHook postBuild
|
||||||
|
'';
|
||||||
|
|
||||||
|
meta = {
|
||||||
|
description = "Free and open source manga reader server that runs extensions built for Mihon (Tachiyomi)";
|
||||||
|
homepage = "https://github.com/Suwayomi/Suwayomi-Server";
|
||||||
|
downloadPage = "https://github.com/Suwayomi/Suwayomi-Server-preview/releases";
|
||||||
|
changelog = "https://github.com/Suwayomi/Suwayomi-Server-preview/releases/tag/v${ver.version}";
|
||||||
|
license = lib.licenses.mpl20;
|
||||||
|
platforms = jdk.meta.platforms;
|
||||||
|
sourceProvenance = [ lib.sourceTypes.binaryBytecode ];
|
||||||
|
mainProgram = "suwayomi-server";
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
moku = "0.10.0";
|
||||||
|
|
||||||
|
suwayomi = {
|
||||||
|
version = "2.2.2196";
|
||||||
|
hash = "sha256-jnJEwmlFZmGodwX3RvDYcnV3Cql2urfGkg5NUT6Xw/Y=";
|
||||||
|
windowsHash = "457ca4a64a57e0d274a87203d25e962103bcb456ee30ada3ea47328a3093329d";
|
||||||
|
linuxHash = "e13d63ceb7e2b15e83d0a78281e8c1c04ac4a833caa73e5a2b68fbaf0cb20c1f";
|
||||||
|
macosArm64Hash = "9e3dbebc7475707e8d11c56a473385c00b09bde0103d013bc1cb3d06c89e5c43";
|
||||||
|
macosX64Hash = "eadee02060b780a5febfb8dada2f89c7bd7db5905cfd20d47eaca02fcde8c9c5";
|
||||||
|
};
|
||||||
|
|
||||||
|
frontend = {
|
||||||
|
pnpmHash = "sha256-fBkNpQXEeGZNbrpx7+0xVYYtQ6dGvpgRflCGPoxvnVY=";
|
||||||
|
distHash = "7db288b4b54277aa82b6ec5b21fc31a1e71f8246c50a74777500083b806c1fa5";
|
||||||
|
distHashSri = "sha256-Z27CJz/9mmkkiEnF1R3E1ZpdW2j7unpP5+e1cqXyXxQ=";
|
||||||
|
};
|
||||||
|
|
||||||
|
gitDeps = {
|
||||||
|
tauri-plugin-discord-rpc = "sha256-xq0qyK2NrwSAFDhXo0vbvcygRD2/7uqBaLpqfpfxkrc=";
|
||||||
|
};
|
||||||
|
|
||||||
|
gitCommit = "239960683b6c7f1347e1798b0e179a8a46628728";
|
||||||
|
tarballHash = "589b389b356a48d54ad4022e68eaac165a1de654d0b98edec79ebbab2c4a1275";
|
||||||
|
}
|
||||||
@@ -1,28 +1,49 @@
|
|||||||
{
|
{
|
||||||
"name": "moku",
|
"name": "moku",
|
||||||
"version": "0.5.0",
|
"private": true,
|
||||||
|
"version": "0.9.4",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite dev",
|
||||||
"build": "vite build",
|
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"tauri": "tauri",
|
"prepare": "svelte-kit sync || echo ''",
|
||||||
"tauri:dev": "tauri dev --config src-tauri/tauri.dev.conf.json"
|
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||||
},
|
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||||
"dependencies": {
|
"build:static": "MOKU_TARGET=static vite build",
|
||||||
"@tauri-apps/api": "^2.0.0",
|
"build:node": "MOKU_TARGET=node vite build",
|
||||||
"@tauri-apps/plugin-os": "^2.3.2",
|
"build:android": "MOKU_TARGET=static vite build",
|
||||||
"@tauri-apps/plugin-shell": "^2.3.5",
|
"tauri:dev": "tauri dev --config src-tauri/tauri.dev.conf.json",
|
||||||
"clsx": "^2.1.1",
|
"tauri:build": "tauri build"
|
||||||
"phosphor-svelte": "^3.1.0",
|
|
||||||
"svelte-spa-router": "^4.0.1"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@sveltejs/vite-plugin-svelte": "^4.0.4",
|
"@sveltejs/adapter-node": "^5.5.4",
|
||||||
"@tauri-apps/cli": "^2.0.0",
|
"@sveltejs/adapter-static": "^3.0.10",
|
||||||
"svelte": "^5.0.0",
|
"@sveltejs/kit": "^2.62.0",
|
||||||
"svelte-check": "^3.0.0",
|
"@sveltejs/vite-plugin-svelte": "^7.1.2",
|
||||||
"typescript": "^5.0.0",
|
"@tauri-apps/cli": "^2.11.2",
|
||||||
"vite": "^5.0.0"
|
"@types/node": "^25.9.3",
|
||||||
|
"phosphor-svelte": "^3.1.0",
|
||||||
|
"svelte": "^5.56.1",
|
||||||
|
"svelte-check": "^4.5.0",
|
||||||
|
"typescript": "^6.0.3",
|
||||||
|
"vite": "^8.0.16"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@capacitor/app": "^8.1.0",
|
||||||
|
"@capacitor/browser": "^8.0.3",
|
||||||
|
"@capacitor/core": "^8.4.0",
|
||||||
|
"@capacitor/filesystem": "^8.1.2",
|
||||||
|
"@capacitor/preferences": "^8.0.1",
|
||||||
|
"@tauri-apps/api": "^2.11.0",
|
||||||
|
"@tauri-apps/plugin-dialog": "^2.7.1",
|
||||||
|
"@tauri-apps/plugin-fs": "^2.5.1",
|
||||||
|
"@tauri-apps/plugin-http": "^2.5.9",
|
||||||
|
"@tauri-apps/plugin-os": "^2.3.2",
|
||||||
|
"@tauri-apps/plugin-process": "^2.3.1",
|
||||||
|
"@tauri-apps/plugin-shell": "^2.3.5",
|
||||||
|
"@tauri-apps/plugin-store": "^2.4.3",
|
||||||
|
"capacitor-native-biometric": "^4.2.2",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"tauri-plugin-discord-rpc-api": "github:Youwes09/tauri-plugin-discord-rpc"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<component type="desktop-application">
|
|
||||||
<id>dev.moku.app</id>
|
|
||||||
<metadata_license>MIT</metadata_license>
|
|
||||||
<project_license>MIT</project_license>
|
|
||||||
|
|
||||||
<name>Moku</name>
|
|
||||||
<summary>Manga reader powered by Suwayomi</summary>
|
|
||||||
|
|
||||||
<description>
|
|
||||||
<p>
|
|
||||||
Moku is a desktop manga reader built on top of Suwayomi-Server (Tachidesk),
|
|
||||||
providing a clean native interface for browsing, reading, and managing your
|
|
||||||
manga library across hundreds of sources.
|
|
||||||
</p>
|
|
||||||
</description>
|
|
||||||
|
|
||||||
<launchable type="desktop-id">dev.moku.app.desktop</launchable>
|
|
||||||
|
|
||||||
<url type="homepage">https://github.com/shozikan/Moku</url>
|
|
||||||
<url type="bugtracker">https://github.com/shozikan/Moku/issues</url>
|
|
||||||
|
|
||||||
<provides>
|
|
||||||
<binary>moku</binary>
|
|
||||||
</provides>
|
|
||||||
|
|
||||||
<content_rating type="oars-1.1" />
|
|
||||||
|
|
||||||
<releases>
|
|
||||||
<release version="0.4.0" date="2025-03-22">
|
|
||||||
<description>
|
|
||||||
<p>Svelte rewrite with improved UI, bundled server, and cross-platform fixes.</p>
|
|
||||||
</description>
|
|
||||||
</release>
|
|
||||||
</releases>
|
|
||||||
</component>
|
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
Name=Moku
|
Name=Moku
|
||||||
Comment=Manga reader powered by Suwayomi
|
Comment=Manga reader powered by Suwayomi
|
||||||
Exec=moku
|
Exec=moku
|
||||||
Icon=dev.moku.app
|
Icon=io.github.moku_project.Moku
|
||||||
Terminal=false
|
Terminal=false
|
||||||
Type=Application
|
Type=Application
|
||||||
Categories=Graphics;Viewer;
|
Categories=Graphics;Viewer;
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<component type="desktop-application">
|
||||||
|
<id>io.github.moku_project.Moku</id>
|
||||||
|
<metadata_license>MIT</metadata_license>
|
||||||
|
<project_license>MIT</project_license>
|
||||||
|
|
||||||
|
<name>Moku</name>
|
||||||
|
<summary>Manga reader powered by Suwayomi</summary>
|
||||||
|
|
||||||
|
<description>
|
||||||
|
<p>
|
||||||
|
Moku is a desktop manga reader built on top of Suwayomi-Server (Tachidesk),
|
||||||
|
providing a clean native interface for browsing, reading, and managing your
|
||||||
|
manga library across hundreds of sources.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Features include library management, chapter tracking, extension support,
|
||||||
|
reading history, notifications, and Discord Rich Presence integration.
|
||||||
|
</p>
|
||||||
|
</description>
|
||||||
|
|
||||||
|
<launchable type="desktop-id">io.github.moku_project.Moku.desktop</launchable>
|
||||||
|
|
||||||
|
<url type="homepage">https://github.com/moku-project/Moku</url>
|
||||||
|
<url type="bugtracker">https://github.com/moku-project/Moku/issues</url>
|
||||||
|
|
||||||
|
<screenshots>
|
||||||
|
<screenshot type="default">
|
||||||
|
<image>https://raw.githubusercontent.com/moku-project/Moku/main/docs/screenshots/Moku-Home.png</image>
|
||||||
|
<caption>Home screen showing your manga library</caption>
|
||||||
|
</screenshot>
|
||||||
|
<screenshot>
|
||||||
|
<image>https://raw.githubusercontent.com/moku-project/Moku/main/docs/screenshots/Moku-Reader.png</image>
|
||||||
|
<caption>Built-in manga reader</caption>
|
||||||
|
</screenshot>
|
||||||
|
<screenshot>
|
||||||
|
<image>https://raw.githubusercontent.com/moku-project/Moku/main/docs/screenshots/Moku-Discover.png</image>
|
||||||
|
<caption>Discover new manga across hundreds of sources</caption>
|
||||||
|
</screenshot>
|
||||||
|
<screenshot>
|
||||||
|
<image>https://raw.githubusercontent.com/moku-project/Moku/main/docs/screenshots/Moku-Downloads.png</image>
|
||||||
|
<caption>Download manager</caption>
|
||||||
|
</screenshot>
|
||||||
|
<screenshot>
|
||||||
|
<image>https://raw.githubusercontent.com/moku-project/Moku/main/docs/screenshots/Moku-Settings.png</image>
|
||||||
|
<caption>Settings</caption>
|
||||||
|
</screenshot>
|
||||||
|
</screenshots>
|
||||||
|
|
||||||
|
<provides>
|
||||||
|
<binary>moku</binary>
|
||||||
|
</provides>
|
||||||
|
|
||||||
|
<content_rating type="oars-1.1" />
|
||||||
|
|
||||||
|
<releases>
|
||||||
|
<release version="0.9.0" date="2025-04-01">
|
||||||
|
<description>
|
||||||
|
<p>Latest release with improved stability and UI refinements.</p>
|
||||||
|
</description>
|
||||||
|
</release>
|
||||||
|
<release version="0.8.0" date="2025-04-01">
|
||||||
|
<description>
|
||||||
|
<p>Old release with improved stability and UI refinements.</p>
|
||||||
|
</description>
|
||||||
|
</release>
|
||||||
|
<release version="0.4.0" date="2025-03-22">
|
||||||
|
<description>
|
||||||
|
<p>Svelte rewrite with improved UI, bundled server, and cross-platform fixes.</p>
|
||||||
|
</description>
|
||||||
|
</release>
|
||||||
|
</releases>
|
||||||
|
</component>
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
onlyBuiltDependencies:
|
||||||
|
- esbuild
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "moku"
|
name = "moku"
|
||||||
version = "0.5.0"
|
version = "0.10.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[lib]
|
[lib]
|
||||||
@@ -15,17 +15,28 @@ path = "src/main.rs"
|
|||||||
tauri-build = { version = "2.0", features = [] }
|
tauri-build = { version = "2.0", features = [] }
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
tauri = { version = "2.0", features = [] }
|
tauri = { version = "2.0", features = ["tray-icon"] }
|
||||||
tauri-plugin-shell = "2"
|
tauri-plugin-shell = "2"
|
||||||
tauri-plugin-updater = "2"
|
|
||||||
tauri-plugin-process = "2"
|
tauri-plugin-process = "2"
|
||||||
tauri-plugin-http = "2"
|
tauri-plugin-http = "2"
|
||||||
|
tauri-plugin-dialog = "2"
|
||||||
|
tauri-plugin-os = "2.3.2"
|
||||||
|
tauri-plugin-store = "2"
|
||||||
|
tauri-plugin-discord-rpc = { git = "https://github.com/Youwes09/tauri-plugin-discord-rpc" }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
walkdir = "2"
|
walkdir = "2"
|
||||||
sysinfo = "0.32"
|
sysinfo = "0.32"
|
||||||
dirs = "5"
|
dirs = "5"
|
||||||
tauri-plugin-os = "2.3.2"
|
urlencoding = "2"
|
||||||
|
tokio = { version = "1", features = ["rt-multi-thread"] }
|
||||||
|
reqwest = { version = "0.12", features = ["blocking"] }
|
||||||
|
|
||||||
|
[target.'cfg(target_os = "windows")'.dependencies]
|
||||||
|
windows = { version = "0.58", features = [
|
||||||
|
"Security_Credentials_UI",
|
||||||
|
"Win32_UI_WindowsAndMessaging",
|
||||||
|
] }
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
codegen-units = 1
|
codegen-units = 1
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# — Suwayomi launcher for Linux AppImage/deb.
|
||||||
|
# Tauri resolves this via resolve_server_binary() in lib.rs, which looks for
|
||||||
|
# "suwayomi-launcher" or "suwayomi-launcher.sh" in the resource directory.
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# ── Locate our resource directory ─────────────────────────────────────────────
|
||||||
|
# In an AppImage: resources sit at <mountpoint>/resources/
|
||||||
|
# In a deb install: /usr/lib/moku/resources/ (Tauri's default)
|
||||||
|
# We resolve relative to this script's own location.
|
||||||
|
SELF="$0"
|
||||||
|
while [ -L "$SELF" ]; do
|
||||||
|
SELF="$(readlink "$SELF")"
|
||||||
|
done
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$SELF")" && pwd)"
|
||||||
|
|
||||||
|
# Tauri places resources one level up from the binary on Linux.
|
||||||
|
# Try a few candidates so this works in both AppImage and installed layouts.
|
||||||
|
find_resource() {
|
||||||
|
for candidate in \
|
||||||
|
"${SCRIPT_DIR}" \
|
||||||
|
"${SCRIPT_DIR}/../resources" \
|
||||||
|
"${SCRIPT_DIR}/resources"
|
||||||
|
do
|
||||||
|
if [ -f "${candidate}/Suwayomi-Server.jar" ]; then
|
||||||
|
echo "$(cd "$candidate" && pwd)"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
RESOURCE_DIR=$(find_resource) || {
|
||||||
|
echo "[launcher] ERROR: cannot locate Suwayomi-Server.jar relative to $SCRIPT_DIR" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
JAR="${RESOURCE_DIR}/Suwayomi-Server.jar"
|
||||||
|
JAVA="${RESOURCE_DIR}/jre/bin/java"
|
||||||
|
CATCH_ABORT="${RESOURCE_DIR}/catch_abort.so"
|
||||||
|
|
||||||
|
echo "[launcher] RESOURCE_DIR=$RESOURCE_DIR" >&2
|
||||||
|
echo "[launcher] JAVA=$JAVA" >&2
|
||||||
|
echo "[launcher] JAR=$JAR" >&2
|
||||||
|
|
||||||
|
if [ ! -x "$JAVA" ]; then
|
||||||
|
echo "[launcher] ERROR: java not executable at $JAVA" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ ! -f "$JAR" ]; then
|
||||||
|
echo "[launcher] ERROR: jar not found at $JAR" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Data directory ─────────────────────────────────────────────────────────────
|
||||||
|
DATA_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/Tachidesk"
|
||||||
|
mkdir -p "$DATA_DIR"
|
||||||
|
|
||||||
|
# ── Seed server.conf on first run ──────────────────────────────────────────────
|
||||||
|
if [ ! -f "$DATA_DIR/server.conf" ]; then
|
||||||
|
cat > "$DATA_DIR/server.conf" << 'EOF'
|
||||||
|
server.ip = "127.0.0.1"
|
||||||
|
server.port = 4567
|
||||||
|
server.webUIEnabled = true
|
||||||
|
server.initialOpenInBrowserEnabled = false
|
||||||
|
server.systemTrayEnabled = false
|
||||||
|
server.webUIInterface = "browser"
|
||||||
|
server.webUIFlavor = "WebUI"
|
||||||
|
server.webUIChannel = "PREVIEW"
|
||||||
|
server.electronPath = ""
|
||||||
|
server.debugLogsEnabled = false
|
||||||
|
server.downloadAsCbz = true
|
||||||
|
server.autoDownloadNewChapters = false
|
||||||
|
server.globalUpdateInterval = 12
|
||||||
|
server.maxSourcesInParallel = 6
|
||||||
|
server.extensionRepos = []
|
||||||
|
EOF
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Force-patch the three keys that cause JCEF/GUI crashes ────────────────────
|
||||||
|
sed -i \
|
||||||
|
-e 's|server\.webUIEnabled.*|server.webUIEnabled = true|' \
|
||||||
|
-e 's|server\.initialOpenInBrowserEnabled.*|server.initialOpenInBrowserEnabled = false|' \
|
||||||
|
-e 's|server\.systemTrayEnabled.*|server.systemTrayEnabled = false|' \
|
||||||
|
"$DATA_DIR/server.conf"
|
||||||
|
|
||||||
|
# Append keys if absent (e.g. user-managed conf missing them)
|
||||||
|
grep -q 'server\.webUIEnabled' "$DATA_DIR/server.conf" || echo 'server.webUIEnabled = true' >> "$DATA_DIR/server.conf"
|
||||||
|
grep -q 'server\.initialOpenInBrowserEnabled' "$DATA_DIR/server.conf" || echo 'server.initialOpenInBrowserEnabled = false' >> "$DATA_DIR/server.conf"
|
||||||
|
grep -q 'server\.systemTrayEnabled' "$DATA_DIR/server.conf" || echo 'server.systemTrayEnabled = false' >> "$DATA_DIR/server.conf"
|
||||||
|
|
||||||
|
# ── Suppress any GUI environment that would confuse the JVM ───────────────────
|
||||||
|
unset DISPLAY
|
||||||
|
unset WAYLAND_DISPLAY
|
||||||
|
|
||||||
|
export _JAVA_OPTIONS="-Djava.awt.headless=true"
|
||||||
|
export JAVA_TOOL_OPTIONS="-Djava.awt.headless=true"
|
||||||
|
|
||||||
|
# ── LD_PRELOAD catch_abort.so if present ──────────────────────────────────────
|
||||||
|
# Catches SIGTRAP/SIGILL from KCEF/Webview so a bad extension can't
|
||||||
|
# bring down the whole server process (mirrors the Flatpak build).
|
||||||
|
if [ -f "$CATCH_ABORT" ]; then
|
||||||
|
export LD_PRELOAD="${CATCH_ABORT}${LD_PRELOAD:+:$LD_PRELOAD}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
exec "$JAVA" \
|
||||||
|
-Djava.awt.headless=true \
|
||||||
|
-Dapple.awt.UIElement=true \
|
||||||
|
-Dsun.java2d.noddraw=true \
|
||||||
|
-Dsun.awt.disablegui=true \
|
||||||
|
-Dsuwayomi.tachidesk.config.server.rootDir="$DATA_DIR" \
|
||||||
|
-jar "$JAR"
|
||||||
@@ -2,9 +2,15 @@
|
|||||||
"$schema": "../gen/schemas/desktop-schema.json",
|
"$schema": "../gen/schemas/desktop-schema.json",
|
||||||
"identifier": "default",
|
"identifier": "default",
|
||||||
"description": "Default permissions for Moku",
|
"description": "Default permissions for Moku",
|
||||||
"windows": ["main"],
|
"windows": [
|
||||||
|
"main"
|
||||||
|
],
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"core:default",
|
"core:default",
|
||||||
|
"core:tray:default",
|
||||||
|
"core:app:allow-default-window-icon",
|
||||||
|
"core:window:allow-hide",
|
||||||
|
"core:window:allow-show",
|
||||||
"shell:allow-open",
|
"shell:allow-open",
|
||||||
"shell:allow-kill",
|
"shell:allow-kill",
|
||||||
"shell:allow-spawn",
|
"shell:allow-spawn",
|
||||||
@@ -26,12 +32,19 @@
|
|||||||
"core:window:allow-inner-position",
|
"core:window:allow-inner-position",
|
||||||
"core:window:allow-outer-position",
|
"core:window:allow-outer-position",
|
||||||
"core:window:allow-scale-factor",
|
"core:window:allow-scale-factor",
|
||||||
"updater:default",
|
|
||||||
"updater:allow-check",
|
|
||||||
"updater:allow-download-and-install",
|
|
||||||
"process:default",
|
"process:default",
|
||||||
|
"process:allow-exit",
|
||||||
"process:allow-restart",
|
"process:allow-restart",
|
||||||
"http:default",
|
"http:default",
|
||||||
"http:allow-fetch"
|
"http:allow-fetch",
|
||||||
|
"store:default",
|
||||||
|
"discord-rpc:default",
|
||||||
|
"discord-rpc:allow-connect",
|
||||||
|
"discord-rpc:allow-disconnect",
|
||||||
|
"discord-rpc:allow-set-activity",
|
||||||
|
"discord-rpc:allow-clear-activity",
|
||||||
|
"discord-rpc:allow-is-running",
|
||||||
|
"dialog:default",
|
||||||
|
"dialog:allow-open"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"$schema": "../gen/schemas/desktop-schema.json",
|
||||||
|
"identifier": "http-scope",
|
||||||
|
"description": "HTTP fetch scope",
|
||||||
|
"windows": ["main"],
|
||||||
|
"permissions": [
|
||||||
|
{
|
||||||
|
"identifier": "http:default",
|
||||||
|
"allow": [
|
||||||
|
{ "url": "http://*:*/*" },
|
||||||
|
{ "url": "https://*:*/*" },
|
||||||
|
{ "url": "http://*/*" },
|
||||||
|
{ "url": "https://*/*" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
use std::path::PathBuf;
|
||||||
|
use tauri::Manager;
|
||||||
|
|
||||||
|
fn backup_dir(app: &tauri::AppHandle) -> PathBuf {
|
||||||
|
app.path()
|
||||||
|
.app_data_dir()
|
||||||
|
.unwrap_or_else(|_| PathBuf::from("."))
|
||||||
|
.join("backups")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unix_now() -> u64 {
|
||||||
|
std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_secs()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn export_app_data(app: tauri::AppHandle, bytes: Vec<u8>) -> Result<(), String> {
|
||||||
|
use tauri_plugin_dialog::DialogExt;
|
||||||
|
|
||||||
|
let filename = format!("moku-backup-{}.zip", unix_now());
|
||||||
|
|
||||||
|
let path = app
|
||||||
|
.dialog()
|
||||||
|
.file()
|
||||||
|
.set_title("Save Moku app data backup")
|
||||||
|
.set_file_name(&filename)
|
||||||
|
.add_filter("Moku Backup", &["zip"])
|
||||||
|
.blocking_save_file()
|
||||||
|
.ok_or("Cancelled")?;
|
||||||
|
|
||||||
|
std::fs::write(PathBuf::from(path.to_string()), &bytes).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn import_app_data(app: tauri::AppHandle) -> Result<Vec<u8>, String> {
|
||||||
|
use tauri_plugin_dialog::DialogExt;
|
||||||
|
|
||||||
|
let path = app
|
||||||
|
.dialog()
|
||||||
|
.file()
|
||||||
|
.set_title("Open Moku app data backup")
|
||||||
|
.add_filter("Moku Backup", &["zip"])
|
||||||
|
.blocking_pick_file()
|
||||||
|
.ok_or("Cancelled")?;
|
||||||
|
|
||||||
|
std::fs::read(PathBuf::from(path.to_string())).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn auto_backup_app_data(app: tauri::AppHandle, bytes: Vec<u8>) -> Result<(), String> {
|
||||||
|
let dir = backup_dir(&app);
|
||||||
|
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let dest = dir.join(format!("auto-moku-backup-{}.zip", unix_now()));
|
||||||
|
std::fs::write(&dest, &bytes).map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let mut entries: Vec<_> = std::fs::read_dir(&dir)
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.filter_map(|e| e.ok())
|
||||||
|
.filter(|e| {
|
||||||
|
e.file_name()
|
||||||
|
.to_string_lossy()
|
||||||
|
.starts_with("auto-moku-backup-")
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
entries.sort_by_key(|e| e.file_name());
|
||||||
|
|
||||||
|
for old in entries.iter().take(entries.len().saturating_sub(5)) {
|
||||||
|
let _ = std::fs::remove_file(old.path());
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_auto_backup_dir(app: tauri::AppHandle) -> String {
|
||||||
|
let dir = backup_dir(&app);
|
||||||
|
let _ = std::fs::create_dir_all(&dir);
|
||||||
|
dir.to_string_lossy().into_owned()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn read_store_files(app: tauri::AppHandle, names: Vec<String>) -> Vec<(String, String)> {
|
||||||
|
let base = app
|
||||||
|
.path()
|
||||||
|
.app_local_data_dir()
|
||||||
|
.unwrap_or_else(|_| PathBuf::from("."));
|
||||||
|
|
||||||
|
names
|
||||||
|
.into_iter()
|
||||||
|
.map(|name| {
|
||||||
|
let content = std::fs::read_to_string(base.join(&name))
|
||||||
|
.unwrap_or_else(|_| "{}".to_string());
|
||||||
|
(name, content)
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
mod windows_hello {
|
||||||
|
use windows::{
|
||||||
|
core::HSTRING,
|
||||||
|
Security::Credentials::UI::{UserConsentVerificationResult, UserConsentVerifier},
|
||||||
|
Win32::UI::WindowsAndMessaging::{
|
||||||
|
BringWindowToTop, FindWindowW, IsIconic, SetForegroundWindow, ShowWindow, SW_RESTORE,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
fn to_wide(s: &str) -> Vec<u16> {
|
||||||
|
use std::os::windows::ffi::OsStrExt;
|
||||||
|
std::ffi::OsStr::new(s)
|
||||||
|
.encode_wide()
|
||||||
|
.chain(std::iter::once(0))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn try_focus_hello_dialog() -> bool {
|
||||||
|
let cls = to_wide("Credential Dialog Xaml Host");
|
||||||
|
unsafe {
|
||||||
|
let Ok(hwnd) = FindWindowW(
|
||||||
|
windows::core::PCWSTR(cls.as_ptr()),
|
||||||
|
windows::core::PCWSTR::null(),
|
||||||
|
) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if IsIconic(hwnd).as_bool() {
|
||||||
|
let _ = ShowWindow(hwnd, SW_RESTORE);
|
||||||
|
}
|
||||||
|
let _ = BringWindowToTop(hwnd);
|
||||||
|
let _ = SetForegroundWindow(hwnd);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn nudge_focus(retries: u32, delay_ms: u64) {
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(delay_ms));
|
||||||
|
for _ in 0..retries {
|
||||||
|
if try_focus_hello_dialog() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(delay_ms));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn authenticate(reason: &str) -> Result<(), String> {
|
||||||
|
let reason = reason.to_owned();
|
||||||
|
let (tx, rx) = std::sync::mpsc::channel();
|
||||||
|
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
nudge_focus(5, 250);
|
||||||
|
let outcome = UserConsentVerifier::RequestVerificationAsync(&HSTRING::from(reason.as_str()))
|
||||||
|
.and_then(|op| {
|
||||||
|
nudge_focus(5, 250);
|
||||||
|
op.get()
|
||||||
|
});
|
||||||
|
let _ = tx.send(outcome);
|
||||||
|
});
|
||||||
|
|
||||||
|
let result = rx
|
||||||
|
.recv()
|
||||||
|
.map_err(|e| format!("internalError:{e:?}"))?
|
||||||
|
.map_err(|e| format!("internalError:{e:?}"))?;
|
||||||
|
|
||||||
|
match result {
|
||||||
|
UserConsentVerificationResult::Verified => Ok(()),
|
||||||
|
UserConsentVerificationResult::Canceled => Err("userCancel".into()),
|
||||||
|
UserConsentVerificationResult::RetriesExhausted => Err("biometryLockout".into()),
|
||||||
|
UserConsentVerificationResult::DeviceBusy => Err("systemCancel".into()),
|
||||||
|
UserConsentVerificationResult::DeviceNotPresent => Err("biometryNotAvailable".into()),
|
||||||
|
UserConsentVerificationResult::DisabledByPolicy => Err("biometryNotAvailable".into()),
|
||||||
|
UserConsentVerificationResult::NotConfiguredForUser => Err("biometryNotEnrolled".into()),
|
||||||
|
_ => Err("authenticationFailed".into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_available() -> bool {
|
||||||
|
use windows::Security::Credentials::UI::UserConsentVerifierAvailability;
|
||||||
|
UserConsentVerifier::CheckAvailabilityAsync()
|
||||||
|
.and_then(|op| op.get())
|
||||||
|
.map(|a| a == UserConsentVerifierAvailability::Available)
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn windows_hello_authenticate(_reason: String) -> Result<(), String> {
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
return windows_hello::authenticate(&_reason);
|
||||||
|
#[cfg(not(target_os = "windows"))]
|
||||||
|
Err("notSupported".into())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn windows_hello_available() -> bool {
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
return windows_hello::is_available();
|
||||||
|
#[cfg(not(target_os = "windows"))]
|
||||||
|
false
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
pub mod backup;
|
||||||
|
pub mod biometric;
|
||||||
|
pub mod server;
|
||||||
|
pub mod storage;
|
||||||
|
pub mod system;
|
||||||
|
pub mod updater;
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
use crate::server::{self, resolve::suwayomi_data_dir, SpawnError};
|
||||||
|
use crate::ServerState;
|
||||||
|
use tauri::Manager;
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn spawn_server(
|
||||||
|
binary: String,
|
||||||
|
binary_args: Option<String>,
|
||||||
|
web_ui_enabled: bool,
|
||||||
|
app: tauri::AppHandle,
|
||||||
|
) -> Result<(), SpawnError> {
|
||||||
|
{
|
||||||
|
let state = app.state::<ServerState>();
|
||||||
|
if state.0.lock().unwrap().is_some() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let data_dir = suwayomi_data_dir();
|
||||||
|
let log_path = data_dir.join("moku-spawn.log");
|
||||||
|
let _ = std::fs::create_dir_all(&data_dir);
|
||||||
|
let mut log = std::fs::OpenOptions::new()
|
||||||
|
.create(true)
|
||||||
|
.append(true)
|
||||||
|
.open(&log_path)
|
||||||
|
.ok();
|
||||||
|
|
||||||
|
let binary_args = binary_args.unwrap_or_default();
|
||||||
|
|
||||||
|
server::do_log(
|
||||||
|
&mut log,
|
||||||
|
&format!(
|
||||||
|
"[spawn_server] binary={:?} binary_args={:?} web_ui_enabled={} data_dir={:?}",
|
||||||
|
binary, binary_args, web_ui_enabled, data_dir
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
server::conf::seed_server_conf(&data_dir, web_ui_enabled);
|
||||||
|
|
||||||
|
let mut invocation =
|
||||||
|
server::resolve::resolve_server_binary(&binary, &app, &mut log).map_err(|e| {
|
||||||
|
server::do_log(&mut log, &format!("[spawn_server] resolve failed: {:?}", e));
|
||||||
|
e
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if !binary_args.trim().is_empty() {
|
||||||
|
let extra: Vec<String> = binary_args.split_whitespace().map(|s| s.to_string()).collect();
|
||||||
|
let mut merged = extra;
|
||||||
|
merged.extend(invocation.args);
|
||||||
|
invocation.args = merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
if invocation.bin.ends_with("java") || invocation.bin.ends_with("java.exe") {
|
||||||
|
let rootdir_flag = format!(
|
||||||
|
"-Dsuwayomi.tachidesk.config.server.rootDir={}",
|
||||||
|
data_dir.to_string_lossy()
|
||||||
|
);
|
||||||
|
invocation.args.insert(0, rootdir_flag);
|
||||||
|
}
|
||||||
|
|
||||||
|
let working_dir = invocation
|
||||||
|
.working_dir
|
||||||
|
.unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
|
||||||
|
|
||||||
|
server::do_log(
|
||||||
|
&mut log,
|
||||||
|
&format!(
|
||||||
|
"[spawn_server] bin={:?} args={:?} cwd={:?}",
|
||||||
|
invocation.bin, invocation.args, working_dir
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
use tauri_plugin_shell::ShellExt;
|
||||||
|
let cmd = app
|
||||||
|
.shell()
|
||||||
|
.command(&invocation.bin)
|
||||||
|
.env("JAVA_TOOL_OPTIONS", "-Djava.awt.headless=true")
|
||||||
|
.args(&invocation.args)
|
||||||
|
.current_dir(&working_dir);
|
||||||
|
|
||||||
|
match cmd.spawn() {
|
||||||
|
Ok((_rx, child)) => {
|
||||||
|
*app.state::<ServerState>().0.lock().unwrap() = Some(child);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
server::do_log(&mut log, &format!("[spawn_server] spawn failed: {}", e));
|
||||||
|
Err(SpawnError::SpawnFailed(e.to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn kill_server(app: tauri::AppHandle) -> Result<(), String> {
|
||||||
|
server::kill_tachidesk(&app);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
use serde::Serialize;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use sysinfo::Disks;
|
||||||
|
use tauri::Emitter;
|
||||||
|
use tauri_plugin_store::StoreExt;
|
||||||
|
use walkdir::WalkDir;
|
||||||
|
|
||||||
|
use crate::server::resolve::suwayomi_data_dir;
|
||||||
|
|
||||||
|
// ── Key-value store (used by the frontend via platformService) ────────────────
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn load_store(app: tauri::AppHandle, key: String) -> Result<Option<String>, String> {
|
||||||
|
let store = app
|
||||||
|
.store(format!("{}.json", key))
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let value = store.get(&key);
|
||||||
|
Ok(value.map(|v| v.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn save_store(app: tauri::AppHandle, key: String, value: String) -> Result<(), String> {
|
||||||
|
let store = app
|
||||||
|
.store(format!("{}.json", key))
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let parsed: serde_json::Value =
|
||||||
|
serde_json::from_str(&value).map_err(|e| e.to_string())?;
|
||||||
|
store.set(key, parsed);
|
||||||
|
store.save().map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Credential store (PIN-encrypted vault, auth tokens) ──────────────────────
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn store_credential(app: tauri::AppHandle, key: String, value: String) -> Result<(), String> {
|
||||||
|
let store = app
|
||||||
|
.store("credentials.json")
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
if value.is_empty() {
|
||||||
|
store.delete(&key);
|
||||||
|
} else {
|
||||||
|
store.set(&key, serde_json::Value::String(value));
|
||||||
|
}
|
||||||
|
store.save().map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_credential(app: tauri::AppHandle, key: String) -> Result<Option<String>, String> {
|
||||||
|
let store = app
|
||||||
|
.store("credentials.json")
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
Ok(store.get(&key).and_then(|v| v.as_str().map(|s| s.to_owned())))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Disk / downloads storage ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct StorageInfo {
|
||||||
|
pub manga_bytes: u64,
|
||||||
|
pub total_bytes: u64,
|
||||||
|
pub free_bytes: u64,
|
||||||
|
pub path: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_downloads_path(downloads_path: &str) -> PathBuf {
|
||||||
|
if !downloads_path.trim().is_empty() {
|
||||||
|
return PathBuf::from(downloads_path.trim());
|
||||||
|
}
|
||||||
|
suwayomi_data_dir().join("downloads")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_storage_info(downloads_path: String) -> Result<StorageInfo, String> {
|
||||||
|
let path = resolve_downloads_path(&downloads_path);
|
||||||
|
|
||||||
|
let manga_bytes = if path.exists() {
|
||||||
|
WalkDir::new(&path)
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|e| e.ok())
|
||||||
|
.filter_map(|e| e.metadata().ok())
|
||||||
|
.filter(|m| m.is_file())
|
||||||
|
.map(|m| m.len())
|
||||||
|
.sum()
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
|
||||||
|
let stat_path = if path.exists() {
|
||||||
|
path.clone()
|
||||||
|
} else {
|
||||||
|
dirs::home_dir().unwrap_or_else(|| PathBuf::from("/"))
|
||||||
|
};
|
||||||
|
|
||||||
|
let disks = Disks::new_with_refreshed_list();
|
||||||
|
let disk = disks
|
||||||
|
.iter()
|
||||||
|
.filter(|d| stat_path.starts_with(d.mount_point()))
|
||||||
|
.max_by_key(|d| d.mount_point().as_os_str().len())
|
||||||
|
.ok_or_else(|| "Could not find disk for path".to_string())?;
|
||||||
|
|
||||||
|
Ok(StorageInfo {
|
||||||
|
manga_bytes,
|
||||||
|
total_bytes: disk.total_space(),
|
||||||
|
free_bytes: disk.available_space(),
|
||||||
|
path: path.to_string_lossy().into_owned(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_default_downloads_path() -> String {
|
||||||
|
resolve_downloads_path("").to_string_lossy().into_owned()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn check_path_exists(path: String) -> bool {
|
||||||
|
std::path::Path::new(path.trim()).is_dir()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn create_directory(path: String) -> Result<(), String> {
|
||||||
|
std::fs::create_dir_all(path.trim()).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn migrate_downloads(
|
||||||
|
app: tauri::AppHandle,
|
||||||
|
src: String,
|
||||||
|
dst: String,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
use std::fs;
|
||||||
|
|
||||||
|
let src_path = PathBuf::from(src.trim());
|
||||||
|
let dst_path = PathBuf::from(dst.trim());
|
||||||
|
|
||||||
|
if !src_path.is_dir() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let total: u64 = WalkDir::new(&src_path)
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|e| e.ok())
|
||||||
|
.filter(|e| e.file_type().is_file())
|
||||||
|
.count() as u64;
|
||||||
|
|
||||||
|
let _ = app.emit(
|
||||||
|
"migrate_progress",
|
||||||
|
serde_json::json!({ "done": 0u64, "total": total, "current": "" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut done: u64 = 0;
|
||||||
|
|
||||||
|
for entry in WalkDir::new(&src_path).into_iter().filter_map(|e| e.ok()) {
|
||||||
|
let rel = entry
|
||||||
|
.path()
|
||||||
|
.strip_prefix(&src_path)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let target = dst_path.join(rel);
|
||||||
|
|
||||||
|
if entry.file_type().is_dir() {
|
||||||
|
fs::create_dir_all(&target).map_err(|e| e.to_string())?;
|
||||||
|
} else {
|
||||||
|
if let Some(parent) = target.parent() {
|
||||||
|
fs::create_dir_all(parent).map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
fs::copy(entry.path(), &target).map_err(|e| e.to_string())?;
|
||||||
|
done += 1;
|
||||||
|
let _ = app.emit(
|
||||||
|
"migrate_progress",
|
||||||
|
serde_json::json!({
|
||||||
|
"done": done, "total": total, "current": rel.to_string_lossy()
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fs::remove_dir_all(&src_path).map_err(|e| e.to_string())?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
use crate::server::resolve::strip_unc;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use tauri::Manager;
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_platform_ui_scale(window: tauri::Window) -> f64 {
|
||||||
|
window.scale_factor().unwrap_or(1.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn restart_app(app: tauri::AppHandle) {
|
||||||
|
tauri::process::restart(&app.env());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn open_path(path: String) -> Result<(), String> {
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
{
|
||||||
|
let p = strip_unc(PathBuf::from(path.trim()));
|
||||||
|
std::process::Command::new("explorer")
|
||||||
|
.arg(p)
|
||||||
|
.spawn()
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
{
|
||||||
|
let p = std::path::Path::new(path.trim());
|
||||||
|
std::process::Command::new("open")
|
||||||
|
.arg(p)
|
||||||
|
.spawn()
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
|
||||||
|
{
|
||||||
|
let p = std::path::Path::new(path.trim());
|
||||||
|
std::process::Command::new("xdg-open")
|
||||||
|
.arg(p)
|
||||||
|
.spawn()
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn pick_downloads_folder(app: tauri::AppHandle) -> Option<String> {
|
||||||
|
use tauri_plugin_dialog::DialogExt;
|
||||||
|
app.dialog()
|
||||||
|
.file()
|
||||||
|
.set_title("Choose Downloads Folder")
|
||||||
|
.blocking_pick_folder()
|
||||||
|
.map(|p| p.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn pick_server_binary(app: tauri::AppHandle) -> Option<String> {
|
||||||
|
use tauri_plugin_dialog::DialogExt;
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
let dialog = app
|
||||||
|
.dialog()
|
||||||
|
.file()
|
||||||
|
.set_title("Choose Server Binary")
|
||||||
|
.add_filter("Executable", &["exe", "jar", "bat", "cmd"]);
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
let dialog = app
|
||||||
|
.dialog()
|
||||||
|
.file()
|
||||||
|
.set_title("Choose Server Binary")
|
||||||
|
.add_filter("Executable or JAR", &["jar", "command", "sh", "app"]);
|
||||||
|
|
||||||
|
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
|
||||||
|
let dialog = app
|
||||||
|
.dialog()
|
||||||
|
.file()
|
||||||
|
.set_title("Choose Server Binary")
|
||||||
|
.add_filter("Executable or JAR", &["jar", "sh"]);
|
||||||
|
|
||||||
|
dialog.blocking_pick_file().map(|p| p.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn exit_app(app: tauri::AppHandle) {
|
||||||
|
app.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn remove_dir_best_effort(path: &std::path::Path) {
|
||||||
|
if path.is_file() {
|
||||||
|
if let Err(e) = std::fs::remove_file(path) {
|
||||||
|
if e.raw_os_error() == Some(32) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if path.is_dir() {
|
||||||
|
if let Ok(entries) = std::fs::read_dir(path) {
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
remove_dir_best_effort(&entry.path());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let _ = std::fs::remove_dir(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn wait_until_deletable(path: &std::path::Path, timeout_secs: u64) -> bool {
|
||||||
|
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout_secs);
|
||||||
|
while std::time::Instant::now() < deadline {
|
||||||
|
let locked = if path.is_file() {
|
||||||
|
std::fs::OpenOptions::new().write(true).open(path).is_err()
|
||||||
|
} else if path.is_dir() {
|
||||||
|
std::fs::read_dir(path).is_err()
|
||||||
|
} else {
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
if !locked {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(200));
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn clear_moku_cache(app: tauri::AppHandle) -> Result<(), String> {
|
||||||
|
let window = app.get_webview_window("main").ok_or("no main window")?;
|
||||||
|
|
||||||
|
let (tx, rx) = tokio::sync::oneshot::channel::<Result<(), String>>();
|
||||||
|
|
||||||
|
window
|
||||||
|
.with_webview(move |_wv| {
|
||||||
|
let _ = tx.send(Ok(()));
|
||||||
|
})
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
rx.await.map_err(|e| e.to_string())??;
|
||||||
|
|
||||||
|
let cache_dir = app.path().app_cache_dir().map_err(|e| e.to_string())?;
|
||||||
|
if cache_dir.exists() {
|
||||||
|
wait_until_deletable(&cache_dir, 3);
|
||||||
|
remove_dir_best_effort(&cache_dir);
|
||||||
|
std::fs::create_dir_all(&cache_dir).map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn clear_suwayomi_cache() -> Result<(), String> {
|
||||||
|
use crate::server::resolve::suwayomi_data_dir;
|
||||||
|
let data_dir = suwayomi_data_dir();
|
||||||
|
for dir in &["cache/kcef", "logs"] {
|
||||||
|
let p = data_dir.join(dir);
|
||||||
|
if p.exists() {
|
||||||
|
remove_dir_best_effort(&p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for dir in &["downloads/thumbnails"] {
|
||||||
|
let p = data_dir.join(dir);
|
||||||
|
if p.exists() {
|
||||||
|
remove_dir_best_effort(&p);
|
||||||
|
let _ = std::fs::create_dir_all(&p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn reset_suwayomi_data(app: tauri::AppHandle) -> Result<(), String> {
|
||||||
|
use crate::server::resolve::suwayomi_data_dir;
|
||||||
|
|
||||||
|
crate::server::kill_tachidesk(&app);
|
||||||
|
|
||||||
|
let data_dir = suwayomi_data_dir();
|
||||||
|
let targets = ["database.mv.db", "extensions", "settings", "logs", "local"];
|
||||||
|
|
||||||
|
for entry_name in &targets {
|
||||||
|
let p = data_dir.join(entry_name);
|
||||||
|
if p.exists() {
|
||||||
|
wait_until_deletable(&p, 10);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for entry_name in &targets {
|
||||||
|
let p = data_dir.join(entry_name);
|
||||||
|
if p.is_dir() {
|
||||||
|
std::fs::remove_dir_all(&p).map_err(|e| format!("{entry_name}: {e}"))?;
|
||||||
|
} else if p.exists() {
|
||||||
|
std::fs::remove_file(&p).map_err(|e| format!("{entry_name}: {e}"))?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Serialize, Clone)]
|
||||||
|
pub struct ReleaseInfo {
|
||||||
|
pub tag_name: String,
|
||||||
|
pub name: String,
|
||||||
|
pub body: String,
|
||||||
|
pub published_at: String,
|
||||||
|
pub html_url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Serialize)]
|
||||||
|
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||||||
|
struct UpdateProgress {
|
||||||
|
downloaded: u64,
|
||||||
|
total: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn list_releases() -> Result<Vec<ReleaseInfo>, String> {
|
||||||
|
use tauri_plugin_http::reqwest;
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct GhRelease {
|
||||||
|
tag_name: String,
|
||||||
|
name: Option<String>,
|
||||||
|
body: Option<String>,
|
||||||
|
published_at: Option<String>,
|
||||||
|
html_url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.user_agent("Moku")
|
||||||
|
.build()
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let resp = client
|
||||||
|
.get("https://api.github.com/repos/moku-project/Moku/releases?per_page=30")
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
return Err(format!("GitHub API returned {}", resp.status()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let releases: Vec<GhRelease> =
|
||||||
|
serde_json::from_str(&resp.text().await.map_err(|e| e.to_string())?)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
Ok(releases
|
||||||
|
.into_iter()
|
||||||
|
.map(|r| ReleaseInfo {
|
||||||
|
tag_name: r.tag_name.clone(),
|
||||||
|
name: r.name.unwrap_or_else(|| r.tag_name.clone()),
|
||||||
|
body: r.body.unwrap_or_default(),
|
||||||
|
published_at: r.published_at.unwrap_or_default(),
|
||||||
|
html_url: r.html_url,
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
#[allow(unused_variables)]
|
||||||
|
pub async fn download_and_install_update(app: tauri::AppHandle, tag: String) -> Result<(), String> {
|
||||||
|
#[cfg(not(target_os = "windows"))]
|
||||||
|
return Err("Native install is Windows-only; open the GitHub release page instead.".into());
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
{
|
||||||
|
use std::io::Write;
|
||||||
|
use tauri::Emitter;
|
||||||
|
use tauri_plugin_http::reqwest;
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct Asset {
|
||||||
|
name: String,
|
||||||
|
browser_download_url: String,
|
||||||
|
size: u64,
|
||||||
|
}
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct Release {
|
||||||
|
assets: Vec<Asset>,
|
||||||
|
}
|
||||||
|
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.user_agent("Moku")
|
||||||
|
.build()
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let resp = client
|
||||||
|
.get(format!(
|
||||||
|
"https://api.github.com/repos/moku-project/Moku/releases/tags/{}",
|
||||||
|
tag
|
||||||
|
))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
return Err(format!(
|
||||||
|
"GitHub API returned {} for tag {}",
|
||||||
|
resp.status(),
|
||||||
|
tag
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let release: Release = serde_json::from_str(&resp.text().await.map_err(|e| e.to_string())?)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let asset = release
|
||||||
|
.assets
|
||||||
|
.into_iter()
|
||||||
|
.find(|a| a.name.ends_with("_x64-setup.exe"))
|
||||||
|
.ok_or_else(|| format!("No x64-setup.exe asset found in release {}", tag))?;
|
||||||
|
|
||||||
|
let total = if asset.size > 0 {
|
||||||
|
Some(asset.size)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
let mut resp = client
|
||||||
|
.get(&asset.browser_download_url)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let tmp_path = std::env::temp_dir().join(&asset.name);
|
||||||
|
let mut file = std::fs::File::create(&tmp_path).map_err(|e| e.to_string())?;
|
||||||
|
let mut downloaded: u64 = 0;
|
||||||
|
|
||||||
|
while let Some(chunk) = resp.chunk().await.map_err(|e| e.to_string())? {
|
||||||
|
file.write_all(&chunk).map_err(|e| e.to_string())?;
|
||||||
|
downloaded += chunk.len() as u64;
|
||||||
|
let _ = app.emit("update-progress", UpdateProgress { downloaded, total });
|
||||||
|
}
|
||||||
|
drop(file);
|
||||||
|
|
||||||
|
use std::os::windows::process::CommandExt;
|
||||||
|
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||||
|
std::process::Command::new(&tmp_path)
|
||||||
|
.creation_flags(CREATE_NO_WINDOW)
|
||||||
|
.spawn()
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let _ = app.emit("update-launching", ());
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,599 +1,168 @@
|
|||||||
use std::path::PathBuf;
|
mod commands;
|
||||||
|
mod server;
|
||||||
|
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
use std::io::Write;
|
use std::io::{Read, Write};
|
||||||
use sysinfo::Disks;
|
use std::net::{TcpListener, TcpStream};
|
||||||
use serde::Serialize;
|
use tauri::{
|
||||||
use tauri::{Manager, WindowEvent};
|
menu::{Menu, MenuItem, PredefinedMenuItem},
|
||||||
#[cfg(target_os = "windows")]
|
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
|
||||||
use tauri::Emitter;
|
Manager, WindowEvent,
|
||||||
use tauri_plugin_shell::{ShellExt, process::CommandChild};
|
};
|
||||||
use walkdir::WalkDir;
|
use tauri_plugin_shell::process::CommandChild;
|
||||||
|
|
||||||
struct ServerState(Mutex<Option<CommandChild>>);
|
pub struct ServerState(pub Mutex<Option<CommandChild>>);
|
||||||
|
|
||||||
#[derive(Serialize)]
|
const IPC_PORT: u16 = 47823;
|
||||||
pub struct StorageInfo {
|
const HANDSHAKE: &[u8] = b"MOKU:1\n";
|
||||||
manga_bytes: u64,
|
const FOCUS_CMD: &[u8] = b"focus\n";
|
||||||
total_bytes: u64,
|
|
||||||
free_bytes: u64,
|
fn do_quit(app: &tauri::AppHandle) {
|
||||||
path: String,
|
server::kill_tachidesk(app);
|
||||||
|
app.exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Debug)]
|
fn start_instance_listener(app: tauri::AppHandle) {
|
||||||
#[serde(tag = "kind", content = "message")]
|
std::thread::spawn(move || {
|
||||||
pub enum SpawnError {
|
let Ok(listener) = TcpListener::bind(("127.0.0.1", IPC_PORT)) else {
|
||||||
NotConfigured(String),
|
|
||||||
SpawnFailed(String),
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Update types ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// A single GitHub release returned to the frontend.
|
|
||||||
#[derive(Serialize, Clone)]
|
|
||||||
pub struct ReleaseInfo {
|
|
||||||
pub tag_name: String,
|
|
||||||
pub name: String,
|
|
||||||
pub body: String,
|
|
||||||
pub published_at: String,
|
|
||||||
pub html_url: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Progress event emitted during download — matches what the frontend listens for.
|
|
||||||
#[derive(Clone, serde::Serialize)]
|
|
||||||
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
|
||||||
struct UpdateProgress {
|
|
||||||
downloaded: u64,
|
|
||||||
total: Option<u64>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Strip the \\?\ extended-length path prefix that Windows adds to long paths.
|
|
||||||
/// Java and many other tools do not accept this prefix and will fail silently.
|
|
||||||
fn strip_unc(path: PathBuf) -> PathBuf {
|
|
||||||
let s = path.to_string_lossy();
|
|
||||||
if let Some(stripped) = s.strip_prefix(r"\\?\") {
|
|
||||||
PathBuf::from(stripped)
|
|
||||||
} else {
|
|
||||||
path
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn resolve_downloads_path(downloads_path: &str) -> PathBuf {
|
|
||||||
if !downloads_path.trim().is_empty() {
|
|
||||||
return PathBuf::from(downloads_path);
|
|
||||||
}
|
|
||||||
let base = std::env::var("XDG_DATA_HOME")
|
|
||||||
.map(PathBuf::from)
|
|
||||||
.unwrap_or_else(|_| dirs::data_dir().unwrap_or_else(|| PathBuf::from("/")));
|
|
||||||
base.join("Tachidesk/downloads")
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
fn get_storage_info(downloads_path: String) -> Result<StorageInfo, String> {
|
|
||||||
let path = resolve_downloads_path(&downloads_path);
|
|
||||||
|
|
||||||
let manga_bytes = if path.exists() {
|
|
||||||
WalkDir::new(&path)
|
|
||||||
.into_iter()
|
|
||||||
.filter_map(|e| e.ok())
|
|
||||||
.filter_map(|e| e.metadata().ok())
|
|
||||||
.filter(|m| m.is_file())
|
|
||||||
.map(|m| m.len())
|
|
||||||
.sum()
|
|
||||||
} else {
|
|
||||||
0
|
|
||||||
};
|
|
||||||
|
|
||||||
let stat_path = if path.exists() {
|
|
||||||
path.clone()
|
|
||||||
} else {
|
|
||||||
dirs::home_dir().unwrap_or_else(|| PathBuf::from("/"))
|
|
||||||
};
|
|
||||||
|
|
||||||
let disks = Disks::new_with_refreshed_list();
|
|
||||||
let disk = disks
|
|
||||||
.iter()
|
|
||||||
.filter(|d| stat_path.starts_with(d.mount_point()))
|
|
||||||
.max_by_key(|d| d.mount_point().as_os_str().len())
|
|
||||||
.ok_or_else(|| "Could not find disk for path".to_string())?;
|
|
||||||
|
|
||||||
Ok(StorageInfo {
|
|
||||||
manga_bytes,
|
|
||||||
total_bytes: disk.total_space(),
|
|
||||||
free_bytes: disk.available_space(),
|
|
||||||
path: path.to_string_lossy().into_owned(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
fn get_platform_ui_scale() -> f64 {
|
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
return 1.0;
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
return 1.0;
|
|
||||||
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
|
|
||||||
return 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
fn kill_tachidesk(app: &tauri::AppHandle) {
|
|
||||||
let state = app.state::<ServerState>();
|
|
||||||
if let Some(child) = state.0.lock().unwrap().take() {
|
|
||||||
let _ = child.kill();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
{
|
|
||||||
use std::os::windows::process::CommandExt;
|
|
||||||
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
|
||||||
|
|
||||||
let _ = std::process::Command::new("taskkill")
|
|
||||||
.args(["/F", "/FI", "IMAGENAME eq java.exe"])
|
|
||||||
.creation_flags(CREATE_NO_WINDOW)
|
|
||||||
.status();
|
|
||||||
|
|
||||||
// Poll until no java.exe remains (up to ~3 s) so the installer can
|
|
||||||
// overwrite the JRE DLLs without hitting a sharing-violation error.
|
|
||||||
for _ in 0..30 {
|
|
||||||
let still_running = std::process::Command::new("tasklist")
|
|
||||||
.args(["/FI", "IMAGENAME eq java.exe", "/NH"])
|
|
||||||
.creation_flags(CREATE_NO_WINDOW)
|
|
||||||
.output()
|
|
||||||
.map(|o| String::from_utf8_lossy(&o.stdout).contains("java.exe"))
|
|
||||||
.unwrap_or(false);
|
|
||||||
|
|
||||||
if !still_running {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
std::thread::sleep(std::time::Duration::from_millis(100));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(not(target_os = "windows"))]
|
|
||||||
let _ = std::process::Command::new("pkill")
|
|
||||||
.args(["-f", "tachidesk"])
|
|
||||||
.status();
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
const DEFAULT_SERVER_CONF: &str = r#"server.ip = "127.0.0.1"
|
|
||||||
server.port = 4567
|
|
||||||
server.webUIEnabled = false
|
|
||||||
server.initialOpenInBrowserEnabled = false
|
|
||||||
server.systemTrayEnabled = false
|
|
||||||
server.webUIInterface = "browser"
|
|
||||||
server.webUIFlavor = "WebUI"
|
|
||||||
server.webUIChannel = "stable"
|
|
||||||
server.electronPath = ""
|
|
||||||
server.debugLogsEnabled = false
|
|
||||||
server.downloadAsCbz = true
|
|
||||||
server.autoDownloadNewChapters = false
|
|
||||||
server.globalUpdateInterval = 12
|
|
||||||
server.maxSourcesInParallel = 6
|
|
||||||
server.extensionRepos = []
|
|
||||||
"#;
|
|
||||||
|
|
||||||
fn seed_server_conf(data_dir: &PathBuf) {
|
|
||||||
let conf_path = data_dir.join("server.conf");
|
|
||||||
|
|
||||||
if !conf_path.exists() {
|
|
||||||
if let Err(e) = std::fs::create_dir_all(data_dir) {
|
|
||||||
eprintln!("Could not create Suwayomi data dir: {e}");
|
|
||||||
return;
|
return;
|
||||||
|
};
|
||||||
|
for stream in listener.incoming().flatten() {
|
||||||
|
handle_ipc_connection(stream, &app);
|
||||||
}
|
}
|
||||||
if let Err(e) = std::fs::write(&conf_path, DEFAULT_SERVER_CONF) {
|
});
|
||||||
eprintln!("Could not write server.conf: {e}");
|
}
|
||||||
}
|
|
||||||
|
fn handle_ipc_connection(mut stream: TcpStream, app: &tauri::AppHandle) {
|
||||||
|
let mut buf = [0u8; 32];
|
||||||
|
let Ok(n) = stream.read(&mut buf) else { return };
|
||||||
|
let msg = &buf[..n];
|
||||||
|
|
||||||
|
if !msg.starts_with(HANDSHAKE) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let Ok(contents) = std::fs::read_to_string(&conf_path) else { return };
|
let cmd = &msg[HANDSHAKE.len()..];
|
||||||
|
if cmd.starts_with(b"focus") {
|
||||||
let patched = patch_conf_key(
|
let _ = stream.write_all(b"ok\n");
|
||||||
patch_conf_key(
|
if let Some(win) = app.get_webview_window("main") {
|
||||||
patch_conf_key(contents, "server.webUIEnabled", "false"),
|
let _ = win.show();
|
||||||
"server.initialOpenInBrowserEnabled", "false",
|
let _ = win.unminimize();
|
||||||
),
|
let _ = win.set_focus();
|
||||||
"server.systemTrayEnabled", "false",
|
|
||||||
);
|
|
||||||
|
|
||||||
let _ = std::fs::write(&conf_path, patched);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn patch_conf_key(text: String, key: &str, value: &str) -> String {
|
|
||||||
let replacement = format!("{key} = {value}");
|
|
||||||
let lines: Vec<&str> = text.lines().collect();
|
|
||||||
|
|
||||||
if let Some(pos) = lines.iter().position(|l| l.trim_start().starts_with(key)) {
|
|
||||||
let mut out = lines
|
|
||||||
.iter()
|
|
||||||
.enumerate()
|
|
||||||
.map(|(i, l)| if i == pos { replacement.as_str() } else { l })
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join("\n");
|
|
||||||
out.push('\n');
|
|
||||||
return out;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut out = text;
|
|
||||||
if !out.ends_with('\n') { out.push('\n'); }
|
|
||||||
out.push_str(&replacement);
|
|
||||||
out.push('\n');
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
fn suwayomi_data_dir() -> PathBuf {
|
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
{
|
|
||||||
dirs::data_dir()
|
|
||||||
.unwrap_or_else(|| PathBuf::from("C:\\ProgramData"))
|
|
||||||
.join("moku\\tachidesk")
|
|
||||||
}
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
{
|
|
||||||
dirs::data_dir()
|
|
||||||
.unwrap_or_else(|| dirs::home_dir().unwrap_or_else(|| PathBuf::from("~")))
|
|
||||||
.join("dev.moku.app/tachidesk")
|
|
||||||
}
|
|
||||||
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
|
|
||||||
{
|
|
||||||
let base = std::env::var("XDG_DATA_HOME")
|
|
||||||
.map(PathBuf::from)
|
|
||||||
.unwrap_or_else(|_| dirs::data_dir().unwrap_or_else(|| PathBuf::from("/tmp")));
|
|
||||||
base.join("moku/tachidesk")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ServerInvocation {
|
fn signal_existing_instance() -> bool {
|
||||||
bin: String,
|
let Ok(mut stream) = TcpStream::connect(("127.0.0.1", IPC_PORT)) else {
|
||||||
args: Vec<String>,
|
return false;
|
||||||
working_dir: Option<PathBuf>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(not(target_os = "macos"))]
|
|
||||||
fn find_java_in_bundle(bundle_dir: &PathBuf, log: &mut Option<std::fs::File>) -> Option<PathBuf> {
|
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
let java = bundle_dir.join("jre").join("bin").join("java.exe");
|
|
||||||
|
|
||||||
#[cfg(not(target_os = "windows"))]
|
|
||||||
let java = bundle_dir.join("jre").join("bin").join("java");
|
|
||||||
|
|
||||||
do_log(log, &format!("[find_java] checking path: {:?}", java));
|
|
||||||
do_log(log, &format!("[find_java] exists: {}", java.exists()));
|
|
||||||
|
|
||||||
if java.exists() { Some(java) } else { None }
|
|
||||||
}
|
|
||||||
|
|
||||||
fn do_log(log: &mut Option<std::fs::File>, msg: &str) {
|
|
||||||
eprintln!("{}", msg);
|
|
||||||
if let Some(f) = log {
|
|
||||||
let _ = writeln!(f, "{}", msg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn resolve_server_binary(
|
|
||||||
binary: &str,
|
|
||||||
app: &tauri::AppHandle,
|
|
||||||
log: &mut Option<std::fs::File>,
|
|
||||||
) -> Result<ServerInvocation, SpawnError> {
|
|
||||||
do_log(log, &format!("[resolve] binary arg = {:?}", binary));
|
|
||||||
|
|
||||||
if !binary.trim().is_empty() {
|
|
||||||
do_log(log, "[resolve] using user-supplied binary path");
|
|
||||||
return Ok(ServerInvocation {
|
|
||||||
bin: binary.to_string(),
|
|
||||||
args: vec![],
|
|
||||||
working_dir: None,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let resource_dir = match app.path().resource_dir() {
|
|
||||||
Ok(p) => {
|
|
||||||
let stripped = strip_unc(p);
|
|
||||||
do_log(log, &format!("[resolve] resource_dir (stripped) = {:?}", stripped));
|
|
||||||
stripped
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
let msg = format!("resource_dir error: {e}");
|
|
||||||
do_log(log, &format!("[resolve] ERROR: {}", msg));
|
|
||||||
return Err(SpawnError::SpawnFailed(msg));
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
stream.set_read_timeout(Some(std::time::Duration::from_millis(500))).ok();
|
||||||
|
|
||||||
#[cfg(not(target_os = "macos"))]
|
let mut msg = Vec::new();
|
||||||
{
|
msg.extend_from_slice(HANDSHAKE);
|
||||||
let bundle_dir = resource_dir.join("binaries").join("suwayomi-bundle");
|
msg.extend_from_slice(FOCUS_CMD);
|
||||||
let jar = bundle_dir.join("bin").join("Suwayomi-Server.jar");
|
|
||||||
|
|
||||||
do_log(log, &format!("[resolve] bundle_dir = {:?}", bundle_dir));
|
if stream.write_all(&msg).is_err() {
|
||||||
do_log(log, &format!("[resolve] bundle_dir exists: {}", bundle_dir.exists()));
|
return false;
|
||||||
do_log(log, &format!("[resolve] jar = {:?}", jar));
|
|
||||||
do_log(log, &format!("[resolve] jar exists: {}", jar.exists()));
|
|
||||||
|
|
||||||
match find_java_in_bundle(&bundle_dir, log) {
|
|
||||||
Some(java) => {
|
|
||||||
do_log(log, &format!("[resolve] java found: {:?}", java));
|
|
||||||
if jar.exists() {
|
|
||||||
do_log(log, "[resolve] both java and jar found — using bundled JRE");
|
|
||||||
return Ok(ServerInvocation {
|
|
||||||
bin: java.to_string_lossy().into_owned(),
|
|
||||||
args: vec![
|
|
||||||
"-jar".to_string(),
|
|
||||||
jar.to_string_lossy().into_owned(),
|
|
||||||
],
|
|
||||||
working_dir: Some(bundle_dir),
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
do_log(log, "[resolve] java found but jar MISSING — skipping bundled path");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None => {
|
|
||||||
do_log(log, "[resolve] java NOT found in bundle — skipping bundled path");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
let mut resp = [0u8; 4];
|
||||||
{
|
matches!(stream.read(&mut resp), Ok(n) if resp[..n].starts_with(b"ok"))
|
||||||
// Tauri places externalBin sidecars next to the main binary in
|
|
||||||
// Contents/MacOS/, not in Contents/Resources/. Derive that path
|
|
||||||
// from resource_dir (Contents/Resources → Contents/MacOS).
|
|
||||||
let macos_dir = resource_dir.join("../MacOS")
|
|
||||||
.canonicalize()
|
|
||||||
.unwrap_or_else(|_| resource_dir.join("../MacOS"));
|
|
||||||
|
|
||||||
do_log(log, &format!("[resolve] macOS macos_dir = {:?}", macos_dir));
|
|
||||||
|
|
||||||
// Tauri strips the target triple when installing externalBin sidecars
|
|
||||||
// into Contents/MacOS/, so the binary is always just "suwayomi-server"
|
|
||||||
// at runtime. The triple-suffixed names are only needed on disk at
|
|
||||||
// build time for Tauri to pick the right arch during bundling.
|
|
||||||
let candidates = [
|
|
||||||
"suwayomi-server",
|
|
||||||
"suwayomi-server-aarch64-apple-darwin",
|
|
||||||
"suwayomi-server-x86_64-apple-darwin",
|
|
||||||
];
|
|
||||||
|
|
||||||
// Search MacOS/ first (correct location), then Resources/ as fallback
|
|
||||||
// for flat dev layouts where the script sits next to resources.
|
|
||||||
for search_dir in &[&macos_dir, &resource_dir] {
|
|
||||||
for name in &candidates {
|
|
||||||
let p = search_dir.join(name);
|
|
||||||
do_log(log, &format!("[resolve] macOS candidate: {:?} exists={}", p, p.exists()));
|
|
||||||
if p.exists() {
|
|
||||||
do_log(log, &format!("[resolve] using macOS sidecar: {:?}", p));
|
|
||||||
return Ok(ServerInvocation {
|
|
||||||
bin: p.to_string_lossy().into_owned(),
|
|
||||||
args: vec![],
|
|
||||||
working_dir: None,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
do_log(log, "[resolve] trying PATH fallback");
|
|
||||||
for name in &["suwayomi-server", "tachidesk-server"] {
|
|
||||||
let found = std::process::Command::new("which")
|
|
||||||
.arg(name)
|
|
||||||
.output()
|
|
||||||
.map(|o| o.status.success())
|
|
||||||
.unwrap_or(false);
|
|
||||||
|
|
||||||
do_log(log, &format!("[resolve] PATH check {:?}: found={}", name, found));
|
|
||||||
|
|
||||||
if found {
|
|
||||||
do_log(log, &format!("[resolve] using PATH binary: {}", name));
|
|
||||||
return Ok(ServerInvocation {
|
|
||||||
bin: name.to_string(),
|
|
||||||
args: vec![],
|
|
||||||
working_dir: None,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
do_log(log, "[resolve] FAILED — no binary found anywhere");
|
|
||||||
Err(SpawnError::NotConfigured(
|
|
||||||
"Server binary not found. Install Suwayomi-Server or set the path in Settings.".to_string(),
|
|
||||||
))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
fn spawn_server(binary: String, app: tauri::AppHandle) -> Result<(), SpawnError> {
|
|
||||||
{
|
|
||||||
let state = app.state::<ServerState>();
|
|
||||||
if state.0.lock().unwrap().is_some() {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let data_dir = suwayomi_data_dir();
|
|
||||||
|
|
||||||
let log_path = data_dir.join("moku-spawn.log");
|
|
||||||
let _ = std::fs::create_dir_all(&data_dir);
|
|
||||||
let mut log = std::fs::OpenOptions::new()
|
|
||||||
.create(true)
|
|
||||||
.append(true)
|
|
||||||
.open(&log_path)
|
|
||||||
.ok();
|
|
||||||
|
|
||||||
do_log(&mut log, "");
|
|
||||||
do_log(&mut log, "========================================");
|
|
||||||
do_log(&mut log, &format!("[spawn_server] called at {:?}", std::time::SystemTime::now()));
|
|
||||||
do_log(&mut log, &format!("[spawn_server] binary arg = {:?}", binary));
|
|
||||||
do_log(&mut log, &format!("[spawn_server] data_dir = {:?}", data_dir));
|
|
||||||
do_log(&mut log, &format!("[spawn_server] log file = {:?}", log_path));
|
|
||||||
do_log(&mut log, &format!("[spawn_server] APPDATA = {:?}", std::env::var("APPDATA")));
|
|
||||||
do_log(&mut log, &format!("[spawn_server] LOCALAPPDATA = {:?}", std::env::var("LOCALAPPDATA")));
|
|
||||||
do_log(&mut log, &format!("[spawn_server] current_dir = {:?}", std::env::current_dir()));
|
|
||||||
|
|
||||||
seed_server_conf(&data_dir);
|
|
||||||
do_log(&mut log, "[spawn_server] server.conf seeded");
|
|
||||||
|
|
||||||
let mut invocation = match resolve_server_binary(&binary, &app, &mut log) {
|
|
||||||
Ok(i) => i,
|
|
||||||
Err(e) => {
|
|
||||||
do_log(&mut log, &format!("[spawn_server] resolve FAILED: {:?}", e));
|
|
||||||
return Err(e);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let bin_display = invocation.bin.clone();
|
|
||||||
let rootdir_flag = format!(
|
|
||||||
"-Dsuwayomi.tachidesk.config.server.rootDir={}",
|
|
||||||
data_dir.to_string_lossy()
|
|
||||||
);
|
|
||||||
|
|
||||||
invocation.args.insert(0, rootdir_flag);
|
|
||||||
|
|
||||||
let working_dir = invocation.working_dir
|
|
||||||
.unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
|
|
||||||
|
|
||||||
do_log(&mut log, &format!("[spawn_server] bin = {:?}", bin_display));
|
|
||||||
do_log(&mut log, &format!("[spawn_server] args = {:?}", invocation.args));
|
|
||||||
do_log(&mut log, &format!("[spawn_server] working_dir = {:?}", working_dir));
|
|
||||||
|
|
||||||
let cmd = app.shell()
|
|
||||||
.command(&invocation.bin)
|
|
||||||
.env("JAVA_TOOL_OPTIONS", "-Djava.awt.headless=true")
|
|
||||||
.args(&invocation.args)
|
|
||||||
.current_dir(&working_dir);
|
|
||||||
|
|
||||||
do_log(&mut log, "[spawn_server] calling cmd.spawn()...");
|
|
||||||
|
|
||||||
match cmd.spawn() {
|
|
||||||
Ok((_rx, child)) => {
|
|
||||||
do_log(&mut log, &format!("[spawn_server] SUCCESS — spawned: {}", bin_display));
|
|
||||||
*app.state::<ServerState>().0.lock().unwrap() = Some(child);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
do_log(&mut log, &format!("[spawn_server] SPAWN FAILED: {}", e));
|
|
||||||
do_log(&mut log, &format!("[spawn_server] error kind: {:?}", e));
|
|
||||||
Err(SpawnError::SpawnFailed(e.to_string()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
fn kill_server(app: tauri::AppHandle) -> Result<(), String> {
|
|
||||||
kill_tachidesk(&app);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Update commands ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// Fetch the list of all GitHub releases so the frontend can show a version picker.
|
|
||||||
/// Uses tauri-plugin-http so it goes through Tauri's permission system.
|
|
||||||
#[tauri::command]
|
|
||||||
async fn list_releases() -> Result<Vec<ReleaseInfo>, String> {
|
|
||||||
use tauri_plugin_http::reqwest;
|
|
||||||
|
|
||||||
let client = reqwest::Client::builder()
|
|
||||||
.user_agent("Moku")
|
|
||||||
.build()
|
|
||||||
.map_err(|e| e.to_string())?;
|
|
||||||
|
|
||||||
let resp = client
|
|
||||||
.get("https://api.github.com/repos/Youwes09/Moku/releases?per_page=30")
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.map_err(|e| e.to_string())?;
|
|
||||||
|
|
||||||
if !resp.status().is_success() {
|
|
||||||
return Err(format!("GitHub API returned {}", resp.status()));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(serde::Deserialize)]
|
|
||||||
struct GhRelease {
|
|
||||||
tag_name: String,
|
|
||||||
name: Option<String>,
|
|
||||||
body: Option<String>,
|
|
||||||
published_at: Option<String>,
|
|
||||||
html_url: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
let body = resp.text().await.map_err(|e| e.to_string())?;
|
|
||||||
let releases: Vec<GhRelease> = serde_json::from_str(&body).map_err(|e| e.to_string())?;
|
|
||||||
|
|
||||||
Ok(releases
|
|
||||||
.into_iter()
|
|
||||||
.map(|r| ReleaseInfo {
|
|
||||||
tag_name: r.tag_name.clone(),
|
|
||||||
name: r.name.unwrap_or_else(|| r.tag_name.clone()),
|
|
||||||
body: r.body.unwrap_or_default(),
|
|
||||||
published_at: r.published_at.unwrap_or_default(),
|
|
||||||
html_url: r.html_url,
|
|
||||||
})
|
|
||||||
.collect())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Download and install the latest update using tauri-plugin-updater.
|
|
||||||
/// Emits `update-progress` events with `{ downloaded, total }` while downloading.
|
|
||||||
/// On Windows the installer runs in passive (silent) mode; the frontend prompts restart.
|
|
||||||
/// On other platforms this command is a no-op — the frontend opens the GitHub page instead.
|
|
||||||
#[tauri::command]
|
|
||||||
#[allow(unused_variables)]
|
|
||||||
async fn download_and_install_update(app: tauri::AppHandle) -> 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;
|
|
||||||
|
|
||||||
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 from the updater endpoint.".into());
|
|
||||||
};
|
|
||||||
|
|
||||||
let app_clone = app.clone();
|
|
||||||
update
|
|
||||||
.download_and_install(
|
|
||||||
move |downloaded, total| {
|
|
||||||
let _ = app_clone.emit("update-progress", UpdateProgress { downloaded: downloaded as u64, total });
|
|
||||||
},
|
|
||||||
|| {},
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(|e| e.to_string())?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Restart the app after a successful update install.
|
|
||||||
#[tauri::command]
|
|
||||||
fn restart_app(app: tauri::AppHandle) {
|
|
||||||
tauri::process::restart(&app.env());
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── App entry point ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
|
if signal_existing_instance() {
|
||||||
|
std::process::exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
|
.plugin(tauri_plugin_store::Builder::new().build())
|
||||||
|
.plugin(tauri_plugin_discord_rpc::init())
|
||||||
|
.plugin(tauri_plugin_dialog::init())
|
||||||
.plugin(tauri_plugin_os::init())
|
.plugin(tauri_plugin_os::init())
|
||||||
.plugin(tauri_plugin_shell::init())
|
.plugin(tauri_plugin_shell::init())
|
||||||
.plugin(tauri_plugin_http::init())
|
.plugin(tauri_plugin_http::init())
|
||||||
.plugin(tauri_plugin_process::init())
|
.plugin(tauri_plugin_process::init())
|
||||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
|
||||||
.manage(ServerState(Mutex::new(None)))
|
.manage(ServerState(Mutex::new(None)))
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
get_storage_info,
|
commands::storage::get_storage_info,
|
||||||
spawn_server,
|
commands::storage::get_default_downloads_path,
|
||||||
kill_server,
|
commands::storage::check_path_exists,
|
||||||
get_platform_ui_scale,
|
commands::storage::create_directory,
|
||||||
list_releases,
|
commands::storage::migrate_downloads,
|
||||||
download_and_install_update,
|
commands::server::spawn_server,
|
||||||
restart_app,
|
commands::server::kill_server,
|
||||||
|
commands::system::get_platform_ui_scale,
|
||||||
|
commands::system::restart_app,
|
||||||
|
commands::system::exit_app,
|
||||||
|
commands::system::clear_moku_cache,
|
||||||
|
commands::system::clear_suwayomi_cache,
|
||||||
|
commands::system::reset_suwayomi_data,
|
||||||
|
commands::system::open_path,
|
||||||
|
commands::system::pick_downloads_folder,
|
||||||
|
commands::system::pick_server_binary,
|
||||||
|
commands::backup::export_app_data,
|
||||||
|
commands::backup::import_app_data,
|
||||||
|
commands::backup::auto_backup_app_data,
|
||||||
|
commands::backup::get_auto_backup_dir,
|
||||||
|
commands::backup::read_store_files,
|
||||||
|
commands::storage::load_store,
|
||||||
|
commands::storage::save_store,
|
||||||
|
commands::storage::store_credential,
|
||||||
|
commands::storage::get_credential,
|
||||||
|
commands::updater::list_releases,
|
||||||
|
commands::updater::download_and_install_update,
|
||||||
|
commands::biometric::windows_hello_authenticate,
|
||||||
|
commands::biometric::windows_hello_available,
|
||||||
])
|
])
|
||||||
.setup(|_app| Ok(()))
|
.setup(|app| {
|
||||||
|
start_instance_listener(app.handle().clone());
|
||||||
|
|
||||||
|
let show = MenuItem::with_id(app, "show", "Show Moku", true, None::<&str>)?;
|
||||||
|
let sep = PredefinedMenuItem::separator(app)?;
|
||||||
|
let quit = MenuItem::with_id(app, "quit", "Quit Moku", true, None::<&str>)?;
|
||||||
|
let menu = Menu::with_items(app, &[&show, &sep, &quit])?;
|
||||||
|
|
||||||
|
TrayIconBuilder::new()
|
||||||
|
.icon(app.default_window_icon().unwrap().clone())
|
||||||
|
.menu(&menu)
|
||||||
|
.show_menu_on_left_click(false)
|
||||||
|
.tooltip("Moku")
|
||||||
|
.on_menu_event(|app, event| match event.id.as_ref() {
|
||||||
|
"show" => {
|
||||||
|
if let Some(win) = app.get_webview_window("main") {
|
||||||
|
let _ = win.show();
|
||||||
|
let _ = win.set_focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"quit" => do_quit(app),
|
||||||
|
_ => {}
|
||||||
|
})
|
||||||
|
.on_tray_icon_event(|tray, event| {
|
||||||
|
if let TrayIconEvent::Click {
|
||||||
|
button: MouseButton::Left,
|
||||||
|
button_state: MouseButtonState::Up,
|
||||||
|
..
|
||||||
|
} = event
|
||||||
|
{
|
||||||
|
let app = tray.app_handle();
|
||||||
|
if let Some(win) = app.get_webview_window("main") {
|
||||||
|
let _ = win.show();
|
||||||
|
let _ = win.set_focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.build(app)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
.on_window_event(|window, event| {
|
.on_window_event(|window, event| {
|
||||||
if let WindowEvent::Destroyed = event {
|
if let WindowEvent::Destroyed = event {
|
||||||
kill_tachidesk(window.app_handle());
|
server::kill_tachidesk(window.app_handle());
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running moku");
|
.expect("error while running moku")
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
const DEFAULT_SERVER_CONF: &str = r#"server.ip = "127.0.0.1"
|
||||||
|
server.port = 4567
|
||||||
|
server.webUIEnabled = false
|
||||||
|
server.initialOpenInBrowserEnabled = false
|
||||||
|
server.systemTrayEnabled = false
|
||||||
|
server.webUIInterface = "browser"
|
||||||
|
server.webUIFlavor = "WebUI"
|
||||||
|
server.webUIChannel = "preview"
|
||||||
|
server.electronPath = ""
|
||||||
|
server.debugLogsEnabled = false
|
||||||
|
server.downloadAsCbz = true
|
||||||
|
server.autoDownloadNewChapters = false
|
||||||
|
server.globalUpdateInterval = 12
|
||||||
|
server.maxSourcesInParallel = 6
|
||||||
|
server.extensionRepos = []
|
||||||
|
"#;
|
||||||
|
|
||||||
|
pub fn seed_server_conf(data_dir: &PathBuf, web_ui_enabled: bool) {
|
||||||
|
let conf_path = data_dir.join("server.conf");
|
||||||
|
|
||||||
|
if !conf_path.exists() {
|
||||||
|
if let Err(e) = std::fs::create_dir_all(data_dir) {
|
||||||
|
eprintln!("Could not create Suwayomi data dir: {e}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let initial = patch_conf_key(
|
||||||
|
DEFAULT_SERVER_CONF.to_string(),
|
||||||
|
"server.webUIEnabled",
|
||||||
|
if web_ui_enabled { "true" } else { "false" },
|
||||||
|
);
|
||||||
|
if let Err(e) = std::fs::write(&conf_path, initial) {
|
||||||
|
eprintln!("Could not write server.conf: {e}");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Ok(contents) = std::fs::read_to_string(&conf_path) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let patched = patch_conf_key(
|
||||||
|
patch_conf_key(
|
||||||
|
patch_conf_key(
|
||||||
|
contents,
|
||||||
|
"server.webUIEnabled",
|
||||||
|
if web_ui_enabled { "true" } else { "false" },
|
||||||
|
),
|
||||||
|
"server.initialOpenInBrowserEnabled",
|
||||||
|
"false",
|
||||||
|
),
|
||||||
|
"server.systemTrayEnabled",
|
||||||
|
"false",
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = std::fs::write(&conf_path, patched);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn patch_conf_key(text: String, key: &str, value: &str) -> String {
|
||||||
|
let replacement = format!("{key} = {value}");
|
||||||
|
let lines: Vec<&str> = text.lines().collect();
|
||||||
|
|
||||||
|
if let Some(pos) = lines.iter().position(|l| l.trim_start().starts_with(key)) {
|
||||||
|
let mut out = lines
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, l)| if i == pos { replacement.as_str() } else { l })
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n");
|
||||||
|
out.push('\n');
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut out = text;
|
||||||
|
if !out.ends_with('\n') {
|
||||||
|
out.push('\n');
|
||||||
|
}
|
||||||
|
out.push_str(&replacement);
|
||||||
|
out.push('\n');
|
||||||
|
out
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
pub mod conf;
|
||||||
|
pub mod resolve;
|
||||||
|
|
||||||
|
use std::io::Write;
|
||||||
|
use tauri::Manager;
|
||||||
|
|
||||||
|
use crate::ServerState;
|
||||||
|
|
||||||
|
pub use resolve::SpawnError;
|
||||||
|
|
||||||
|
pub fn do_log(log: &mut Option<std::fs::File>, msg: &str) {
|
||||||
|
eprintln!("{}", msg);
|
||||||
|
if let Some(f) = log {
|
||||||
|
let _ = writeln!(f, "{}", msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn kill_tachidesk(app: &tauri::AppHandle) {
|
||||||
|
let state = app.state::<ServerState>();
|
||||||
|
if let Some(child) = state.0.lock().unwrap().take() {
|
||||||
|
let _ = child.kill();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
{
|
||||||
|
use std::os::windows::process::CommandExt;
|
||||||
|
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||||
|
|
||||||
|
let _ = std::process::Command::new("taskkill")
|
||||||
|
.args(["/F", "/FI", "IMAGENAME eq java.exe"])
|
||||||
|
.creation_flags(CREATE_NO_WINDOW)
|
||||||
|
.status();
|
||||||
|
|
||||||
|
for _ in 0..30 {
|
||||||
|
let still_running = std::process::Command::new("tasklist")
|
||||||
|
.args(["/FI", "IMAGENAME eq java.exe", "/NH"])
|
||||||
|
.creation_flags(CREATE_NO_WINDOW)
|
||||||
|
.output()
|
||||||
|
.map(|o| String::from_utf8_lossy(&o.stdout).contains("java.exe"))
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
if !still_running {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(100));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "windows"))]
|
||||||
|
let _ = std::process::Command::new("pkill")
|
||||||
|
.args(["-f", "tachidesk"])
|
||||||
|
.status();
|
||||||
|
}
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
use crate::server::do_log;
|
||||||
|
use serde::Serialize;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use tauri::Manager;
|
||||||
|
|
||||||
|
#[derive(Serialize, Debug)]
|
||||||
|
#[serde(tag = "kind", content = "message")]
|
||||||
|
pub enum SpawnError {
|
||||||
|
NotConfigured(String),
|
||||||
|
SpawnFailed(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct ServerInvocation {
|
||||||
|
pub bin: String,
|
||||||
|
pub args: Vec<String>,
|
||||||
|
pub working_dir: Option<PathBuf>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn suwayomi_data_dir() -> PathBuf {
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
{
|
||||||
|
dirs::data_dir()
|
||||||
|
.unwrap_or_else(|| PathBuf::from("C:\\ProgramData"))
|
||||||
|
.join("Tachidesk")
|
||||||
|
}
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
{
|
||||||
|
dirs::data_dir()
|
||||||
|
.unwrap_or_else(|| dirs::home_dir().unwrap_or_else(|| PathBuf::from("~")))
|
||||||
|
.join("Tachidesk")
|
||||||
|
}
|
||||||
|
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
|
||||||
|
{
|
||||||
|
let base = std::env::var("XDG_DATA_HOME")
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.unwrap_or_else(|_| dirs::data_dir().unwrap_or_else(|| PathBuf::from("/tmp")));
|
||||||
|
base.join("Tachidesk")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn strip_unc(path: PathBuf) -> PathBuf {
|
||||||
|
let s = path.to_string_lossy();
|
||||||
|
if let Some(stripped) = s.strip_prefix(r"\\?\") {
|
||||||
|
PathBuf::from(stripped)
|
||||||
|
} else {
|
||||||
|
path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn java_bin_name() -> &'static str {
|
||||||
|
if cfg!(target_os = "windows") { "java.exe" } else { "java" }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn find_java_in_bundle(bundle_dir: &PathBuf, log: &mut Option<std::fs::File>) -> Option<PathBuf> {
|
||||||
|
let java = bundle_dir.join("jre").join("bin").join(java_bin_name());
|
||||||
|
do_log(log, &format!("[find_java] {:?} exists={}", java, java.exists()));
|
||||||
|
if java.exists() { Some(java) } else { None }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn data_root_args() -> Vec<String> {
|
||||||
|
vec!["--dataRoot".to_string(), suwayomi_data_dir().to_string_lossy().into_owned()]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn jar_data_root_flag() -> String {
|
||||||
|
format!("-Dsuwayomi.server.rootDir={}", suwayomi_data_dir().to_string_lossy())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn jar_invocation(java: PathBuf, jar: PathBuf, working_dir: PathBuf) -> ServerInvocation {
|
||||||
|
ServerInvocation {
|
||||||
|
bin: java.to_string_lossy().into_owned(),
|
||||||
|
args: vec![
|
||||||
|
jar_data_root_flag(),
|
||||||
|
"-jar".to_string(),
|
||||||
|
jar.to_string_lossy().into_owned(),
|
||||||
|
],
|
||||||
|
working_dir: Some(working_dir),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn resolve_server_binary(
|
||||||
|
binary: &str,
|
||||||
|
app: &tauri::AppHandle,
|
||||||
|
log: &mut Option<std::fs::File>,
|
||||||
|
) -> Result<ServerInvocation, SpawnError> {
|
||||||
|
do_log(log, &format!("[resolve] binary={:?}", binary));
|
||||||
|
|
||||||
|
if !binary.trim().is_empty() {
|
||||||
|
let path = strip_unc(PathBuf::from(binary.trim()));
|
||||||
|
do_log(log, &format!("[resolve] user path={:?} exists={}", path, path.exists()));
|
||||||
|
if path.exists() {
|
||||||
|
let working_dir = path.parent().map(|p| p.to_path_buf());
|
||||||
|
return Ok(ServerInvocation {
|
||||||
|
bin: path.to_string_lossy().into_owned(),
|
||||||
|
args: data_root_args(),
|
||||||
|
working_dir,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
do_log(log, "[resolve] user path not found, falling through");
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Ok(exe) = std::env::current_exe() {
|
||||||
|
if let Some(bin_dir) = exe.parent() {
|
||||||
|
for name in &["tachidesk-server", "suwayomi-launcher"] {
|
||||||
|
let p = bin_dir.join(name);
|
||||||
|
do_log(log, &format!("[resolve] sibling={:?} exists={}", p, p.exists()));
|
||||||
|
if p.exists() {
|
||||||
|
return Ok(ServerInvocation {
|
||||||
|
bin: p.to_string_lossy().into_owned(),
|
||||||
|
args: data_root_args(),
|
||||||
|
working_dir: Some(bin_dir.to_path_buf()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "macos"))]
|
||||||
|
{
|
||||||
|
let resource_dir = {
|
||||||
|
let raw = app.path().resource_dir().unwrap_or_default();
|
||||||
|
let stripped = strip_unc(raw);
|
||||||
|
do_log(log, &format!("[resolve] resource_dir={:?}", stripped));
|
||||||
|
stripped
|
||||||
|
};
|
||||||
|
|
||||||
|
let bundle_dir = resource_dir.join("binaries").join("suwayomi-bundle");
|
||||||
|
let jar = bundle_dir.join("bin").join("Suwayomi-Server.jar");
|
||||||
|
|
||||||
|
do_log(log, &format!("[resolve] bundle_dir={:?} exists={}", bundle_dir, bundle_dir.exists()));
|
||||||
|
do_log(log, &format!("[resolve] jar={:?} exists={}", jar, jar.exists()));
|
||||||
|
|
||||||
|
if let Some(java) = find_java_in_bundle(&bundle_dir, log) {
|
||||||
|
if jar.exists() {
|
||||||
|
do_log(log, "[resolve] using bundled JRE + jar");
|
||||||
|
return Ok(jar_invocation(java, jar, bundle_dir));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for name in &["suwayomi-launcher", "suwayomi-launcher.sh", "tachidesk-server"] {
|
||||||
|
let p = resource_dir.join(name);
|
||||||
|
do_log(log, &format!("[resolve] sidecar={:?} exists={}", p, p.exists()));
|
||||||
|
if p.exists() {
|
||||||
|
return Ok(ServerInvocation {
|
||||||
|
bin: p.to_string_lossy().into_owned(),
|
||||||
|
args: data_root_args(),
|
||||||
|
working_dir: Some(resource_dir.clone()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(java) = find_java_in_bundle(&resource_dir, log) {
|
||||||
|
let jar = std::fs::read_dir(&resource_dir)
|
||||||
|
.ok()
|
||||||
|
.and_then(|mut rd| {
|
||||||
|
rd.find(|e| e.as_ref().map(|e| e.file_name().to_string_lossy().ends_with(".jar")).unwrap_or(false))
|
||||||
|
.and_then(|e| e.ok())
|
||||||
|
.map(|e| e.path())
|
||||||
|
});
|
||||||
|
if let Some(jar_path) = jar {
|
||||||
|
do_log(log, &format!("[resolve] generic JRE java={:?} jar={:?}", java, jar_path));
|
||||||
|
return Ok(jar_invocation(java, jar_path, resource_dir));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
{
|
||||||
|
let resource_dir = app.path().resource_dir().unwrap_or_default();
|
||||||
|
let bundle_dir = resource_dir.join("suwayomi-bundle");
|
||||||
|
|
||||||
|
do_log(log, &format!("[resolve] macOS resource_dir={:?}", resource_dir));
|
||||||
|
do_log(log, &format!("[resolve] macOS bundle_dir={:?} exists={}", bundle_dir, bundle_dir.exists()));
|
||||||
|
|
||||||
|
let java = bundle_dir.join("jre").join("bin").join("java");
|
||||||
|
let jar = bundle_dir.join("bin").join("Suwayomi-Server.jar");
|
||||||
|
let launcher_sh = bundle_dir.join("Suwayomi Launcher.command");
|
||||||
|
let launcher_jar = bundle_dir.join("Suwayomi-Launcher.jar");
|
||||||
|
|
||||||
|
do_log(log, &format!("[resolve] java={:?} exists={}", java, java.exists()));
|
||||||
|
do_log(log, &format!("[resolve] jar={:?} exists={}", jar, jar.exists()));
|
||||||
|
do_log(log, &format!("[resolve] launcher.command={:?} exists={}", launcher_sh, launcher_sh.exists()));
|
||||||
|
do_log(log, &format!("[resolve] launcher.jar={:?} exists={}", launcher_jar, launcher_jar.exists()));
|
||||||
|
|
||||||
|
if java.exists() && jar.exists() {
|
||||||
|
do_log(log, "[resolve] macOS using bundled JRE + Suwayomi-Server.jar");
|
||||||
|
return Ok(jar_invocation(java, jar, bundle_dir));
|
||||||
|
}
|
||||||
|
|
||||||
|
if launcher_sh.exists() {
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
let _ = std::fs::set_permissions(&launcher_sh, std::fs::Permissions::from_mode(0o755));
|
||||||
|
do_log(log, "[resolve] macOS using Suwayomi Launcher.command");
|
||||||
|
return Ok(ServerInvocation {
|
||||||
|
bin: launcher_sh.to_string_lossy().into_owned(),
|
||||||
|
args: vec![],
|
||||||
|
working_dir: Some(bundle_dir),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if java.exists() && launcher_jar.exists() {
|
||||||
|
do_log(log, "[resolve] macOS using bundled JRE + Suwayomi-Launcher.jar");
|
||||||
|
return Ok(jar_invocation(java, launcher_jar, bundle_dir));
|
||||||
|
}
|
||||||
|
|
||||||
|
do_log(log, "[resolve] macOS bundle not found, falling through to PATH");
|
||||||
|
}
|
||||||
|
|
||||||
|
for name in &["suwayomi-server", "tachidesk-server"] {
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
let resolved = std::process::Command::new("where")
|
||||||
|
.arg(name)
|
||||||
|
.output()
|
||||||
|
.ok()
|
||||||
|
.filter(|o| o.status.success())
|
||||||
|
.and_then(|o| String::from_utf8(o.stdout).ok())
|
||||||
|
.and_then(|s| s.lines().next().map(|l| l.trim().to_string()));
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "windows"))]
|
||||||
|
let resolved = std::process::Command::new("which")
|
||||||
|
.arg(name)
|
||||||
|
.output()
|
||||||
|
.ok()
|
||||||
|
.filter(|o| o.status.success())
|
||||||
|
.and_then(|o| String::from_utf8(o.stdout).ok())
|
||||||
|
.and_then(|s| s.lines().next().map(|l| l.trim().to_string()));
|
||||||
|
|
||||||
|
if let Some(bin_path) = resolved {
|
||||||
|
do_log(log, &format!("[resolve] found on PATH: {:?}", bin_path));
|
||||||
|
return Ok(ServerInvocation {
|
||||||
|
bin: bin_path,
|
||||||
|
args: vec![],
|
||||||
|
working_dir: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(SpawnError::NotConfigured(
|
||||||
|
"Server binary not found. Install Suwayomi-Server or set the path in Settings.".to_string(),
|
||||||
|
))
|
||||||
|
}
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "Moku",
|
"productName": "Moku",
|
||||||
"version": "0.5.0",
|
"version": "0.10.0",
|
||||||
"identifier": "dev.moku.app",
|
"identifier": "io.github.MokuProject.Moku",
|
||||||
"build": {
|
"build": {
|
||||||
|
"devUrl": "http://localhost:1420",
|
||||||
"frontendDist": "../dist",
|
"frontendDist": "../dist",
|
||||||
"beforeBuildCommand": "pnpm build"
|
"beforeBuildCommand": "pnpm build:static"
|
||||||
},
|
},
|
||||||
"app": {
|
"app": {
|
||||||
"windows": [
|
"windows": [
|
||||||
@@ -27,9 +28,7 @@
|
|||||||
},
|
},
|
||||||
"bundle": {
|
"bundle": {
|
||||||
"active": true,
|
"active": true,
|
||||||
"targets": [
|
"targets": ["nsis"],
|
||||||
"nsis"
|
|
||||||
],
|
|
||||||
"icon": [
|
"icon": [
|
||||||
"icons/32x32.png",
|
"icons/32x32.png",
|
||||||
"icons/128x128.png",
|
"icons/128x128.png",
|
||||||
@@ -49,10 +48,6 @@
|
|||||||
"plugins": {
|
"plugins": {
|
||||||
"shell": {
|
"shell": {
|
||||||
"open": true
|
"open": true
|
||||||
},
|
|
||||||
"updater": {
|
|
||||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDM2NEQzNDdFRjlDNUVEN0MKUldSODdjWDVmalJOTml1b0xzMDU3ZE1sNWJLZUhqUDN5cmJUdkdpeFlEVGNoQVN3UjhCc3AxV3QK",
|
|
||||||
"endpoints": []
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
{
|
{
|
||||||
"build": {
|
"build": {
|
||||||
"devUrl": "http://localhost:1420",
|
|
||||||
"beforeDevCommand": "pnpm dev"
|
"beforeDevCommand": "pnpm dev"
|
||||||
},
|
},
|
||||||
"app": {
|
"app": {
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"bundle": {
|
||||||
|
"targets": ["appimage", "deb"],
|
||||||
|
"externalBin": [
|
||||||
|
"binaries/suwayomi-launcher-linux"
|
||||||
|
],
|
||||||
|
"resources": {
|
||||||
|
"binaries/suwayomi-bundle/bin/Suwayomi-Server.jar": "Suwayomi-Server.jar",
|
||||||
|
"binaries/suwayomi-bundle/bin/catch_abort.so": "catch_abort.so",
|
||||||
|
"binaries/suwayomi-bundle/jre": "jre"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,20 +1,8 @@
|
|||||||
{
|
{
|
||||||
"bundle": {
|
"bundle": {
|
||||||
"createUpdaterArtifacts": true,
|
|
||||||
"resources": [
|
"resources": [
|
||||||
"binaries/suwayomi-bundle/bin/Suwayomi-Server.jar",
|
"binaries/suwayomi-bundle/bin/Suwayomi-Server.jar",
|
||||||
"binaries/suwayomi-bundle/jre/**/*"
|
"binaries/suwayomi-bundle/jre/**/*"
|
||||||
]
|
]
|
||||||
},
|
|
||||||
"plugins": {
|
|
||||||
"updater": {
|
|
||||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDM2NEQzNDdFRjlDNUVEN0MKUldSODdjWDVmalJOTml1b0xzMDU3ZE1sNWJLZUhqUDN5cmJUdkdpeFlEVGNoQVN3UjhCc3AxV3QK",
|
|
||||||
"endpoints": [
|
|
||||||
"https://github.com/Youwes09/Moku/releases/latest/download/latest.json"
|
|
||||||
],
|
|
||||||
"windows": {
|
|
||||||
"installMode": "passive"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,305 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { onMount } from "svelte";
|
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
|
||||||
import { listen } from "@tauri-apps/api/event";
|
|
||||||
import { getVersion } from "@tauri-apps/api/app";
|
|
||||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
|
||||||
import { gql } from "./lib/client";
|
|
||||||
import { GET_DOWNLOAD_STATUS } from "./lib/queries";
|
|
||||||
import { store, addToast, setActiveDownloads, setSettingsOpen } from "./store/state.svelte";
|
|
||||||
import type { DownloadStatus, DownloadQueueItem } from "./lib/types";
|
|
||||||
import Layout from "./components/layout/Layout.svelte";
|
|
||||||
import Reader from "./components/reader/Reader.svelte";
|
|
||||||
import Settings from "./components/settings/Settings.svelte";
|
|
||||||
import ThemeEditor from "./components/settings/ThemeEditor.svelte";
|
|
||||||
import TitleBar from "./components/layout/TitleBar.svelte";
|
|
||||||
import Toaster from "./components/layout/Toaster.svelte";
|
|
||||||
import SplashScreen from "./components/layout/SplashScreen.svelte";
|
|
||||||
import MangaPreview from "./components/shared/MangaPreview.svelte";
|
|
||||||
|
|
||||||
let themeStyleEl: HTMLStyleElement | null = null;
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
const themeId = store.settings.theme ?? "dark";
|
|
||||||
const isCustom = themeId.startsWith("custom:");
|
|
||||||
|
|
||||||
if (!isCustom) {
|
|
||||||
themeStyleEl?.remove();
|
|
||||||
themeStyleEl = null;
|
|
||||||
document.documentElement.setAttribute("data-theme", themeId);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const custom = store.settings.customThemes?.find(t => t.id === themeId);
|
|
||||||
if (!custom) {
|
|
||||||
themeStyleEl?.remove();
|
|
||||||
themeStyleEl = null;
|
|
||||||
document.documentElement.setAttribute("data-theme", "dark");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const vars = Object.entries(custom.tokens)
|
|
||||||
.map(([k, v]) => ` --${k}: ${v};`)
|
|
||||||
.join("\n");
|
|
||||||
const css = `[data-theme="custom"] {\n${vars}\n}`;
|
|
||||||
|
|
||||||
if (!themeStyleEl) {
|
|
||||||
themeStyleEl = document.createElement("style");
|
|
||||||
themeStyleEl.id = "moku-custom-theme";
|
|
||||||
document.head.appendChild(themeStyleEl);
|
|
||||||
}
|
|
||||||
themeStyleEl.textContent = css;
|
|
||||||
document.documentElement.setAttribute("data-theme", "custom");
|
|
||||||
});
|
|
||||||
|
|
||||||
let themeEditorOpen = $state(false);
|
|
||||||
let themeEditorEditId = $state<string | null>(null);
|
|
||||||
|
|
||||||
function openThemeEditor(id?: string | null) {
|
|
||||||
themeEditorEditId = id ?? null;
|
|
||||||
themeEditorOpen = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeThemeEditor() {
|
|
||||||
themeEditorOpen = false;
|
|
||||||
themeEditorEditId = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const MAX_ATTEMPTS = 10;
|
|
||||||
const win = getCurrentWindow();
|
|
||||||
|
|
||||||
let serverProbeOk = $state(false);
|
|
||||||
let appReady = $state(false);
|
|
||||||
let failed = $state(false);
|
|
||||||
let notConfigured = $state(false);
|
|
||||||
let idle = $state(false);
|
|
||||||
let devSplash = $state(false);
|
|
||||||
let platformScale = $state(1);
|
|
||||||
|
|
||||||
function applyZoom() {
|
|
||||||
const normalized = store.settings.uiScale * platformScale;
|
|
||||||
document.documentElement.style.zoom = `${normalized}%`;
|
|
||||||
document.documentElement.style.setProperty("--ui-scale", String(normalized));
|
|
||||||
document.documentElement.style.setProperty("--visual-vh", `${window.innerHeight / (normalized / 100)}px`);
|
|
||||||
}
|
|
||||||
|
|
||||||
let prevQueue: DownloadQueueItem[] = [];
|
|
||||||
let idleTimer: ReturnType<typeof setTimeout> | null = null;
|
|
||||||
let pollInterval: ReturnType<typeof setInterval>;
|
|
||||||
let unlistenDownload: (() => void) | undefined;
|
|
||||||
|
|
||||||
function detectCompletions(prev: DownloadQueueItem[], next: DownloadQueueItem[]) {
|
|
||||||
for (const item of prev) {
|
|
||||||
if (item.state !== "DOWNLOADING") continue;
|
|
||||||
if (!next.some(q => q.chapter.id === item.chapter.id)) {
|
|
||||||
const manga = item.chapter.manga;
|
|
||||||
addToast({ kind: "success", title: "Chapter downloaded",
|
|
||||||
body: manga ? `${manga.title} — ${item.chapter.name}` : item.chapter.name,
|
|
||||||
duration: 4000 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyQueue(next: DownloadQueueItem[]) {
|
|
||||||
detectCompletions(prevQueue, next);
|
|
||||||
prevQueue = next;
|
|
||||||
setActiveDownloads(next.map(item => ({
|
|
||||||
chapterId: item.chapter.id, mangaId: item.chapter.mangaId, progress: item.progress,
|
|
||||||
})));
|
|
||||||
}
|
|
||||||
|
|
||||||
function resetIdle() {
|
|
||||||
if (idleTimer) clearTimeout(idleTimer);
|
|
||||||
if (idle) return;
|
|
||||||
const ms = (store.settings.idleTimeoutMin ?? 5) * 60 * 1000;
|
|
||||||
if (ms === 0) return;
|
|
||||||
idleTimer = setTimeout(() => idle = true, ms);
|
|
||||||
}
|
|
||||||
|
|
||||||
const idleEvents = ["mousemove", "mousedown", "keydown", "touchstart", "wheel"] as const;
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (!appReady) return;
|
|
||||||
idleEvents.forEach(e => window.addEventListener(e, resetIdle, { passive: true }));
|
|
||||||
resetIdle();
|
|
||||||
return () => idleEvents.forEach(e => window.removeEventListener(e, resetIdle));
|
|
||||||
});
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
store.settings.uiScale; platformScale;
|
|
||||||
applyZoom();
|
|
||||||
});
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (!appReady) return;
|
|
||||||
const poll = () => gql<{ downloadStatus: DownloadStatus }>(GET_DOWNLOAD_STATUS)
|
|
||||||
.then(d => applyQueue(d.downloadStatus.queue)).catch(console.error);
|
|
||||||
poll();
|
|
||||||
pollInterval = setInterval(poll, 2000);
|
|
||||||
return () => clearInterval(pollInterval);
|
|
||||||
});
|
|
||||||
|
|
||||||
async function checkForUpdateSilently() {
|
|
||||||
try {
|
|
||||||
const [currentVersion, releases] = await Promise.all([
|
|
||||||
getVersion(),
|
|
||||||
invoke<Array<{ tag_name: string; html_url: string }>>("list_releases"),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const valid = releases.filter(r => typeof r.tag_name === "string" && r.tag_name.trim());
|
|
||||||
if (!valid.length) return;
|
|
||||||
|
|
||||||
const parse = (tag: string): number[] =>
|
|
||||||
tag.replace(/^v/, "").split(".").map(Number);
|
|
||||||
|
|
||||||
const compare = (a: number[], b: number[]): number => {
|
|
||||||
for (let i = 0; i < 3; i++) {
|
|
||||||
if ((a[i] ?? 0) !== (b[i] ?? 0)) return (b[i] ?? 0) - (a[i] ?? 0);
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
const latestTag = valid
|
|
||||||
.map(r => r.tag_name)
|
|
||||||
.sort((a, b) => compare(parse(a), parse(b)))[0]
|
|
||||||
.replace(/^v/, "");
|
|
||||||
|
|
||||||
const isNewer = compare(parse(latestTag), parse(currentVersion)) < 0;
|
|
||||||
if (isNewer) {
|
|
||||||
addToast({
|
|
||||||
kind: "info",
|
|
||||||
title: `Update available — v${latestTag}`,
|
|
||||||
body: "Open Settings → About to install.",
|
|
||||||
duration: 8000,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
|
|
||||||
let cancelProbe = false;
|
|
||||||
|
|
||||||
function startProbe() {
|
|
||||||
cancelProbe = false;
|
|
||||||
failed = false;
|
|
||||||
let tries = 0;
|
|
||||||
|
|
||||||
async function probe() {
|
|
||||||
if (cancelProbe) return;
|
|
||||||
tries++;
|
|
||||||
try {
|
|
||||||
const rawUrl = store.settings.serverUrl;
|
|
||||||
const base = typeof rawUrl === "string" && rawUrl.trim()
|
|
||||||
? rawUrl.replace(/\/$/, "")
|
|
||||||
: "http://127.0.0.1:4567";
|
|
||||||
const s = store.settings;
|
|
||||||
const auth: Record<string, string> = s.serverAuthEnabled && s.serverAuthUser && s.serverAuthPass
|
|
||||||
? { Authorization: `Basic ${btoa(`${s.serverAuthUser.trim()}:${s.serverAuthPass.trim()}`)}` }
|
|
||||||
: {};
|
|
||||||
const res = await fetch(`${base}/api/graphql`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json", ...auth },
|
|
||||||
body: JSON.stringify({ query: "{ __typename }" }),
|
|
||||||
signal: AbortSignal.timeout(2000),
|
|
||||||
});
|
|
||||||
if (res.ok && !cancelProbe) { serverProbeOk = true; return; }
|
|
||||||
} catch {}
|
|
||||||
if (tries >= MAX_ATTEMPTS && !cancelProbe) { failed = true; return; }
|
|
||||||
if (!cancelProbe) setTimeout(probe, 750);
|
|
||||||
}
|
|
||||||
|
|
||||||
setTimeout(probe, 800);
|
|
||||||
}
|
|
||||||
|
|
||||||
onMount(async () => {
|
|
||||||
document.addEventListener("contextmenu", e => e.preventDefault());
|
|
||||||
(window as any).__mokuShowSplash = () => devSplash = true;
|
|
||||||
|
|
||||||
platformScale = await invoke<number>("get_platform_ui_scale").catch(() => 1);
|
|
||||||
applyZoom();
|
|
||||||
|
|
||||||
store.isFullscreen = await win.isFullscreen();
|
|
||||||
const unlistenResize = await win.onResized(async () => {
|
|
||||||
store.isFullscreen = await win.isFullscreen();
|
|
||||||
});
|
|
||||||
|
|
||||||
if (store.settings.autoStartServer) {
|
|
||||||
invoke<void>("spawn_server", { binary: store.settings.serverBinary }).catch((err: any) => {
|
|
||||||
if (err?.kind === "NotConfigured") {
|
|
||||||
notConfigured = true;
|
|
||||||
} else {
|
|
||||||
console.warn("Could not start server:", err);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
startProbe();
|
|
||||||
|
|
||||||
type P = { chapterId: number; mangaId: number; progress: number }[];
|
|
||||||
unlistenDownload = await listen<P>("download-progress", e => { setActiveDownloads(e.payload); });
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
cancelProbe = true;
|
|
||||||
unlistenResize();
|
|
||||||
if (store.settings.autoStartServer) invoke("kill_server").catch(() => {});
|
|
||||||
if (idleTimer) clearTimeout(idleTimer);
|
|
||||||
if (pollInterval) clearInterval(pollInterval);
|
|
||||||
unlistenDownload?.();
|
|
||||||
delete (window as any).__mokuShowSplash;
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (!appReady) return;
|
|
||||||
const timer = setTimeout(checkForUpdateSilently, 5_000);
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
});
|
|
||||||
|
|
||||||
function handleRetry() {
|
|
||||||
failed = false;
|
|
||||||
notConfigured = false;
|
|
||||||
serverProbeOk = false;
|
|
||||||
startProbe();
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleBypass() {
|
|
||||||
cancelProbe = true;
|
|
||||||
serverProbeOk = true;
|
|
||||||
appReady = true;
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#if devSplash}
|
|
||||||
<SplashScreen mode="idle" showFps showCards={store.settings.splashCards ?? true}
|
|
||||||
onDismiss={() => setTimeout(() => devSplash = false, 340)} />
|
|
||||||
{:else if !appReady}
|
|
||||||
<SplashScreen mode="loading" ringFull={serverProbeOk} {failed} {notConfigured}
|
|
||||||
showCards={store.settings.splashCards ?? true}
|
|
||||||
onReady={() => appReady = true}
|
|
||||||
onRetry={handleRetry}
|
|
||||||
onBypass={handleBypass} />
|
|
||||||
{:else}
|
|
||||||
<div class="root">
|
|
||||||
{#if idle && !store.activeChapter}
|
|
||||||
<SplashScreen mode="idle" showCards={store.settings.splashCards ?? true}
|
|
||||||
onDismiss={() => { idle = false; resetIdle(); }} />
|
|
||||||
{/if}
|
|
||||||
{#if !store.activeChapter && !store.isFullscreen}<TitleBar />{/if}
|
|
||||||
<div class="content">
|
|
||||||
{#if store.activeChapter}<Reader />{:else}<Layout />{/if}
|
|
||||||
</div>
|
|
||||||
{#if store.settingsOpen}<Settings onOpenThemeEditor={openThemeEditor} />{/if}
|
|
||||||
{#if themeEditorOpen}
|
|
||||||
<ThemeEditor
|
|
||||||
bind:editingId={themeEditorEditId}
|
|
||||||
onClose={closeThemeEditor}
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
<MangaPreview />
|
|
||||||
<Toaster />
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.root { display: flex; flex-direction: column; height: 100%; overflow: hidden; }
|
|
||||||
.content { flex: 1; overflow: hidden; }
|
|
||||||
</style>
|
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
@import '$lib/components/settings/Settings.css';
|
||||||
|
@import '$lib/styles/themes.css';
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg-void: #080808;
|
||||||
|
--bg-base: #0c0c0c;
|
||||||
|
--bg-surface: #101010;
|
||||||
|
--bg-raised: #151515;
|
||||||
|
--bg-overlay: #1a1a1a;
|
||||||
|
--bg-subtle: #202020;
|
||||||
|
|
||||||
|
--border-dim: #1c1c1c;
|
||||||
|
--border-base: #242424;
|
||||||
|
--border-strong: #2e2e2e;
|
||||||
|
--border-focus: #4a5c4a;
|
||||||
|
|
||||||
|
--text-primary: #f0efec;
|
||||||
|
--text-secondary: #c8c6c0;
|
||||||
|
--text-muted: #8a8880;
|
||||||
|
--text-faint: #4e4d4a;
|
||||||
|
--text-disabled: #2a2a28;
|
||||||
|
|
||||||
|
--accent: #6b8f6b;
|
||||||
|
--accent-dim: #2a3d2a;
|
||||||
|
--accent-muted: #1a251a;
|
||||||
|
--accent-fg: #a8c4a8;
|
||||||
|
--accent-bright: #8fb88f;
|
||||||
|
|
||||||
|
--color-error: #c47a7a;
|
||||||
|
--color-error-bg: #1f1212;
|
||||||
|
--color-success: #7aab7a;
|
||||||
|
--color-info: #7a9ec4;
|
||||||
|
--color-info-bg: #121a1f;
|
||||||
|
--color-read: #2e2e2c;
|
||||||
|
|
||||||
|
--dot-active: var(--accent);
|
||||||
|
--dot-inactive: var(--text-faint);
|
||||||
|
|
||||||
|
--t-fast: 0.08s ease;
|
||||||
|
--t-base: 0.14s ease;
|
||||||
|
--t-slow: 0.22s ease;
|
||||||
|
|
||||||
|
--radius-sm: 3px;
|
||||||
|
--radius-md: 5px;
|
||||||
|
--radius-lg: 7px;
|
||||||
|
--radius-xl: 10px;
|
||||||
|
--radius-2xl: 14px;
|
||||||
|
--radius-full: 9999px;
|
||||||
|
|
||||||
|
--sp-1: 4px;
|
||||||
|
--sp-2: 8px;
|
||||||
|
--sp-3: 12px;
|
||||||
|
--sp-4: 16px;
|
||||||
|
--sp-5: 20px;
|
||||||
|
--sp-6: 24px;
|
||||||
|
--sp-8: 32px;
|
||||||
|
--sp-10: 40px;
|
||||||
|
|
||||||
|
--sidebar-width: 52px;
|
||||||
|
--titlebar-height: 36px;
|
||||||
|
|
||||||
|
--font-ui: "DM Mono", "Fira Mono", ui-monospace, monospace;
|
||||||
|
--font-sans: "DM Sans", ui-sans-serif, system-ui, sans-serif;
|
||||||
|
|
||||||
|
--text-2xs: 10px;
|
||||||
|
--text-xs: 11px;
|
||||||
|
--text-sm: 12px;
|
||||||
|
--text-base: 13px;
|
||||||
|
--text-md: 14px;
|
||||||
|
--text-lg: 15px;
|
||||||
|
--text-xl: 17px;
|
||||||
|
--text-2xl: 20px;
|
||||||
|
--text-3xl: 24px;
|
||||||
|
|
||||||
|
--weight-normal: 400;
|
||||||
|
--weight-medium: 500;
|
||||||
|
--weight-semi: 600;
|
||||||
|
|
||||||
|
--leading-none: 1;
|
||||||
|
--leading-tight: 1.3;
|
||||||
|
--leading-snug: 1.45;
|
||||||
|
--leading-base: 1.6;
|
||||||
|
|
||||||
|
--tracking-tight: -0.02em;
|
||||||
|
--tracking-normal: 0;
|
||||||
|
--tracking-wide: 0.06em;
|
||||||
|
--tracking-wider: 0.1em;
|
||||||
|
|
||||||
|
--z-reader: 50;
|
||||||
|
--z-modal: 100;
|
||||||
|
--z-settings: 150;
|
||||||
|
}
|
||||||
|
|
||||||
|
*, *::before, *::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--bg-void);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
#svelte {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
color: inherit;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
input, textarea, select {
|
||||||
|
font: inherit;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul, ol { list-style: none; }
|
||||||
|
|
||||||
|
img, svg { display: block; max-width: 100%; }
|
||||||
|
|
||||||
|
p { margin: 0; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: var(--text-base);
|
||||||
|
font-weight: var(--weight-normal);
|
||||||
|
line-height: var(--leading-base);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: transparent transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
*::-webkit-scrollbar { width: 4px; height: 4px; }
|
||||||
|
*::-webkit-scrollbar-track { background: transparent; }
|
||||||
|
*::-webkit-scrollbar-thumb { background: transparent; border-radius: 99px; }
|
||||||
|
*::-webkit-scrollbar-thumb:hover { background: transparent; }
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeUp {
|
||||||
|
from { opacity: 0; transform: translateY(5px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeDown {
|
||||||
|
from { opacity: 0; transform: translateY(-5px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes scaleIn {
|
||||||
|
from { opacity: 0; transform: scale(0.97); }
|
||||||
|
to { opacity: 1; transform: scale(1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
from { transform: rotate(0deg); }
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.35; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shimmer {
|
||||||
|
from { background-position: -200% 0; }
|
||||||
|
to { background-position: 200% 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.anim-fade-in { animation: fadeIn 0.14s ease both; }
|
||||||
|
.anim-fade-up { animation: fadeUp 0.18s ease both; }
|
||||||
|
.anim-fade-down { animation: fadeDown 0.18s ease both; }
|
||||||
|
.anim-scale-in { animation: scaleIn 0.14s ease both; }
|
||||||
|
.anim-pulse { animation: pulse 1.6s ease infinite; }
|
||||||
|
.anim-spin { animation: spin 0.7s linear infinite; }
|
||||||
|
|
||||||
|
.skeleton {
|
||||||
|
background: linear-gradient(90deg, var(--bg-raised) 25%, var(--bg-overlay) 50%, var(--bg-raised) 75%);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: shimmer 1.4s ease infinite;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
declare global {
|
||||||
|
namespace App {}
|
||||||
|
const __APP_VERSION__: string
|
||||||
|
}
|
||||||
|
export {}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<link rel="icon" href="%sveltekit.assets%/favicon.svg" />
|
||||||
|
%sveltekit.head%
|
||||||
|
</head>
|
||||||
|
<body data-theme="dark">
|
||||||
|
<div id="svelte">%sveltekit.body%</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { store } from "../../store/state.svelte";
|
|
||||||
import Sidebar from "./Sidebar.svelte";
|
|
||||||
import Home from "../pages/Home.svelte";
|
|
||||||
import Library from "../pages/Library.svelte";
|
|
||||||
import SeriesDetail from "../pages/SeriesDetail.svelte";
|
|
||||||
import RecentActivity from "./RecentActivity.svelte";
|
|
||||||
import Search from "../pages/Search.svelte";
|
|
||||||
import Discover from "../pages/Discover.svelte";
|
|
||||||
import GenreDrillPage from "../pages/GenreDrillPage.svelte";
|
|
||||||
import Downloads from "../pages/Downloads.svelte";
|
|
||||||
import Extensions from "../pages/Extensions.svelte";
|
|
||||||
import Tracking from "../pages/Tracking.svelte";
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="root">
|
|
||||||
<Sidebar />
|
|
||||||
<main class="main">
|
|
||||||
{#if store.activeManga}
|
|
||||||
<SeriesDetail />
|
|
||||||
{:else if store.navPage === "home"}
|
|
||||||
<Home />
|
|
||||||
{:else if store.navPage === "library"}
|
|
||||||
<Library />
|
|
||||||
{:else if store.navPage === "search"}
|
|
||||||
<Search />
|
|
||||||
{:else if store.navPage === "history"}
|
|
||||||
<RecentActivity />
|
|
||||||
{:else if (store.navPage === "explore" || store.navPage === "sources") && store.genreFilter}
|
|
||||||
<GenreDrillPage />
|
|
||||||
{:else if store.navPage === "explore" || store.navPage === "sources"}
|
|
||||||
<Discover />
|
|
||||||
{:else if store.navPage === "downloads"}
|
|
||||||
<Downloads />
|
|
||||||
{:else if store.navPage === "extensions"}
|
|
||||||
<Extensions />
|
|
||||||
{:else if store.navPage === "tracking"}
|
|
||||||
<Tracking />
|
|
||||||
{:else}
|
|
||||||
<Home />
|
|
||||||
{/if}
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.root { display: flex; height: 100%; background: var(--bg-base); overflow: hidden; }
|
|
||||||
.main { flex: 1; overflow: hidden; background: var(--bg-surface); transform: translateZ(0); contain: layout style; }
|
|
||||||
</style>
|
|
||||||
@@ -1,323 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { ClockCounterClockwise, Trash, MagnifyingGlass, Play, Books, Fire, BookOpen, Clock, TrendUp } from "phosphor-svelte";
|
|
||||||
import { thumbUrl } from "../../lib/client";
|
|
||||||
import { store, clearHistory, openReader, setActiveManga } from "../../store/state.svelte";
|
|
||||||
import type { HistoryEntry } from "../../store/state.svelte";
|
|
||||||
|
|
||||||
let search = $state("");
|
|
||||||
let confirmClear = $state(false);
|
|
||||||
|
|
||||||
function timeAgo(ts: number): string {
|
|
||||||
const diff = Date.now() - ts, m = Math.floor(diff / 60000);
|
|
||||||
if (m < 1) return "Just now";
|
|
||||||
if (m < 60) return `${m}m ago`;
|
|
||||||
const h = Math.floor(m / 60);
|
|
||||||
if (h < 24) return `${h}h ago`;
|
|
||||||
const d = Math.floor(h / 24);
|
|
||||||
if (d < 7) return `${d}d ago`;
|
|
||||||
return new Date(ts).toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
|
||||||
}
|
|
||||||
|
|
||||||
function dayLabel(ts: number): string {
|
|
||||||
const d = new Date(ts), now = new Date();
|
|
||||||
if (d.toDateString() === now.toDateString()) return "Today";
|
|
||||||
const yest = new Date(now); yest.setDate(now.getDate() - 1);
|
|
||||||
if (d.toDateString() === yest.toDateString()) return "Yesterday";
|
|
||||||
return d.toLocaleDateString("en-US", { weekday: "long", month: "long", day: "numeric" });
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatReadTime(m: number): string {
|
|
||||||
if (m < 1) return "< 1 min";
|
|
||||||
if (m < 60) return `${m} min`;
|
|
||||||
const h = Math.floor(m / 60), r = m % 60;
|
|
||||||
return r === 0 ? `${h}h` : `${h}h ${r}m`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const SESSION_GAP_MS = 30 * 60 * 1000;
|
|
||||||
|
|
||||||
interface Session {
|
|
||||||
mangaId: number;
|
|
||||||
mangaTitle: string;
|
|
||||||
thumbnailUrl: string;
|
|
||||||
latestChapterId: number;
|
|
||||||
latestChapterName: string;
|
|
||||||
latestPageNumber: number;
|
|
||||||
firstChapterName: string;
|
|
||||||
chapterCount: number;
|
|
||||||
readAt: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildSessions(entries: HistoryEntry[]): Session[] {
|
|
||||||
if (!entries.length) return [];
|
|
||||||
const sessions: Session[] = [];
|
|
||||||
let i = 0;
|
|
||||||
while (i < entries.length) {
|
|
||||||
const anchor = entries[i];
|
|
||||||
const group: HistoryEntry[] = [anchor];
|
|
||||||
let j = i + 1;
|
|
||||||
while (j < entries.length) {
|
|
||||||
const next = entries[j];
|
|
||||||
if (next.mangaId === anchor.mangaId && anchor.readAt - next.readAt <= SESSION_GAP_MS) {
|
|
||||||
group.push(next); j++;
|
|
||||||
} else break;
|
|
||||||
}
|
|
||||||
const latest = group[0], oldest = group[group.length - 1];
|
|
||||||
sessions.push({
|
|
||||||
mangaId: latest.mangaId,
|
|
||||||
mangaTitle: latest.mangaTitle,
|
|
||||||
thumbnailUrl: latest.thumbnailUrl,
|
|
||||||
latestChapterId: latest.chapterId,
|
|
||||||
latestChapterName: latest.chapterName,
|
|
||||||
latestPageNumber: latest.pageNumber,
|
|
||||||
firstChapterName: oldest.chapterName,
|
|
||||||
chapterCount: group.length,
|
|
||||||
readAt: latest.readAt,
|
|
||||||
});
|
|
||||||
i = j;
|
|
||||||
}
|
|
||||||
return sessions;
|
|
||||||
}
|
|
||||||
|
|
||||||
const filtered = $derived(search.trim()
|
|
||||||
? store.history.filter((e) =>
|
|
||||||
e.mangaTitle.toLowerCase().includes(search.toLowerCase()) ||
|
|
||||||
e.chapterName.toLowerCase().includes(search.toLowerCase())
|
|
||||||
)
|
|
||||||
: store.history);
|
|
||||||
|
|
||||||
const sessions = $derived(buildSessions(filtered));
|
|
||||||
|
|
||||||
const groups = $derived.by(() => {
|
|
||||||
const map = new Map<string, Session[]>();
|
|
||||||
for (const s of sessions) {
|
|
||||||
const l = dayLabel(s.readAt);
|
|
||||||
if (!map.has(l)) map.set(l, []);
|
|
||||||
map.get(l)!.push(s);
|
|
||||||
}
|
|
||||||
return Array.from(map.entries()).map(([label, items]) => ({ label, items }));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Resume: navigate to the manga's SeriesDetail (which will pick up from
|
|
||||||
// activeChapterList once chapters load). We can't hold a stale chapter list
|
|
||||||
// here — SeriesDetail fetches fresh chapters itself.
|
|
||||||
function resume(session: Session) {
|
|
||||||
setActiveManga({
|
|
||||||
id: session.mangaId,
|
|
||||||
title: session.mangaTitle,
|
|
||||||
thumbnailUrl: session.thumbnailUrl,
|
|
||||||
inLibrary: false,
|
|
||||||
} as any);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleClear() {
|
|
||||||
if (!confirmClear) { confirmClear = true; setTimeout(() => confirmClear = false, 3000); return; }
|
|
||||||
clearHistory(); confirmClear = false;
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="root">
|
|
||||||
|
|
||||||
<div class="header">
|
|
||||||
<span class="heading">History</span>
|
|
||||||
<div class="header-right">
|
|
||||||
<div class="search-wrap">
|
|
||||||
<MagnifyingGlass size={12} class="search-icon" weight="light" />
|
|
||||||
<input class="search" placeholder="Search history…" bind:value={search} />
|
|
||||||
{#if search}<button class="search-clear" onclick={() => search = ""}>×</button>{/if}
|
|
||||||
</div>
|
|
||||||
{#if store.history.length > 0}
|
|
||||||
<button class="clear-btn" class:confirm={confirmClear} onclick={handleClear}
|
|
||||||
title={confirmClear ? "Click again to confirm" : "Clear history"}>
|
|
||||||
<Trash size={14} weight="light" />
|
|
||||||
{#if confirmClear}<span class="clear-label">Confirm?</span>{/if}
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if store.readingStats.totalChaptersRead > 0}
|
|
||||||
<div class="stats-bar">
|
|
||||||
<div class="stat-group">
|
|
||||||
<Fire size={13} weight="fill" class="stat-fire" />
|
|
||||||
<span class="stat-val accent">{store.readingStats.currentStreakDays}</span>
|
|
||||||
<span class="stat-label">day streak</span>
|
|
||||||
</div>
|
|
||||||
<div class="stat-sep"></div>
|
|
||||||
<div class="stat-group">
|
|
||||||
<BookOpen size={13} weight="light" class="stat-icon-neutral" />
|
|
||||||
<span class="stat-val">{store.readingStats.totalChaptersRead}</span>
|
|
||||||
<span class="stat-label">chapters</span>
|
|
||||||
</div>
|
|
||||||
<div class="stat-sep"></div>
|
|
||||||
<div class="stat-group">
|
|
||||||
<Clock size={13} weight="light" class="stat-icon-neutral" />
|
|
||||||
<span class="stat-val">{formatReadTime(store.readingStats.totalMinutesRead)}</span>
|
|
||||||
<span class="stat-label">read time</span>
|
|
||||||
</div>
|
|
||||||
<div class="stat-sep"></div>
|
|
||||||
<div class="stat-group">
|
|
||||||
<TrendUp size={13} weight="light" class="stat-icon-neutral" />
|
|
||||||
<span class="stat-val">{store.readingStats.totalMangaRead}</span>
|
|
||||||
<span class="stat-label">series</span>
|
|
||||||
</div>
|
|
||||||
<div class="stat-sep"></div>
|
|
||||||
<div class="stat-group">
|
|
||||||
<span class="stat-val muted">{store.readingStats.longestStreakDays}d</span>
|
|
||||||
<span class="stat-label">best streak</span>
|
|
||||||
</div>
|
|
||||||
<span class="stats-note">Stats are preserved when you clear the feed</span>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if store.history.length === 0}
|
|
||||||
<div class="empty">
|
|
||||||
<ClockCounterClockwise size={32} weight="light" class="empty-icon" />
|
|
||||||
<p class="empty-text">No reading history yet</p>
|
|
||||||
<p class="empty-hint">Chapters you read will appear here</p>
|
|
||||||
</div>
|
|
||||||
{:else if sessions.length === 0}
|
|
||||||
<div class="empty">
|
|
||||||
<Books size={28} weight="light" class="empty-icon" />
|
|
||||||
<p class="empty-text">No results for "{search}"</p>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div class="timeline">
|
|
||||||
{#each groups as { label, items }}
|
|
||||||
<div class="day-group">
|
|
||||||
<div class="day-label-row">
|
|
||||||
<span class="day-label">{label}</span>
|
|
||||||
<div class="day-line"></div>
|
|
||||||
</div>
|
|
||||||
<div class="session-list">
|
|
||||||
{#each items as session (session.latestChapterId)}
|
|
||||||
<button class="session-row" onclick={() => resume(session)}>
|
|
||||||
<div class="thumb-wrap">
|
|
||||||
<img src={thumbUrl(session.thumbnailUrl)} alt={session.mangaTitle} class="thumb" />
|
|
||||||
{#if session.chapterCount > 1}
|
|
||||||
<span class="session-count">{session.chapterCount}</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<div class="session-info">
|
|
||||||
<span class="session-title">{session.mangaTitle}</span>
|
|
||||||
<span class="session-chapter">
|
|
||||||
{#if session.chapterCount > 1}
|
|
||||||
{session.firstChapterName}
|
|
||||||
<span class="ch-arrow">→</span>
|
|
||||||
{session.latestChapterName}
|
|
||||||
{:else}
|
|
||||||
{session.latestChapterName}
|
|
||||||
{#if session.latestPageNumber > 1}
|
|
||||||
<span class="ch-page">p.{session.latestPageNumber}</span>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<span class="session-time">{timeAgo(session.readAt)}</span>
|
|
||||||
<div class="play-pill">
|
|
||||||
<Play size={10} weight="fill" /> Resume
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.root { display: flex; flex-direction: column; height: 100%; overflow: hidden; animation: fadeIn 0.14s ease both; }
|
|
||||||
|
|
||||||
.header {
|
|
||||||
display: flex; align-items: center; justify-content: space-between;
|
|
||||||
padding: var(--sp-4) var(--sp-6); border-bottom: 1px solid var(--border-dim); flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.heading { font-family: var(--font-ui); font-size: var(--text-xs); font-weight: var(--weight-normal); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; }
|
|
||||||
.header-right { display: flex; align-items: center; gap: var(--sp-2); }
|
|
||||||
|
|
||||||
.search-wrap { position: relative; display: flex; align-items: center; }
|
|
||||||
.search-wrap :global(.search-icon) { position: absolute; left: 9px; color: var(--text-faint); pointer-events: none; }
|
|
||||||
.search { background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 5px 26px; color: var(--text-primary); font-size: var(--text-sm); width: 180px; outline: none; transition: border-color var(--t-base); }
|
|
||||||
.search::placeholder { color: var(--text-faint); }
|
|
||||||
.search:focus { border-color: var(--border-strong); }
|
|
||||||
.search-clear { position: absolute; right: 7px; color: var(--text-faint); font-size: 14px; line-height: 1; background: none; border: none; cursor: pointer; padding: 2px; transition: color var(--t-base); }
|
|
||||||
.search-clear:hover { color: var(--text-muted); }
|
|
||||||
|
|
||||||
.clear-btn {
|
|
||||||
display: flex; align-items: center; gap: 5px;
|
|
||||||
height: 28px; padding: 0 var(--sp-2); border-radius: var(--radius-md);
|
|
||||||
color: var(--text-faint); background: none; border: 1px solid transparent;
|
|
||||||
cursor: pointer; font-family: var(--font-ui); font-size: var(--text-2xs);
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
transition: color var(--t-base), background var(--t-base), border-color var(--t-base);
|
|
||||||
}
|
|
||||||
.clear-btn:hover { color: var(--color-error); background: var(--color-error-bg); }
|
|
||||||
.clear-btn.confirm { color: var(--color-error); background: var(--color-error-bg); border-color: var(--color-error); }
|
|
||||||
.clear-label { font-size: var(--text-2xs); }
|
|
||||||
|
|
||||||
.stats-bar {
|
|
||||||
display: flex; align-items: center; gap: var(--sp-3); flex-wrap: wrap;
|
|
||||||
padding: var(--sp-3) var(--sp-6); border-bottom: 1px solid var(--border-dim);
|
|
||||||
background: var(--bg-raised); flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.stat-group { display: flex; align-items: center; gap: 5px; }
|
|
||||||
.stat-sep { width: 1px; height: 14px; background: var(--border-dim); flex-shrink: 0; }
|
|
||||||
:global(.stat-fire) { color: #f97316; }
|
|
||||||
:global(.stat-icon-neutral) { color: var(--text-faint); }
|
|
||||||
.stat-val { font-family: var(--font-ui); font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-secondary); }
|
|
||||||
.stat-val.accent { color: var(--accent-fg); }
|
|
||||||
.stat-val.muted { color: var(--text-faint); }
|
|
||||||
.stat-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
|
||||||
.stats-note { margin-left: auto; font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); opacity: 0.5; letter-spacing: var(--tracking-wide); font-style: italic; }
|
|
||||||
|
|
||||||
.timeline { flex: 1; overflow-y: auto; padding: var(--sp-4) var(--sp-6); }
|
|
||||||
|
|
||||||
.day-group { margin-bottom: var(--sp-5); }
|
|
||||||
.day-label-row { display: flex; align-items: center; gap: var(--sp-3); margin-bottom: var(--sp-3); }
|
|
||||||
.day-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; white-space: nowrap; flex-shrink: 0; }
|
|
||||||
.day-line { flex: 1; height: 1px; background: var(--border-dim); }
|
|
||||||
|
|
||||||
.session-list { display: flex; flex-direction: column; gap: 2px; }
|
|
||||||
|
|
||||||
.session-row {
|
|
||||||
display: flex; align-items: center; gap: var(--sp-3);
|
|
||||||
width: 100%; padding: 8px var(--sp-3); border-radius: var(--radius-md);
|
|
||||||
border: 1px solid transparent; background: none; text-align: left; cursor: pointer;
|
|
||||||
transition: background var(--t-fast), border-color var(--t-fast);
|
|
||||||
}
|
|
||||||
.session-row:hover { background: var(--bg-raised); border-color: var(--border-dim); }
|
|
||||||
.session-row:hover .play-pill { opacity: 1; transform: translateX(0); }
|
|
||||||
|
|
||||||
.thumb-wrap { position: relative; flex-shrink: 0; }
|
|
||||||
.thumb { width: 38px; height: 54px; border-radius: var(--radius-sm); object-fit: cover; display: block; background: var(--bg-raised); border: 1px solid var(--border-dim); }
|
|
||||||
.session-count {
|
|
||||||
position: absolute; bottom: -4px; right: -6px;
|
|
||||||
background: var(--accent-muted); border: 1px solid var(--accent-dim); color: var(--accent-fg);
|
|
||||||
font-family: var(--font-ui); font-size: 9px; font-weight: 600;
|
|
||||||
padding: 1px 4px; border-radius: 6px; line-height: 1.4; pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.session-info { flex: 1; display: flex; flex-direction: column; gap: 3px; overflow: hidden; min-width: 0; }
|
|
||||||
.session-title { font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
||||||
.session-chapter { font-size: var(--text-xs); color: var(--text-muted); display: flex; align-items: center; gap: var(--sp-1); min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
||||||
.ch-arrow { color: var(--text-faint); font-size: 10px; flex-shrink: 0; }
|
|
||||||
.ch-page { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); flex-shrink: 0; }
|
|
||||||
|
|
||||||
.session-time { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); flex-shrink: 0; white-space: nowrap; }
|
|
||||||
.play-pill {
|
|
||||||
display: flex; align-items: center; gap: 4px; flex-shrink: 0;
|
|
||||||
font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide);
|
|
||||||
color: var(--accent-fg); background: var(--accent-muted); border: 1px solid var(--accent-dim);
|
|
||||||
padding: 3px 8px; border-radius: var(--radius-full);
|
|
||||||
opacity: 0; transform: translateX(4px);
|
|
||||||
transition: opacity var(--t-base), transform var(--t-base);
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: var(--sp-2); }
|
|
||||||
:global(.empty-icon) { color: var(--text-faint); }
|
|
||||||
.empty-text { font-size: var(--text-base); color: var(--text-muted); }
|
|
||||||
.empty-hint { font-size: var(--text-sm); color: var(--text-faint); }
|
|
||||||
|
|
||||||
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
|
|
||||||
</style>
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { House, Books, MagnifyingGlass, ClockCounterClockwise, Compass, DownloadSimple, PuzzlePiece, GearSix, ChartLineUp } from "phosphor-svelte";
|
|
||||||
import { store, setNavPage, setActiveManga, setActiveSource, setLibraryFilter, setGenreFilter, setSettingsOpen } from "../../store/state.svelte";
|
|
||||||
import type { NavPage } from "../../store/state.svelte";
|
|
||||||
|
|
||||||
const TABS: { id: NavPage; label: string; icon: any }[] = [
|
|
||||||
{ id: "home", label: "Home", icon: House },
|
|
||||||
{ id: "library", label: "Library", icon: Books },
|
|
||||||
{ id: "search", label: "Search", icon: MagnifyingGlass },
|
|
||||||
{ id: "history", label: "History", icon: ClockCounterClockwise },
|
|
||||||
{ id: "explore", label: "Discover", icon: Compass },
|
|
||||||
{ id: "downloads", label: "Downloads", icon: DownloadSimple },
|
|
||||||
{ id: "extensions", label: "Extensions", icon: PuzzlePiece },
|
|
||||||
{ id: "tracking", label: "Tracking", icon: ChartLineUp },
|
|
||||||
];
|
|
||||||
|
|
||||||
function navigate(id: NavPage) {
|
|
||||||
store.navPage = id;
|
|
||||||
store.activeManga = null;
|
|
||||||
store.genreFilter = "";
|
|
||||||
if (id !== "explore") store.activeSource = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function goHome() {
|
|
||||||
store.navPage = "home";
|
|
||||||
store.activeSource = null;
|
|
||||||
store.activeManga = null;
|
|
||||||
store.libraryFilter = "library";
|
|
||||||
store.genreFilter = "";
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<aside class="root">
|
|
||||||
<button class="logo" onclick={goHome} title="Home" aria-label="Go to Home">
|
|
||||||
<div class="logo-icon"></div>
|
|
||||||
</button>
|
|
||||||
<nav class="nav">
|
|
||||||
{#each TABS as tab}
|
|
||||||
<button class="tab" class:active={store.navPage === tab.id}
|
|
||||||
title={tab.label} onclick={() => navigate(tab.id)}>
|
|
||||||
<tab.icon size={18} weight="light" />
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</nav>
|
|
||||||
<div class="bottom">
|
|
||||||
<button class="settings-btn" onclick={() => store.settingsOpen = true} title="Settings">
|
|
||||||
<GearSix size={18} weight="light" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.root { width: var(--sidebar-width); flex-shrink: 0; background: var(--bg-void); display: flex; flex-direction: column; align-items: center; padding: var(--sp-4) 0; }
|
|
||||||
.logo { width: 80px; height: 80px; display: flex; align-items: center; justify-content: center; margin-bottom: var(--sp-3); background: none; border: none; outline: none; cursor: pointer; border-radius: var(--radius-lg); transition: opacity var(--t-base), transform var(--t-base); padding: 0; appearance: none; -webkit-appearance: none; }
|
|
||||||
.logo:hover { opacity: 0.8; transform: scale(0.96); }
|
|
||||||
.logo:active { transform: scale(0.92); }
|
|
||||||
.logo:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
|
||||||
.logo-icon { width: 67px; height: 67px; background-color: var(--accent); mask-image: url("../../assets/moku-icon-wordmark.svg"); mask-repeat: no-repeat; mask-position: center; mask-size: contain; -webkit-mask-image: url("../../assets/moku-icon-wordmark.svg"); -webkit-mask-repeat: no-repeat; -webkit-mask-position: center; -webkit-mask-size: contain; filter: drop-shadow(0 0 8px rgba(107,143,107,0.35)); pointer-events: none; }
|
|
||||||
.nav { flex: 1; display: flex; flex-direction: column; align-items: center; gap: var(--sp-1); width: 100%; padding: 0 var(--sp-2); }
|
|
||||||
.tab { width: 36px; height: 36px; display: flex; align-items: center; justify-content: center; border-radius: var(--radius-md); color: var(--text-faint); background: none; border: none; outline: none; cursor: pointer; padding: 0; appearance: none; -webkit-appearance: none; transition: color var(--t-base), background var(--t-base); }
|
|
||||||
.tab:hover { color: var(--text-muted); background: var(--bg-raised); }
|
|
||||||
.tab:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
|
|
||||||
.tab.active { color: var(--accent-fg); background: var(--accent-muted); }
|
|
||||||
.tab.active:hover { color: var(--accent-fg); background: var(--accent-muted); }
|
|
||||||
.bottom { display: flex; flex-direction: column; align-items: center; width: 100%; padding: var(--sp-3) var(--sp-2) 0; border-top: 1px solid var(--border-dim); margin-top: var(--sp-3); }
|
|
||||||
.settings-btn { width: 36px; height: 36px; display: flex; align-items: center; justify-content: center; border-radius: var(--radius-md); color: var(--text-faint); background: none; border: none; outline: none; cursor: pointer; padding: 0; appearance: none; -webkit-appearance: none; transition: color var(--t-base), background var(--t-base), transform var(--t-slow); }
|
|
||||||
.settings-btn:hover { color: var(--text-muted); background: var(--bg-raised); transform: rotate(30deg); }
|
|
||||||
.settings-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
|
|
||||||
</style>
|
|
||||||
@@ -1,450 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { onMount } from "svelte";
|
|
||||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
|
||||||
import { store } from "../../store/state.svelte";
|
|
||||||
import logoUrl from "../../assets/moku-icon-splash.svg";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
mode?: "loading" | "idle";
|
|
||||||
ringFull?: boolean;
|
|
||||||
failed?: boolean;
|
|
||||||
notConfigured?: boolean;
|
|
||||||
showCards?: boolean;
|
|
||||||
showFps?: boolean;
|
|
||||||
onReady?: () => void;
|
|
||||||
onRetry?: () => void;
|
|
||||||
onBypass?: () => void;
|
|
||||||
onDismiss?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { mode = "loading", ringFull = false, failed = false, notConfigured = false,
|
|
||||||
showCards = true, showFps = false, onReady, onRetry, onBypass, onDismiss }: Props = $props();
|
|
||||||
|
|
||||||
const lockEnabled = $derived(
|
|
||||||
store.settings.appLockEnabled && (store.settings.appLockPin?.length ?? 0) >= 4
|
|
||||||
);
|
|
||||||
|
|
||||||
let pinEntry = $state("");
|
|
||||||
let pinShake = $state(false);
|
|
||||||
let pinUnlocked = $state(false);
|
|
||||||
let pinVisible = $state(false);
|
|
||||||
|
|
||||||
function submitPin() {
|
|
||||||
if (pinEntry === store.settings.appLockPin) {
|
|
||||||
pinUnlocked = true;
|
|
||||||
pinEntry = "";
|
|
||||||
if (mode === "idle") triggerExit(onDismiss);
|
|
||||||
} else {
|
|
||||||
pinShake = true;
|
|
||||||
pinEntry = "";
|
|
||||||
setTimeout(() => pinShake = false, 500);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function onPinKey(e: KeyboardEvent) {
|
|
||||||
if (e.key === "Enter") { submitPin(); return; }
|
|
||||||
if (e.key === "Backspace") { pinEntry = pinEntry.slice(0, -1); return; }
|
|
||||||
if (/^\d$/.test(e.key)) {
|
|
||||||
pinEntry = (pinEntry + e.key).slice(0, 8);
|
|
||||||
if (pinEntry.length >= (store.settings.appLockPin?.length ?? 4)) submitPin();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleRetry() { onRetry?.(); }
|
|
||||||
function handleBypass() { onBypass?.(); }
|
|
||||||
|
|
||||||
const EXIT_MS = 320;
|
|
||||||
const PHASE1_TARGET = 0.85;
|
|
||||||
const PHASE1_MS = 3000;
|
|
||||||
const PHASE2_TARGET = 0.95;
|
|
||||||
const PHASE2_MS = 10000;
|
|
||||||
|
|
||||||
let dots = $state("");
|
|
||||||
let ringProg = $state(0.025);
|
|
||||||
let exiting = $state(false);
|
|
||||||
let exitLock = false;
|
|
||||||
|
|
||||||
let fpsEl = $state<HTMLSpanElement | undefined>(undefined);
|
|
||||||
|
|
||||||
function triggerExit(cb?: () => void) {
|
|
||||||
if (exitLock) return;
|
|
||||||
exitLock = true;
|
|
||||||
exiting = true;
|
|
||||||
setTimeout(() => cb?.(), EXIT_MS);
|
|
||||||
}
|
|
||||||
|
|
||||||
let animFrame: number;
|
|
||||||
let animStart: number | null = null;
|
|
||||||
let animPhase = 1;
|
|
||||||
|
|
||||||
function animateRing(ts: number) {
|
|
||||||
if (exitLock) return;
|
|
||||||
if (animStart === null) animStart = ts;
|
|
||||||
const elapsed = ts - animStart;
|
|
||||||
|
|
||||||
if (animPhase === 1) {
|
|
||||||
const t = Math.min(elapsed / PHASE1_MS, 1);
|
|
||||||
const eased = 1 - Math.pow(1 - t, 3);
|
|
||||||
ringProg = 0.025 + eased * (PHASE1_TARGET - 0.025);
|
|
||||||
if (t >= 1) { animPhase = 2; animStart = ts; }
|
|
||||||
} else if (animPhase === 2) {
|
|
||||||
const t = Math.min(elapsed / PHASE2_MS, 1);
|
|
||||||
const eased = 1 - Math.pow(1 - t, 4);
|
|
||||||
ringProg = PHASE1_TARGET + eased * (PHASE2_TARGET - PHASE1_TARGET);
|
|
||||||
}
|
|
||||||
|
|
||||||
animFrame = requestAnimationFrame(animateRing);
|
|
||||||
}
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (mode === "loading" && !failed && !notConfigured) {
|
|
||||||
animFrame = requestAnimationFrame(animateRing);
|
|
||||||
return () => cancelAnimationFrame(animFrame);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (ringFull) {
|
|
||||||
cancelAnimationFrame(animFrame);
|
|
||||||
ringProg = 1;
|
|
||||||
if (lockEnabled && !pinUnlocked) {
|
|
||||||
setTimeout(() => { pinVisible = true; }, 400);
|
|
||||||
} else {
|
|
||||||
setTimeout(() => triggerExit(onReady), 650);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const dotsInterval = setInterval(() => {
|
|
||||||
dots = dots.length >= 3 ? "" : dots + ".";
|
|
||||||
}, 420);
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
if (mode === "idle" && onDismiss) {
|
|
||||||
if (lockEnabled) {
|
|
||||||
return () => clearInterval(dotsInterval);
|
|
||||||
}
|
|
||||||
const handler = () => triggerExit(onDismiss);
|
|
||||||
const t = setTimeout(() => {
|
|
||||||
window.addEventListener("keydown", handler, { once: true });
|
|
||||||
window.addEventListener("mousedown", handler, { once: true });
|
|
||||||
window.addEventListener("touchstart", handler, { once: true });
|
|
||||||
}, 200);
|
|
||||||
return () => {
|
|
||||||
clearTimeout(t);
|
|
||||||
clearInterval(dotsInterval);
|
|
||||||
window.removeEventListener("keydown", handler);
|
|
||||||
window.removeEventListener("mousedown", handler);
|
|
||||||
window.removeEventListener("touchstart", handler);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return () => clearInterval(dotsInterval);
|
|
||||||
});
|
|
||||||
|
|
||||||
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; }
|
|
||||||
|
|
||||||
const LAYER_CFG = [
|
|
||||||
{ wMin: 26, wMax: 40, speedMin: 30, speedMax: 50, alpha: 0.22 },
|
|
||||||
{ wMin: 38, wMax: 56, speedMin: 52, speedMax: 80, alpha: 0.35 },
|
|
||||||
{ wMin: 54, wMax: 76, speedMin: 85, speedMax: 120, alpha: 0.50 },
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
const BUF = 80, COLS = 14;
|
|
||||||
|
|
||||||
function hash(n: number): number {
|
|
||||||
let x = Math.imul(n ^ (n >>> 16), 0x45d9f3b);
|
|
||||||
x = Math.imul(x ^ (x >>> 16), 0x45d9f3b);
|
|
||||||
return ((x ^ (x >>> 16)) >>> 0) / 0xffffffff;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildCards(vw: number, vh: number) {
|
|
||||||
const cards: CardDef[] = [], laneW = vw / COLS;
|
|
||||||
for (let layer = 0; layer < 3; layer++) {
|
|
||||||
const cfg = LAYER_CFG[layer];
|
|
||||||
for (let col = 0; col < COLS; col++) {
|
|
||||||
const seed = col * 31 + layer * 97 + 7;
|
|
||||||
const w = cfg.wMin + hash(seed + 1) * (cfg.wMax - cfg.wMin);
|
|
||||||
const h = w * 1.44;
|
|
||||||
const speed = cfg.speedMin + hash(seed + 5) * (cfg.speedMax - cfg.speedMin);
|
|
||||||
const travel = vh + h + BUF;
|
|
||||||
cards.push({
|
|
||||||
cx: (col + 0.5) * laneW + (hash(seed + 2) * 2 - 1) * Math.max(0, (laneW - w) / 2 - 2),
|
|
||||||
w, h, lines: 1 + Math.floor(hash(seed + 7) * 3), alpha: cfg.alpha, speed,
|
|
||||||
cycleSec: travel / speed,
|
|
||||||
phase: ((col / COLS) + hash(seed + 6) * 0.6 + layer * 0.23) % 1,
|
|
||||||
travel, yStart: vh + h / 2 + BUF / 2,
|
|
||||||
angleStart: hash(seed + 3) * 50 - 25,
|
|
||||||
tilt: (hash(seed + 4) * 2 - 1) * 18,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const trigs: CardTrig[] = cards.map(c => ({
|
|
||||||
cosA: Math.cos(c.angleStart * (Math.PI / 180)),
|
|
||||||
sinA: Math.sin(c.angleStart * (Math.PI / 180)),
|
|
||||||
tiltRad: c.tilt * (Math.PI / 180),
|
|
||||||
}));
|
|
||||||
return { cards, trigs };
|
|
||||||
}
|
|
||||||
|
|
||||||
function rrect(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number) {
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(x + r, y); ctx.lineTo(x + w - r, y); ctx.arcTo(x + w, y, x + w, y + r, r);
|
|
||||||
ctx.lineTo(x + w, y + h - r); ctx.arcTo(x + w, y + h, x + w - r, y + h, r);
|
|
||||||
ctx.lineTo(x + r, y + h); ctx.arcTo(x, y + h, x, y + h - r, r);
|
|
||||||
ctx.lineTo(x, y + r); ctx.arcTo(x, y, x + r, y, r);
|
|
||||||
ctx.closePath();
|
|
||||||
}
|
|
||||||
|
|
||||||
const STAMP_PAD = 6;
|
|
||||||
|
|
||||||
function buildStamp(c: CardDef, dpr: number): HTMLCanvasElement {
|
|
||||||
const oc = document.createElement("canvas");
|
|
||||||
oc.width = Math.round(Math.ceil(c.w + STAMP_PAD * 2) * dpr);
|
|
||||||
oc.height = Math.round(Math.ceil(c.h + STAMP_PAD * 2) * dpr);
|
|
||||||
const ctx = oc.getContext("2d")!;
|
|
||||||
ctx.scale(dpr, dpr);
|
|
||||||
const x0 = STAMP_PAD, y0 = STAMP_PAD;
|
|
||||||
const coverH = (c.w * 0.72) * 1.05;
|
|
||||||
const lineY0 = y0 + 3 + coverH + 5;
|
|
||||||
ctx.fillStyle = "rgba(0,0,0,0.5)"; rrect(ctx, x0 + 2, y0 + 2, c.w, c.h, 4); ctx.fill();
|
|
||||||
ctx.fillStyle = "rgba(255,255,255,0.07)"; rrect(ctx, x0, y0, c.w, c.h, 4); ctx.fill();
|
|
||||||
ctx.strokeStyle = "rgba(255,255,255,0.75)"; ctx.lineWidth = 1.2; rrect(ctx, x0, y0, c.w, c.h, 4); ctx.stroke();
|
|
||||||
ctx.fillStyle = "rgba(255,255,255,0.15)"; rrect(ctx, x0 + 3, y0 + 3, c.w - 6, coverH, 3); ctx.fill();
|
|
||||||
ctx.fillStyle = "rgba(255,255,255,0.08)"; rrect(ctx, x0 + 3, y0 + 3, (c.w - 6) * 0.45, coverH, 3); ctx.fill();
|
|
||||||
for (let li = 0; li < c.lines; li++) {
|
|
||||||
ctx.fillStyle = li === 0 ? "rgba(255,255,255,0.35)" : "rgba(255,255,255,0.20)";
|
|
||||||
ctx.fillRect(x0 + 4, lineY0 + li * 8, (c.w - 8) * (li === 0 ? 0.78 : 0.52), li === 0 ? 3 : 2);
|
|
||||||
}
|
|
||||||
return oc;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildVignette(vw: number, vh: number, dpr: number): HTMLCanvasElement {
|
|
||||||
const oc = document.createElement("canvas");
|
|
||||||
oc.width = Math.round(vw * dpr); oc.height = Math.round(vh * dpr);
|
|
||||||
const ctx = oc.getContext("2d")!;
|
|
||||||
ctx.scale(dpr, dpr);
|
|
||||||
const g = ctx.createRadialGradient(vw / 2, vh / 2, 0, vw / 2, vh / 2, Math.max(vw, vh) * 0.65);
|
|
||||||
g.addColorStop(0, "rgba(0,0,0,0)"); g.addColorStop(0.4, "rgba(0,0,0,0)"); g.addColorStop(0.7, "rgba(0,0,0,0.25)"); g.addColorStop(1, "rgba(0,0,0,0.65)");
|
|
||||||
ctx.fillStyle = g; ctx.fillRect(0, 0, vw, vh);
|
|
||||||
return oc;
|
|
||||||
}
|
|
||||||
|
|
||||||
function drawFrame(
|
|
||||||
ctx: CanvasRenderingContext2D, t: number, cw: number, ch: number, dpr: number,
|
|
||||||
cards: CardDef[], trigs: CardTrig[], stamps: HTMLCanvasElement[], vignette: HTMLCanvasElement,
|
|
||||||
) {
|
|
||||||
ctx.clearRect(0, 0, cw, ch);
|
|
||||||
for (let i = 0; i < cards.length; i++) {
|
|
||||||
const c = cards[i];
|
|
||||||
const p = ((t / c.cycleSec) + c.phase) % 1;
|
|
||||||
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;
|
|
||||||
const tg = trigs[i];
|
|
||||||
const delta = tg.tiltRad * p;
|
|
||||||
const cos = tg.cosA * Math.cos(delta) - tg.sinA * Math.sin(delta);
|
|
||||||
const sin = tg.sinA * Math.cos(delta) + tg.cosA * Math.sin(delta);
|
|
||||||
ctx.globalAlpha = alpha;
|
|
||||||
ctx.setTransform(cos * dpr, sin * dpr, -sin * dpr, cos * dpr, c.cx * dpr, cy * dpr);
|
|
||||||
const sw = stamps[i].width / dpr, sh = stamps[i].height / dpr;
|
|
||||||
ctx.drawImage(stamps[i], -sw / 2, -sh / 2, sw, sh);
|
|
||||||
}
|
|
||||||
ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.globalAlpha = 1;
|
|
||||||
ctx.drawImage(vignette, 0, 0, cw, ch);
|
|
||||||
}
|
|
||||||
|
|
||||||
let fps = 0, fpsFrames = 0, fpsLast = 0;
|
|
||||||
function tickFps(now: number) {
|
|
||||||
fpsFrames++;
|
|
||||||
if (now - fpsLast >= 500) {
|
|
||||||
fps = Math.round(fpsFrames / ((now - fpsLast) / 1000));
|
|
||||||
fpsFrames = 0; fpsLast = now;
|
|
||||||
if (fpsEl) fpsEl.textContent = `${fps} fps`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function mountCanvas(el: HTMLCanvasElement) {
|
|
||||||
const win = getCurrentWindow();
|
|
||||||
const ctx = el.getContext("2d")!;
|
|
||||||
interface RenderState {
|
|
||||||
cards: CardDef[]; trigs: CardTrig[]; stamps: HTMLCanvasElement[];
|
|
||||||
vignette: HTMLCanvasElement; CW: number; CH: number; scale: number;
|
|
||||||
}
|
|
||||||
let live: RenderState | null = null;
|
|
||||||
let lastLogW = 0, lastLogH = 0, lastScale = 0, buildGen = 0;
|
|
||||||
|
|
||||||
async function syncSize() {
|
|
||||||
const gen = ++buildGen;
|
|
||||||
const [phys, scale] = await Promise.all([win.innerSize(), win.scaleFactor()]);
|
|
||||||
if (gen !== buildGen) return;
|
|
||||||
const logW = phys.width / scale, logH = phys.height / scale;
|
|
||||||
if (logW === lastLogW && logH === lastLogH && scale === lastScale) return;
|
|
||||||
lastLogW = logW; lastLogH = logH; lastScale = scale;
|
|
||||||
const built = buildCards(logW, logH);
|
|
||||||
const stamps = built.cards.map(c => buildStamp(c, scale));
|
|
||||||
const vig = buildVignette(logW, logH, scale);
|
|
||||||
el.width = phys.width; el.height = phys.height;
|
|
||||||
live = { cards: built.cards, trigs: built.trigs, stamps, vignette: vig, CW: phys.width, CH: phys.height, scale };
|
|
||||||
}
|
|
||||||
|
|
||||||
const ro = new ResizeObserver(() => syncSize());
|
|
||||||
ro.observe(el); syncSize();
|
|
||||||
|
|
||||||
let raf = 0, t0 = -1;
|
|
||||||
function frame(now: number) {
|
|
||||||
raf = requestAnimationFrame(frame);
|
|
||||||
if (!live) return;
|
|
||||||
if (t0 < 0) t0 = now;
|
|
||||||
if (showFps) tickFps(now);
|
|
||||||
const { cards, trigs, stamps, vignette, CW, CH, scale } = live;
|
|
||||||
drawFrame(ctx, (now - t0) / 1000, CW, CH, scale, cards, trigs, stamps, vignette);
|
|
||||||
}
|
|
||||||
raf = requestAnimationFrame(frame);
|
|
||||||
return () => { cancelAnimationFrame(raf); ro.disconnect(); };
|
|
||||||
}
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
const needsPin =
|
|
||||||
(mode === "idle" && lockEnabled) ||
|
|
||||||
(mode === "loading" && lockEnabled && ringFull && !pinUnlocked);
|
|
||||||
if (!needsPin) return;
|
|
||||||
window.addEventListener("keydown", onPinKey);
|
|
||||||
return () => window.removeEventListener("keydown", onPinKey);
|
|
||||||
});
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (pinUnlocked && mode !== "idle") {
|
|
||||||
triggerExit(onReady);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const ringR = $derived(70);
|
|
||||||
const ringPad = $derived(12);
|
|
||||||
const ringSize = $derived((ringR + ringPad) * 2);
|
|
||||||
const ringC = $derived(ringR + ringPad);
|
|
||||||
const ringCirc = $derived(2 * Math.PI * ringR);
|
|
||||||
const ringArc = $derived(ringCirc * Math.min(Math.max(ringProg, 0.025), 0.999));
|
|
||||||
const ringTop = $derived(-((ringSize - 140) / 2));
|
|
||||||
const ringLeft = $derived(-((ringSize - 140) / 2));
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="splash" class:exiting style="cursor: {mode === 'idle' && !lockEnabled ? 'pointer' : 'default'}">
|
|
||||||
{#if showCards}
|
|
||||||
<canvas style="position:absolute;inset:0;pointer-events:none;width:100%;height:100%" use:mountCanvas></canvas>
|
|
||||||
{#if showFps}
|
|
||||||
<span bind:this={fpsEl} style="position:absolute;top:8px;right:8px;font-family:var(--font-ui);font-size:10px;color:var(--text-faint);z-index:2;pointer-events:none"></span>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if mode === "idle" && lockEnabled}
|
|
||||||
<div style="z-index:1;display:flex;flex-direction:column;align-items:center;gap:var(--sp-6)">
|
|
||||||
<div style="position:relative;width:96px;height:96px">
|
|
||||||
<div class="logo-glow"></div>
|
|
||||||
<img src={logoUrl} alt="Moku" class="logo-breathe" style="width:96px;height:96px;border-radius:22px;display:block;position:relative" />
|
|
||||||
</div>
|
|
||||||
<div class="pin-block">
|
|
||||||
<div class="pin-dots" class:pin-shake={pinShake}>
|
|
||||||
{#each Array(store.settings.appLockPin?.length ?? 4) as _, i}
|
|
||||||
<div class="pin-dot" class:pin-dot-filled={i < pinEntry.length}></div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
<button class="pin-submit-btn" onclick={submitPin} tabindex="-1" aria-label="Submit PIN">Unlock</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{:else if mode === "idle"}
|
|
||||||
<div style="z-index:1;display:flex;flex-direction:column;align-items:center">
|
|
||||||
<div style="position:relative;width:128px;height:128px;margin-bottom:32px">
|
|
||||||
<div class="logo-glow"></div>
|
|
||||||
<img src={logoUrl} alt="Moku" class="logo-breathe" style="width:128px;height:128px;border-radius:28px;display:block;position:relative" />
|
|
||||||
</div>
|
|
||||||
<p class="hint">press any key to continue</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{:else}
|
|
||||||
<div style="position:relative;width:140px;height:140px;margin-bottom:20px;z-index:1">
|
|
||||||
{#if !failed && !notConfigured}
|
|
||||||
<svg width={ringSize} height={ringSize}
|
|
||||||
class="loading-ring"
|
|
||||||
class:ring-hide={lockEnabled && pinVisible}
|
|
||||||
style="position:absolute;pointer-events:none;top:{ringTop}px;left:{ringLeft}px">
|
|
||||||
<circle cx={ringC} cy={ringC} r={ringR} fill="none" stroke="var(--border-base)" stroke-width="2" />
|
|
||||||
<circle cx={ringC} cy={ringC} r={ringR} fill="none" stroke="var(--accent)" stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-dasharray="{ringArc} {ringCirc}"
|
|
||||||
transform="rotate(-90 {ringC} {ringC})"
|
|
||||||
style="transition: stroke-dasharray 0.4s cubic-bezier(0.4,0,0.2,1)" />
|
|
||||||
</svg>
|
|
||||||
{/if}
|
|
||||||
<img src={logoUrl} alt="Moku" style="width:140px;height:140px;border-radius:32px;display:block" />
|
|
||||||
</div>
|
|
||||||
<p class="title-label">moku</p>
|
|
||||||
|
|
||||||
<div class="bottom-area" style="z-index:1">
|
|
||||||
<div class="status-slot" class:status-slot-hide={lockEnabled && pinVisible}>
|
|
||||||
{#if failed || notConfigured}
|
|
||||||
<div class="error-box">
|
|
||||||
<p class="error-label">
|
|
||||||
{failed ? "Could not reach server" : "Server not configured"}
|
|
||||||
</p>
|
|
||||||
<div class="error-actions">
|
|
||||||
<button class="err-btn" onclick={handleRetry}>Retry</button>
|
|
||||||
<button class="err-btn err-btn--primary" onclick={handleBypass}>Enter app</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<p class="status-text">{ringFull ? "" : `Initializing server${dots}`}</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if lockEnabled}
|
|
||||||
<div class="pin-slot" class:pin-slot-visible={pinVisible}>
|
|
||||||
<div class="pin-dots" class:pin-shake={pinShake}>
|
|
||||||
{#each Array(store.settings.appLockPin?.length ?? 4) as _, i}
|
|
||||||
<div class="pin-dot" class:pin-dot-filled={i < pinEntry.length}></div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
<button class="pin-submit-btn" onclick={submitPin} tabindex="-1" aria-label="Submit PIN">Unlock</button>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.splash { position: fixed; inset: 0; z-index: 9999; background: var(--bg-base); overflow: hidden; display: flex; flex-direction: column; align-items: center; justify-content: center; animation: spIn 0.35s cubic-bezier(0,0,0.2,1) both; }
|
|
||||||
.splash.exiting { animation: spOut 320ms cubic-bezier(0.4,0,1,1) both; }
|
|
||||||
@keyframes spIn { from { opacity:0; transform:scale(1.015) } to { opacity:1; transform:scale(1) } }
|
|
||||||
@keyframes spOut { from { opacity:1; transform:scale(1) } to { opacity:0; transform:scale(0.96) } }
|
|
||||||
@keyframes logoBreathe { 0%,100% { transform:scale(1); filter:drop-shadow(0 0 0px rgba(255,255,255,0)) } 50% { transform:scale(1.04); filter:drop-shadow(0 0 18px rgba(255,255,255,0.12)) } }
|
|
||||||
@keyframes hintFade { 0%,100% { opacity:0.35 } 50% { opacity:0.7 } }
|
|
||||||
.logo-glow { position: absolute; inset: -20px; border-radius: 50%; background: radial-gradient(circle, rgba(255,255,255,0.06) 0%, transparent 70%); animation: logoBreathe 4s ease-in-out infinite; }
|
|
||||||
.logo-breathe { animation: logoBreathe 4s ease-in-out infinite; }
|
|
||||||
.hint { font-family: var(--font-ui); font-size: 10px; color: var(--text-faint); letter-spacing: 0.22em; text-transform: uppercase; margin: 0; user-select: none; animation: hintFade 3.5s ease-in-out infinite; }
|
|
||||||
.title-label { font-family: var(--font-ui); font-size: 11px; font-weight: 500; letter-spacing: 0.26em; text-transform: uppercase; color: var(--text-secondary); margin: 0 0 8px; z-index: 1; user-select: none; }
|
|
||||||
.error-box { display: flex; flex-direction: column; align-items: center; gap: 12px; padding: 16px 20px; border-radius: var(--radius-lg); background: var(--bg-surface); border: 1px solid var(--border-base); min-width: 200px; text-align: center; animation: errIn 0.25s cubic-bezier(0,0,0.2,1) both; }
|
|
||||||
@keyframes errIn { from { opacity:0; transform:translateY(4px) } to { opacity:1; transform:translateY(0) } }
|
|
||||||
.error-label { font-family: var(--font-ui); font-size: 11px; font-weight: 500; color: var(--text-muted); letter-spacing: 0.06em; margin: 0; }
|
|
||||||
.error-actions { display: flex; gap: 6px; }
|
|
||||||
.err-btn { padding: 5px 14px; border-radius: var(--radius-md); border: 1px solid var(--border-base); background: transparent; color: var(--text-muted); cursor: pointer; font-family: var(--font-ui); font-size: 11px; letter-spacing: 0.04em; transition: border-color 0.15s, color 0.15s; }
|
|
||||||
.err-btn:hover { border-color: var(--border-strong); color: var(--text-secondary); }
|
|
||||||
.err-btn--primary { border-color: var(--accent-dim); color: var(--accent-fg); background: var(--accent-muted); }
|
|
||||||
.err-btn--primary:hover { border-color: var(--accent); color: var(--accent-bright); }
|
|
||||||
|
|
||||||
.bottom-area { display: flex; align-items: center; justify-content: center; min-height: 48px; position: relative; }
|
|
||||||
.status-slot { display: flex; align-items: center; justify-content: center; transition: opacity 0.35s ease; position: absolute; }
|
|
||||||
.status-slot-hide { opacity: 0; pointer-events: none; }
|
|
||||||
.status-text { font-family: var(--font-ui); font-size: 10px; color: var(--text-faint); letter-spacing: 0.12em; margin: 0; min-width: 160px; text-align: center; }
|
|
||||||
.loading-ring { transition: opacity 0.5s ease; }
|
|
||||||
.ring-hide { opacity: 0; }
|
|
||||||
.pin-slot { display: flex; flex-direction: column; align-items: center; gap: var(--sp-3); position: absolute; opacity: 0; transform: translateY(4px); transition: opacity 0.4s ease, transform 0.4s ease; pointer-events: none; }
|
|
||||||
.pin-slot-visible { opacity: 1; transform: translateY(0); pointer-events: auto; }
|
|
||||||
.pin-block { display: flex; flex-direction: column; align-items: center; gap: var(--sp-3); position: relative; }
|
|
||||||
.pin-dots { display: flex; gap: 12px; align-items: center; }
|
|
||||||
.pin-dot { width: 10px; height: 10px; border-radius: 50%; border: 1px solid var(--border-strong); background: transparent; transition: background 0.12s, border-color 0.12s; }
|
|
||||||
.pin-dot-filled { background: var(--accent); border-color: var(--accent); }
|
|
||||||
@keyframes pinShake { 0%,100% { transform:translateX(0) } 20%,60% { transform:translateX(-6px) } 40%,80% { transform:translateX(6px) } }
|
|
||||||
.pin-shake { animation: pinShake 0.42s ease; }
|
|
||||||
.pin-submit-btn { opacity: 0; width: 1px; height: 1px; overflow: hidden; padding: 0; border: none; background: none; cursor: pointer; pointer-events: auto; position: absolute; }
|
|
||||||
</style>
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { onMount } from "svelte";
|
|
||||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
|
||||||
import { platform } from "@tauri-apps/plugin-os";
|
|
||||||
|
|
||||||
const win = getCurrentWindow();
|
|
||||||
const isMac = platform() === "macos";
|
|
||||||
|
|
||||||
let isFullscreen = $state(false);
|
|
||||||
|
|
||||||
onMount(async () => {
|
|
||||||
isFullscreen = await win.isFullscreen();
|
|
||||||
const unlisten = await win.onResized(async () => {
|
|
||||||
isFullscreen = await win.isFullscreen();
|
|
||||||
});
|
|
||||||
return unlisten;
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#if !isFullscreen}
|
|
||||||
<div class="bar" data-tauri-drag-region>
|
|
||||||
{#if isMac}<div class="mac-spacer"></div>{/if}
|
|
||||||
<span class="title" data-tauri-drag-region>Moku</span>
|
|
||||||
{#if !isMac}
|
|
||||||
<div class="controls">
|
|
||||||
<button onclick={() => win.minimize()} title="Minimize" aria-label="Minimize">
|
|
||||||
<svg width="10" height="1" viewBox="0 0 10 1">
|
|
||||||
<line x1="0" y1="0.5" x2="10" y2="0.5" stroke="currentColor" stroke-width="1.5" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button onclick={() => win.toggleMaximize()} title="Maximize" aria-label="Maximize">
|
|
||||||
<svg width="9" height="9" viewBox="0 0 9 9">
|
|
||||||
<rect x="0.75" y="0.75" width="7.5" height="7.5" rx="1" fill="none" stroke="currentColor" stroke-width="1.5" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button class="close" onclick={() => win.close()} title="Close" aria-label="Close">
|
|
||||||
<svg width="10" height="10" viewBox="0 0 10 10">
|
|
||||||
<line x1="1" y1="1" x2="9" y2="9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
|
|
||||||
<line x1="9" y1="1" x2="1" y2="9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.bar {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
height: 32px;
|
|
||||||
padding: 0 var(--sp-3) 0 var(--sp-4);
|
|
||||||
background: var(--bg-void);
|
|
||||||
border-bottom: 1px solid var(--border-dim);
|
|
||||||
flex-shrink: 0;
|
|
||||||
user-select: none;
|
|
||||||
-webkit-app-region: drag;
|
|
||||||
}
|
|
||||||
/* Spacer to clear the native macOS traffic lights (~70px) */
|
|
||||||
.mac-spacer {
|
|
||||||
width: 70px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
-webkit-app-region: drag;
|
|
||||||
}
|
|
||||||
.title {
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-2xs);
|
|
||||||
color: var(--text-faint);
|
|
||||||
letter-spacing: var(--tracking-wider);
|
|
||||||
text-transform: uppercase;
|
|
||||||
-webkit-app-region: drag;
|
|
||||||
}
|
|
||||||
.controls {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 2px;
|
|
||||||
-webkit-app-region: no-drag;
|
|
||||||
}
|
|
||||||
button {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 28px; height: 28px;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
color: var(--text-faint);
|
|
||||||
transition: color var(--t-base), background var(--t-base);
|
|
||||||
-webkit-app-region: no-drag;
|
|
||||||
}
|
|
||||||
button:hover { color: var(--text-muted); background: var(--bg-raised); }
|
|
||||||
.close:hover { color: #fff; background: #c0392b; }
|
|
||||||
</style>
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { store, dismissToast } from "../../store/state.svelte";
|
|
||||||
import type { Toast } from "../../store/state.svelte";
|
|
||||||
|
|
||||||
const timers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
||||||
|
|
||||||
function schedule(t: Toast) {
|
|
||||||
if (timers.has(t.id)) return;
|
|
||||||
const dur = t.duration ?? 3500;
|
|
||||||
if (dur === 0) return;
|
|
||||||
timers.set(t.id, setTimeout(() => dismissToast(t.id), dur));
|
|
||||||
}
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
store.toasts.forEach(schedule);
|
|
||||||
return () => timers.forEach(clearTimeout);
|
|
||||||
});
|
|
||||||
|
|
||||||
const icons: Record<Toast["kind"], string> = {
|
|
||||||
success: "M9 12l2 2 4-4M12 2a10 10 0 1 0 0 20A10 10 0 0 0 12 2z",
|
|
||||||
error: "M12 9v4M12 17h.01M12 2a10 10 0 1 0 0 20A10 10 0 0 0 12 2z",
|
|
||||||
info: "M12 16v-4M12 8h.01M12 2a10 10 0 1 0 0 20A10 10 0 0 0 12 2z",
|
|
||||||
download: "M12 3v13M7 11l5 5 5-5M5 21h14",
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#if store.toasts.length}
|
|
||||||
<div class="toaster" aria-live="polite">
|
|
||||||
{#each store.toasts as t (t.id)}
|
|
||||||
<div class="toast toast-{t.kind}" role="alert">
|
|
||||||
<span class="icon">
|
|
||||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none"
|
|
||||||
stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
|
||||||
<path d={icons[t.kind]} />
|
|
||||||
</svg>
|
|
||||||
</span>
|
|
||||||
<div class="body">
|
|
||||||
<p class="title">{t.title}</p>
|
|
||||||
{#if t.body}<p class="sub">{t.body}</p>{/if}
|
|
||||||
</div>
|
|
||||||
<button class="close" onclick={() => dismissToast(t.id)} title="Dismiss">
|
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none"
|
|
||||||
stroke="currentColor" stroke-width="1.5" stroke-linecap="round">
|
|
||||||
<path d="M18 6L6 18M6 6l12 12" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.toaster {
|
|
||||||
position: fixed; bottom: var(--sp-5); right: var(--sp-5);
|
|
||||||
z-index: 9999; display: flex; flex-direction: column;
|
|
||||||
gap: var(--sp-2); pointer-events: none; max-width: 320px;
|
|
||||||
}
|
|
||||||
.toast {
|
|
||||||
display: flex; align-items: flex-start; gap: var(--sp-2);
|
|
||||||
padding: var(--sp-2) var(--sp-3);
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
border: 1px solid var(--border-base);
|
|
||||||
background: var(--bg-raised);
|
|
||||||
box-shadow: 0 4px 24px rgba(0,0,0,0.45), 0 0 0 1px rgba(0,0,0,0.08);
|
|
||||||
pointer-events: all; min-width: 220px;
|
|
||||||
animation: toastIn 0.18s cubic-bezier(0.16,1,0.3,1) both;
|
|
||||||
}
|
|
||||||
@keyframes toastIn {
|
|
||||||
from { opacity: 0; transform: translateX(24px) scale(0.96); }
|
|
||||||
to { opacity: 1; transform: translateX(0) scale(1); }
|
|
||||||
}
|
|
||||||
.toast-success { border-color: var(--accent-dim); }
|
|
||||||
.toast-success .icon { color: var(--accent-fg); }
|
|
||||||
.toast-error { border-color: var(--color-error); }
|
|
||||||
.toast-error .icon { color: var(--color-error); }
|
|
||||||
.toast-download .icon, .toast-info .icon { color: var(--accent-fg); }
|
|
||||||
.icon { flex-shrink: 0; margin-top: 2px; color: var(--text-faint); }
|
|
||||||
.body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
|
|
||||||
.title { font-size: var(--text-sm); color: var(--text-secondary); font-weight: var(--weight-medium); line-height: 1.3; }
|
|
||||||
.sub {
|
|
||||||
font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint);
|
|
||||||
letter-spacing: var(--tracking-wide); white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
.close {
|
|
||||||
display: flex; align-items: center; justify-content: center;
|
|
||||||
width: 18px; height: 18px; border-radius: var(--radius-sm);
|
|
||||||
color: var(--text-faint); flex-shrink: 0; margin-top: 1px;
|
|
||||||
transition: color var(--t-base), background var(--t-base);
|
|
||||||
}
|
|
||||||
.close:hover { color: var(--text-muted); background: var(--bg-overlay); }
|
|
||||||
</style>
|
|
||||||
@@ -1,394 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { onDestroy } from "svelte";
|
|
||||||
import { BookmarkSimple, FolderSimplePlus, Folder, Sparkle, ArrowsClockwise } from "phosphor-svelte";
|
|
||||||
import { gql, thumbUrl } from "../../lib/client";
|
|
||||||
import { GET_SOURCES, FETCH_SOURCE_MANGA, UPDATE_MANGA } from "../../lib/queries";
|
|
||||||
import { cache, CACHE_KEYS } from "../../lib/cache";
|
|
||||||
import { dedupeSources, dedupeMangaByTitle, dedupeMangaById } from "../../lib/util";
|
|
||||||
import { store, addFolder, assignMangaToFolder, setPreviewManga, clearDiscoverCache } from "../../store/state.svelte";
|
|
||||||
import type { Manga, Source } from "../../lib/types";
|
|
||||||
import ContextMenu from "../shared/ContextMenu.svelte";
|
|
||||||
import type { MenuEntry } from "../shared/ContextMenu.svelte";
|
|
||||||
import SourceBrowse from "../shared/SourceBrowse.svelte";
|
|
||||||
|
|
||||||
// ── Constants ─────────────────────────────────────────────────────────────
|
|
||||||
const GENRE_TABS = ["All", "Action", "Romance", "Fantasy", "Comedy", "Drama", "Horror", "Sci-Fi", "Adventure", "Thriller"];
|
|
||||||
const GRID_LIMIT = 200;
|
|
||||||
const CONCURRENCY = 6;
|
|
||||||
const PAGES_INIT = 3; // pages per source on All tab
|
|
||||||
const PAGES_GENRE = 2; // pages per source on genre tabs
|
|
||||||
|
|
||||||
const EXPLORE_ALL_MANGA = `
|
|
||||||
query ExploreAllManga {
|
|
||||||
mangas(orderBy: IN_LIBRARY_AT, orderByType: DESC) {
|
|
||||||
nodes { id title thumbnailUrl inLibrary genre status source { id displayName } }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
const MANGAS_BY_GENRE = `
|
|
||||||
query MangasByGenre($genre: String!, $first: Int) {
|
|
||||||
mangas(filter: { genre: { includesInsensitive: $genre } }, first: $first, orderBy: IN_LIBRARY_AT, orderByType: DESC) {
|
|
||||||
nodes { id title thumbnailUrl inLibrary genre status source { id displayName } }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
function dKey(srcId: string, type: string, genre: string, page: number) {
|
|
||||||
return `${srcId}|${type}|${genre}:p${page}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Local component state ─────────────────────────────────────────────────
|
|
||||||
let allSources: Source[] = $state([]);
|
|
||||||
let loadingLib = $state(true);
|
|
||||||
let loadError = $state(false);
|
|
||||||
let currentGenre = $state("All");
|
|
||||||
let genreResults = $state(new Map<string, Manga[]>());
|
|
||||||
let genreLoading = $state(false);
|
|
||||||
let refreshing = $state(false);
|
|
||||||
|
|
||||||
let activeCtrl: AbortController | null = null;
|
|
||||||
let ctx: { x: number; y: number; manga: Manga } | null = $state(null);
|
|
||||||
|
|
||||||
const isLoading = $derived(genreLoading || refreshing || (currentGenre === "All" && loadingLib));
|
|
||||||
const visibleGrid = $derived(genreResults.get(currentGenre) ?? []);
|
|
||||||
|
|
||||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
|
||||||
function dedup(items: Manga[]): Manga[] {
|
|
||||||
return dedupeMangaByTitle(dedupeMangaById(items), store.settings.mangaLinks);
|
|
||||||
}
|
|
||||||
|
|
||||||
function filterOut(mangas: Manga[]): Manga[] {
|
|
||||||
return dedup(mangas.filter(m => !m.inLibrary && !store.discoverLibraryIds.has(m.id)));
|
|
||||||
}
|
|
||||||
|
|
||||||
function rotatedSources(): Source[] {
|
|
||||||
const lang = store.settings.preferredExtensionLang || "en";
|
|
||||||
const srcs = dedupeSources(allSources.filter(s => s.id !== "0"), lang);
|
|
||||||
if (!srcs.length) return [];
|
|
||||||
const off = store.discoverSrcOffset % srcs.length;
|
|
||||||
return [...srcs.slice(off), ...srcs.slice(0, off)];
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runConcurrent<T>(items: T[], fn: (i: T) => Promise<void>, signal: AbortSignal) {
|
|
||||||
let i = 0;
|
|
||||||
const worker = async () => {
|
|
||||||
while (i < items.length) {
|
|
||||||
if (signal.aborted) return;
|
|
||||||
await fn(items[i++]).catch(() => {});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, items.length) }, worker));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Push results into the reactive grid immediately — no batch delay.
|
|
||||||
function pushToGrid(genre: string, incoming: Manga[]) {
|
|
||||||
const filtered = filterOut(incoming);
|
|
||||||
if (!filtered.length) return;
|
|
||||||
const cur = genreResults.get(genre) ?? [];
|
|
||||||
genreResults.set(genre, dedup([...cur, ...filtered]).slice(0, GRID_LIMIT));
|
|
||||||
genreResults = new Map(genreResults);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Source fan-out ────────────────────────────────────────────────────────
|
|
||||||
async function fanOut(genre: string, ctrl: AbortController) {
|
|
||||||
const srcs = rotatedSources();
|
|
||||||
if (!srcs.length) return;
|
|
||||||
|
|
||||||
const isAll = genre === "All";
|
|
||||||
const type = isAll ? "POPULAR" : "SEARCH";
|
|
||||||
const query = isAll ? null : genre;
|
|
||||||
const maxPages = isAll ? PAGES_INIT : PAGES_GENRE;
|
|
||||||
|
|
||||||
await runConcurrent(srcs, async src => {
|
|
||||||
for (let page = 1; page <= maxPages; page++) {
|
|
||||||
if (ctrl.signal.aborted) return;
|
|
||||||
|
|
||||||
const key = dKey(src.id, type, genre, page);
|
|
||||||
let mangas: Manga[];
|
|
||||||
let hasNextPage = false;
|
|
||||||
|
|
||||||
if (store.discoverCache.has(key)) {
|
|
||||||
// Cache hit — no network call needed
|
|
||||||
mangas = store.discoverCache.get(key)!;
|
|
||||||
} else {
|
|
||||||
const result = await gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>(
|
|
||||||
FETCH_SOURCE_MANGA,
|
|
||||||
{ source: src.id, type, page, query },
|
|
||||||
ctrl.signal
|
|
||||||
).then(d => d.fetchSourceManga).catch(() => null);
|
|
||||||
|
|
||||||
if (!result || ctrl.signal.aborted) return;
|
|
||||||
mangas = result.mangas;
|
|
||||||
hasNextPage = result.hasNextPage;
|
|
||||||
store.discoverCache.set(key, mangas);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ctrl.signal.aborted) return;
|
|
||||||
|
|
||||||
if (isAll) {
|
|
||||||
pushToGrid("All", mangas);
|
|
||||||
} else {
|
|
||||||
const matching = mangas.filter(m =>
|
|
||||||
(m.genre ?? []).some(g => g.toLowerCase() === genre.toLowerCase())
|
|
||||||
);
|
|
||||||
pushToGrid(genre, matching.length ? matching : mangas);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stop paging early if source is exhausted
|
|
||||||
if (!hasNextPage) return;
|
|
||||||
}
|
|
||||||
}, ctrl.signal);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Tab switch ────────────────────────────────────────────────────────────
|
|
||||||
async function switchGenre(genre: string) {
|
|
||||||
if (currentGenre === genre) return;
|
|
||||||
|
|
||||||
activeCtrl?.abort();
|
|
||||||
currentGenre = genre;
|
|
||||||
|
|
||||||
const ctrl = new AbortController();
|
|
||||||
activeCtrl = ctrl;
|
|
||||||
|
|
||||||
if (genre === "All") {
|
|
||||||
// Already have results from this session — show instantly, re-fan in background
|
|
||||||
if ((genreResults.get("All") ?? []).length > 0) {
|
|
||||||
genreLoading = false;
|
|
||||||
fanOut("All", ctrl).catch(() => {});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
genreResults.set("All", []);
|
|
||||||
genreResults = new Map(genreResults);
|
|
||||||
genreLoading = true;
|
|
||||||
await fanOut("All", ctrl);
|
|
||||||
if (!ctrl.signal.aborted) genreLoading = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Genre tab: serve cached local results instantly, always fan out too
|
|
||||||
const localKey = `local|${genre}`;
|
|
||||||
if (store.discoverCache.has(localKey)) {
|
|
||||||
genreResults.set(genre, store.discoverCache.get(localKey)!);
|
|
||||||
genreResults = new Map(genreResults);
|
|
||||||
fanOut(genre, ctrl).catch(() => {});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
genreLoading = true;
|
|
||||||
try {
|
|
||||||
const d = await gql<{ mangas: { nodes: Manga[] } }>(
|
|
||||||
MANGAS_BY_GENRE, { genre, first: GRID_LIMIT }, ctrl.signal
|
|
||||||
);
|
|
||||||
if (ctrl.signal.aborted) return;
|
|
||||||
|
|
||||||
const local = dedup(d.mangas.nodes);
|
|
||||||
store.discoverCache.set(localKey, local);
|
|
||||||
genreResults.set(genre, local.slice(0, GRID_LIMIT));
|
|
||||||
genreResults = new Map(genreResults);
|
|
||||||
genreLoading = false;
|
|
||||||
|
|
||||||
fanOut(genre, ctrl).catch(() => {});
|
|
||||||
} catch (e: any) {
|
|
||||||
if (e?.name !== "AbortError") console.error(e);
|
|
||||||
if (!ctrl.signal.aborted) genreLoading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Refresh ───────────────────────────────────────────────────────────────
|
|
||||||
async function refresh() {
|
|
||||||
activeCtrl?.abort();
|
|
||||||
clearDiscoverCache(); // wipes store.discoverCache + bumps discoverSrcOffset
|
|
||||||
genreResults = new Map();
|
|
||||||
refreshing = true;
|
|
||||||
genreLoading = true;
|
|
||||||
const genre = currentGenre;
|
|
||||||
currentGenre = "";
|
|
||||||
await new Promise(r => setTimeout(r, 20));
|
|
||||||
await switchGenre(genre);
|
|
||||||
refreshing = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Initial load ──────────────────────────────────────────────────────────
|
|
||||||
function loadAll() {
|
|
||||||
loadingLib = true;
|
|
||||||
loadError = false;
|
|
||||||
|
|
||||||
// Already have a session grid — show it immediately
|
|
||||||
if ((genreResults.get("All") ?? []).length > 0) {
|
|
||||||
loadingLib = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Refresh library ID set so newly-added manga get filtered out
|
|
||||||
cache.get(CACHE_KEYS.DISCOVER, () =>
|
|
||||||
gql<{ mangas: { nodes: Manga[] } }>(EXPLORE_ALL_MANGA).then(d => d.mangas.nodes)
|
|
||||||
).then(m => {
|
|
||||||
store.discoverLibraryIds = new Set(
|
|
||||||
dedupeMangaById(m).filter(x => x.inLibrary).map(x => x.id)
|
|
||||||
);
|
|
||||||
}).catch(e => { console.error(e); loadError = true; })
|
|
||||||
.finally(() => { loadingLib = false; });
|
|
||||||
|
|
||||||
// Load sources then kick off All tab fan-out (only if grid is empty)
|
|
||||||
gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
|
|
||||||
.then(d => {
|
|
||||||
allSources = d.sources.nodes;
|
|
||||||
if ((currentGenre === "All" || currentGenre === "") &&
|
|
||||||
(genreResults.get("All") ?? []).length === 0) {
|
|
||||||
const ctrl = new AbortController();
|
|
||||||
activeCtrl = ctrl;
|
|
||||||
genreLoading = true;
|
|
||||||
fanOut("All", ctrl).then(() => {
|
|
||||||
if (!ctrl.signal.aborted) genreLoading = false;
|
|
||||||
}).catch(() => {});
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(console.error);
|
|
||||||
}
|
|
||||||
|
|
||||||
onDestroy(() => { activeCtrl?.abort(); });
|
|
||||||
|
|
||||||
loadAll();
|
|
||||||
|
|
||||||
// ── Context menu ──────────────────────────────────────────────────────────
|
|
||||||
function openCtx(e: MouseEvent, m: Manga) {
|
|
||||||
e.preventDefault(); e.stopPropagation();
|
|
||||||
ctx = { x: e.clientX, y: e.clientY, manga: m };
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildCtxItems(m: Manga): MenuEntry[] {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
label: m.inLibrary ? "In Library" : "Add to library",
|
|
||||||
icon: BookmarkSimple, disabled: m.inLibrary,
|
|
||||||
onClick: () => gql(UPDATE_MANGA, { id: m.id, inLibrary: true })
|
|
||||||
.then(() => {
|
|
||||||
cache.clear(CACHE_KEYS.LIBRARY);
|
|
||||||
store.discoverLibraryIds = new Set([...store.discoverLibraryIds, m.id]);
|
|
||||||
}).catch(console.error),
|
|
||||||
},
|
|
||||||
...(store.settings.folders.length > 0 ? [
|
|
||||||
{ separator: true } as MenuEntry,
|
|
||||||
...store.settings.folders.map(f => ({
|
|
||||||
label: f.mangaIds.includes(m.id) ? `✓ ${f.name}` : f.name,
|
|
||||||
icon: Folder,
|
|
||||||
onClick: () => assignMangaToFolder(f.id, m.id),
|
|
||||||
})),
|
|
||||||
] : []),
|
|
||||||
{ separator: true },
|
|
||||||
{
|
|
||||||
label: "New folder & add", icon: FolderSimplePlus,
|
|
||||||
onClick: () => {
|
|
||||||
const n = prompt("Folder name:");
|
|
||||||
if (n?.trim()) { const id = addFolder(n.trim()); assignMangaToFolder(id, m.id); }
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#if store.activeSource}
|
|
||||||
<SourceBrowse />
|
|
||||||
{:else}
|
|
||||||
<div class="root">
|
|
||||||
|
|
||||||
<div class="header">
|
|
||||||
<span class="heading">Discover</span>
|
|
||||||
<div class="tab-strip">
|
|
||||||
{#each GENRE_TABS as tab (tab)}
|
|
||||||
<button
|
|
||||||
class="genre-tab"
|
|
||||||
class:active={currentGenre === tab}
|
|
||||||
onclick={() => switchGenre(tab)}
|
|
||||||
>
|
|
||||||
{#if tab === "All"}<Sparkle size={10} weight="fill" />{/if}
|
|
||||||
{tab}
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
<button class="refresh-btn" class:spinning={refreshing} onclick={refresh} title="Refresh results" disabled={refreshing}>
|
|
||||||
<ArrowsClockwise size={13} weight="bold" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="body">
|
|
||||||
{#if isLoading && visibleGrid.length === 0}
|
|
||||||
<div class="manga-grid">
|
|
||||||
{#each Array(24) as _, i (i)}
|
|
||||||
<div class="card-skeleton"><div class="skeleton cover-area"></div></div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{:else if loadError && visibleGrid.length === 0}
|
|
||||||
<div class="empty">
|
|
||||||
<span>Could not reach Suwayomi</span>
|
|
||||||
<button class="retry-btn" onclick={loadAll}>Retry</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{:else if visibleGrid.length === 0}
|
|
||||||
<div class="empty"><span>Nothing found for "{currentGenre}"</span></div>
|
|
||||||
|
|
||||||
{:else}
|
|
||||||
<div class="manga-grid">
|
|
||||||
{#each visibleGrid as m (m.id)}
|
|
||||||
<button
|
|
||||||
class="manga-card"
|
|
||||||
onclick={() => setPreviewManga(m)}
|
|
||||||
oncontextmenu={(e) => openCtx(e, m)}
|
|
||||||
>
|
|
||||||
<div class="cover-wrap">
|
|
||||||
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} class="cover" loading="lazy" decoding="async" />
|
|
||||||
<div class="cover-gradient"></div>
|
|
||||||
{#if m.inLibrary}<span class="lib-badge">Saved</span>{/if}
|
|
||||||
<div class="card-footer">
|
|
||||||
<p class="card-title">{m.title}</p>
|
|
||||||
{#if m.source?.displayName}<p class="card-source">{m.source.displayName}</p>{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if ctx}
|
|
||||||
<ContextMenu x={ctx.x} y={ctx.y} items={buildCtxItems(ctx.manga)} onClose={() => ctx = null} />
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.root { display: flex; flex-direction: column; height: 100%; overflow: hidden; animation: fadeIn 0.14s ease both; }
|
|
||||||
.header { display: flex; align-items: center; gap: var(--sp-4); flex-shrink: 0; padding: var(--sp-3) var(--sp-4) var(--sp-3) var(--sp-6); border-bottom: 1px solid var(--border-dim); overflow-x: auto; scrollbar-width: none; }
|
|
||||||
.header::-webkit-scrollbar { display: none; }
|
|
||||||
.heading { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; flex-shrink: 0; }
|
|
||||||
.tab-strip { display: flex; gap: var(--sp-1); flex-shrink: 0; }
|
|
||||||
.genre-tab { display: flex; align-items: center; gap: 5px; font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 4px 12px; border-radius: var(--radius-full); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); cursor: pointer; white-space: nowrap; transition: background var(--t-base), color var(--t-base), border-color var(--t-base); }
|
|
||||||
.genre-tab:hover { color: var(--text-muted); border-color: var(--border-strong); background: var(--bg-raised); }
|
|
||||||
.genre-tab.active { background: var(--accent-muted); border-color: var(--accent-dim); color: var(--accent-fg); }
|
|
||||||
.refresh-btn { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: var(--radius-sm); background: none; border: none; color: var(--text-faint); cursor: pointer; flex-shrink: 0; margin-left: auto; transition: color var(--t-base), background var(--t-base); }
|
|
||||||
.refresh-btn:hover { color: var(--text-muted); background: var(--bg-raised); }
|
|
||||||
.body { flex: 1; overflow-y: auto; padding: var(--sp-4) var(--sp-5) var(--sp-6); will-change: scroll-position; -webkit-overflow-scrolling: touch; contain: layout style; }
|
|
||||||
.manga-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(clamp(90px, 11vw, 130px), 1fr)); gap: var(--sp-2); align-content: start; contain: layout style; }
|
|
||||||
.manga-card { background: none; border: none; padding: 0; cursor: pointer; text-align: left; }
|
|
||||||
.manga-card:hover .cover { filter: brightness(1.08) saturate(1.05); transform: scale(1.02); }
|
|
||||||
.manga-card:hover .card-title { color: #fff; }
|
|
||||||
.manga-card:hover { will-change: transform; }
|
|
||||||
.cover-wrap { position: relative; aspect-ratio: 2/3; overflow: hidden; border-radius: var(--radius-md); background: var(--bg-raised); border: 1px solid var(--border-dim); box-shadow: 0 2px 8px rgba(0,0,0,0.25); }
|
|
||||||
.cover { width: 100%; height: 100%; object-fit: cover; display: block; transition: filter 0.15s ease, transform 0.15s ease; }
|
|
||||||
.cover-gradient { position: absolute; inset: 0; background: linear-gradient(to top, rgba(0,0,0,0.82) 0%, rgba(0,0,0,0.15) 50%, transparent 72%); pointer-events: none; }
|
|
||||||
.lib-badge { position: absolute; top: var(--sp-1); right: var(--sp-1); font-family: var(--font-ui); font-size: 9px; letter-spacing: var(--tracking-wide); text-transform: uppercase; background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim); padding: 1px 5px; border-radius: var(--radius-sm); }
|
|
||||||
.card-footer { position: absolute; bottom: 0; left: 0; right: 0; padding: var(--sp-2); pointer-events: none; }
|
|
||||||
.card-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); transition: color var(--t-base); }
|
|
||||||
.card-source { font-family: var(--font-ui); font-size: 9px; color: rgba(255,255,255,0.45); letter-spacing: var(--tracking-wide); margin-top: 1px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
||||||
.card-skeleton { padding: 0; }
|
|
||||||
.cover-area { aspect-ratio: 2/3; border-radius: var(--radius-md); }
|
|
||||||
.skeleton { background: var(--bg-raised); border-radius: var(--radius-sm); animation: pulse 1.6s ease-in-out infinite; }
|
|
||||||
@keyframes pulse { 0%, 100% { opacity: 0.4 } 50% { opacity: 0.85 } }
|
|
||||||
.empty { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: var(--sp-3); padding: var(--sp-10) var(--sp-6); color: var(--text-faint); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); }
|
|
||||||
.retry-btn { padding: 6px 16px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: var(--bg-raised); color: var(--text-muted); cursor: pointer; font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); }
|
|
||||||
.refresh-btn.spinning { opacity: 0.5; cursor: default; }
|
|
||||||
.refresh-btn.spinning :global(svg) { animation: spin 0.8s linear infinite; }
|
|
||||||
@keyframes spin { from { transform: rotate(0deg) } to { transform: rotate(360deg) } }
|
|
||||||
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
|
|
||||||
</style>
|
|
||||||
@@ -1,181 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { Play, Pause, Trash, CircleNotch, X } from "phosphor-svelte";
|
|
||||||
import { gql, thumbUrl } from "../../lib/client";
|
|
||||||
import { GET_DOWNLOAD_STATUS, START_DOWNLOADER, STOP_DOWNLOADER, CLEAR_DOWNLOADER, DEQUEUE_DOWNLOAD } from "../../lib/queries";
|
|
||||||
import { store, setActiveDownloads } from "../../store/state.svelte";
|
|
||||||
import type { DownloadStatus } from "../../lib/types";
|
|
||||||
|
|
||||||
let status: DownloadStatus | null = $state(null);
|
|
||||||
let loading = $state(true);
|
|
||||||
let togglingPlay = $state(false);
|
|
||||||
let clearing = $state(false);
|
|
||||||
let dequeueing = $state(new Set<number>());
|
|
||||||
let interval: ReturnType<typeof setInterval>;
|
|
||||||
|
|
||||||
function applyStatus(ds: DownloadStatus) {
|
|
||||||
status = ds;
|
|
||||||
setActiveDownloads(ds.queue.map((item) => ({
|
|
||||||
chapterId: item.chapter.id,
|
|
||||||
mangaId: item.chapter.mangaId,
|
|
||||||
progress: item.progress,
|
|
||||||
})));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function poll() {
|
|
||||||
gql<{ downloadStatus: DownloadStatus }>(GET_DOWNLOAD_STATUS)
|
|
||||||
.then((d) => applyStatus(d.downloadStatus))
|
|
||||||
.catch(console.error)
|
|
||||||
.finally(() => loading = false);
|
|
||||||
}
|
|
||||||
|
|
||||||
$effect(() => { poll(); interval = setInterval(poll, 2000); return () => clearInterval(interval); });
|
|
||||||
|
|
||||||
async function togglePlay() {
|
|
||||||
if (togglingPlay) return;
|
|
||||||
togglingPlay = true;
|
|
||||||
const wasRunning = status?.state === "STARTED";
|
|
||||||
if (status) status = { ...status, state: wasRunning ? "STOPPED" : "STARTED" };
|
|
||||||
try {
|
|
||||||
if (wasRunning) {
|
|
||||||
const d = await gql<{ stopDownloader: { downloadStatus: DownloadStatus } }>(STOP_DOWNLOADER);
|
|
||||||
applyStatus(d.stopDownloader.downloadStatus);
|
|
||||||
} else {
|
|
||||||
const d = await gql<{ startDownloader: { downloadStatus: DownloadStatus } }>(START_DOWNLOADER);
|
|
||||||
applyStatus(d.startDownloader.downloadStatus);
|
|
||||||
}
|
|
||||||
} catch (e) { console.error(e); poll(); }
|
|
||||||
finally { togglingPlay = false; }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function clear() {
|
|
||||||
if (clearing) return;
|
|
||||||
clearing = true;
|
|
||||||
if (status) status = { ...status, queue: [] };
|
|
||||||
setActiveDownloads([]);
|
|
||||||
try {
|
|
||||||
const d = await gql<{ clearDownloader: { downloadStatus: DownloadStatus } }>(CLEAR_DOWNLOADER);
|
|
||||||
applyStatus(d.clearDownloader.downloadStatus);
|
|
||||||
} catch (e) { console.error(e); poll(); }
|
|
||||||
finally { clearing = false; }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function dequeue(chapterId: number) {
|
|
||||||
if (dequeueing.has(chapterId)) return;
|
|
||||||
dequeueing = new Set(dequeueing).add(chapterId);
|
|
||||||
if (status) status = { ...status, queue: status.queue.filter((i) => i.chapter.id !== chapterId) };
|
|
||||||
try { await gql(DEQUEUE_DOWNLOAD, { chapterId }); poll(); }
|
|
||||||
catch (e) { console.error(e); poll(); }
|
|
||||||
finally { dequeueing.delete(chapterId); dequeueing = new Set(dequeueing); }
|
|
||||||
}
|
|
||||||
let queue = $derived(status?.queue ?? []);
|
|
||||||
const isRunning = $derived(status?.state === "STARTED");
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="root">
|
|
||||||
<div class="header">
|
|
||||||
<h1 class="heading">Downloads</h1>
|
|
||||||
|
|
||||||
<div class="header-actions">
|
|
||||||
<button class="icon-btn" class:loading={togglingPlay} onclick={togglePlay}
|
|
||||||
disabled={togglingPlay || (queue.length === 0 && !isRunning)} title={isRunning ? "Pause" : "Resume"}>
|
|
||||||
{#if togglingPlay}<CircleNotch size={14} weight="light" class="anim-spin" />
|
|
||||||
{:else if isRunning}<Pause size={14} weight="fill" />
|
|
||||||
{:else}<Play size={14} weight="fill" />{/if}
|
|
||||||
</button>
|
|
||||||
<button class="icon-btn" class:loading={clearing} onclick={clear}
|
|
||||||
disabled={clearing || queue.length === 0} title="Clear queue">
|
|
||||||
{#if clearing}<CircleNotch size={14} weight="light" class="anim-spin" />
|
|
||||||
{:else}<Trash size={14} weight="regular" />{/if}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="content">
|
|
||||||
<div class="status-bar">
|
|
||||||
<div class="status-dot" class:active={isRunning}></div>
|
|
||||||
<span class="status-text">
|
|
||||||
{togglingPlay ? (isRunning ? "Pausing…" : "Starting…") : isRunning ? "Downloading" : "Paused"}
|
|
||||||
</span>
|
|
||||||
<span class="status-count">{queue.length} queued</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if loading}
|
|
||||||
<div class="empty"><CircleNotch size={16} weight="light" class="anim-spin" style="color:var(--text-faint)" /></div>
|
|
||||||
{:else if queue.length === 0}
|
|
||||||
<div class="empty">Queue is empty.</div>
|
|
||||||
{:else}
|
|
||||||
<div class="list">
|
|
||||||
{#each queue as item, i (item.chapter.id)}
|
|
||||||
{@const isActive = i === 0 && isRunning}
|
|
||||||
{@const pages = item.chapter.pageCount ?? 0}
|
|
||||||
{@const done = Math.round(item.progress * pages)}
|
|
||||||
{@const manga = item.chapter.manga}
|
|
||||||
{@const isRemoving = dequeueing.has(item.chapter.id)}
|
|
||||||
<div class="row" class:row-active={isActive} class:row-removing={isRemoving}>
|
|
||||||
{#if manga?.thumbnailUrl}
|
|
||||||
<div class="thumb">
|
|
||||||
<img src={thumbUrl(manga.thumbnailUrl)} alt={manga?.title} class="thumb-img" loading="lazy" decoding="async" />
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
<div class="info">
|
|
||||||
{#if manga?.title}<span class="manga-title">{manga.title}</span>{/if}
|
|
||||||
<span class="chapter-name">{item.chapter.name}</span>
|
|
||||||
{#if pages > 0}
|
|
||||||
<span class="pages-label">{isActive ? `${done} / ${pages} pages` : `${pages} pages`}</span>
|
|
||||||
{/if}
|
|
||||||
{#if isActive}
|
|
||||||
<div class="progress-wrap">
|
|
||||||
<div class="progress-bar" style="width:{Math.round(item.progress * 100)}%"></div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<div class="row-right">
|
|
||||||
<span class="state-label">{item.state}</span>
|
|
||||||
{#if !isActive}
|
|
||||||
<button class="remove-btn" onclick={() => dequeue(item.chapter.id)} disabled={isRemoving} title="Remove from queue">
|
|
||||||
{#if isRemoving}<CircleNotch size={11} weight="light" class="anim-spin" />{:else}<X size={12} weight="light" />{/if}
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div><!-- .content -->
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.root { display: flex; flex-direction: column; height: 100%; overflow: hidden; animation: fadeIn 0.14s ease both; }
|
|
||||||
.header { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-4) var(--sp-6); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
|
|
||||||
.heading { font-family: var(--font-ui); font-size: var(--text-xs); font-weight: var(--weight-normal); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; }
|
|
||||||
.header-actions { display: flex; gap: var(--sp-2); }
|
|
||||||
.content { flex: 1; overflow-y: auto; padding: var(--sp-5) var(--sp-6) var(--sp-6); display: flex; flex-direction: column; gap: var(--sp-4); }
|
|
||||||
.icon-btn { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); color: var(--text-muted); transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
|
|
||||||
.icon-btn:hover:not(:disabled) { color: var(--text-secondary); border-color: var(--border-strong); background: var(--bg-raised); }
|
|
||||||
.icon-btn:disabled { opacity: 0.3; cursor: default; }
|
|
||||||
.icon-btn.loading { border-color: var(--accent-dim); color: var(--accent-fg); background: var(--accent-muted); }
|
|
||||||
.status-bar { display: flex; align-items: center; gap: var(--sp-3); padding: var(--sp-3); background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); }
|
|
||||||
.status-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--text-faint); flex-shrink: 0; transition: background var(--t-base); }
|
|
||||||
.status-dot.active { background: var(--accent); animation: pulse 1.6s ease infinite; }
|
|
||||||
@keyframes pulse { 0%,100% { opacity: 1 } 50% { opacity: 0.4 } }
|
|
||||||
.status-text { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); flex: 1; letter-spacing: var(--tracking-wide); }
|
|
||||||
.status-count { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
|
||||||
.list { display: flex; flex-direction: column; gap: var(--sp-2); }
|
|
||||||
.row { display: flex; align-items: center; gap: var(--sp-3); padding: var(--sp-3); background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); transition: border-color var(--t-fast), opacity var(--t-base); }
|
|
||||||
.row.row-active { border-color: var(--accent-dim); }
|
|
||||||
.row.row-removing { opacity: 0.4; pointer-events: none; }
|
|
||||||
.thumb { width: 36px; height: 54px; border-radius: var(--radius-sm); overflow: hidden; background: var(--bg-overlay); flex-shrink: 0; border: 1px solid var(--border-dim); }
|
|
||||||
.thumb-img { width: 100%; height: 100%; object-fit: cover; }
|
|
||||||
.info { flex: 1; display: flex; flex-direction: column; gap: 3px; overflow: hidden; min-width: 0; }
|
|
||||||
.manga-title { font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
||||||
.chapter-name { font-size: var(--text-xs); color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
||||||
.pages-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
|
||||||
.progress-wrap { height: 2px; background: var(--border-base); border-radius: var(--radius-full); overflow: hidden; margin-top: 4px; }
|
|
||||||
.progress-bar { height: 100%; background: var(--accent); border-radius: var(--radius-full); transition: width 0.4s ease; }
|
|
||||||
.row-right { display: flex; flex-direction: column; align-items: flex-end; gap: var(--sp-1); flex-shrink: 0; }
|
|
||||||
.state-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; }
|
|
||||||
.remove-btn { display: flex; align-items: center; justify-content: center; width: 20px; height: 20px; border-radius: var(--radius-sm); color: var(--text-faint); transition: color var(--t-base), background var(--t-base); }
|
|
||||||
.remove-btn:hover:not(:disabled) { color: var(--color-error); background: var(--color-error-bg); }
|
|
||||||
.remove-btn:disabled { opacity: 0.5; cursor: default; }
|
|
||||||
.empty { display: flex; align-items: center; justify-content: center; height: 160px; color: var(--text-faint); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); }
|
|
||||||
</style>
|
|
||||||
@@ -1,351 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { untrack } from "svelte";
|
|
||||||
import { MagnifyingGlass, ArrowsClockwise, Plus, CircleNotch, CaretRight, CaretDown, X, Check, GitBranch } from "phosphor-svelte";
|
|
||||||
import { gql, thumbUrl } from "../../lib/client";
|
|
||||||
import { GET_EXTENSIONS, FETCH_EXTENSIONS, UPDATE_EXTENSION, INSTALL_EXTERNAL_EXTENSION, GET_SETTINGS, SET_EXTENSION_REPOS } from "../../lib/queries";
|
|
||||||
import { store } from "../../store/state.svelte";
|
|
||||||
import type { Extension } from "../../lib/types";
|
|
||||||
|
|
||||||
type Filter = "installed" | "available" | "updates" | "all";
|
|
||||||
type Panel = null | "apk" | "repos";
|
|
||||||
|
|
||||||
function baseName(name: string): string { return name.replace(/\s*\([A-Z0-9-]{2,10}\)\s*$/, "").trim(); }
|
|
||||||
|
|
||||||
let extensions: Extension[] = $state([]);
|
|
||||||
let loading = $state(true);
|
|
||||||
let refreshing = $state(false);
|
|
||||||
let filter: Filter = $state("installed");
|
|
||||||
let search = $state("");
|
|
||||||
let working = $state(new Set<string>());
|
|
||||||
let expanded = $state(new Set<string>());
|
|
||||||
let panel: Panel = $state(null);
|
|
||||||
let externalUrl = $state("");
|
|
||||||
let installing = $state(false);
|
|
||||||
let installError: string|null = $state(null);
|
|
||||||
let installSuccess = $state(false);
|
|
||||||
let repos: string[] = $state([]);
|
|
||||||
let reposLoading = $state(false);
|
|
||||||
let newRepoUrl = $state("");
|
|
||||||
let repoError: string|null = $state(null);
|
|
||||||
let savingRepos = $state(false);
|
|
||||||
|
|
||||||
async function load() {
|
|
||||||
return gql<{ extensions: { nodes: Extension[] } }>(GET_EXTENSIONS)
|
|
||||||
.then((d) => extensions = d.extensions.nodes).catch(console.error);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchFromRepo() {
|
|
||||||
refreshing = true;
|
|
||||||
return gql<{ fetchExtensions: { extensions: Extension[] } }>(FETCH_EXTENSIONS)
|
|
||||||
.then((d) => extensions = d.fetchExtensions.extensions).catch(console.error)
|
|
||||||
.finally(() => refreshing = false);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadRepos() {
|
|
||||||
reposLoading = true;
|
|
||||||
try { const d = await gql<{ settings: { extensionRepos: string[] } }>(GET_SETTINGS); repos = d.settings.extensionRepos ?? []; }
|
|
||||||
catch (e) { console.error(e); } finally { reposLoading = false; }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveRepos(updated: string[]) {
|
|
||||||
savingRepos = true;
|
|
||||||
try { const d = await gql<{ setSettings: { settings: { extensionRepos: string[] } } }>(SET_EXTENSION_REPOS, { repos: updated }); repos = d.setSettings.settings.extensionRepos; }
|
|
||||||
catch (e: any) { repoError = e instanceof Error ? e.message : "Failed to save"; } finally { savingRepos = false; }
|
|
||||||
}
|
|
||||||
|
|
||||||
function addRepo() {
|
|
||||||
const url = newRepoUrl.trim();
|
|
||||||
if (!url) return;
|
|
||||||
if (!url.startsWith("http://") && !url.startsWith("https://")) { repoError = "URL must start with http:// or https://"; return; }
|
|
||||||
if (repos.includes(url)) { repoError = "Repo already added"; return; }
|
|
||||||
repoError = null; newRepoUrl = "";
|
|
||||||
saveRepos([...repos, url]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeRepo(url: string) { saveRepos(repos.filter((r) => r !== url)); }
|
|
||||||
|
|
||||||
async function mutate(fn: () => Promise<unknown>, pkgName: string) {
|
|
||||||
working = new Set(working).add(pkgName);
|
|
||||||
await fn().catch(console.error);
|
|
||||||
await load();
|
|
||||||
working.delete(pkgName); working = new Set(working);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function installExternal() {
|
|
||||||
const url = externalUrl.trim();
|
|
||||||
if (!url) return;
|
|
||||||
if (!url.startsWith("http://") && !url.startsWith("https://")) { installError = "URL must start with http:// or https://"; return; }
|
|
||||||
if (!url.endsWith(".apk")) { installError = "URL must point to an .apk file"; return; }
|
|
||||||
installing = true; installError = null; installSuccess = false;
|
|
||||||
try {
|
|
||||||
await gql(INSTALL_EXTERNAL_EXTENSION, { url });
|
|
||||||
installSuccess = true; externalUrl = "";
|
|
||||||
await load();
|
|
||||||
setTimeout(() => { panel = null; installSuccess = false; }, 1500);
|
|
||||||
} catch (e: any) { installError = e instanceof Error ? e.message : "Install failed"; }
|
|
||||||
finally { installing = false; }
|
|
||||||
}
|
|
||||||
|
|
||||||
function openPanel(p: Panel) {
|
|
||||||
panel = panel === p ? null : p;
|
|
||||||
installError = null; installSuccess = false; externalUrl = "";
|
|
||||||
repoError = null; newRepoUrl = "";
|
|
||||||
if (p === "repos") loadRepos();
|
|
||||||
}
|
|
||||||
|
|
||||||
$effect(() => { untrack(() => fetchFromRepo().finally(() => { loading = false; })); });
|
|
||||||
|
|
||||||
const filtered = $derived(extensions.filter((e) => {
|
|
||||||
const q = search.toLowerCase();
|
|
||||||
const matchSearch = e.name.toLowerCase().includes(q) || e.lang.toLowerCase().includes(q);
|
|
||||||
const matchFilter = filter === "installed" ? e.isInstalled : filter === "available" ? !e.isInstalled : filter === "updates" ? e.hasUpdate : true;
|
|
||||||
return matchSearch && matchFilter;
|
|
||||||
}));
|
|
||||||
|
|
||||||
const groups = $derived.by(() => {
|
|
||||||
const map = new Map<string, Extension[]>();
|
|
||||||
for (const ext of filtered) { const key = baseName(ext.name); if (!map.has(key)) map.set(key, []); map.get(key)!.push(ext); }
|
|
||||||
const preferredLang = store.settings.preferredExtensionLang;
|
|
||||||
return Array.from(map.entries()).map(([base, all]) => {
|
|
||||||
const primary = all.find((v) => v.lang === preferredLang) ?? all.find((v) => v.lang === "en") ?? all[0];
|
|
||||||
return { base, primary, variants: all.filter((v) => v.pkgName !== primary.pkgName) };
|
|
||||||
});
|
|
||||||
});
|
|
||||||
const updateCount = $derived(extensions.filter((e) => e.hasUpdate).length);
|
|
||||||
|
|
||||||
const FILTERS: { id: Filter; label: string }[] = [
|
|
||||||
{ id: "installed", label: "Installed" },
|
|
||||||
{ id: "available", label: "Available" },
|
|
||||||
{ id: "updates", label: "Updates" },
|
|
||||||
{ id: "all", label: "All" },
|
|
||||||
];
|
|
||||||
|
|
||||||
function toggleExpand(base: string) {
|
|
||||||
const next = new Set(expanded);
|
|
||||||
next.has(base) ? next.delete(base) : next.add(base);
|
|
||||||
expanded = next;
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="root">
|
|
||||||
<div class="header">
|
|
||||||
<h1 class="heading">Extensions</h1>
|
|
||||||
<div class="tabs">
|
|
||||||
{#each FILTERS as f}
|
|
||||||
<button class="tab" class:active={filter === f.id} onclick={() => filter = f.id}>
|
|
||||||
{f.id === "updates" && updateCount > 0 ? `Updates (${updateCount})` : f.label}
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
<div class="header-right">
|
|
||||||
<div class="search-wrap">
|
|
||||||
<MagnifyingGlass size={12} class="search-icon" weight="light" />
|
|
||||||
<input class="search" placeholder="Search" bind:value={search} />
|
|
||||||
</div>
|
|
||||||
<button class="icon-btn" class:active={panel === "repos"} onclick={() => openPanel("repos")} title="Manage repos">
|
|
||||||
<GitBranch size={14} weight="light" />
|
|
||||||
</button>
|
|
||||||
<button class="icon-btn" class:active={panel === "apk"} onclick={() => openPanel("apk")} title="Install from URL">
|
|
||||||
<Plus size={14} weight="light" />
|
|
||||||
</button>
|
|
||||||
<button class="icon-btn" onclick={fetchFromRepo} disabled={refreshing} title="Refresh repo">
|
|
||||||
<ArrowsClockwise size={14} weight="light" class={refreshing ? "anim-spin" : ""} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if panel === "apk"}
|
|
||||||
<div class="ext-panel">
|
|
||||||
<div class="panel-header">
|
|
||||||
<span class="panel-title">Install from APK URL</span>
|
|
||||||
<button class="icon-btn" onclick={() => panel = null}><X size={14} weight="light" /></button>
|
|
||||||
</div>
|
|
||||||
<div class="ext-row">
|
|
||||||
<input class="ext-input" class:error={installError} placeholder="https://example.com/extension.apk"
|
|
||||||
bind:value={externalUrl} disabled={installing}
|
|
||||||
oninput={() => installError = null}
|
|
||||||
onkeydown={(e) => e.key === "Enter" && !installing && installExternal()} use:focusOnMount />
|
|
||||||
<button class="install-btn" class:success={installSuccess} onclick={installExternal} disabled={installing || !externalUrl.trim()}>
|
|
||||||
{#if installing}<CircleNotch size={13} weight="light" class="anim-spin" />
|
|
||||||
{:else if installSuccess}<Check size={13} weight="bold" /> Done
|
|
||||||
{:else}Install{/if}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{#if installError}<div class="panel-error">{installError}</div>{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if panel === "repos"}
|
|
||||||
<div class="ext-panel">
|
|
||||||
<div class="panel-header">
|
|
||||||
<span class="panel-title">Extension Repositories</span>
|
|
||||||
<button class="icon-btn" onclick={() => panel = null}><X size={14} weight="light" /></button>
|
|
||||||
</div>
|
|
||||||
{#if reposLoading}
|
|
||||||
<div class="repo-loading"><CircleNotch size={14} weight="light" class="anim-spin" style="color:var(--text-faint)" /></div>
|
|
||||||
{:else}
|
|
||||||
{#if repos.length === 0}
|
|
||||||
<div class="repo-empty">No repos configured.</div>
|
|
||||||
{:else}
|
|
||||||
<div class="repo-list">
|
|
||||||
{#each repos as url}
|
|
||||||
<div class="repo-row">
|
|
||||||
<span class="repo-url">{url}</span>
|
|
||||||
<button class="repo-remove" onclick={() => removeRepo(url)} disabled={savingRepos} title="Remove repo">
|
|
||||||
{#if savingRepos}<CircleNotch size={12} weight="light" class="anim-spin" />{:else}<X size={12} weight="bold" />{/if}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
<div class="ext-row" style="margin-top:var(--sp-2)">
|
|
||||||
<input class="ext-input" class:error={repoError} placeholder="https://example.com/index.min.json"
|
|
||||||
bind:value={newRepoUrl} disabled={savingRepos}
|
|
||||||
oninput={() => repoError = null}
|
|
||||||
onkeydown={(e) => e.key === "Enter" && !savingRepos && addRepo()} />
|
|
||||||
<button class="install-btn" onclick={addRepo} disabled={savingRepos || !newRepoUrl.trim()}>
|
|
||||||
{#if savingRepos}<CircleNotch size={13} weight="light" class="anim-spin" />{:else}Add{/if}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{#if repoError}<div class="panel-error">{repoError}</div>{/if}
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
{#if loading}
|
|
||||||
<div class="empty"><CircleNotch size={16} weight="light" class="anim-spin" style="color:var(--text-faint)" /></div>
|
|
||||||
{:else if groups.length === 0}
|
|
||||||
<div class="empty">No extensions found.</div>
|
|
||||||
{:else}
|
|
||||||
<div class="list">
|
|
||||||
{#each groups as { base, primary, variants }}
|
|
||||||
{@const isExpanded = expanded.has(base)}
|
|
||||||
{@const hasVariants = variants.length > 0}
|
|
||||||
<div class="group">
|
|
||||||
<div class="row">
|
|
||||||
<img src={thumbUrl(primary.iconUrl)} alt={primary.name} class="icon" onerror={(e) => (e.target as HTMLImageElement).style.display = "none"} />
|
|
||||||
<div class="info">
|
|
||||||
<span class="name">{base}</span>
|
|
||||||
<span class="meta"><span class="lang-tag">{primary.lang.toUpperCase()}</span> v{primary.versionName}</span>
|
|
||||||
</div>
|
|
||||||
{#if working.has(primary.pkgName)}
|
|
||||||
<CircleNotch size={14} weight="light" class="anim-spin" style="color:var(--text-faint)" />
|
|
||||||
{:else if primary.hasUpdate}
|
|
||||||
<div class="row-actions">
|
|
||||||
<button class="action-btn" onclick={() => mutate(() => gql(UPDATE_EXTENSION, { id: primary.pkgName, update: true }), primary.pkgName)}>Update</button>
|
|
||||||
<button class="action-btn-dim" onclick={() => mutate(() => gql(UPDATE_EXTENSION, { id: primary.pkgName, uninstall: true }), primary.pkgName)}>Remove</button>
|
|
||||||
</div>
|
|
||||||
{:else if primary.isInstalled}
|
|
||||||
<button class="action-btn-dim" onclick={() => mutate(() => gql(UPDATE_EXTENSION, { id: primary.pkgName, uninstall: true }), primary.pkgName)}>Remove</button>
|
|
||||||
{:else}
|
|
||||||
<button class="action-btn" onclick={() => mutate(() => gql(UPDATE_EXTENSION, { id: primary.pkgName, install: true }), primary.pkgName)}>Install</button>
|
|
||||||
{/if}
|
|
||||||
{#if hasVariants}
|
|
||||||
<button class="expand-btn" onclick={() => toggleExpand(base)} title="{variants.length + 1} languages">
|
|
||||||
{#if isExpanded}<CaretDown size={12} weight="light" />{:else}<CaretRight size={12} weight="light" />{/if}
|
|
||||||
<span class="expand-count">{variants.length + 1}</span>
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{#if isExpanded && hasVariants}
|
|
||||||
<div class="variants">
|
|
||||||
{#each variants as v}
|
|
||||||
<div class="variant-row">
|
|
||||||
<span class="lang-tag">{v.lang.toUpperCase()}</span>
|
|
||||||
<span class="variant-name">{v.name}</span>
|
|
||||||
<span class="variant-version">v{v.versionName}</span>
|
|
||||||
{#if v.hasUpdate}<span class="update-badge-small">↑</span>{/if}
|
|
||||||
<div class="variant-actions">
|
|
||||||
{#if working.has(v.pkgName)}
|
|
||||||
<CircleNotch size={14} weight="light" class="anim-spin" style="color:var(--text-faint)" />
|
|
||||||
{:else if v.hasUpdate}
|
|
||||||
<button class="action-btn" onclick={() => mutate(() => gql(UPDATE_EXTENSION, { id: v.pkgName, update: true }), v.pkgName)}>Update</button>
|
|
||||||
{:else if v.isInstalled}
|
|
||||||
<button class="action-btn-dim" onclick={() => mutate(() => gql(UPDATE_EXTENSION, { id: v.pkgName, uninstall: true }), v.pkgName)}>Remove</button>
|
|
||||||
{:else}
|
|
||||||
<button class="action-btn" onclick={() => mutate(() => gql(UPDATE_EXTENSION, { id: v.pkgName, install: true }), v.pkgName)}>Install</button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.root { display: flex; flex-direction: column; height: 100%; overflow: hidden; animation: fadeIn 0.14s ease both; }
|
|
||||||
.header { display: flex; align-items: center; gap: var(--sp-4); padding: var(--sp-4) var(--sp-6); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
|
|
||||||
.header-right { display: flex; align-items: center; gap: var(--sp-2); margin-left: auto; }
|
|
||||||
.heading { font-family: var(--font-ui); font-size: var(--text-xs); font-weight: var(--weight-normal); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; }
|
|
||||||
.header-actions { display: flex; gap: var(--sp-1); }
|
|
||||||
.icon-btn { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: var(--radius-md); color: var(--text-muted); transition: color var(--t-base), background var(--t-base); }
|
|
||||||
.icon-btn:hover:not(:disabled) { color: var(--text-primary); background: var(--bg-raised); }
|
|
||||||
.icon-btn:disabled { opacity: 0.4; }
|
|
||||||
.icon-btn.active { color: var(--accent-fg); background: var(--accent-muted); }
|
|
||||||
.ext-panel { display: flex; flex-direction: column; gap: var(--sp-2); padding: 0 var(--sp-6) var(--sp-3); flex-shrink: 0; animation: fadeIn 0.1s ease both; }
|
|
||||||
.panel-header { display: flex; align-items: center; justify-content: space-between; }
|
|
||||||
.panel-title { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); letter-spacing: var(--tracking-wide); }
|
|
||||||
.panel-error { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--color-error); letter-spacing: var(--tracking-wide); padding: 0 2px; }
|
|
||||||
.ext-row { display: flex; gap: var(--sp-2); }
|
|
||||||
.ext-input { flex: 1; background: var(--bg-raised); border: 1px solid var(--border-strong); border-radius: var(--radius-md); padding: 6px var(--sp-3); color: var(--text-primary); font-size: var(--text-sm); outline: none; transition: border-color var(--t-base); }
|
|
||||||
.ext-input:focus { border-color: var(--border-focus); }
|
|
||||||
.ext-input:disabled { opacity: 0.5; }
|
|
||||||
.ext-input.error { border-color: var(--color-error) !important; }
|
|
||||||
.install-btn { display: flex; align-items: center; gap: var(--sp-1); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 6px 14px; border-radius: var(--radius-md); background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim); cursor: pointer; flex-shrink: 0; transition: filter var(--t-base), opacity var(--t-base); white-space: nowrap; }
|
|
||||||
.install-btn:hover:not(:disabled) { filter: brightness(1.1); }
|
|
||||||
.install-btn:disabled { opacity: 0.5; cursor: default; }
|
|
||||||
.install-btn.success { background: rgba(107,143,107,0.2); border-color: var(--accent-fg); color: var(--accent-fg); }
|
|
||||||
.repo-loading { display: flex; align-items: center; justify-content: center; padding: var(--sp-3); }
|
|
||||||
.repo-empty { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); padding: var(--sp-1) 2px; }
|
|
||||||
.repo-list { display: flex; flex-direction: column; gap: 2px; }
|
|
||||||
.repo-row { display: flex; align-items: center; gap: var(--sp-2); padding: 5px var(--sp-2); border-radius: var(--radius-md); background: var(--bg-raised); border: 1px solid var(--border-dim); }
|
|
||||||
.repo-url { flex: 1; font-size: var(--text-2xs); color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
||||||
.repo-remove { display: flex; align-items: center; justify-content: center; width: 20px; height: 20px; border-radius: var(--radius-sm); color: var(--text-faint); flex-shrink: 0; transition: color var(--t-base), background var(--t-base); }
|
|
||||||
.repo-remove:hover:not(:disabled) { color: var(--color-error); background: var(--bg-overlay); }
|
|
||||||
|
|
||||||
.tabs { display: flex; gap: 2px; background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 2px; }
|
|
||||||
.tab { display: flex; align-items: center; gap: 5px; font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); text-transform: uppercase; padding: 4px 10px; border-radius: var(--radius-sm); color: var(--text-faint); white-space: nowrap; transition: background var(--t-base), color var(--t-base); }
|
|
||||||
.tab:hover { color: var(--text-muted); }
|
|
||||||
.tab.active { background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim); }
|
|
||||||
.search-wrap { position: relative; display: flex; align-items: center; }
|
|
||||||
.search-wrap :global(.search-icon) { position: absolute; left: 9px; color: var(--text-faint); pointer-events: none; }
|
|
||||||
.search { background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 5px 10px 5px 26px; color: var(--text-primary); font-size: var(--text-sm); width: 160px; outline: none; transition: border-color var(--t-base); }
|
|
||||||
.search::placeholder { color: var(--text-faint); }
|
|
||||||
.search:focus { border-color: var(--border-strong); }
|
|
||||||
.list { flex: 1; overflow-y: auto; padding: 0 var(--sp-4) var(--sp-4); display: flex; flex-direction: column; gap: 1px; }
|
|
||||||
.group { display: flex; flex-direction: column; }
|
|
||||||
.row { display: flex; align-items: center; gap: var(--sp-3); padding: 8px var(--sp-3); border-radius: var(--radius-md); border: 1px solid transparent; transition: background var(--t-fast), border-color var(--t-fast); }
|
|
||||||
.row:hover { background: var(--bg-raised); border-color: var(--border-dim); }
|
|
||||||
.icon { width: 32px; height: 32px; border-radius: var(--radius-md); object-fit: cover; flex-shrink: 0; background: var(--bg-raised); }
|
|
||||||
.info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; min-width: 0; }
|
|
||||||
.name { font-size: var(--text-base); font-weight: var(--weight-medium); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
||||||
.meta { display: flex; align-items: center; gap: var(--sp-2); font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
|
||||||
.lang-tag { background: var(--bg-overlay); border: 1px solid var(--border-dim); border-radius: var(--radius-sm); padding: 1px 5px; font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-muted); letter-spacing: var(--tracking-wider); }
|
|
||||||
.update-badge-small { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--accent-fg); flex-shrink: 0; }
|
|
||||||
.row-actions { display: flex; gap: var(--sp-1); flex-shrink: 0; }
|
|
||||||
.action-btn { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 4px 10px; border-radius: var(--radius-md); background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim); cursor: pointer; flex-shrink: 0; transition: filter var(--t-base); }
|
|
||||||
.action-btn:hover { filter: brightness(1.1); }
|
|
||||||
.action-btn-dim { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 4px 10px; border-radius: var(--radius-md); background: none; color: var(--text-faint); border: 1px solid var(--border-dim); cursor: pointer; flex-shrink: 0; transition: color var(--t-base), border-color var(--t-base); }
|
|
||||||
.action-btn-dim:hover { color: var(--color-error); border-color: var(--color-error); }
|
|
||||||
.expand-btn { display: flex; align-items: center; gap: 3px; padding: 4px 6px; border-radius: var(--radius-sm); color: var(--text-faint); flex-shrink: 0; transition: color var(--t-base), background var(--t-base); }
|
|
||||||
.expand-btn:hover { color: var(--text-muted); background: var(--bg-overlay); }
|
|
||||||
.expand-count { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); }
|
|
||||||
.variants { display: flex; flex-direction: column; gap: 1px; margin: 1px 0 2px calc(32px + var(--sp-3) + var(--sp-3)); padding-left: var(--sp-3); border-left: 1px solid var(--border-dim); animation: fadeIn 0.1s ease both; }
|
|
||||||
.variant-row { display: flex; align-items: center; gap: var(--sp-2); padding: 5px var(--sp-2); border-radius: var(--radius-md); transition: background var(--t-fast); }
|
|
||||||
.variant-row:hover { background: var(--bg-raised); }
|
|
||||||
.variant-name { flex: 1; font-size: var(--text-sm); color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
||||||
.variant-version { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); flex-shrink: 0; }
|
|
||||||
.variant-actions { flex-shrink: 0; }
|
|
||||||
.empty { display: flex; align-items: center; justify-content: center; flex: 1; color: var(--text-faint); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); }
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<script module>
|
|
||||||
function focusOnMount(node: HTMLElement) { node.focus(); }
|
|
||||||
</script>
|
|
||||||
@@ -1,626 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { onMount, untrack } from "svelte";
|
|
||||||
import { Play, ArrowRight, ArrowLeft, BookOpen, Clock, Fire, TrendUp, CalendarBlank, CheckCircle, PushPin, X as XIcon, MagnifyingGlass, ListBullets } from "phosphor-svelte";
|
|
||||||
import { gql, thumbUrl } from "../../lib/client";
|
|
||||||
import { GET_LIBRARY, GET_CHAPTERS, GET_MANGA } from "../../lib/queries";
|
|
||||||
import { cache, CACHE_KEYS } from "../../lib/cache";
|
|
||||||
import { store, openReader, COMPLETED_FOLDER_ID, setHeroSlot, setActiveManga, setPreviewManga, setNavPage, setGenreFilter } from "../../store/state.svelte";
|
|
||||||
import type { HistoryEntry } from "../../store/state.svelte";
|
|
||||||
import type { Manga, Chapter } from "../../lib/types";
|
|
||||||
|
|
||||||
function timeAgo(ts: number): string {
|
|
||||||
const diff = Date.now() - ts, m = Math.floor(diff / 60000);
|
|
||||||
if (m < 1) return "Just now";
|
|
||||||
if (m < 60) return `${m}m ago`;
|
|
||||||
const h = Math.floor(m / 60);
|
|
||||||
if (h < 24) return `${h}h ago`;
|
|
||||||
const d = Math.floor(h / 24);
|
|
||||||
if (d < 7) return `${d}d ago`;
|
|
||||||
return new Date(ts).toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatReadTime(mins: number): string {
|
|
||||||
if (mins < 1) return `${Math.round(mins * 60)}s`;
|
|
||||||
if (mins < 60) return `${Math.round(mins)}m`;
|
|
||||||
const h = Math.floor(mins / 60), r = Math.round(mins % 60);
|
|
||||||
if (h < 24) return r === 0 ? `${h}h` : `${h}h ${r}m`;
|
|
||||||
const d = Math.floor(h / 24), rh = h % 24;
|
|
||||||
return rh === 0 ? `${d}d` : `${d}d ${rh}h`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function focusEl(node: HTMLElement) { node.focus(); }
|
|
||||||
|
|
||||||
let libraryManga: Manga[] = $state([]);
|
|
||||||
let extraManga: Manga[] = $state([]);
|
|
||||||
let loadingLibrary: boolean = $state(true);
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
loadLibrary();
|
|
||||||
});
|
|
||||||
|
|
||||||
function loadLibrary() {
|
|
||||||
cache.get(CACHE_KEYS.LIBRARY, () =>
|
|
||||||
gql<{ mangas: { nodes: Manga[] } }>(GET_LIBRARY).then(d => d.mangas.nodes)
|
|
||||||
).then(m => { libraryManga = m; fetchExtraCompleted(m); })
|
|
||||||
.catch(console.error)
|
|
||||||
.finally(() => loadingLibrary = false);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Re-fetch library and reset hero chapters whenever the reader closes,
|
|
||||||
// so the hero reflects the latest-read chapter immediately.
|
|
||||||
$effect(() => {
|
|
||||||
const sessionId = store.readerSessionId;
|
|
||||||
if (sessionId === 0) return; // skip initial mount — onMount handles that
|
|
||||||
cache.clear(CACHE_KEYS.LIBRARY);
|
|
||||||
loadingLibrary = true;
|
|
||||||
heroChapters = [];
|
|
||||||
heroAllChapters = [];
|
|
||||||
heroChaptersFor = null;
|
|
||||||
loadLibrary();
|
|
||||||
});
|
|
||||||
|
|
||||||
async function fetchExtraCompleted(library: Manga[]) {
|
|
||||||
const completedIds = store.settings.folders.find(f => f.id === COMPLETED_FOLDER_ID)?.mangaIds ?? [];
|
|
||||||
const missingIds = completedIds.filter(id => !library.some(m => m.id === id));
|
|
||||||
if (!missingIds.length) return;
|
|
||||||
const results = await Promise.allSettled(missingIds.map(id => gql<{ manga: Manga }>(GET_MANGA, { id }).then(d => d.manga)));
|
|
||||||
const valid = results.flatMap(r => r.status === "fulfilled" && r.value ? [r.value] : []);
|
|
||||||
if (valid.length) extraManga = valid;
|
|
||||||
}
|
|
||||||
|
|
||||||
const continueReading = $derived((() => {
|
|
||||||
const seen = new Set<number>();
|
|
||||||
const out: HistoryEntry[] = [];
|
|
||||||
for (const e of store.history) {
|
|
||||||
if (seen.has(e.mangaId)) continue;
|
|
||||||
seen.add(e.mangaId);
|
|
||||||
out.push(e);
|
|
||||||
if (out.length >= 10) break;
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
})());
|
|
||||||
|
|
||||||
const TOTAL_SLOTS = 4;
|
|
||||||
interface HeroSlot { kind: "continue" | "pinned" | "empty"; entry?: HistoryEntry; manga?: Manga; slotIndex: number; }
|
|
||||||
|
|
||||||
const resolvedSlots = $derived((() => {
|
|
||||||
const pins = store.settings.heroSlots ?? [null, null, null, null];
|
|
||||||
const slots: HeroSlot[] = [];
|
|
||||||
const first = continueReading[0];
|
|
||||||
slots.push(first ? { kind: "continue", entry: first, slotIndex: 0 } : { kind: "empty", slotIndex: 0 });
|
|
||||||
let hi = 1;
|
|
||||||
for (let i = 1; i < TOTAL_SLOTS; i++) {
|
|
||||||
const pinId = pins[i];
|
|
||||||
if (pinId != null) {
|
|
||||||
const manga = libraryManga.find(m => m.id === pinId);
|
|
||||||
if (manga) { slots.push({ kind: "pinned", manga, slotIndex: i }); continue; }
|
|
||||||
}
|
|
||||||
const entry = continueReading[hi++];
|
|
||||||
slots.push(entry ? { kind: "continue", entry, slotIndex: i } : { kind: "empty", slotIndex: i });
|
|
||||||
}
|
|
||||||
return slots;
|
|
||||||
})());
|
|
||||||
|
|
||||||
let activeIdx = $state(0);
|
|
||||||
const activeSlot = $derived(resolvedSlots[activeIdx]);
|
|
||||||
const heroThumb = $derived(activeSlot?.kind === "pinned" ? thumbUrl(activeSlot.manga?.thumbnailUrl ?? "") : activeSlot?.kind === "continue" ? thumbUrl(activeSlot.entry?.thumbnailUrl ?? "") : "");
|
|
||||||
const heroTitle = $derived(activeSlot?.kind === "pinned" ? (activeSlot.manga?.title ?? "") : activeSlot?.kind === "continue" ? (activeSlot.entry?.mangaTitle ?? "") : "");
|
|
||||||
const heroManga = $derived(activeSlot?.kind === "pinned" ? activeSlot.manga : activeSlot?.kind === "continue" ? libraryManga.find(m => m.id === activeSlot.entry?.mangaId) : null);
|
|
||||||
const heroEntry = $derived(activeSlot?.kind === "continue" ? activeSlot.entry : null);
|
|
||||||
const heroMangaId = $derived(heroEntry?.mangaId ?? heroManga?.id ?? null);
|
|
||||||
|
|
||||||
function cycleNext() { activeIdx = (activeIdx + 1) % TOTAL_SLOTS; heroChapters = []; heroAllChapters = []; }
|
|
||||||
function cyclePrev() { activeIdx = (activeIdx - 1 + TOTAL_SLOTS) % TOTAL_SLOTS; heroChapters = []; heroAllChapters = []; }
|
|
||||||
function goToSlot(i: number) { if (i !== activeIdx) { activeIdx = i; heroChapters = []; heroAllChapters = []; } }
|
|
||||||
|
|
||||||
function onKey(e: KeyboardEvent) {
|
|
||||||
if (e.target !== document.body && !(e.target instanceof HTMLElement && e.target.closest(".hero-stage"))) return;
|
|
||||||
if (e.key === "ArrowRight") cycleNext();
|
|
||||||
if (e.key === "ArrowLeft") cyclePrev();
|
|
||||||
}
|
|
||||||
onMount(() => {
|
|
||||||
window.addEventListener("keydown", onKey);
|
|
||||||
return () => window.removeEventListener("keydown", onKey);
|
|
||||||
});
|
|
||||||
|
|
||||||
let heroStageH = $state(300);
|
|
||||||
let heroChapters: Chapter[] = $state([]);
|
|
||||||
let heroAllChapters: Chapter[] = $state([]);
|
|
||||||
let loadingHeroChapters = $state(false);
|
|
||||||
let heroChaptersFor: number | null = null;
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
const id = heroMangaId;
|
|
||||||
if (id && id !== heroChaptersFor) untrack(() => loadHeroChapters(id));
|
|
||||||
});
|
|
||||||
|
|
||||||
async function loadHeroChapters(mangaId: number) {
|
|
||||||
heroChaptersFor = mangaId;
|
|
||||||
loadingHeroChapters = true;
|
|
||||||
heroChapters = [];
|
|
||||||
heroAllChapters = [];
|
|
||||||
try {
|
|
||||||
const d = await gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId });
|
|
||||||
if (heroChaptersFor !== mangaId) return;
|
|
||||||
const all = [...d.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder);
|
|
||||||
heroAllChapters = all;
|
|
||||||
const lastReadIdx = heroEntry ? all.findIndex(c => c.id === heroEntry!.chapterId) : all.findLastIndex(c => c.isRead);
|
|
||||||
const startIdx = Math.max(0, lastReadIdx);
|
|
||||||
heroChapters = all.slice(startIdx, startIdx + 5);
|
|
||||||
} catch { heroChapters = []; heroAllChapters = []; }
|
|
||||||
finally { loadingHeroChapters = false; }
|
|
||||||
}
|
|
||||||
|
|
||||||
let resuming = $state(false);
|
|
||||||
|
|
||||||
async function openChapter(chapter: Chapter) {
|
|
||||||
if (!heroMangaId) return;
|
|
||||||
resuming = true;
|
|
||||||
try {
|
|
||||||
let all = heroAllChapters;
|
|
||||||
if (!all.length) {
|
|
||||||
const d = await gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId: heroMangaId });
|
|
||||||
all = [...d.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder);
|
|
||||||
}
|
|
||||||
openReader(chapter, all);
|
|
||||||
} catch { store.activeManga = { id: heroMangaId, title: heroTitle, thumbnailUrl: heroManga?.thumbnailUrl ?? "" } as any; }
|
|
||||||
finally { resuming = false; }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function resumeActive() {
|
|
||||||
if (!heroEntry && heroManga) { store.activeManga = heroManga; return; }
|
|
||||||
if (!heroEntry) return;
|
|
||||||
const target = heroAllChapters.find(c => c.id === heroEntry!.chapterId) ?? heroAllChapters[0];
|
|
||||||
if (target && heroAllChapters.length) { await openChapter(target); return; }
|
|
||||||
resuming = true;
|
|
||||||
try {
|
|
||||||
const d = await gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId: heroEntry.mangaId });
|
|
||||||
const chapters = [...d.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder);
|
|
||||||
const ch = chapters.find(c => c.id === heroEntry!.chapterId) ?? chapters[0];
|
|
||||||
if (ch) openReader(ch, chapters);
|
|
||||||
else store.activeManga = { id: heroEntry.mangaId, title: heroEntry.mangaTitle, thumbnailUrl: heroEntry.thumbnailUrl } as any;
|
|
||||||
} catch { store.activeManga = { id: heroEntry.mangaId, title: heroEntry.mangaTitle, thumbnailUrl: heroEntry.thumbnailUrl } as any; }
|
|
||||||
finally { resuming = false; }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function resumeEntry(entry: HistoryEntry) {
|
|
||||||
try {
|
|
||||||
const d = await gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId: entry.mangaId });
|
|
||||||
const chapters = [...d.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder);
|
|
||||||
const ch = chapters.find(c => c.id === entry.chapterId) ?? chapters[0];
|
|
||||||
if (ch) openReader(ch, chapters);
|
|
||||||
else store.activeManga = { id: entry.mangaId, title: entry.mangaTitle, thumbnailUrl: entry.thumbnailUrl } as any;
|
|
||||||
} catch { store.activeManga = { id: entry.mangaId, title: entry.mangaTitle, thumbnailUrl: entry.thumbnailUrl } as any; }
|
|
||||||
}
|
|
||||||
|
|
||||||
let pickerOpen = $state(false);
|
|
||||||
let pickerSlotIndex: 1|2|3|null = $state(null);
|
|
||||||
let pickerSearch = $state("");
|
|
||||||
|
|
||||||
const pickerResults = $derived(pickerSearch.trim()
|
|
||||||
? libraryManga.filter(m => m.title.toLowerCase().includes(pickerSearch.toLowerCase())).slice(0, 20)
|
|
||||||
: libraryManga.slice(0, 20));
|
|
||||||
|
|
||||||
function openPicker(i: 1|2|3) { pickerSlotIndex = i; pickerOpen = true; pickerSearch = ""; }
|
|
||||||
function closePicker() { pickerOpen = false; pickerSlotIndex = null; }
|
|
||||||
function pinManga(m: Manga) { if (pickerSlotIndex !== null) { setHeroSlot(pickerSlotIndex, m.id); closePicker(); } }
|
|
||||||
function unpinSlot(i: 1|2|3) { setHeroSlot(i, null); }
|
|
||||||
|
|
||||||
const completedIds = $derived(store.settings.folders.find(f => f.id === COMPLETED_FOLDER_ID)?.mangaIds ?? []);
|
|
||||||
const completedPool = $derived([...libraryManga, ...extraManga.filter(m => !libraryManga.some(l => l.id === m.id))]);
|
|
||||||
const completedManga = $derived(completedIds.length > 0 ? completedPool.filter(m => completedIds.includes(m.id)).slice(0, 20) : []);
|
|
||||||
const recentHistory = $derived(store.history.slice(0, 6));
|
|
||||||
const stats = $derived(store.readingStats);
|
|
||||||
|
|
||||||
function handleRowWheel(e: WheelEvent) {
|
|
||||||
if (Math.abs(e.deltaY) <= Math.abs(e.deltaX)) return;
|
|
||||||
(e.currentTarget as HTMLElement).scrollLeft += e.deltaY;
|
|
||||||
e.stopPropagation();
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="root">
|
|
||||||
<div class="body">
|
|
||||||
|
|
||||||
<div class="hero-section">
|
|
||||||
<div class="hero-stage" bind:clientHeight={heroStageH} style="--hero-h:{heroStageH}px">
|
|
||||||
|
|
||||||
{#if heroThumb}
|
|
||||||
<div class="hero-backdrop" style="background-image:url({heroThumb})"></div>
|
|
||||||
{:else}
|
|
||||||
<div class="hero-backdrop hero-bd-empty"></div>
|
|
||||||
{/if}
|
|
||||||
<div class="hero-scrim"></div>
|
|
||||||
|
|
||||||
<button class="hero-cover-col" onclick={resumeActive} disabled={resuming || activeSlot?.kind === "empty"} aria-label={heroTitle ? `Resume ${heroTitle}` : "No manga selected"}>
|
|
||||||
{#if heroThumb}
|
|
||||||
<img src={heroThumb} alt={heroTitle} class="hero-cover" loading="eager" decoding="async" />
|
|
||||||
{#if activeSlot?.kind === "continue"}
|
|
||||||
<div class="cover-resume-hint"><Play size={18} weight="fill" /></div>
|
|
||||||
{/if}
|
|
||||||
{:else}
|
|
||||||
<div class="hero-cover-empty"><BookOpen size={28} weight="light" /></div>
|
|
||||||
{/if}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div class="hero-details">
|
|
||||||
{#if activeSlot?.kind === "empty"}
|
|
||||||
<p class="hero-empty-title">Nothing here yet</p>
|
|
||||||
<p class="hero-empty-sub">{activeSlot.slotIndex === 0 ? "Read a manga to see it here" : "Pin a manga or keep reading to fill this slot"}</p>
|
|
||||||
{#if activeSlot.slotIndex !== 0}
|
|
||||||
<button class="hero-cta" onclick={() => openPicker(activeSlot.slotIndex as 1|2|3)}>
|
|
||||||
<PushPin size={11} weight="fill" /> Pin manga
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
{:else}
|
|
||||||
<div class="hero-tags">
|
|
||||||
{#if activeSlot?.kind === "continue"}
|
|
||||||
<span class="hero-tag hero-tag-reading"><Play size={8} weight="fill" /> Reading</span>
|
|
||||||
{:else}
|
|
||||||
<span class="hero-tag hero-tag-pinned"><PushPin size={8} weight="fill" /> Pinned</span>
|
|
||||||
{/if}
|
|
||||||
{#each (heroManga?.genre ?? []).slice(0, 3) as g}
|
|
||||||
<button class="hero-tag hero-tag-genre" onclick={() => { setGenreFilter(g); setNavPage("explore"); }}>{g}</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h2 class="hero-title">{heroTitle}</h2>
|
|
||||||
{#if heroManga?.author}<p class="hero-author">{heroManga.author}</p>{/if}
|
|
||||||
|
|
||||||
{#if heroEntry}
|
|
||||||
<p class="hero-progress">
|
|
||||||
<Clock size={10} weight="light" />
|
|
||||||
{heroEntry.chapterName}
|
|
||||||
{#if heroEntry.pageNumber > 1}<span class="hero-prog-page"> · p.{heroEntry.pageNumber}</span>{/if}
|
|
||||||
<span class="hero-prog-time">{timeAgo(heroEntry.readAt)}</span>
|
|
||||||
</p>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if heroManga?.description}<p class="hero-desc">{heroManga.description}</p>{/if}
|
|
||||||
|
|
||||||
<div class="hero-actions">
|
|
||||||
{#if activeSlot?.kind === "continue"}
|
|
||||||
<button class="hero-cta" onclick={resumeActive} disabled={resuming}>
|
|
||||||
<Play size={11} weight="fill" />{resuming ? "Loading…" : "Resume"}
|
|
||||||
</button>
|
|
||||||
{:else if heroManga}
|
|
||||||
<button class="hero-cta" onclick={() => store.previewManga = heroManga!}>
|
|
||||||
<BookOpen size={11} weight="light" /> View manga
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
{#if activeSlot?.slotIndex !== 0}
|
|
||||||
{#if activeSlot?.kind === "pinned"}
|
|
||||||
<button class="hero-cta-ghost" onclick={() => unpinSlot(activeSlot.slotIndex as 1|2|3)}>
|
|
||||||
<XIcon size={10} weight="bold" /> Unpin
|
|
||||||
</button>
|
|
||||||
{:else}
|
|
||||||
<button class="hero-cta-ghost" onclick={() => openPicker(activeSlot!.slotIndex as 1|2|3)}>
|
|
||||||
<PushPin size={10} weight="light" /> Pin
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="hero-nav-row">
|
|
||||||
<button class="hero-nav-btn" onclick={cyclePrev} aria-label="Previous"><ArrowLeft size={12} weight="bold" /></button>
|
|
||||||
<div class="hero-dots">
|
|
||||||
{#each resolvedSlots as slot, i}
|
|
||||||
<button class="hero-dot" class:active={activeIdx === i} class:pinned={slot.kind === "pinned"} onclick={() => goToSlot(i)} aria-label="Slot {i + 1}"></button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
<button class="hero-nav-btn" onclick={cycleNext} aria-label="Next"><ArrowRight size={12} weight="bold" /></button>
|
|
||||||
<span class="hero-counter">{activeIdx + 1}/{TOTAL_SLOTS}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="hero-chapters">
|
|
||||||
<div class="hero-chapters-header"><ListBullets size={11} weight="bold" /><span>Up Next</span></div>
|
|
||||||
|
|
||||||
{#if activeSlot?.kind === "empty"}
|
|
||||||
<p class="hero-chapters-empty">No chapters to show</p>
|
|
||||||
{:else if loadingHeroChapters}
|
|
||||||
{#each Array(4) as _}
|
|
||||||
<div class="chapter-row-sk">
|
|
||||||
<div class="sk sk-num"></div>
|
|
||||||
<div class="sk-info"><div class="sk sk-name"></div><div class="sk sk-meta"></div></div>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
{:else if heroChapters.length === 0}
|
|
||||||
<p class="hero-chapters-empty">No chapters available</p>
|
|
||||||
{:else}
|
|
||||||
{#each heroChapters as ch (ch.id)}
|
|
||||||
{@const isCurrent = heroEntry?.chapterId === ch.id}
|
|
||||||
<button class="chapter-row" class:chapter-row-current={isCurrent} class:chapter-row-read={ch.isRead && !isCurrent} onclick={() => openChapter(ch)}>
|
|
||||||
<span class="ch-num">Ch.{ch.chapterNumber % 1 === 0 ? Math.floor(ch.chapterNumber) : ch.chapterNumber}</span>
|
|
||||||
<div class="ch-info">
|
|
||||||
<span class="ch-name">{ch.name}</span>
|
|
||||||
{#if isCurrent && heroEntry && heroEntry.pageNumber > 1}
|
|
||||||
<span class="ch-meta">p.{heroEntry.pageNumber} · in progress</span>
|
|
||||||
{:else if ch.isRead}
|
|
||||||
<span class="ch-meta ch-read">Read</span>
|
|
||||||
{:else if ch.uploadDate}
|
|
||||||
<span class="ch-meta">{new Date(Number(ch.uploadDate) > 1e10 ? Number(ch.uploadDate) : Number(ch.uploadDate)*1000).toLocaleDateString("en-US",{month:"short",day:"numeric"})}</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{#if isCurrent}<Play size={10} weight="fill" class="ch-play-icon" />{/if}
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
{#if heroManga}
|
|
||||||
<button class="ch-view-all" onclick={() => { if (heroManga) store.activeManga = heroManga; }}>
|
|
||||||
All chapters <ArrowRight size={9} weight="bold" />
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="section">
|
|
||||||
<div class="section-header">
|
|
||||||
<span class="section-title"><Clock size={10} weight="bold" /> Recent Activity</span>
|
|
||||||
{#if recentHistory.length > 0}
|
|
||||||
<button class="see-all" onclick={() => setNavPage("history")}>Full History <ArrowRight size={9} weight="bold" /></button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<div class="activity-list">
|
|
||||||
{#if recentHistory.length > 0}
|
|
||||||
{#each recentHistory as entry (entry.chapterId)}
|
|
||||||
<button class="activity-row" onclick={() => resumeEntry(entry)}>
|
|
||||||
<img src={thumbUrl(entry.thumbnailUrl)} alt={entry.mangaTitle} class="activity-thumb" loading="lazy" decoding="async" />
|
|
||||||
<div class="activity-info">
|
|
||||||
<span class="activity-title">{entry.mangaTitle}</span>
|
|
||||||
<span class="activity-sub">{entry.chapterName}{entry.pageNumber > 1 ? ` · p.${entry.pageNumber}` : ""}</span>
|
|
||||||
</div>
|
|
||||||
<span class="activity-time">{timeAgo(entry.readAt)}</span>
|
|
||||||
<span class="activity-play"><Play size={10} weight="fill" /></span>
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
{:else}
|
|
||||||
<div class="activity-placeholder">
|
|
||||||
{#each Array(5) as _, i}
|
|
||||||
<div class="activity-row activity-row-sk">
|
|
||||||
<div class="sk-thumb"></div>
|
|
||||||
<div class="activity-info">
|
|
||||||
<div class="sk sk-title" style="width: {55 + (i * 7) % 30}%"></div>
|
|
||||||
<div class="sk sk-sub" style="width: {30 + (i * 11) % 25}%"></div>
|
|
||||||
</div>
|
|
||||||
<div class="sk sk-time"></div>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
<div class="activity-placeholder-overlay">
|
|
||||||
<button class="activity-placeholder-cta" onclick={() => setNavPage("library")}>
|
|
||||||
<BookOpen size={12} weight="light" /> Start reading
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="bottom-row">
|
|
||||||
<div class="bottom-col">
|
|
||||||
<div class="bottom-section-hd">
|
|
||||||
<span class="section-title"><CheckCircle size={10} weight="bold" /> Completed</span>
|
|
||||||
{#if completedManga.length > 0}
|
|
||||||
<button class="see-all" onclick={() => store.navPage = "library"}>View all <ArrowRight size={9} weight="bold" /></button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{#if completedManga.length > 0}
|
|
||||||
<div class="mini-row" onwheel={(e) => { e.preventDefault(); handleRowWheel(e); }}>
|
|
||||||
{#each completedManga as m (m.id)}
|
|
||||||
<button class="mini-card" onclick={() => store.previewManga = m}>
|
|
||||||
<div class="mini-cover-wrap">
|
|
||||||
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} class="mini-cover" loading="lazy" decoding="async" />
|
|
||||||
<div class="mini-gradient"></div>
|
|
||||||
<div class="mini-footer">
|
|
||||||
<p class="mini-card-title">{m.title}</p>
|
|
||||||
{#if m.source?.displayName}<p class="mini-card-source">{m.source.displayName}</p>{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<p class="bottom-empty">Finish a manga to see it here</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="bottom-divider"></div>
|
|
||||||
|
|
||||||
<div class="bottom-col">
|
|
||||||
<div class="bottom-section-hd">
|
|
||||||
<span class="section-title"><TrendUp size={10} weight="bold" /> Your Stats</span>
|
|
||||||
</div>
|
|
||||||
<div class="stats-grid">
|
|
||||||
<div class="stat-card"><div class="stat-icon-wrap stat-fire"><Fire size={16} weight="fill" /></div><div class="stat-body"><span class="stat-val">{stats.currentStreakDays}</span><span class="stat-label">Day streak</span></div></div>
|
|
||||||
<div class="stat-card"><div class="stat-icon-wrap stat-accent"><BookOpen size={16} weight="light" /></div><div class="stat-body"><span class="stat-val">{stats.totalChaptersRead}</span><span class="stat-label">Chapters read</span></div></div>
|
|
||||||
<div class="stat-card"><div class="stat-icon-wrap stat-neutral"><Clock size={16} weight="light" /></div><div class="stat-body"><span class="stat-val">{formatReadTime(stats.totalMinutesRead)}</span><span class="stat-label">Read time</span></div></div>
|
|
||||||
<div class="stat-card"><div class="stat-icon-wrap stat-neutral"><TrendUp size={16} weight="light" /></div><div class="stat-body"><span class="stat-val">{stats.totalMangaRead}</span><span class="stat-label">Series started</span></div></div>
|
|
||||||
<div class="stat-card"><div class="stat-icon-wrap stat-green"><CheckCircle size={16} weight="light" /></div><div class="stat-body"><span class="stat-val">{completedIds.length}</span><span class="stat-label">Completed</span></div></div>
|
|
||||||
<div class="stat-card"><div class="stat-icon-wrap stat-neutral"><CalendarBlank size={16} weight="light" /></div><div class="stat-body"><span class="stat-val">{stats.longestStreakDays}d</span><span class="stat-label">Best streak</span></div></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if pickerOpen}
|
|
||||||
<div class="picker-backdrop" role="presentation"
|
|
||||||
onclick={(e) => { if (e.target === e.currentTarget) closePicker(); }}
|
|
||||||
onkeydown={(e) => { if (e.key === "Escape") closePicker(); }}>
|
|
||||||
<div class="picker-modal">
|
|
||||||
<div class="picker-header">
|
|
||||||
<span class="picker-title">Pin manga — slot {(pickerSlotIndex ?? 0) + 1}</span>
|
|
||||||
<button class="picker-close" onclick={closePicker}><XIcon size={13} weight="light" /></button>
|
|
||||||
</div>
|
|
||||||
<div class="picker-search-wrap">
|
|
||||||
<MagnifyingGlass size={12} weight="light" style="color:var(--text-faint);flex-shrink:0" />
|
|
||||||
<input class="picker-search" placeholder="Search library…" bind:value={pickerSearch} use:focusEl />
|
|
||||||
</div>
|
|
||||||
<div class="picker-list">
|
|
||||||
{#if loadingLibrary}
|
|
||||||
<p class="picker-empty">Loading…</p>
|
|
||||||
{:else if pickerResults.length === 0}
|
|
||||||
<p class="picker-empty">No results</p>
|
|
||||||
{:else}
|
|
||||||
{#each pickerResults as m (m.id)}
|
|
||||||
<button class="picker-row" onclick={() => pinManga(m)}>
|
|
||||||
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} class="picker-thumb" loading="lazy" />
|
|
||||||
<div class="picker-info">
|
|
||||||
<span class="picker-manga-title">{m.title}</span>
|
|
||||||
{#if m.source?.displayName}<span class="picker-source">{m.source.displayName}</span>{/if}
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.root { display: flex; flex-direction: column; height: 100%; overflow: hidden; animation: fadeIn 0.14s ease both; }
|
|
||||||
.body { flex: 1; display: flex; flex-direction: column; overflow-y: auto; overflow-x: hidden; min-height: 0; }
|
|
||||||
.hero-section { padding: var(--sp-3) var(--sp-4) var(--sp-2); flex-shrink: 0; display: flex; flex-direction: column; }
|
|
||||||
.hero-stage { position: relative; display: flex; align-items: stretch; height: 374px; border-radius: var(--radius-xl); overflow: hidden; background: var(--bg-raised); border: 1px solid var(--border-dim); box-shadow: 0 6px 28px rgba(0,0,0,0.28); }
|
|
||||||
.hero-backdrop { position: absolute; inset: -14px; background-size: cover; background-position: center 25%; filter: blur(20px) saturate(2.2) brightness(0.45); transform: scale(1.07); pointer-events: none; z-index: 0; }
|
|
||||||
.hero-bd-empty { background: var(--bg-void); filter: none; }
|
|
||||||
.hero-scrim { position: absolute; inset: 0; z-index: 1; pointer-events: none; background: linear-gradient(100deg, rgba(0,0,0,0.0) 0%, rgba(0,0,0,0.55) 100%); }
|
|
||||||
.hero-cover-col { position: relative; z-index: 2; flex-shrink: 0; width: 263px; height: 374px; overflow: hidden; cursor: pointer; border-right: 1px solid rgba(255,255,255,0.08); background: var(--bg-raised); }
|
|
||||||
.hero-cover-col:hover .hero-cover { filter: brightness(1.08); }
|
|
||||||
.hero-cover-col:hover .cover-resume-hint { opacity: 1; }
|
|
||||||
.hero-cover { width: 100%; height: 100%; object-fit: cover; display: block; transition: filter 0.2s ease; }
|
|
||||||
.hero-cover-empty { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; background: var(--bg-overlay); color: var(--text-faint); }
|
|
||||||
.cover-resume-hint { position: absolute; inset: var(--sp-3); display: flex; align-items: center; justify-content: center; color: #fff; font-size: 36px; background: rgba(0,0,0,0.4); border-radius: var(--radius-lg); opacity: 0; transition: opacity 0.18s ease; pointer-events: none; }
|
|
||||||
.hero-details { position: relative; z-index: 2; flex: 1; min-width: 0; padding: var(--sp-4) var(--sp-5) var(--sp-3); display: flex; flex-direction: column; gap: var(--sp-2); overflow: hidden; border-right: 1px solid rgba(255,255,255,0.06); }
|
|
||||||
.hero-tags { display: flex; flex-wrap: wrap; gap: 5px; flex-shrink: 0; }
|
|
||||||
.hero-tag { font-family: var(--font-ui); font-size: 9px; letter-spacing: var(--tracking-wide); text-transform: uppercase; padding: 2px 7px; border-radius: var(--radius-full); background: rgba(255,255,255,0.1); color: rgba(255,255,255,0.62); border: 1px solid rgba(255,255,255,0.14); }
|
|
||||||
.hero-tag-reading { background: var(--accent-muted); color: var(--accent-fg); border-color: var(--accent-dim); }
|
|
||||||
.hero-tag-pinned { background: rgba(168,132,232,0.18); color: #c4a8f0; border-color: rgba(168,132,232,0.28); }
|
|
||||||
.hero-tag-genre { cursor: pointer; transition: background 0.15s ease, color 0.15s ease; }
|
|
||||||
.hero-tag-genre:hover { background: rgba(255,255,255,0.18); color: rgba(255,255,255,0.9); }
|
|
||||||
.hero-title { font-size: var(--text-xl); font-weight: var(--weight-medium); color: #fff; line-height: var(--leading-tight); margin: 0; flex-shrink: 0; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; text-shadow: 0 2px 10px rgba(0,0,0,0.5); }
|
|
||||||
.hero-author { font-family: var(--font-ui); font-size: var(--text-xs); color: rgba(255,255,255,0.48); letter-spacing: var(--tracking-wide); flex-shrink: 0; }
|
|
||||||
.hero-progress { display: flex; align-items: center; gap: 5px; flex-shrink: 0; font-family: var(--font-ui); font-size: var(--text-xs); color: rgba(255,255,255,0.58); letter-spacing: var(--tracking-wide); }
|
|
||||||
.hero-prog-page { color: rgba(255,255,255,0.38); }
|
|
||||||
.hero-prog-time { margin-left: auto; color: rgba(255,255,255,0.32); }
|
|
||||||
.hero-desc { font-size: var(--text-xs); color: rgba(255,255,255,0.42); line-height: 1.55; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; flex-shrink: 0; }
|
|
||||||
.hero-empty-title { font-size: var(--text-base); font-weight: var(--weight-medium); color: rgba(255,255,255,0.5); flex-shrink: 0; }
|
|
||||||
.hero-empty-sub { font-family: var(--font-ui); font-size: var(--text-xs); color: rgba(255,255,255,0.28); letter-spacing: var(--tracking-wide); line-height: var(--leading-snug); }
|
|
||||||
.hero-actions { display: flex; gap: var(--sp-2); flex-shrink: 0; flex-wrap: wrap; }
|
|
||||||
.hero-cta { display: inline-flex; align-items: center; gap: 6px; font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 7px 16px; border-radius: var(--radius-full); background: var(--accent-muted); border: 1px solid var(--accent-dim); color: var(--accent-fg); cursor: pointer; transition: filter var(--t-base); white-space: nowrap; }
|
|
||||||
.hero-cta:hover:not(:disabled) { filter: brightness(1.15); }
|
|
||||||
.hero-cta:disabled { opacity: 0.55; cursor: default; }
|
|
||||||
.hero-cta-ghost { display: inline-flex; align-items: center; gap: 6px; font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 7px 14px; border-radius: var(--radius-full); background: rgba(255,255,255,0.07); border: 1px solid rgba(255,255,255,0.13); color: rgba(255,255,255,0.52); cursor: pointer; transition: background var(--t-base), color var(--t-base); white-space: nowrap; }
|
|
||||||
.hero-cta-ghost:hover { background: rgba(255,255,255,0.13); color: rgba(255,255,255,0.82); }
|
|
||||||
.hero-nav-row { display: flex; align-items: center; gap: var(--sp-2); flex-shrink: 0; margin-top: auto; padding-top: var(--sp-2); border-top: 1px solid rgba(255,255,255,0.08); }
|
|
||||||
.hero-nav-btn { display: flex; align-items: center; justify-content: center; width: 22px; height: 22px; border-radius: 50%; background: rgba(255,255,255,0.1); border: 1px solid rgba(255,255,255,0.12); color: rgba(255,255,255,0.6); cursor: pointer; flex-shrink: 0; transition: background var(--t-base), color var(--t-base); }
|
|
||||||
.hero-nav-btn:hover { background: rgba(255,255,255,0.2); color: #fff; }
|
|
||||||
.hero-dots { display: flex; gap: 5px; align-items: center; }
|
|
||||||
.hero-dot { width: 5px; height: 5px; border-radius: 50%; background: rgba(255,255,255,0.22); border: none; cursor: pointer; padding: 0; transition: background var(--t-base), transform var(--t-base); }
|
|
||||||
.hero-dot:hover { background: rgba(255,255,255,0.5); }
|
|
||||||
.hero-dot.active { background: #fff; transform: scale(1.35); }
|
|
||||||
.hero-dot.pinned { background: rgba(168,132,232,0.55); }
|
|
||||||
.hero-dot.pinned.active { background: #c4a8f0; }
|
|
||||||
.hero-counter { font-family: var(--font-ui); font-size: 10px; color: rgba(255,255,255,0.3); letter-spacing: var(--tracking-wide); margin-left: auto; }
|
|
||||||
.hero-chapters { position: relative; z-index: 2; width: clamp(180px, 32%, 240px); flex-shrink: 0; display: flex; flex-direction: column; padding: var(--sp-4) var(--sp-3); gap: 1px; overflow: hidden; }
|
|
||||||
.hero-chapters-header { display: flex; align-items: center; gap: var(--sp-2); font-family: var(--font-ui); font-size: var(--text-xs); color: rgba(255,255,255,0.4); letter-spacing: var(--tracking-wider); text-transform: uppercase; padding-bottom: var(--sp-2); margin-bottom: var(--sp-1); border-bottom: 1px solid rgba(255,255,255,0.08); flex-shrink: 0; }
|
|
||||||
.hero-chapters-empty { font-family: var(--font-ui); font-size: var(--text-xs); color: rgba(255,255,255,0.25); letter-spacing: var(--tracking-wide); padding: var(--sp-3) 0; }
|
|
||||||
.chapter-row { display: flex; align-items: center; gap: var(--sp-2); width: 100%; padding: 7px var(--sp-2); border-radius: var(--radius-sm); background: none; border: none; text-align: left; cursor: pointer; transition: background var(--t-fast); }
|
|
||||||
.chapter-row:hover { background: rgba(255,255,255,0.07); }
|
|
||||||
.chapter-row-current { background: rgba(255,255,255,0.1) !important; }
|
|
||||||
.ch-num { font-family: var(--font-ui); font-size: var(--text-2xs); color: rgba(255,255,255,0.35); letter-spacing: var(--tracking-wide); flex-shrink: 0; min-width: 36px; }
|
|
||||||
.chapter-row-current .ch-num { color: var(--accent-fg); }
|
|
||||||
.ch-info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; min-width: 0; }
|
|
||||||
.ch-name { font-size: var(--text-xs); color: rgba(255,255,255,0.75); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
||||||
.chapter-row-read .ch-name { color: rgba(255,255,255,0.35); }
|
|
||||||
.chapter-row-current .ch-name { color: #fff; font-weight: var(--weight-medium); }
|
|
||||||
.ch-meta { font-family: var(--font-ui); font-size: 9px; color: rgba(255,255,255,0.28); letter-spacing: var(--tracking-wide); }
|
|
||||||
.ch-read { color: rgba(255,255,255,0.2); }
|
|
||||||
:global(.ch-play-icon) { color: var(--accent-fg); flex-shrink: 0; }
|
|
||||||
.chapter-row-sk { display: flex; gap: var(--sp-2); padding: 7px var(--sp-2); align-items: center; }
|
|
||||||
.sk-info { flex: 1; display: flex; flex-direction: column; gap: 4px; }
|
|
||||||
.sk { background: rgba(255,255,255,0.06); border-radius: var(--radius-sm); }
|
|
||||||
.sk-num { width: 32px; height: 10px; flex-shrink: 0; }
|
|
||||||
.sk-name { height: 11px; width: 85%; }
|
|
||||||
.sk-meta { height: 9px; width: 50%; }
|
|
||||||
.ch-view-all { display: flex; align-items: center; gap: 4px; margin-top: auto; font-family: var(--font-ui); font-size: var(--text-2xs); color: rgba(255,255,255,0.3); letter-spacing: var(--tracking-wide); background: none; border: none; cursor: pointer; padding: var(--sp-2) var(--sp-2) 0; transition: color var(--t-base); }
|
|
||||||
.ch-view-all:hover { color: var(--accent-fg); }
|
|
||||||
.section { border-top: 1px solid var(--border-dim); flex-shrink: 0; }
|
|
||||||
.section-header { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-3) var(--sp-4) var(--sp-2); }
|
|
||||||
.section-title { display: inline-flex; align-items: center; gap: var(--sp-2); font-family: var(--font-ui); font-size: var(--text-sm); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; }
|
|
||||||
.see-all { display: flex; align-items: center; gap: 4px; font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); text-transform: uppercase; color: var(--text-faint); background: none; border: none; cursor: pointer; padding: 0; transition: color var(--t-base); }
|
|
||||||
.see-all:hover { color: var(--accent-fg); }
|
|
||||||
.activity-list { display: flex; flex-direction: column; padding: 0 var(--sp-3); overflow: hidden; }
|
|
||||||
.activity-row { display: flex; align-items: center; gap: var(--sp-3); padding: 7px var(--sp-2); border-radius: var(--radius-md); border: 1px solid transparent; background: none; text-align: left; cursor: pointer; width: 100%; transition: background var(--t-fast), border-color var(--t-fast); }
|
|
||||||
.activity-row:hover { background: var(--bg-raised); border-color: var(--border-dim); }
|
|
||||||
.activity-row:hover .activity-play { opacity: 1; }
|
|
||||||
.activity-thumb { width: 33px; height: 48px; border-radius: var(--radius-sm); object-fit: cover; flex-shrink: 0; border: 1px solid var(--border-dim); }
|
|
||||||
.activity-info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; min-width: 0; }
|
|
||||||
.activity-title { font-size: var(--text-base); font-weight: var(--weight-medium); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
||||||
.activity-sub { font-family: var(--font-ui); font-size: var(--text-sm); color: var(--text-muted); letter-spacing: var(--tracking-wide); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
||||||
.activity-time { font-family: var(--font-ui); font-size: var(--text-sm); color: var(--text-faint); letter-spacing: var(--tracking-wide); flex-shrink: 0; }
|
|
||||||
.activity-play { color: var(--accent-fg); flex-shrink: 0; opacity: 0; transition: opacity var(--t-base); }
|
|
||||||
.bottom-row { display: grid; grid-template-columns: 1fr 1px 1fr; padding: 0 var(--sp-4); border-top: 1px solid var(--border-dim); flex-shrink: 0; }
|
|
||||||
.bottom-divider { background: var(--border-dim); align-self: stretch; }
|
|
||||||
.bottom-col { display: flex; flex-direction: column; min-width: 0; padding-top: var(--sp-4); padding-bottom: var(--sp-5); }
|
|
||||||
.bottom-col:first-child { padding-right: var(--sp-4); }
|
|
||||||
.bottom-col:last-child { padding-left: var(--sp-4); }
|
|
||||||
.bottom-section-hd { display: flex; align-items: center; justify-content: space-between; padding-bottom: var(--sp-2); }
|
|
||||||
.bottom-empty { font-family: var(--font-ui); font-size: var(--text-sm); color: var(--text-faint); letter-spacing: var(--tracking-wide); padding: var(--sp-1) 0; }
|
|
||||||
.mini-row { display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: var(--sp-3); }
|
|
||||||
|
|
||||||
.mini-card { width: 100%; background: none; border: none; padding: 0; cursor: pointer; text-align: left; }
|
|
||||||
.mini-card:hover .mini-cover { filter: brightness(1.08) saturate(1.05); transform: scale(1.02); }
|
|
||||||
.mini-card:hover { will-change: transform; }
|
|
||||||
.mini-cover-wrap { position: relative; aspect-ratio: 2/3; overflow: hidden; border-radius: var(--radius-md); background: var(--bg-raised); border: 1px solid var(--border-dim); box-shadow: 0 2px 12px rgba(0,0,0,0.35); }
|
|
||||||
.mini-cover { width: 100%; height: 100%; object-fit: cover; display: block; transition: filter 0.15s ease, transform 0.15s ease; }
|
|
||||||
.mini-gradient { position: absolute; inset: 0; background: linear-gradient(to top, rgba(0,0,0,0.85) 0%, rgba(0,0,0,0.1) 55%, transparent 75%); pointer-events: none; }
|
|
||||||
.mini-footer { position: absolute; bottom: 0; left: 0; right: 0; padding: var(--sp-2); pointer-events: none; }
|
|
||||||
.mini-card-title { font-size: var(--text-xs); font-weight: var(--weight-medium); color: rgba(255,255,255,0.92); line-height: var(--leading-snug); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; text-shadow: 0 1px 4px rgba(0,0,0,0.7); }
|
|
||||||
.mini-card-source { font-family: var(--font-ui); font-size: 9px; color: rgba(255,255,255,0.45); letter-spacing: var(--tracking-wide); margin-top: 1px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
||||||
.stats-grid { display: grid; grid-template-columns: 1fr 1fr; gap: var(--sp-2); }
|
|
||||||
.stat-card { display: flex; align-items: center; gap: var(--sp-3); background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: var(--sp-3) var(--sp-3); }
|
|
||||||
.stat-icon-wrap { display: flex; align-items: center; justify-content: center; width: 34px; height: 34px; border-radius: var(--radius-sm); flex-shrink: 0; }
|
|
||||||
.stat-fire { background: rgba(251,146,60,0.15); color: #fb923c; }
|
|
||||||
.stat-accent { background: var(--accent-muted); color: var(--accent-fg); }
|
|
||||||
.stat-neutral { background: var(--bg-overlay); color: var(--text-faint); }
|
|
||||||
.stat-green { background: rgba(34,197,94,0.12); color: #22c55e; }
|
|
||||||
.stat-body { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
|
|
||||||
.stat-val { font-family: var(--font-ui); font-size: var(--text-lg, 1.05rem); font-weight: var(--weight-medium); color: var(--text-secondary); line-height: 1; }
|
|
||||||
.stat-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); white-space: nowrap; }
|
|
||||||
.activity-row-sk { cursor: default; pointer-events: none; }
|
|
||||||
.sk-thumb { width: 33px; height: 48px; border-radius: var(--radius-sm); background: rgba(255,255,255,0.06); flex-shrink: 0; }
|
|
||||||
.sk { background: var(--bg-raised); border-radius: var(--radius-sm); }
|
|
||||||
.sk-title { height: 11px; margin-bottom: 5px; }
|
|
||||||
.sk-sub { height: 9px; }
|
|
||||||
.sk-time { width: 32px; height: 9px; flex-shrink: 0; background: rgba(255,255,255,0.06); border-radius: var(--radius-sm); }
|
|
||||||
.activity-placeholder { position: relative; }
|
|
||||||
.activity-placeholder-overlay { position: absolute; left: 0; right: 0; top: 0; bottom: -1px; display: flex; align-items: flex-end; justify-content: center; padding-bottom: var(--sp-4); pointer-events: none; background: linear-gradient(to bottom, transparent 0%, rgba(0,0,0,0.6) 100%); }
|
|
||||||
.activity-placeholder-cta { pointer-events: all; display: inline-flex; align-items: center; gap: 6px; font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 7px 16px; border-radius: var(--radius-full); background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.14); color: rgba(255,255,255,0.65); cursor: pointer; transition: background var(--t-base), color var(--t-base); }
|
|
||||||
.activity-placeholder-cta:hover { background: rgba(255,255,255,0.13); color: rgba(255,255,255,0.9); }
|
|
||||||
.picker-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.6); z-index: var(--z-settings); display: flex; align-items: center; justify-content: center; animation: fadeIn 0.1s ease both; backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px); }
|
|
||||||
.picker-modal { width: min(460px, calc(100vw - 48px)); max-height: 68vh; display: flex; flex-direction: column; background: var(--bg-surface); border: 1px solid var(--border-base); border-radius: var(--radius-xl); overflow: hidden; box-shadow: 0 24px 64px rgba(0,0,0,0.6); animation: scaleIn 0.14s ease both; }
|
|
||||||
.picker-header { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-4) var(--sp-5); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
|
|
||||||
.picker-title { font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-secondary); }
|
|
||||||
.picker-close { display: flex; align-items: center; justify-content: center; width: 24px; height: 24px; border-radius: var(--radius-sm); color: var(--text-faint); background: none; border: none; cursor: pointer; }
|
|
||||||
.picker-close:hover { color: var(--text-muted); background: var(--bg-raised); }
|
|
||||||
.picker-search-wrap { display: flex; align-items: center; gap: var(--sp-2); padding: var(--sp-3) var(--sp-4); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
|
|
||||||
.picker-search { flex: 1; background: none; border: none; outline: none; color: var(--text-primary); font-size: var(--text-sm); }
|
|
||||||
.picker-search::placeholder { color: var(--text-faint); }
|
|
||||||
.picker-list { flex: 1; overflow-y: auto; padding: var(--sp-2); scrollbar-width: none; }
|
|
||||||
.picker-list::-webkit-scrollbar { display: none; }
|
|
||||||
.picker-empty { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); padding: var(--sp-4) var(--sp-3); text-align: center; }
|
|
||||||
.picker-row { display: flex; align-items: center; gap: var(--sp-3); width: 100%; padding: 8px var(--sp-3); border-radius: var(--radius-md); border: none; background: none; text-align: left; cursor: pointer; transition: background var(--t-fast); }
|
|
||||||
.picker-row:hover { background: var(--bg-raised); }
|
|
||||||
.picker-thumb { height: 50px; width: 35px; aspect-ratio: 1 / 1.42; border-radius: var(--radius-sm); object-fit: cover; flex-shrink: 0; border: 1px solid var(--border-dim); background: var(--bg-raised); display: block; }
|
|
||||||
.picker-info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; min-width: 0; }
|
|
||||||
.picker-manga-title { font-size: var(--text-sm); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
||||||
.picker-source { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
|
||||||
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
|
|
||||||
@keyframes scaleIn { from { opacity: 0; transform: scale(0.97) } to { opacity: 1; transform: scale(1) } }
|
|
||||||
@keyframes pulse { 0%,100% { opacity: 0.4 } 50% { opacity: 0.7 } }
|
|
||||||
</style>
|
|
||||||
@@ -1,330 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { onMount, untrack } from "svelte";
|
|
||||||
import { MagnifyingGlass, Books, DownloadSimple, Folder, FolderSimplePlus, Trash } from "phosphor-svelte";
|
|
||||||
import { gql, thumbUrl } from "../../lib/client";
|
|
||||||
import { GET_LIBRARY, GET_MANGA, UPDATE_MANGA, GET_CHAPTERS, DELETE_DOWNLOADED_CHAPTERS, DEQUEUE_DOWNLOAD } from "../../lib/queries";
|
|
||||||
import { cache, CACHE_KEYS, CACHE_GROUPS, DEFAULT_TTL_MS } from "../../lib/cache";
|
|
||||||
import { dedupeMangaById, dedupeMangaByTitle } from "../../lib/util";
|
|
||||||
import { store, setActiveManga, setLibraryFilter } from "../../store/state.svelte";
|
|
||||||
import { addFolder, assignMangaToFolder, removeMangaFromFolder, getMangaFolders } from "../../store/state.svelte";
|
|
||||||
import { COMPLETED_FOLDER_ID } from "../../store/state.svelte";
|
|
||||||
import type { Manga, Chapter } from "../../lib/types";
|
|
||||||
import ContextMenu, { type MenuEntry } from "../shared/ContextMenu.svelte";
|
|
||||||
|
|
||||||
const CARD_MIN_W = 130;
|
|
||||||
const CARD_GAP = 16;
|
|
||||||
|
|
||||||
let allManga: Manga[] = $state([]);
|
|
||||||
let extraManga: Manga[] = $state([]); // non-library manga needed for folders (e.g. completed)
|
|
||||||
let loading: boolean = $state(true);
|
|
||||||
let error: string|null = $state(null);
|
|
||||||
let retryCount: number = $state(0);
|
|
||||||
let search: string = $state("");
|
|
||||||
let renderVisible: number = $state(0);
|
|
||||||
let scrollEl: HTMLDivElement;
|
|
||||||
let containerWidth: number = $state(800);
|
|
||||||
let ctx: { x: number; y: number; manga: Manga } | null = $state(null);
|
|
||||||
let emptyCtx: { x: number; y: number } | null = $state(null);
|
|
||||||
|
|
||||||
let prevChapterId: number | null = null;
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
const wasOpen = prevChapterId !== null;
|
|
||||||
prevChapterId = store.activeChapter?.id ?? null;
|
|
||||||
if (wasOpen && !store.activeChapter) cache.clear(CACHE_KEYS.LIBRARY);
|
|
||||||
});
|
|
||||||
|
|
||||||
function fetchLibrary() {
|
|
||||||
return cache.get(
|
|
||||||
CACHE_KEYS.LIBRARY,
|
|
||||||
() => gql<{ mangas: { nodes: Manga[] } }>(GET_LIBRARY).then(d => d.mangas.nodes),
|
|
||||||
DEFAULT_TTL_MS,
|
|
||||||
CACHE_GROUPS.LIBRARY,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadData() {
|
|
||||||
fetchLibrary()
|
|
||||||
.then(nodes => { allManga = dedupeMangaByTitle(dedupeMangaById(nodes), store.settings.mangaLinks); error = null; })
|
|
||||||
.catch(e => error = e.message)
|
|
||||||
.finally(() => loading = false);
|
|
||||||
}
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
retryCount;
|
|
||||||
loading = true; error = null;
|
|
||||||
if (retryCount > 0) cache.clear(CACHE_KEYS.LIBRARY);
|
|
||||||
untrack(() => loadData());
|
|
||||||
});
|
|
||||||
|
|
||||||
// Lazily fetch manga that are in a folder but not in the library (e.g. completed but removed from library)
|
|
||||||
$effect(() => {
|
|
||||||
const allIds = new Set(allManga.map(m => m.id));
|
|
||||||
const missingIds = store.settings.folders
|
|
||||||
.flatMap(f => f.mangaIds)
|
|
||||||
.filter(id => !allIds.has(id));
|
|
||||||
if (!missingIds.length) return;
|
|
||||||
const toFetch = [...new Set(missingIds)].filter(id => !extraManga.some(m => m.id === id));
|
|
||||||
if (!toFetch.length) return;
|
|
||||||
untrack(() => {
|
|
||||||
Promise.all(
|
|
||||||
toFetch.map(id =>
|
|
||||||
cache.get(CACHE_KEYS.MANGA(id), () =>
|
|
||||||
gql<{ manga: Manga }>(GET_MANGA, { id }).then(d => d.manga)
|
|
||||||
).catch(() => null)
|
|
||||||
)
|
|
||||||
).then(results => {
|
|
||||||
const valid = results.filter(Boolean) as Manga[];
|
|
||||||
if (valid.length) extraManga = dedupeMangaById([...extraManga, ...valid]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
$effect(() => { if (scrollEl) scrollEl.scrollTo({ top: 0 }); });
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
const f = store.settings.folders.find(f => f.id === store.libraryFilter);
|
|
||||||
if (f && !f.showTab) untrack(() => { store.libraryFilter = "library"; });
|
|
||||||
});
|
|
||||||
|
|
||||||
const isBuiltin = (f: string) => f === "library" || f === "downloaded";
|
|
||||||
|
|
||||||
// All manga available for folder filtering — library + any extras fetched above
|
|
||||||
const folderPool = $derived((() => {
|
|
||||||
const seen = new Set(allManga.map(m => m.id));
|
|
||||||
return [...allManga, ...extraManga.filter(m => !seen.has(m.id))];
|
|
||||||
})());
|
|
||||||
|
|
||||||
const filtered = $derived((() => {
|
|
||||||
const q = search.trim().toLowerCase();
|
|
||||||
if (store.libraryFilter === "library") {
|
|
||||||
return q ? allManga.filter(m => m.title.toLowerCase().includes(q)) : allManga;
|
|
||||||
}
|
|
||||||
if (store.libraryFilter === "downloaded") {
|
|
||||||
const items = allManga.filter(m => (m.downloadCount ?? 0) > 0);
|
|
||||||
return q ? items.filter(m => m.title.toLowerCase().includes(q)) : items;
|
|
||||||
}
|
|
||||||
const folder = store.settings.folders.find(f => f.id === store.libraryFilter);
|
|
||||||
if (folder) {
|
|
||||||
const items = folderPool.filter(m => folder.mangaIds.includes(m.id));
|
|
||||||
return q ? items.filter(m => m.title.toLowerCase().includes(q)) : items;
|
|
||||||
}
|
|
||||||
return [];
|
|
||||||
})());
|
|
||||||
|
|
||||||
const cols = $derived(Math.max(1, Math.floor((containerWidth + CARD_GAP) / (CARD_MIN_W + CARD_GAP))));
|
|
||||||
const visibleManga = $derived(filtered.slice(0, renderVisible));
|
|
||||||
const hasMore = $derived(filtered.length > renderVisible);
|
|
||||||
const remainingCount = $derived(filtered.length - renderVisible);
|
|
||||||
|
|
||||||
$effect(() => { filtered; untrack(() => { renderVisible = store.settings.renderLimit ?? 48; }); });
|
|
||||||
|
|
||||||
const counts = $derived({
|
|
||||||
library: allManga.length,
|
|
||||||
downloaded: allManga.filter(m => (m.downloadCount ?? 0) > 0).length,
|
|
||||||
...store.settings.folders.reduce((a, f) => ({ ...a, [f.id]: folderPool.filter(m => f.mangaIds.includes(m.id)).length }), {} as Record<string, number>),
|
|
||||||
});
|
|
||||||
|
|
||||||
function loadMore() { renderVisible += store.settings.renderLimit ?? 48; }
|
|
||||||
|
|
||||||
async function removeFromLibrary(manga: Manga) {
|
|
||||||
await gql(UPDATE_MANGA, { id: manga.id, inLibrary: false }).catch(console.error);
|
|
||||||
allManga = allManga.filter(m => m.id !== manga.id);
|
|
||||||
cache.clearGroup(CACHE_GROUPS.LIBRARY);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteAllDownloads(manga: Manga) {
|
|
||||||
try {
|
|
||||||
const data = await gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId: manga.id });
|
|
||||||
const ids = data.chapters.nodes.filter(c => c.isDownloaded).map(c => c.id);
|
|
||||||
if (!ids.length) return;
|
|
||||||
await gql(DELETE_DOWNLOADED_CHAPTERS, { ids });
|
|
||||||
await Promise.allSettled(ids.map(id => gql(DEQUEUE_DOWNLOAD, { chapterId: id })));
|
|
||||||
allManga = allManga.map(m => m.id === manga.id ? { ...m, downloadCount: 0 } : m);
|
|
||||||
} catch (e) { console.error(e); }
|
|
||||||
}
|
|
||||||
|
|
||||||
function openCtx(e: MouseEvent, m: Manga) { e.preventDefault(); ctx = { x: e.clientX, y: e.clientY, manga: m }; }
|
|
||||||
|
|
||||||
function buildCtxItems(m: Manga): MenuEntry[] {
|
|
||||||
const mangaFolders = getMangaFolders(m.id);
|
|
||||||
const folderEntries: MenuEntry[] = store.settings.folders.map(f => {
|
|
||||||
const inFolder = mangaFolders.some(mf => mf.id === f.id);
|
|
||||||
return { label: inFolder ? `Remove from ${f.name}` : `Add to ${f.name}`, icon: Folder, onClick: () => inFolder ? removeMangaFromFolder(f.id, m.id) : assignMangaToFolder(f.id, m.id) };
|
|
||||||
});
|
|
||||||
return [
|
|
||||||
{ label: m.inLibrary ? "Remove from library" : "Add to library", icon: Books, onClick: () => m.inLibrary ? removeFromLibrary(m) : gql(UPDATE_MANGA, { id: m.id, inLibrary: true }).then(() => { allManga = allManga.map(x => x.id === m.id ? { ...x, inLibrary: true } : x); cache.clear(CACHE_KEYS.LIBRARY); }).catch(console.error) },
|
|
||||||
{ label: "Delete all downloads", icon: Trash, danger: true, disabled: !(m.downloadCount && m.downloadCount > 0), onClick: () => deleteAllDownloads(m) },
|
|
||||||
...(folderEntries.length ? [{ separator: true } as MenuEntry, ...folderEntries] : []),
|
|
||||||
{ separator: true },
|
|
||||||
{ label: "New folder", icon: FolderSimplePlus, onClick: () => { const name = prompt("Folder name:"); if (name?.trim()) { const id = addFolder(name.trim()); assignMangaToFolder(id, m.id); } } },
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildEmptyCtx(): MenuEntry[] {
|
|
||||||
return [{ label: "New folder", icon: FolderSimplePlus, onClick: () => { const name = prompt("Folder name:"); if (name?.trim()) addFolder(name.trim()); } }];
|
|
||||||
}
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
const ro = new ResizeObserver(([e]) => containerWidth = e.contentRect.width);
|
|
||||||
ro.observe(scrollEl);
|
|
||||||
const unsub = cache.subscribe(CACHE_KEYS.LIBRARY, loadData);
|
|
||||||
return () => { ro.disconnect(); unsub(); };
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="root"
|
|
||||||
role="presentation"
|
|
||||||
bind:this={scrollEl}
|
|
||||||
oncontextmenu={(e) => {
|
|
||||||
if ((e.target as HTMLElement).closest("button")) return;
|
|
||||||
e.preventDefault();
|
|
||||||
emptyCtx = { x: e.clientX, y: e.clientY };
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{#if store.settings.libraryBranches ?? true}
|
|
||||||
<svg class="branches" viewBox="0 0 400 600" preserveAspectRatio="xMaxYMid slice" aria-hidden="true">
|
|
||||||
<g stroke="var(--accent)" stroke-width="0.6" fill="none" opacity="0.13">
|
|
||||||
<path d="M380 600 C380 500 340 460 310 400 C280 340 300 280 270 220"/>
|
|
||||||
<path d="M270 220 C255 190 230 175 210 150"/>
|
|
||||||
<path d="M270 220 C290 195 310 185 330 165"/>
|
|
||||||
<path d="M310 400 C290 375 265 368 245 350"/>
|
|
||||||
<path d="M310 400 C330 370 355 362 370 340"/>
|
|
||||||
<path d="M210 150 C195 128 185 108 175 80"/>
|
|
||||||
<path d="M210 150 C225 130 240 122 258 105"/>
|
|
||||||
<path d="M245 350 C228 330 215 315 205 290"/>
|
|
||||||
<path d="M175 80 C168 60 162 42 158 20"/>
|
|
||||||
<path d="M175 80 C185 62 195 50 208 35"/>
|
|
||||||
<path d="M205 290 C196 268 190 250 186 225"/>
|
|
||||||
<path d="M258 105 C268 88 278 72 292 52"/>
|
|
||||||
<path class="anim-branch" d="M186 225 C180 205 176 185 174 160"/>
|
|
||||||
<path class="anim-branch" d="M292 52 C300 36 308 20 318 0"/>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if error}
|
|
||||||
<div class="center">
|
|
||||||
<p class="error-msg">Could not reach Suwayomi</p>
|
|
||||||
<p class="error-detail">Make sure the server is running, then retry.</p>
|
|
||||||
<button class="retry-btn" onclick={() => retryCount++}>Retry</button>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div class="header">
|
|
||||||
<div class="header-left">
|
|
||||||
<span class="heading">Library</span>
|
|
||||||
<div class="tabs">
|
|
||||||
{#each [["library","Saved"], ["downloaded","Downloaded"]] as [f, label]}
|
|
||||||
<button class="tab" class:active={store.libraryFilter === f} onclick={() => store.libraryFilter = f}>
|
|
||||||
{#if f === "library"}<Books size={11} weight="bold" />
|
|
||||||
{:else if f === "downloaded"}<DownloadSimple size={11} weight="bold" />{/if}
|
|
||||||
{label}
|
|
||||||
<span class="tab-count">{counts[f] ?? 0}</span>
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
{#each store.settings.folders.filter(f => f.showTab) as folder}
|
|
||||||
<button class="tab" class:active={store.libraryFilter === folder.id} onclick={() => store.libraryFilter = folder.id}>
|
|
||||||
<Folder size={11} weight="bold" />
|
|
||||||
{folder.name}
|
|
||||||
<span class="tab-count">{counts[folder.id] ?? 0}</span>
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="search-wrap">
|
|
||||||
<MagnifyingGlass size={13} class="search-icon" weight="light" />
|
|
||||||
<input class="search" placeholder="Search" bind:value={search} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="content">
|
|
||||||
{#if loading}
|
|
||||||
<div class="grid">
|
|
||||||
{#each Array(12) as _}
|
|
||||||
<div class="card-skeleton">
|
|
||||||
<div class="cover-skeleton skeleton"></div>
|
|
||||||
<div class="title-skeleton skeleton"></div>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{:else if filtered.length === 0}
|
|
||||||
<div class="center">
|
|
||||||
{store.libraryFilter === "library" ? "No manga saved to library — browse sources to add some."
|
|
||||||
: store.libraryFilter === "downloaded" ? "No downloaded manga."
|
|
||||||
: "No manga in this folder yet. Right-click manga anywhere to assign them."}
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div class="grid" style="--cols:{cols}">
|
|
||||||
{#each visibleManga as m (m.id)}
|
|
||||||
<button class="card" onclick={() => store.activeManga = m} oncontextmenu={(e) => openCtx(e, m)}>
|
|
||||||
<div class="cover-wrap">
|
|
||||||
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} class="cover" style="object-fit:{store.settings.libraryCropCovers ? 'cover' : 'contain'}" loading="lazy" decoding="async" />
|
|
||||||
{#if m.downloadCount}<span class="badge-dl">{m.downloadCount}</span>{/if}
|
|
||||||
{#if m.unreadCount}<span class="badge-unread">{m.unreadCount}</span>{/if}
|
|
||||||
</div>
|
|
||||||
<p class="title">{m.title}</p>
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{#if hasMore}
|
|
||||||
<div class="load-more-row">
|
|
||||||
<button class="load-more-btn" onclick={loadMore}>
|
|
||||||
Show {Math.min(remainingCount, store.settings.renderLimit ?? 48)} more
|
|
||||||
<span class="load-more-count">({remainingCount} remaining)</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
</div><!-- .content -->
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if ctx}
|
|
||||||
<ContextMenu x={ctx.x} y={ctx.y} items={buildCtxItems(ctx.manga)} onClose={() => ctx = null} />
|
|
||||||
{/if}
|
|
||||||
{#if emptyCtx}
|
|
||||||
<ContextMenu x={emptyCtx.x} y={emptyCtx.y} items={buildEmptyCtx()} onClose={() => emptyCtx = null} />
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.root { position: relative; display: flex; flex-direction: column; height: 100%; overflow: hidden; animation: fadeIn 0.14s ease both; }
|
|
||||||
.content { flex: 1; overflow-y: auto; padding: var(--sp-5) var(--sp-6) var(--sp-6); will-change: scroll-position; -webkit-overflow-scrolling: touch; }
|
|
||||||
.branches { position: absolute; top: 0; right: 0; width: 400px; height: 600px; pointer-events: none; z-index: 0; }
|
|
||||||
.branches :global(.anim-branch) { stroke-dasharray: 60; stroke-dashoffset: 60; animation: branchGrow 2.4s ease forwards; }
|
|
||||||
@keyframes branchGrow { to { stroke-dashoffset: 0; } }
|
|
||||||
.header { position: relative; z-index: 1; display: flex; align-items: center; justify-content: space-between; padding: var(--sp-4) var(--sp-6); border-bottom: 1px solid var(--border-dim); gap: var(--sp-4); flex-wrap: wrap; flex-shrink: 0; }
|
|
||||||
.header-left { display: flex; align-items: center; gap: var(--sp-3); flex-wrap: wrap; }
|
|
||||||
.heading { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; flex-shrink: 0; }
|
|
||||||
.tabs { display: flex; gap: 2px; background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 2px; }
|
|
||||||
.tab { display: flex; align-items: center; gap: 5px; font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); text-transform: uppercase; padding: 4px 10px; border-radius: var(--radius-sm); color: var(--text-faint); white-space: nowrap; transition: background var(--t-base), color var(--t-base); }
|
|
||||||
.tab:hover { color: var(--text-muted); }
|
|
||||||
.tab.active { background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim); }
|
|
||||||
.tab-count { font-size: var(--text-2xs); opacity: 0.6; }
|
|
||||||
.search-wrap { position: relative; display: flex; align-items: center; }
|
|
||||||
.search-wrap :global(.search-icon) { position: absolute; left: 10px; color: var(--text-faint); pointer-events: none; }
|
|
||||||
.search { background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 5px 10px 5px 28px; color: var(--text-primary); font-size: var(--text-sm); width: 180px; outline: none; transition: border-color var(--t-base); }
|
|
||||||
.search::placeholder { color: var(--text-faint); }
|
|
||||||
.search:focus { border-color: var(--border-strong); }
|
|
||||||
.grid { position: relative; z-index: 1; display: grid; grid-template-columns: repeat(var(--cols, auto-fill), minmax(130px, 1fr)); gap: var(--sp-4); }
|
|
||||||
.card { background: none; border: none; padding: 0; cursor: pointer; text-align: left; }
|
|
||||||
.card:hover .cover { filter: brightness(1.07); }
|
|
||||||
.card:hover .title { color: var(--text-primary); }
|
|
||||||
.cover-wrap { position: relative; aspect-ratio: 2/3; overflow: hidden; border-radius: var(--radius-md); background: var(--bg-raised); border: 1px solid var(--border-dim); transform: translateZ(0); }
|
|
||||||
.cover { width: 100%; height: 100%; transition: filter var(--t-base); will-change: filter; }
|
|
||||||
.badge-dl { position: absolute; bottom: var(--sp-1); right: var(--sp-1); min-width: 18px; height: 18px; padding: 0 3px; display: flex; align-items: center; justify-content: center; font-size: 10px; font-weight: bold; background: var(--accent-dim); color: var(--accent-fg); border-radius: var(--radius-sm); border: 1px solid var(--accent-muted); }
|
|
||||||
.badge-unread { position: absolute; top: var(--sp-1); left: var(--sp-1); min-width: 18px; height: 18px; padding: 0 4px; display: flex; align-items: center; justify-content: center; font-size: 10px; font-weight: bold; background: var(--bg-void); color: var(--text-primary); border-radius: var(--radius-sm); border: 1px solid var(--border-strong); }
|
|
||||||
.title { margin-top: var(--sp-2); font-size: var(--text-sm); color: var(--text-secondary); line-height: var(--leading-snug); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; transition: color var(--t-base); }
|
|
||||||
.card-skeleton { padding: 0; }
|
|
||||||
.cover-skeleton { aspect-ratio: 2/3; border-radius: var(--radius-md); }
|
|
||||||
.title-skeleton { height: 12px; margin-top: var(--sp-2); width: 80%; border-radius: var(--radius-sm); }
|
|
||||||
.load-more-row { display: flex; justify-content: center; padding: var(--sp-5) 0 var(--sp-2); position: relative; z-index: 1; }
|
|
||||||
.load-more-btn { display: flex; align-items: center; gap: var(--sp-2); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 8px 20px; border-radius: var(--radius-full); border: 1px solid var(--border-dim); background: var(--bg-raised); color: var(--text-muted); cursor: pointer; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
|
|
||||||
.load-more-btn:hover { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
|
|
||||||
.load-more-count { color: var(--text-faint); font-size: var(--text-2xs); }
|
|
||||||
.center { position: relative; z-index: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; height: 60%; color: var(--text-muted); font-size: var(--text-sm); gap: var(--sp-2); text-align: center; line-height: var(--leading-base); }
|
|
||||||
.error-msg { color: var(--color-error); font-size: var(--text-base); }
|
|
||||||
.error-detail { color: var(--text-faint); font-size: var(--text-sm); }
|
|
||||||
.retry-btn { margin-top: var(--sp-3); padding: 6px 16px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: var(--bg-raised); color: var(--text-muted); cursor: pointer; font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); }
|
|
||||||
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
|
|
||||||
</style>
|
|
||||||
@@ -1,919 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { onMount, untrack } from "svelte";
|
|
||||||
import { ArrowLeft, BookmarkSimple, Download, CheckCircle, Circle, ArrowSquareOut, CircleNotch, Play, SortAscending, SortDescending, CaretDown, ArrowsClockwise, List, SquaresFour, FolderSimplePlus, Trash, DownloadSimple, X, LinkSimpleHorizontalBreak, ChartLineUp } from "phosphor-svelte";
|
|
||||||
import { gql, thumbUrl } from "../../lib/client";
|
|
||||||
import { GET_MANGA, GET_CHAPTERS, FETCH_CHAPTERS, ENQUEUE_DOWNLOAD, UPDATE_MANGA, MARK_CHAPTER_READ, MARK_CHAPTERS_READ, DELETE_DOWNLOADED_CHAPTERS, ENQUEUE_CHAPTERS_DOWNLOAD, GET_ALL_MANGA } from "../../lib/queries";
|
|
||||||
import { cache, CACHE_KEYS, recordSourceAccess } from "../../lib/cache";
|
|
||||||
import { store, addToast, updateSettings, addFolder, assignMangaToFolder, removeMangaFromFolder, getMangaFolders, openReader, checkAndMarkCompleted, setActiveManga, setGenreFilter, setNavPage, linkManga, unlinkManga } from "../../store/state.svelte";
|
|
||||||
import type { Manga, Chapter } from "../../lib/types";
|
|
||||||
import ContextMenu, { type MenuEntry } from "../shared/ContextMenu.svelte";
|
|
||||||
import MigrateModal from "./MigrateModal.svelte";
|
|
||||||
import TrackingPanel from "../shared/TrackingPanel.svelte";
|
|
||||||
|
|
||||||
const CHAPTERS_PER_PAGE = 25;
|
|
||||||
const MANGA_TTL_MS = 5 * 60 * 1000;
|
|
||||||
const CHAPTER_TTL_MS = 2 * 60 * 1000;
|
|
||||||
|
|
||||||
const mangaStore: Map<number, { data: Manga; fetchedAt: number }> = new Map();
|
|
||||||
const chapterStore: Map<number, { data: Chapter[]; fetchedAt: number }> = new Map();
|
|
||||||
|
|
||||||
let manga: Manga | null = $state(null);
|
|
||||||
let chapters: Chapter[] = $state([]);
|
|
||||||
let loadingManga: boolean = $state(false);
|
|
||||||
let loadingChapters: boolean = $state(true);
|
|
||||||
let enqueueing: Set<number> = $state(new Set());
|
|
||||||
let dlOpen: boolean = $state(false);
|
|
||||||
let detailsOpen: boolean = $state(false);
|
|
||||||
let togglingLibrary: boolean = $state(false);
|
|
||||||
let chapterPage: number = $state(1);
|
|
||||||
let ctx: { x: number; y: number; chapter: Chapter; idx: number } | null = $state(null);
|
|
||||||
let jumpOpen: boolean = $state(false);
|
|
||||||
let jumpInput: string = $state("");
|
|
||||||
let viewMode: "list" | "grid" = $state("list");
|
|
||||||
let deletingAll: boolean = $state(false);
|
|
||||||
let refreshing: boolean = $state(false);
|
|
||||||
let genresExpanded: boolean = $state(false);
|
|
||||||
let folderPickerOpen: boolean = $state(false);
|
|
||||||
let folderCreating: boolean = $state(false);
|
|
||||||
let folderNewName: string = $state("");
|
|
||||||
let rangeFrom: string = $state("");
|
|
||||||
let rangeTo: string = $state("");
|
|
||||||
let showRange: boolean = $state(false);
|
|
||||||
let migrateOpen: boolean = $state(false);
|
|
||||||
let dlDropRef: HTMLDivElement | undefined = $state();
|
|
||||||
let folderPickerRef: HTMLDivElement | undefined = $state();
|
|
||||||
|
|
||||||
// Series link state
|
|
||||||
let linkPickerOpen: boolean = $state(false);
|
|
||||||
let linkSearch: string = $state("");
|
|
||||||
let allMangaForLink: Manga[] = $state([]);
|
|
||||||
let loadingLinkList: boolean = $state(false);
|
|
||||||
|
|
||||||
// Tracking modal
|
|
||||||
let trackingOpen: boolean = $state(false);
|
|
||||||
|
|
||||||
let mangaAbort: AbortController | null = null;
|
|
||||||
let chapterAbort: AbortController | null = null;
|
|
||||||
let loadingFor: number | null = null;
|
|
||||||
|
|
||||||
function formatDate(ts: string | null | undefined): string {
|
|
||||||
if (!ts) return "";
|
|
||||||
const n = Number(ts);
|
|
||||||
const d = new Date(n > 1e10 ? n : n * 1000);
|
|
||||||
return d.toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" });
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyChapters(nodes: Chapter[]) {
|
|
||||||
chapters = nodes;
|
|
||||||
if (store.activeManga && nodes.length > 0) checkAndMarkCompleted(store.activeManga.id, nodes);
|
|
||||||
}
|
|
||||||
|
|
||||||
const sortDir = $derived(store.settings.chapterSortDir);
|
|
||||||
const sortMode = $derived(store.settings.chapterSortMode ?? "source");
|
|
||||||
let sortMenuOpen = $state(false);
|
|
||||||
|
|
||||||
const sortedChapters = $derived.by(() => {
|
|
||||||
const base = [...chapters];
|
|
||||||
if (sortMode === "chapterNumber") {
|
|
||||||
base.sort((a, b) => a.chapterNumber - b.chapterNumber);
|
|
||||||
} else if (sortMode === "uploadDate") {
|
|
||||||
base.sort((a, b) => Number(a.uploadDate ?? 0) - Number(b.uploadDate ?? 0));
|
|
||||||
} else {
|
|
||||||
base.sort((a, b) => a.sourceOrder - b.sourceOrder);
|
|
||||||
}
|
|
||||||
return sortDir === "desc" ? base.reverse() : base;
|
|
||||||
});
|
|
||||||
const totalPages = $derived(Math.ceil(sortedChapters.length / CHAPTERS_PER_PAGE));
|
|
||||||
const pageChapters = $derived(sortedChapters.slice((chapterPage - 1) * CHAPTERS_PER_PAGE, chapterPage * CHAPTERS_PER_PAGE));
|
|
||||||
const readCount = $derived(chapters.filter(c => c.isRead).length);
|
|
||||||
const totalCount = $derived(chapters.length);
|
|
||||||
const progressPct = $derived(totalCount > 0 ? (readCount / totalCount) * 100 : 0);
|
|
||||||
const downloadedCount = $derived(chapters.filter(c => c.isDownloaded).length);
|
|
||||||
|
|
||||||
const continueChapter = $derived((() => {
|
|
||||||
if (!chapters.length) return null;
|
|
||||||
const asc = [...chapters].sort((a, b) => a.sourceOrder - b.sourceOrder);
|
|
||||||
const anyRead = asc.some(c => c.isRead);
|
|
||||||
const inProgress = asc.find(c => !c.isRead && (c.lastPageRead ?? 0) > 0);
|
|
||||||
if (inProgress) return { chapter: inProgress, type: "continue" as const };
|
|
||||||
const firstUnread = asc.find(c => !c.isRead);
|
|
||||||
if (firstUnread) return { chapter: firstUnread, type: (anyRead ? "continue" : "start") as const };
|
|
||||||
return { chapter: asc[0], type: "reread" as const };
|
|
||||||
})());
|
|
||||||
|
|
||||||
const statusLabel = $derived(manga?.status ? manga.status.charAt(0) + manga.status.slice(1).toLowerCase() : null);
|
|
||||||
const assignedFolders = $derived(store.activeManga ? getMangaFolders(store.activeManga.id) : []);
|
|
||||||
const hasFolders = $derived(assignedFolders.length > 0);
|
|
||||||
|
|
||||||
function loadManga(id: number) {
|
|
||||||
mangaAbort?.abort();
|
|
||||||
const ctrl = new AbortController();
|
|
||||||
mangaAbort = ctrl;
|
|
||||||
loadingFor = id;
|
|
||||||
const cached = mangaStore.get(id);
|
|
||||||
if (cached) {
|
|
||||||
manga = cached.data; loadingManga = false;
|
|
||||||
if (Date.now() - cached.fetchedAt < MANGA_TTL_MS) return;
|
|
||||||
gql<{ manga: Manga }>(GET_MANGA, { id }, ctrl.signal).then(d => {
|
|
||||||
if (ctrl.signal.aborted || loadingFor !== id) return;
|
|
||||||
mangaStore.set(id, { data: d.manga, fetchedAt: Date.now() });
|
|
||||||
manga = d.manga;
|
|
||||||
if (d.manga.source?.id) recordSourceAccess(d.manga.source.id);
|
|
||||||
}).catch(() => {});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
loadingManga = true;
|
|
||||||
gql<{ manga: Manga }>(GET_MANGA, { id }, ctrl.signal).then(d => {
|
|
||||||
if (ctrl.signal.aborted || loadingFor !== id) return;
|
|
||||||
mangaStore.set(id, { data: d.manga, fetchedAt: Date.now() });
|
|
||||||
manga = d.manga;
|
|
||||||
if (d.manga.source?.id) recordSourceAccess(d.manga.source.id);
|
|
||||||
}).catch(() => {}).finally(() => { if (!ctrl.signal.aborted && loadingFor === id) loadingManga = false; });
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadChapters(id: number) {
|
|
||||||
chapterAbort?.abort();
|
|
||||||
const ctrl = new AbortController();
|
|
||||||
chapterAbort = ctrl;
|
|
||||||
const cached = chapterStore.get(id);
|
|
||||||
if (cached) {
|
|
||||||
applyChapters(cached.data); loadingChapters = false;
|
|
||||||
if (Date.now() - cached.fetchedAt < CHAPTER_TTL_MS) return;
|
|
||||||
gql(FETCH_CHAPTERS, { mangaId: id }, ctrl.signal)
|
|
||||||
.then(() => gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId: id }, ctrl.signal))
|
|
||||||
.then(d => {
|
|
||||||
if (ctrl.signal.aborted || loadingFor !== id) return;
|
|
||||||
chapterStore.set(id, { data: d.chapters.nodes, fetchedAt: Date.now() });
|
|
||||||
applyChapters(d.chapters.nodes);
|
|
||||||
}).catch(() => {});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
chapters = []; loadingChapters = true;
|
|
||||||
gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId: id }, ctrl.signal).then(d => {
|
|
||||||
if (ctrl.signal.aborted || loadingFor !== id) return;
|
|
||||||
applyChapters(d.chapters.nodes); loadingChapters = false;
|
|
||||||
return gql(FETCH_CHAPTERS, { mangaId: id }, ctrl.signal)
|
|
||||||
.then(() => gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId: id }, ctrl.signal))
|
|
||||||
.then(fresh => {
|
|
||||||
if (ctrl.signal.aborted || loadingFor !== id) return;
|
|
||||||
chapterStore.set(id, { data: fresh.chapters.nodes, fetchedAt: Date.now() });
|
|
||||||
applyChapters(fresh.chapters.nodes);
|
|
||||||
});
|
|
||||||
}).catch(() => { if (!ctrl.signal.aborted) loadingChapters = false; });
|
|
||||||
}
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
const m = store.activeManga;
|
|
||||||
if (m) untrack(() => { loadManga(m.id); loadChapters(m.id); });
|
|
||||||
});
|
|
||||||
|
|
||||||
let prevChapterId: number | null = null;
|
|
||||||
$effect(() => {
|
|
||||||
const wasOpen = prevChapterId !== null;
|
|
||||||
prevChapterId = store.activeChapter?.id ?? null;
|
|
||||||
if (wasOpen && !store.activeChapter && store.activeManga) {
|
|
||||||
const id = store.activeManga.id;
|
|
||||||
untrack(() => { loadChapters(id); cache.clear(CACHE_KEYS.LIBRARY); });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
async function toggleLibrary() {
|
|
||||||
if (!manga) return;
|
|
||||||
togglingLibrary = true;
|
|
||||||
const next = !manga.inLibrary;
|
|
||||||
await gql(UPDATE_MANGA, { id: manga.id, inLibrary: next }).catch(console.error);
|
|
||||||
manga = { ...manga, inLibrary: next };
|
|
||||||
if (mangaStore.has(manga.id)) { const e = mangaStore.get(manga.id)!; mangaStore.set(manga.id, { ...e, data: manga }); }
|
|
||||||
cache.clear(CACHE_KEYS.LIBRARY);
|
|
||||||
togglingLibrary = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function reloadChapters(id: number) {
|
|
||||||
const d = await gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId: id });
|
|
||||||
chapterStore.set(id, { data: d.chapters.nodes, fetchedAt: Date.now() });
|
|
||||||
applyChapters(d.chapters.nodes);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function enqueue(ch: Chapter, e: MouseEvent) {
|
|
||||||
e.stopPropagation();
|
|
||||||
enqueueing = new Set(enqueueing).add(ch.id);
|
|
||||||
await gql(ENQUEUE_DOWNLOAD, { chapterId: ch.id }).catch(console.error);
|
|
||||||
addToast({ kind: "download", title: "Download queued", body: ch.name });
|
|
||||||
enqueueing.delete(ch.id); enqueueing = new Set(enqueueing);
|
|
||||||
if (store.activeManga) reloadChapters(store.activeManga.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function enqueueMultiple(chapterIds: number[]) {
|
|
||||||
if (!chapterIds.length) return;
|
|
||||||
await gql(ENQUEUE_CHAPTERS_DOWNLOAD, { chapterIds }).catch(console.error);
|
|
||||||
addToast({ kind: "download", title: "Download queued", body: `${chapterIds.length} chapter${chapterIds.length !== 1 ? "s" : ""} added` });
|
|
||||||
if (store.activeManga) reloadChapters(store.activeManga.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function markRead(chapterId: number, isRead: boolean) {
|
|
||||||
await gql(MARK_CHAPTER_READ, { id: chapterId, isRead }).catch(console.error);
|
|
||||||
chapters = chapters.map(c => c.id === chapterId ? { ...c, isRead } : c);
|
|
||||||
if (store.activeManga) { chapterStore.set(store.activeManga.id, { data: chapters, fetchedAt: Date.now() }); checkAndMarkCompleted(store.activeManga.id, chapters); }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function markBulk(ids: number[], isRead: boolean) {
|
|
||||||
if (!ids.length) return;
|
|
||||||
await gql(MARK_CHAPTERS_READ, { ids, isRead }).catch(console.error);
|
|
||||||
const idSet = new Set(ids);
|
|
||||||
chapters = chapters.map(c => idSet.has(c.id) ? { ...c, isRead } : c);
|
|
||||||
if (store.activeManga) { chapterStore.set(store.activeManga.id, { data: chapters, fetchedAt: Date.now() }); checkAndMarkCompleted(store.activeManga.id, chapters); }
|
|
||||||
}
|
|
||||||
|
|
||||||
const markAboveRead = (i: number) => markBulk(sortedChapters.slice(0, i + 1).filter(c => !c.isRead).map(c => c.id), true);
|
|
||||||
const markBelowRead = (i: number) => markBulk(sortedChapters.slice(i).filter(c => !c.isRead).map(c => c.id), true);
|
|
||||||
const markAboveUnread = (i: number) => markBulk(sortedChapters.slice(0, i + 1).filter(c => c.isRead).map(c => c.id), false);
|
|
||||||
const markBelowUnread = (i: number) => markBulk(sortedChapters.slice(i).filter(c => c.isRead).map(c => c.id), false);
|
|
||||||
|
|
||||||
async function deleteDownloaded(chapterId: number) {
|
|
||||||
await gql(DELETE_DOWNLOADED_CHAPTERS, { ids: [chapterId] }).catch(console.error);
|
|
||||||
chapters = chapters.map(c => c.id === chapterId ? { ...c, isDownloaded: false } : c);
|
|
||||||
if (store.activeManga) chapterStore.set(store.activeManga.id, { data: chapters, fetchedAt: Date.now() });
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteAllDownloads() {
|
|
||||||
const ids = chapters.filter(c => c.isDownloaded).map(c => c.id);
|
|
||||||
if (!ids.length) return;
|
|
||||||
deletingAll = true;
|
|
||||||
await gql(DELETE_DOWNLOADED_CHAPTERS, { ids }).catch(console.error);
|
|
||||||
chapters = chapters.map(c => ({ ...c, isDownloaded: false }));
|
|
||||||
if (store.activeManga) chapterStore.set(store.activeManga.id, { data: chapters, fetchedAt: Date.now() });
|
|
||||||
deletingAll = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function refreshChapters() {
|
|
||||||
if (!store.activeManga || refreshing) return;
|
|
||||||
refreshing = true;
|
|
||||||
chapterStore.delete(store.activeManga.id);
|
|
||||||
gql(FETCH_CHAPTERS, { mangaId: store.activeManga.id })
|
|
||||||
.then(() => reloadChapters(store.activeManga!.id))
|
|
||||||
.then(() => addToast({ kind: "success", title: "Chapters refreshed" }))
|
|
||||||
.catch(e => addToast({ kind: "error", title: "Refresh failed", body: e?.message }))
|
|
||||||
.finally(() => refreshing = false);
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildCtxItems(ch: Chapter, idx: number): MenuEntry[] {
|
|
||||||
const above = sortedChapters.slice(0, idx + 1), below = sortedChapters.slice(idx), last = sortedChapters.length - 1;
|
|
||||||
return [
|
|
||||||
{ label: ch.isRead ? "Mark as unread" : "Mark as read", icon: ch.isRead ? Circle : CheckCircle, onClick: () => markRead(ch.id, !ch.isRead) },
|
|
||||||
{ separator: true },
|
|
||||||
{ label: "Mark above as read", icon: CheckCircle, onClick: () => markAboveRead(idx), disabled: above.filter(c => !c.isRead).length === 0 },
|
|
||||||
{ label: "Mark above as unread", icon: Circle, onClick: () => markAboveUnread(idx), disabled: above.filter(c => c.isRead).length === 0 },
|
|
||||||
{ separator: true },
|
|
||||||
{ label: "Mark below as read", icon: CheckCircle, onClick: () => markBelowRead(idx), disabled: idx === last || below.filter(c => !c.isRead).length === 0 },
|
|
||||||
{ label: "Mark below as unread", icon: Circle, onClick: () => markBelowUnread(idx), disabled: idx === last || below.filter(c => c.isRead).length === 0 },
|
|
||||||
{ separator: true },
|
|
||||||
{ label: ch.isDownloaded ? "Delete download" : "Download", icon: ch.isDownloaded ? Trash : Download, danger: ch.isDownloaded, onClick: () => ch.isDownloaded ? deleteDownloaded(ch.id) : gql(ENQUEUE_DOWNLOAD, { chapterId: ch.id }).catch(console.error) },
|
|
||||||
{ separator: true },
|
|
||||||
{ label: "Download next 5 from here", icon: DownloadSimple, onClick: () => enqueueMultiple(sortedChapters.slice(idx, idx + 5).filter(c => !c.isDownloaded).map(c => c.id)) },
|
|
||||||
{ label: "Download all from here", icon: DownloadSimple, onClick: () => enqueueMultiple(sortedChapters.slice(idx).filter(c => !c.isDownloaded).map(c => c.id)) },
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleDlOutside(e: MouseEvent) { if (dlDropRef && !dlDropRef.contains(e.target as Node)) dlOpen = false; }
|
|
||||||
function handleFolderOutside(e: MouseEvent) { if (folderPickerRef && !folderPickerRef.contains(e.target as Node)) { folderPickerOpen = false; folderCreating = false; folderNewName = ""; } }
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (dlOpen) { setTimeout(() => document.addEventListener("mousedown", handleDlOutside, true), 0); }
|
|
||||||
else document.removeEventListener("mousedown", handleDlOutside, true);
|
|
||||||
});
|
|
||||||
$effect(() => {
|
|
||||||
if (folderPickerOpen) { setTimeout(() => document.addEventListener("mousedown", handleFolderOutside, true), 0); }
|
|
||||||
else document.removeEventListener("mousedown", handleFolderOutside, true);
|
|
||||||
});
|
|
||||||
|
|
||||||
function enqueueNext(n: number) {
|
|
||||||
if (!continueChapter) return;
|
|
||||||
const idx = sortedChapters.indexOf(continueChapter.chapter);
|
|
||||||
if (idx < 0) return;
|
|
||||||
enqueueMultiple(sortedChapters.slice(idx, idx + n).filter(c => !c.isDownloaded).map(c => c.id));
|
|
||||||
}
|
|
||||||
|
|
||||||
function enqueueRange() {
|
|
||||||
const from = parseFloat(rangeFrom), to = parseFloat(rangeTo);
|
|
||||||
if (isNaN(from) || isNaN(to)) return;
|
|
||||||
const lo = Math.min(from, to), hi = Math.max(from, to);
|
|
||||||
enqueueMultiple(sortedChapters.filter(c => c.chapterNumber >= lo && c.chapterNumber <= hi && !c.isDownloaded).map(c => c.id));
|
|
||||||
}
|
|
||||||
|
|
||||||
function createFolder() {
|
|
||||||
const name = folderNewName.trim();
|
|
||||||
if (!name || !store.activeManga) return;
|
|
||||||
const id = addFolder(name);
|
|
||||||
assignMangaToFolder(id, store.activeManga.id);
|
|
||||||
folderNewName = ""; folderCreating = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
onMount(() => () => { mangaAbort?.abort(); chapterAbort?.abort(); });
|
|
||||||
|
|
||||||
// ── Series link ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const linkedIds = $derived(
|
|
||||||
store.activeManga ? (store.settings.mangaLinks?.[store.activeManga.id] ?? []) : []
|
|
||||||
);
|
|
||||||
|
|
||||||
const linkPickerResults = $derived.by(() => {
|
|
||||||
const id = store.activeManga?.id;
|
|
||||||
const others = allMangaForLink.filter(m => m.id !== id);
|
|
||||||
const q = linkSearch.trim().toLowerCase();
|
|
||||||
const filtered = q ? others.filter(m => m.title.toLowerCase().includes(q)) : others;
|
|
||||||
const linked = filtered.filter(m => linkedIds.includes(m.id));
|
|
||||||
const rest = filtered.filter(m => !linkedIds.includes(m.id)).slice(0, 30);
|
|
||||||
return [...linked, ...rest];
|
|
||||||
});
|
|
||||||
|
|
||||||
async function openLinkPicker() {
|
|
||||||
linkPickerOpen = true; linkSearch = "";
|
|
||||||
if (allMangaForLink.length) return;
|
|
||||||
loadingLinkList = true;
|
|
||||||
gql<{ mangas: { nodes: Manga[] } }>(GET_ALL_MANGA)
|
|
||||||
.then(d => { allMangaForLink = d.mangas.nodes; })
|
|
||||||
.catch(console.error)
|
|
||||||
.finally(() => { loadingLinkList = false; });
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeLinkPicker() { linkPickerOpen = false; linkSearch = ""; }
|
|
||||||
|
|
||||||
function handleLink(other: Manga) {
|
|
||||||
if (!store.activeManga) return;
|
|
||||||
if (linkedIds.includes(other.id)) unlinkManga(store.activeManga.id, other.id);
|
|
||||||
else linkManga(store.activeManga.id, other.id);
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#if store.activeManga}
|
|
||||||
<div class="root" role="presentation" oncontextmenu={(e) => e.preventDefault()}>
|
|
||||||
|
|
||||||
<!-- ── Sidebar ────────────────────────────────────────────────────────── -->
|
|
||||||
<div class="sidebar">
|
|
||||||
<button class="back" onclick={() => setActiveManga(null)}>
|
|
||||||
<ArrowLeft size={13} weight="light" /> Back
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- Zone 1: Cover -->
|
|
||||||
<div class="cover-wrap">
|
|
||||||
<img src={thumbUrl(store.activeManga.thumbnailUrl)} alt={store.activeManga.title} class="cover" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Zone 2: Meta -->
|
|
||||||
{#if loadingManga}
|
|
||||||
<div class="meta-skeleton">
|
|
||||||
<div class="skeleton sk-line" style="width:90%;height:14px"></div>
|
|
||||||
<div class="skeleton sk-line" style="width:60%;height:11px"></div>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div class="meta">
|
|
||||||
<p class="title">{manga?.title}</p>
|
|
||||||
{#if manga?.author || manga?.artist}
|
|
||||||
<p class="byline">{[manga?.author, manga?.artist].filter(Boolean).filter((v, i, a) => a.indexOf(v) === i).join(" · ")}</p>
|
|
||||||
{/if}
|
|
||||||
{#if statusLabel}
|
|
||||||
<span class="status-badge" class:ongoing={manga?.status === "ONGOING"} class:ended={manga?.status !== "ONGOING"}>{statusLabel}</span>
|
|
||||||
{/if}
|
|
||||||
{#if manga?.genre?.length}
|
|
||||||
<div class="genres">
|
|
||||||
{#each (genresExpanded ? manga.genre : manga.genre.slice(0, 3)) as g}
|
|
||||||
<button class="genre" onclick={() => { setGenreFilter(g); setNavPage("explore"); setActiveManga(null); }}>{g}</button>
|
|
||||||
{/each}
|
|
||||||
{#if manga.genre.length > 3}
|
|
||||||
<button class="genre-toggle" onclick={() => genresExpanded = !genresExpanded}>
|
|
||||||
{genresExpanded ? "less" : `+${manga.genre.length - 3}`}
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{#if manga?.description}
|
|
||||||
<p class="desc">{manga.description}</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Zone 3: Primary CTA + library action -->
|
|
||||||
<div class="cta-section">
|
|
||||||
{#if continueChapter}
|
|
||||||
<button class="read-btn" onclick={() => openReader(continueChapter!.chapter, sortedChapters)}>
|
|
||||||
<Play size={12} weight="fill" />
|
|
||||||
{continueChapter.type === "continue"
|
|
||||||
? `Continue · Ch.${continueChapter.chapter.chapterNumber}${(continueChapter.chapter.lastPageRead ?? 0) > 0 ? ` p.${continueChapter.chapter.lastPageRead}` : ""}`
|
|
||||||
: continueChapter.type === "reread" ? "Read again" : "Start reading"}
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
<div class="actions">
|
|
||||||
<button class="library-btn" class:active={manga?.inLibrary} onclick={toggleLibrary} disabled={togglingLibrary || loadingManga}>
|
|
||||||
<BookmarkSimple size={13} weight={manga?.inLibrary ? "fill" : "light"} />
|
|
||||||
{manga?.inLibrary ? "In Library" : "Add to Library"}
|
|
||||||
</button>
|
|
||||||
{#if manga?.realUrl}
|
|
||||||
<a href={manga.realUrl} target="_blank" rel="noreferrer" class="external-link">
|
|
||||||
<ArrowSquareOut size={13} weight="light" />
|
|
||||||
</a>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Zone 4: Progress -->
|
|
||||||
{#if totalCount > 0}
|
|
||||||
<div class="progress-section">
|
|
||||||
<div class="progress-header">
|
|
||||||
<span class="progress-label">{readCount} / {totalCount} read</span>
|
|
||||||
<span class="progress-pct">{Math.round(progressPct)}%</span>
|
|
||||||
</div>
|
|
||||||
<div class="progress-track"><div class="progress-fill" style="width:{progressPct}%"></div></div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Zone 5: Details accordion (source info + all secondary actions) -->
|
|
||||||
{#if !loadingManga && manga?.source}
|
|
||||||
<div class="details-section">
|
|
||||||
<button class="details-toggle" onclick={() => detailsOpen = !detailsOpen}>
|
|
||||||
<span>Details</span>
|
|
||||||
<CaretDown size={11} weight="light" style="transform:{detailsOpen ? 'rotate(180deg)' : 'rotate(0)'};transition:transform 0.15s ease" />
|
|
||||||
</button>
|
|
||||||
{#if detailsOpen}
|
|
||||||
<div class="details-body">
|
|
||||||
<div class="detail-row"><span class="detail-key">Source</span><span class="detail-val">{manga.source.displayName}</span></div>
|
|
||||||
{#if manga.status}<div class="detail-row"><span class="detail-key">Status</span><span class="detail-val">{statusLabel}</span></div>{/if}
|
|
||||||
{#if manga.author}<div class="detail-row"><span class="detail-key">Author</span><span class="detail-val">{manga.author}</span></div>{/if}
|
|
||||||
{#if manga.artist && manga.artist !== manga.author}<div class="detail-row"><span class="detail-key">Artist</span><span class="detail-val">{manga.artist}</span></div>{/if}
|
|
||||||
|
|
||||||
<div class="detail-actions">
|
|
||||||
<button class="detail-action-btn" onclick={() => migrateOpen = true}>
|
|
||||||
<ArrowsClockwise size={12} weight="light" /> Switch Source
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="detail-action-btn"
|
|
||||||
class:detail-action-active={linkedIds.length > 0}
|
|
||||||
onclick={openLinkPicker}
|
|
||||||
>
|
|
||||||
<LinkSimpleHorizontalBreak size={12} weight={linkedIds.length > 0 ? "fill" : "light"} />
|
|
||||||
Series Link{linkedIds.length > 0 ? ` (${linkedIds.length})` : ""}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="detail-action-btn"
|
|
||||||
onclick={() => trackingOpen = true}
|
|
||||||
>
|
|
||||||
<ChartLineUp size={12} weight="light" /> Tracking
|
|
||||||
</button>
|
|
||||||
{#if downloadedCount > 0}
|
|
||||||
<button class="detail-action-btn detail-action-danger" onclick={deleteAllDownloads} disabled={deletingAll}>
|
|
||||||
<Trash size={12} weight="light" /> {deletingAll ? "Deleting…" : `Delete Downloads (${downloadedCount})`}
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- ── Chapter list ───────────────────────────────────────────────────── -->
|
|
||||||
<div class="list-wrap">
|
|
||||||
<div class="list-header">
|
|
||||||
<div class="list-header-left">
|
|
||||||
<div class="sort-wrap">
|
|
||||||
<button class="sort-btn" onclick={() => sortMenuOpen = !sortMenuOpen}>
|
|
||||||
{#if sortDir === "desc"}<SortDescending size={14} weight="light" />{:else}<SortAscending size={14} weight="light" />{/if}
|
|
||||||
{{ source: "Source order", chapterNumber: "Ch. number", uploadDate: "Upload date" }[sortMode]}
|
|
||||||
<CaretDown size={10} weight="light" />
|
|
||||||
</button>
|
|
||||||
{#if sortMenuOpen}
|
|
||||||
<div class="sort-menu" role="presentation" onmouseleave={() => sortMenuOpen = false}>
|
|
||||||
{#each [["source","Source order"],["chapterNumber","Chapter number"],["uploadDate","Upload date"]] as [val, label]}
|
|
||||||
<button class="sort-option" class:active={sortMode === val}
|
|
||||||
onclick={() => { updateSettings({ chapterSortMode: val as any }); chapterPage = 1; sortMenuOpen = false; }}>
|
|
||||||
{label}
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
<div class="sort-divider"></div>
|
|
||||||
<button class="sort-option" onclick={() => { updateSettings({ chapterSortDir: sortDir === "desc" ? "asc" : "desc" }); chapterPage = 1; sortMenuOpen = false; }}>
|
|
||||||
{sortDir === "desc" ? "↑ Ascending" : "↓ Descending"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<!-- View toggle: icon reflects current state -->
|
|
||||||
<button class="icon-btn" class:active={viewMode === "grid"} onclick={() => viewMode = viewMode === "list" ? "grid" : "list"} title={viewMode === "list" ? "Grid view" : "List view"}>
|
|
||||||
{#if viewMode === "list"}<SquaresFour size={14} weight="light" />{:else}<List size={14} weight="light" />{/if}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="list-header-right">
|
|
||||||
<button class="icon-btn" onclick={refreshChapters} 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"} />
|
|
||||||
</button>
|
|
||||||
{#if folderPickerOpen}
|
|
||||||
<div class="fp-menu">
|
|
||||||
{#if store.settings.folders.length === 0 && !folderCreating}
|
|
||||||
<p class="fp-empty">No folders yet</p>
|
|
||||||
{/if}
|
|
||||||
{#each store.settings.folders as folder}
|
|
||||||
{@const isIn = store.activeManga ? folder.mangaIds.includes(store.activeManga.id) : false}
|
|
||||||
<button class="fp-item" class:fp-item-active={isIn}
|
|
||||||
onclick={() => store.activeManga && (isIn ? removeMangaFromFolder(folder.id, store.activeManga.id) : assignMangaToFolder(folder.id, store.activeManga.id))}>
|
|
||||||
<span class="fp-check">{isIn ? "✓" : ""}</span>{folder.name}
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
<div class="fp-div"></div>
|
|
||||||
{#if folderCreating}
|
|
||||||
<div class="fp-create">
|
|
||||||
<input class="fp-input" placeholder="Folder name…" bind:value={folderNewName}
|
|
||||||
onkeydown={(e) => { if (e.key === "Enter") createFolder(); if (e.key === "Escape") { folderCreating = false; folderNewName = ""; } }} use:focusOnMount />
|
|
||||||
<button class="fp-confirm" onclick={createFolder} disabled={!folderNewName.trim()}>Add</button>
|
|
||||||
<button class="fp-cancel" onclick={() => { folderCreating = false; folderNewName = ""; }}>
|
|
||||||
<X size={12} weight="light" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<button class="fp-new" onclick={() => folderCreating = true}>+ New folder</button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Download dropdown -->
|
|
||||||
{#if chapters.length > 0}
|
|
||||||
<div class="dl-wrap" bind:this={dlDropRef}>
|
|
||||||
<button class="icon-btn" onclick={() => dlOpen = !dlOpen}>
|
|
||||||
<Download size={13} weight="light" />
|
|
||||||
</button>
|
|
||||||
{#if dlOpen}
|
|
||||||
<div class="dl-dropdown">
|
|
||||||
{#if continueChapter}
|
|
||||||
{@const contIdx = sortedChapters.indexOf(continueChapter.chapter)}
|
|
||||||
{#if contIdx >= 0}
|
|
||||||
<p class="dl-section-label">From Ch.{continueChapter.chapter.chapterNumber}</p>
|
|
||||||
<div class="dl-next-row">
|
|
||||||
{#each [5, 10, 25] as n}
|
|
||||||
{@const avail = sortedChapters.slice(contIdx, contIdx + n).filter(c => !c.isDownloaded).length}
|
|
||||||
<button class="dl-next-btn" disabled={avail === 0} onclick={() => { enqueueNext(n); dlOpen = false; }}>
|
|
||||||
<span>Next {n}</span><span class="dl-next-sub">{avail} new</span>
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
<div class="dl-divider"></div>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
{#if !showRange}
|
|
||||||
<button class="dl-item" onclick={() => showRange = true}>
|
|
||||||
<span>Custom range…</span><span class="dl-item-sub">Enter chapter numbers</span>
|
|
||||||
</button>
|
|
||||||
{:else}
|
|
||||||
<div class="dl-range-row">
|
|
||||||
<button class="dl-range-back" onclick={() => showRange = false}>‹</button>
|
|
||||||
<input class="dl-range-input" placeholder="From" bind:value={rangeFrom} onkeydown={(e) => e.key === "Enter" && enqueueRange()} use:focusOnMount />
|
|
||||||
<span class="dl-range-sep">–</span>
|
|
||||||
<input class="dl-range-input" placeholder="To" bind:value={rangeTo} onkeydown={(e) => e.key === "Enter" && enqueueRange()} />
|
|
||||||
<button class="dl-range-go" disabled={!rangeFrom.trim() || !rangeTo.trim()} onclick={enqueueRange}>Go</button>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
<div class="dl-divider"></div>
|
|
||||||
<button class="dl-item" onclick={() => { enqueueMultiple(sortedChapters.filter(c => !c.isRead && !c.isDownloaded).map(c => c.id)); dlOpen = false; }}>
|
|
||||||
<span>Unread chapters</span><span class="dl-item-sub">{sortedChapters.filter(c => !c.isRead && !c.isDownloaded).length} remaining</span>
|
|
||||||
</button>
|
|
||||||
<button class="dl-item" onclick={() => { enqueueMultiple(sortedChapters.filter(c => !c.isDownloaded).map(c => c.id)); dlOpen = false; }}>
|
|
||||||
<span>Download all</span><span class="dl-item-sub">{sortedChapters.filter(c => !c.isDownloaded).length} not downloaded</span>
|
|
||||||
</button>
|
|
||||||
{#if downloadedCount > 0}
|
|
||||||
<div class="dl-divider"></div>
|
|
||||||
<button class="dl-item dl-item-danger" onclick={() => { deleteAllDownloads(); dlOpen = false; }} disabled={deletingAll}>
|
|
||||||
<span>{deletingAll ? "Deleting…" : "Delete all downloads"}</span>
|
|
||||||
<span class="dl-item-sub">{downloadedCount} downloaded</span>
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if totalPages > 1}
|
|
||||||
<div class="pagination">
|
|
||||||
<button class="page-btn" onclick={() => chapterPage = Math.max(1, chapterPage - 1)} disabled={chapterPage === 1}>←</button>
|
|
||||||
<span class="page-num">{chapterPage} / {totalPages}</span>
|
|
||||||
<button class="page-btn" onclick={() => chapterPage = Math.min(totalPages, chapterPage + 1)} disabled={chapterPage === totalPages}>→</button>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class={viewMode === "grid" ? "ch-grid" : "ch-list"}>
|
|
||||||
{#if loadingChapters && chapters.length === 0}
|
|
||||||
{#if viewMode === "grid"}
|
|
||||||
{#each Array(24) as _}<div class="grid-cell-skeleton skeleton"></div>{/each}
|
|
||||||
{:else}
|
|
||||||
{#each Array(8) as _}<div class="row-skeleton"><div class="skeleton sk-line" style="width:55%;height:12px"></div><div class="skeleton sk-line" style="width:25%;height:11px"></div></div>{/each}
|
|
||||||
{/if}
|
|
||||||
{:else if viewMode === "grid"}
|
|
||||||
{#each sortedChapters as ch, i}
|
|
||||||
{@const inProgress = !ch.isRead && (ch.lastPageRead ?? 0) > 0}
|
|
||||||
<button class="grid-cell" class:read={ch.isRead} class:in-progress={inProgress}
|
|
||||||
onclick={() => openReader(ch, sortedChapters)}
|
|
||||||
oncontextmenu={(e) => { e.preventDefault(); ctx = { x: e.clientX, y: e.clientY, chapter: ch, idx: i }; }}
|
|
||||||
title={ch.name}>
|
|
||||||
<span class="grid-cell-num">{ch.chapterNumber % 1 === 0 ? ch.chapterNumber.toFixed(0) : ch.chapterNumber}</span>
|
|
||||||
{#if ch.isRead}<span class="grid-cell-dot"></span>{/if}
|
|
||||||
{#if enqueueing.has(ch.id)}<span class="grid-cell-spinner"><CircleNotch size={10} weight="light" class="anim-spin" /></span>{/if}
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
{:else}
|
|
||||||
{#each pageChapters as ch}
|
|
||||||
{@const idxInSorted = sortedChapters.indexOf(ch)}
|
|
||||||
<div role="button" tabindex="0" class="ch-row" class:read={ch.isRead}
|
|
||||||
onclick={() => openReader(ch, sortedChapters)}
|
|
||||||
onkeydown={(e) => e.key === "Enter" && openReader(ch, sortedChapters)}
|
|
||||||
oncontextmenu={(e) => { e.preventDefault(); ctx = { x: e.clientX, y: e.clientY, chapter: ch, idx: idxInSorted }; }}>
|
|
||||||
<div class="ch-left">
|
|
||||||
<span class="ch-name">{ch.name}</span>
|
|
||||||
<div class="ch-meta">
|
|
||||||
{#if ch.scanlator}<span class="ch-meta-item">{ch.scanlator}</span>{/if}
|
|
||||||
{#if ch.uploadDate}<span class="ch-meta-item">{formatDate(ch.uploadDate)}</span>{/if}
|
|
||||||
{#if ch.lastPageRead && ch.lastPageRead > 0 && !ch.isRead}<span class="ch-meta-item">p.{ch.lastPageRead}</span>{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="ch-right">
|
|
||||||
{#if ch.isRead}<CheckCircle size={14} weight="light" class="read-icon" />{/if}
|
|
||||||
{#if ch.isDownloaded}
|
|
||||||
<button class="dl-btn" onclick={(e) => { e.stopPropagation(); deleteDownloaded(ch.id); }}><Trash size={13} weight="light" /></button>
|
|
||||||
{:else if enqueueing.has(ch.id)}
|
|
||||||
<CircleNotch size={14} weight="light" class="anim-spin enqueue-icon" />
|
|
||||||
{:else}
|
|
||||||
<button class="dl-btn" onclick={(e) => { e.stopPropagation(); enqueue(ch, e); }}><Download size={13} weight="light" /></button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if totalPages > 1}
|
|
||||||
<div class="pagination-bottom">
|
|
||||||
<button class="page-btn" onclick={() => chapterPage = Math.max(1, chapterPage - 1)} disabled={chapterPage === 1}>← Prev</button>
|
|
||||||
<span class="page-num">{chapterPage} / {totalPages}</span>
|
|
||||||
<button class="page-btn" onclick={() => chapterPage = Math.min(totalPages, chapterPage + 1)} disabled={chapterPage === totalPages}>Next →</button>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if ctx}
|
|
||||||
<ContextMenu x={ctx.x} y={ctx.y} items={buildCtxItems(ctx.chapter, ctx.idx)} onClose={() => ctx = null} />
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if migrateOpen && manga}
|
|
||||||
<MigrateModal
|
|
||||||
{manga}
|
|
||||||
currentChapters={chapters}
|
|
||||||
onClose={() => migrateOpen = false}
|
|
||||||
onMigrated={(newManga) => { setActiveManga(newManga); migrateOpen = false; cache.clear(CACHE_KEYS.LIBRARY); }}
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if trackingOpen && store.activeManga}
|
|
||||||
<TrackingPanel
|
|
||||||
mangaId={store.activeManga.id}
|
|
||||||
mangaTitle={store.activeManga.title}
|
|
||||||
onClose={() => trackingOpen = false}
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if linkPickerOpen}
|
|
||||||
<div
|
|
||||||
class="link-backdrop"
|
|
||||||
role="presentation"
|
|
||||||
onclick={(e) => { if (e.target === e.currentTarget) closeLinkPicker(); }}
|
|
||||||
onkeydown={(e) => e.key === "Escape" && closeLinkPicker()}
|
|
||||||
>
|
|
||||||
<div class="link-modal">
|
|
||||||
<div class="link-header">
|
|
||||||
<span class="link-title">Link as same series</span>
|
|
||||||
<button class="link-close" onclick={closeLinkPicker}><X size={14} weight="light" /></button>
|
|
||||||
</div>
|
|
||||||
<p class="link-hint">Mark two manga as the same series so duplicates are merged in search and discover. Click a linked entry again to unlink.</p>
|
|
||||||
<div class="link-search-wrap">
|
|
||||||
<input class="link-search" placeholder="Search your library…" bind:value={linkSearch} use:focusOnMount />
|
|
||||||
</div>
|
|
||||||
<div class="link-list">
|
|
||||||
{#if loadingLinkList}
|
|
||||||
<p class="link-empty">Loading…</p>
|
|
||||||
{:else if linkPickerResults.length === 0}
|
|
||||||
<p class="link-empty">No results</p>
|
|
||||||
{:else}
|
|
||||||
{#each linkPickerResults as m (m.id)}
|
|
||||||
{@const isLinked = linkedIds.includes(m.id)}
|
|
||||||
<button class="link-row" class:link-row-linked={isLinked} onclick={() => handleLink(m)}>
|
|
||||||
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} class="link-thumb" loading="lazy" decoding="async" />
|
|
||||||
<div class="link-info">
|
|
||||||
<span class="link-manga-title">{m.title}</span>
|
|
||||||
{#if m.source?.displayName}<span class="link-source">{m.source.displayName}</span>{/if}
|
|
||||||
</div>
|
|
||||||
<span class="link-status">{isLinked ? "✓ Linked" : "Link"}</span>
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.root { display: flex; height: 100%; overflow: hidden; animation: fadeIn 0.14s ease both; }
|
|
||||||
|
|
||||||
/* ── Sidebar ─────────────────────────────────────────────────────────────── */
|
|
||||||
.sidebar { width: 240px; flex-shrink: 0; padding: var(--sp-5); border-right: 1px solid var(--border-dim); overflow-y: auto; display: flex; flex-direction: column; gap: var(--sp-4); background: var(--bg-base); }
|
|
||||||
.back { display: flex; align-items: center; gap: var(--sp-2); color: var(--text-muted); font-size: var(--text-xs); font-family: var(--font-ui); letter-spacing: var(--tracking-wide); text-transform: uppercase; transition: color var(--t-base); }
|
|
||||||
.back:hover { color: var(--text-secondary); }
|
|
||||||
|
|
||||||
/* Zone 1: Cover */
|
|
||||||
.cover-wrap { width: 100%; aspect-ratio: 2/3; border-radius: var(--radius-md); overflow: hidden; background: var(--bg-raised); border: 1px solid var(--border-dim); flex-shrink: 0; }
|
|
||||||
.cover { width: 100%; height: 100%; object-fit: cover; }
|
|
||||||
|
|
||||||
/* Zone 2: Meta */
|
|
||||||
.meta-skeleton { display: flex; flex-direction: column; gap: var(--sp-2); }
|
|
||||||
.sk-line { border-radius: var(--radius-sm); }
|
|
||||||
.meta { display: flex; flex-direction: column; gap: var(--sp-3); }
|
|
||||||
.title { font-size: var(--text-base); font-weight: var(--weight-medium); color: var(--text-primary); line-height: var(--leading-snug); letter-spacing: var(--tracking-tight); }
|
|
||||||
.byline { font-size: var(--text-xs); color: var(--text-muted); font-family: var(--font-ui); }
|
|
||||||
.status-badge { display: inline-block; font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wider); text-transform: uppercase; padding: 2px 7px; border-radius: var(--radius-sm); width: fit-content; }
|
|
||||||
.status-badge.ongoing { background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim); }
|
|
||||||
.status-badge.ended { background: var(--bg-raised); color: var(--text-faint); border: 1px solid var(--border-dim); }
|
|
||||||
.genres { display: flex; flex-wrap: wrap; gap: var(--sp-1); }
|
|
||||||
.genre { font-size: var(--text-2xs); font-family: var(--font-ui); color: var(--text-faint); background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-sm); padding: 1px 6px; letter-spacing: var(--tracking-wide); cursor: pointer; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
|
|
||||||
.genre:hover { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
|
|
||||||
.genre-toggle { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-sm); padding: 1px 6px; letter-spacing: var(--tracking-wide); cursor: pointer; transition: color var(--t-base), border-color var(--t-base); }
|
|
||||||
.genre-toggle:hover { color: var(--accent-fg); border-color: var(--accent-dim); }
|
|
||||||
/* Description clamped — no expand in 240px sidebar */
|
|
||||||
.desc { font-size: var(--text-xs); color: var(--text-muted); line-height: var(--leading-base); display: -webkit-box; -webkit-line-clamp: 4; -webkit-box-orient: vertical; overflow: hidden; }
|
|
||||||
|
|
||||||
/* Zone 3: CTA */
|
|
||||||
.cta-section { display: flex; flex-direction: column; gap: var(--sp-2); }
|
|
||||||
.read-btn { display: flex; align-items: center; justify-content: center; gap: var(--sp-2); width: 100%; padding: 9px var(--sp-3); border-radius: var(--radius-md); background: var(--accent); border: 1px solid var(--accent); color: var(--accent-contrast, #fff); font-size: var(--text-xs); font-family: var(--font-ui); letter-spacing: var(--tracking-wide); cursor: pointer; transition: opacity var(--t-base); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
||||||
.read-btn:hover { opacity: 0.88; }
|
|
||||||
.actions { display: flex; align-items: center; gap: var(--sp-2); }
|
|
||||||
.library-btn { display: flex; align-items: center; gap: var(--sp-2); font-size: var(--text-xs); font-family: var(--font-ui); letter-spacing: var(--tracking-wide); padding: 5px 10px; border-radius: var(--radius-md); border: 1px solid var(--border-strong); color: var(--text-muted); background: var(--bg-raised); transition: border-color var(--t-base), color var(--t-base), background var(--t-base); flex: 1; }
|
|
||||||
.library-btn:hover { border-color: var(--accent); color: var(--accent-fg); }
|
|
||||||
.library-btn.active { background: var(--accent-muted); border-color: var(--accent-dim); color: var(--accent-fg); }
|
|
||||||
.library-btn:disabled { opacity: 0.4; cursor: default; }
|
|
||||||
.external-link { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); color: var(--text-faint); flex-shrink: 0; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
|
|
||||||
.external-link:hover { color: var(--text-muted); border-color: var(--border-strong); }
|
|
||||||
|
|
||||||
/* Zone 4: Progress */
|
|
||||||
.progress-section { display: flex; flex-direction: column; gap: var(--sp-1); }
|
|
||||||
.progress-header { display: flex; justify-content: space-between; align-items: center; }
|
|
||||||
.progress-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
|
||||||
.progress-pct { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--accent-fg); letter-spacing: var(--tracking-wide); }
|
|
||||||
.progress-track { height: 3px; background: var(--border-base); border-radius: var(--radius-full); overflow: hidden; }
|
|
||||||
.progress-fill { height: 100%; background: var(--accent); border-radius: var(--radius-full); transition: width 0.4s ease; }
|
|
||||||
|
|
||||||
/* Zone 5: Details accordion */
|
|
||||||
.details-section { display: flex; flex-direction: column; gap: 2px; }
|
|
||||||
.details-toggle { display: flex; align-items: center; justify-content: space-between; font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); padding: 4px 0; transition: color var(--t-base); }
|
|
||||||
.details-toggle:hover { color: var(--text-muted); }
|
|
||||||
.details-body { display: flex; flex-direction: column; gap: var(--sp-2); padding-top: var(--sp-2); }
|
|
||||||
.detail-row { display: flex; justify-content: space-between; align-items: baseline; gap: var(--sp-2); }
|
|
||||||
.detail-key { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
|
||||||
.detail-val { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-secondary); letter-spacing: var(--tracking-wide); text-align: right; }
|
|
||||||
.detail-actions { display: flex; flex-direction: column; gap: var(--sp-1); padding-top: var(--sp-1); }
|
|
||||||
.detail-action-btn { display: flex; align-items: center; gap: var(--sp-2); width: 100%; padding: 6px var(--sp-2); border-radius: var(--radius-md); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); color: var(--text-faint); background: none; border: 1px solid var(--border-dim); cursor: pointer; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
|
|
||||||
.detail-action-btn:hover { color: var(--text-muted); border-color: var(--border-strong); background: var(--bg-raised); }
|
|
||||||
.detail-action-active { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
|
|
||||||
.detail-action-active:hover { color: var(--accent-fg); border-color: var(--accent); }
|
|
||||||
.detail-action-danger { color: var(--color-error); }
|
|
||||||
.detail-action-danger:hover:not(:disabled) { background: var(--color-error-bg); border-color: var(--color-error); color: var(--color-error); }
|
|
||||||
.detail-action-danger:disabled { opacity: 0.4; cursor: default; }
|
|
||||||
|
|
||||||
/* ── Series link modal ───────────────────────────────────────────────────── */
|
|
||||||
.link-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.72); z-index: var(--z-settings); display: flex; align-items: center; justify-content: center; backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px); animation: fadeIn 0.12s ease both; }
|
|
||||||
.link-modal { width: min(460px, calc(100vw - 48px)); max-height: 70vh; display: flex; flex-direction: column; background: var(--bg-surface); border: 1px solid var(--border-base); border-radius: var(--radius-xl); overflow: hidden; box-shadow: 0 24px 64px rgba(0,0,0,0.6); animation: scaleIn 0.14s ease both; }
|
|
||||||
.link-header { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-4) var(--sp-5); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
|
|
||||||
.link-title { font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-secondary); }
|
|
||||||
.link-close { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: var(--radius-sm); color: var(--text-faint); background: none; border: none; cursor: pointer; transition: color var(--t-base), background var(--t-base); }
|
|
||||||
.link-close:hover { color: var(--text-muted); background: var(--bg-raised); }
|
|
||||||
.link-hint { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); line-height: var(--leading-snug); padding: var(--sp-3) var(--sp-5) 0; flex-shrink: 0; }
|
|
||||||
.link-search-wrap { padding: var(--sp-3) var(--sp-4); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
|
|
||||||
.link-search { width: 100%; background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 6px 10px; color: var(--text-primary); font-size: var(--text-sm); outline: none; transition: border-color var(--t-base); }
|
|
||||||
.link-search:focus { border-color: var(--border-strong); }
|
|
||||||
.link-list { flex: 1; overflow-y: auto; padding: var(--sp-2); scrollbar-width: none; }
|
|
||||||
.link-list::-webkit-scrollbar { display: none; }
|
|
||||||
.link-empty { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); padding: var(--sp-4) var(--sp-3); text-align: center; letter-spacing: var(--tracking-wide); }
|
|
||||||
.link-row { display: flex; align-items: center; gap: var(--sp-3); width: 100%; padding: 8px var(--sp-3); border-radius: var(--radius-md); border: none; background: none; text-align: left; cursor: pointer; transition: background var(--t-fast); }
|
|
||||||
.link-row:hover { background: var(--bg-raised); }
|
|
||||||
.link-row-linked { background: var(--accent-muted) !important; }
|
|
||||||
.link-thumb { width: 34px; height: 48px; border-radius: var(--radius-sm); object-fit: cover; flex-shrink: 0; border: 1px solid var(--border-dim); }
|
|
||||||
.link-info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; min-width: 0; }
|
|
||||||
.link-manga-title { font-size: var(--text-sm); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
||||||
.link-source { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
|
||||||
.link-status { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--text-faint); flex-shrink: 0; padding: 2px 8px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); }
|
|
||||||
.link-row-linked .link-status { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
|
|
||||||
|
|
||||||
/* ── Chapter list ────────────────────────────────────────────────────────── */
|
|
||||||
.list-wrap { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
|
|
||||||
.list-header { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-3) var(--sp-4); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; gap: var(--sp-2); flex-wrap: wrap; }
|
|
||||||
.list-header-left, .list-header-right { display: flex; align-items: center; gap: var(--sp-1); }
|
|
||||||
.sort-btn { display: flex; align-items: center; gap: 5px; font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 4px var(--sp-2); border-radius: var(--radius-sm); color: var(--text-muted); transition: color var(--t-base), background var(--t-base); }
|
|
||||||
.sort-btn:hover { color: var(--text-secondary); background: var(--bg-raised); }
|
|
||||||
.sort-wrap { position: relative; }
|
|
||||||
.sort-menu { position: absolute; top: calc(100% + 4px); left: 0; min-width: 160px; background: var(--bg-raised); border: 1px solid var(--border-base); border-radius: var(--radius-md); padding: var(--sp-1); z-index: 200; box-shadow: 0 8px 24px rgba(0,0,0,0.5); animation: scaleIn 0.1s ease both; transform-origin: top left; }
|
|
||||||
.sort-option { display: block; width: 100%; padding: 6px var(--sp-3); border-radius: var(--radius-sm); font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-secondary); background: none; border: none; cursor: pointer; text-align: left; transition: background var(--t-fast), color var(--t-fast); }
|
|
||||||
.sort-option:hover { background: var(--bg-overlay); color: var(--text-primary); }
|
|
||||||
.sort-option.active { color: var(--accent-fg); }
|
|
||||||
.sort-divider { height: 1px; background: var(--border-dim); margin: var(--sp-1) var(--sp-2); }
|
|
||||||
.icon-btn { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); color: var(--text-muted); background: none; cursor: pointer; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
|
|
||||||
.icon-btn:hover:not(:disabled) { color: var(--text-secondary); border-color: var(--border-strong); background: var(--bg-raised); }
|
|
||||||
.icon-btn.active { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
|
|
||||||
.icon-btn:disabled { opacity: 0.3; cursor: default; }
|
|
||||||
|
|
||||||
/* ── Folder picker ───────────────────────────────────────────────────────── */
|
|
||||||
.fp-wrap { position: relative; }
|
|
||||||
.fp-menu { position: absolute; top: calc(100% + 4px); right: 0; min-width: 180px; background: var(--bg-raised); border: 1px solid var(--border-base); border-radius: var(--radius-md); padding: var(--sp-1); z-index: 200; box-shadow: 0 8px 24px rgba(0,0,0,0.5); animation: scaleIn 0.1s ease both; transform-origin: top right; }
|
|
||||||
.fp-empty { padding: var(--sp-2) var(--sp-3); font-size: var(--text-xs); color: var(--text-faint); }
|
|
||||||
.fp-item { display: flex; align-items: center; gap: var(--sp-2); width: 100%; padding: 6px var(--sp-3); border-radius: var(--radius-sm); font-size: var(--text-xs); color: var(--text-secondary); background: none; border: none; cursor: pointer; text-align: left; transition: background var(--t-fast), color var(--t-fast); }
|
|
||||||
.fp-item:hover { background: var(--bg-overlay); }
|
|
||||||
.fp-item.fp-item-active { color: var(--accent-fg); }
|
|
||||||
.fp-check { width: 12px; font-size: var(--text-xs); color: var(--accent-fg); flex-shrink: 0; }
|
|
||||||
.fp-div { height: 1px; background: var(--border-dim); margin: var(--sp-1) var(--sp-2); }
|
|
||||||
.fp-create { display: flex; align-items: center; gap: var(--sp-1); padding: 4px var(--sp-2); }
|
|
||||||
.fp-input { flex: 1; background: var(--bg-overlay); border: 1px solid var(--border-strong); border-radius: var(--radius-sm); padding: 4px 8px; font-size: var(--text-xs); color: var(--text-secondary); outline: none; min-width: 0; }
|
|
||||||
.fp-input:focus { border-color: var(--border-focus); }
|
|
||||||
.fp-confirm { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 4px 8px; border-radius: var(--radius-sm); border: 1px solid var(--accent-dim); background: var(--accent-muted); color: var(--accent-fg); cursor: pointer; }
|
|
||||||
.fp-confirm:disabled { opacity: 0.4; cursor: default; }
|
|
||||||
.fp-cancel { display: flex; align-items: center; justify-content: center; width: 22px; height: 22px; border-radius: var(--radius-sm); border: 1px solid transparent; background: none; color: var(--text-faint); cursor: pointer; transition: color var(--t-base), border-color var(--t-base); }
|
|
||||||
.fp-cancel:hover { color: var(--text-muted); border-color: var(--border-dim); }
|
|
||||||
.fp-new { width: 100%; padding: 6px var(--sp-3); border-radius: var(--radius-sm); font-size: var(--text-xs); color: var(--text-faint); background: none; border: none; cursor: pointer; text-align: left; transition: color var(--t-fast), background var(--t-fast); }
|
|
||||||
.fp-new:hover { color: var(--text-secondary); background: var(--bg-overlay); }
|
|
||||||
|
|
||||||
/* ── Download dropdown ───────────────────────────────────────────────────── */
|
|
||||||
.dl-wrap { position: relative; }
|
|
||||||
.dl-dropdown { position: absolute; top: calc(100% + 4px); right: 0; min-width: 220px; background: var(--bg-raised); border: 1px solid var(--border-base); border-radius: var(--radius-lg); padding: var(--sp-1); z-index: 200; box-shadow: 0 8px 32px rgba(0,0,0,0.5); animation: scaleIn 0.1s ease both; transform-origin: top right; }
|
|
||||||
.dl-section-label { padding: 6px var(--sp-3) 4px; font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; }
|
|
||||||
.dl-next-row { display: flex; gap: 4px; padding: 2px var(--sp-2) var(--sp-2); }
|
|
||||||
.dl-next-btn { flex: 1; display: flex; align-items: center; justify-content: center; gap: 5px; padding: 5px 6px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: var(--bg-overlay); color: var(--text-secondary); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); cursor: pointer; transition: background var(--t-fast), border-color var(--t-fast), color var(--t-fast); }
|
|
||||||
.dl-next-btn:hover:not(:disabled) { background: var(--accent-muted); border-color: var(--accent-dim); color: var(--accent-fg); }
|
|
||||||
.dl-next-btn:disabled { opacity: 0.3; cursor: default; }
|
|
||||||
.dl-next-sub { font-size: var(--text-2xs); color: var(--text-faint); }
|
|
||||||
.dl-divider { height: 1px; background: var(--border-dim); margin: var(--sp-1) var(--sp-2); }
|
|
||||||
.dl-item { display: flex; flex-direction: column; align-items: flex-start; gap: 2px; width: 100%; padding: 7px var(--sp-3); border-radius: var(--radius-md); font-size: var(--text-sm); color: var(--text-secondary); background: none; border: none; cursor: pointer; text-align: left; transition: background var(--t-fast), color var(--t-fast); }
|
|
||||||
.dl-item:hover:not(:disabled) { background: var(--bg-overlay); color: var(--text-primary); }
|
|
||||||
.dl-item:disabled { opacity: 0.3; cursor: default; }
|
|
||||||
.dl-item.dl-item-danger { color: var(--color-error); }
|
|
||||||
.dl-item.dl-item-danger:hover:not(:disabled) { background: var(--color-error-bg); }
|
|
||||||
.dl-item-sub { font-size: var(--text-xs); color: var(--text-faint); }
|
|
||||||
.dl-range-row { display: flex; align-items: center; gap: 4px; padding: 7px var(--sp-2); }
|
|
||||||
.dl-range-back { display: flex; align-items: center; justify-content: center; width: 20px; height: 20px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); font-size: 14px; cursor: pointer; }
|
|
||||||
.dl-range-back:hover { color: var(--text-muted); background: var(--bg-overlay); }
|
|
||||||
.dl-range-input { flex: 1; min-width: 0; padding: 4px 8px; background: var(--bg-overlay); border: 1px solid var(--border-strong); border-radius: var(--radius-sm); color: var(--text-secondary); font-family: var(--font-ui); font-size: var(--text-xs); outline: none; text-align: center; }
|
|
||||||
.dl-range-input:focus { border-color: var(--border-focus); }
|
|
||||||
.dl-range-sep { color: var(--text-faint); font-size: var(--text-xs); }
|
|
||||||
.dl-range-go { padding: 4px 10px; border-radius: var(--radius-sm); border: 1px solid var(--accent-dim); background: var(--accent-muted); color: var(--accent-fg); font-family: var(--font-ui); font-size: var(--text-xs); cursor: pointer; }
|
|
||||||
.dl-range-go:disabled { opacity: 0.3; cursor: default; }
|
|
||||||
|
|
||||||
/* ── Pagination ──────────────────────────────────────────────────────────── */
|
|
||||||
.pagination, .pagination-bottom { display: flex; align-items: center; gap: var(--sp-2); }
|
|
||||||
.pagination-bottom { justify-content: center; padding: var(--sp-3); border-top: 1px solid var(--border-dim); flex-shrink: 0; }
|
|
||||||
.page-btn { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 4px 10px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); color: var(--text-faint); background: none; cursor: pointer; transition: color var(--t-base), border-color var(--t-base); }
|
|
||||||
.page-btn:hover:not(:disabled) { color: var(--text-muted); border-color: var(--border-strong); }
|
|
||||||
.page-btn: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); }
|
|
||||||
|
|
||||||
/* ── Chapter rows ────────────────────────────────────────────────────────── */
|
|
||||||
.ch-list { flex: 1; overflow-y: auto; }
|
|
||||||
.ch-grid { flex: 1; overflow-y: auto; display: grid; grid-template-columns: repeat(auto-fill, minmax(42px, 1fr)); gap: 4px; padding: var(--sp-3); align-content: start; }
|
|
||||||
.ch-row { display: flex; align-items: center; padding: 10px var(--sp-4); border-bottom: 1px solid var(--border-dim); cursor: pointer; transition: background var(--t-fast); gap: var(--sp-3); }
|
|
||||||
.ch-row:hover { background: var(--bg-raised); }
|
|
||||||
.ch-row.read { opacity: 0.45; }
|
|
||||||
.ch-left { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 3px; }
|
|
||||||
.ch-name { font-size: var(--text-sm); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
||||||
.ch-meta { display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap; }
|
|
||||||
.ch-meta-item { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
|
||||||
.ch-right { display: flex; align-items: center; gap: var(--sp-1); flex-shrink: 0; }
|
|
||||||
:global(.read-icon) { color: var(--text-faint); }
|
|
||||||
:global(.enqueue-icon) { color: var(--text-faint); }
|
|
||||||
.dl-btn { display: flex; align-items: center; justify-content: center; width: 24px; height: 24px; border-radius: var(--radius-sm); color: var(--text-faint); transition: color var(--t-base), background var(--t-base); opacity: 0; }
|
|
||||||
.ch-row:hover .dl-btn { opacity: 1; }
|
|
||||||
.dl-btn:hover { color: var(--text-muted); background: var(--bg-overlay); }
|
|
||||||
.row-skeleton { display: flex; flex-direction: column; gap: var(--sp-2); padding: 12px var(--sp-4); border-bottom: 1px solid var(--border-dim); }
|
|
||||||
.grid-cell { display: flex; align-items: center; justify-content: center; aspect-ratio: 1; border-radius: var(--radius-sm); background: var(--bg-raised); border: 1px solid var(--border-dim); font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); cursor: pointer; position: relative; transition: background var(--t-fast), border-color var(--t-fast); }
|
|
||||||
.grid-cell:hover { background: var(--bg-overlay); border-color: var(--border-strong); }
|
|
||||||
.grid-cell.read { background: var(--color-read); color: var(--text-faint); border-color: transparent; }
|
|
||||||
.grid-cell.in-progress { border-color: var(--accent-dim); color: var(--accent-fg); }
|
|
||||||
.grid-cell-num { font-size: 10px; }
|
|
||||||
.grid-cell-dot { position: absolute; bottom: 3px; right: 3px; width: 4px; height: 4px; border-radius: 50%; background: var(--text-faint); }
|
|
||||||
.grid-cell-spinner { position: absolute; top: 2px; right: 2px; }
|
|
||||||
.grid-cell-skeleton { aspect-ratio: 1; border-radius: var(--radius-sm); }
|
|
||||||
|
|
||||||
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
|
|
||||||
@keyframes scaleIn { from { opacity: 0; transform: scale(0.97) } to { opacity: 1; transform: scale(1) } }
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<script module>
|
|
||||||
function focusOnMount(node: HTMLElement) { node.focus(); }
|
|
||||||
</script>
|
|
||||||
@@ -1,628 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { CircleNotch, ArrowSquareOut, ArrowsClockwise, X, Lock, MagnifyingGlass, Funnel } from "phosphor-svelte";
|
|
||||||
import { gql, thumbUrl } from "../../lib/client";
|
|
||||||
import {
|
|
||||||
GET_ALL_TRACKER_RECORDS,
|
|
||||||
UPDATE_TRACK,
|
|
||||||
UNBIND_TRACK,
|
|
||||||
FETCH_TRACK,
|
|
||||||
} from "../../lib/queries";
|
|
||||||
import { addToast, setActiveManga, setNavPage } from "../../store/state.svelte";
|
|
||||||
import type { Tracker, TrackRecord } from "../../lib/types";
|
|
||||||
|
|
||||||
// ── Types ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
interface TrackerWithRecords extends Tracker {
|
|
||||||
trackRecords: { nodes: TrackRecord[] };
|
|
||||||
}
|
|
||||||
|
|
||||||
interface FlatRecord extends TrackRecord {
|
|
||||||
tracker: Tracker;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── State ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
let trackers: TrackerWithRecords[] = $state([]);
|
|
||||||
let loading: boolean = $state(true);
|
|
||||||
let error: string | null = $state(null);
|
|
||||||
|
|
||||||
// Filter/view state
|
|
||||||
let activeTrackerId: number | "all" = $state("all");
|
|
||||||
let statusFilter: number | "all" = $state("all");
|
|
||||||
let searchQuery: string = $state("");
|
|
||||||
let sortBy: "title" | "status" | "score" | "progress" = $state("title");
|
|
||||||
|
|
||||||
// Mutation state
|
|
||||||
let updatingId: number | null = $state(null);
|
|
||||||
let syncingId: number | null = $state(null);
|
|
||||||
// Chapter editing: recordId → draft value
|
|
||||||
let editingChapter: number | null = $state(null);
|
|
||||||
let chapterDraft: number = $state(0);
|
|
||||||
|
|
||||||
// ── Load ───────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async function load() {
|
|
||||||
loading = true; error = null;
|
|
||||||
try {
|
|
||||||
const res = await gql<{ trackers: { nodes: TrackerWithRecords[] } }>(GET_ALL_TRACKER_RECORDS);
|
|
||||||
trackers = res.trackers.nodes;
|
|
||||||
} catch (e: any) {
|
|
||||||
error = e?.message ?? "Failed to load tracking data";
|
|
||||||
} finally {
|
|
||||||
loading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$effect(() => { load(); });
|
|
||||||
|
|
||||||
// ── Derived data ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const loggedInTrackers = $derived(trackers.filter(t => t.isLoggedIn));
|
|
||||||
|
|
||||||
const allRecords: FlatRecord[] = $derived(
|
|
||||||
loggedInTrackers.flatMap(t =>
|
|
||||||
t.trackRecords.nodes.map(r => ({
|
|
||||||
...r,
|
|
||||||
trackerId: r.trackerId ?? t.id, // fallback in case field is missing
|
|
||||||
tracker: t as Tracker,
|
|
||||||
}))
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
const totalCount = $derived(allRecords.length);
|
|
||||||
|
|
||||||
// Status options across active tracker
|
|
||||||
const statusOptions = $derived.by(() => {
|
|
||||||
if (activeTrackerId === "all") {
|
|
||||||
// Merge all statuses, dedupe by value+name
|
|
||||||
const seen = new Map<string, { value: number; name: string }>();
|
|
||||||
for (const t of loggedInTrackers) {
|
|
||||||
for (const s of t.statuses ?? []) seen.set(`${s.value}:${s.name}`, s);
|
|
||||||
}
|
|
||||||
return [...seen.values()];
|
|
||||||
}
|
|
||||||
return loggedInTrackers.find(t => t.id === activeTrackerId)?.statuses ?? [];
|
|
||||||
});
|
|
||||||
|
|
||||||
const filtered = $derived.by(() => {
|
|
||||||
let list = activeTrackerId === "all"
|
|
||||||
? allRecords
|
|
||||||
: allRecords.filter(r => Number(r.trackerId) === Number(activeTrackerId));
|
|
||||||
|
|
||||||
if (statusFilter !== "all")
|
|
||||||
list = list.filter(r => Number(r.status) === Number(statusFilter));
|
|
||||||
|
|
||||||
if (searchQuery.trim())
|
|
||||||
list = list.filter(r =>
|
|
||||||
r.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
||||||
r.manga?.title?.toLowerCase().includes(searchQuery.toLowerCase())
|
|
||||||
);
|
|
||||||
|
|
||||||
return [...list].sort((a, b) => {
|
|
||||||
if (sortBy === "title") return a.title.localeCompare(b.title);
|
|
||||||
if (sortBy === "status") return a.status - b.status;
|
|
||||||
if (sortBy === "score") return parseFloat(b.displayScore ?? "0") - parseFloat(a.displayScore ?? "0");
|
|
||||||
if (sortBy === "progress") {
|
|
||||||
const ap = a.totalChapters > 0 ? a.lastChapterRead / a.totalChapters : 0;
|
|
||||||
const bp = b.totalChapters > 0 ? b.lastChapterRead / b.totalChapters : 0;
|
|
||||||
return bp - ap;
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Mutations ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async function updateStatus(record: FlatRecord, status: number) {
|
|
||||||
updatingId = record.id;
|
|
||||||
try {
|
|
||||||
const res = await gql<{ updateTrack: { trackRecord: TrackRecord } }>(
|
|
||||||
UPDATE_TRACK, { recordId: record.id, status }
|
|
||||||
);
|
|
||||||
patchRecord(record.trackerId, res.updateTrack.trackRecord);
|
|
||||||
} catch (e: any) {
|
|
||||||
addToast({ kind: "error", title: "Update failed", body: e?.message });
|
|
||||||
} finally {
|
|
||||||
updatingId = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function updateScore(record: FlatRecord, scoreString: string) {
|
|
||||||
updatingId = record.id;
|
|
||||||
try {
|
|
||||||
const res = await gql<{ updateTrack: { trackRecord: TrackRecord } }>(
|
|
||||||
UPDATE_TRACK, { recordId: record.id, scoreString }
|
|
||||||
);
|
|
||||||
patchRecord(record.trackerId, res.updateTrack.trackRecord);
|
|
||||||
} catch (e: any) {
|
|
||||||
addToast({ kind: "error", title: "Update failed", body: e?.message });
|
|
||||||
} finally {
|
|
||||||
updatingId = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function syncRecord(record: FlatRecord) {
|
|
||||||
syncingId = record.id;
|
|
||||||
try {
|
|
||||||
const res = await gql<{ fetchTrack: { trackRecord: TrackRecord } }>(
|
|
||||||
FETCH_TRACK, { recordId: record.id }
|
|
||||||
);
|
|
||||||
patchRecord(record.trackerId, res.fetchTrack.trackRecord);
|
|
||||||
addToast({ kind: "success", title: "Synced from tracker" });
|
|
||||||
} catch (e: any) {
|
|
||||||
addToast({ kind: "error", title: "Sync failed", body: e?.message });
|
|
||||||
} finally {
|
|
||||||
syncingId = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function unbind(record: FlatRecord) {
|
|
||||||
updatingId = record.id;
|
|
||||||
try {
|
|
||||||
await gql(UNBIND_TRACK, { recordId: record.id });
|
|
||||||
trackers = trackers.map(t =>
|
|
||||||
t.id !== record.trackerId ? t : {
|
|
||||||
...t,
|
|
||||||
trackRecords: { nodes: t.trackRecords.nodes.filter(r => r.id !== record.id) }
|
|
||||||
}
|
|
||||||
);
|
|
||||||
addToast({ kind: "info", title: "Unlinked from " + record.tracker.name });
|
|
||||||
} catch (e: any) {
|
|
||||||
addToast({ kind: "error", title: "Unbind failed", body: e?.message });
|
|
||||||
} finally {
|
|
||||||
updatingId = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function patchRecord(trackerId: number, updated: Partial<TrackRecord> & { id: number }) {
|
|
||||||
trackers = trackers.map(t =>
|
|
||||||
t.id !== trackerId ? t : {
|
|
||||||
...t,
|
|
||||||
trackRecords: {
|
|
||||||
nodes: t.trackRecords.nodes.map(r => r.id === updated.id ? { ...r, ...updated } : r)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function openManga(record: FlatRecord) {
|
|
||||||
if (!record.manga) return;
|
|
||||||
setActiveManga(record.manga as any);
|
|
||||||
setNavPage("library");
|
|
||||||
}
|
|
||||||
|
|
||||||
function openChapterEditor(record: FlatRecord) {
|
|
||||||
editingChapter = record.id;
|
|
||||||
chapterDraft = record.lastChapterRead;
|
|
||||||
}
|
|
||||||
|
|
||||||
function cancelChapterEditor() {
|
|
||||||
editingChapter = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function submitChapter(record: FlatRecord) {
|
|
||||||
const val = Math.max(0, chapterDraft);
|
|
||||||
editingChapter = null;
|
|
||||||
if (val === record.lastChapterRead) return;
|
|
||||||
updatingId = record.id;
|
|
||||||
try {
|
|
||||||
const res = await gql<{ updateTrack: { trackRecord: TrackRecord } }>(
|
|
||||||
UPDATE_TRACK, { recordId: record.id, lastChapterRead: val }
|
|
||||||
);
|
|
||||||
patchRecord(record.trackerId, res.updateTrack.trackRecord);
|
|
||||||
} catch (e: any) {
|
|
||||||
addToast({ kind: "error", title: "Update failed", body: e?.message });
|
|
||||||
} finally {
|
|
||||||
updatingId = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="page">
|
|
||||||
|
|
||||||
<!-- ── Header ──────────────────────────────────────────────────────────── -->
|
|
||||||
<div class="header">
|
|
||||||
<div class="header-top">
|
|
||||||
<h1 class="heading">Tracking</h1>
|
|
||||||
<div class="header-actions">
|
|
||||||
<button class="icon-btn" onclick={load} disabled={loading} title="Refresh all">
|
|
||||||
<ArrowsClockwise size={14} weight="light" class={loading ? "anim-spin" : ""} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Tracker filter tabs -->
|
|
||||||
{#if !loading && loggedInTrackers.length > 0}
|
|
||||||
<div class="tracker-tabs">
|
|
||||||
<button
|
|
||||||
class="tracker-tab"
|
|
||||||
class:tab-active={activeTrackerId === "all"}
|
|
||||||
onclick={() => { activeTrackerId = "all"; statusFilter = "all"; }}
|
|
||||||
>
|
|
||||||
All
|
|
||||||
<span class="tab-count">{totalCount}</span>
|
|
||||||
</button>
|
|
||||||
{#each loggedInTrackers as t}
|
|
||||||
{@const count = t.trackRecords.nodes.length}
|
|
||||||
<button
|
|
||||||
class="tracker-tab"
|
|
||||||
class:tab-active={activeTrackerId === t.id}
|
|
||||||
onclick={() => { activeTrackerId = Number(t.id); statusFilter = "all"; }}
|
|
||||||
>
|
|
||||||
<img src={thumbUrl(t.icon)} alt={t.name} class="tab-tracker-icon" />
|
|
||||||
{t.name}
|
|
||||||
<span class="tab-count">{count}</span>
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Filter + sort bar -->
|
|
||||||
<div class="filter-bar">
|
|
||||||
<div class="search-wrap">
|
|
||||||
<MagnifyingGlass size={12} weight="light" class="search-ico" />
|
|
||||||
<input
|
|
||||||
class="filter-search"
|
|
||||||
placeholder="Search titles…"
|
|
||||||
bind:value={searchQuery}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="filter-right">
|
|
||||||
<Funnel size={12} weight="light" style="color:var(--text-faint);flex-shrink:0" />
|
|
||||||
<select class="filter-select" bind:value={statusFilter}
|
|
||||||
onchange={(e) => {
|
|
||||||
const v = (e.target as HTMLSelectElement).value;
|
|
||||||
statusFilter = v === "all" ? "all" : parseInt(v);
|
|
||||||
}}>
|
|
||||||
<option value="all">All statuses</option>
|
|
||||||
{#each statusOptions as s}
|
|
||||||
<option value={s.value}>{s.name}</option>
|
|
||||||
{/each}
|
|
||||||
</select>
|
|
||||||
|
|
||||||
<select class="filter-select" bind:value={sortBy}>
|
|
||||||
<option value="title">Sort: Title</option>
|
|
||||||
<option value="status">Sort: Status</option>
|
|
||||||
<option value="score">Sort: Score</option>
|
|
||||||
<option value="progress">Sort: Progress</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- ── Body ────────────────────────────────────────────────────────────── -->
|
|
||||||
<div class="page-body">
|
|
||||||
|
|
||||||
{#if loading}
|
|
||||||
<div class="state-center">
|
|
||||||
<CircleNotch size={22} weight="light" class="anim-spin" style="color:var(--text-faint)" />
|
|
||||||
<span class="state-label">Loading tracking data…</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{:else if error}
|
|
||||||
<div class="state-center">
|
|
||||||
<p class="state-error">{error}</p>
|
|
||||||
<button class="retry-btn" onclick={load}>Retry</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{:else if loggedInTrackers.length === 0}
|
|
||||||
<div class="state-center">
|
|
||||||
<p class="state-text">No trackers connected.</p>
|
|
||||||
<p class="state-hint">Go to Settings → Tracking to log in to AniList, MAL, or others.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{:else if filtered.length === 0}
|
|
||||||
<div class="state-center">
|
|
||||||
<p class="state-text">{searchQuery || statusFilter !== "all" ? "No results match your filters." : "Nothing tracked yet."}</p>
|
|
||||||
{#if searchQuery || statusFilter !== "all"}
|
|
||||||
<button class="retry-btn" onclick={() => { searchQuery = ""; statusFilter = "all"; }}>Clear filters</button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{:else}
|
|
||||||
<div class="records-list">
|
|
||||||
{#each filtered as record (record.tracker.id + ":" + record.id)}
|
|
||||||
{@const tracker = record.tracker}
|
|
||||||
{@const isBusy = updatingId === record.id}
|
|
||||||
{@const isSyncing = syncingId === record.id}
|
|
||||||
{@const progress = record.totalChapters > 0
|
|
||||||
? Math.min(100, (record.lastChapterRead / record.totalChapters) * 100)
|
|
||||||
: null}
|
|
||||||
|
|
||||||
<div class="record-card" class:record-busy={isBusy}>
|
|
||||||
|
|
||||||
<!-- Cover -->
|
|
||||||
<div class="record-cover-wrap" role="button" tabindex="0"
|
|
||||||
onclick={() => openManga(record)}
|
|
||||||
onkeydown={(e) => e.key === "Enter" && openManga(record)}
|
|
||||||
>
|
|
||||||
{#if record.manga?.thumbnailUrl}
|
|
||||||
<img src={thumbUrl(record.manga.thumbnailUrl)} alt={record.title} class="record-cover" loading="lazy" />
|
|
||||||
{:else}
|
|
||||||
<div class="record-cover record-cover-empty"></div>
|
|
||||||
{/if}
|
|
||||||
<!-- Tracker badge -->
|
|
||||||
<img src={thumbUrl(tracker.icon)} alt={tracker.name} class="record-tracker-badge" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Info -->
|
|
||||||
<div class="record-body">
|
|
||||||
<div class="record-top">
|
|
||||||
<div class="record-titles" role="button" tabindex="0"
|
|
||||||
onclick={() => openManga(record)}
|
|
||||||
onkeydown={(e) => e.key === "Enter" && openManga(record)}
|
|
||||||
>
|
|
||||||
<span class="record-title">{record.title}</span>
|
|
||||||
{#if record.manga?.title && record.manga.title !== record.title}
|
|
||||||
<span class="record-local-title">{record.manga.title}</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<div class="record-header-actions">
|
|
||||||
{#if activeTrackerId === "all"}
|
|
||||||
<span class="record-tracker-label">
|
|
||||||
<img src={thumbUrl(record.tracker.icon)} alt={record.tracker.name} class="record-tracker-label-icon" />
|
|
||||||
{record.tracker.name}
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
{#if isSyncing}
|
|
||||||
<CircleNotch size={12} weight="light" class="anim-spin" style="color:var(--text-faint)" />
|
|
||||||
{:else}
|
|
||||||
<button class="card-icon-btn" title="Sync from tracker" onclick={() => syncRecord(record)}>
|
|
||||||
<ArrowsClockwise size={12} weight="light" />
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
{#if record.remoteUrl}
|
|
||||||
<a href={record.remoteUrl} target="_blank" rel="noreferrer" class="card-icon-btn" title="Open on {record.tracker.name}">
|
|
||||||
<ArrowSquareOut size={12} weight="light" />
|
|
||||||
</a>
|
|
||||||
{/if}
|
|
||||||
<button class="card-icon-btn danger" title="Unlink" onclick={() => unbind(record)} disabled={isBusy}>
|
|
||||||
<X size={12} weight="bold" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Controls row -->
|
|
||||||
<div class="record-controls">
|
|
||||||
<select
|
|
||||||
class="record-select"
|
|
||||||
value={record.status}
|
|
||||||
disabled={isBusy}
|
|
||||||
onchange={(e) => updateStatus(record, parseInt((e.target as HTMLSelectElement).value))}
|
|
||||||
>
|
|
||||||
{#each (tracker.statuses ?? []) as s}
|
|
||||||
<option value={s.value}>{s.name}</option>
|
|
||||||
{/each}
|
|
||||||
</select>
|
|
||||||
|
|
||||||
<select
|
|
||||||
class="record-select record-select-score"
|
|
||||||
value={record.displayScore}
|
|
||||||
disabled={isBusy}
|
|
||||||
onchange={(e) => updateScore(record, (e.target as HTMLSelectElement).value)}
|
|
||||||
>
|
|
||||||
{#each (tracker.scores ?? []) as s}
|
|
||||||
<option value={s}>★ {s}</option>
|
|
||||||
{/each}
|
|
||||||
</select>
|
|
||||||
|
|
||||||
{#if record.private}
|
|
||||||
<span class="private-badge" title="Private"><Lock size={10} weight="fill" /></span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Progress / Chapter editor -->
|
|
||||||
{#if editingChapter === record.id}
|
|
||||||
<div class="chapter-editor">
|
|
||||||
<div class="chapter-editor-top">
|
|
||||||
<span class="chapter-editor-label">Chapter read</span>
|
|
||||||
<div class="chapter-input-wrap">
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
class="chapter-input"
|
|
||||||
min="0"
|
|
||||||
max={record.totalChapters > 0 ? record.totalChapters : undefined}
|
|
||||||
step="0.5"
|
|
||||||
bind:value={chapterDraft}
|
|
||||||
onkeydown={(e) => {
|
|
||||||
if (e.key === "Enter") submitChapter(record);
|
|
||||||
if (e.key === "Escape") cancelChapterEditor();
|
|
||||||
}}
|
|
||||||
use:focusEl
|
|
||||||
/>
|
|
||||||
{#if record.totalChapters > 0}
|
|
||||||
<span class="chapter-total">/ {record.totalChapters}</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{#if record.totalChapters > 0}
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
class="chapter-slider"
|
|
||||||
min="0"
|
|
||||||
max={record.totalChapters}
|
|
||||||
step="1"
|
|
||||||
bind:value={chapterDraft}
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
<div class="chapter-editor-actions">
|
|
||||||
<button class="chapter-save-btn" onclick={() => submitChapter(record)}>Save</button>
|
|
||||||
<button class="chapter-cancel-btn" onclick={cancelChapterEditor}>Cancel</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{:else if progress !== null}
|
|
||||||
<div class="record-progress clickable" role="button" tabindex="0"
|
|
||||||
onclick={() => openChapterEditor(record)}
|
|
||||||
onkeydown={(e) => e.key === "Enter" && openChapterEditor(record)}
|
|
||||||
title="Click to edit"
|
|
||||||
>
|
|
||||||
<div class="progress-track">
|
|
||||||
<div class="progress-fill" style="width:{progress}%"></div>
|
|
||||||
</div>
|
|
||||||
<span class="progress-label">Ch. {record.lastChapterRead} / {record.totalChapters}</span>
|
|
||||||
<span class="progress-edit-hint">✎</span>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div class="record-progress clickable no-total" role="button" tabindex="0"
|
|
||||||
onclick={() => openChapterEditor(record)}
|
|
||||||
onkeydown={(e) => e.key === "Enter" && openChapterEditor(record)}
|
|
||||||
title="Click to set chapter"
|
|
||||||
>
|
|
||||||
<span class="progress-label">
|
|
||||||
{record.lastChapterRead > 0 ? `Ch. ${record.lastChapterRead} read` : "Set chapter…"}
|
|
||||||
</span>
|
|
||||||
<span class="progress-edit-hint">✎</span>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.page { display: flex; flex-direction: column; height: 100%; overflow: hidden; animation: fadeIn 0.14s ease both; }
|
|
||||||
|
|
||||||
/* ── Header ─────────────────────────────────────────────────────────────── */
|
|
||||||
.header { flex-shrink: 0; border-bottom: 1px solid var(--border-dim); background: var(--bg-base); }
|
|
||||||
.header-top { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-4) var(--sp-6) var(--sp-3); }
|
|
||||||
.heading { font-family: var(--font-ui); font-size: var(--text-xs); font-weight: var(--weight-normal); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; }
|
|
||||||
.header-actions { display: flex; align-items: center; gap: var(--sp-2); }
|
|
||||||
.icon-btn { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); color: var(--text-muted); background: none; cursor: pointer; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
|
|
||||||
.icon-btn:hover:not(:disabled) { color: var(--text-secondary); border-color: var(--border-strong); background: var(--bg-raised); }
|
|
||||||
.icon-btn:disabled { opacity: 0.3; cursor: default; }
|
|
||||||
|
|
||||||
/* ── Tracker tabs ───────────────────────────────────────────────────────── */
|
|
||||||
.tracker-tabs { display: flex; align-items: center; gap: 2px; padding: var(--sp-2) var(--sp-4); overflow-x: auto; scrollbar-width: none; }
|
|
||||||
.tracker-tabs::-webkit-scrollbar { display: none; }
|
|
||||||
.tracker-tab { display: flex; align-items: center; gap: var(--sp-2); padding: 4px 10px; font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); color: var(--text-faint); background: none; border: none; border-radius: var(--radius-md); cursor: pointer; white-space: nowrap; transition: color var(--t-base), background var(--t-base); }
|
|
||||||
.tracker-tab:hover { color: var(--text-muted); }
|
|
||||||
.tab-active { background: var(--accent-muted); color: var(--accent-fg) !important; border: 1px solid var(--accent-dim); }
|
|
||||||
.tab-tracker-icon { width: 14px; height: 14px; border-radius: 2px; object-fit: contain; }
|
|
||||||
.tab-count { font-size: 10px; padding: 1px 5px; border-radius: var(--radius-full); background: var(--bg-overlay); color: var(--text-faint); min-width: 18px; text-align: center; }
|
|
||||||
|
|
||||||
/* ── Filter bar ─────────────────────────────────────────────────────────── */
|
|
||||||
.filter-bar { display: flex; align-items: center; gap: var(--sp-3); padding: var(--sp-2) var(--sp-5); border-top: 1px solid var(--border-dim); }
|
|
||||||
.search-wrap { display: flex; align-items: center; gap: var(--sp-2); flex: 1; background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 5px 10px; }
|
|
||||||
:global(.search-ico) { color: var(--text-faint); flex-shrink: 0; }
|
|
||||||
.filter-search { flex: 1; background: none; border: none; outline: none; font-size: var(--text-sm); color: var(--text-primary); min-width: 0; }
|
|
||||||
.filter-search::placeholder { color: var(--text-faint); }
|
|
||||||
.filter-right { display: flex; align-items: center; gap: var(--sp-2); flex-shrink: 0; }
|
|
||||||
.filter-select {
|
|
||||||
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
|
|
||||||
padding: 5px 28px 5px 10px; border-radius: var(--radius-md);
|
|
||||||
border: 1px solid var(--border-dim); background: var(--bg-raised);
|
|
||||||
color: var(--text-muted); outline: none; cursor: pointer;
|
|
||||||
appearance: none; -webkit-appearance: none;
|
|
||||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%23888' stroke-width='1.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E");
|
|
||||||
background-repeat: no-repeat; background-position: right 8px center;
|
|
||||||
transition: border-color var(--t-base), color var(--t-base);
|
|
||||||
}
|
|
||||||
.filter-select:hover { border-color: var(--border-strong); color: var(--text-secondary); }
|
|
||||||
.filter-select option { background: var(--bg-surface); color: var(--text-secondary); }
|
|
||||||
|
|
||||||
/* ── Body ───────────────────────────────────────────────────────────────── */
|
|
||||||
.page-body { flex: 1; overflow-y: auto; padding: var(--sp-4) var(--sp-5); scrollbar-width: thin; scrollbar-color: var(--border-strong) transparent; }
|
|
||||||
|
|
||||||
/* ── States ─────────────────────────────────────────────────────────────── */
|
|
||||||
.state-center { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: var(--sp-3); height: 100%; padding: var(--sp-10); text-align: center; }
|
|
||||||
.state-label { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
|
||||||
.state-text { font-size: var(--text-sm); color: var(--text-muted); }
|
|
||||||
.state-hint { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
|
||||||
.state-error { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--color-error); letter-spacing: var(--tracking-wide); }
|
|
||||||
.retry-btn { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 6px 16px; border-radius: var(--radius-md); border: 1px solid var(--border-strong); background: none; color: var(--text-muted); cursor: pointer; transition: color var(--t-base), border-color var(--t-base); }
|
|
||||||
.retry-btn:hover { color: var(--accent-fg); border-color: var(--accent-dim); }
|
|
||||||
|
|
||||||
/* ── Records list ───────────────────────────────────────────────────────── */
|
|
||||||
.records-list { display: flex; flex-direction: column; gap: var(--sp-2); }
|
|
||||||
|
|
||||||
.record-card {
|
|
||||||
display: flex; align-items: flex-start; gap: var(--sp-4);
|
|
||||||
padding: var(--sp-3) var(--sp-4);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
border: 1px solid var(--border-dim);
|
|
||||||
background: var(--bg-raised);
|
|
||||||
transition: border-color var(--t-base), opacity var(--t-base);
|
|
||||||
}
|
|
||||||
.record-card:hover { border-color: var(--border-strong); }
|
|
||||||
.record-busy { opacity: 0.5; pointer-events: none; }
|
|
||||||
|
|
||||||
/* Cover */
|
|
||||||
.record-cover-wrap { position: relative; flex-shrink: 0; cursor: pointer; }
|
|
||||||
.record-cover { width: 48px; height: 68px; object-fit: cover; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); display: block; }
|
|
||||||
.record-cover-empty { background: var(--bg-overlay); }
|
|
||||||
.record-cover-wrap:hover .record-cover { opacity: 0.8; }
|
|
||||||
.record-tracker-badge { position: absolute; bottom: -4px; right: -4px; width: 16px; height: 16px; border-radius: 3px; border: 1px solid var(--bg-raised); object-fit: contain; background: var(--bg-raised); }
|
|
||||||
|
|
||||||
/* Body */
|
|
||||||
.record-body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: var(--sp-2); }
|
|
||||||
.record-top { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--sp-3); }
|
|
||||||
.record-titles { display: flex; flex-direction: column; gap: 2px; flex: 1; min-width: 0; cursor: pointer; }
|
|
||||||
.record-title { font-size: var(--text-sm); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; transition: color var(--t-base); }
|
|
||||||
.record-titles:hover .record-title { color: var(--accent-fg); }
|
|
||||||
.record-local-title { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
||||||
.record-header-actions { display: flex; align-items: center; gap: 2px; flex-shrink: 0; }
|
|
||||||
.record-tracker-label { display: flex; align-items: center; gap: 4px; font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); padding: 2px 6px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: var(--bg-overlay); margin-right: var(--sp-1); }
|
|
||||||
.record-tracker-label-icon { width: 12px; height: 12px; border-radius: 2px; object-fit: contain; }
|
|
||||||
.card-icon-btn { display: flex; align-items: center; justify-content: center; width: 22px; height: 22px; border-radius: var(--radius-sm); color: var(--text-faint); background: none; border: none; cursor: pointer; text-decoration: none; transition: color var(--t-base), background var(--t-base); }
|
|
||||||
.card-icon-btn:hover { color: var(--text-muted); background: var(--bg-overlay); }
|
|
||||||
.card-icon-btn.danger:hover { color: var(--color-error); background: var(--color-error-bg); }
|
|
||||||
.card-icon-btn:disabled { opacity: 0.3; cursor: default; }
|
|
||||||
|
|
||||||
/* Controls */
|
|
||||||
.record-controls { display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap; }
|
|
||||||
.record-select {
|
|
||||||
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
|
|
||||||
padding: 3px 26px 3px 8px; border-radius: var(--radius-sm);
|
|
||||||
border: 1px solid var(--border-strong); background: var(--bg-overlay);
|
|
||||||
color: var(--text-secondary); outline: none; cursor: pointer;
|
|
||||||
appearance: none; -webkit-appearance: none;
|
|
||||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%23888' stroke-width='1.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E");
|
|
||||||
background-repeat: no-repeat; background-position: right 7px center;
|
|
||||||
transition: border-color var(--t-base);
|
|
||||||
}
|
|
||||||
.record-select:hover:not(:disabled) { border-color: var(--accent-dim); }
|
|
||||||
.record-select:disabled { opacity: 0.4; cursor: default; }
|
|
||||||
.record-select option { background: var(--bg-surface); color: var(--text-secondary); }
|
|
||||||
.record-select-score { max-width: 90px; }
|
|
||||||
.private-badge { display: flex; align-items: center; color: var(--text-faint); padding: 2px 4px; }
|
|
||||||
|
|
||||||
/* Progress */
|
|
||||||
.record-progress { display: flex; align-items: center; gap: var(--sp-3); }
|
|
||||||
.progress-track { flex: 1; height: 3px; background: var(--border-base); border-radius: var(--radius-full); overflow: hidden; max-width: 160px; }
|
|
||||||
.progress-fill { height: 100%; background: var(--accent); border-radius: var(--radius-full); transition: width 0.3s ease; }
|
|
||||||
.progress-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); white-space: nowrap; }
|
|
||||||
.record-progress.clickable { cursor: pointer; border-radius: var(--radius-sm); padding: 2px 4px; margin: -2px -4px; transition: background var(--t-fast); }
|
|
||||||
.record-progress.clickable:hover { background: var(--bg-overlay); }
|
|
||||||
.record-progress.clickable:hover .progress-label { color: var(--text-muted); }
|
|
||||||
.progress-edit-hint { font-size: 10px; color: var(--text-faint); opacity: 0; transition: opacity var(--t-fast); }
|
|
||||||
.record-progress.clickable:hover .progress-edit-hint { opacity: 1; }
|
|
||||||
|
|
||||||
/* Chapter editor */
|
|
||||||
.chapter-editor { display: flex; flex-direction: column; gap: var(--sp-2); padding: var(--sp-3); border-radius: var(--radius-md); border: 1px solid var(--accent-dim); background: var(--bg-overlay); }
|
|
||||||
.chapter-editor-top { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-3); }
|
|
||||||
.chapter-editor-label { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
|
||||||
.chapter-input-wrap { display: flex; align-items: center; gap: var(--sp-2); }
|
|
||||||
.chapter-input { width: 72px; background: var(--bg-raised); border: 1px solid var(--accent-dim); border-radius: var(--radius-sm); padding: 4px 8px; font-family: var(--font-ui); font-size: var(--text-sm); color: var(--text-primary); outline: none; text-align: center; appearance: none; -webkit-appearance: none; -moz-appearance: textfield; }
|
|
||||||
.chapter-input:focus { border-color: var(--accent); }
|
|
||||||
.chapter-input::-webkit-outer-spin-button,
|
|
||||||
.chapter-input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
|
|
||||||
.chapter-total { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
|
||||||
.chapter-slider { width: 100%; accent-color: var(--accent); cursor: pointer; height: 4px; }
|
|
||||||
.chapter-editor-actions { display: flex; align-items: center; gap: var(--sp-2); justify-content: flex-end; }
|
|
||||||
.chapter-save-btn { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 4px 14px; border-radius: var(--radius-sm); border: 1px solid var(--accent); background: var(--accent-muted); color: var(--accent-fg); cursor: pointer; transition: filter var(--t-base); }
|
|
||||||
.chapter-save-btn:hover { filter: brightness(1.15); }
|
|
||||||
.chapter-cancel-btn { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 4px 10px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); cursor: pointer; transition: color var(--t-base), border-color var(--t-base); }
|
|
||||||
.chapter-cancel-btn:hover { color: var(--text-muted); border-color: var(--border-strong); }
|
|
||||||
|
|
||||||
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<script module>
|
|
||||||
function focusEl(node: HTMLElement) { setTimeout(() => node.focus(), 0); }
|
|
||||||
</script>
|
|
||||||
@@ -1,816 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { onMount, untrack } from "svelte";
|
|
||||||
import { X, CaretLeft, CaretRight, ArrowLeft, ArrowRight, Square, Rows, Download, ArrowsLeftRight, ArrowsIn, ArrowsOut, ArrowsVertical, CircleNotch } from "phosphor-svelte";
|
|
||||||
import { gql, thumbUrl } from "../../lib/client";
|
|
||||||
import { FETCH_CHAPTER_PAGES, MARK_CHAPTER_READ, ENQUEUE_DOWNLOAD, ENQUEUE_CHAPTERS_DOWNLOAD } from "../../lib/queries";
|
|
||||||
import { store, closeReader, openReader, addHistory, updateSettings, checkAndMarkCompleted, setSettingsOpen } from "../../store/state.svelte";
|
|
||||||
import { matchesKeybind, toggleFullscreen, DEFAULT_KEYBINDS } from "../../lib/keybinds";
|
|
||||||
import type { FitMode } from "../../store/state.svelte";
|
|
||||||
|
|
||||||
// ─── Constants ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const AVG_MIN_PER_PAGE = 0.33;
|
|
||||||
const MAX_CACHED = 10;
|
|
||||||
const READ_LINE_PCT = 0.20;
|
|
||||||
|
|
||||||
// ─── Page cache ───────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const pageCache = new Map<number, string[]>();
|
|
||||||
const inflight = new Map<number, Promise<string[]>>();
|
|
||||||
const cacheOrder: number[] = [];
|
|
||||||
|
|
||||||
function cacheTouch(id: number) {
|
|
||||||
const i = cacheOrder.indexOf(id);
|
|
||||||
if (i !== -1) cacheOrder.splice(i, 1);
|
|
||||||
cacheOrder.push(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
function cacheEvict(keep: Set<number>) {
|
|
||||||
while (pageCache.size > MAX_CACHED) {
|
|
||||||
const victim = cacheOrder.find(id => !keep.has(id));
|
|
||||||
if (victim === undefined) break;
|
|
||||||
cacheOrder.splice(cacheOrder.indexOf(victim), 1);
|
|
||||||
pageCache.delete(victim);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function fetchPages(chapterId: number, signal?: AbortSignal): Promise<string[]> {
|
|
||||||
const cached = pageCache.get(chapterId);
|
|
||||||
if (cached) { cacheTouch(chapterId); return Promise.resolve(cached); }
|
|
||||||
if (signal?.aborted) return Promise.reject(new DOMException("Aborted", "AbortError"));
|
|
||||||
if (!inflight.has(chapterId)) {
|
|
||||||
const p = gql<{ fetchChapterPages: { pages: string[] } }>(FETCH_CHAPTER_PAGES, { chapterId })
|
|
||||||
.then(d => {
|
|
||||||
const urls = d.fetchChapterPages.pages.map(thumbUrl);
|
|
||||||
pageCache.set(chapterId, urls);
|
|
||||||
cacheTouch(chapterId);
|
|
||||||
return urls;
|
|
||||||
})
|
|
||||||
.finally(() => inflight.delete(chapterId));
|
|
||||||
inflight.set(chapterId, p);
|
|
||||||
}
|
|
||||||
const base = inflight.get(chapterId)!;
|
|
||||||
if (!signal) return base;
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
signal.addEventListener("abort", () => reject(new DOMException("Aborted", "AbortError")), { once: true });
|
|
||||||
base.then(resolve, reject);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Image helpers ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const aspectCache = new Map<string, number>();
|
|
||||||
function preloadImage(url: string) { new Image().src = url; }
|
|
||||||
|
|
||||||
function decodeImage(url: string): Promise<void> {
|
|
||||||
return new Promise(resolve => {
|
|
||||||
const img = new Image();
|
|
||||||
img.onload = () => { img.decode ? img.decode().then(resolve, resolve) : resolve(); };
|
|
||||||
img.onerror = () => resolve();
|
|
||||||
img.src = url;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function measureAspect(url: string): Promise<number> {
|
|
||||||
if (aspectCache.has(url)) return Promise.resolve(aspectCache.get(url)!);
|
|
||||||
return new Promise(res => {
|
|
||||||
const img = new Image();
|
|
||||||
img.onload = () => {
|
|
||||||
const r = img.naturalHeight > 0 ? img.naturalWidth / img.naturalHeight : 0.67;
|
|
||||||
aspectCache.set(url, r);
|
|
||||||
res(r);
|
|
||||||
};
|
|
||||||
img.onerror = () => res(0.67);
|
|
||||||
img.src = url;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
interface StripChapter { chapterId: number; chapterName: string; urls: string[]; }
|
|
||||||
|
|
||||||
// ─── DOM refs ─────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
let containerEl: HTMLDivElement;
|
|
||||||
|
|
||||||
// ─── UI state ─────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
let loading = $state(true);
|
|
||||||
let error: string | null = $state(null);
|
|
||||||
let dlOpen = $state(false);
|
|
||||||
let zoomOpen = $state(false);
|
|
||||||
let uiVisible = $state(true);
|
|
||||||
let pageReady = $state(false);
|
|
||||||
let pageGroups: number[][] = $state([]);
|
|
||||||
let stripChapters: StripChapter[] = $state([]);
|
|
||||||
let visibleChapterId: number | null = $state(null);
|
|
||||||
let nextN = $state(5);
|
|
||||||
let dlBusy = $state(false);
|
|
||||||
let hideTimer: ReturnType<typeof setTimeout> | null = null;
|
|
||||||
|
|
||||||
// ─── Non-reactive bookkeeping ─────────────────────────────────────────────────
|
|
||||||
|
|
||||||
let markedRead = new Set<number>();
|
|
||||||
let appending = false;
|
|
||||||
let abortCtrl: AbortController | null = null;
|
|
||||||
let loadingId: number | null = null;
|
|
||||||
let navToken = 0;
|
|
||||||
// Only write history after the user has genuinely moved past the opening page.
|
|
||||||
// Prevents the "started on page 1" entry being saved as last position on close.
|
|
||||||
let hasNavigated = false;
|
|
||||||
|
|
||||||
// ─── Derived ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const rtl = $derived(store.settings.readingDirection === "rtl");
|
|
||||||
const fit = $derived((store.settings.fitMode ?? "width") as FitMode);
|
|
||||||
const style = $derived(store.settings.pageStyle ?? "single");
|
|
||||||
const maxW = $derived(store.settings.maxPageWidth ?? 900);
|
|
||||||
const autoNext = $derived(store.settings.autoNextChapter ?? false);
|
|
||||||
const markOnNext = $derived(store.settings.markReadOnNext ?? true);
|
|
||||||
const overlayBars = $derived(store.settings.overlayBars ?? false);
|
|
||||||
const lastPage = $derived(store.pageUrls.length);
|
|
||||||
|
|
||||||
const displayChapter = $derived(
|
|
||||||
style === "longstrip" && autoNext && visibleChapterId
|
|
||||||
? (store.activeChapterList.find(c => c.id === visibleChapterId) ?? store.activeChapter)
|
|
||||||
: store.activeChapter
|
|
||||||
);
|
|
||||||
|
|
||||||
const adjacent = $derived.by(() => {
|
|
||||||
const ref = displayChapter ?? store.activeChapter;
|
|
||||||
if (!ref || !store.activeChapterList.length) return { prev: null, next: null, remaining: [] };
|
|
||||||
const idx = store.activeChapterList.findIndex(c => c.id === ref.id);
|
|
||||||
return {
|
|
||||||
prev: idx > 0 ? store.activeChapterList[idx - 1] : null,
|
|
||||||
next: idx < store.activeChapterList.length - 1 ? store.activeChapterList[idx + 1] : null,
|
|
||||||
remaining: store.activeChapterList.slice(idx + 1),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const visibleChunkLastPage = $derived.by(() => {
|
|
||||||
if (style !== "longstrip" || !autoNext) return lastPage;
|
|
||||||
const chId = visibleChapterId ?? store.activeChapter?.id;
|
|
||||||
const chunk = stripChapters.find(c => c.chapterId === chId);
|
|
||||||
return chunk?.urls.length ?? lastPage;
|
|
||||||
});
|
|
||||||
|
|
||||||
const imgCls = $derived([
|
|
||||||
"img",
|
|
||||||
fit === "width" && "fit-width",
|
|
||||||
fit === "height" && "fit-height",
|
|
||||||
fit === "screen" && "fit-screen",
|
|
||||||
fit === "original" && "fit-original",
|
|
||||||
store.settings.optimizeContrast && "optimize-contrast",
|
|
||||||
].filter(Boolean).join(" "));
|
|
||||||
|
|
||||||
const fitLabel = $derived({ width: "Fit W", height: "Fit H", screen: "Fit Screen", original: "1:1" }[fit]);
|
|
||||||
|
|
||||||
const stripToRender = $derived(
|
|
||||||
style === "longstrip"
|
|
||||||
? (autoNext && stripChapters.length > 0
|
|
||||||
? stripChapters
|
|
||||||
: [{ chapterId: store.activeChapter?.id ?? 0, chapterName: store.activeChapter?.name ?? "", urls: store.pageUrls }])
|
|
||||||
: []
|
|
||||||
);
|
|
||||||
|
|
||||||
const currentGroup = $derived(
|
|
||||||
style === "double" && pageGroups.length
|
|
||||||
? (pageGroups.find(g => g.includes(store.pageNumber)) ?? [store.pageNumber])
|
|
||||||
: [store.pageNumber]
|
|
||||||
);
|
|
||||||
|
|
||||||
// ─── Chapter loading ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
const ch = store.activeChapter;
|
|
||||||
if (ch) untrack(() => loadChapter(ch.id));
|
|
||||||
});
|
|
||||||
|
|
||||||
async function loadChapter(id: number) {
|
|
||||||
abortCtrl?.abort();
|
|
||||||
const ctrl = new AbortController();
|
|
||||||
abortCtrl = ctrl;
|
|
||||||
loadingId = id;
|
|
||||||
navToken++;
|
|
||||||
appending = false;
|
|
||||||
markedRead = new Set();
|
|
||||||
hasNavigated = false;
|
|
||||||
loading = true;
|
|
||||||
error = null;
|
|
||||||
pageGroups = [];
|
|
||||||
pageReady = false;
|
|
||||||
stripChapters = [];
|
|
||||||
visibleChapterId = null;
|
|
||||||
store.pageUrls = [];
|
|
||||||
store.pageNumber = 1;
|
|
||||||
try {
|
|
||||||
const urls = await fetchPages(id, ctrl.signal);
|
|
||||||
if (ctrl.signal.aborted) return;
|
|
||||||
store.pageUrls = urls;
|
|
||||||
pageReady = true;
|
|
||||||
loading = false;
|
|
||||||
} catch (e: any) {
|
|
||||||
if (ctrl.signal.aborted) return;
|
|
||||||
error = e instanceof Error ? e.message : String(e);
|
|
||||||
loading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Strip initialisation ─────────────────────────────────────────────────────
|
|
||||||
// Runs when a chapter finishes loading in longstrip mode.
|
|
||||||
// Starts the strip with just the current chapter; appendNextChapter adds more
|
|
||||||
// as the user scrolls. Nothing is ever removed from the DOM mid-read.
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (style === "longstrip" && store.pageUrls.length && store.activeChapter) {
|
|
||||||
const ch = store.activeChapter;
|
|
||||||
const urls = store.pageUrls;
|
|
||||||
appending = false;
|
|
||||||
if (autoNext) {
|
|
||||||
stripChapters = [{ chapterId: ch.id, chapterName: ch.name, urls }];
|
|
||||||
visibleChapterId = ch.id;
|
|
||||||
} else {
|
|
||||||
stripChapters = [];
|
|
||||||
visibleChapterId = null;
|
|
||||||
}
|
|
||||||
if (containerEl) containerEl.scrollTop = 0;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
$effect(() => { if (style !== "longstrip" && containerEl) containerEl.scrollTop = 0; });
|
|
||||||
|
|
||||||
// ─── Forward append only ──────────────────────────────────────────────────────
|
|
||||||
// Appends the next chapter to the bottom when the user scrolls past 80%.
|
|
||||||
// No eviction, no prepend, no sliding window — chapters accumulate forward.
|
|
||||||
|
|
||||||
function appendNextChapter() {
|
|
||||||
if (appending || !stripChapters.length) return;
|
|
||||||
const lastChunk = stripChapters[stripChapters.length - 1];
|
|
||||||
const list = store.activeChapterList;
|
|
||||||
const lastIdx = list.findIndex(c => c.id === lastChunk.chapterId);
|
|
||||||
if (lastIdx < 0 || lastIdx >= list.length - 1) return;
|
|
||||||
const next = list[lastIdx + 1];
|
|
||||||
if (!next || stripChapters.some(c => c.chapterId === next.id)) return;
|
|
||||||
appending = true;
|
|
||||||
|
|
||||||
fetchPages(next.id)
|
|
||||||
.then(urls => {
|
|
||||||
urls.forEach(url => measureAspect(url).catch(() => {}));
|
|
||||||
urls.slice(0, 6).forEach(preloadImage);
|
|
||||||
return urls;
|
|
||||||
})
|
|
||||||
.then(urls => {
|
|
||||||
if (stripChapters.some(c => c.chapterId === next.id)) { appending = false; return; }
|
|
||||||
stripChapters = [...stripChapters, { chapterId: next.id, chapterName: next.name, urls }];
|
|
||||||
appending = false;
|
|
||||||
})
|
|
||||||
.catch(() => { appending = false; });
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Scroll tracking ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
let stripChaptersRef: StripChapter[] = [];
|
|
||||||
$effect(() => { stripChaptersRef = stripChapters; });
|
|
||||||
|
|
||||||
let autoNextRef = false;
|
|
||||||
$effect(() => { autoNextRef = autoNext; });
|
|
||||||
|
|
||||||
function setupScrollTracking(): () => void {
|
|
||||||
if (!containerEl || style !== "longstrip") return () => {};
|
|
||||||
|
|
||||||
function onScroll() {
|
|
||||||
const imgs = containerEl.querySelectorAll<HTMLElement>("img[data-local-page]");
|
|
||||||
if (!imgs.length) return;
|
|
||||||
|
|
||||||
const containerTop = containerEl.getBoundingClientRect().top;
|
|
||||||
const readLineY = containerTop + containerEl.clientHeight * READ_LINE_PCT;
|
|
||||||
let activePage: number | null = null;
|
|
||||||
let activeChId: number | null = null;
|
|
||||||
|
|
||||||
for (const img of imgs) {
|
|
||||||
if (img.getBoundingClientRect().top <= readLineY) {
|
|
||||||
activePage = Number(img.dataset.localPage);
|
|
||||||
activeChId = Number(img.dataset.chapter);
|
|
||||||
} else break;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (activePage === null) {
|
|
||||||
activePage = Number(imgs[0].dataset.localPage);
|
|
||||||
activeChId = Number(imgs[0].dataset.chapter);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (activePage !== null) store.pageNumber = activePage;
|
|
||||||
if (activeChId && activeChId !== visibleChapterId) visibleChapterId = activeChId;
|
|
||||||
|
|
||||||
if (store.settings.autoMarkRead && activePage !== null && activeChId) {
|
|
||||||
const chunk = stripChaptersRef.find(c => c.chapterId === activeChId);
|
|
||||||
const total = chunk ? chunk.urls.length : store.pageUrls.length;
|
|
||||||
if (total > 0 && activePage >= total) markChapterRead(activeChId);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (containerEl.scrollTop + containerEl.clientHeight >= containerEl.scrollHeight - 40) {
|
|
||||||
const last = stripChaptersRef[stripChaptersRef.length - 1];
|
|
||||||
if (last && store.settings.autoMarkRead) markChapterRead(last.chapterId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function onScrollAppend() {
|
|
||||||
if (!autoNextRef) return;
|
|
||||||
const pct = (containerEl.scrollTop + containerEl.clientHeight) / containerEl.scrollHeight;
|
|
||||||
if (pct >= 0.80) appendNextChapter();
|
|
||||||
}
|
|
||||||
|
|
||||||
containerEl.addEventListener("scroll", onScroll, { passive: true });
|
|
||||||
containerEl.addEventListener("scroll", onScrollAppend, { passive: true });
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
containerEl.removeEventListener("scroll", onScroll);
|
|
||||||
containerEl.removeEventListener("scroll", onScrollAppend);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Observer lifecycle ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
let cleanupScroll: () => void = () => {};
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
void style;
|
|
||||||
if (!containerEl) return;
|
|
||||||
untrack(() => {
|
|
||||||
cleanupScroll();
|
|
||||||
cleanupScroll = setupScrollTracking();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Prefetch + cache eviction ────────────────────────────────────────────────
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (store.activeChapter && store.activeChapterList.length) {
|
|
||||||
const idx = store.activeChapterList.findIndex(c => c.id === store.activeChapter!.id);
|
|
||||||
if (idx >= 0) {
|
|
||||||
const toPin: number[] = [store.activeChapter.id];
|
|
||||||
for (let i = 1; i <= 3; i++) {
|
|
||||||
const entry = store.activeChapterList[idx + i];
|
|
||||||
if (!entry) break;
|
|
||||||
toPin.push(entry.id);
|
|
||||||
fetchPages(entry.id)
|
|
||||||
.then(urls => { const n = i === 1 ? 8 : i === 2 ? 4 : 2; urls.slice(0, n).forEach(preloadImage); })
|
|
||||||
.catch(() => {});
|
|
||||||
}
|
|
||||||
if (idx > 0) {
|
|
||||||
toPin.push(store.activeChapterList[idx - 1].id);
|
|
||||||
fetchPages(store.activeChapterList[idx - 1].id).catch(() => {});
|
|
||||||
}
|
|
||||||
cacheEvict(new Set(toPin));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Double-page spread computation ──────────────────────────────────────────
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (style === "double" && store.pageUrls.length) {
|
|
||||||
let cancelled = false;
|
|
||||||
const snap = store.pageUrls;
|
|
||||||
Promise.all(snap.map(measureAspect)).then(aspects => {
|
|
||||||
if (cancelled || snap !== store.pageUrls) return;
|
|
||||||
const offset = store.settings.offsetDoubleSpreads;
|
|
||||||
const groups: number[][] = [[1]];
|
|
||||||
if (offset) groups.push([2]);
|
|
||||||
let i = offset ? 3 : 2;
|
|
||||||
while (i <= snap.length) {
|
|
||||||
const a = aspects[i - 1], nextA = aspects[i] ?? 0;
|
|
||||||
if (a > 1.2 || i === snap.length || nextA > 1.2) { groups.push([i++]); }
|
|
||||||
else { groups.push(rtl ? [i + 1, i] : [i, i + 1]); i += 2; }
|
|
||||||
}
|
|
||||||
pageGroups = groups;
|
|
||||||
});
|
|
||||||
return () => { cancelled = true; };
|
|
||||||
} else { pageGroups = []; }
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Preload around current page ─────────────────────────────────────────────
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
const ahead = store.settings.preloadPages ?? 3;
|
|
||||||
for (let i = 1; i <= ahead; i++) { const url = store.pageUrls[store.pageNumber - 1 + i]; if (url) decodeImage(url); }
|
|
||||||
const behind = store.pageUrls[store.pageNumber - 2];
|
|
||||||
if (behind) preloadImage(behind);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Progress / history tracking ─────────────────────────────────────────────
|
|
||||||
// Only records history after the user has genuinely navigated (pageNumber > 1,
|
|
||||||
// or scrolled past page 1 in longstrip). This prevents the chapter-open event
|
|
||||||
// from writing "page 1" as the last-read position, which caused the history to
|
|
||||||
// always show the chapter you started on rather than where you left off.
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
// Use displayChapter, not store.activeChapter — in longstrip with autoNext,
|
|
||||||
// store.activeChapter stays as the chapter you *opened* (e.g. ch61) while
|
|
||||||
// displayChapter tracks visibleChapterId (the chapter actually on screen).
|
|
||||||
// Using store.activeChapter here caused every history write to stamp ch61
|
|
||||||
// even when the user had scrolled all the way to ch72.
|
|
||||||
const ch = displayChapter ?? store.activeChapter;
|
|
||||||
if (ch && lastPage && store.activeManga) {
|
|
||||||
const chapterId = ch.id;
|
|
||||||
const chapterName = ch.name;
|
|
||||||
const mangaId = store.activeManga.id;
|
|
||||||
const mangaTitle = store.activeManga.title;
|
|
||||||
const thumb = store.activeManga.thumbnailUrl;
|
|
||||||
const pageNum = store.pageNumber;
|
|
||||||
const atLast = store.pageNumber === lastPage;
|
|
||||||
|
|
||||||
// Mark that the user has moved past the initial load.
|
|
||||||
if (pageNum > 1) hasNavigated = true;
|
|
||||||
|
|
||||||
untrack(() => {
|
|
||||||
// Skip the very first page-1 write that fires on chapter load.
|
|
||||||
if (!hasNavigated) return;
|
|
||||||
addHistory({ mangaId, mangaTitle, thumbnailUrl: thumb, chapterId, chapterName, pageNumber: pageNum, readAt: Date.now() });
|
|
||||||
if (style !== "longstrip" && store.settings.autoMarkRead && atLast) markChapterRead(chapterId);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Mark read ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function markChapterRead(id: number) {
|
|
||||||
if (markedRead.has(id)) return;
|
|
||||||
markedRead.add(id);
|
|
||||||
const chapter = store.activeChapterList.find(c => c.id === id) ?? store.activeChapter;
|
|
||||||
const pages = chapter?.pageCount ?? store.pageUrls.length ?? 15;
|
|
||||||
const minutes = Math.max(1, Math.round(pages * AVG_MIN_PER_PAGE));
|
|
||||||
if (store.activeManga && chapter) {
|
|
||||||
addHistory(
|
|
||||||
{ mangaId: store.activeManga.id, mangaTitle: store.activeManga.title, thumbnailUrl: store.activeManga.thumbnailUrl, chapterId: id, chapterName: chapter.name, pageNumber: pages, readAt: Date.now() },
|
|
||||||
true, minutes,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
gql(MARK_CHAPTER_READ, { id, isRead: true })
|
|
||||||
.then(() => {
|
|
||||||
if (store.activeManga) {
|
|
||||||
const updated = store.activeChapterList.map(c => c.id === id ? { ...c, isRead: true } : c);
|
|
||||||
checkAndMarkCompleted(store.activeManga.id, updated);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(e => { markedRead.delete(id); console.error(e); });
|
|
||||||
}
|
|
||||||
|
|
||||||
function maybeMarkCurrentRead() {
|
|
||||||
const ch = store.activeChapter;
|
|
||||||
if (ch && markOnNext) markChapterRead(ch.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Navigation ───────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function advanceGroup(forward: boolean) {
|
|
||||||
if (!pageGroups.length) return;
|
|
||||||
const gi = pageGroups.findIndex(g => g.includes(store.pageNumber));
|
|
||||||
if (forward) {
|
|
||||||
if (gi < pageGroups.length - 1) store.pageNumber = pageGroups[gi + 1][0];
|
|
||||||
else if (adjacent.next) { store.pageNumber = 1; openReader(adjacent.next, store.activeChapterList); }
|
|
||||||
else closeReader();
|
|
||||||
} else {
|
|
||||||
if (gi > 0) store.pageNumber = pageGroups[gi - 1][0];
|
|
||||||
else if (adjacent.prev) openReader(adjacent.prev, store.activeChapterList);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function goForward() {
|
|
||||||
if (loading) return;
|
|
||||||
if (style === "longstrip") { if (adjacent.next) { maybeMarkCurrentRead(); openReader(adjacent.next, store.activeChapterList); } return; }
|
|
||||||
if (style === "double" && pageGroups.length) { advanceGroup(true); return; }
|
|
||||||
if (!store.pageUrls.length) return;
|
|
||||||
if (store.pageNumber < lastPage) {
|
|
||||||
const target = store.pageNumber + 1;
|
|
||||||
const token = ++navToken;
|
|
||||||
decodeImage(store.pageUrls[target - 1]).then(() => {
|
|
||||||
if (navToken === token && store.pageNumber === target - 1) store.pageNumber = target;
|
|
||||||
});
|
|
||||||
} else if (adjacent.next) { maybeMarkCurrentRead(); store.pageNumber = 1; openReader(adjacent.next, store.activeChapterList); }
|
|
||||||
else closeReader();
|
|
||||||
}
|
|
||||||
|
|
||||||
function goBack() {
|
|
||||||
if (loading) return;
|
|
||||||
if (style === "longstrip") { if (adjacent.prev) openReader(adjacent.prev, store.activeChapterList); return; }
|
|
||||||
if (style === "double" && pageGroups.length) { advanceGroup(false); return; }
|
|
||||||
if (!store.pageUrls.length) return;
|
|
||||||
if (store.pageNumber > 1) {
|
|
||||||
const target = store.pageNumber - 1;
|
|
||||||
const token = ++navToken;
|
|
||||||
decodeImage(store.pageUrls[target - 1]).then(() => {
|
|
||||||
if (navToken === token && store.pageNumber === target + 1) store.pageNumber = target;
|
|
||||||
});
|
|
||||||
} else if (adjacent.prev) openReader(adjacent.prev, store.activeChapterList);
|
|
||||||
}
|
|
||||||
|
|
||||||
const goNext = $derived(rtl ? goBack : goForward);
|
|
||||||
const goPrev = $derived(rtl ? goForward : goBack);
|
|
||||||
|
|
||||||
// ─── Settings toggles ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function cycleStyle() {
|
|
||||||
const opts = ["single", "longstrip"] as const;
|
|
||||||
const cur = style === "double" ? "single" : style;
|
|
||||||
updateSettings({ pageStyle: opts[(opts.indexOf(cur as typeof opts[number]) + 1) % opts.length] });
|
|
||||||
}
|
|
||||||
|
|
||||||
function cycleFit() {
|
|
||||||
const opts: FitMode[] = ["width", "height", "screen", "original"];
|
|
||||||
updateSettings({ fitMode: opts[(opts.indexOf(fit) + 1) % opts.length] });
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── UI helpers ───────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function showUi() {
|
|
||||||
uiVisible = true;
|
|
||||||
if (hideTimer) clearTimeout(hideTimer);
|
|
||||||
hideTimer = setTimeout(() => uiVisible = false, 3000);
|
|
||||||
}
|
|
||||||
|
|
||||||
function onWheel(e: WheelEvent) {
|
|
||||||
if (!e.ctrlKey) return;
|
|
||||||
e.preventDefault();
|
|
||||||
updateSettings({ maxPageWidth: Math.min(2400, Math.max(200, maxW + (e.deltaY < 0 ? 50 : -50))) });
|
|
||||||
}
|
|
||||||
|
|
||||||
function onKey(e: KeyboardEvent) {
|
|
||||||
if ((e.target as HTMLElement).tagName === "INPUT") return;
|
|
||||||
const kb = store.settings.keybinds ?? DEFAULT_KEYBINDS;
|
|
||||||
const mW = store.settings.maxPageWidth ?? 900;
|
|
||||||
const r = store.settings.readingDirection === "rtl";
|
|
||||||
if (e.key === "Escape") {
|
|
||||||
e.preventDefault();
|
|
||||||
if (zoomOpen) { zoomOpen = false; return; }
|
|
||||||
if (dlOpen) { dlOpen = false; return; }
|
|
||||||
closeReader(); return;
|
|
||||||
}
|
|
||||||
if (e.ctrlKey && (e.key === "=" || e.key === "+")) { e.preventDefault(); updateSettings({ maxPageWidth: Math.min(2400, mW + 100) }); return; }
|
|
||||||
if (e.ctrlKey && e.key === "-") { e.preventDefault(); updateSettings({ maxPageWidth: Math.max(200, mW - 100) }); return; }
|
|
||||||
if (e.ctrlKey && e.key === "0") { e.preventDefault(); updateSettings({ maxPageWidth: 900 }); return; }
|
|
||||||
if (matchesKeybind(e, kb.exitReader)) { e.preventDefault(); closeReader(); }
|
|
||||||
else if (matchesKeybind(e, kb.pageRight)) { e.preventDefault(); goForward(); }
|
|
||||||
else if (matchesKeybind(e, kb.pageLeft)) { e.preventDefault(); goBack(); }
|
|
||||||
else if (matchesKeybind(e, kb.firstPage)) { e.preventDefault(); store.pageNumber = 1; }
|
|
||||||
else if (matchesKeybind(e, kb.lastPage)) { e.preventDefault(); store.pageNumber = lastPage; }
|
|
||||||
else if (matchesKeybind(e, kb.chapterRight)) {
|
|
||||||
e.preventDefault();
|
|
||||||
const list = store.activeChapterList, idx = list.findIndex(c => c.id === loadingId);
|
|
||||||
const next = idx >= 0 && idx < list.length - 1 ? list[idx + 1] : null;
|
|
||||||
if (next) { maybeMarkCurrentRead(); openReader(next, list); }
|
|
||||||
}
|
|
||||||
else if (matchesKeybind(e, kb.chapterLeft)) {
|
|
||||||
e.preventDefault();
|
|
||||||
const list = store.activeChapterList, idx = list.findIndex(c => c.id === loadingId);
|
|
||||||
const prev = idx > 0 ? list[idx - 1] : null;
|
|
||||||
if (prev) openReader(prev, list);
|
|
||||||
}
|
|
||||||
else if (matchesKeybind(e, kb.togglePageStyle)) { e.preventDefault(); cycleStyle(); }
|
|
||||||
else if (matchesKeybind(e, kb.toggleReadingDirection)) { e.preventDefault(); updateSettings({ readingDirection: r ? "ltr" : "rtl" }); }
|
|
||||||
else if (matchesKeybind(e, kb.toggleFullscreen)) { e.preventDefault(); toggleFullscreen().catch(console.error); }
|
|
||||||
else if (matchesKeybind(e, kb.openSettings)) { e.preventDefault(); setSettingsOpen(true); }
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleTap(e: MouseEvent) {
|
|
||||||
if (style === "longstrip") return;
|
|
||||||
const x = e.clientX / window.innerWidth;
|
|
||||||
if (!rtl) { if (x > 0.6) goForward(); else if (x < 0.4) goBack(); }
|
|
||||||
else { if (x < 0.4) goForward(); else if (x > 0.6) goBack(); }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runDl(fn: () => Promise<unknown>) {
|
|
||||||
dlBusy = true;
|
|
||||||
try { await fn(); } catch (e: any) { console.error(e); }
|
|
||||||
dlBusy = false; dlOpen = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Mount / unmount ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
showUi();
|
|
||||||
window.addEventListener("keydown", onKey);
|
|
||||||
window.addEventListener("wheel", onWheel, { passive: false });
|
|
||||||
containerEl?.focus({ preventScroll: true });
|
|
||||||
return () => {
|
|
||||||
abortCtrl?.abort();
|
|
||||||
if (hideTimer) clearTimeout(hideTimer);
|
|
||||||
window.removeEventListener("keydown", onKey);
|
|
||||||
window.removeEventListener("wheel", onWheel);
|
|
||||||
cleanupScroll();
|
|
||||||
};
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="root" class:overlay-bars={overlayBars} role="presentation" onmousemove={(e) => { if (e.clientY < 60 || window.innerHeight - e.clientY < 60) showUi(); }}>
|
|
||||||
|
|
||||||
<div class="topbar" class:hidden={!uiVisible}>
|
|
||||||
<button class="icon-btn" onclick={closeReader} title="Close reader"><X size={15} weight="light" /></button>
|
|
||||||
<button class="icon-btn" onclick={() => { if (adjacent.prev) { maybeMarkCurrentRead(); openReader(adjacent.prev, store.activeChapterList); } }} disabled={!adjacent.prev}>
|
|
||||||
<CaretLeft size={14} weight="light" />
|
|
||||||
</button>
|
|
||||||
<span class="ch-label">
|
|
||||||
<span class="ch-title">{store.activeManga?.title}</span>
|
|
||||||
<span class="ch-sep">/</span>
|
|
||||||
<span>{displayChapter?.name}</span>
|
|
||||||
</span>
|
|
||||||
<span class="page-label">{store.pageNumber} / {visibleChunkLastPage || "…"}</span>
|
|
||||||
<button class="icon-btn" onclick={() => { if (adjacent.next) { maybeMarkCurrentRead(); openReader(adjacent.next, store.activeChapterList); } }} disabled={!adjacent.next}>
|
|
||||||
<CaretRight size={14} weight="light" />
|
|
||||||
</button>
|
|
||||||
<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>
|
|
||||||
<div class="zoom-wrap">
|
|
||||||
<button class="zoom-btn" onclick={() => zoomOpen = !zoomOpen}>{Math.round((maxW / 900) * 100)}%</button>
|
|
||||||
{#if zoomOpen}
|
|
||||||
<div class="zoom-popover">
|
|
||||||
<input type="range" class="zoom-slider" min={200} max={2400} step={50} value={maxW}
|
|
||||||
oninput={(e) => updateSettings({ maxPageWidth: Number(e.currentTarget.value) })} />
|
|
||||||
<button class="zoom-reset" onclick={() => updateSettings({ maxPageWidth: 900 })}>{Math.round((maxW / 900) * 100)}%</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}>
|
|
||||||
{#if style === "single"}<Square size={14} weight="light" />{:else}<Rows size={14} weight="light" />{/if}
|
|
||||||
<span class="mode-label">{style}</span>
|
|
||||||
</button>
|
|
||||||
{#if style !== "single"}
|
|
||||||
<button class="mode-btn" class:active={store.settings.pageGap} onclick={() => updateSettings({ pageGap: !store.settings.pageGap })}>
|
|
||||||
<span class="mode-label">Gap</span>
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
{#if style === "longstrip"}
|
|
||||||
<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}
|
|
||||||
<button class="mode-btn" onclick={() => dlOpen = true}>
|
|
||||||
<Download size={14} weight="light" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
bind:this={containerEl}
|
|
||||||
class="viewer"
|
|
||||||
class:strip={style === "longstrip"}
|
|
||||||
style="--max-page-width:{maxW}px"
|
|
||||||
role="presentation"
|
|
||||||
tabindex="-1"
|
|
||||||
onclick={handleTap}
|
|
||||||
onwheel={(e) => { if (e.ctrlKey) e.preventDefault(); }}
|
|
||||||
onkeydown={(e) => { if (e.key === " " && style === "longstrip") { e.preventDefault(); containerEl?.scrollBy({ top: containerEl.clientHeight * 0.85, behavior: "smooth" }); } }}
|
|
||||||
>
|
|
||||||
{#if loading}
|
|
||||||
<div class="center-overlay"><CircleNotch size={20} weight="light" class="anim-spin" style="color:var(--text-faint)" /></div>
|
|
||||||
{/if}
|
|
||||||
{#if error}
|
|
||||||
<div class="center-overlay"><p class="error-msg">{error}</p></div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if style === "longstrip"}
|
|
||||||
{#each stripToRender as chunk}
|
|
||||||
{#each chunk.urls as url, i}
|
|
||||||
<img
|
|
||||||
src={url}
|
|
||||||
alt="{chunk.chapterName} – Page {i + 1}"
|
|
||||||
data-local-page={i + 1}
|
|
||||||
data-chapter={chunk.chapterId}
|
|
||||||
data-total={chunk.urls.length}
|
|
||||||
class="{imgCls}{store.settings.pageGap ? ' strip-gap' : ''}"
|
|
||||||
loading={i < 3 ? "eager" : "lazy"}
|
|
||||||
decoding="async"
|
|
||||||
/>
|
|
||||||
{/each}
|
|
||||||
{/each}
|
|
||||||
<div style="height:1px;flex-shrink:0"></div>
|
|
||||||
{:else if pageReady}
|
|
||||||
{#if style === "double" && pageGroups.length}
|
|
||||||
<div class="double-wrap">
|
|
||||||
{#each currentGroup as pg}
|
|
||||||
<img src={store.pageUrls[pg - 1]} alt="Page {pg}" class="{imgCls} page-half {pg === currentGroup[0] ? 'gap-left' : 'gap-right'}" decoding="async" />
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<img src={store.pageUrls[store.pageNumber - 1]} alt="Page {store.pageNumber}" class={imgCls} decoding="async" style="transition:opacity 0.1s ease" />
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="bottombar" class:hidden={!uiVisible}>
|
|
||||||
<button class="nav-btn" onclick={goPrev} disabled={loading || (style === "longstrip" ? !adjacent.prev : (store.pageNumber === 1 && !adjacent.prev))}>
|
|
||||||
<ArrowLeft size={13} weight="light" />
|
|
||||||
</button>
|
|
||||||
<button class="nav-btn" onclick={goNext} disabled={loading || (style === "longstrip" ? !adjacent.next : (store.pageNumber === lastPage && !adjacent.next))}>
|
|
||||||
<ArrowRight size={13} weight="light" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if dlOpen && store.activeChapter}
|
|
||||||
{@const queueable = adjacent.remaining.filter(c => !c.isDownloaded)}
|
|
||||||
<div class="dl-backdrop" role="presentation" onclick={() => dlOpen = false}>
|
|
||||||
<div class="dl-modal" role="presentation" onclick={(e) => e.stopPropagation()}>
|
|
||||||
<p class="dl-title">Download</p>
|
|
||||||
<button class="dl-option" disabled={dlBusy || !!store.activeChapter.isDownloaded}
|
|
||||||
onclick={() => runDl(() => gql(ENQUEUE_DOWNLOAD, { chapterId: store.activeChapter!.id }))}>
|
|
||||||
This chapter
|
|
||||||
<span class="dl-sub">{store.activeChapter.isDownloaded ? "Already downloaded" : store.activeChapter.name}</span>
|
|
||||||
</button>
|
|
||||||
<div class="dl-row">
|
|
||||||
<button class="dl-option" disabled={dlBusy || queueable.length === 0}
|
|
||||||
onclick={() => runDl(() => gql(ENQUEUE_CHAPTERS_DOWNLOAD, { chapterIds: queueable.slice(0, nextN).map(c => c.id) }))}>
|
|
||||||
Next chapters
|
|
||||||
<span class="dl-sub">{Math.min(nextN, queueable.length)} not yet downloaded</span>
|
|
||||||
</button>
|
|
||||||
<div class="dl-stepper" role="presentation" onclick={(e) => e.stopPropagation()}>
|
|
||||||
<button class="dl-step-btn" onclick={() => nextN = Math.max(1, nextN - 1)} disabled={nextN <= 1}>−</button>
|
|
||||||
<span class="dl-step-val">{nextN}</span>
|
|
||||||
<button class="dl-step-btn" onclick={() => nextN = Math.min(queueable.length || 1, nextN + 1)} disabled={nextN >= queueable.length}>+</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button class="dl-option" disabled={dlBusy || queueable.length === 0}
|
|
||||||
onclick={() => runDl(() => gql(ENQUEUE_CHAPTERS_DOWNLOAD, { chapterIds: queueable.map(c => c.id) }))}>
|
|
||||||
All remaining
|
|
||||||
<span class="dl-sub">{queueable.length} not yet downloaded</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/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; }
|
|
||||||
.overlay-bars { position: fixed; }
|
|
||||||
.overlay-bars .topbar { position: absolute; top: 0; left: 0; right: 0; z-index: 10; }
|
|
||||||
.overlay-bars .bottombar { position: absolute; bottom: 0; left: 0; right: 0; z-index: 10; }
|
|
||||||
.overlay-bars .viewer { height: 100%; }
|
|
||||||
.topbar { display: flex; align-items: center; 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, .bottombar.hidden { opacity: 0; pointer-events: none; }
|
|
||||||
.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); }
|
|
||||||
.icon-btn:disabled { opacity: 0.2; cursor: default; }
|
|
||||||
.ch-label { flex: 1; 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; }
|
|
||||||
.ch-title { color: var(--text-secondary); font-weight: var(--weight-medium); }
|
|
||||||
.ch-sep { color: var(--text-faint); }
|
|
||||||
.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); }
|
|
||||||
.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; }
|
|
||||||
.zoom-wrap { position: relative; flex-shrink: 0; }
|
|
||||||
.zoom-btn { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--text-faint); padding: 4px var(--sp-2); border-radius: var(--radius-sm); min-width: 36px; text-align: center; transition: color var(--t-base), background var(--t-base); }
|
|
||||||
.zoom-btn:hover { color: var(--text-secondary); 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) var(--sp-3) var(--sp-2); display: flex; flex-direction: column; align-items: center; gap: var(--sp-2); box-shadow: 0 8px 24px rgba(0,0,0,0.5); z-index: 100; min-width: 160px; animation: scaleIn 0.1s ease both; transform-origin: top center; }
|
|
||||||
.zoom-slider { width: 140px; 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; }
|
|
||||||
.zoom-reset { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); letter-spacing: var(--tracking-wide); padding: 2px var(--sp-2); border-radius: var(--radius-sm); transition: color var(--t-base), background var(--t-base); }
|
|
||||||
.zoom-reset:hover { color: var(--text-primary); background: var(--bg-overlay); }
|
|
||||||
.viewer { flex: 1; overflow-y: auto; overflow-x: hidden; display: flex; flex-direction: column; align-items: center; justify-content: center; -webkit-overflow-scrolling: touch; position: relative; }
|
|
||||||
.viewer.strip { justify-content: flex-start; padding: var(--sp-4) 0; }
|
|
||||||
.viewer:focus { outline: none; }
|
|
||||||
.img { display: block; user-select: none; image-rendering: auto; }
|
|
||||||
.img.optimize-contrast { image-rendering: -webkit-optimize-contrast; }
|
|
||||||
.fit-width { max-width: var(--max-page-width); width: 100%; height: auto; }
|
|
||||||
.fit-height { max-height: calc(100vh - 80px); width: auto; max-width: 100%; height: auto; }
|
|
||||||
.fit-screen { max-width: 100%; max-height: calc(100vh - 80px); object-fit: contain; height: auto; }
|
|
||||||
.fit-original { max-width: none; width: auto; height: auto; }
|
|
||||||
.strip-gap { margin-bottom: 8px; }
|
|
||||||
.double-wrap { display: flex; align-items: flex-start; justify-content: center; max-width: calc(var(--max-page-width) * 2); width: 100%; }
|
|
||||||
.page-half { flex: 1; min-width: 0; object-fit: contain; }
|
|
||||||
.gap-left { margin-right: 2px; }
|
|
||||||
.gap-right { margin-left: 2px; }
|
|
||||||
.center-overlay { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; }
|
|
||||||
.error-msg { color: var(--color-error); font-size: var(--text-base); }
|
|
||||||
.bottombar { display: flex; align-items: center; justify-content: center; gap: var(--sp-4); padding: var(--sp-3); border-top: 1px solid var(--border-dim); background: var(--bg-void); flex-shrink: 0; transition: opacity 0.25s ease; }
|
|
||||||
.nav-btn { display: flex; align-items: center; justify-content: center; width: 34px; height: 34px; border-radius: var(--radius-md); border: 1px solid var(--border-strong); color: var(--text-muted); transition: background var(--t-base), color var(--t-base); }
|
|
||||||
.nav-btn:hover:not(:disabled) { background: var(--bg-raised); color: var(--text-primary); }
|
|
||||||
.nav-btn:disabled { opacity: 0.25; cursor: default; }
|
|
||||||
.dl-backdrop { position: fixed; inset: 0; z-index: calc(var(--z-reader) + 10); display: flex; align-items: flex-start; justify-content: flex-end; padding: 48px var(--sp-4) 0; }
|
|
||||||
.dl-modal { background: var(--bg-raised); border: 1px solid var(--border-base); border-radius: var(--radius-xl); padding: var(--sp-3); min-width: 210px; display: flex; flex-direction: column; gap: var(--sp-1); box-shadow: 0 8px 32px rgba(0,0,0,0.6); animation: scaleIn 0.12s ease both; transform-origin: top right; }
|
|
||||||
.dl-title { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; padding: 2px var(--sp-2) var(--sp-2); border-bottom: 1px solid var(--border-dim); margin-bottom: var(--sp-1); }
|
|
||||||
.dl-option { display: flex; flex-direction: column; align-items: flex-start; gap: 2px; width: 100%; padding: 7px var(--sp-3); border-radius: var(--radius-md); font-size: var(--text-sm); color: var(--text-secondary); background: none; border: none; cursor: pointer; text-align: left; transition: background var(--t-fast), color var(--t-fast); }
|
|
||||||
.dl-option:hover:not(:disabled) { background: var(--bg-overlay); color: var(--text-primary); }
|
|
||||||
.dl-option:disabled { opacity: 0.3; cursor: default; }
|
|
||||||
.dl-sub { font-size: var(--text-xs); color: var(--text-faint); }
|
|
||||||
.dl-row { display: flex; align-items: center; gap: var(--sp-2); }
|
|
||||||
.dl-stepper { display: flex; align-items: center; gap: 2px; background: var(--bg-overlay); border: 1px solid var(--border-strong); border-radius: var(--radius-sm); overflow: hidden; flex-shrink: 0; }
|
|
||||||
.dl-step-btn { display: flex; align-items: center; justify-content: center; width: 22px; height: 28px; font-size: var(--text-base); color: var(--text-muted); background: none; border: none; cursor: pointer; line-height: 1; transition: color var(--t-fast), background var(--t-fast); }
|
|
||||||
.dl-step-btn:hover:not(:disabled) { color: var(--text-primary); background: var(--bg-raised); }
|
|
||||||
.dl-step-btn:disabled { opacity: 0.25; cursor: default; }
|
|
||||||
.dl-step-val { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-secondary); min-width: 24px; text-align: center; letter-spacing: var(--tracking-wide); }
|
|
||||||
@keyframes scaleIn { from { opacity: 0; transform: scale(0.97) } to { opacity: 1; transform: scale(1) } }
|
|
||||||
</style>
|
|
||||||
@@ -1,585 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { X, FloppyDisk, UploadSimple, DownloadSimple, ArrowLeft, Trash } from "phosphor-svelte";
|
|
||||||
import {
|
|
||||||
store, updateSettings, saveCustomTheme, deleteCustomTheme,
|
|
||||||
type CustomTheme, type ThemeTokens, DEFAULT_THEME_TOKENS,
|
|
||||||
} from "../../store/state.svelte";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
editingId?: string | null;
|
|
||||||
onClose: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { editingId = $bindable(null), onClose }: Props = $props();
|
|
||||||
|
|
||||||
// ── Token group definitions ───────────────────────────────────────────────
|
|
||||||
|
|
||||||
const TOKEN_GROUPS: { label: string; tokens: (keyof ThemeTokens)[] }[] = [
|
|
||||||
{
|
|
||||||
label: "Backgrounds",
|
|
||||||
tokens: ["bg-void", "bg-base", "bg-surface", "bg-raised", "bg-overlay", "bg-subtle"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Borders",
|
|
||||||
tokens: ["border-dim", "border-base", "border-strong", "border-focus"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Text",
|
|
||||||
tokens: ["text-primary", "text-secondary", "text-muted", "text-faint", "text-disabled"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Accent",
|
|
||||||
tokens: ["accent", "accent-dim", "accent-muted", "accent-fg", "accent-bright"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Semantic",
|
|
||||||
tokens: ["color-error", "color-error-bg", "color-success", "color-info", "color-info-bg"],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const TOKEN_LABELS: Record<keyof ThemeTokens, string> = {
|
|
||||||
"bg-void": "Void (deepest bg)",
|
|
||||||
"bg-base": "Base",
|
|
||||||
"bg-surface": "Surface",
|
|
||||||
"bg-raised": "Raised",
|
|
||||||
"bg-overlay": "Overlay",
|
|
||||||
"bg-subtle": "Subtle",
|
|
||||||
"border-dim": "Dim border",
|
|
||||||
"border-base": "Base border",
|
|
||||||
"border-strong": "Strong border",
|
|
||||||
"border-focus": "Focus ring",
|
|
||||||
"text-primary": "Primary text",
|
|
||||||
"text-secondary": "Secondary text",
|
|
||||||
"text-muted": "Muted text",
|
|
||||||
"text-faint": "Faint text",
|
|
||||||
"text-disabled": "Disabled text",
|
|
||||||
"accent": "Accent",
|
|
||||||
"accent-dim": "Accent dim",
|
|
||||||
"accent-muted": "Accent muted",
|
|
||||||
"accent-fg": "Accent foreground",
|
|
||||||
"accent-bright": "Accent bright",
|
|
||||||
"color-error": "Error",
|
|
||||||
"color-error-bg": "Error background",
|
|
||||||
"color-success": "Success",
|
|
||||||
"color-info": "Info",
|
|
||||||
"color-info-bg": "Info background",
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── State ─────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function loadInitial(): { name: string; tokens: ThemeTokens } {
|
|
||||||
if (editingId) {
|
|
||||||
const existing = store.settings.customThemes.find(t => t.id === editingId);
|
|
||||||
if (existing) return { name: existing.name, tokens: { ...existing.tokens } };
|
|
||||||
}
|
|
||||||
return { name: "My Theme", tokens: { ...DEFAULT_THEME_TOKENS } };
|
|
||||||
}
|
|
||||||
|
|
||||||
const initial = loadInitial();
|
|
||||||
let themeName: string = $state(initial.name);
|
|
||||||
let tokens: ThemeTokens = $state(initial.tokens);
|
|
||||||
let saveStatus: "idle" | "saved" = $state("idle");
|
|
||||||
let importError: string | null = $state(null);
|
|
||||||
|
|
||||||
// ── CSS vars helper ───────────────────────────────────────────────────────
|
|
||||||
function toCssVars(t: ThemeTokens): string {
|
|
||||||
return Object.entries(t).map(([k, v]) => `--${k}: ${v};`).join(" ");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Actions ───────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function handleSave() {
|
|
||||||
const name = themeName.trim() || "Untitled Theme";
|
|
||||||
const id = editingId ?? `custom:${Math.random().toString(36).slice(2, 10)}`;
|
|
||||||
const theme: CustomTheme = { id, name, tokens: { ...tokens } };
|
|
||||||
saveCustomTheme(theme);
|
|
||||||
updateSettings({ theme: id });
|
|
||||||
editingId = id;
|
|
||||||
saveStatus = "saved";
|
|
||||||
setTimeout(() => (saveStatus = "idle"), 1800);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleDelete() {
|
|
||||||
if (!editingId) { onClose(); return; }
|
|
||||||
if (!confirm(`Delete theme "${themeName}"? This cannot be undone.`)) return;
|
|
||||||
deleteCustomTheme(editingId);
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleExport() {
|
|
||||||
const data: CustomTheme = {
|
|
||||||
id: editingId ?? "custom:export",
|
|
||||||
name: themeName.trim() || "Untitled Theme",
|
|
||||||
tokens: { ...tokens },
|
|
||||||
};
|
|
||||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const a = document.createElement("a");
|
|
||||||
a.href = url;
|
|
||||||
a.download = `${data.name.replace(/[^a-z0-9]/gi, "-").toLowerCase()}-theme.json`;
|
|
||||||
a.click();
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleImport() {
|
|
||||||
const inp = document.createElement("input");
|
|
||||||
inp.type = "file";
|
|
||||||
inp.accept = ".json";
|
|
||||||
inp.onchange = async () => {
|
|
||||||
const file = inp.files?.[0];
|
|
||||||
if (!file) return;
|
|
||||||
try {
|
|
||||||
const text = await file.text();
|
|
||||||
const data = JSON.parse(text);
|
|
||||||
if (!data.tokens || typeof data.tokens !== "object") throw new Error("Invalid theme file — missing tokens");
|
|
||||||
if (typeof data.name === "string") themeName = data.name;
|
|
||||||
tokens = { ...DEFAULT_THEME_TOKENS, ...data.tokens };
|
|
||||||
importError = null;
|
|
||||||
} catch (e: any) {
|
|
||||||
importError = e.message ?? "Could not parse theme file";
|
|
||||||
setTimeout(() => (importError = null), 3000);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
inp.click();
|
|
||||||
}
|
|
||||||
|
|
||||||
function resetToDefaults() {
|
|
||||||
tokens = { ...DEFAULT_THEME_TOKENS };
|
|
||||||
}
|
|
||||||
|
|
||||||
function onKey(e: KeyboardEvent) {
|
|
||||||
if (e.key === "Escape") onClose();
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<svelte:window onkeydown={onKey} />
|
|
||||||
|
|
||||||
<!-- ── Main editor ────────────────────────────────────────────────────────────── -->
|
|
||||||
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
|
||||||
<div class="te-backdrop" onclick={onClose} role="presentation">
|
|
||||||
<div
|
|
||||||
class="te-shell"
|
|
||||||
role="dialog"
|
|
||||||
aria-label="Theme editor"
|
|
||||||
onclick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
|
|
||||||
<!-- ── Header ──────────────────────────────────────────────────────── -->
|
|
||||||
<header class="te-header">
|
|
||||||
<div class="te-header-left">
|
|
||||||
<button class="te-icon-btn" onclick={onClose} title="Close editor">
|
|
||||||
<ArrowLeft size={14} weight="bold" />
|
|
||||||
</button>
|
|
||||||
<input
|
|
||||||
bind:value={themeName}
|
|
||||||
class="te-name-input"
|
|
||||||
placeholder="Theme name"
|
|
||||||
maxlength={40}
|
|
||||||
spellcheck={false}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="te-header-actions">
|
|
||||||
{#if importError}
|
|
||||||
<span class="te-import-err">{importError}</span>
|
|
||||||
{/if}
|
|
||||||
<button class="te-action-btn" onclick={handleImport} title="Import from JSON">
|
|
||||||
<UploadSimple size={13} />
|
|
||||||
<span>Import</span>
|
|
||||||
</button>
|
|
||||||
<button class="te-action-btn" onclick={handleExport} title="Export as JSON">
|
|
||||||
<DownloadSimple size={13} />
|
|
||||||
<span>Export</span>
|
|
||||||
</button>
|
|
||||||
<button class="te-action-btn te-ghost" onclick={resetToDefaults} title="Reset all to dark defaults">
|
|
||||||
Reset
|
|
||||||
</button>
|
|
||||||
{#if editingId}
|
|
||||||
<button class="te-action-btn te-danger" onclick={handleDelete} title="Delete theme">
|
|
||||||
<Trash size={13} />
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
<button class="te-save-btn" class:saved={saveStatus === "saved"} onclick={handleSave}>
|
|
||||||
<FloppyDisk size={13} />
|
|
||||||
<span>{saveStatus === "saved" ? "Saved!" : "Save Theme"}</span>
|
|
||||||
</button>
|
|
||||||
<button class="te-icon-btn" onclick={onClose} title="Close">
|
|
||||||
<X size={14} weight="bold" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<!-- ── Body ───────────────────────────────────────────────────────── -->
|
|
||||||
<div class="te-body">
|
|
||||||
|
|
||||||
<!-- Left: live preview -->
|
|
||||||
<aside class="te-preview-pane">
|
|
||||||
<div class="te-pane-label">Live Preview</div>
|
|
||||||
|
|
||||||
<!--
|
|
||||||
FIX 1: toCssVars scoped only to this element, so only the
|
|
||||||
preview UI sees the draft tokens — not the editor shell.
|
|
||||||
-->
|
|
||||||
<div class="te-preview-ui" style={toCssVars(tokens)}>
|
|
||||||
<!-- Sidebar -->
|
|
||||||
<div class="prv-sidebar">
|
|
||||||
{#each [true, false, false, false] as active}
|
|
||||||
<div class="prv-sb-dot" class:active></div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
<!-- Main -->
|
|
||||||
<div class="prv-main">
|
|
||||||
<div class="prv-titlebar">
|
|
||||||
<div class="prv-win-dots">
|
|
||||||
<span></span><span></span><span></span>
|
|
||||||
</div>
|
|
||||||
<div class="prv-win-title">Moku</div>
|
|
||||||
</div>
|
|
||||||
<div class="prv-content">
|
|
||||||
<div class="prv-row">
|
|
||||||
<div class="prv-bar" style="width:52px;background:var(--text-secondary);opacity:0.45"></div>
|
|
||||||
<div class="prv-bar" style="width:18px;background:var(--accent)"></div>
|
|
||||||
</div>
|
|
||||||
<div class="prv-grid">
|
|
||||||
{#each Array(6) as _, i}
|
|
||||||
<div class="prv-card" class:active-card={i === 0}>
|
|
||||||
<div class="prv-cover"></div>
|
|
||||||
<div class="prv-card-line"></div>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
<div class="prv-reader">
|
|
||||||
<div class="prv-page"></div>
|
|
||||||
</div>
|
|
||||||
<div class="prv-toast">
|
|
||||||
<div class="prv-toast-dot"></div>
|
|
||||||
<div class="prv-toast-lines">
|
|
||||||
<div class="prv-bar" style="width:80%;background:var(--text-secondary)"></div>
|
|
||||||
<div class="prv-bar" style="width:55%;background:var(--text-faint);margin-top:3px"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Swatch strip — scoped to draft tokens too -->
|
|
||||||
<div class="te-swatches" style={toCssVars(tokens)}>
|
|
||||||
{#each [
|
|
||||||
["bg-base","bg-base"],["bg-surface","bg-surface"],
|
|
||||||
["accent","accent"],["accent-fg","accent-fg"],
|
|
||||||
["text-primary","text-primary"],["text-muted","text-muted"],
|
|
||||||
["color-error","color-error"],
|
|
||||||
] as [varName, label]}
|
|
||||||
<div
|
|
||||||
class="te-swatch"
|
|
||||||
style="background: var(--{varName})"
|
|
||||||
title={label}
|
|
||||||
></div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<!-- Right: token editor -->
|
|
||||||
<div class="te-editor-pane">
|
|
||||||
{#each TOKEN_GROUPS as group}
|
|
||||||
<div class="te-group">
|
|
||||||
<div class="te-group-label">{group.label}</div>
|
|
||||||
<div class="te-token-list">
|
|
||||||
{#each group.tokens as token}
|
|
||||||
<div class="te-token-row">
|
|
||||||
<span class="te-color-swatch" style="background: {tokens[token]}"></span>
|
|
||||||
<span class="te-token-name">{TOKEN_LABELS[token]}</span>
|
|
||||||
<span class="te-token-key">{token}</span>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
class="te-hex-input"
|
|
||||||
value={tokens[token]}
|
|
||||||
spellcheck={false}
|
|
||||||
oninput={(e) => {
|
|
||||||
const v = (e.target as HTMLInputElement).value.trim();
|
|
||||||
if (/^#[0-9a-fA-F]{3,8}$/.test(v)) tokens = { ...tokens, [token]: v };
|
|
||||||
}}
|
|
||||||
onblur={(e) => {
|
|
||||||
const v = (e.target as HTMLInputElement).value.trim();
|
|
||||||
if (!/^#[0-9a-fA-F]{3,8}$/.test(v)) {
|
|
||||||
(e.target as HTMLInputElement).value = tokens[token];
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
/* ── Backdrop ─────────────────────────────────────────────────────────────── */
|
|
||||||
.te-backdrop {
|
|
||||||
position: fixed; inset: 0;
|
|
||||||
background: rgba(0, 0, 0, 0.72);
|
|
||||||
z-index: 200;
|
|
||||||
/* FIX 2: center the modal instead of stretch */
|
|
||||||
display: flex; align-items: center; justify-content: center;
|
|
||||||
animation: teBackdropIn 0.14s ease both;
|
|
||||||
}
|
|
||||||
@keyframes teBackdropIn { from { opacity: 0; } to { opacity: 1; } }
|
|
||||||
|
|
||||||
/* ── Shell ────────────────────────────────────────────────────────────────── */
|
|
||||||
.te-shell {
|
|
||||||
/* FIX 2: constrained dimensions so it doesn't fill the screen */
|
|
||||||
width: calc(100% - 48px);
|
|
||||||
max-width: 1100px;
|
|
||||||
height: calc(100% - 48px);
|
|
||||||
max-height: 760px;
|
|
||||||
display: flex; flex-direction: column;
|
|
||||||
background: var(--bg-base);
|
|
||||||
border: 1px solid var(--border-base);
|
|
||||||
border-radius: 10px;
|
|
||||||
animation: teShellIn 0.2s cubic-bezier(0.22, 1, 0.36, 1) both;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
@keyframes teShellIn {
|
|
||||||
from { transform: translateY(10px) scale(0.99); opacity: 0; }
|
|
||||||
to { transform: translateY(0) scale(1); opacity: 1; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Header ───────────────────────────────────────────────────────────────── */
|
|
||||||
.te-header {
|
|
||||||
display: flex; align-items: center; justify-content: space-between;
|
|
||||||
gap: 12px; padding: 0 16px; height: 46px;
|
|
||||||
border-bottom: 1px solid var(--border-dim);
|
|
||||||
background: var(--bg-surface);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.te-header-left {
|
|
||||||
display: flex; align-items: center; gap: 8px; flex: 1; min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.te-icon-btn {
|
|
||||||
display: flex; align-items: center; justify-content: center;
|
|
||||||
width: 26px; height: 26px; border-radius: 5px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
transition: color 0.1s, background 0.1s;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.te-icon-btn:hover { color: var(--text-primary); background: var(--bg-overlay); }
|
|
||||||
|
|
||||||
.te-name-input {
|
|
||||||
flex: 1; min-width: 0;
|
|
||||||
background: none; border: none; outline: none;
|
|
||||||
font-family: var(--font-sans); font-size: 13px; font-weight: 500;
|
|
||||||
color: var(--text-primary);
|
|
||||||
border-bottom: 1px solid transparent;
|
|
||||||
padding: 3px 0;
|
|
||||||
transition: border-color 0.12s;
|
|
||||||
}
|
|
||||||
.te-name-input:focus { border-color: var(--border-focus); }
|
|
||||||
.te-name-input::placeholder { color: var(--text-faint); }
|
|
||||||
|
|
||||||
.te-header-actions {
|
|
||||||
display: flex; align-items: center; gap: 6px; flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.te-import-err {
|
|
||||||
font-family: var(--font-ui); font-size: 11px; letter-spacing: 0.04em;
|
|
||||||
color: var(--color-error); flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.te-action-btn {
|
|
||||||
display: flex; align-items: center; gap: 5px;
|
|
||||||
font-family: var(--font-ui); font-size: 11px; letter-spacing: 0.06em;
|
|
||||||
padding: 4px 10px; border-radius: 4px;
|
|
||||||
border: 1px solid var(--border-dim);
|
|
||||||
background: none; color: var(--text-muted);
|
|
||||||
cursor: pointer; flex-shrink: 0;
|
|
||||||
transition: color 0.1s, border-color 0.1s, background 0.1s;
|
|
||||||
}
|
|
||||||
.te-action-btn:hover { color: var(--text-secondary); border-color: var(--border-strong); }
|
|
||||||
|
|
||||||
.te-ghost { border-color: transparent; }
|
|
||||||
.te-ghost:hover { border-color: var(--border-dim); }
|
|
||||||
|
|
||||||
.te-danger { color: var(--color-error); border-color: transparent; }
|
|
||||||
.te-danger:hover { background: var(--color-error-bg); border-color: var(--color-error); }
|
|
||||||
|
|
||||||
.te-save-btn {
|
|
||||||
display: flex; align-items: center; gap: 5px;
|
|
||||||
font-family: var(--font-ui); font-size: 11px; letter-spacing: 0.06em;
|
|
||||||
padding: 5px 14px; border-radius: 4px;
|
|
||||||
border: 1px solid var(--accent-dim);
|
|
||||||
background: var(--accent-muted); color: var(--accent-fg);
|
|
||||||
cursor: pointer; flex-shrink: 0;
|
|
||||||
transition: filter 0.1s, background 0.12s;
|
|
||||||
}
|
|
||||||
.te-save-btn:hover { filter: brightness(1.12); }
|
|
||||||
.te-save-btn.saved { background: var(--accent-dim); border-color: var(--accent); }
|
|
||||||
|
|
||||||
/* ── Body ─────────────────────────────────────────────────────────────────── */
|
|
||||||
.te-body { flex: 1; overflow: hidden; display: flex; min-height: 0; }
|
|
||||||
|
|
||||||
/* ── Preview pane ─────────────────────────────────────────────────────────── */
|
|
||||||
.te-preview-pane {
|
|
||||||
width: 260px; flex-shrink: 0;
|
|
||||||
border-right: 1px solid var(--border-dim);
|
|
||||||
background: var(--bg-void);
|
|
||||||
display: flex; flex-direction: column;
|
|
||||||
padding: 16px; gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.te-pane-label {
|
|
||||||
font-family: var(--font-ui); font-size: 10px;
|
|
||||||
letter-spacing: 0.1em; text-transform: uppercase;
|
|
||||||
color: var(--text-faint);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* te-preview-ui receives draft CSS vars via inline style */
|
|
||||||
.te-preview-ui {
|
|
||||||
flex: 1; min-height: 0;
|
|
||||||
border-radius: 8px; overflow: hidden;
|
|
||||||
border: 1px solid var(--border-base);
|
|
||||||
display: flex; background: var(--bg-void);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Sidebar strip */
|
|
||||||
.prv-sidebar {
|
|
||||||
width: 34px; flex-shrink: 0;
|
|
||||||
background: var(--bg-surface);
|
|
||||||
border-right: 1px solid var(--border-dim);
|
|
||||||
display: flex; flex-direction: column;
|
|
||||||
align-items: center; padding: 12px 0; gap: 9px;
|
|
||||||
}
|
|
||||||
.prv-sb-dot {
|
|
||||||
width: 10px; height: 10px; border-radius: 50%;
|
|
||||||
background: var(--text-faint); opacity: 0.4;
|
|
||||||
transition: background 0.15s, opacity 0.15s;
|
|
||||||
}
|
|
||||||
.prv-sb-dot.active { background: var(--accent); opacity: 1; }
|
|
||||||
|
|
||||||
.prv-main { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
|
|
||||||
|
|
||||||
.prv-titlebar {
|
|
||||||
height: 26px; flex-shrink: 0;
|
|
||||||
background: var(--bg-raised);
|
|
||||||
border-bottom: 1px solid var(--border-dim);
|
|
||||||
display: flex; align-items: center; padding: 0 8px; gap: 7px;
|
|
||||||
}
|
|
||||||
.prv-win-dots { display: flex; gap: 4px; }
|
|
||||||
.prv-win-dots span { width: 6px; height: 6px; border-radius: 50%; background: var(--border-strong); }
|
|
||||||
.prv-win-title { font-family: var(--font-ui); font-size: 9px; letter-spacing: 0.1em; color: var(--text-faint); }
|
|
||||||
|
|
||||||
.prv-content {
|
|
||||||
flex: 1; overflow: hidden;
|
|
||||||
padding: 8px; display: flex; flex-direction: column; gap: 7px;
|
|
||||||
background: var(--bg-base);
|
|
||||||
}
|
|
||||||
|
|
||||||
.prv-row { display: flex; align-items: center; gap: 6px; flex-shrink: 0; }
|
|
||||||
.prv-bar { height: 3px; border-radius: 2px; }
|
|
||||||
|
|
||||||
.prv-grid {
|
|
||||||
display: grid; grid-template-columns: repeat(3, 1fr); gap: 4px; flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.prv-card {
|
|
||||||
border-radius: 4px; border: 1px solid var(--border-dim);
|
|
||||||
background: var(--bg-raised); overflow: hidden;
|
|
||||||
transition: border-color 0.15s;
|
|
||||||
}
|
|
||||||
.prv-card.active-card { border-color: var(--accent); }
|
|
||||||
.prv-cover { height: 34px; background: var(--bg-overlay); }
|
|
||||||
.prv-card-line { height: 3px; margin: 4px 4px; border-radius: 2px; background: var(--text-faint); opacity: 0.5; }
|
|
||||||
|
|
||||||
.prv-reader {
|
|
||||||
flex: 1; min-height: 0;
|
|
||||||
border-radius: 4px; border: 1px solid var(--border-dim);
|
|
||||||
background: var(--bg-overlay);
|
|
||||||
display: flex; align-items: center; justify-content: center;
|
|
||||||
}
|
|
||||||
.prv-page { width: 68%; height: 86%; background: var(--bg-subtle); border-radius: 2px; }
|
|
||||||
|
|
||||||
.prv-toast {
|
|
||||||
flex-shrink: 0;
|
|
||||||
display: flex; align-items: center; gap: 6px;
|
|
||||||
padding: 6px 8px; border-radius: 5px;
|
|
||||||
background: var(--bg-overlay); border: 1px solid var(--accent-dim);
|
|
||||||
}
|
|
||||||
.prv-toast-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--accent); flex-shrink: 0; }
|
|
||||||
.prv-toast-lines { flex: 1; }
|
|
||||||
|
|
||||||
/* Swatch strip */
|
|
||||||
.te-swatches { display: flex; gap: 5px; flex-wrap: wrap; flex-shrink: 0; }
|
|
||||||
.te-swatch {
|
|
||||||
width: 22px; height: 22px; border-radius: 4px;
|
|
||||||
border: 1px solid rgba(255,255,255,0.07);
|
|
||||||
flex-shrink: 0; cursor: default;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Editor pane ──────────────────────────────────────────────────────────── */
|
|
||||||
.te-editor-pane {
|
|
||||||
flex: 1; overflow-y: auto;
|
|
||||||
padding: 16px 20px;
|
|
||||||
display: flex; flex-direction: column; gap: 22px;
|
|
||||||
}
|
|
||||||
.te-editor-pane::-webkit-scrollbar { width: 4px; }
|
|
||||||
.te-editor-pane::-webkit-scrollbar-track { background: transparent; }
|
|
||||||
.te-editor-pane::-webkit-scrollbar-thumb {
|
|
||||||
background: var(--border-strong); border-radius: 9999px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.te-group { display: flex; flex-direction: column; gap: 2px; }
|
|
||||||
|
|
||||||
.te-group-label {
|
|
||||||
font-family: var(--font-ui); font-size: 10px;
|
|
||||||
letter-spacing: 0.1em; text-transform: uppercase;
|
|
||||||
color: var(--text-faint);
|
|
||||||
padding-bottom: 7px; margin-bottom: 4px;
|
|
||||||
border-bottom: 1px solid var(--border-dim);
|
|
||||||
}
|
|
||||||
|
|
||||||
.te-token-list { display: flex; flex-direction: column; gap: 1px; }
|
|
||||||
|
|
||||||
.te-token-row {
|
|
||||||
display: flex; align-items: center; gap: 10px;
|
|
||||||
padding: 5px 8px; border-radius: 5px;
|
|
||||||
transition: background 0.1s;
|
|
||||||
}
|
|
||||||
.te-token-row:hover { background: var(--bg-raised); }
|
|
||||||
|
|
||||||
.te-color-swatch {
|
|
||||||
width: 16px; height: 16px; border-radius: 4px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
border: 1px solid rgba(255,255,255,0.12);
|
|
||||||
box-shadow: 0 0 0 1px rgba(0,0,0,0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.te-token-name {
|
|
||||||
flex: 1; font-size: 12px; color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.te-token-key {
|
|
||||||
font-family: var(--font-ui); font-size: 10px;
|
|
||||||
letter-spacing: 0.05em; color: var(--text-faint);
|
|
||||||
flex-shrink: 0; min-width: 0;
|
|
||||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
|
||||||
max-width: 160px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.te-hex-input {
|
|
||||||
width: 82px; flex-shrink: 0;
|
|
||||||
font-family: var(--font-ui); font-size: 11px; letter-spacing: 0.05em;
|
|
||||||
color: var(--text-muted);
|
|
||||||
background: var(--bg-overlay);
|
|
||||||
border: 1px solid var(--border-dim);
|
|
||||||
border-radius: 3px; padding: 3px 7px;
|
|
||||||
outline: none;
|
|
||||||
transition: border-color 0.1s, color 0.1s;
|
|
||||||
}
|
|
||||||
.te-hex-input:focus { border-color: var(--border-focus); color: var(--text-primary); }
|
|
||||||
|
|
||||||
</style>
|
|
||||||
@@ -1,137 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
export interface MenuItem {
|
|
||||||
label: string;
|
|
||||||
icon?: any;
|
|
||||||
onClick: () => void;
|
|
||||||
danger?: boolean;
|
|
||||||
disabled?: boolean;
|
|
||||||
separator?: never;
|
|
||||||
}
|
|
||||||
export interface MenuSeparator { separator: true }
|
|
||||||
export type MenuEntry = MenuItem | MenuSeparator;
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
items: MenuEntry[];
|
|
||||||
onClose: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { x, y, items, onClose }: Props = $props();
|
|
||||||
|
|
||||||
let focused = $state(-1);
|
|
||||||
let el = $state<HTMLDivElement | undefined>(undefined);
|
|
||||||
|
|
||||||
const actionable = $derived(
|
|
||||||
items
|
|
||||||
.map((_, i) => i)
|
|
||||||
.filter((i) => !("separator" in items[i]) && !(items[i] as MenuItem).disabled)
|
|
||||||
);
|
|
||||||
|
|
||||||
$effect(() => { if (actionable.length && focused === -1) focused = actionable[0]; });
|
|
||||||
|
|
||||||
const pos = $derived.by(() => {
|
|
||||||
const zoom = parseFloat(document.documentElement.style.zoom || "100") / 100 || 1;
|
|
||||||
const menuW = 200, menuH = items.length * 34;
|
|
||||||
const vw = window.innerWidth / zoom, vh = window.innerHeight / zoom;
|
|
||||||
const sx = x / zoom, sy = y / zoom;
|
|
||||||
return {
|
|
||||||
left: Math.max(4, sx + menuW > vw ? sx - menuW : sx),
|
|
||||||
top: Math.max(4, sy + menuH > vh ? sy - menuH : sy),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
function onMouseDown(e: MouseEvent) {
|
|
||||||
if (el && !el.contains(e.target as Node)) onClose();
|
|
||||||
}
|
|
||||||
|
|
||||||
function onKey(e: KeyboardEvent) {
|
|
||||||
if (e.key === "Escape") { e.stopPropagation(); onClose(); return; }
|
|
||||||
if (e.key === "ArrowDown") {
|
|
||||||
e.preventDefault();
|
|
||||||
const cur = actionable.indexOf(focused);
|
|
||||||
focused = actionable[(cur + 1) % actionable.length] ?? actionable[0];
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (e.key === "ArrowUp") {
|
|
||||||
e.preventDefault();
|
|
||||||
const cur = actionable.indexOf(focused);
|
|
||||||
focused = actionable[(cur - 1 + actionable.length) % actionable.length] ?? actionable[0];
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (e.key === "Enter" && focused >= 0) {
|
|
||||||
e.preventDefault();
|
|
||||||
const item = items[focused] as MenuItem;
|
|
||||||
if (item && !item.disabled) { item.onClick(); onClose(); }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
document.addEventListener("mousedown", onMouseDown, true);
|
|
||||||
document.addEventListener("keydown", onKey, true);
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener("mousedown", onMouseDown, true);
|
|
||||||
document.removeEventListener("keydown", onKey, true);
|
|
||||||
};
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div bind:this={el} class="menu" role="menu" tabindex="-1" style="left:{pos.left}px;top:{pos.top}px"
|
|
||||||
oncontextmenu={(e) => e.preventDefault()}>
|
|
||||||
{#each items as item, i}
|
|
||||||
{#if "separator" in item}
|
|
||||||
<div class="sep"></div>
|
|
||||||
{:else}
|
|
||||||
{@const mi = item as MenuItem}
|
|
||||||
<button
|
|
||||||
class="item"
|
|
||||||
class:danger={mi.danger}
|
|
||||||
class:disabled={mi.disabled}
|
|
||||||
class:focused={focused === i}
|
|
||||||
disabled={mi.disabled}
|
|
||||||
onclick={() => { if (!mi.disabled) { mi.onClick(); onClose(); } }}
|
|
||||||
onmouseenter={() => { if (!mi.disabled) focused = i; }}
|
|
||||||
onmouseleave={() => focused = -1}
|
|
||||||
>
|
|
||||||
<span class="icon" class:icon-danger={mi.danger}>
|
|
||||||
{#if mi.icon}<mi.icon size={13} weight="light" />{/if}
|
|
||||||
</span>
|
|
||||||
<span class="label">{mi.label}</span>
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.menu {
|
|
||||||
position: fixed; z-index: 200;
|
|
||||||
background: var(--bg-raised);
|
|
||||||
border: 1px solid var(--border-base);
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
padding: var(--sp-1); min-width: 190px;
|
|
||||||
box-shadow: 0 0 0 1px rgba(0,0,0,0.08), 0 4px 12px rgba(0,0,0,0.35), 0 16px 40px rgba(0,0,0,0.25);
|
|
||||||
animation: scaleIn 0.1s ease both;
|
|
||||||
transform-origin: top left;
|
|
||||||
}
|
|
||||||
.item {
|
|
||||||
display: flex; align-items: center; gap: var(--sp-2);
|
|
||||||
width: 100%; padding: 5px var(--sp-2);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
font-size: var(--text-sm); color: var(--text-secondary);
|
|
||||||
text-align: left; cursor: pointer;
|
|
||||||
transition: background var(--t-fast), color var(--t-fast);
|
|
||||||
background: none; border: none; outline: none;
|
|
||||||
}
|
|
||||||
.item:hover:not(.disabled), .item.focused:not(.disabled) { background: var(--bg-overlay); color: var(--text-primary); }
|
|
||||||
.item.danger { color: var(--color-error); }
|
|
||||||
.item.danger:hover:not(.disabled), .item.danger.focused:not(.disabled) { background: var(--color-error-bg); }
|
|
||||||
.item.disabled { opacity: 0.3; cursor: default; pointer-events: none; }
|
|
||||||
.icon {
|
|
||||||
display: flex; align-items: center; justify-content: center;
|
|
||||||
width: 18px; height: 18px; flex-shrink: 0;
|
|
||||||
color: var(--text-faint); border-radius: var(--radius-sm);
|
|
||||||
}
|
|
||||||
.icon-danger { color: var(--color-error); opacity: 0.7; }
|
|
||||||
.label { flex: 1; line-height: 1.3; }
|
|
||||||
.sep { height: 1px; background: var(--border-dim); margin: 3px var(--sp-1); }
|
|
||||||
</style>
|
|
||||||
@@ -1,509 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { onMount, onDestroy } from "svelte";
|
|
||||||
import { X, BookmarkSimple, ArrowSquareOut, Play, CircleNotch, Books, CaretDown, FolderSimplePlus, Folder, LinkSimpleHorizontalBreak } from "phosphor-svelte";
|
|
||||||
import { gql, thumbUrl } from "../../lib/client";
|
|
||||||
import { GET_MANGA, GET_CHAPTERS, FETCH_MANGA, FETCH_CHAPTERS, UPDATE_MANGA, ENQUEUE_CHAPTERS_DOWNLOAD } from "../../lib/queries";
|
|
||||||
import { GET_ALL_MANGA } from "../../lib/queries";
|
|
||||||
import { cache, CACHE_KEYS } from "../../lib/cache";
|
|
||||||
import { store, openReader, addToast, addFolder, assignMangaToFolder, removeMangaFromFolder, checkAndMarkCompleted, linkManga, unlinkManga, setPreviewManga, setActiveManga, setNavPage, setGenreFilter } from "../../store/state.svelte";
|
|
||||||
import type { Manga, Chapter } from "../../lib/types";
|
|
||||||
|
|
||||||
let manga: Manga | null = $state(null);
|
|
||||||
let chapters: Chapter[] = $state([]);
|
|
||||||
let loadingDetail = $state(false);
|
|
||||||
let loadingChapters = $state(false);
|
|
||||||
let togglingLib = $state(false);
|
|
||||||
let descExpanded = $state(false);
|
|
||||||
let folderOpen = $state(false);
|
|
||||||
let newFolderName = $state("");
|
|
||||||
let creatingFolder = $state(false);
|
|
||||||
let queueingAll = $state(false);
|
|
||||||
let fetchError: string|null = $state(null);
|
|
||||||
let folderRef: HTMLDivElement = $state() as HTMLDivElement;
|
|
||||||
|
|
||||||
let linkPickerOpen = $state(false);
|
|
||||||
let linkSearch = $state("");
|
|
||||||
let allMangaForLink: Manga[] = $state([]);
|
|
||||||
let loadingLinkList = $state(false);
|
|
||||||
|
|
||||||
const linkedIds = $derived(store.previewManga ? (store.settings.mangaLinks?.[store.previewManga.id] ?? []) : []);
|
|
||||||
|
|
||||||
const linkPickerResults = $derived.by(() => {
|
|
||||||
const others = allMangaForLink.filter((m) => m.id !== store.previewManga?.id);
|
|
||||||
const q = linkSearch.trim().toLowerCase();
|
|
||||||
const filtered = q ? others.filter(m => m.title.toLowerCase().includes(q)) : others;
|
|
||||||
const linked = filtered.filter(m => linkedIds.includes(m.id));
|
|
||||||
const rest = filtered.filter(m => !linkedIds.includes(m.id)).slice(0, 30);
|
|
||||||
return [...linked, ...rest];
|
|
||||||
});
|
|
||||||
|
|
||||||
async function openLinkPicker() {
|
|
||||||
linkPickerOpen = true; linkSearch = "";
|
|
||||||
if (allMangaForLink.length) return;
|
|
||||||
loadingLinkList = true;
|
|
||||||
gql<{ mangas: { nodes: Manga[] } }>(GET_ALL_MANGA)
|
|
||||||
.then(d => { allMangaForLink = d.mangas.nodes; })
|
|
||||||
.catch(console.error)
|
|
||||||
.finally(() => { loadingLinkList = false; });
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeLinkPicker() { linkPickerOpen = false; linkSearch = ""; }
|
|
||||||
|
|
||||||
function handleLink(other: Manga) {
|
|
||||||
if (!store.previewManga) return;
|
|
||||||
if (linkedIds.includes(other.id)) unlinkManga(store.previewManga.id, other.id);
|
|
||||||
else linkManga(store.previewManga.id, other.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
let detailAbort: AbortController | null = null;
|
|
||||||
let chapterAbort: AbortController | null = null;
|
|
||||||
|
|
||||||
function close() {
|
|
||||||
detailAbort?.abort(); chapterAbort?.abort();
|
|
||||||
setPreviewManga(null);
|
|
||||||
manga = null; chapters = []; descExpanded = false;
|
|
||||||
folderOpen = false; creatingFolder = false; newFolderName = ""; fetchError = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDate(d: Date) { return d.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" }); }
|
|
||||||
|
|
||||||
const displayManga = $derived(manga ?? store.previewManga);
|
|
||||||
const totalCount = $derived(chapters.length);
|
|
||||||
const readCount = $derived(chapters.filter((c) => c.isRead).length);
|
|
||||||
const unreadCount = $derived(totalCount - readCount);
|
|
||||||
const downloadedCount = $derived(chapters.filter((c) => c.isDownloaded).length);
|
|
||||||
const bookmarkCount = $derived(chapters.filter((c) => c.isBookmarked).length);
|
|
||||||
const inLibrary = $derived(manga?.inLibrary ?? store.previewManga?.inLibrary ?? false);
|
|
||||||
const scanlators = $derived([...new Set(chapters.map((c) => c.scanlator).filter((s): s is string => !!s?.trim()))]);
|
|
||||||
const uploadDates = $derived(chapters.map((c) => c.uploadDate ? new Date(c.uploadDate).getTime() : null).filter((d): d is number => d !== null && !isNaN(d)));
|
|
||||||
const firstUpload = $derived(uploadDates.length ? new Date(Math.min(...uploadDates)) : null);
|
|
||||||
const lastUpload = $derived(uploadDates.length ? new Date(Math.max(...uploadDates)) : null);
|
|
||||||
const statusLabel = $derived(displayManga?.status ? displayManga.status.charAt(0) + displayManga.status.slice(1).toLowerCase() : null);
|
|
||||||
const assignedFolders = $derived(store.previewManga ? store.settings.folders.filter((f) => f.mangaIds.includes(store.previewManga!.id)) : []);
|
|
||||||
|
|
||||||
const continueChapter = $derived.by(() => {
|
|
||||||
if (!chapters.length) return null;
|
|
||||||
const inProgress = chapters.find((c) => !c.isRead && (c.lastPageRead ?? 0) > 0);
|
|
||||||
if (inProgress) return { ch: inProgress, label: `Continue · Ch.${inProgress.chapterNumber}` };
|
|
||||||
const firstUnread = chapters.find((c) => !c.isRead);
|
|
||||||
if (firstUnread) return { ch: firstUnread, label: `Start · Ch.${firstUnread.chapterNumber}` };
|
|
||||||
return { ch: chapters[0], label: "Read again" };
|
|
||||||
});
|
|
||||||
|
|
||||||
$effect(() => { if (store.previewManga) load(store.previewManga.id); });
|
|
||||||
|
|
||||||
async function load(id: number) {
|
|
||||||
detailAbort?.abort(); chapterAbort?.abort();
|
|
||||||
const dCtrl = new AbortController(), cCtrl = new AbortController();
|
|
||||||
detailAbort = dCtrl; chapterAbort = cCtrl;
|
|
||||||
manga = store.previewManga as Manga;
|
|
||||||
chapters = []; descExpanded = false; fetchError = null;
|
|
||||||
loadingDetail = true; loadingChapters = true;
|
|
||||||
|
|
||||||
(async (): Promise<Manga> => {
|
|
||||||
const key = CACHE_KEYS.MANGA(id);
|
|
||||||
if (cache.has(key)) return cache.get(key, () => Promise.resolve(store.previewManga as Manga)) as Promise<Manga>;
|
|
||||||
try {
|
|
||||||
const d = await gql<{ fetchManga: { manga: Manga } }>(FETCH_MANGA, { id }, dCtrl.signal);
|
|
||||||
return d.fetchManga.manga;
|
|
||||||
} catch (e: any) {
|
|
||||||
if (e?.name === "AbortError") throw e;
|
|
||||||
const local = await gql<{ manga: Manga }>(GET_MANGA, { id }, dCtrl.signal).then((d) => d.manga);
|
|
||||||
if (local) return local;
|
|
||||||
throw new Error("Could not load manga details");
|
|
||||||
}
|
|
||||||
})().then((fullManga) => {
|
|
||||||
if (dCtrl.signal.aborted) return;
|
|
||||||
if (!cache.has(CACHE_KEYS.MANGA(id))) cache.get(CACHE_KEYS.MANGA(id), () => Promise.resolve(fullManga));
|
|
||||||
manga = fullManga; loadingDetail = false;
|
|
||||||
}).catch((e) => {
|
|
||||||
if (e?.name === "AbortError") return;
|
|
||||||
manga = store.previewManga as Manga;
|
|
||||||
fetchError = "Could not load full details — showing cached data";
|
|
||||||
loadingDetail = false;
|
|
||||||
});
|
|
||||||
|
|
||||||
gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId: id }, cCtrl.signal)
|
|
||||||
.then(async (d) => {
|
|
||||||
if (cCtrl.signal.aborted) return;
|
|
||||||
let nodes = [...d.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder);
|
|
||||||
if (nodes.length === 0) {
|
|
||||||
try {
|
|
||||||
const fetched = await gql<{ fetchChapters: { chapters: Chapter[] } }>(FETCH_CHAPTERS, { mangaId: id }, cCtrl.signal);
|
|
||||||
if (!cCtrl.signal.aborted) nodes = [...fetched.fetchChapters.chapters].sort((a, b) => a.sourceOrder - b.sourceOrder);
|
|
||||||
} catch (e: any) { if (e?.name === "AbortError") return; }
|
|
||||||
}
|
|
||||||
if (!cCtrl.signal.aborted) {
|
|
||||||
chapters = nodes;
|
|
||||||
if (nodes.length > 0) checkAndMarkCompleted(id, nodes);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(() => {})
|
|
||||||
.finally(() => { if (!cCtrl.signal.aborted) loadingChapters = false; });
|
|
||||||
}
|
|
||||||
|
|
||||||
async function toggleLibrary() {
|
|
||||||
if (!manga) return;
|
|
||||||
togglingLib = true;
|
|
||||||
const next = !manga.inLibrary;
|
|
||||||
await gql(UPDATE_MANGA, { id: manga.id, inLibrary: next }).catch(console.error);
|
|
||||||
manga = { ...manga, inLibrary: next };
|
|
||||||
cache.clear(CACHE_KEYS.MANGA(manga.id));
|
|
||||||
cache.get(CACHE_KEYS.MANGA(manga.id), () => Promise.resolve(manga!));
|
|
||||||
cache.clear(CACHE_KEYS.LIBRARY);
|
|
||||||
togglingLib = false;
|
|
||||||
addToast({ kind: "success", title: next ? "Added to library" : "Removed from library" });
|
|
||||||
}
|
|
||||||
|
|
||||||
async function downloadAll() {
|
|
||||||
const ids = chapters.filter((c) => !c.isDownloaded && !c.isRead).map((c) => c.id);
|
|
||||||
if (!ids.length) return;
|
|
||||||
queueingAll = true;
|
|
||||||
await gql(ENQUEUE_CHAPTERS_DOWNLOAD, { chapterIds: ids }).catch(console.error);
|
|
||||||
addToast({ kind: "download", title: "Downloading", body: `${ids.length} chapters queued` });
|
|
||||||
queueingAll = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function openSeriesDetail() {
|
|
||||||
if (!displayManga) return;
|
|
||||||
setActiveManga(displayManga);
|
|
||||||
setNavPage("library");
|
|
||||||
close();
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleFolderCreate() {
|
|
||||||
const name = newFolderName.trim();
|
|
||||||
if (!name || !store.previewManga) return;
|
|
||||||
const id = addFolder(name);
|
|
||||||
assignMangaToFolder(id, store.previewManga.id);
|
|
||||||
newFolderName = ""; creatingFolder = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleFolderOutside(e: MouseEvent) {
|
|
||||||
if (folderRef && !folderRef.contains(e.target as Node)) { folderOpen = false; creatingFolder = false; newFolderName = ""; }
|
|
||||||
}
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (folderOpen) {
|
|
||||||
setTimeout(() => document.addEventListener("mousedown", handleFolderOutside), 0);
|
|
||||||
return () => document.removeEventListener("mousedown", handleFolderOutside);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
function onKey(e: KeyboardEvent) { if (e.key === "Escape") close(); }
|
|
||||||
onMount(() => window.addEventListener("keydown", onKey));
|
|
||||||
onDestroy(() => { window.removeEventListener("keydown", onKey); detailAbort?.abort(); chapterAbort?.abort(); });
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#if store.previewManga}
|
|
||||||
<div class="backdrop" role="presentation" onclick={(e) => { if (e.target === e.currentTarget) close(); }} onkeydown={(e) => { if (e.key === "Escape") close(); }}>
|
|
||||||
<div class="modal" role="dialog" aria-label="Manga preview">
|
|
||||||
|
|
||||||
<div class="cover-col">
|
|
||||||
<div class="cover-wrap">
|
|
||||||
<img src={thumbUrl(store.previewManga.thumbnailUrl)} alt={displayManga?.title} class="cover" />
|
|
||||||
{#if loadingDetail}
|
|
||||||
<div class="cover-spinner"><CircleNotch size={18} weight="light" class="anim-spin" /></div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<div class="cover-actions">
|
|
||||||
|
|
||||||
<button class="action-btn" class:active={inLibrary} onclick={toggleLibrary} disabled={togglingLib || loadingDetail}>
|
|
||||||
<span class="action-icon"><BookmarkSimple size={13} weight={inLibrary ? "fill" : "light"} /></span>
|
|
||||||
<span class="action-label">{togglingLib ? "…" : inLibrary ? "In Library" : "Add to Library"}</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button class="action-btn" onclick={openSeriesDetail}>
|
|
||||||
<span class="action-icon"><Books size={13} weight="light" /></span>
|
|
||||||
<span class="action-label">Series Detail</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div class="folder-wrap" bind:this={folderRef}>
|
|
||||||
<button class="action-btn" class:active={assignedFolders.length > 0} onclick={() => folderOpen = !folderOpen}>
|
|
||||||
<span class="action-icon"><FolderSimplePlus size={13} weight={assignedFolders.length > 0 ? "fill" : "light"} /></span>
|
|
||||||
<span class="action-label">{assignedFolders.length > 0 ? assignedFolders.map((f) => f.name).join(", ") : "Add to folder"}</span>
|
|
||||||
</button>
|
|
||||||
{#if folderOpen}
|
|
||||||
<div class="folder-menu">
|
|
||||||
{#if store.settings.folders.length === 0 && !creatingFolder}<p class="folder-empty">No folders yet</p>{/if}
|
|
||||||
{#each store.settings.folders as f}
|
|
||||||
{@const isIn = store.previewManga ? f.mangaIds.includes(store.previewManga.id) : false}
|
|
||||||
<button class="folder-item" class:folder-item-on={isIn}
|
|
||||||
onclick={() => store.previewManga && (isIn ? removeMangaFromFolder(f.id, store.previewManga.id) : assignMangaToFolder(f.id, store.previewManga.id))}>
|
|
||||||
<Folder size={12} weight={isIn ? "fill" : "light"} />{isIn ? "✓ " : ""}{f.name}
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
<div class="folder-divider"></div>
|
|
||||||
{#if creatingFolder}
|
|
||||||
<div class="folder-create-row">
|
|
||||||
<input class="folder-input" placeholder="Folder name…" bind:value={newFolderName}
|
|
||||||
onkeydown={(e) => { if (e.key === "Enter") handleFolderCreate(); if (e.key === "Escape") { creatingFolder = false; newFolderName = ""; } }}
|
|
||||||
use:focusAction />
|
|
||||||
<button class="folder-ok" onclick={handleFolderCreate} disabled={!newFolderName.trim()}>Add</button>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<button class="folder-new" onclick={() => creatingFolder = true}>+ New folder</button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button class="action-btn" class:active={linkedIds.length > 0} onclick={openLinkPicker}>
|
|
||||||
<span class="action-icon"><LinkSimpleHorizontalBreak size={13} weight={linkedIds.length > 0 ? "fill" : "light"} /></span>
|
|
||||||
<span class="action-label">{linkedIds.length > 0 ? `Series Link (${linkedIds.length})` : "Series Link"}</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="content">
|
|
||||||
<div class="content-header">
|
|
||||||
<div class="title-block">
|
|
||||||
<h2 class="title">{displayManga?.title}</h2>
|
|
||||||
{#if loadingDetail}
|
|
||||||
<div class="sk-byline"></div>
|
|
||||||
{:else if displayManga?.author || displayManga?.artist}
|
|
||||||
<p class="byline">{[displayManga?.author, displayManga?.artist].filter(Boolean).filter((v, i, a) => a.indexOf(v) === i).join(" · ")}</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<button class="close-btn" onclick={close}><X size={15} weight="light" /></button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="content-body">
|
|
||||||
{#if fetchError}<div class="error-banner">{fetchError}</div>{/if}
|
|
||||||
|
|
||||||
{#if loadingDetail}
|
|
||||||
<div class="sk-row"><div class="sk-badge"></div><div class="sk-badge" style="width:72px"></div></div>
|
|
||||||
{:else}
|
|
||||||
<div class="badges">
|
|
||||||
{#if statusLabel}<span class="badge" class:badge-green={displayManga?.status === "ONGOING"}>{statusLabel}</span>{/if}
|
|
||||||
{#if displayManga?.source}<span class="badge">{displayManga.source.displayName}</span>{/if}
|
|
||||||
{#if inLibrary}<span class="badge badge-accent">In Library</span>{/if}
|
|
||||||
{#if !loadingChapters && unreadCount > 0}<span class="badge badge-unread">{unreadCount} unread</span>{/if}
|
|
||||||
{#if !loadingChapters && bookmarkCount > 0}<span class="badge">{bookmarkCount} bookmarked</span>{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="chapter-box">
|
|
||||||
{#if loadingChapters}
|
|
||||||
<div class="chapter-loading">
|
|
||||||
<CircleNotch size={13} weight="light" class="anim-spin" style="color:var(--text-faint)" />
|
|
||||||
<span class="chapter-loading-label">Loading chapters…</span>
|
|
||||||
</div>
|
|
||||||
{:else if totalCount > 0}
|
|
||||||
<div class="chapter-meta">
|
|
||||||
<span class="chapter-label">
|
|
||||||
{totalCount} {totalCount === 1 ? "chapter" : "chapters"}{readCount > 0 ? ` · ${readCount} read` : ""}{unreadCount > 0 && readCount > 0 ? ` · ${unreadCount} left` : ""}{downloadedCount > 0 ? ` · ${downloadedCount} dl` : ""}
|
|
||||||
</span>
|
|
||||||
{#if unreadCount > 0}
|
|
||||||
<button class="dl-all-btn" onclick={downloadAll} disabled={queueingAll}>
|
|
||||||
{#if queueingAll}<CircleNotch size={11} weight="light" class="anim-spin" />{/if}
|
|
||||||
{queueingAll ? "Queuing…" : "Download unread"}
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{#if readCount > 0}
|
|
||||||
<div class="progress-track"><div class="progress-fill" style="width:{(readCount / totalCount) * 100}%"></div></div>
|
|
||||||
{/if}
|
|
||||||
{#if continueChapter}
|
|
||||||
<button class="read-btn" onclick={() => { openReader(continueChapter!.ch, chapters); close(); }}>
|
|
||||||
<Play size={12} weight="fill" />{continueChapter.label}
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
{:else if !loadingDetail}
|
|
||||||
<span class="chapter-label" style="color:var(--text-faint)">No chapters in local library</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if loadingDetail}
|
|
||||||
<div class="sk-desc">
|
|
||||||
<div class="sk-line" style="width:100%"></div>
|
|
||||||
<div class="sk-line" style="width:88%"></div>
|
|
||||||
<div class="sk-line" style="width:70%"></div>
|
|
||||||
</div>
|
|
||||||
{:else if displayManga?.description}
|
|
||||||
<div class="desc-block">
|
|
||||||
<p class="desc" class:desc-open={descExpanded}>{displayManga.description}</p>
|
|
||||||
{#if displayManga.description.length > 220}
|
|
||||||
<button class="desc-toggle" onclick={() => descExpanded = !descExpanded}>
|
|
||||||
{descExpanded ? "Show less" : "Show more"}
|
|
||||||
<CaretDown size={10} weight="light" style="transform:{descExpanded ? 'rotate(180deg)' : 'none'};transition:transform 0.15s ease" />
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if !loadingDetail && displayManga?.genre?.length}
|
|
||||||
<div class="genres">
|
|
||||||
{#each displayManga.genre as g}
|
|
||||||
<button class="genre-tag" onclick={() => { setGenreFilter(g); setNavPage("explore"); close(); }}>{g}</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if !loadingDetail}
|
|
||||||
<div class="meta-table">
|
|
||||||
{#if displayManga?.author}<div class="meta-row"><span class="meta-key">Author</span><span class="meta-val">{displayManga.author}</span></div>{/if}
|
|
||||||
{#if displayManga?.artist && displayManga.artist !== displayManga.author}<div class="meta-row"><span class="meta-key">Artist</span><span class="meta-val">{displayManga.artist}</span></div>{/if}
|
|
||||||
{#if statusLabel}<div class="meta-row"><span class="meta-key">Status</span><span class="meta-val">{statusLabel}</span></div>{/if}
|
|
||||||
{#if displayManga?.source}<div class="meta-row"><span class="meta-key">Source</span><span class="meta-val">{displayManga.source.displayName}</span></div>{/if}
|
|
||||||
{#if !loadingChapters && scanlators.length > 0}<div class="meta-row"><span class="meta-key">{scanlators.length === 1 ? "Scanlator" : "Scanlators"}</span><span class="meta-val">{scanlators.join(", ")}</span></div>{/if}
|
|
||||||
{#if !loadingChapters && firstUpload && lastUpload}
|
|
||||||
<div class="meta-row">
|
|
||||||
<span class="meta-key">Published</span>
|
|
||||||
<span class="meta-val">{firstUpload.getTime() === lastUpload.getTime() ? formatDate(firstUpload) : `${formatDate(firstUpload)} – ${formatDate(lastUpload)}`}</span>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{#if !loadingChapters && downloadedCount > 0}<div class="meta-row"><span class="meta-key">Downloaded</span><span class="meta-val">{downloadedCount} / {totalCount} chapters</span></div>{/if}
|
|
||||||
{#if displayManga?.realUrl}<div class="meta-row"><span class="meta-key">Link</span><a href={displayManga.realUrl} target="_blank" rel="noreferrer" class="meta-link">Open <ArrowSquareOut size={11} weight="light" /></a></div>{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if linkPickerOpen}
|
|
||||||
<div class="link-backdrop" role="presentation"
|
|
||||||
onclick={(e) => { if (e.target === e.currentTarget) closeLinkPicker(); }}
|
|
||||||
onkeydown={(e) => e.key === "Escape" && closeLinkPicker()}>
|
|
||||||
<div class="link-modal">
|
|
||||||
<div class="link-header">
|
|
||||||
<span class="link-title">Link as same series</span>
|
|
||||||
<button class="close-btn" onclick={closeLinkPicker}><X size={14} weight="light" /></button>
|
|
||||||
</div>
|
|
||||||
<p class="link-hint">
|
|
||||||
Mark two manga as the same series so duplicates are merged in search and discover.
|
|
||||||
Click a linked entry again to unlink.
|
|
||||||
</p>
|
|
||||||
<div class="link-search-wrap">
|
|
||||||
<input class="link-search" placeholder="Search your library…" bind:value={linkSearch} use:focusAction />
|
|
||||||
</div>
|
|
||||||
<div class="link-list">
|
|
||||||
{#if loadingLinkList}
|
|
||||||
<p class="link-empty">Loading…</p>
|
|
||||||
{:else if linkPickerResults.length === 0}
|
|
||||||
<p class="link-empty">No results</p>
|
|
||||||
{:else}
|
|
||||||
{#each linkPickerResults as m (m.id)}
|
|
||||||
{@const isLinked = linkedIds.includes(m.id)}
|
|
||||||
<button class="link-row" class:link-row-linked={isLinked} onclick={() => handleLink(m)}>
|
|
||||||
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} class="link-thumb" loading="lazy" decoding="async" />
|
|
||||||
<div class="link-info">
|
|
||||||
<span class="link-manga-title">{m.title}</span>
|
|
||||||
{#if m.source?.displayName}<span class="link-source">{m.source.displayName}</span>{/if}
|
|
||||||
</div>
|
|
||||||
<span class="link-status">{isLinked ? "✓ Linked" : "Link"}</span>
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<script module>
|
|
||||||
function focusAction(node: HTMLElement) { node.focus(); }
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.72); z-index: var(--z-settings); display: flex; align-items: center; justify-content: center; animation: fadeIn 0.12s ease both; backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px); }
|
|
||||||
.modal { width: min(800px, calc(100vw - 48px)); height: min(560px, calc(100vh - 80px)); display: flex; background: var(--bg-surface); border: 1px solid var(--border-base); border-radius: var(--radius-xl); overflow: hidden; animation: scaleIn 0.16s ease both; box-shadow: 0 0 0 1px var(--border-dim), 0 24px 64px rgba(0,0,0,0.6); }
|
|
||||||
.cover-col { width: 190px; flex-shrink: 0; background: var(--bg-raised); border-right: 1px solid var(--border-dim); display: flex; flex-direction: column; padding: var(--sp-5) var(--sp-4) var(--sp-4); gap: var(--sp-3); overflow: hidden; }
|
|
||||||
.cover-wrap { position: relative; width: 100%; }
|
|
||||||
.cover { width: 100%; aspect-ratio: 2/3; object-fit: cover; border-radius: var(--radius-md); border: 1px solid var(--border-dim); display: block; }
|
|
||||||
.cover-spinner { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; background: rgba(0,0,0,0.35); border-radius: var(--radius-md); color: var(--text-faint); }
|
|
||||||
.cover-actions { display: flex; flex-direction: column; gap: var(--sp-2); }
|
|
||||||
.action-btn { display: flex; align-items: center; gap: var(--sp-2); width: 100%; padding: 7px var(--sp-3); border-radius: var(--radius-md); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); border: 1px solid var(--border-strong); background: none; color: var(--text-muted); cursor: pointer; text-align: left; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
|
|
||||||
.action-btn:hover:not(:disabled) { color: var(--accent-fg); border-color: var(--accent); background: var(--accent-muted); }
|
|
||||||
.action-btn:disabled { opacity: 0.4; cursor: default; }
|
|
||||||
.action-btn.active { background: var(--accent-muted); border-color: var(--accent-dim); color: var(--accent-fg); }
|
|
||||||
.action-icon { display: flex; align-items: center; justify-content: center; width: 16px; flex-shrink: 0; }
|
|
||||||
.action-label { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
|
|
||||||
.folder-wrap { position: relative; width: 100%; }
|
|
||||||
.folder-menu { position: absolute; bottom: calc(100% + 4px); left: 0; right: 0; background: var(--bg-raised); border: 1px solid var(--border-base); border-radius: var(--radius-md); padding: var(--sp-1); display: flex; flex-direction: column; gap: 1px; box-shadow: 0 8px 24px rgba(0,0,0,0.5); z-index: 10; animation: scaleIn 0.1s ease both; transform-origin: bottom center; }
|
|
||||||
.folder-empty { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); padding: var(--sp-2) var(--sp-3); }
|
|
||||||
.folder-item { display: flex; align-items: center; gap: var(--sp-2); padding: 6px var(--sp-3); border-radius: var(--radius-sm); font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); background: none; border: none; cursor: pointer; text-align: left; transition: background var(--t-fast), color var(--t-fast); }
|
|
||||||
.folder-item:hover { background: var(--bg-overlay); color: var(--text-primary); }
|
|
||||||
.folder-item.folder-item-on { color: var(--accent-fg); }
|
|
||||||
.folder-divider { height: 1px; background: var(--border-dim); margin: var(--sp-1) 0; }
|
|
||||||
.folder-create-row { display: flex; gap: var(--sp-1); padding: var(--sp-1); }
|
|
||||||
.folder-input { flex: 1; background: var(--bg-overlay); border: 1px solid var(--border-strong); border-radius: var(--radius-sm); padding: 4px 8px; color: var(--text-secondary); font-family: var(--font-ui); font-size: var(--text-xs); outline: none; min-width: 0; }
|
|
||||||
.folder-input:focus { border-color: var(--border-focus); }
|
|
||||||
.folder-ok { font-family: var(--font-ui); font-size: var(--text-xs); padding: 4px 8px; border-radius: var(--radius-sm); border: 1px solid var(--border-strong); background: none; color: var(--text-muted); cursor: pointer; flex-shrink: 0; transition: color var(--t-base); }
|
|
||||||
.folder-ok:disabled { opacity: 0.4; cursor: default; }
|
|
||||||
.folder-ok:not(:disabled):hover { color: var(--accent-fg); border-color: var(--accent); }
|
|
||||||
.folder-new { padding: 6px var(--sp-3); border-radius: var(--radius-sm); font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); background: none; border: none; cursor: pointer; text-align: left; width: 100%; transition: color var(--t-fast); }
|
|
||||||
.folder-new:hover { color: var(--accent-fg); }
|
|
||||||
.content { flex: 1; display: flex; flex-direction: column; overflow: hidden; min-width: 0; }
|
|
||||||
.content-header { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--sp-4); padding: var(--sp-5) var(--sp-6) var(--sp-4); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
|
|
||||||
.title-block { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: var(--sp-1); }
|
|
||||||
.title { font-size: var(--text-lg); font-weight: var(--weight-medium); color: var(--text-primary); letter-spacing: var(--tracking-tight); line-height: var(--leading-tight); }
|
|
||||||
.byline { font-size: var(--text-sm); color: var(--text-muted); line-height: var(--leading-snug); }
|
|
||||||
.sk-byline { height: 14px; width: 55%; background: var(--bg-overlay); border-radius: var(--radius-sm); animation: pulse 1.4s ease infinite; }
|
|
||||||
.close-btn { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: var(--radius-sm); color: var(--text-faint); background: none; border: none; cursor: pointer; flex-shrink: 0; transition: color var(--t-base), background var(--t-base); }
|
|
||||||
.close-btn:hover { color: var(--text-muted); background: var(--bg-raised); }
|
|
||||||
.content-body { flex: 1; overflow-y: auto; padding: var(--sp-5) var(--sp-6); display: flex; flex-direction: column; gap: var(--sp-4); scrollbar-width: none; }
|
|
||||||
.content-body::-webkit-scrollbar { display: none; }
|
|
||||||
.error-banner { font-family: var(--font-ui); font-size: var(--text-xs); color: #f59e0b; background: rgba(245,158,11,0.1); border: 1px solid rgba(245,158,11,0.25); border-radius: var(--radius-sm); padding: 6px var(--sp-3); }
|
|
||||||
.sk-row { display: flex; gap: var(--sp-2); align-items: center; }
|
|
||||||
.sk-badge { height: 20px; width: 54px; background: var(--bg-overlay); border-radius: var(--radius-sm); animation: pulse 1.4s ease infinite; }
|
|
||||||
.sk-desc { display: flex; flex-direction: column; gap: 8px; padding: var(--sp-2) 0; }
|
|
||||||
.sk-line { height: 13px; background: var(--bg-overlay); border-radius: var(--radius-sm); animation: pulse 1.4s ease infinite; }
|
|
||||||
.badges { display: flex; flex-wrap: wrap; gap: var(--sp-2); }
|
|
||||||
.badge { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); text-transform: uppercase; padding: 3px 8px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: var(--bg-raised); color: var(--text-faint); }
|
|
||||||
.badge-green { background: rgba(34,197,94,0.12); border-color: rgba(34,197,94,0.3); color: #22c55e; }
|
|
||||||
.badge-accent { background: var(--accent-muted); border-color: var(--accent-dim); color: var(--accent-fg); }
|
|
||||||
.badge-unread { background: rgba(245,158,11,0.12); border-color: rgba(245,158,11,0.3); color: #f59e0b; }
|
|
||||||
.chapter-box { display: flex; flex-direction: column; gap: var(--sp-3); padding: var(--sp-4); background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); }
|
|
||||||
.chapter-loading { display: flex; align-items: center; gap: var(--sp-2); }
|
|
||||||
.chapter-loading-label { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
|
||||||
.chapter-meta { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-3); }
|
|
||||||
.chapter-label { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); letter-spacing: var(--tracking-wide); }
|
|
||||||
.dl-all-btn { display: flex; align-items: center; gap: var(--sp-1); font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 3px 10px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); cursor: pointer; flex-shrink: 0; transition: color var(--t-base), border-color var(--t-base); }
|
|
||||||
.dl-all-btn:hover:not(:disabled) { color: var(--text-muted); border-color: var(--border-strong); }
|
|
||||||
.dl-all-btn:disabled { opacity: 0.5; cursor: default; }
|
|
||||||
.progress-track { height: 3px; background: var(--bg-overlay); border-radius: var(--radius-full); overflow: hidden; }
|
|
||||||
.progress-fill { height: 100%; background: var(--accent); border-radius: var(--radius-full); transition: width 0.3s ease; }
|
|
||||||
.read-btn { display: flex; align-items: center; gap: var(--sp-2); padding: 8px var(--sp-4); border-radius: var(--radius-md); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); background: var(--accent-muted); border: 1px solid var(--accent-dim); color: var(--accent-fg); cursor: pointer; align-self: flex-start; transition: filter var(--t-base); }
|
|
||||||
.read-btn:hover { filter: brightness(1.1); }
|
|
||||||
.desc-block { display: flex; flex-direction: column; gap: var(--sp-2); border-top: 1px solid var(--border-dim); padding-top: var(--sp-3); }
|
|
||||||
.desc { font-size: var(--text-sm); color: var(--text-muted); line-height: var(--leading-base); display: -webkit-box; -webkit-line-clamp: 5; -webkit-box-orient: vertical; overflow: hidden; }
|
|
||||||
.desc.desc-open { display: block; -webkit-line-clamp: unset; overflow: visible; }
|
|
||||||
.desc-toggle { display: flex; align-items: center; gap: var(--sp-1); font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); background: none; border: none; cursor: pointer; padding: 0; align-self: flex-start; transition: color var(--t-base); }
|
|
||||||
.desc-toggle:hover { color: var(--accent-fg); }
|
|
||||||
.genres { display: flex; flex-wrap: wrap; gap: var(--sp-1); }
|
|
||||||
.genre-tag { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 3px 8px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: var(--bg-raised); color: var(--text-faint); cursor: pointer; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
|
|
||||||
.genre-tag:hover { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
|
|
||||||
.meta-table { display: flex; flex-direction: column; gap: 1px; border-top: 1px solid var(--border-dim); padding-top: var(--sp-3); }
|
|
||||||
.meta-row { display: flex; align-items: baseline; gap: var(--sp-3); padding: 5px 0; }
|
|
||||||
.meta-key { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); text-transform: uppercase; min-width: 56px; flex-shrink: 0; }
|
|
||||||
.meta-val { font-size: var(--text-sm); color: var(--text-secondary); line-height: var(--leading-snug); }
|
|
||||||
.meta-link { display: inline-flex; align-items: center; gap: 4px; font-size: var(--text-sm); color: var(--accent-fg); text-decoration: none; transition: opacity var(--t-base); }
|
|
||||||
.meta-link:hover { opacity: 0.75; }
|
|
||||||
.link-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.65); z-index: calc(var(--z-settings) + 1); display: flex; align-items: center; justify-content: center; backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px); animation: fadeIn 0.1s ease both; }
|
|
||||||
.link-modal { width: min(460px, calc(100vw - 48px)); max-height: 70vh; display: flex; flex-direction: column; background: var(--bg-surface); border: 1px solid var(--border-base); border-radius: var(--radius-xl); overflow: hidden; box-shadow: 0 24px 64px rgba(0,0,0,0.6); animation: scaleIn 0.14s ease both; }
|
|
||||||
.link-header { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-4) var(--sp-5); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
|
|
||||||
.link-title { font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-secondary); }
|
|
||||||
.link-hint { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); line-height: var(--leading-snug); padding: var(--sp-3) var(--sp-5) 0; flex-shrink: 0; }
|
|
||||||
.link-search-wrap { padding: var(--sp-3) var(--sp-4); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
|
|
||||||
.link-search { width: 100%; background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 6px 10px; color: var(--text-primary); font-size: var(--text-sm); outline: none; transition: border-color var(--t-base); }
|
|
||||||
.link-search:focus { border-color: var(--border-strong); }
|
|
||||||
.link-list { flex: 1; overflow-y: auto; padding: var(--sp-2); scrollbar-width: none; }
|
|
||||||
.link-list::-webkit-scrollbar { display: none; }
|
|
||||||
.link-empty { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); padding: var(--sp-4) var(--sp-3); text-align: center; letter-spacing: var(--tracking-wide); }
|
|
||||||
.link-row { display: flex; align-items: center; gap: var(--sp-3); width: 100%; padding: 8px var(--sp-3); border-radius: var(--radius-md); border: none; background: none; text-align: left; cursor: pointer; transition: background var(--t-fast); }
|
|
||||||
.link-row:hover { background: var(--bg-raised); }
|
|
||||||
.link-row-linked { background: var(--accent-muted) !important; }
|
|
||||||
.link-thumb { width: 34px; height: 48px; border-radius: var(--radius-sm); object-fit: cover; flex-shrink: 0; border: 1px solid var(--border-dim); }
|
|
||||||
.link-info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; min-width: 0; }
|
|
||||||
.link-manga-title { font-size: var(--text-sm); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
||||||
.link-source { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
|
||||||
.link-status { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--text-faint); flex-shrink: 0; padding: 2px 8px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); }
|
|
||||||
.link-row-linked .link-status { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
|
|
||||||
@keyframes pulse { 0%,100% { opacity: 0.4 } 50% { opacity: 0.8 } }
|
|
||||||
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
|
|
||||||
@keyframes scaleIn { from { opacity: 0; transform: scale(0.97) } to { opacity: 1; transform: scale(1) } }
|
|
||||||
</style>
|
|
||||||
@@ -1,637 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { CircleNotch, X, MagnifyingGlass, ArrowSquareOut, Lock, LockOpen, ArrowsClockwise } from "phosphor-svelte";
|
|
||||||
import { gql, thumbUrl } from "../../lib/client";
|
|
||||||
import {
|
|
||||||
GET_TRACKERS,
|
|
||||||
GET_MANGA_TRACK_RECORDS,
|
|
||||||
SEARCH_TRACKER,
|
|
||||||
BIND_TRACK,
|
|
||||||
UPDATE_TRACK,
|
|
||||||
UNBIND_TRACK,
|
|
||||||
FETCH_TRACK,
|
|
||||||
} from "../../lib/queries";
|
|
||||||
import { addToast } from "../../store/state.svelte";
|
|
||||||
import type { Tracker, TrackRecord, TrackSearch } from "../../lib/types";
|
|
||||||
|
|
||||||
let { mangaId, mangaTitle, onClose }: {
|
|
||||||
mangaId: number;
|
|
||||||
mangaTitle: string;
|
|
||||||
onClose: () => void;
|
|
||||||
} = $props();
|
|
||||||
|
|
||||||
// ── State ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
type TabId = "records" | number;
|
|
||||||
|
|
||||||
let trackers: Tracker[] = $state([]);
|
|
||||||
let records: TrackRecord[] = $state([]);
|
|
||||||
let loading: boolean = $state(true);
|
|
||||||
let activeTab: TabId = $state("records");
|
|
||||||
|
|
||||||
let searchQuery: string = $state("");
|
|
||||||
let searchResults: TrackSearch[] = $state([]);
|
|
||||||
let searching: boolean = $state(false);
|
|
||||||
let searchInited: Set<number> = $state(new Set());
|
|
||||||
|
|
||||||
let binding: boolean = $state(false);
|
|
||||||
let updatingRecord: number | null = $state(null);
|
|
||||||
let syncing: number | null = $state(null);
|
|
||||||
let editingChapter: number | null = $state(null);
|
|
||||||
let chapterDraft: number = $state(0);
|
|
||||||
|
|
||||||
// ── Load ───────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async function load() {
|
|
||||||
loading = true;
|
|
||||||
try {
|
|
||||||
const [tRes, rRes] = await Promise.all([
|
|
||||||
gql<{ trackers: { nodes: Tracker[] } }>(GET_TRACKERS),
|
|
||||||
gql<{ manga: { trackRecords: { nodes: TrackRecord[] } } }>(
|
|
||||||
GET_MANGA_TRACK_RECORDS, { mangaId }
|
|
||||||
),
|
|
||||||
]);
|
|
||||||
trackers = tRes.trackers.nodes;
|
|
||||||
records = rRes.manga.trackRecords.nodes;
|
|
||||||
} catch (e: any) {
|
|
||||||
addToast({ kind: "error", title: "Failed to load tracking", body: e?.message });
|
|
||||||
} finally {
|
|
||||||
loading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$effect(() => { load(); });
|
|
||||||
|
|
||||||
// Auto-search with manga title when switching to a tracker tab
|
|
||||||
$effect(() => {
|
|
||||||
const tab = activeTab;
|
|
||||||
if (typeof tab !== "number") return;
|
|
||||||
if (searchInited.has(tab)) return;
|
|
||||||
searchQuery = mangaTitle;
|
|
||||||
searchInited = new Set([...searchInited, tab]);
|
|
||||||
doSearch(tab, mangaTitle);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function trackerFor(id: number) { return trackers.find(t => t.id === id); }
|
|
||||||
function recordFor(trackerId: number){ return records.find(r => r.trackerId === trackerId); }
|
|
||||||
const loggedInTrackers = $derived(trackers.filter(t => t.isLoggedIn));
|
|
||||||
|
|
||||||
// ── Search ─────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
let searchTimer: ReturnType<typeof setTimeout>;
|
|
||||||
|
|
||||||
function onSearchInput() {
|
|
||||||
clearTimeout(searchTimer);
|
|
||||||
if (typeof activeTab !== "number") return;
|
|
||||||
const tid = activeTab;
|
|
||||||
if (!searchQuery.trim()) { searchResults = []; return; }
|
|
||||||
searchTimer = setTimeout(() => doSearch(tid, searchQuery), 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function doSearch(trackerId: number, query: string) {
|
|
||||||
if (!query.trim()) return;
|
|
||||||
searching = true;
|
|
||||||
searchResults = [];
|
|
||||||
try {
|
|
||||||
const res = await gql<{ searchTracker: { trackSearches: TrackSearch[] } }>(
|
|
||||||
SEARCH_TRACKER, { trackerId, query: query.trim() }
|
|
||||||
);
|
|
||||||
searchResults = res.searchTracker.trackSearches;
|
|
||||||
} catch (e: any) {
|
|
||||||
addToast({ kind: "error", title: "Search failed", body: e?.message });
|
|
||||||
} finally {
|
|
||||||
searching = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Bind / Unbind ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async function bind(result: TrackSearch) {
|
|
||||||
if (typeof activeTab !== "number") return;
|
|
||||||
binding = true;
|
|
||||||
try {
|
|
||||||
const res = await gql<{ bindTrack: { trackRecord: TrackRecord } }>(
|
|
||||||
BIND_TRACK, { mangaId, trackerId: activeTab, remoteId: result.remoteId }
|
|
||||||
);
|
|
||||||
records = [...records.filter(r => r.trackerId !== activeTab), res.bindTrack.trackRecord];
|
|
||||||
addToast({ kind: "success", title: "Now tracking", body: result.title });
|
|
||||||
activeTab = "records";
|
|
||||||
} catch (e: any) {
|
|
||||||
addToast({ kind: "error", title: "Failed to bind", body: e?.message });
|
|
||||||
} finally {
|
|
||||||
binding = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function unbind(record: TrackRecord) {
|
|
||||||
updatingRecord = record.id;
|
|
||||||
try {
|
|
||||||
await gql(UNBIND_TRACK, { recordId: record.id });
|
|
||||||
records = records.filter(r => r.id !== record.id);
|
|
||||||
addToast({ kind: "info", title: "Unlinked from " + trackerFor(record.trackerId)?.name });
|
|
||||||
} catch (e: any) {
|
|
||||||
addToast({ kind: "error", title: "Failed to unlink", body: e?.message });
|
|
||||||
} finally {
|
|
||||||
updatingRecord = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Update ─────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async function updateStatus(record: TrackRecord, status: number) {
|
|
||||||
updatingRecord = record.id;
|
|
||||||
try {
|
|
||||||
const res = await gql<{ updateTrack: { trackRecord: TrackRecord } }>(
|
|
||||||
UPDATE_TRACK, { recordId: record.id, status }
|
|
||||||
);
|
|
||||||
patchRecord(res.updateTrack.trackRecord);
|
|
||||||
} catch (e: any) {
|
|
||||||
addToast({ kind: "error", title: "Update failed", body: e?.message });
|
|
||||||
} finally {
|
|
||||||
updatingRecord = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function updateScore(record: TrackRecord, scoreString: string) {
|
|
||||||
updatingRecord = record.id;
|
|
||||||
try {
|
|
||||||
const res = await gql<{ updateTrack: { trackRecord: TrackRecord } }>(
|
|
||||||
UPDATE_TRACK, { recordId: record.id, scoreString }
|
|
||||||
);
|
|
||||||
patchRecord(res.updateTrack.trackRecord);
|
|
||||||
} catch (e: any) {
|
|
||||||
addToast({ kind: "error", title: "Update failed", body: e?.message });
|
|
||||||
} finally {
|
|
||||||
updatingRecord = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function togglePrivate(record: TrackRecord) {
|
|
||||||
updatingRecord = record.id;
|
|
||||||
try {
|
|
||||||
const res = await gql<{ updateTrack: { trackRecord: TrackRecord } }>(
|
|
||||||
UPDATE_TRACK, { recordId: record.id, private: !record.private }
|
|
||||||
);
|
|
||||||
patchRecord(res.updateTrack.trackRecord);
|
|
||||||
} catch (e: any) {
|
|
||||||
addToast({ kind: "error", title: "Update failed", body: e?.message });
|
|
||||||
} finally {
|
|
||||||
updatingRecord = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function syncRecord(record: TrackRecord) {
|
|
||||||
syncing = record.id;
|
|
||||||
try {
|
|
||||||
const res = await gql<{ fetchTrack: { trackRecord: TrackRecord } }>(
|
|
||||||
FETCH_TRACK, { recordId: record.id }
|
|
||||||
);
|
|
||||||
patchRecord(res.fetchTrack.trackRecord);
|
|
||||||
addToast({ kind: "success", title: "Synced from tracker" });
|
|
||||||
} catch (e: any) {
|
|
||||||
addToast({ kind: "error", title: "Sync failed", body: e?.message });
|
|
||||||
} finally {
|
|
||||||
syncing = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function patchRecord(updated: Partial<TrackRecord> & { id: number }) {
|
|
||||||
records = records.map(r => r.id === updated.id ? { ...r, ...updated } : r);
|
|
||||||
}
|
|
||||||
|
|
||||||
function openChapterEditor(record: TrackRecord) {
|
|
||||||
editingChapter = record.id;
|
|
||||||
chapterDraft = record.lastChapterRead;
|
|
||||||
}
|
|
||||||
|
|
||||||
function cancelChapterEditor() { editingChapter = null; }
|
|
||||||
|
|
||||||
async function submitChapter(record: TrackRecord) {
|
|
||||||
const val = Math.max(0, chapterDraft);
|
|
||||||
editingChapter = null;
|
|
||||||
if (val === record.lastChapterRead) return;
|
|
||||||
updatingRecord = record.id;
|
|
||||||
try {
|
|
||||||
const res = await gql<{ updateTrack: { trackRecord: TrackRecord } }>(
|
|
||||||
UPDATE_TRACK, { recordId: record.id, lastChapterRead: val }
|
|
||||||
);
|
|
||||||
patchRecord(res.updateTrack.trackRecord);
|
|
||||||
} catch (e: any) {
|
|
||||||
addToast({ kind: "error", title: "Update failed", body: e?.message });
|
|
||||||
} finally {
|
|
||||||
updatingRecord = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<svelte:window onkeydown={(e) => e.key === "Escape" && onClose()} />
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="backdrop"
|
|
||||||
role="presentation"
|
|
||||||
onclick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
|
||||||
>
|
|
||||||
<div class="modal" role="dialog" aria-label="Tracking">
|
|
||||||
|
|
||||||
<!-- ── Header ─────────────────────────────────────────────────────────── -->
|
|
||||||
<div class="modal-header">
|
|
||||||
<div class="header-left">
|
|
||||||
<span class="modal-title">Tracking</span>
|
|
||||||
<span class="modal-subtitle">{mangaTitle}</span>
|
|
||||||
</div>
|
|
||||||
<button class="close-btn" onclick={onClose}><X size={15} weight="light" /></button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if loading}
|
|
||||||
<div class="state-body">
|
|
||||||
<CircleNotch size={20} weight="light" class="anim-spin" style="color:var(--text-faint)" />
|
|
||||||
<span class="state-label">Loading…</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{:else if loggedInTrackers.length === 0}
|
|
||||||
<div class="state-body">
|
|
||||||
<p class="state-text">No trackers connected.</p>
|
|
||||||
<p class="state-hint">Go to Settings → Tracking to log in.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{:else}
|
|
||||||
<!-- ── Tabs ──────────────────────────────────────────────────────────── -->
|
|
||||||
<div class="tabs">
|
|
||||||
<button
|
|
||||||
class="tab"
|
|
||||||
class:tab-active={activeTab === "records"}
|
|
||||||
onclick={() => activeTab = "records"}
|
|
||||||
>
|
|
||||||
My List
|
|
||||||
{#if records.length > 0}
|
|
||||||
<span class="tab-badge">{records.length}</span>
|
|
||||||
{/if}
|
|
||||||
</button>
|
|
||||||
{#each loggedInTrackers as t}
|
|
||||||
{@const rec = recordFor(t.id)}
|
|
||||||
<button
|
|
||||||
class="tab"
|
|
||||||
class:tab-active={activeTab === t.id}
|
|
||||||
onclick={() => { activeTab = t.id; searchResults = []; }}
|
|
||||||
>
|
|
||||||
<img src={thumbUrl(t.icon)} alt={t.name} class="tab-icon" />
|
|
||||||
{t.name}
|
|
||||||
{#if rec}<span class="tab-dot"></span>{/if}
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- ── My List tab ───────────────────────────────────────────────────── -->
|
|
||||||
{#if activeTab === "records"}
|
|
||||||
<div class="tab-body">
|
|
||||||
{#if records.length === 0}
|
|
||||||
<div class="state-body">
|
|
||||||
<p class="state-text">Not tracking this manga yet.</p>
|
|
||||||
<p class="state-hint">Click a tracker tab above to search and add it.</p>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
{#each records as record (record.id)}
|
|
||||||
{@const tracker = trackerFor(record.trackerId)}
|
|
||||||
{@const isBusy = updatingRecord === record.id}
|
|
||||||
<div class="record-row" class:record-busy={isBusy}>
|
|
||||||
|
|
||||||
<div class="record-identity">
|
|
||||||
{#if tracker}
|
|
||||||
<img src={thumbUrl(tracker.icon)} alt={tracker.name} class="record-tracker-icon" />
|
|
||||||
{/if}
|
|
||||||
{#if record.remoteUrl}
|
|
||||||
<a href={record.remoteUrl} target="_blank" rel="noreferrer" class="record-title">
|
|
||||||
{record.title}
|
|
||||||
<ArrowSquareOut size={10} weight="light" />
|
|
||||||
</a>
|
|
||||||
{:else}
|
|
||||||
<span class="record-title-plain">{record.title}</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="record-controls">
|
|
||||||
<select
|
|
||||||
class="record-select"
|
|
||||||
value={record.status}
|
|
||||||
disabled={isBusy}
|
|
||||||
onchange={(e) => updateStatus(record, parseInt((e.target as HTMLSelectElement).value))}
|
|
||||||
>
|
|
||||||
{#each (tracker?.statuses ?? []) as s}
|
|
||||||
<option value={s.value}>{s.name}</option>
|
|
||||||
{/each}
|
|
||||||
</select>
|
|
||||||
|
|
||||||
<select
|
|
||||||
class="record-select record-select-score"
|
|
||||||
value={record.displayScore}
|
|
||||||
disabled={isBusy}
|
|
||||||
onchange={(e) => updateScore(record, (e.target as HTMLSelectElement).value)}
|
|
||||||
>
|
|
||||||
{#each (tracker?.scores ?? []) as s}
|
|
||||||
<option value={s}>★ {s}</option>
|
|
||||||
{/each}
|
|
||||||
</select>
|
|
||||||
|
|
||||||
{#if tracker?.supportsPrivateTracking}
|
|
||||||
<button
|
|
||||||
class="record-icon-btn"
|
|
||||||
class:icon-active={record.private}
|
|
||||||
title={record.private ? "Private — click to make public" : "Public — click to make private"}
|
|
||||||
disabled={isBusy}
|
|
||||||
onclick={() => togglePrivate(record)}
|
|
||||||
>
|
|
||||||
{#if record.private}
|
|
||||||
<Lock size={12} weight="fill" />
|
|
||||||
{:else}
|
|
||||||
<LockOpen size={12} weight="light" />
|
|
||||||
{/if}
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<button
|
|
||||||
class="record-icon-btn"
|
|
||||||
title="Sync from tracker"
|
|
||||||
disabled={syncing === record.id}
|
|
||||||
onclick={() => syncRecord(record)}
|
|
||||||
>
|
|
||||||
<ArrowsClockwise size={12} weight="light" class={syncing === record.id ? "anim-spin" : ""} />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
class="record-icon-btn icon-danger"
|
|
||||||
title="Unlink"
|
|
||||||
disabled={isBusy}
|
|
||||||
onclick={() => unbind(record)}
|
|
||||||
>
|
|
||||||
<X size={12} weight="bold" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if editingChapter === record.id}
|
|
||||||
<div class="chapter-editor">
|
|
||||||
<div class="chapter-editor-top">
|
|
||||||
<span class="chapter-editor-label">Chapter read</span>
|
|
||||||
<div class="chapter-input-wrap">
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
class="chapter-input"
|
|
||||||
min="0"
|
|
||||||
max={record.totalChapters > 0 ? record.totalChapters : undefined}
|
|
||||||
step="0.5"
|
|
||||||
bind:value={chapterDraft}
|
|
||||||
onkeydown={(e) => {
|
|
||||||
if (e.key === "Enter") submitChapter(record);
|
|
||||||
if (e.key === "Escape") cancelChapterEditor();
|
|
||||||
}}
|
|
||||||
use:autoFocus
|
|
||||||
/>
|
|
||||||
{#if record.totalChapters > 0}
|
|
||||||
<span class="chapter-total">/ {record.totalChapters}</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{#if record.totalChapters > 0}
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
class="chapter-slider"
|
|
||||||
min="0"
|
|
||||||
max={record.totalChapters}
|
|
||||||
step="1"
|
|
||||||
bind:value={chapterDraft}
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
<div class="chapter-editor-actions">
|
|
||||||
<button class="chapter-save-btn" onclick={() => submitChapter(record)}>Save</button>
|
|
||||||
<button class="chapter-cancel-btn" onclick={cancelChapterEditor}>Cancel</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{:else if record.totalChapters > 0}
|
|
||||||
<div class="record-progress clickable" role="button" tabindex="0"
|
|
||||||
onclick={() => openChapterEditor(record)}
|
|
||||||
onkeydown={(e) => e.key === "Enter" && openChapterEditor(record)}
|
|
||||||
title="Click to edit"
|
|
||||||
>
|
|
||||||
<span class="record-progress-label">Ch. {record.lastChapterRead} / {record.totalChapters} <span class="edit-hint">✎</span></span>
|
|
||||||
<div class="record-progress-track">
|
|
||||||
<div class="record-progress-fill" style="width:{Math.min(100,(record.lastChapterRead/record.totalChapters)*100)}%"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div class="record-progress clickable" role="button" tabindex="0"
|
|
||||||
onclick={() => openChapterEditor(record)}
|
|
||||||
onkeydown={(e) => e.key === "Enter" && openChapterEditor(record)}
|
|
||||||
title="Click to set chapter"
|
|
||||||
>
|
|
||||||
<span class="record-progress-label">
|
|
||||||
{record.lastChapterRead > 0 ? `Ch. ${record.lastChapterRead} read` : "Set chapter…"} <span class="edit-hint">✎</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- ── Tracker search tab ─────────────────────────────────────────────── -->
|
|
||||||
{:else}
|
|
||||||
{@const tracker = trackerFor(activeTab as number)}
|
|
||||||
{@const boundRecord = recordFor(activeTab as number)}
|
|
||||||
<div class="search-bar">
|
|
||||||
<MagnifyingGlass size={13} weight="light" class="search-icon" />
|
|
||||||
<input
|
|
||||||
class="search-input"
|
|
||||||
placeholder="Search {tracker?.name}…"
|
|
||||||
bind:value={searchQuery}
|
|
||||||
oninput={onSearchInput}
|
|
||||||
onkeydown={(e) => e.key === "Enter" && doSearch(activeTab as number, searchQuery)}
|
|
||||||
use:autoFocus
|
|
||||||
/>
|
|
||||||
{#if searching}
|
|
||||||
<CircleNotch size={13} weight="light" class="anim-spin search-icon" />
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="search-results">
|
|
||||||
{#if searching && searchResults.length === 0}
|
|
||||||
<div class="state-body"><p class="state-hint">Searching…</p></div>
|
|
||||||
{:else if !searching && searchQuery.trim() && searchResults.length === 0}
|
|
||||||
<div class="state-body"><p class="state-text">No results for "{searchQuery}"</p></div>
|
|
||||||
{:else if !searchQuery.trim()}
|
|
||||||
<div class="state-body"><p class="state-hint">Type a title to search</p></div>
|
|
||||||
{:else}
|
|
||||||
{#each searchResults as result (result.trackerId + ":" + result.remoteId)}
|
|
||||||
{@const isBound = boundRecord?.remoteId === result.remoteId}
|
|
||||||
<button
|
|
||||||
class="result-row"
|
|
||||||
class:result-bound={isBound}
|
|
||||||
onclick={() => isBound ? unbind(boundRecord!) : bind(result)}
|
|
||||||
disabled={binding}
|
|
||||||
>
|
|
||||||
{#if result.coverUrl}
|
|
||||||
<img
|
|
||||||
src={result.coverUrl}
|
|
||||||
alt={result.title}
|
|
||||||
class="result-cover"
|
|
||||||
loading="lazy"
|
|
||||||
onerror={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
|
|
||||||
/>
|
|
||||||
{:else}
|
|
||||||
<div class="result-cover result-cover-empty"></div>
|
|
||||||
{/if}
|
|
||||||
<div class="result-info">
|
|
||||||
<span class="result-title">{result.title}</span>
|
|
||||||
<div class="result-meta">
|
|
||||||
{#if result.publishingType}<span class="result-tag">{result.publishingType}</span>{/if}
|
|
||||||
{#if result.publishingStatus}<span class="result-tag">{result.publishingStatus}</span>{/if}
|
|
||||||
{#if result.totalChapters > 0}<span class="result-tag">{result.totalChapters} ch</span>{/if}
|
|
||||||
</div>
|
|
||||||
{#if result.summary}
|
|
||||||
<p class="result-summary">{result.summary.slice(0,140)}{result.summary.length > 140 ? "…" : ""}</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<span class="result-action" class:result-action-on={isBound}>
|
|
||||||
{isBound ? "✓ Tracking" : "Track"}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script module>
|
|
||||||
function autoFocus(node: HTMLElement) { setTimeout(() => node.focus(), 50); }
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.backdrop {
|
|
||||||
position: fixed; inset: 0; background: rgba(0,0,0,0.72);
|
|
||||||
z-index: var(--z-settings);
|
|
||||||
display: flex; align-items: center; justify-content: center;
|
|
||||||
backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px);
|
|
||||||
animation: fadeIn 0.12s ease both;
|
|
||||||
}
|
|
||||||
.modal {
|
|
||||||
width: min(580px, calc(100vw - 48px));
|
|
||||||
max-height: min(680px, calc(100vh - 80px));
|
|
||||||
display: flex; flex-direction: column;
|
|
||||||
background: var(--bg-surface); border: 1px solid var(--border-base);
|
|
||||||
border-radius: var(--radius-xl); overflow: hidden;
|
|
||||||
box-shadow: 0 0 0 1px var(--border-dim), 0 24px 64px rgba(0,0,0,0.6);
|
|
||||||
animation: scaleIn 0.15s ease both;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Header */
|
|
||||||
.modal-header {
|
|
||||||
display: flex; align-items: center; justify-content: space-between;
|
|
||||||
padding: var(--sp-4) var(--sp-5); border-bottom: 1px solid var(--border-dim); flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.header-left { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
|
|
||||||
.modal-title { font-size: var(--text-base); font-weight: var(--weight-medium); color: var(--text-primary); letter-spacing: var(--tracking-tight); }
|
|
||||||
.modal-subtitle { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
||||||
.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); }
|
|
||||||
|
|
||||||
/* States */
|
|
||||||
.state-body { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: var(--sp-2); padding: var(--sp-10) var(--sp-5); flex: 1; }
|
|
||||||
.state-label { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
|
||||||
.state-text { font-size: var(--text-sm); color: var(--text-muted); }
|
|
||||||
.state-hint { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); text-align: center; }
|
|
||||||
|
|
||||||
/* Tabs */
|
|
||||||
.tabs { display: flex; align-items: center; gap: 2px; padding: var(--sp-2) var(--sp-3); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; overflow-x: auto; scrollbar-width: none; }
|
|
||||||
.tabs::-webkit-scrollbar { display: none; }
|
|
||||||
.tab { display: flex; align-items: center; gap: var(--sp-2); position: relative; font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 5px 10px; border-radius: var(--radius-sm); color: var(--text-faint); background: none; border: none; cursor: pointer; white-space: nowrap; transition: color var(--t-base), background var(--t-base); }
|
|
||||||
.tab:hover { color: var(--text-muted); background: var(--bg-raised); }
|
|
||||||
.tab-active { color: var(--text-secondary); background: var(--bg-raised); }
|
|
||||||
.tab-icon { width: 14px; height: 14px; border-radius: 2px; object-fit: contain; }
|
|
||||||
.tab-badge { font-size: 10px; padding: 0 5px; border-radius: var(--radius-full); background: var(--accent-dim); color: var(--accent-fg); min-width: 16px; text-align: center; }
|
|
||||||
.tab-dot { position: absolute; top: 4px; right: 4px; width: 5px; height: 5px; border-radius: 50%; background: var(--accent); }
|
|
||||||
|
|
||||||
/* Records */
|
|
||||||
.tab-body { flex: 1; overflow-y: auto; padding: var(--sp-3); scrollbar-width: none; display: flex; flex-direction: column; gap: var(--sp-2); }
|
|
||||||
.tab-body::-webkit-scrollbar { display: none; }
|
|
||||||
.record-row { display: flex; flex-direction: column; gap: var(--sp-2); padding: var(--sp-4); border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: var(--bg-raised); transition: opacity var(--t-base); }
|
|
||||||
.record-busy { opacity: 0.5; pointer-events: none; }
|
|
||||||
.record-identity { display: flex; align-items: center; gap: var(--sp-2); min-width: 0; }
|
|
||||||
.record-tracker-icon { width: 16px; height: 16px; border-radius: 3px; flex-shrink: 0; object-fit: contain; }
|
|
||||||
.record-title { display: inline-flex; align-items: center; gap: 3px; font-size: var(--text-sm); color: var(--accent-fg); text-decoration: none; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; transition: opacity var(--t-base); }
|
|
||||||
.record-title:hover { opacity: 0.75; }
|
|
||||||
.record-title-plain { font-size: var(--text-sm); color: var(--text-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; }
|
|
||||||
.record-controls { display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap; }
|
|
||||||
.record-select {
|
|
||||||
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
|
|
||||||
padding: 4px 28px 4px 8px; border-radius: var(--radius-sm);
|
|
||||||
border: 1px solid var(--border-strong); background: var(--bg-overlay);
|
|
||||||
color: var(--text-secondary); outline: none; cursor: pointer;
|
|
||||||
appearance: none; -webkit-appearance: none;
|
|
||||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%23888' stroke-width='1.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E");
|
|
||||||
background-repeat: no-repeat; background-position: right 8px center;
|
|
||||||
transition: border-color var(--t-base);
|
|
||||||
}
|
|
||||||
.record-select:hover:not(:disabled) { border-color: var(--accent-dim); }
|
|
||||||
.record-select:focus { border-color: var(--accent); outline: none; }
|
|
||||||
.record-select:disabled { opacity: 0.4; cursor: default; }
|
|
||||||
.record-select option { background: var(--bg-surface); color: var(--text-secondary); }
|
|
||||||
.record-select-score { max-width: 100px; }
|
|
||||||
.record-icon-btn { display: flex; align-items: center; justify-content: center; width: 26px; height: 26px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); cursor: pointer; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); flex-shrink: 0; }
|
|
||||||
.record-icon-btn:hover:not(:disabled) { color: var(--text-muted); border-color: var(--border-strong); background: var(--bg-overlay); }
|
|
||||||
.record-icon-btn.icon-active { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
|
|
||||||
.record-icon-btn.icon-danger:hover:not(:disabled) { color: var(--color-error); border-color: var(--color-error); background: var(--color-error-bg); }
|
|
||||||
.record-icon-btn:disabled { opacity: 0.3; cursor: default; }
|
|
||||||
.record-progress { display: flex; flex-direction: column; gap: 4px; }
|
|
||||||
.record-progress.clickable { cursor: pointer; border-radius: var(--radius-sm); padding: 3px 4px; margin: -3px -4px; transition: background var(--t-fast); }
|
|
||||||
.record-progress.clickable:hover { background: var(--bg-overlay); }
|
|
||||||
.record-progress-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
|
||||||
.edit-hint { opacity: 0; font-size: 10px; transition: opacity var(--t-fast); }
|
|
||||||
.record-progress.clickable:hover .edit-hint { opacity: 1; }
|
|
||||||
.record-progress-track { height: 2px; background: var(--border-base); border-radius: var(--radius-full); overflow: hidden; }
|
|
||||||
.record-progress-fill { height: 100%; background: var(--accent); border-radius: var(--radius-full); transition: width 0.3s ease; }
|
|
||||||
/* Chapter editor */
|
|
||||||
.chapter-editor { display: flex; flex-direction: column; gap: var(--sp-2); padding: var(--sp-3); border-radius: var(--radius-md); border: 1px solid var(--accent-dim); background: var(--bg-overlay); }
|
|
||||||
.chapter-editor-top { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-3); }
|
|
||||||
.chapter-editor-label { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
|
||||||
.chapter-input-wrap { display: flex; align-items: center; gap: var(--sp-2); }
|
|
||||||
.chapter-input { width: 68px; background: var(--bg-raised); border: 1px solid var(--accent-dim); border-radius: var(--radius-sm); padding: 3px 6px; font-family: var(--font-ui); font-size: var(--text-sm); color: var(--text-primary); outline: none; text-align: center; }
|
|
||||||
.chapter-input:focus { border-color: var(--accent); }
|
|
||||||
.chapter-total { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); }
|
|
||||||
.chapter-slider { width: 100%; accent-color: var(--accent); cursor: pointer; height: 4px; }
|
|
||||||
.chapter-editor-actions { display: flex; gap: var(--sp-2); justify-content: flex-end; }
|
|
||||||
.chapter-save-btn { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 3px 12px; border-radius: var(--radius-sm); border: 1px solid var(--accent); background: var(--accent-muted); color: var(--accent-fg); cursor: pointer; transition: filter var(--t-base); }
|
|
||||||
.chapter-save-btn:hover { filter: brightness(1.15); }
|
|
||||||
.chapter-cancel-btn { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 3px 8px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); cursor: pointer; transition: color var(--t-base), border-color var(--t-base); }
|
|
||||||
.chapter-cancel-btn:hover { color: var(--text-muted); border-color: var(--border-strong); }
|
|
||||||
|
|
||||||
/* Search */
|
|
||||||
.search-bar { display: flex; align-items: center; gap: var(--sp-2); padding: var(--sp-3) var(--sp-4); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
|
|
||||||
:global(.search-icon) { color: var(--text-faint); flex-shrink: 0; }
|
|
||||||
.search-input { flex: 1; background: none; border: none; outline: none; font-size: var(--text-sm); color: var(--text-primary); }
|
|
||||||
.search-input::placeholder { color: var(--text-faint); }
|
|
||||||
.search-results { flex: 1; overflow-y: auto; padding: var(--sp-2); scrollbar-width: none; }
|
|
||||||
.search-results::-webkit-scrollbar { display: none; }
|
|
||||||
|
|
||||||
/* Results */
|
|
||||||
.result-row { display: flex; align-items: flex-start; gap: var(--sp-3); width: 100%; padding: var(--sp-3); border-radius: var(--radius-md); border: none; background: none; text-align: left; cursor: pointer; transition: background var(--t-fast); }
|
|
||||||
.result-row:hover:not(:disabled) { background: var(--bg-raised); }
|
|
||||||
.result-row:disabled { opacity: 0.5; cursor: default; }
|
|
||||||
.result-bound { background: var(--accent-muted) !important; }
|
|
||||||
.result-cover { width: 46px; height: 66px; object-fit: cover; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); flex-shrink: 0; }
|
|
||||||
.result-cover-empty { background: var(--bg-raised); }
|
|
||||||
.hidden { display: none; }
|
|
||||||
.result-info { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: var(--sp-1); padding-top: 2px; }
|
|
||||||
.result-title { font-size: var(--text-sm); color: var(--text-secondary); line-height: var(--leading-snug); text-align: left; }
|
|
||||||
.result-meta { display: flex; flex-wrap: wrap; gap: var(--sp-1); }
|
|
||||||
.result-tag { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 1px 6px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: var(--bg-raised); color: var(--text-faint); }
|
|
||||||
.result-summary { font-size: var(--text-xs); color: var(--text-faint); line-height: var(--leading-snug); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; text-align: left; }
|
|
||||||
.result-action { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 4px 12px; border-radius: var(--radius-sm); border: 1px solid var(--border-strong); background: none; color: var(--text-faint); flex-shrink: 0; align-self: center; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
|
|
||||||
.result-row:hover:not(:disabled) .result-action { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
|
|
||||||
.result-action-on { color: var(--accent-fg) !important; border-color: var(--accent-dim) !important; background: var(--accent-muted) !important; }
|
|
||||||
|
|
||||||
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
|
|
||||||
@keyframes scaleIn { from { opacity: 0; transform: scale(0.97) } to { opacity: 1; transform: scale(1) } }
|
|
||||||
</style>
|
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { detectAdapter } from '$lib/platform-adapters'
|
||||||
|
import { initPlatformService } from '$lib/platform-service'
|
||||||
|
import { initRequestManager } from '$lib/request-manager'
|
||||||
|
import { appState } from '$lib/state/app.svelte'
|
||||||
|
import { configureAuth, probeServer } from '$lib/core/auth'
|
||||||
|
import { loadSettings, loadLibrary } from '$lib/core/persistence/persist'
|
||||||
|
import { loadSettingsIntoState, settingsState } from '$lib/state/settings.svelte'
|
||||||
|
import { historyState } from '$lib/state/history.svelte'
|
||||||
|
import { readerState } from '$lib/state/reader.svelte'
|
||||||
|
import { seriesState } from '$lib/state/series.svelte'
|
||||||
|
|
||||||
|
const KEY_URL = 'moku_server_url'
|
||||||
|
const KEY_AUTH = 'moku_auth_config'
|
||||||
|
|
||||||
|
interface SavedAuth {
|
||||||
|
mode: 'NONE' | 'BASIC_AUTH' | 'UI_LOGIN'
|
||||||
|
user?: string
|
||||||
|
pass?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
async function boot() {
|
||||||
|
try {
|
||||||
|
const platformAdapter = detectAdapter()
|
||||||
|
initPlatformService(platformAdapter)
|
||||||
|
|
||||||
|
const { SuwayomiAdapter } = await import('$lib/server-adapters/suwayomi')
|
||||||
|
const serverAdapter = new SuwayomiAdapter()
|
||||||
|
initRequestManager(serverAdapter)
|
||||||
|
|
||||||
|
await platformAdapter.init()
|
||||||
|
|
||||||
|
appState.platform = platformAdapter.platform
|
||||||
|
appState.version = await platformAdapter.getVersion()
|
||||||
|
|
||||||
|
const [settingsData, libraryData] = await Promise.all([
|
||||||
|
loadSettings(),
|
||||||
|
loadLibrary(),
|
||||||
|
])
|
||||||
|
|
||||||
|
await loadSettingsIntoState(settingsData.settings)
|
||||||
|
|
||||||
|
seriesState.bookmarks = libraryData.bookmarks
|
||||||
|
readerState.markers = libraryData.markers
|
||||||
|
historyState.load(libraryData.sessions, libraryData.dailyReadCounts)
|
||||||
|
|
||||||
|
const savedUrl = (await platformAdapter.getCredential(KEY_URL)) ?? 'http://127.0.0.1:4567'
|
||||||
|
const savedAuthRaw = await platformAdapter.getCredential(KEY_AUTH)
|
||||||
|
const savedAuth: SavedAuth = savedAuthRaw ? JSON.parse(savedAuthRaw) : { mode: 'NONE' }
|
||||||
|
|
||||||
|
appState.serverUrl = savedUrl
|
||||||
|
appState.authMode = savedAuth.mode
|
||||||
|
|
||||||
|
configureAuth(savedUrl, savedAuth.mode, savedAuth.user, savedAuth.pass)
|
||||||
|
|
||||||
|
await serverAdapter.connect({
|
||||||
|
baseUrl: savedUrl,
|
||||||
|
credentials:
|
||||||
|
savedAuth.mode === 'BASIC_AUTH' && savedAuth.user && savedAuth.pass
|
||||||
|
? { username: savedAuth.user, password: savedAuth.pass }
|
||||||
|
: undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
const isTauri = platformAdapter.platform === 'tauri'
|
||||||
|
const autoStartServer = settingsState.settings.autoStartServer
|
||||||
|
|
||||||
|
if (isTauri && autoStartServer) {
|
||||||
|
appState.status = 'booting'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const probe = await probeServer()
|
||||||
|
|
||||||
|
if (probe === 'auth_required') { appState.status = 'auth'; return }
|
||||||
|
if (probe === 'unreachable') {
|
||||||
|
appState.error = `Could not reach server at ${savedUrl}`
|
||||||
|
appState.status = 'error'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
appState.authenticated = true
|
||||||
|
appState.status = 'ready'
|
||||||
|
} catch (e) {
|
||||||
|
appState.error = String(e)
|
||||||
|
appState.status = 'error'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
boot()
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||||
|
<rect width="512" height="512" fill="#091209"/>
|
||||||
|
<g transform="translate(256,265) rotate(7) scale(0.098,-0.098) translate(-5000,-4800)" fill="#2d7a5f" stroke="none">
|
||||||
|
<path d="M4908 6615 c-597 -629 -935 -1264 -934 -1755 0 -508 195 -778 790
|
||||||
|
-1098 l194 -104 22 -114 c33 -164 76 -271 140 -347 80 -94 106 -62 77 94 -47
|
||||||
|
255 43 443 294 613 797 539 714 1392 -250 2551 -230 278 -224 275 -333 160z
|
||||||
|
m52 -945 c0 -472 -8 -850 -17 -850 -36 0 -693 703 -692 740 2 48 167 321 309
|
||||||
|
509 175 233 359 451 380 451 13 0 20 -304 20 -850z m183 775 c120 -126 357
|
||||||
|
-447 356 -482 0 -18 -47 -83 -105 -144 -57 -61 -151 -163 -208 -225 -151 -166
|
||||||
|
-146 -180 -146 406 0 286 7 520 16 520 9 0 48 -34 87 -75z m541 -750 c86 -150
|
||||||
|
196 -391 196 -430 0 -8 -25 -42 -55 -75 -31 -33 -149 -163 -264 -290 -339
|
||||||
|
-374 -480 -520 -501 -520 -13 0 -20 167 -20 465 l1 465 151 170 c83 94 193
|
||||||
|
217 244 275 122 138 137 135 248 -60z m-1098 -633 l374 -378 0 -462 c0 -254
|
||||||
|
-5 -462 -11 -462 -6 0 -55 23 -110 50 l-99 51 0 208 c0 259 -11 288 -176 457
|
||||||
|
-196 200 -188 199 -326 62 l-117 -116 -35 79 c-71 161 -39 569 64 816 42 100
|
||||||
|
20 116 436 -305z m1349 23 c109 -390 -87 -848 -475 -1111 -207 -139 -305 -262
|
||||||
|
-338 -420 -7 -31 -21 -56 -32 -56 -36 -1 -46 86 -48 405 l-2 313 123 137 c68
|
||||||
|
75 214 236 325 357 454 493 421 466 447 375z m-1292 -785 c12 -29 15 -118 7
|
||||||
|
-215 l-14 -166 -73 44 c-174 104 -403 341 -403 416 0 15 47 70 105 123 l104
|
||||||
|
98 127 -125 c70 -69 136 -147 147 -175z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
@@ -1,274 +0,0 @@
|
|||||||
/**
|
|
||||||
* Session-level request cache — v3.
|
|
||||||
*
|
|
||||||
* Key design decisions (preserved from v1/v2):
|
|
||||||
* - Stores the Promise itself — concurrent callers await the same fetch (no thundering herd).
|
|
||||||
* - On real errors the entry is evicted so the next call retries.
|
|
||||||
* - AbortErrors do NOT evict — cancellation ≠ failure.
|
|
||||||
* - Subscribers are notified when a key is explicitly cleared or updated.
|
|
||||||
*
|
|
||||||
* v3 additions:
|
|
||||||
* - cache.set(): direct write without a fetcher — for optimistic updates and
|
|
||||||
* post-mutation cache patching. Notifies subscribers immediately.
|
|
||||||
* - Invalidation groups: tag a cache key with one or more group strings.
|
|
||||||
* cache.clearGroup("library") clears ALL keys tagged with "library" in one call.
|
|
||||||
* This replaces the pattern of manually calling cache.clear() on every related key.
|
|
||||||
* - Subscriber notifications on set() — reactive components re-render when the
|
|
||||||
* cache is updated, not just when it's cleared.
|
|
||||||
* - cache.update(): atomically patch a cached value (read → transform → write).
|
|
||||||
*/
|
|
||||||
|
|
||||||
interface Entry<T> {
|
|
||||||
promise: Promise<T>;
|
|
||||||
fetchedAt: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const store = new Map<string, Entry<unknown>>();
|
|
||||||
const subs = new Map<string, Set<() => void>>();
|
|
||||||
const groups = new Map<string, Set<string>>(); // groupTag → Set<cacheKey>
|
|
||||||
|
|
||||||
export const DEFAULT_TTL_MS = 5 * 60 * 1_000;
|
|
||||||
|
|
||||||
function notify(key: string) {
|
|
||||||
subs.get(key)?.forEach((cb) => cb());
|
|
||||||
}
|
|
||||||
|
|
||||||
export const cache = {
|
|
||||||
/**
|
|
||||||
* Return a cached promise. Re-fetches once older than `ttl` ms.
|
|
||||||
* Pass `Infinity` to pin for the session.
|
|
||||||
*/
|
|
||||||
get<T>(
|
|
||||||
key: string,
|
|
||||||
fetcher: () => Promise<T>,
|
|
||||||
ttl: number = DEFAULT_TTL_MS,
|
|
||||||
group?: string | string[],
|
|
||||||
): Promise<T> {
|
|
||||||
const existing = store.get(key) as Entry<T> | undefined;
|
|
||||||
if (existing && Date.now() - existing.fetchedAt < ttl) return existing.promise;
|
|
||||||
|
|
||||||
const promise = fetcher().catch((err) => {
|
|
||||||
if (err?.name !== "AbortError") store.delete(key);
|
|
||||||
return Promise.reject(err);
|
|
||||||
}) as Promise<T>;
|
|
||||||
|
|
||||||
store.set(key, { promise, fetchedAt: Date.now() });
|
|
||||||
|
|
||||||
// Register in invalidation groups
|
|
||||||
if (group) {
|
|
||||||
const tags = Array.isArray(group) ? group : [group];
|
|
||||||
for (const tag of tags) {
|
|
||||||
if (!groups.has(tag)) groups.set(tag, new Set());
|
|
||||||
groups.get(tag)!.add(key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Notify subscribers once the fetch resolves (reactive update on new data)
|
|
||||||
promise.then(() => notify(key)).catch(() => {});
|
|
||||||
|
|
||||||
return promise;
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Directly write a value into the cache — for optimistic updates and
|
|
||||||
* post-mutation patching. Notifies subscribers immediately.
|
|
||||||
*/
|
|
||||||
set<T>(key: string, value: T, group?: string | string[]) {
|
|
||||||
const promise = Promise.resolve(value);
|
|
||||||
store.set(key, { promise, fetchedAt: Date.now() });
|
|
||||||
|
|
||||||
if (group) {
|
|
||||||
const tags = Array.isArray(group) ? group : [group];
|
|
||||||
for (const tag of tags) {
|
|
||||||
if (!groups.has(tag)) groups.set(tag, new Set());
|
|
||||||
groups.get(tag)!.add(key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
notify(key);
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Atomically patch a cached value.
|
|
||||||
* If the key doesn't exist, does nothing.
|
|
||||||
*/
|
|
||||||
update<T>(key: string, fn: (prev: T) => T) {
|
|
||||||
const existing = store.get(key) as Entry<T> | undefined;
|
|
||||||
if (!existing) return;
|
|
||||||
const next = existing.promise.then(fn);
|
|
||||||
store.set(key, { promise: next, fetchedAt: Date.now() });
|
|
||||||
next.then(() => notify(key)).catch(() => {});
|
|
||||||
},
|
|
||||||
|
|
||||||
has(key: string): boolean { return store.has(key); },
|
|
||||||
|
|
||||||
ageOf(key: string): number | undefined {
|
|
||||||
const e = store.get(key);
|
|
||||||
return e ? Date.now() - e.fetchedAt : undefined;
|
|
||||||
},
|
|
||||||
|
|
||||||
clear(key: string) {
|
|
||||||
store.delete(key);
|
|
||||||
notify(key);
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clear all keys belonging to an invalidation group.
|
|
||||||
* e.g. cache.clearGroup("library") clears "library", "all_manga_unfiltered", etc.
|
|
||||||
*/
|
|
||||||
clearGroup(tag: string) {
|
|
||||||
const keys = groups.get(tag);
|
|
||||||
if (!keys) return;
|
|
||||||
for (const key of keys) {
|
|
||||||
store.delete(key);
|
|
||||||
notify(key);
|
|
||||||
}
|
|
||||||
groups.delete(tag);
|
|
||||||
},
|
|
||||||
|
|
||||||
clearAll() {
|
|
||||||
const allKeys = [...store.keys()];
|
|
||||||
store.clear();
|
|
||||||
groups.clear();
|
|
||||||
allKeys.forEach(notify);
|
|
||||||
},
|
|
||||||
|
|
||||||
subscribe(key: string, cb: () => void): () => void {
|
|
||||||
if (!subs.has(key)) subs.set(key, new Set());
|
|
||||||
subs.get(key)!.add(cb);
|
|
||||||
return () => subs.get(key)?.delete(cb);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── Cache key constants ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Invalidation group tags.
|
|
||||||
* cache.clearGroup(CACHE_GROUPS.LIBRARY) clears all library-related keys at once.
|
|
||||||
*/
|
|
||||||
export const CACHE_GROUPS = {
|
|
||||||
LIBRARY: "g:library", // library + all_manga_unfiltered
|
|
||||||
SOURCES: "g:sources", // sources list + per-source page caches
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export const CACHE_KEYS = {
|
|
||||||
LIBRARY: "library",
|
|
||||||
ALL_MANGA: "all_manga_unfiltered",
|
|
||||||
DISCOVER: "discover_all_manga", // Discover's unfiltered fetch — separate from library
|
|
||||||
SOURCES: "sources",
|
|
||||||
POPULAR: "popular",
|
|
||||||
GENRE: (genre: string) => `genre:${genre}`,
|
|
||||||
MANGA: (id: number) => `manga:${id}`,
|
|
||||||
CHAPTERS: (id: number) => `chapters:${id}`,
|
|
||||||
|
|
||||||
sourceMangaPages(
|
|
||||||
sourceId: string,
|
|
||||||
type: "POPULAR" | "LATEST" | "SEARCH",
|
|
||||||
query?: string | string[],
|
|
||||||
): string {
|
|
||||||
const q = Array.isArray(query) ? [...query].sort().join("+") : (query ?? "");
|
|
||||||
return `pages:${sourceId}:${type}:${q}`;
|
|
||||||
},
|
|
||||||
|
|
||||||
sourceMangaPage(
|
|
||||||
sourceId: string,
|
|
||||||
type: "POPULAR" | "LATEST" | "SEARCH",
|
|
||||||
page: number,
|
|
||||||
query?: string | string[],
|
|
||||||
): string {
|
|
||||||
const q = Array.isArray(query) ? [...query].sort().join("+") : (query ?? "");
|
|
||||||
return `page:${sourceId}:${type}:${page}:${q}`;
|
|
||||||
},
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
// ── In-flight request deduplication (for non-cached calls) ───────────────────
|
|
||||||
//
|
|
||||||
// Some requests (chapter lists, manga detail) are NOT stored in the long-lived
|
|
||||||
// cache but still get fired multiple times when a user rapidly opens/closes a
|
|
||||||
// manga. This map deduplicates them so only one network round-trip is active at
|
|
||||||
// a time per key.
|
|
||||||
|
|
||||||
const inflight = new Map<string, Promise<unknown>>();
|
|
||||||
|
|
||||||
export function deduped<T>(key: string, fetcher: () => Promise<T>): Promise<T> {
|
|
||||||
if (inflight.has(key)) return inflight.get(key) as Promise<T>;
|
|
||||||
const p = fetcher().finally(() => inflight.delete(key));
|
|
||||||
inflight.set(key, p);
|
|
||||||
return p;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── PageSet: per-session page-number tracker ──────────────────────────────────
|
|
||||||
//
|
|
||||||
// Tracks which page numbers have been fetched for a (source, type, query) bucket.
|
|
||||||
// Lives in a separate map from the TTL store so it never gets TTL-evicted while
|
|
||||||
// a browse session is actively paginating.
|
|
||||||
//
|
|
||||||
// Usage:
|
|
||||||
// const ps = getPageSet(sourceId, "SEARCH", ["Action", "Romance"]);
|
|
||||||
// ps.add(1); // after fetching page 1
|
|
||||||
// ps.next(); // → 2
|
|
||||||
// ps.pages(); // → Set {1}
|
|
||||||
// ps.clear(); // call when query/tags change
|
|
||||||
|
|
||||||
const _pageSets = new Map<string, Set<number>>();
|
|
||||||
|
|
||||||
export interface PageSet {
|
|
||||||
add(page: number): void;
|
|
||||||
pages(): Set<number>;
|
|
||||||
/** Next page to fetch: max fetched + 1, or 1 if nothing fetched yet. */
|
|
||||||
next(): number;
|
|
||||||
clear(): void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getPageSet(
|
|
||||||
sourceId: string,
|
|
||||||
type: "POPULAR" | "LATEST" | "SEARCH",
|
|
||||||
query?: string | string[],
|
|
||||||
): PageSet {
|
|
||||||
const key = CACHE_KEYS.sourceMangaPages(sourceId, type, query);
|
|
||||||
return {
|
|
||||||
add(page) {
|
|
||||||
if (!_pageSets.has(key)) _pageSets.set(key, new Set());
|
|
||||||
_pageSets.get(key)!.add(page);
|
|
||||||
},
|
|
||||||
pages() { return new Set(_pageSets.get(key) ?? []); },
|
|
||||||
next() { const s = _pageSets.get(key); return s?.size ? Math.max(...s) + 1 : 1; },
|
|
||||||
clear() { _pageSets.delete(key); },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Source frecency helpers ───────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const FRECENCY_KEY = "moku-source-frecency";
|
|
||||||
const MAX_FRECENCY_SOURCES = 4;
|
|
||||||
|
|
||||||
type FrecencyMap = Record<string, number>;
|
|
||||||
|
|
||||||
function loadFrecency(): FrecencyMap {
|
|
||||||
try { const r = localStorage.getItem(FRECENCY_KEY); return r ? JSON.parse(r) : {}; }
|
|
||||||
catch { return {}; }
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveFrecency(map: FrecencyMap) {
|
|
||||||
try { localStorage.setItem(FRECENCY_KEY, JSON.stringify(map)); } catch {}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function recordSourceAccess(sourceId: string) {
|
|
||||||
if (!sourceId || sourceId === "0") return;
|
|
||||||
const map = loadFrecency();
|
|
||||||
map[sourceId] = (map[sourceId] ?? 0) + 1;
|
|
||||||
saveFrecency(map);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getTopSources<T extends { id: string }>(sources: T[]): T[] {
|
|
||||||
const map = loadFrecency();
|
|
||||||
const withScore = sources.map((s) => ({ s, score: map[s.id] ?? 0 }));
|
|
||||||
const hasFrecency = withScore.some((x) => x.score > 0);
|
|
||||||
if (hasFrecency) {
|
|
||||||
return withScore
|
|
||||||
.sort((a, b) => b.score - a.score)
|
|
||||||
.slice(0, MAX_FRECENCY_SOURCES)
|
|
||||||
.map((x) => x.s);
|
|
||||||
}
|
|
||||||
return sources.slice(0, MAX_FRECENCY_SOURCES);
|
|
||||||
}
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
import { store } from "../store/state.svelte";
|
|
||||||
|
|
||||||
const DEFAULT_URL = "http://127.0.0.1:4567";
|
|
||||||
|
|
||||||
function getServerUrl(): string {
|
|
||||||
const url = store.settings.serverUrl;
|
|
||||||
return typeof url === "string" && url.trim() ? url.replace(/\/$/, "") : DEFAULT_URL;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getAuthHeader(): Record<string, string> {
|
|
||||||
const s = store.settings;
|
|
||||||
if (!s.serverAuthEnabled) return {};
|
|
||||||
const user = typeof s.serverAuthUser === "string" ? s.serverAuthUser.trim() : "";
|
|
||||||
const pass = typeof s.serverAuthPass === "string" ? s.serverAuthPass.trim() : "";
|
|
||||||
if (user && pass) return { Authorization: `Basic ${btoa(`${user}:${pass}`)}` };
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
function gqlUrl(): string { return `${getServerUrl()}/api/graphql`; }
|
|
||||||
|
|
||||||
export function thumbUrl(path: string): string {
|
|
||||||
if (!path) return "";
|
|
||||||
if (path.startsWith("http")) return path;
|
|
||||||
return `${getServerUrl()}${path}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface GQLResponse<T> {
|
|
||||||
data: T;
|
|
||||||
errors?: { message: string }[];
|
|
||||||
}
|
|
||||||
|
|
||||||
function abortableSleep(ms: number, signal?: AbortSignal): Promise<void> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
if (signal?.aborted) { reject(new DOMException("Aborted", "AbortError")); return; }
|
|
||||||
const timer = setTimeout(resolve, ms);
|
|
||||||
signal?.addEventListener("abort", () => {
|
|
||||||
clearTimeout(timer);
|
|
||||||
reject(new DOMException("Aborted", "AbortError"));
|
|
||||||
}, { once: true });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchWithRetry(
|
|
||||||
url: string,
|
|
||||||
init: RequestInit,
|
|
||||||
signal?: AbortSignal,
|
|
||||||
retries = 3,
|
|
||||||
delayMs = 300,
|
|
||||||
): Promise<Response> {
|
|
||||||
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
|
||||||
|
|
||||||
for (let i = 0; i < retries; i++) {
|
|
||||||
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch(url, { ...init, signal });
|
|
||||||
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
|
||||||
return res;
|
|
||||||
} catch (e: any) {
|
|
||||||
const isAbort = e?.name === "AbortError" || signal?.aborted;
|
|
||||||
if (isAbort) throw new DOMException("Aborted", "AbortError");
|
|
||||||
if (i === retries - 1) throw e;
|
|
||||||
await abortableSleep(delayMs * Math.pow(1.5, i), signal);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw new Error("unreachable");
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function gql<T>(
|
|
||||||
query: string,
|
|
||||||
variables?: Record<string, unknown>,
|
|
||||||
signal?: AbortSignal,
|
|
||||||
): Promise<T> {
|
|
||||||
const res = await fetchWithRetry(gqlUrl(), {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json", ...getAuthHeader() },
|
|
||||||
body: JSON.stringify({ query, variables }),
|
|
||||||
}, signal);
|
|
||||||
|
|
||||||
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
|
||||||
if (!res.ok) throw new Error(`Suwayomi HTTP ${res.status}`);
|
|
||||||
|
|
||||||
const json: GQLResponse<T> = await res.json();
|
|
||||||
|
|
||||||
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
|
||||||
if (json.errors?.length) throw new Error(json.errors[0].message);
|
|
||||||
|
|
||||||
return json.data;
|
|
||||||
}
|
|
||||||
@@ -1,35 +1,37 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { ArrowLeft, BookmarkSimple, FolderSimplePlus, Folder, CircleNotch } from "phosphor-svelte";
|
|
||||||
import { untrack } from "svelte";
|
import { untrack } from "svelte";
|
||||||
import { gql, thumbUrl } from "../../lib/client";
|
import { getAdapter } from "$lib/request-manager";
|
||||||
import { GET_ALL_MANGA, GET_LIBRARY, GET_SOURCES, FETCH_SOURCE_MANGA, UPDATE_MANGA } from "../../lib/queries";
|
import { settingsState } from "$lib/state/settings.svelte";
|
||||||
import { cache, CACHE_KEYS, getPageSet } from "../../lib/cache";
|
import { setPreviewManga } from "$lib/state/series.svelte";
|
||||||
import { dedupeSources, dedupeMangaById } from "../../lib/util";
|
import { dedupeMangaById, shouldHideNsfw } from "$lib/core/util";
|
||||||
import { store, addFolder, assignMangaToFolder, setGenreFilter, setPreviewManga, setNavPage } from "../../store/state.svelte";
|
import Thumbnail from "$lib/components/shared/manga/Thumbnail.svelte";
|
||||||
import type { Manga, Source } from "../../lib/types";
|
import ContextMenu from "$lib/components/shared/ui/ContextMenu.svelte";
|
||||||
import ContextMenu, { type MenuEntry } from "../shared/ContextMenu.svelte";
|
import { ArrowLeftIcon, BookmarkSimpleIcon, FolderSimplePlusIcon, FolderIcon, CircleNotchIcon } from "phosphor-svelte";
|
||||||
|
import type { Manga, Source, Category } from "$lib/types";
|
||||||
|
import {
|
||||||
|
PAGE_SIZE, INITIAL_PAGES, MAX_SOURCES,
|
||||||
|
parseTags, tagsLabel, matchesAllTags, runConcurrent,
|
||||||
|
} from "$lib/components/browse/lib/searchFilter";
|
||||||
|
|
||||||
const PAGE_SIZE = 50;
|
interface MenuItem {
|
||||||
const INITIAL_PAGES = 3;
|
label: string;
|
||||||
const MAX_SOURCES = 12;
|
icon?: any;
|
||||||
const CONCURRENCY = 4;
|
onClick: () => void;
|
||||||
|
danger?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
separator?: never;
|
||||||
|
children?: MenuEntry[];
|
||||||
|
}
|
||||||
|
interface MenuSeparator { separator: true }
|
||||||
|
type MenuEntry = MenuItem | MenuSeparator;
|
||||||
|
|
||||||
function parseTags(f: string): string[] { return f.split("+").map((t) => t.trim()).filter(Boolean); }
|
interface Props {
|
||||||
function tagsLabel(tags: string[]): string {
|
genre: string;
|
||||||
if (tags.length === 1) return tags[0];
|
onBack: () => void;
|
||||||
return tags.slice(0, -1).join(", ") + " & " + tags[tags.length - 1];
|
|
||||||
}
|
}
|
||||||
function matchesAllTags(m: Manga, tags: string[]): boolean {
|
let { genre, onBack }: Props = $props();
|
||||||
const g = (m.genre ?? []).map((x) => x.toLowerCase());
|
|
||||||
return tags.every((t) => g.includes(t.toLowerCase()));
|
const tags = $derived(parseTags(genre));
|
||||||
}
|
|
||||||
async function runConcurrent<T>(items: T[], fn: (item: T) => Promise<void>, signal: AbortSignal): Promise<void> {
|
|
||||||
let i = 0;
|
|
||||||
async function worker() { while (i < items.length) { if (signal.aborted) return; await fn(items[i++]).catch(() => {}); } }
|
|
||||||
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, items.length) }, worker));
|
|
||||||
}
|
|
||||||
const prevNavPage = store.navPage;
|
|
||||||
const tags = $derived(parseTags(store.genreFilter));
|
|
||||||
const primaryTag = $derived(tags[0] ?? "");
|
const primaryTag = $derived(tags[0] ?? "");
|
||||||
const label = $derived(tagsLabel(tags));
|
const label = $derived(tagsLabel(tags));
|
||||||
|
|
||||||
@@ -39,21 +41,25 @@
|
|||||||
let loadingMore = $state(false);
|
let loadingMore = $state(false);
|
||||||
let visibleCount = $state(PAGE_SIZE);
|
let visibleCount = $state(PAGE_SIZE);
|
||||||
let ctx: { x: number; y: number; manga: Manga } | null = $state(null);
|
let ctx: { x: number; y: number; manga: Manga } | null = $state(null);
|
||||||
|
let categories: Category[] = $state([]);
|
||||||
|
let catsLoaded = false;
|
||||||
|
|
||||||
const nextPageMap = new Map<string, number>();
|
const nextPageMap = new Map<string, number>();
|
||||||
let sources: Source[] = $state([]);
|
let sources: Source[] = $state([]);
|
||||||
let abortCtrl: AbortController | null = null;
|
let abortCtrl: AbortController | null = null;
|
||||||
|
|
||||||
const filtered = $derived.by(() => {
|
const filtered = $derived.by(() => {
|
||||||
const libMatches = libraryManga.filter((m) => matchesAllTags(m, tags));
|
const libMatches = libraryManga.filter((m) => matchesAllTags(m, tags) && !shouldHideNsfw(m as any, settingsState.settings));
|
||||||
const libIds = new Set(libMatches.map((m) => m.id));
|
const libIds = new Set(libMatches.map((m) => m.id));
|
||||||
return dedupeMangaById([...libMatches, ...sourceManga.filter((m) => !libIds.has(m.id))]);
|
return dedupeMangaById([...libMatches, ...sourceManga.filter((m) => !libIds.has(m.id) && !shouldHideNsfw(m as any, settingsState.settings))]);
|
||||||
});
|
});
|
||||||
|
|
||||||
const visibleItems = $derived(filtered.slice(0, visibleCount));
|
const visibleItems = $derived(filtered.slice(0, visibleCount));
|
||||||
const hasMoreVisible = $derived(visibleCount < filtered.length);
|
const hasMoreVisible = $derived(visibleCount < filtered.length);
|
||||||
const hasMoreNetwork = $derived(sources.some((s) => (nextPageMap.get(s.id) ?? -1) > 0));
|
const hasMoreNetwork = $derived(sources.some((s) => (nextPageMap.get(s.id) ?? -1) > 0));
|
||||||
const hasMore = $derived(hasMoreVisible || hasMoreNetwork);
|
const hasMore = $derived(hasMoreVisible || hasMoreNetwork);
|
||||||
$effect(() => { const f = store.genreFilter; if (f) untrack(() => load(f)); });
|
|
||||||
|
$effect(() => { const f = genre; if (f) untrack(() => load(f)); });
|
||||||
|
|
||||||
async function load(filter: string) {
|
async function load(filter: string) {
|
||||||
abortCtrl?.abort();
|
abortCtrl?.abort();
|
||||||
@@ -65,39 +71,30 @@
|
|||||||
visibleCount = PAGE_SIZE;
|
visibleCount = PAGE_SIZE;
|
||||||
nextPageMap.clear();
|
nextPageMap.clear();
|
||||||
|
|
||||||
const preferredLang = store.settings.preferredExtensionLang || "en";
|
|
||||||
const t = parseTags(filter);
|
const t = parseTags(filter);
|
||||||
const pt = t[0] ?? "";
|
const pt = t[0] ?? "";
|
||||||
|
|
||||||
cache.get(CACHE_KEYS.LIBRARY, () =>
|
getAdapter().getMangaList({}).then((result: { items: Manga[] }) => {
|
||||||
Promise.all([gql<{ mangas: { nodes: Manga[] } }>(GET_ALL_MANGA), gql<{ mangas: { nodes: Manga[] } }>(GET_LIBRARY)])
|
if (!ctrl.signal.aborted) libraryManga = result.items;
|
||||||
.then(([all, lib]) => { const m = new Map(lib.mangas.nodes.map((x) => [x.id, x])); return all.mangas.nodes.map((x) => m.get(x.id) ?? x); })
|
}).catch(() => {});
|
||||||
).then((manga) => { if (!ctrl.signal.aborted) libraryManga = manga; }).catch(() => {});
|
|
||||||
|
|
||||||
cache.get(CACHE_KEYS.SOURCES, () =>
|
getAdapter().getSources().then(async (allSources: Source[]) => {
|
||||||
gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
|
if (ctrl.signal.aborted) return;
|
||||||
.then((d) => dedupeSources(d.sources.nodes.filter((s) => s.id !== "0"), preferredLang)),
|
const srcs = allSources.filter((s: Source) => s.id !== "0").slice(0, MAX_SOURCES);
|
||||||
Infinity,
|
|
||||||
).then(async (allSources) => {
|
|
||||||
const srcs = allSources.slice(0, MAX_SOURCES);
|
|
||||||
sources = srcs;
|
sources = srcs;
|
||||||
for (const src of srcs) nextPageMap.set(src.id, -1);
|
for (const src of srcs) nextPageMap.set(src.id, -1);
|
||||||
|
|
||||||
await runConcurrent(srcs, async (src) => {
|
await runConcurrent(srcs, async (src: Source) => {
|
||||||
if (ctrl.signal.aborted) return;
|
if (ctrl.signal.aborted) return;
|
||||||
const ps = getPageSet(src.id, "SEARCH", t);
|
|
||||||
const pageItems: Manga[] = [];
|
const pageItems: Manga[] = [];
|
||||||
for (let page = 1; page <= INITIAL_PAGES; page++) {
|
for (let page = 1; page <= INITIAL_PAGES; page++) {
|
||||||
if (ctrl.signal.aborted) return;
|
if (ctrl.signal.aborted) return;
|
||||||
const pageKey = CACHE_KEYS.sourceMangaPage(src.id, "SEARCH", page, t);
|
let result: { items: Manga[]; hasNextPage: boolean } | null = null;
|
||||||
const result = await cache.get<{ mangas: Manga[]; hasNextPage: boolean }>(
|
try {
|
||||||
pageKey,
|
result = await getAdapter().searchSource(src.id, pt, page, ctrl.signal);
|
||||||
() => gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>(FETCH_SOURCE_MANGA, { source: src.id, type: "SEARCH", page, query: pt }, ctrl.signal)
|
} catch { break; }
|
||||||
.then((d) => d.fetchSourceManga),
|
|
||||||
).catch(() => null);
|
|
||||||
if (!result || ctrl.signal.aborted) break;
|
if (!result || ctrl.signal.aborted) break;
|
||||||
ps.add(page);
|
const matching = t.length > 1 ? result.items.filter((m) => matchesAllTags(m, t)) : result.items;
|
||||||
const matching = t.length > 1 ? result.mangas.filter((m) => matchesAllTags(m, t)) : result.mangas;
|
|
||||||
pageItems.push(...matching);
|
pageItems.push(...matching);
|
||||||
if (!result.hasNextPage) { nextPageMap.set(src.id, -1); break; }
|
if (!result.hasNextPage) { nextPageMap.set(src.id, -1); break; }
|
||||||
else if (page === INITIAL_PAGES) nextPageMap.set(src.id, INITIAL_PAGES + 1);
|
else if (page === INITIAL_PAGES) nextPageMap.set(src.id, INITIAL_PAGES + 1);
|
||||||
@@ -122,20 +119,16 @@
|
|||||||
const ctrl = new AbortController();
|
const ctrl = new AbortController();
|
||||||
abortCtrl = ctrl;
|
abortCtrl = ctrl;
|
||||||
try {
|
try {
|
||||||
await runConcurrent(srcs, async (src) => {
|
await runConcurrent(srcs, async (src: Source) => {
|
||||||
const page = nextPageMap.get(src.id)!;
|
const page = nextPageMap.get(src.id)!;
|
||||||
if (ctrl.signal.aborted) return;
|
if (ctrl.signal.aborted) return;
|
||||||
const ps = getPageSet(src.id, "SEARCH", tags);
|
let result: { items: Manga[]; hasNextPage: boolean } | null = null;
|
||||||
const pageKey = CACHE_KEYS.sourceMangaPage(src.id, "SEARCH", page, tags);
|
try {
|
||||||
const result = await cache.get<{ mangas: Manga[]; hasNextPage: boolean }>(
|
result = await getAdapter().searchSource(src.id, primaryTag, page, ctrl.signal);
|
||||||
pageKey,
|
} catch { nextPageMap.set(src.id, -1); return; }
|
||||||
() => gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>(FETCH_SOURCE_MANGA, { source: src.id, type: "SEARCH", page, query: primaryTag }, ctrl.signal)
|
|
||||||
.then((d) => d.fetchSourceManga),
|
|
||||||
).catch(() => { nextPageMap.set(src.id, -1); return null; });
|
|
||||||
if (!result || ctrl.signal.aborted) return;
|
if (!result || ctrl.signal.aborted) return;
|
||||||
ps.add(page);
|
|
||||||
nextPageMap.set(src.id, result.hasNextPage ? page + 1 : -1);
|
nextPageMap.set(src.id, result.hasNextPage ? page + 1 : -1);
|
||||||
const matching = tags.length > 1 ? result.mangas.filter((m) => matchesAllTags(m, tags)) : result.mangas;
|
const matching = tags.length > 1 ? result.items.filter((m) => matchesAllTags(m, tags)) : result.items;
|
||||||
if (matching.length > 0) sourceManga = dedupeMangaById([...sourceManga, ...matching]);
|
if (matching.length > 0) sourceManga = dedupeMangaById([...sourceManga, ...matching]);
|
||||||
}, ctrl.signal);
|
}, ctrl.signal);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -143,19 +136,49 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openCtx(e: MouseEvent, m: Manga) {
|
||||||
|
e.preventDefault();
|
||||||
|
ctx = { x: e.clientX, y: e.clientY, manga: m };
|
||||||
|
if (!catsLoaded) {
|
||||||
|
catsLoaded = true;
|
||||||
|
getAdapter().getCategories()
|
||||||
|
.then((cats: Category[]) => { categories = cats.filter((c: Category) => c.id !== 0); })
|
||||||
|
.catch(console.error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function buildCtxItems(m: Manga): MenuEntry[] {
|
function buildCtxItems(m: Manga): MenuEntry[] {
|
||||||
return [
|
return [
|
||||||
{ label: m.inLibrary ? "In Library" : "Add to library", icon: BookmarkSimple, disabled: m.inLibrary,
|
{
|
||||||
onClick: () => gql(UPDATE_MANGA, { id: m.id, inLibrary: true }).then(() => { sourceManga = sourceManga.map((x) => x.id === m.id ? { ...x, inLibrary: true } : x); cache.clear(CACHE_KEYS.LIBRARY); }).catch(console.error) },
|
label: m.inLibrary ? "In Library" : "Add to library",
|
||||||
...(store.settings.folders.length > 0 ? [
|
icon: BookmarkSimpleIcon,
|
||||||
|
disabled: m.inLibrary,
|
||||||
|
onClick: () => getAdapter().addToLibrary(String(m.id))
|
||||||
|
.then(() => { sourceManga = sourceManga.map((x) => x.id === m.id ? { ...x, inLibrary: true } : x); })
|
||||||
|
.catch(console.error),
|
||||||
|
},
|
||||||
|
...(categories.length > 0 ? [
|
||||||
{ separator: true } as MenuEntry,
|
{ separator: true } as MenuEntry,
|
||||||
...store.settings.folders.map((f): MenuEntry => ({
|
...categories.map((cat): MenuEntry => ({
|
||||||
label: f.mangaIds.includes(m.id) ? `✓ ${f.name}` : f.name, icon: Folder,
|
label: (cat.mangas ?? []).some((x: Manga) => x.id === m.id) ? `✓ ${cat.name}` : cat.name,
|
||||||
onClick: () => assignMangaToFolder(f.id, m.id),
|
icon: FolderIcon,
|
||||||
|
onClick: () => getAdapter().updateMangaCategories(String(m.id), [cat.id], []).catch(console.error),
|
||||||
})),
|
})),
|
||||||
] : []),
|
] : []),
|
||||||
{ separator: true },
|
{ separator: true },
|
||||||
{ label: "New folder & add", icon: FolderSimplePlus, onClick: () => { const name = prompt("Folder name:"); if (name?.trim()) { const id = addFolder(name.trim()); assignMangaToFolder(id, m.id); } } },
|
{
|
||||||
|
label: "New folder & add",
|
||||||
|
icon: FolderSimplePlusIcon,
|
||||||
|
onClick: async () => {
|
||||||
|
const name = prompt("Folder name:");
|
||||||
|
if (!name?.trim()) return;
|
||||||
|
const cat = await getAdapter().createCategory(name.trim()).catch(console.error);
|
||||||
|
if (cat) {
|
||||||
|
categories = [...categories, cat];
|
||||||
|
await getAdapter().updateMangaCategories(String(m.id), [cat.id], []).catch(console.error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,8 +187,8 @@
|
|||||||
|
|
||||||
<div class="root">
|
<div class="root">
|
||||||
<div class="header">
|
<div class="header">
|
||||||
<button class="back" onclick={() => { setGenreFilter(""); setNavPage(prevNavPage); }}>
|
<button class="back" onclick={onBack}>
|
||||||
<ArrowLeft size={13} weight="light" /><span>Back</span>
|
<ArrowLeftIcon size={13} weight="light" /><span>Back</span>
|
||||||
</button>
|
</button>
|
||||||
<span class="title">{label}</span>
|
<span class="title">{label}</span>
|
||||||
{#if !loadingInitial || filtered.length > 0}
|
{#if !loadingInitial || filtered.length > 0}
|
||||||
@@ -189,10 +212,10 @@
|
|||||||
<div class="empty">No manga found for "{label}".</div>
|
<div class="empty">No manga found for "{label}".</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
{#each visibleItems as m (m.id)}
|
{#each visibleItems as m, i (m.id)}
|
||||||
<button class="card" onclick={() => setPreviewManga(m)} oncontextmenu={(e) => { e.preventDefault(); e.stopPropagation(); ctx = { x: e.clientX, y: e.clientY, manga: m }; }}>
|
<button class="card" onclick={() => setPreviewManga(m)} oncontextmenu={(e) => { e.stopPropagation(); openCtx(e, m); }}>
|
||||||
<div class="cover-wrap">
|
<div class="cover-wrap">
|
||||||
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} class="cover" loading="lazy" decoding="async" />
|
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="cover" priority={i < 12 ? 12 - i : 0} id={m.id} />
|
||||||
{#if m.inLibrary}<span class="in-library-badge">Saved</span>{/if}
|
{#if m.inLibrary}<span class="in-library-badge">Saved</span>{/if}
|
||||||
</div>
|
</div>
|
||||||
<p class="card-title">{m.title}</p>
|
<p class="card-title">{m.title}</p>
|
||||||
@@ -201,7 +224,7 @@
|
|||||||
{#if hasMore}
|
{#if hasMore}
|
||||||
<div class="show-more-cell">
|
<div class="show-more-cell">
|
||||||
<button class="show-more-btn" onclick={loadMore} disabled={loadingMore}>
|
<button class="show-more-btn" onclick={loadMore} disabled={loadingMore}>
|
||||||
{#if loadingMore}<CircleNotch size={13} weight="light" class="anim-spin" /> Loading…{:else}Show more{/if}
|
{#if loadingMore}<CircleNotchIcon size={13} weight="light" class="anim-spin" /> Loading…{:else}Show more{/if}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -222,18 +245,21 @@
|
|||||||
.result-count, .loading-hint { margin-left: auto; font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
.result-count, .loading-hint { margin-left: auto; font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||||
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(clamp(100px,13vw,140px),1fr)); gap: var(--sp-4); padding: var(--sp-5) var(--sp-6) var(--sp-6); overflow-y: auto; flex: 1; align-content: start; will-change: scroll-position; -webkit-overflow-scrolling: touch; contain: layout style; }
|
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(clamp(100px,13vw,140px),1fr)); gap: var(--sp-4); padding: var(--sp-5) var(--sp-6) var(--sp-6); overflow-y: auto; flex: 1; align-content: start; will-change: scroll-position; -webkit-overflow-scrolling: touch; contain: layout style; }
|
||||||
.card { background: none; border: none; padding: 0; cursor: pointer; text-align: left; }
|
.card { background: none; border: none; padding: 0; cursor: pointer; text-align: left; }
|
||||||
.card:hover .cover { filter: brightness(1.06); }
|
.card:hover :global(.cover) { filter: brightness(1.06); }
|
||||||
.card:hover .card-title { color: var(--text-primary); }
|
.card:hover .card-title { color: var(--text-primary); }
|
||||||
.cover-wrap { position: relative; aspect-ratio: 2/3; overflow: hidden; border-radius: var(--radius-md); background: var(--bg-raised); border: 1px solid var(--border-dim); transform: translateZ(0); }
|
.cover-wrap { position: relative; aspect-ratio: 2/3; overflow: hidden; border-radius: var(--radius-md); background: var(--bg-raised); border: 1px solid var(--border-dim); transform: translateZ(0); }
|
||||||
.cover { width: 100%; height: 100%; object-fit: cover; transition: filter var(--t-base); will-change: filter; }
|
:global(.cover) { width: 100%; height: 100%; object-fit: cover; transition: filter var(--t-base); will-change: filter; }
|
||||||
.in-library-badge { position: absolute; bottom: var(--sp-1); left: var(--sp-1); font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); text-transform: uppercase; background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim); padding: 2px 5px; border-radius: var(--radius-sm); }
|
.in-library-badge { position: absolute; bottom: var(--sp-1); left: var(--sp-1); font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); text-transform: uppercase; background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim); padding: 2px 5px; border-radius: var(--radius-sm); }
|
||||||
.card-title { margin-top: var(--sp-2); font-size: var(--text-sm); color: var(--text-secondary); line-height: var(--leading-snug); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; transition: color var(--t-base); }
|
.card-title { margin-top: var(--sp-2); font-size: var(--text-sm); color: var(--text-secondary); line-height: var(--leading-snug); display: -webkit-box; -webkit-line-clamp: 2; line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; transition: color var(--t-base); }
|
||||||
.card-skeleton { padding: 0; }
|
.card-skeleton { padding: 0; }
|
||||||
.cover-skeleton { aspect-ratio: 2/3; border-radius: var(--radius-md); }
|
.cover-skeleton { aspect-ratio: 2/3; border-radius: var(--radius-md); }
|
||||||
.title-skeleton { height: 11px; margin-top: var(--sp-2); width: 75%; }
|
.title-skeleton { height: 11px; margin-top: var(--sp-2); width: 75%; }
|
||||||
|
@keyframes shimmer { from { background-position: -200% 0 } to { background-position: 200% 0 } }
|
||||||
|
.skeleton { border-radius: var(--radius-sm); background: linear-gradient(90deg, var(--bg-raised) 25%, var(--bg-overlay, color-mix(in srgb, var(--bg-raised) 80%, var(--text-primary) 6%)) 50%, var(--bg-raised) 75%); background-size: 200% 100%; animation: shimmer 1.6s ease-in-out infinite; }
|
||||||
.empty { display: flex; align-items: center; justify-content: center; flex: 1; color: var(--text-faint); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); }
|
.empty { display: flex; align-items: center; justify-content: center; flex: 1; color: var(--text-faint); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); }
|
||||||
.show-more-cell { grid-column: 1/-1; display: flex; justify-content: center; padding: var(--sp-2) 0 var(--sp-4); }
|
.show-more-cell { grid-column: 1/-1; display: flex; justify-content: center; padding: var(--sp-2) 0 var(--sp-4); }
|
||||||
.show-more-btn { display: flex; align-items: center; gap: var(--sp-2); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 7px 20px; border-radius: var(--radius-md); background: var(--bg-raised); color: var(--text-muted); border: 1px solid var(--border-dim); cursor: pointer; transition: color var(--t-base), border-color var(--t-base); }
|
.show-more-btn { display: flex; align-items: center; gap: var(--sp-2); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 7px 20px; border-radius: var(--radius-md); background: var(--bg-raised); color: var(--text-muted); border: 1px solid var(--border-dim); cursor: pointer; transition: color var(--t-base), border-color var(--t-base); }
|
||||||
.show-more-btn:hover:not(:disabled) { color: var(--text-secondary); border-color: var(--border-strong); }
|
.show-more-btn:hover:not(:disabled) { color: var(--text-secondary); border-color: var(--border-strong); }
|
||||||
.show-more-btn:disabled { opacity: 0.5; cursor: default; }
|
.show-more-btn:disabled { opacity: 0.5; cursor: default; }
|
||||||
|
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
|
||||||
</style>
|
</style>
|
||||||
@@ -0,0 +1,331 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onDestroy } from "svelte";
|
||||||
|
import { getAdapter } from "$lib/request-manager";
|
||||||
|
import { settingsState } from "$lib/state/settings.svelte";
|
||||||
|
import { shouldHideNsfw, shouldHideSource, dedupeMangaById, dedupeMangaByTitle } from "$lib/core/util";
|
||||||
|
import Thumbnail from "$lib/components/shared/manga/Thumbnail.svelte";
|
||||||
|
import type { Manga, Source } from "$lib/types";
|
||||||
|
import type { CachedManga } from "$lib/components/browse/lib/searchFilter";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
allSources: Source[];
|
||||||
|
availableLangs: string[];
|
||||||
|
hasMultipleLangs: boolean;
|
||||||
|
loadingSources: boolean;
|
||||||
|
pendingPrefill: string;
|
||||||
|
popularResults: (Manga & { _priority: number })[];
|
||||||
|
popularLoading: boolean;
|
||||||
|
sourceCache: Map<number, CachedManga>;
|
||||||
|
query: string;
|
||||||
|
onQueryChange: (q: string) => void;
|
||||||
|
onPrefillConsumed: () => void;
|
||||||
|
onPreview: (m: Manga) => void;
|
||||||
|
}
|
||||||
|
let {
|
||||||
|
allSources, availableLangs, hasMultipleLangs, loadingSources,
|
||||||
|
pendingPrefill, popularResults, popularLoading,
|
||||||
|
sourceCache,
|
||||||
|
query, onQueryChange,
|
||||||
|
onPrefillConsumed, onPreview,
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
const preferredLang = $derived(settingsState.settings.preferredExtensionLang ?? "en");
|
||||||
|
|
||||||
|
let kw_results: SourceResult[] = $state([]);
|
||||||
|
let kw_showAdvanced = $state(false);
|
||||||
|
let kw_selectedLangs: Set<string> = $state(new Set());
|
||||||
|
let kw_inputEl: HTMLInputElement | null = $state(null);
|
||||||
|
let kw_abortCtrl: AbortController | null = null;
|
||||||
|
let kw_debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let kw_localQuery = $state("");
|
||||||
|
let kw_pending = $state(false);
|
||||||
|
|
||||||
|
interface SourceResult {
|
||||||
|
source: Source;
|
||||||
|
mangas: Manga[];
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!allSources.length) return;
|
||||||
|
const available = new Set(allSources.map((s) => s.lang));
|
||||||
|
kw_selectedLangs = available.has(preferredLang)
|
||||||
|
? new Set([preferredLang])
|
||||||
|
: new Set(availableLangs.slice(0, 1));
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!loadingSources && pendingPrefill && allSources.length) {
|
||||||
|
const q = pendingPrefill;
|
||||||
|
onPrefillConsumed();
|
||||||
|
kw_localQuery = q;
|
||||||
|
onQueryChange(q);
|
||||||
|
kwDoSearch(q);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function kwHandleInput(value: string) {
|
||||||
|
kw_localQuery = value;
|
||||||
|
if (kw_debounceTimer) clearTimeout(kw_debounceTimer);
|
||||||
|
if (!value.trim()) { kw_abortCtrl?.abort(); kw_results = []; kw_pending = false; onQueryChange(""); return; }
|
||||||
|
kw_pending = true;
|
||||||
|
kw_debounceTimer = setTimeout(() => {
|
||||||
|
kw_pending = false;
|
||||||
|
onQueryChange(value);
|
||||||
|
kwDoSearch(value);
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
const kw_visibleSources = $derived.by(() => {
|
||||||
|
let srcs = allSources;
|
||||||
|
if (kw_selectedLangs.size > 0)
|
||||||
|
srcs = srcs.filter((s) => kw_selectedLangs.has(s.lang));
|
||||||
|
if (settingsState.settings.contentLevel !== "unrestricted")
|
||||||
|
srcs = srcs.filter((s) => !shouldHideSource(s, settingsState.settings));
|
||||||
|
return srcs;
|
||||||
|
});
|
||||||
|
|
||||||
|
async function kwDoSearch(q: string) {
|
||||||
|
const trimmed = q.trim();
|
||||||
|
if (!trimmed) return;
|
||||||
|
const visible = kw_visibleSources;
|
||||||
|
if (!visible.length) return;
|
||||||
|
|
||||||
|
kw_abortCtrl?.abort();
|
||||||
|
const ctrl = new AbortController();
|
||||||
|
kw_abortCtrl = ctrl;
|
||||||
|
|
||||||
|
kw_results = visible.map((src) => ({ source: src, mangas: [], loading: true, error: null }));
|
||||||
|
const idxOf = new Map(visible.map((src, i) => [src.id, i]));
|
||||||
|
|
||||||
|
await Promise.allSettled(visible.map(async (src) => {
|
||||||
|
const idx = idxOf.get(src.id)!;
|
||||||
|
try {
|
||||||
|
const result: { items: Manga[]; hasNextPage: boolean } = await getAdapter().searchSource(src.id, trimmed, 1, ctrl.signal);
|
||||||
|
if (ctrl.signal.aborted) return;
|
||||||
|
const mangas = result.items.filter((m) => !shouldHideNsfw(m as any, settingsState.settings));
|
||||||
|
kw_results[idx] = { ...kw_results[idx], mangas, loading: false };
|
||||||
|
} catch (e: any) {
|
||||||
|
if (ctrl.signal.aborted || e?.name === "AbortError") return;
|
||||||
|
kw_results[idx] = { ...kw_results[idx], loading: false, error: e.message ?? "Error" };
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function kwToggleLang(lang: string) {
|
||||||
|
const next = new Set(kw_selectedLangs);
|
||||||
|
if (next.has(lang)) { if (next.size === 1) return; next.delete(lang); }
|
||||||
|
else next.add(lang);
|
||||||
|
kw_selectedLangs = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
const kw_visibleCount = $derived(kw_visibleSources.length);
|
||||||
|
const kw_anyLoading = $derived(kw_results.some((r) => r.loading));
|
||||||
|
const kw_allDone = $derived(kw_results.length > 0 && kw_results.every((r) => !r.loading));
|
||||||
|
const kw_hasResults = $derived(kw_results.some((r) => r.mangas.length > 0));
|
||||||
|
|
||||||
|
const kw_flatResults = $derived.by(() => {
|
||||||
|
const all = kw_results.flatMap((r) =>
|
||||||
|
r.mangas.map((m) => ({ ...m, _sourceName: r.source.displayName }))
|
||||||
|
);
|
||||||
|
const deduped = dedupeMangaByTitle(
|
||||||
|
dedupeMangaById(all),
|
||||||
|
settingsState.settings.mangaLinks,
|
||||||
|
) as (Manga & { _sourceName?: string })[];
|
||||||
|
return deduped.map((m, i) => ({ ...m, _priority: i < 12 ? 12 - i : 0 }));
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
kw_abortCtrl?.abort();
|
||||||
|
if (kw_debounceTimer) clearTimeout(kw_debounceTimer);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="keywordBar">
|
||||||
|
<div class="searchBar">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 256 256" fill="currentColor" class="searchIcon" aria-hidden="true">
|
||||||
|
<path d="M229.66,218.34l-50.07-50.07a88,88,0,1,0-11.31,11.31l50.06,50.07a8,8,0,0,0,11.32-11.31ZM40,112a72,72,0,1,1,72,72A72.08,72.08,0,0,1,40,112Z"/>
|
||||||
|
</svg>
|
||||||
|
<input
|
||||||
|
bind:this={kw_inputEl}
|
||||||
|
value={kw_localQuery}
|
||||||
|
oninput={(e) => kwHandleInput((e.target as HTMLInputElement).value)}
|
||||||
|
class="searchInput"
|
||||||
|
placeholder="Search across sources…"
|
||||||
|
/>
|
||||||
|
{#if kw_pending || kw_anyLoading}
|
||||||
|
<svg width="13" height="13" viewBox="0 0 256 256" fill="currentColor" class="anim-spin" style="color:var(--text-faint);flex-shrink:0" aria-hidden="true">
|
||||||
|
<path d="M232,128a104,104,0,0,1-208,0c0-41,23.81-78.36,60.66-95.27a8,8,0,0,1,6.68,14.54C60.15,61.59,40,93.27,40,128a88,88,0,0,0,176,0c0-34.73-20.15-66.41-51.34-80.73a8,8,0,0,1,6.68-14.54C208.19,49.64,232,87,232,128Z"/>
|
||||||
|
</svg>
|
||||||
|
{:else if kw_localQuery}
|
||||||
|
<button class="clearBtn" title="Clear" onclick={() => { kwHandleInput(""); kw_inputEl?.focus(); }}>×</button>
|
||||||
|
{/if}
|
||||||
|
{#if hasMultipleLangs}
|
||||||
|
<button
|
||||||
|
class="advancedBtn"
|
||||||
|
class:advancedBtnActive={kw_showAdvanced}
|
||||||
|
title="Language & filter options"
|
||||||
|
onclick={() => (kw_showAdvanced = !kw_showAdvanced)}
|
||||||
|
>
|
||||||
|
<svg width="13" height="13" viewBox="0 0 256 256" fill="currentColor" aria-hidden="true">
|
||||||
|
<path d="M40,88H73a32,32,0,0,0,62,0h81a8,8,0,0,0,0-16H135a32,32,0,0,0-62,0H40a8,8,0,0,0,0,16Zm64-24A16,16,0,1,1,88,80,16,16,0,0,1,104,64ZM216,168H183a32,32,0,0,0-62,0H40a8,8,0,0,0,0,16h81a32,32,0,0,0,62,0h33a8,8,0,0,0,0-16Zm-64,24a16,16,0,1,1,16-16A16,16,0,0,1,152,192Z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if kw_showAdvanced && hasMultipleLangs}
|
||||||
|
<div class="advancedPanel">
|
||||||
|
<div class="advancedHeader">
|
||||||
|
<span class="advancedTitle">LANGUAGES</span>
|
||||||
|
<div class="advancedActions">
|
||||||
|
<button class="advancedLink" onclick={() => (kw_selectedLangs = new Set(availableLangs))}>All</button>
|
||||||
|
<button class="advancedLink" onclick={() => (kw_selectedLangs = new Set([preferredLang]))}>Reset</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="langGrid">
|
||||||
|
{#each availableLangs as lang (lang)}
|
||||||
|
<button class="langChip" class:langChipActive={kw_selectedLangs.has(lang)} onclick={() => kwToggleLang(lang)}>
|
||||||
|
{lang === preferredLang ? `${lang.toUpperCase()} ★` : lang.toUpperCase()}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<div class="advancedDivider"></div>
|
||||||
|
<div class="advancedFooter">
|
||||||
|
Searching <strong>{kw_visibleCount}</strong> source{kw_visibleCount !== 1 ? "s" : ""}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if !kw_localQuery.trim()}
|
||||||
|
{#if popularLoading && popularResults.length === 0}
|
||||||
|
<div class="searchGrid">
|
||||||
|
{#each Array(24) as _, i (i)}<div class="skCard"><div class="skeleton skCover"></div></div>{/each}
|
||||||
|
</div>
|
||||||
|
{:else if popularResults.length > 0}
|
||||||
|
<div class="searchHeader">
|
||||||
|
<span class="searchLabel">Popular right now</span>
|
||||||
|
</div>
|
||||||
|
<div class="searchGrid">
|
||||||
|
{#each popularResults as m (m.id)}
|
||||||
|
<button class="srchCard" onclick={() => onPreview(m)}>
|
||||||
|
<div class="srchCoverWrap">
|
||||||
|
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="cover" priority={m._priority} id={m.id} />
|
||||||
|
<div class="srchGradient"></div>
|
||||||
|
{#if m.inLibrary}<span class="inLibBadge">Saved</span>{/if}
|
||||||
|
<div class="srchFooter">
|
||||||
|
<p class="srchTitle">{m.title}</p>
|
||||||
|
{#if m.source?.displayName}<p class="srchSource">{m.source.displayName}</p>{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
{#if popularLoading}
|
||||||
|
{#each Array(12) as _, i (i)}<div class="skCard"><div class="skeleton skCover"></div></div>{/each}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="empty">
|
||||||
|
<svg width="36" height="36" viewBox="0 0 256 256" fill="currentColor" class="emptyIcon" aria-hidden="true">
|
||||||
|
<path d="M229.66,218.34l-50.07-50.07a88,88,0,1,0-11.31,11.31l50.06,50.07a8,8,0,0,0,11.32-11.31ZM40,112a72,72,0,1,1,72,72A72.08,72.08,0,0,1,40,112Z"/>
|
||||||
|
</svg>
|
||||||
|
<p class="emptyText">Search across sources</p>
|
||||||
|
<p class="emptyHint">
|
||||||
|
{#if hasMultipleLangs}
|
||||||
|
{kw_visibleCount} source{kw_visibleCount !== 1 ? "s" : ""} · {kw_selectedLangs.size} language{kw_selectedLangs.size !== 1 ? "s" : ""}
|
||||||
|
{:else}
|
||||||
|
{kw_visibleCount} source{kw_visibleCount !== 1 ? "s" : ""}
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{:else if kw_pending}
|
||||||
|
<div class="searchGrid">
|
||||||
|
{#each Array(12) as _, i (i)}<div class="skCard"><div class="skeleton skCover"></div></div>{/each}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
{#if kw_flatResults.length > 0}
|
||||||
|
<div class="searchHeader">
|
||||||
|
<span class="searchLabel">{kw_flatResults.length} result{kw_flatResults.length !== 1 ? "s" : ""} for "{kw_localQuery.trim()}"</span>
|
||||||
|
</div>
|
||||||
|
<div class="searchGrid">
|
||||||
|
{#each kw_flatResults as m (m.id)}
|
||||||
|
<button class="srchCard" onclick={() => onPreview(m)}>
|
||||||
|
<div class="srchCoverWrap">
|
||||||
|
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="cover" priority={m._priority} id={m.id} />
|
||||||
|
<div class="srchGradient"></div>
|
||||||
|
{#if m.inLibrary}<span class="inLibBadge">Saved</span>{/if}
|
||||||
|
<div class="srchFooter">
|
||||||
|
<p class="srchTitle">{m.title}</p>
|
||||||
|
{#if (m as any)._sourceName}<p class="srchSource">{(m as any)._sourceName}</p>{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
{#if kw_anyLoading}
|
||||||
|
{#each Array(6) as _, i (i)}<div class="skCard"><div class="skeleton skCover"></div></div>{/each}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else if kw_anyLoading}
|
||||||
|
<div class="searchGrid">
|
||||||
|
{#each Array(12) as _, i (i)}<div class="skCard"><div class="skeleton skCover"></div></div>{/each}
|
||||||
|
</div>
|
||||||
|
{:else if kw_allDone && !kw_hasResults}
|
||||||
|
<div class="empty">
|
||||||
|
<svg width="36" height="36" viewBox="0 0 256 256" fill="currentColor" class="emptyIcon" aria-hidden="true">
|
||||||
|
<path d="M229.66,218.34l-50.07-50.07a88,88,0,1,0-11.31,11.31l50.06,50.07a8,8,0,0,0,11.32-11.31ZM40,112a72,72,0,1,1,72,72A72.08,72.08,0,0,1,40,112Z"/>
|
||||||
|
</svg>
|
||||||
|
<p class="emptyText">No results for "{kw_localQuery.trim()}"</p>
|
||||||
|
<p class="emptyHint">Try a different spelling or fewer words</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.keywordBar { padding: var(--sp-3) var(--sp-4) var(--sp-2); flex-shrink: 0; display: flex; flex-direction: column; gap: var(--sp-2); }
|
||||||
|
.searchBar { display: flex; align-items: center; gap: var(--sp-2); background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-lg); padding: var(--sp-2) var(--sp-3); transition: border-color var(--t-base); }
|
||||||
|
.searchBar:focus-within { border-color: var(--border-strong); }
|
||||||
|
.searchIcon { color: var(--text-faint); flex-shrink: 0; }
|
||||||
|
.searchInput { flex: 1; background: none; border: none; outline: none; font-size: var(--text-sm); color: var(--text-primary); min-width: 0; }
|
||||||
|
.searchInput::placeholder { color: var(--text-faint); }
|
||||||
|
.clearBtn { color: var(--text-faint); font-size: 16px; line-height: 1; background: none; border: none; cursor: pointer; padding: 2px; transition: color var(--t-base); }
|
||||||
|
.clearBtn:hover { color: var(--text-muted); }
|
||||||
|
.advancedBtn { display: flex; align-items: center; padding: 4px; border-radius: var(--radius-sm); border: 1px solid transparent; background: none; color: var(--text-faint); cursor: pointer; transition: color var(--t-base), background var(--t-base), border-color var(--t-base); }
|
||||||
|
.advancedBtn:hover { color: var(--text-muted); background: var(--bg-overlay); }
|
||||||
|
.advancedBtnActive { color: var(--accent-fg); background: var(--accent-muted); border-color: var(--accent-dim); }
|
||||||
|
.advancedPanel { background: var(--bg-surface); border: 1px solid var(--border-dim); border-radius: var(--radius-lg); padding: var(--sp-3); display: flex; flex-direction: column; gap: var(--sp-2); }
|
||||||
|
.advancedHeader { display: flex; align-items: center; justify-content: space-between; }
|
||||||
|
.advancedTitle { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); letter-spacing: var(--tracking-wide); }
|
||||||
|
.advancedActions { display: flex; gap: var(--sp-2); }
|
||||||
|
.advancedLink { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--accent-fg); background: none; border: none; cursor: pointer; padding: 0; transition: opacity var(--t-base); }
|
||||||
|
.advancedLink:hover { opacity: 0.75; }
|
||||||
|
.langGrid { display: flex; flex-wrap: wrap; gap: var(--sp-1); }
|
||||||
|
.langChip { padding: 3px 8px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: none; font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--text-faint); cursor: pointer; transition: color var(--t-base), background var(--t-base), border-color var(--t-base); }
|
||||||
|
.langChip:hover { color: var(--text-muted); background: var(--bg-raised); }
|
||||||
|
.langChipActive { color: var(--accent-fg); background: var(--accent-muted); border-color: var(--accent-dim); }
|
||||||
|
.advancedDivider { height: 1px; background: var(--border-dim); }
|
||||||
|
.advancedFooter { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); }
|
||||||
|
.searchHeader { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-3) var(--sp-4) var(--sp-1); flex-shrink: 0; }
|
||||||
|
.searchLabel { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; }
|
||||||
|
.searchGrid { display: grid; grid-template-columns: repeat(auto-fill, minmax(clamp(90px, 11vw, 130px), 1fr)); gap: var(--sp-2); padding: var(--sp-2) var(--sp-4) var(--sp-6); overflow-y: auto; flex: 1; align-content: start; will-change: scroll-position; }
|
||||||
|
.srchCard { background: none; border: none; padding: 0; cursor: pointer; text-align: left; }
|
||||||
|
.srchCard:hover .srchCoverWrap { filter: brightness(1.08) saturate(1.05); }
|
||||||
|
.srchCoverWrap { position: relative; aspect-ratio: 2/3; overflow: hidden; border-radius: var(--radius-md); background: var(--bg-raised); border: 1px solid var(--border-dim); transform: translateZ(0); transition: filter var(--t-base); }
|
||||||
|
.srchGradient { position: absolute; inset: 0; z-index: 1; background: linear-gradient(to top, rgba(0,0,0,0.82) 0%, rgba(0,0,0,0.15) 50%, transparent 72%); pointer-events: none; }
|
||||||
|
.srchFooter { position: absolute; bottom: 0; left: 0; right: 0; z-index: 2; padding: var(--sp-2); pointer-events: none; }
|
||||||
|
.srchTitle { font-size: var(--text-xs); font-weight: var(--weight-medium); color: rgba(255,255,255,0.92); line-height: var(--leading-snug); display: -webkit-box; -webkit-line-clamp: 2; line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; text-shadow: 0 1px 4px rgba(0,0,0,0.7); }
|
||||||
|
.srchSource { font-family: var(--font-ui); font-size: 9px; color: rgba(255,255,255,0.45); letter-spacing: var(--tracking-wide); margin-top: 1px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.inLibBadge { position: absolute; top: var(--sp-2); left: var(--sp-2); z-index: 2; font-family: var(--font-ui); font-size: 9px; letter-spacing: var(--tracking-wide); background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim); border-radius: var(--radius-sm); padding: 1px 5px; }
|
||||||
|
.skCard { display: flex; flex-direction: column; gap: var(--sp-2); flex-shrink: 0; width: 100%; }
|
||||||
|
@keyframes shimmer { from { background-position: -200% 0 } to { background-position: 200% 0 } }
|
||||||
|
.skeleton { border-radius: var(--radius-sm); background: linear-gradient(90deg, var(--bg-raised) 25%, var(--bg-overlay, color-mix(in srgb, var(--bg-raised) 80%, var(--text-primary) 6%)) 50%, var(--bg-raised) 75%); background-size: 200% 100%; animation: shimmer 1.6s ease-in-out infinite; }
|
||||||
|
.skCover { aspect-ratio: 2 / 3; width: 100%; border-radius: var(--radius-md); }
|
||||||
|
.empty { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: var(--sp-2); padding: var(--sp-8); }
|
||||||
|
.emptyIcon { color: var(--text-faint); opacity: 0.5; }
|
||||||
|
.emptyText { font-size: var(--text-sm); color: var(--text-muted); font-weight: var(--weight-medium); margin: 0; }
|
||||||
|
.emptyHint { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); margin: 0; }
|
||||||
|
@keyframes anim-spin { from { transform: rotate(0deg) } to { transform: rotate(360deg) } }
|
||||||
|
.anim-spin { animation: anim-spin 0.8s linear infinite; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,304 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onDestroy, untrack } from "svelte";
|
||||||
|
import { page } from "$app/stores";
|
||||||
|
import { goto } from "$app/navigation";
|
||||||
|
import { getAdapter } from "$lib/request-manager";
|
||||||
|
import { settingsState } from "$lib/state/settings.svelte";
|
||||||
|
import { setPreviewManga } from "$lib/state/series.svelte";
|
||||||
|
import { toCachedManga, shouldHideNsfw, runConcurrent, type CachedManga } from "$lib/components/browse/lib/searchFilter";
|
||||||
|
import type { Manga, Source } from "$lib/types";
|
||||||
|
|
||||||
|
import KeywordTab from "$lib/components/browse/KeywordTab.svelte";
|
||||||
|
import TagTab from "$lib/components/browse/TagTab.svelte";
|
||||||
|
import SourceTab from "$lib/components/browse/SourceTab.svelte";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
initialTab?: "keyword" | "tag" | "source";
|
||||||
|
preselectedSourceId?: string;
|
||||||
|
}
|
||||||
|
let { initialTab, preselectedSourceId }: Props = $props();
|
||||||
|
|
||||||
|
const anims = $derived(settingsState.settings.qolAnimations ?? true);
|
||||||
|
|
||||||
|
type SearchTab = "keyword" | "tag" | "source";
|
||||||
|
|
||||||
|
const urlTab = $derived(($page.url.searchParams.get("tab") as SearchTab | null) ?? initialTab ?? "keyword");
|
||||||
|
const urlQuery = $derived($page.url.searchParams.get("q") ?? "");
|
||||||
|
|
||||||
|
function setTab(next: SearchTab) {
|
||||||
|
const u = new URL($page.url);
|
||||||
|
u.searchParams.set("tab", next);
|
||||||
|
goto(u.toString(), { replaceState: true, noScroll: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function setQuery(next: string) {
|
||||||
|
const u = new URL($page.url);
|
||||||
|
if (next) u.searchParams.set("q", next);
|
||||||
|
else u.searchParams.delete("q");
|
||||||
|
goto(u.toString(), { replaceState: true, noScroll: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
let pendingPrefill = $state("");
|
||||||
|
|
||||||
|
let tabsEl = $state<HTMLDivElement | undefined>(undefined);
|
||||||
|
let tabIndicator = $state({ left: 0, width: 0 });
|
||||||
|
|
||||||
|
function updateIndicator() {
|
||||||
|
if (!tabsEl) return;
|
||||||
|
const active = tabsEl.querySelector<HTMLElement>(".tab.tabActive");
|
||||||
|
if (!active) return;
|
||||||
|
tabIndicator = { left: active.offsetLeft, width: active.offsetWidth };
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => { urlTab; if (anims) requestAnimationFrame(() => requestAnimationFrame(updateIndicator)); });
|
||||||
|
|
||||||
|
const SEARCH_PAGES = 3;
|
||||||
|
const SEARCH_LIMIT = 200;
|
||||||
|
const SEARCH_BATCH = 20;
|
||||||
|
const POPULAR_CACHE_PAGES = 3;
|
||||||
|
|
||||||
|
let allSources: Source[] = $state([]);
|
||||||
|
let localSource: Source | null = $state(null);
|
||||||
|
let loadingSources = $state(false);
|
||||||
|
|
||||||
|
const preferredLang = $derived(settingsState.settings.preferredExtensionLang ?? "en");
|
||||||
|
const availableLangs = $derived(Array.from(new Set<string>(allSources.map((s) => s.lang))).sort());
|
||||||
|
const hasMultipleLangs = $derived(availableLangs.length > 1);
|
||||||
|
|
||||||
|
let sourcesAbort: AbortController | null = null;
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
sourcesAbort?.abort();
|
||||||
|
const ctrl = new AbortController();
|
||||||
|
sourcesAbort = ctrl;
|
||||||
|
loadingSources = true;
|
||||||
|
getAdapter().getSources()
|
||||||
|
.then((nodes: Source[]) => {
|
||||||
|
if (ctrl.signal.aborted) return;
|
||||||
|
localSource = nodes.find((s: Source) => s.id === "0") ?? null;
|
||||||
|
allSources = nodes.filter((s: Source) => s.id !== "0");
|
||||||
|
startSourceCacheBuild();
|
||||||
|
popularStart(allSources);
|
||||||
|
})
|
||||||
|
.catch(console.error)
|
||||||
|
.finally(() => { if (!ctrl.signal.aborted) loadingSources = false; });
|
||||||
|
return () => { ctrl.abort(); };
|
||||||
|
});
|
||||||
|
|
||||||
|
let popular_raw: Manga[] = $state([]);
|
||||||
|
let popular_loading = $state(false);
|
||||||
|
let popular_abortCtrl: AbortController | null = null;
|
||||||
|
let popular_sourcePool: Source[] = [];
|
||||||
|
let popular_sourceCursor = 0;
|
||||||
|
let popular_seenIds = new Set<number>();
|
||||||
|
let popular_seenTitles = new Set<string>();
|
||||||
|
|
||||||
|
const popular_results = $derived(popular_raw.map((m, i) => ({ ...m, _priority: Math.max(0, 50 - i) })));
|
||||||
|
|
||||||
|
function popular_push(incoming: Manga[]) {
|
||||||
|
const toAdd: Manga[] = [];
|
||||||
|
for (const m of incoming) {
|
||||||
|
if (shouldHideNsfw(m as any, settingsState.settings)) continue;
|
||||||
|
if (popular_seenIds.has(m.id)) continue;
|
||||||
|
const norm = m.title.toLowerCase().replace(/[^a-z0-9\s]/g, " ").trim();
|
||||||
|
if (popular_seenTitles.has(norm)) continue;
|
||||||
|
popular_seenIds.add(m.id);
|
||||||
|
popular_seenTitles.add(norm);
|
||||||
|
toAdd.push(m);
|
||||||
|
}
|
||||||
|
if (!toAdd.length) return;
|
||||||
|
popular_raw = [...popular_raw, ...toAdd].slice(0, SEARCH_LIMIT);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function popular_fanOut(signal: AbortSignal) {
|
||||||
|
const batch = popular_sourcePool.slice(popular_sourceCursor, popular_sourceCursor + SEARCH_BATCH);
|
||||||
|
if (!batch.length) return;
|
||||||
|
await runConcurrent(batch, async (src) => {
|
||||||
|
for (let p = 1; p <= SEARCH_PAGES; p++) {
|
||||||
|
if (signal.aborted) return;
|
||||||
|
try {
|
||||||
|
const result = await getAdapter().browseSource(src.id, p);
|
||||||
|
if (signal.aborted) return;
|
||||||
|
popular_push(result.items as Manga[]);
|
||||||
|
if (!result.hasNextPage) break;
|
||||||
|
} catch { break; }
|
||||||
|
}
|
||||||
|
}, signal);
|
||||||
|
popular_sourceCursor += batch.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function popularStart(sources: Source[]) {
|
||||||
|
if (popular_raw.length > 0) return;
|
||||||
|
popular_abortCtrl?.abort();
|
||||||
|
const ctrl = new AbortController();
|
||||||
|
popular_abortCtrl = ctrl;
|
||||||
|
popular_seenIds.clear();
|
||||||
|
popular_seenTitles.clear();
|
||||||
|
popular_raw = [];
|
||||||
|
popular_sourcePool = sources;
|
||||||
|
popular_sourceCursor = 0;
|
||||||
|
popular_loading = true;
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
while (!ctrl.signal.aborted && popular_sourceCursor < popular_sourcePool.length) {
|
||||||
|
await popular_fanOut(ctrl.signal);
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
if (!ctrl.signal.aborted) popular_loading = false;
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
|
export const sourceCache = new Map<number, CachedManga>();
|
||||||
|
let sourceCacheReady = $state(false);
|
||||||
|
let sourceCacheLoading = $state(false);
|
||||||
|
let sourceCacheEnriching = $state(false);
|
||||||
|
let sourceCacheAbort: AbortController | null = null;
|
||||||
|
|
||||||
|
async function buildSourceCache(sources: Source[], signal: AbortSignal) {
|
||||||
|
const tasks: { src: Source; page: number }[] = [];
|
||||||
|
for (const src of sources) {
|
||||||
|
for (let p = 1; p <= POPULAR_CACHE_PAGES; p++) tasks.push({ src, page: p });
|
||||||
|
}
|
||||||
|
await runConcurrent(tasks, async ({ src, page: p }) => {
|
||||||
|
if (signal.aborted) return;
|
||||||
|
try {
|
||||||
|
const result = await getAdapter().browseSource(src.id, p);
|
||||||
|
if (signal.aborted) return;
|
||||||
|
for (const m of result.items as Manga[]) {
|
||||||
|
if (!sourceCache.has(m.id)) sourceCache.set(m.id, toCachedManga(m as any, src.id));
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e?.name === "AbortError") return;
|
||||||
|
}
|
||||||
|
}, signal);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function enrichGenres(signal: AbortSignal) {
|
||||||
|
const unenriched = [...sourceCache.values()].filter((m) => !m.genreEnriched);
|
||||||
|
if (!unenriched.length) return;
|
||||||
|
sourceCacheEnriching = true;
|
||||||
|
await runConcurrent(unenriched, async (entry) => {
|
||||||
|
if (signal.aborted) return;
|
||||||
|
try {
|
||||||
|
const m = await getAdapter().getManga(String(entry.id));
|
||||||
|
if (signal.aborted) return;
|
||||||
|
const updated = sourceCache.get(entry.id);
|
||||||
|
if (updated) {
|
||||||
|
updated.genre = (m as any).genre ?? [];
|
||||||
|
updated.status = (m as any).status ?? updated.status;
|
||||||
|
updated.lowerGenres = updated.genre.map((g: string) => g.toLowerCase());
|
||||||
|
updated.genreEnriched = true;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
const updated = sourceCache.get(entry.id);
|
||||||
|
if (updated) updated.genreEnriched = true;
|
||||||
|
}
|
||||||
|
}, signal);
|
||||||
|
if (!signal.aborted) sourceCacheEnriching = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function startSourceCacheBuild() {
|
||||||
|
if (sourceCacheLoading || sourceCacheReady) return;
|
||||||
|
sourceCacheAbort?.abort();
|
||||||
|
const ctrl = new AbortController();
|
||||||
|
sourceCacheAbort = ctrl;
|
||||||
|
sourceCacheLoading = true;
|
||||||
|
sourceCache.clear();
|
||||||
|
buildSourceCache(allSources, ctrl.signal)
|
||||||
|
.then(() => {
|
||||||
|
if (ctrl.signal.aborted) return;
|
||||||
|
sourceCacheReady = true;
|
||||||
|
sourceCacheLoading = false;
|
||||||
|
enrichGenres(ctrl.signal);
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
if (e?.name !== "AbortError") console.error(e);
|
||||||
|
sourceCacheLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
sourcesAbort?.abort();
|
||||||
|
popular_abortCtrl?.abort();
|
||||||
|
sourceCacheAbort?.abort();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="root">
|
||||||
|
<div class="header">
|
||||||
|
<span class="heading">Search</span>
|
||||||
|
|
||||||
|
<div class="tabs" class:tabs-anims={anims} bind:this={tabsEl}>
|
||||||
|
{#if anims && tabIndicator.width > 0}
|
||||||
|
<div class="tab-slide-indicator" style="left:{tabIndicator.left}px;width:{tabIndicator.width}px" aria-hidden="true"></div>
|
||||||
|
{/if}
|
||||||
|
<button class="tab" class:tabActive={urlTab === "keyword"} onclick={() => setTab("keyword")}>
|
||||||
|
<svg width="11" height="11" viewBox="0 0 256 256" fill="currentColor" aria-hidden="true">
|
||||||
|
<path d="M229.66,218.34l-50.07-50.07a88,88,0,1,0-11.31,11.31l50.06,50.07a8,8,0,0,0,11.32-11.31ZM40,112a72,72,0,1,1,72,72A72.08,72.08,0,0,1,40,112Z"/>
|
||||||
|
</svg>
|
||||||
|
Keyword
|
||||||
|
</button>
|
||||||
|
<button class="tab" class:tabActive={urlTab === "tag"} onclick={() => setTab("tag")}>
|
||||||
|
<svg width="11" height="11" viewBox="0 0 256 256" fill="currentColor" aria-hidden="true">
|
||||||
|
<path d="M224,104H200l8-48a8,8,0,0,0-15.79-2.67L183.79,104H136l8-48a8,8,0,0,0-15.79-2.67L119.79,104H72a8,8,0,0,0,0,16h45.33L105.6,200H56a8,8,0,0,0,0,16H103l-8,48a8,8,0,0,0,15.79,2.67L119.21,216H168l-8,48a8,8,0,0,0,15.79,2.67L184.21,216H232a8,8,0,0,0,0-16H186.67l11.73-80H224a8,8,0,0,0,0-16Zm-69.33,96H101.6L113.33,120h53.07Z"/>
|
||||||
|
</svg>
|
||||||
|
Tags
|
||||||
|
</button>
|
||||||
|
<button class="tab" class:tabActive={urlTab === "source"} onclick={() => setTab("source")}>
|
||||||
|
<svg width="11" height="11" viewBox="0 0 256 256" fill="currentColor" aria-hidden="true">
|
||||||
|
<path d="M224,128a8,8,0,0,1-8,8H40a8,8,0,0,1,0-16H216A8,8,0,0,1,224,128ZM40,72H216a8,8,0,0,0,0-16H40a8,8,0,0,0,0,16ZM216,184H40a8,8,0,0,0,0,16H216a8,8,0,0,0,0-16Z"/>
|
||||||
|
</svg>
|
||||||
|
Sources
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if urlTab === "keyword"}
|
||||||
|
<KeywordTab
|
||||||
|
{allSources}
|
||||||
|
{availableLangs}
|
||||||
|
{hasMultipleLangs}
|
||||||
|
{loadingSources}
|
||||||
|
{pendingPrefill}
|
||||||
|
popularResults={popular_results}
|
||||||
|
popularLoading={popular_loading}
|
||||||
|
{sourceCache}
|
||||||
|
query={urlQuery}
|
||||||
|
onQueryChange={setQuery}
|
||||||
|
onPrefillConsumed={() => { pendingPrefill = ""; }}
|
||||||
|
onPreview={(m) => setPreviewManga(m)}
|
||||||
|
/>
|
||||||
|
{:else if urlTab === "tag"}
|
||||||
|
<TagTab
|
||||||
|
{allSources}
|
||||||
|
{sourceCache}
|
||||||
|
{sourceCacheReady}
|
||||||
|
{sourceCacheLoading}
|
||||||
|
{sourceCacheEnriching}
|
||||||
|
onPreview={(m) => setPreviewManga(m)}
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<SourceTab
|
||||||
|
{allSources}
|
||||||
|
{availableLangs}
|
||||||
|
{loadingSources}
|
||||||
|
{localSource}
|
||||||
|
{preselectedSourceId}
|
||||||
|
onPreview={(m) => setPreviewManga(m)}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.root { display: flex; flex-direction: column; height: 100%; overflow: hidden; animation: fadeIn 0.14s ease both; }
|
||||||
|
.header { position: relative; z-index: 100; display: flex; align-items: center; gap: var(--sp-4); padding: var(--sp-4) var(--sp-6); flex-shrink: 0; border-bottom: 1px solid var(--border-dim); }
|
||||||
|
.heading { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; flex-shrink: 0; }
|
||||||
|
.tabs { margin-left: auto; display: flex; gap: 2px; background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 2px; position: relative; }
|
||||||
|
.tab-slide-indicator { position: absolute; top: 2px; bottom: 2px; border-radius: var(--radius-sm); background: var(--accent-muted); border: 1px solid var(--accent-dim); pointer-events: none; z-index: 0; transition: left 0.22s cubic-bezier(0.16,1,0.3,1), width 0.22s cubic-bezier(0.16,1,0.3,1); }
|
||||||
|
.tab { position: relative; z-index: 1; display: flex; align-items: center; gap: 5px; font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); text-transform: uppercase; padding: 4px 10px; border-radius: var(--radius-sm); color: var(--text-faint); white-space: nowrap; transition: background var(--t-base), color var(--t-base); cursor: pointer; border: 1px solid transparent; }
|
||||||
|
.tab:hover { color: var(--text-muted); }
|
||||||
|
.tabActive { color: var(--accent-fg); background: var(--accent-muted); border-color: var(--accent-dim); }
|
||||||
|
.tabs-anims .tabActive { background: transparent; border-color: transparent; }
|
||||||
|
.tabActive:hover { color: var(--accent-fg); }
|
||||||
|
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,376 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onDestroy } from "svelte";
|
||||||
|
import { getAdapter } from "$lib/request-manager";
|
||||||
|
import { settingsState, updateSettings } from "$lib/state/settings.svelte";
|
||||||
|
import { shouldHideNsfw, shouldHideSource } from "$lib/core/util";
|
||||||
|
import Thumbnail from "$lib/components/shared/manga/Thumbnail.svelte";
|
||||||
|
import ContextMenu from "$lib/components/shared/ui/ContextMenu.svelte";
|
||||||
|
import { PushPin, PushPinSlash, ArrowRight } from "phosphor-svelte";
|
||||||
|
import type { Manga, Source } from "$lib/types";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
allSources: Source[];
|
||||||
|
availableLangs: string[];
|
||||||
|
loadingSources: boolean;
|
||||||
|
localSource: Source | null;
|
||||||
|
onPreview: (m: Manga) => void;
|
||||||
|
preselectedSourceId?: string;
|
||||||
|
}
|
||||||
|
let { allSources, availableLangs, loadingSources, localSource, onPreview, preselectedSourceId }: Props = $props();
|
||||||
|
|
||||||
|
const preferredLang = $derived(settingsState.settings.preferredExtensionLang ?? "en");
|
||||||
|
|
||||||
|
let src_selectedLang = $state(settingsState.settings.preferredExtensionLang || "all");
|
||||||
|
let src_activeSource: Source | null = $state(null);
|
||||||
|
let src_browseResults: Manga[] = $state([]);
|
||||||
|
let src_loadingBrowse = $state(false);
|
||||||
|
let src_browseQuery = $state("");
|
||||||
|
let src_submitted = $state("");
|
||||||
|
let src_hasNextPage = $state(false);
|
||||||
|
let src_currentPage = $state(1);
|
||||||
|
let src_abortCtrl: AbortController | null = null;
|
||||||
|
|
||||||
|
let ctx_x = $state(0);
|
||||||
|
let ctx_y = $state(0);
|
||||||
|
let ctx_source: Source | null = $state(null);
|
||||||
|
|
||||||
|
const pinnedIds = $derived(settingsState.settings.pinnedSourceIds ?? []);
|
||||||
|
const pinnedSources = $derived(
|
||||||
|
pinnedIds
|
||||||
|
.map((id: string) => allSources.find((s) => s.id === id))
|
||||||
|
.filter((s: Source | undefined): s is Source => !!s)
|
||||||
|
);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!preselectedSourceId || !allSources.length || src_activeSource) return;
|
||||||
|
const target = allSources.find((s) => s.id === preselectedSourceId);
|
||||||
|
if (target) srcSelectSource(target);
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!allSources.length) return;
|
||||||
|
const langs = new Set(allSources.map((s) => s.lang));
|
||||||
|
if (src_selectedLang !== "all" && !langs.has(src_selectedLang)) {
|
||||||
|
src_selectedLang = langs.has(preferredLang) ? preferredLang : "all";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const src_visibleSources = $derived.by(() => {
|
||||||
|
const hide = (s: Source) => shouldHideSource(s, settingsState.settings);
|
||||||
|
if (src_selectedLang !== "all") {
|
||||||
|
return allSources.filter((s) => s.lang === src_selectedLang && !hide(s));
|
||||||
|
}
|
||||||
|
const map = new Map<string, Source>();
|
||||||
|
for (const s of allSources) {
|
||||||
|
if (hide(s)) continue;
|
||||||
|
const existing = map.get(s.name);
|
||||||
|
if (!existing) { map.set(s.name, s); continue; }
|
||||||
|
const existingPref = existing.lang === preferredLang;
|
||||||
|
const newPref = s.lang === preferredLang;
|
||||||
|
if (newPref && !existingPref) map.set(s.name, s);
|
||||||
|
else if (!existingPref && !newPref && s.lang < existing.lang) map.set(s.name, s);
|
||||||
|
}
|
||||||
|
return Array.from(map.values());
|
||||||
|
});
|
||||||
|
|
||||||
|
async function srcFetchBrowse(src: Source, type: "POPULAR" | "SEARCH", q?: string, page = 1) {
|
||||||
|
src_abortCtrl?.abort();
|
||||||
|
const ctrl = new AbortController();
|
||||||
|
src_abortCtrl = ctrl;
|
||||||
|
if (page === 1) { src_loadingBrowse = true; src_browseResults = []; }
|
||||||
|
try {
|
||||||
|
let result: { items: Manga[]; hasNextPage: boolean };
|
||||||
|
if (type === "SEARCH" && q) {
|
||||||
|
result = await getAdapter().searchSource(src.id, q, page, ctrl.signal);
|
||||||
|
} else {
|
||||||
|
result = await getAdapter().browseSource(src.id, page);
|
||||||
|
}
|
||||||
|
if (ctrl.signal.aborted) return;
|
||||||
|
const incoming = result.items.filter((m) => !shouldHideNsfw(m as any, settingsState.settings));
|
||||||
|
src_browseResults = page === 1 ? incoming : [...src_browseResults, ...incoming];
|
||||||
|
src_hasNextPage = result.hasNextPage;
|
||||||
|
src_currentPage = page;
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e?.name !== "AbortError") console.error(e);
|
||||||
|
} finally {
|
||||||
|
if (!ctrl.signal.aborted) src_loadingBrowse = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function srcSelectSource(src: Source) {
|
||||||
|
src_activeSource = src; src_browseQuery = ""; src_submitted = "";
|
||||||
|
srcFetchBrowse(src, "POPULAR");
|
||||||
|
}
|
||||||
|
|
||||||
|
function srcHandleSearch() {
|
||||||
|
if (!src_activeSource || !src_browseQuery.trim()) return;
|
||||||
|
src_submitted = src_browseQuery.trim();
|
||||||
|
srcFetchBrowse(src_activeSource, "SEARCH", src_browseQuery.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
function srcClearSearch() {
|
||||||
|
src_browseQuery = ""; src_submitted = "";
|
||||||
|
if (src_activeSource) srcFetchBrowse(src_activeSource, "POPULAR");
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCtx(e: MouseEvent, src: Source) {
|
||||||
|
e.preventDefault();
|
||||||
|
ctx_x = e.clientX; ctx_y = e.clientY; ctx_source = src;
|
||||||
|
}
|
||||||
|
function closeCtx() { ctx_source = null; }
|
||||||
|
|
||||||
|
function togglePinnedSource(id: string) {
|
||||||
|
const current = settingsState.settings.pinnedSourceIds ?? [];
|
||||||
|
const next = current.includes(id) ? current.filter((x: string) => x !== id) : [...current, id];
|
||||||
|
updateSettings({ pinnedSourceIds: next });
|
||||||
|
}
|
||||||
|
|
||||||
|
onDestroy(() => { src_abortCtrl?.abort(); });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="splitRoot">
|
||||||
|
<div class="splitSidebar">
|
||||||
|
<div class="srcLangRow">
|
||||||
|
<span class="langPocketLabel">Language</span>
|
||||||
|
<select class="langSelect" bind:value={src_selectedLang}>
|
||||||
|
<option value="all">All</option>
|
||||||
|
{#each availableLangs as lang (lang)}
|
||||||
|
<option value={lang}>{lang.toUpperCase()}{lang === preferredLang ? " ★" : ""}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if loadingSources}
|
||||||
|
<div class="splitLoading">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 256 256" fill="currentColor" class="anim-spin" style="color:var(--text-faint)" aria-hidden="true">
|
||||||
|
<path d="M232,128a104,104,0,0,1-208,0c0-41,23.81-78.36,60.66-95.27a8,8,0,0,1,6.68,14.54C60.15,61.59,40,93.27,40,128a88,88,0,0,0,176,0c0-34.73-20.15-66.41-51.34-80.73a8,8,0,0,1,6.68-14.54C208.19,49.64,232,87,232,128Z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="splitList">
|
||||||
|
{#if localSource}
|
||||||
|
<button
|
||||||
|
class="splitItem splitItemSource"
|
||||||
|
class:splitItemActive={src_activeSource?.id === localSource.id}
|
||||||
|
onclick={() => srcSelectSource(localSource)}
|
||||||
|
oncontextmenu={(e) => openCtx(e, localSource)}
|
||||||
|
>
|
||||||
|
<div class="localSourceIcon">
|
||||||
|
<svg width="12" height="12" viewBox="0 0 256 256" fill="currentColor" aria-hidden="true">
|
||||||
|
<path d="M128,20A108,108,0,1,0,236,128,108.12,108.12,0,0,0,128,20Zm0,192a84,84,0,1,1,84-84A84.09,84.09,0,0,1,128,212Zm44-84a44,44,0,1,1-44-44A44.05,44.05,0,0,1,172,128Z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<span class="splitItemLabel">Local Source</span>
|
||||||
|
</button>
|
||||||
|
<div class="localDivider"></div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if pinnedSources.length > 0}
|
||||||
|
<p class="sectionLabel">Pinned</p>
|
||||||
|
{#each pinnedSources as src (src.id)}
|
||||||
|
<button
|
||||||
|
class="splitItem splitItemSource"
|
||||||
|
class:splitItemActive={src_activeSource?.id === src.id}
|
||||||
|
onclick={() => srcSelectSource(src)}
|
||||||
|
oncontextmenu={(e) => openCtx(e, src)}
|
||||||
|
>
|
||||||
|
<Thumbnail src={src.iconUrl} alt="" class="splitSourceIcon" onerror={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
|
||||||
|
<span class="splitItemLabel">{src.name}</span>
|
||||||
|
<span class="pinIndicator" title="Pinned">
|
||||||
|
<PushPin size={9} weight="fill" />
|
||||||
|
</span>
|
||||||
|
{#if src.isNsfw}<span class="nsfwBadge">18+</span>{/if}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
<div class="localDivider"></div>
|
||||||
|
<p class="sectionLabel">All Sources</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#each src_visibleSources as src (src.id)}
|
||||||
|
<button
|
||||||
|
class="splitItem splitItemSource"
|
||||||
|
class:splitItemActive={src_activeSource?.id === src.id}
|
||||||
|
onclick={() => srcSelectSource(src)}
|
||||||
|
oncontextmenu={(e) => openCtx(e, src)}
|
||||||
|
>
|
||||||
|
<Thumbnail src={src.iconUrl} alt="" class="splitSourceIcon" onerror={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
|
||||||
|
<span class="splitItemLabel">{src.name}</span>
|
||||||
|
{#if src_selectedLang === "all"}
|
||||||
|
<span class="sourceLang">{src.lang.toUpperCase()}</span>
|
||||||
|
{/if}
|
||||||
|
{#if src.isNsfw}<span class="nsfwBadge">18+</span>{/if}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
{#if src_visibleSources.length === 0}
|
||||||
|
<p class="splitEmpty">No sources for this language</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="splitContent">
|
||||||
|
{#if !src_activeSource}
|
||||||
|
<div class="empty">
|
||||||
|
<svg width="32" height="32" viewBox="0 0 256 256" fill="currentColor" class="emptyIcon" aria-hidden="true">
|
||||||
|
<path d="M224,128a8,8,0,0,1-8,8H40a8,8,0,0,1,0-16H216A8,8,0,0,1,224,128ZM40,72H216a8,8,0,0,0,0-16H40a8,8,0,0,0,0,16ZM216,184H40a8,8,0,0,0,0,16H216a8,8,0,0,0,0-16Z"/>
|
||||||
|
</svg>
|
||||||
|
<p class="emptyText">Browse a source</p>
|
||||||
|
<p class="emptyHint">Select a source to see its popular titles, or search within it.</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="splitContentHeader">
|
||||||
|
<div class="splitSourceTitle">
|
||||||
|
<Thumbnail src={src_activeSource.iconUrl} alt="" class="splitSourceIcon" onerror={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
|
||||||
|
<span class="splitContentTitle">{src_activeSource.displayName}</span>
|
||||||
|
{#if src_loadingBrowse}
|
||||||
|
<svg width="13" height="13" viewBox="0 0 256 256" fill="currentColor" class="anim-spin" style="color:var(--text-faint)" aria-hidden="true">
|
||||||
|
<path d="M232,128a104,104,0,0,1-208,0c0-41,23.81-78.36,60.66-95.27a8,8,0,0,1,6.68,14.54C60.15,61.59,40,93.27,40,128a88,88,0,0,0,176,0c0-34.73-20.15-66.41-51.34-80.73a8,8,0,0,1,6.68-14.54C208.19,49.64,232,87,232,128Z"/>
|
||||||
|
</svg>
|
||||||
|
{:else if src_browseResults.length > 0}
|
||||||
|
<span class="splitResultCount">{src_browseResults.length} results</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sourceBrowseBar">
|
||||||
|
<div class="searchBar" style="flex:1">
|
||||||
|
<svg width="12" height="12" viewBox="0 0 256 256" fill="currentColor" class="searchIcon" aria-hidden="true">
|
||||||
|
<path d="M229.66,218.34l-50.07-50.07a88,88,0,1,0-11.31,11.31l50.06,50.07a8,8,0,0,0,11.32-11.31ZM40,112a72,72,0,1,1,72,72A72.08,72.08,0,0,1,40,112Z"/>
|
||||||
|
</svg>
|
||||||
|
<input
|
||||||
|
bind:value={src_browseQuery}
|
||||||
|
class="searchInput"
|
||||||
|
placeholder="Search {src_activeSource.displayName}…"
|
||||||
|
onkeydown={(e) => e.key === "Enter" && srcHandleSearch()}
|
||||||
|
/>
|
||||||
|
{#if src_submitted}
|
||||||
|
<button class="clearBtn" title="Clear search" onclick={srcClearSearch}>×</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<button class="searchBtn" onclick={srcHandleSearch} disabled={!src_browseQuery.trim() || src_loadingBrowse}>Search</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if src_loadingBrowse && src_browseResults.length === 0}
|
||||||
|
<div class="tagGrid">
|
||||||
|
{#each Array(18) as _, i (i)}
|
||||||
|
<div class="skCard"><div class="skeleton skCover"></div><div class="skeleton skTitle"></div></div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else if src_browseResults.length > 0}
|
||||||
|
<div class="tagGrid">
|
||||||
|
{#each src_browseResults as m, i (m.id)}
|
||||||
|
<button class="card" onclick={() => onPreview(m)}>
|
||||||
|
<div class="coverWrap">
|
||||||
|
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="cover" priority={i < 12 ? 12 - i : 0} id={m.id} />
|
||||||
|
{#if m.inLibrary}<span class="inLibBadge">Saved</span>{/if}
|
||||||
|
</div>
|
||||||
|
<p class="cardTitle">{m.title}</p>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
{#if src_hasNextPage}
|
||||||
|
<div class="showMoreCell">
|
||||||
|
<button
|
||||||
|
class="showMoreBtn"
|
||||||
|
disabled={src_loadingBrowse}
|
||||||
|
onclick={() => src_activeSource && srcFetchBrowse(src_activeSource, src_submitted ? "SEARCH" : "POPULAR", src_submitted || undefined, src_currentPage + 1)}
|
||||||
|
>
|
||||||
|
{src_loadingBrowse ? "Loading…" : "Load more"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else if !src_loadingBrowse}
|
||||||
|
<div class="empty">
|
||||||
|
<p class="emptyText">No results</p>
|
||||||
|
<p class="emptyHint">Try a different search term.</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if ctx_source}
|
||||||
|
{@const isPinned = pinnedIds.includes(ctx_source.id)}
|
||||||
|
<ContextMenu
|
||||||
|
x={ctx_x}
|
||||||
|
y={ctx_y}
|
||||||
|
onClose={closeCtx}
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
label: isPinned ? "Unpin source" : "Pin source",
|
||||||
|
icon: isPinned ? PushPinSlash : PushPin,
|
||||||
|
onClick: () => { togglePinnedSource(ctx_source!.id); },
|
||||||
|
},
|
||||||
|
{ separator: true },
|
||||||
|
{
|
||||||
|
label: "Browse source",
|
||||||
|
icon: ArrowRight,
|
||||||
|
onClick: () => { srcSelectSource(ctx_source!); },
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.splitRoot { flex: 1; display: flex; overflow: hidden; }
|
||||||
|
.splitSidebar { width: 180px; flex-shrink: 0; border-right: 1px solid var(--border-dim); overflow: hidden; display: flex; flex-direction: column; }
|
||||||
|
.srcLangRow { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-2) var(--sp-3); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; gap: var(--sp-2); }
|
||||||
|
.langPocketLabel { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; }
|
||||||
|
.langSelect { appearance: none; -webkit-appearance: none; background: var(--bg-overlay); border: 1px solid var(--border-dim); border-radius: var(--radius-sm); color: var(--text-secondary); font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 4px 24px 4px 8px; cursor: pointer; max-width: 110px; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8' viewBox='0 0 256 256'%3E%3Cpath fill='%23888' d='M213.66,101.66l-80,80a8,8,0,0,1-11.32,0l-80-80A8,8,0,0,1,53.66,90.34L128,164.69l74.34-74.35a8,8,0,0,1,11.32,11.32Z'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: right 7px center; transition: border-color var(--t-base), background var(--t-base), color var(--t-base); }
|
||||||
|
.langSelect:hover { border-color: var(--border-strong); background-color: var(--bg-raised); color: var(--text-primary); }
|
||||||
|
.langSelect:focus { outline: none; border-color: var(--accent-dim); color: var(--text-primary); }
|
||||||
|
.langSelect option { background: var(--bg-surface); color: var(--text-secondary); }
|
||||||
|
.splitLoading { flex: 1; display: flex; align-items: center; justify-content: center; padding: var(--sp-6); }
|
||||||
|
.splitList { flex: 1; overflow-y: auto; padding: var(--sp-1); scrollbar-width: thin; scrollbar-color: var(--border-dim) transparent; }
|
||||||
|
.localSourceIcon { width: 20px; height: 20px; border-radius: var(--radius-sm); background: var(--accent-muted); border: 1px solid var(--accent-dim); display: flex; align-items: center; justify-content: center; color: var(--accent-fg); flex-shrink: 0; }
|
||||||
|
.localDivider { height: 1px; background: var(--border-dim); margin: var(--sp-1) var(--sp-2); }
|
||||||
|
.splitItem { display: flex; align-items: center; gap: var(--sp-2); width: 100%; padding: 7px var(--sp-3); border-radius: var(--radius-md); border: 1px solid transparent; background: none; text-align: left; cursor: pointer; transition: background var(--t-fast), border-color var(--t-fast); }
|
||||||
|
.splitItem:hover { background: var(--bg-raised); border-color: var(--border-dim); }
|
||||||
|
.splitItemActive { background: var(--accent-muted); border-color: var(--accent-dim); }
|
||||||
|
.splitItemActive:hover { background: var(--accent-muted); }
|
||||||
|
.splitItemSource { gap: var(--sp-2); }
|
||||||
|
.splitItemLabel { font-size: var(--text-xs); color: var(--text-muted); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.splitItemActive .splitItemLabel { color: var(--accent-fg); font-weight: var(--weight-medium); }
|
||||||
|
.splitEmpty { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); padding: var(--sp-3); margin: 0; }
|
||||||
|
.sectionLabel { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; padding: var(--sp-2) var(--sp-3) var(--sp-1); margin: 0; }
|
||||||
|
.pinIndicator { display: flex; align-items: center; color: var(--accent-fg); opacity: 0.7; flex-shrink: 0; margin-left: auto; margin-right: 2px; }
|
||||||
|
.sourceLang { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); margin-left: auto; margin-right: 4px; }
|
||||||
|
.nsfwBadge { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--color-error); background: var(--color-error-bg, rgba(180,60,60,0.08)); border: 1px solid rgba(180,60,60,0.25); border-radius: var(--radius-sm); padding: 1px 5px; margin-left: auto; flex-shrink: 0; }
|
||||||
|
.splitContent { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
|
||||||
|
.splitContentHeader { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-3) var(--sp-4); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; gap: var(--sp-2); }
|
||||||
|
.splitSourceTitle { display: flex; align-items: center; gap: var(--sp-2); flex: 1; min-width: 0; }
|
||||||
|
.splitContentTitle { font-size: var(--text-base); font-weight: var(--weight-medium); color: var(--text-secondary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; letter-spacing: var(--tracking-tight); }
|
||||||
|
.splitResultCount { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); flex-shrink: 0; }
|
||||||
|
:global(.splitSourceIcon) { width: 20px; height: 20px; border-radius: var(--radius-sm); object-fit: cover; flex-shrink: 0; background: var(--bg-raised); }
|
||||||
|
.sourceBrowseBar { display: flex; align-items: center; gap: var(--sp-2); padding: var(--sp-2) var(--sp-4); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
|
||||||
|
.searchBar { display: flex; align-items: center; gap: var(--sp-2); background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-lg); padding: var(--sp-2) var(--sp-3); transition: border-color var(--t-base); }
|
||||||
|
.searchBar:focus-within { border-color: var(--border-strong); }
|
||||||
|
.searchIcon { color: var(--text-faint); flex-shrink: 0; }
|
||||||
|
.searchInput { flex: 1; background: none; border: none; outline: none; font-size: var(--text-sm); color: var(--text-primary); min-width: 0; }
|
||||||
|
.searchInput::placeholder { color: var(--text-faint); }
|
||||||
|
.clearBtn { color: var(--text-faint); font-size: 16px; line-height: 1; background: none; border: none; cursor: pointer; padding: 2px; transition: color var(--t-base); }
|
||||||
|
.clearBtn:hover { color: var(--text-muted); }
|
||||||
|
.searchBtn { padding: 6px 14px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: var(--bg-raised); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); color: var(--text-muted); cursor: pointer; transition: background var(--t-base), color var(--t-base), border-color var(--t-base); flex-shrink: 0; }
|
||||||
|
.searchBtn:hover:not(:disabled) { background: var(--bg-overlay); color: var(--text-secondary); border-color: var(--border-strong); }
|
||||||
|
.searchBtn:disabled { opacity: 0.4; cursor: default; }
|
||||||
|
.tagGrid { display: grid; grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); gap: var(--sp-4); padding: var(--sp-4); overflow-y: auto; flex: 1; align-content: start; }
|
||||||
|
.card { background: none; border: none; padding: 0; cursor: pointer; text-align: left; display: flex; flex-direction: column; gap: var(--sp-2); }
|
||||||
|
.coverWrap { position: relative; aspect-ratio: 2/3; overflow: hidden; border-radius: var(--radius-md); background: var(--bg-raised); border: 1px solid var(--border-dim); }
|
||||||
|
.cardTitle { font-size: var(--text-sm); color: var(--text-secondary); line-height: var(--leading-snug); display: -webkit-box; -webkit-line-clamp: 2; line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
|
||||||
|
.inLibBadge { position: absolute; top: var(--sp-2); left: var(--sp-2); font-family: var(--font-ui); font-size: 9px; letter-spacing: var(--tracking-wide); background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim); border-radius: var(--radius-sm); padding: 1px 5px; }
|
||||||
|
.showMoreCell { grid-column: 1 / -1; display: flex; justify-content: center; padding: var(--sp-2) 0; }
|
||||||
|
.showMoreBtn { display: inline-flex; align-items: center; gap: var(--sp-1); padding: 5px 12px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: none; font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); color: var(--text-muted); cursor: pointer; transition: background var(--t-base), color var(--t-base), border-color var(--t-base); }
|
||||||
|
.showMoreBtn:hover:not(:disabled) { background: var(--bg-raised); color: var(--text-secondary); border-color: var(--border-strong); }
|
||||||
|
.showMoreBtn:disabled { opacity: 0.4; cursor: default; }
|
||||||
|
.skCard { display: flex; flex-direction: column; gap: var(--sp-2); }
|
||||||
|
@keyframes shimmer { from { background-position: -200% 0 } to { background-position: 200% 0 } }
|
||||||
|
.skeleton { border-radius: var(--radius-sm); background: linear-gradient(90deg, var(--bg-raised) 25%, var(--bg-overlay, color-mix(in srgb, var(--bg-raised) 80%, var(--text-primary) 6%)) 50%, var(--bg-raised) 75%); background-size: 200% 100%; animation: shimmer 1.6s ease-in-out infinite; }
|
||||||
|
.skCover { aspect-ratio: 2/3; width: 100%; border-radius: var(--radius-md); }
|
||||||
|
.skTitle { height: 10px; width: 80%; }
|
||||||
|
.empty { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: var(--sp-2); padding: var(--sp-8); }
|
||||||
|
.emptyIcon { color: var(--text-faint); opacity: 0.5; }
|
||||||
|
.emptyText { font-size: var(--text-sm); color: var(--text-muted); font-weight: var(--weight-medium); margin: 0; }
|
||||||
|
.emptyHint { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); margin: 0; }
|
||||||
|
@keyframes anim-spin { from { transform: rotate(0deg) } to { transform: rotate(360deg) } }
|
||||||
|
.anim-spin { animation: anim-spin 0.8s linear infinite; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,461 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onDestroy, untrack } from "svelte";
|
||||||
|
import { getAdapter } from "$lib/request-manager";
|
||||||
|
import { settingsState } from "$lib/state/settings.svelte";
|
||||||
|
import { shouldHideNsfw, dedupeMangaById, dedupeMangaByTitle, normalizeTitle } from "$lib/core/util";
|
||||||
|
import { runConcurrent, filterSourceCache, buildTagFilter, COMMON_GENRES, MANGA_STATUSES, type TagMode, type CachedManga } from "$lib/components/browse/lib/searchFilter";
|
||||||
|
import Thumbnail from "$lib/components/shared/manga/Thumbnail.svelte";
|
||||||
|
import type { Manga, Source } from "$lib/types";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
allSources: Source[];
|
||||||
|
sourceCache: Map<number, CachedManga>;
|
||||||
|
sourceCacheReady: boolean;
|
||||||
|
sourceCacheLoading: boolean;
|
||||||
|
sourceCacheEnriching: boolean;
|
||||||
|
onPreview: (m: Manga) => void;
|
||||||
|
}
|
||||||
|
let {
|
||||||
|
allSources, sourceCache,
|
||||||
|
sourceCacheReady, sourceCacheLoading, sourceCacheEnriching,
|
||||||
|
onPreview,
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
const SEARCH_LIMIT = 200;
|
||||||
|
const preferredLang = $derived(settingsState.settings.preferredExtensionLang ?? "en");
|
||||||
|
|
||||||
|
let tag_activeTags: string[] = $state([]);
|
||||||
|
let tag_activeStatuses: string[] = $state([]);
|
||||||
|
let tag_tagMode: TagMode = $state("AND");
|
||||||
|
let tag_tagFilter = $state("");
|
||||||
|
|
||||||
|
const tag_filteredGenres = $derived.by(() => {
|
||||||
|
const q = tag_tagFilter.trim().toLowerCase();
|
||||||
|
return q ? COMMON_GENRES.filter((g) => g.toLowerCase().includes(q)) : [...COMMON_GENRES];
|
||||||
|
});
|
||||||
|
const tag_hasActiveFilters = $derived(tag_activeTags.length > 0 || tag_activeStatuses.length > 0);
|
||||||
|
|
||||||
|
let tag_localResults: Manga[] = $state([]);
|
||||||
|
let tag_totalCount = $state(0);
|
||||||
|
let tag_loadingLocal = $state(false);
|
||||||
|
let tag_loadingMoreLocal = $state(false);
|
||||||
|
let tag_localOffset = $state(0);
|
||||||
|
let tag_localHasNext = $state(false);
|
||||||
|
let tag_abortLocal: AbortController | null = null;
|
||||||
|
let tag_abortLoadMore: AbortController | null = null;
|
||||||
|
|
||||||
|
const renderLimit = $derived(settingsState.settings.renderLimit ?? 48);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const _tags = tag_activeTags;
|
||||||
|
const _mode = tag_tagMode;
|
||||||
|
const _statuses = tag_activeStatuses;
|
||||||
|
untrack(() => tagFetchLocal(_tags, _mode, _statuses));
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
async function tagFetchLocal(activeTags: string[], tagMode: TagMode, activeStatuses: string[]) {
|
||||||
|
if (activeTags.length === 0 && activeStatuses.length === 0) {
|
||||||
|
tag_localResults = []; tag_totalCount = 0; tag_localHasNext = false; tag_localOffset = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tag_abortLocal?.abort();
|
||||||
|
tag_abortLoadMore?.abort();
|
||||||
|
const ctrl = new AbortController();
|
||||||
|
tag_abortLocal = ctrl;
|
||||||
|
tag_localResults = []; tag_totalCount = 0; tag_localOffset = 0; tag_localHasNext = false;
|
||||||
|
tag_loadingLocal = true;
|
||||||
|
const limit = renderLimit;
|
||||||
|
try {
|
||||||
|
const d = await getAdapter().getMangasByGenre(
|
||||||
|
buildTagFilter(activeTags, tagMode, activeStatuses), limit, 0, ctrl.signal,
|
||||||
|
);
|
||||||
|
if (ctrl.signal.aborted) return;
|
||||||
|
const nsfwFilter = (m: Manga) => !shouldHideNsfw(m as any, settingsState.settings);
|
||||||
|
tag_localResults = d.items.filter(nsfwFilter);
|
||||||
|
tag_totalCount = d.totalCount;
|
||||||
|
tag_localHasNext = d.hasNextPage;
|
||||||
|
tag_localOffset = limit;
|
||||||
|
if (d.hasNextPage && tag_localResults.length < 20) tagLoadMoreLocal();
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e?.name !== "AbortError") console.error(e);
|
||||||
|
} finally {
|
||||||
|
if (!ctrl.signal.aborted) tag_loadingLocal = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tagLoadMoreLocal() {
|
||||||
|
if (tag_loadingMoreLocal || !tag_localHasNext) return;
|
||||||
|
tag_abortLoadMore?.abort();
|
||||||
|
const ctrl = new AbortController();
|
||||||
|
tag_abortLoadMore = ctrl;
|
||||||
|
tag_loadingMoreLocal = true;
|
||||||
|
const limit = renderLimit;
|
||||||
|
try {
|
||||||
|
const d = await getAdapter().getMangasByGenre(
|
||||||
|
buildTagFilter(tag_activeTags, tag_tagMode, tag_activeStatuses), limit, tag_localOffset, ctrl.signal,
|
||||||
|
);
|
||||||
|
if (ctrl.signal.aborted) return;
|
||||||
|
const nsfwFilter = (m: Manga) => !shouldHideNsfw(m as any, settingsState.settings);
|
||||||
|
tag_localResults = [...tag_localResults, ...d.items.filter(nsfwFilter)];
|
||||||
|
tag_localHasNext = d.hasNextPage;
|
||||||
|
tag_localOffset += limit;
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e?.name !== "AbortError") console.error(e);
|
||||||
|
} finally {
|
||||||
|
if (!ctrl.signal.aborted) tag_loadingMoreLocal = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let tag_searchSources = $state(false);
|
||||||
|
let tag_sourceFiltered: CachedManga[] = $state([]);
|
||||||
|
let tag_sourceFanOut: Manga[] = $state([]);
|
||||||
|
let tag_fanOutLoading = $state(false);
|
||||||
|
let tag_fanOutAbort: AbortController | null = null;
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const _tags = tag_activeTags;
|
||||||
|
const _mode = tag_tagMode;
|
||||||
|
const _statuses = tag_activeStatuses;
|
||||||
|
const _ready = sourceCacheReady;
|
||||||
|
const _search = tag_searchSources;
|
||||||
|
untrack(() => {
|
||||||
|
if (_search && _ready && (_tags.length > 0 || _statuses.length > 0)) {
|
||||||
|
tag_sourceFiltered = filterSourceCache(sourceCache, _tags, _mode, _statuses, settingsState.settings);
|
||||||
|
} else {
|
||||||
|
tag_sourceFiltered = [];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const _tags = tag_activeTags;
|
||||||
|
const _search = tag_searchSources;
|
||||||
|
untrack(() => {
|
||||||
|
if (_search && _tags.length === 1 && tag_activeStatuses.length === 0) {
|
||||||
|
tagStartFanOut(_tags[0]);
|
||||||
|
} else {
|
||||||
|
tag_fanOutAbort?.abort();
|
||||||
|
tag_fanOutAbort = null;
|
||||||
|
tag_sourceFanOut = [];
|
||||||
|
tag_fanOutLoading = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
async function tagStartFanOut(genre: string) {
|
||||||
|
tag_fanOutAbort?.abort();
|
||||||
|
const ctrl = new AbortController();
|
||||||
|
tag_fanOutAbort = ctrl;
|
||||||
|
tag_sourceFanOut = [];
|
||||||
|
tag_fanOutLoading = true;
|
||||||
|
|
||||||
|
const seenIds = new Set<number>();
|
||||||
|
const seenTitles = new Set<string>();
|
||||||
|
const genreLower = genre.toLowerCase();
|
||||||
|
|
||||||
|
const srcs = allSources.filter((s) => !shouldHideNsfw(s as any, settingsState.settings));
|
||||||
|
|
||||||
|
await runConcurrent(srcs, async (src) => {
|
||||||
|
for (let page = 1; page <= 2; page++) {
|
||||||
|
if (ctrl.signal.aborted) return;
|
||||||
|
let result: { items: Manga[]; hasNextPage: boolean } | null = null;
|
||||||
|
try {
|
||||||
|
result = await getAdapter().searchSource(src.id, genre, page, ctrl.signal);
|
||||||
|
} catch { return; }
|
||||||
|
if (!result || ctrl.signal.aborted) return;
|
||||||
|
const matching = result.items.filter((m) =>
|
||||||
|
((m as any).genre ?? []).some((g: string) => g.toLowerCase() === genreLower)
|
||||||
|
);
|
||||||
|
const candidates = (matching.length ? matching : result.items).filter(
|
||||||
|
(m) => !shouldHideNsfw(m as any, settingsState.settings)
|
||||||
|
);
|
||||||
|
const toAdd: Manga[] = [];
|
||||||
|
for (const m of candidates) {
|
||||||
|
if (seenIds.has(m.id)) continue;
|
||||||
|
const norm = normalizeTitle(m.title);
|
||||||
|
if (seenTitles.has(norm)) continue;
|
||||||
|
seenIds.add(m.id);
|
||||||
|
seenTitles.add(norm);
|
||||||
|
toAdd.push(m);
|
||||||
|
}
|
||||||
|
if (toAdd.length) {
|
||||||
|
tag_sourceFanOut = [...tag_sourceFanOut, ...toAdd].slice(0, SEARCH_LIMIT);
|
||||||
|
}
|
||||||
|
if (!result.hasNextPage) return;
|
||||||
|
}
|
||||||
|
}, ctrl.signal);
|
||||||
|
|
||||||
|
if (!ctrl.signal.aborted) tag_fanOutLoading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let tag_autoSearchFired = $state(false);
|
||||||
|
$effect(() => {
|
||||||
|
void tag_activeTags;
|
||||||
|
void tag_activeStatuses;
|
||||||
|
untrack(() => { tag_autoSearchFired = false; });
|
||||||
|
});
|
||||||
|
$effect(() => {
|
||||||
|
const _loadingLocal = tag_loadingLocal;
|
||||||
|
const _hasFilters = tag_hasActiveFilters;
|
||||||
|
const _resultLen = tag_localResults.length;
|
||||||
|
const _cacheReady = sourceCacheReady;
|
||||||
|
if (!_loadingLocal && _hasFilters && _cacheReady) {
|
||||||
|
untrack(() => {
|
||||||
|
if (!tag_autoSearchFired && !tag_searchSources && _resultLen < 20) {
|
||||||
|
tag_autoSearchFired = true;
|
||||||
|
tag_searchSources = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const tag_localIds = $derived(new Set(tag_localResults.map((m) => m.id)));
|
||||||
|
|
||||||
|
const tag_mergedResults = $derived.by(() => {
|
||||||
|
const fanOutMapped = tag_sourceFanOut.filter((m) => !tag_localIds.has(m.id));
|
||||||
|
const cacheMapped: Manga[] = tag_sourceFiltered
|
||||||
|
.filter((m) => !tag_localIds.has(m.id) && !fanOutMapped.some((f) => f.id === m.id))
|
||||||
|
.map((m) => ({ id: m.id, title: m.title, thumbnailUrl: m.thumbnailUrl, inLibrary: m.inLibrary, genre: m.genre, status: m.status } as Manga));
|
||||||
|
return dedupeMangaByTitle(
|
||||||
|
dedupeMangaById([...tag_localResults, ...fanOutMapped, ...cacheMapped]),
|
||||||
|
settingsState.settings.mangaLinks,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const tag_totalVisible = $derived(tag_mergedResults.length);
|
||||||
|
|
||||||
|
function tagToggleTag(tag: string) {
|
||||||
|
tag_activeTags = tag_activeTags.includes(tag)
|
||||||
|
? tag_activeTags.filter((t) => t !== tag)
|
||||||
|
: [...tag_activeTags, tag];
|
||||||
|
}
|
||||||
|
|
||||||
|
function tagToggleStatus(status: string) {
|
||||||
|
tag_activeStatuses = tag_activeStatuses.includes(status)
|
||||||
|
? tag_activeStatuses.filter((s) => s !== status)
|
||||||
|
: [...tag_activeStatuses, status];
|
||||||
|
}
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
tag_abortLocal?.abort();
|
||||||
|
tag_abortLoadMore?.abort();
|
||||||
|
tag_fanOutAbort?.abort();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="splitRoot">
|
||||||
|
|
||||||
|
<div class="splitSidebar">
|
||||||
|
<div class="splitSearchWrap">
|
||||||
|
<svg width="12" height="12" viewBox="0 0 256 256" fill="currentColor" class="splitSearchIcon" aria-hidden="true">
|
||||||
|
<path d="M229.66,218.34l-50.07-50.07a88,88,0,1,0-11.31,11.31l50.06,50.07a8,8,0,0,0,11.32-11.31ZM40,112a72,72,0,1,1,72,72A72.08,72.08,0,0,1,40,112Z"/>
|
||||||
|
</svg>
|
||||||
|
<input bind:value={tag_tagFilter} class="splitSearchInput" placeholder="Filter genres…" />
|
||||||
|
{#if tag_tagFilter}
|
||||||
|
<button class="splitSearchClear" title="Clear" onclick={() => (tag_tagFilter = "")}>×</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="splitList">
|
||||||
|
<div class="splitSectionLabel">Status</div>
|
||||||
|
{#each MANGA_STATUSES as { value, label } (value)}
|
||||||
|
<button class="splitItem" class:splitItemActive={tag_activeStatuses.includes(value)} onclick={() => tagToggleStatus(value)}>
|
||||||
|
<span class="splitItemLabel">{label}</span>
|
||||||
|
{#if tag_activeStatuses.includes(value)}<span class="tagCheckMark">✓</span>{/if}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
<div class="splitSectionLabel splitSectionLabelSpaced">Genre</div>
|
||||||
|
{#each tag_filteredGenres as tag (tag)}
|
||||||
|
<button class="splitItem" class:splitItemActive={tag_activeTags.includes(tag)} onclick={() => tagToggleTag(tag)}>
|
||||||
|
<span class="splitItemLabel">{tag}</span>
|
||||||
|
{#if tag_activeTags.includes(tag)}<span class="tagCheckMark">✓</span>{/if}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
{#if tag_filteredGenres.length === 0}
|
||||||
|
<p class="splitEmpty">No matching genres</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="splitContent">
|
||||||
|
{#if !tag_hasActiveFilters}
|
||||||
|
<div class="empty">
|
||||||
|
<svg width="32" height="32" viewBox="0 0 256 256" fill="currentColor" class="emptyIcon" aria-hidden="true">
|
||||||
|
<path d="M224,104H200l8-48a8,8,0,0,0-15.79-2.67L183.79,104H136l8-48a8,8,0,0,0-15.79-2.67L119.79,104H72a8,8,0,0,0,0,16h45.33L105.6,200H56a8,8,0,0,0,0,16H103l-8,48a8,8,0,0,0,15.79,2.67L119.21,216H168l-8,48a8,8,0,0,0,15.79,2.67L184.21,216H232a8,8,0,0,0,0-16H186.67l11.73-80H224a8,8,0,0,0,0-16Zm-69.33,96H101.6L113.33,120h53.07Z"/>
|
||||||
|
</svg>
|
||||||
|
<p class="emptyText">Browse by tag</p>
|
||||||
|
<p class="emptyHint">Select a status or genre to find matching manga.</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
|
||||||
|
<div class="tagActiveBar">
|
||||||
|
<div class="tagPillRow">
|
||||||
|
{#each tag_activeStatuses as status (status)}
|
||||||
|
<span class="tagPill tagPillStatus">
|
||||||
|
{MANGA_STATUSES.find((s) => s.value === status)?.label ?? status}
|
||||||
|
<button class="tagPillRemove" title="Remove {status}" onclick={() => tagToggleStatus(status)}>×</button>
|
||||||
|
</span>
|
||||||
|
{/each}
|
||||||
|
{#each tag_activeTags as tag (tag)}
|
||||||
|
<span class="tagPill">
|
||||||
|
{tag}
|
||||||
|
<button class="tagPillRemove" title="Remove {tag}" onclick={() => tagToggleTag(tag)}>×</button>
|
||||||
|
</span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<div class="tagBarRight">
|
||||||
|
{#if tag_activeTags.length > 1}
|
||||||
|
<div class="tagModeToggle">
|
||||||
|
<button class="tagModeBtn" class:tagModeBtnActive={tag_tagMode === "AND"} title="Match ALL tags" onclick={() => (tag_tagMode = "AND")}>AND</button>
|
||||||
|
<button class="tagModeBtn" class:tagModeBtnActive={tag_tagMode === "OR"} title="Match ANY tag" onclick={() => (tag_tagMode = "OR")}>OR</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<button
|
||||||
|
class="tagModeBtn"
|
||||||
|
class:tagModeBtnActive={tag_searchSources}
|
||||||
|
title={sourceCacheLoading ? "Building source cache…" : sourceCacheReady ? "Search across sources" : "Sources unavailable"}
|
||||||
|
disabled={!sourceCacheReady && !sourceCacheLoading}
|
||||||
|
onclick={() => (tag_searchSources = !tag_searchSources)}
|
||||||
|
>
|
||||||
|
{#if sourceCacheLoading || tag_fanOutLoading}
|
||||||
|
<svg width="11" height="11" viewBox="0 0 256 256" fill="currentColor" class="anim-spin" aria-hidden="true">
|
||||||
|
<path d="M232,128a104,104,0,0,1-208,0c0-41,23.81-78.36,60.66-95.27a8,8,0,0,1,6.68,14.54C60.15,61.59,40,93.27,40,128a88,88,0,0,0,176,0c0-34.73-20.15-66.41-51.34-80.73a8,8,0,0,1,6.68-14.54C208.19,49.64,232,87,232,128Z"/>
|
||||||
|
</svg>
|
||||||
|
{:else}
|
||||||
|
<svg width="11" height="11" viewBox="0 0 256 256" fill="currentColor" style="margin-right:3px;vertical-align:middle" aria-hidden="true">
|
||||||
|
<path d="M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24ZM101.63,168h52.74C149,186.34,140,202.87,128,215.89,116,202.87,107,186.34,101.63,168ZM98,152a145.72,145.72,0,0,1,0-48h60a145.72,145.72,0,0,1,0,48ZM40,128a87.61,87.61,0,0,1,3.33-24H81.79a161.79,161.79,0,0,0,0,48H43.33A87.61,87.61,0,0,1,40,128ZM154.37,88H101.63C107,69.66,116,53.13,128,40.11,140,53.13,149,69.66,154.37,88ZM174.21,104h38.46a88.15,88.15,0,0,1,0,48H174.21a161.79,161.79,0,0,0,0-48Zm32.32-16H170.71a133.32,133.32,0,0,0-22.7-45.8A88.21,88.21,0,0,1,206.53,88ZM108,42.2A133.32,133.32,0,0,0,85.29,88H49.47A88.21,88.21,0,0,1,108,42.2ZM49.47,168H85.29A133.32,133.32,0,0,0,108,213.8,88.21,88.21,0,0,1,49.47,168Zm98.53,45.8A133.32,133.32,0,0,0,170.71,168h35.82A88.21,88.21,0,0,1,148,213.8Z"/>
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
Sources{sourceCacheEnriching ? " ·" : ""}
|
||||||
|
</button>
|
||||||
|
<button class="tagClearAll" onclick={() => { tag_activeTags = []; tag_activeStatuses = []; }}>Clear all</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="splitContentHeader">
|
||||||
|
<span class="splitContentTitle">
|
||||||
|
{#if tag_activeStatuses.length > 0 && tag_activeTags.length === 0}
|
||||||
|
{tag_activeStatuses.map((s) => MANGA_STATUSES.find((x) => x.value === s)?.label ?? s).join(" · ")}
|
||||||
|
{:else if tag_activeTags.length === 1 && tag_activeStatuses.length === 0}
|
||||||
|
{tag_activeTags[0]}
|
||||||
|
{:else}
|
||||||
|
{[...tag_activeStatuses.map((s) => MANGA_STATUSES.find((x) => x.value === s)?.label ?? s), ...tag_activeTags].join(` ${tag_tagMode} `)}
|
||||||
|
{/if}
|
||||||
|
{#if tag_searchSources}
|
||||||
|
<span style="margin-left:6px;font-weight:400;opacity:0.55;font-size:0.9em">+ sources</span>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
{#if tag_loadingLocal}
|
||||||
|
<svg width="13" height="13" viewBox="0 0 256 256" fill="currentColor" class="anim-spin" style="color:var(--text-faint)" aria-hidden="true">
|
||||||
|
<path d="M232,128a104,104,0,0,1-208,0c0-41,23.81-78.36,60.66-95.27a8,8,0,0,1,6.68,14.54C60.15,61.59,40,93.27,40,128a88,88,0,0,0,176,0c0-34.73-20.15-66.41-51.34-80.73a8,8,0,0,1,6.68-14.54C208.19,49.64,232,87,232,128Z"/>
|
||||||
|
</svg>
|
||||||
|
{:else}
|
||||||
|
<span class="splitResultCount">
|
||||||
|
{tag_totalVisible}{tag_localHasNext ? "+" : ""} results
|
||||||
|
{#if tag_searchSources && sourceCacheReady}
|
||||||
|
· {sourceCache.size} cached
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if tag_loadingLocal}
|
||||||
|
<div class="tagGrid">
|
||||||
|
{#each Array(48) as _, i (i)}
|
||||||
|
<div class="skCard"><div class="skeleton skCover"></div><div class="skeleton skTitle"></div></div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else if tag_mergedResults.length > 0}
|
||||||
|
<div class="tagGrid">
|
||||||
|
{#each tag_mergedResults as m, i (m.id)}
|
||||||
|
<button class="card" onclick={() => onPreview(m)}>
|
||||||
|
<div class="coverWrap">
|
||||||
|
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="cover" priority={i < 12 ? 12 - i : 0} id={m.id} />
|
||||||
|
{#if m.inLibrary}<span class="inLibBadge">Saved</span>{/if}
|
||||||
|
</div>
|
||||||
|
<p class="cardTitle">{m.title}</p>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
{#if tag_loadingMoreLocal}
|
||||||
|
{#each Array(12) as _, i (i)}
|
||||||
|
<div class="skCard"><div class="skeleton skCover"></div><div class="skeleton skTitle"></div></div>
|
||||||
|
{/each}
|
||||||
|
{:else if tag_localHasNext}
|
||||||
|
<div class="loadMoreRow">
|
||||||
|
<button class="loadMoreBtn" onclick={tagLoadMoreLocal}>Load more</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="empty">
|
||||||
|
<p class="emptyText">No results</p>
|
||||||
|
<p class="emptyHint">
|
||||||
|
{#if tag_searchSources}Try OR mode or broader tags.
|
||||||
|
{:else}Try OR mode, enable Sources, or check your library.
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.splitRoot { flex: 1; display: flex; overflow: hidden; }
|
||||||
|
.splitSidebar { width: 180px; flex-shrink: 0; border-right: 1px solid var(--border-dim); overflow: hidden; display: flex; flex-direction: column; }
|
||||||
|
.splitSearchWrap { display: flex; align-items: center; gap: var(--sp-1); padding: var(--sp-2) var(--sp-3); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
|
||||||
|
.splitSearchIcon { color: var(--text-faint); flex-shrink: 0; }
|
||||||
|
.splitSearchInput { flex: 1; background: none; border: none; outline: none; font-size: var(--text-xs); color: var(--text-primary); font-family: var(--font-ui); min-width: 0; }
|
||||||
|
.splitSearchInput::placeholder { color: var(--text-faint); }
|
||||||
|
.splitSearchClear { color: var(--text-faint); font-size: 13px; line-height: 1; background: none; border: none; cursor: pointer; padding: 2px; transition: color var(--t-base); }
|
||||||
|
.splitSearchClear:hover { color: var(--text-muted); }
|
||||||
|
.splitList { flex: 1; overflow-y: auto; padding: var(--sp-1); scrollbar-width: thin; scrollbar-color: var(--border-dim) transparent; }
|
||||||
|
.splitSectionLabel { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wider); text-transform: uppercase; color: var(--text-faint); padding: var(--sp-2) var(--sp-3) var(--sp-1); pointer-events: none; user-select: none; }
|
||||||
|
.splitSectionLabelSpaced { margin-top: var(--sp-2); border-top: 1px solid var(--border-dim); padding-top: var(--sp-3); }
|
||||||
|
.splitItem { display: flex; align-items: center; gap: var(--sp-2); width: 100%; padding: 7px var(--sp-3); border-radius: var(--radius-md); border: 1px solid transparent; background: none; text-align: left; cursor: pointer; transition: background var(--t-fast), border-color var(--t-fast); }
|
||||||
|
.splitItem:hover { background: var(--bg-raised); border-color: var(--border-dim); }
|
||||||
|
.splitItemActive { background: var(--accent-muted); border-color: var(--accent-dim); }
|
||||||
|
.splitItemActive:hover { background: var(--accent-muted); }
|
||||||
|
.splitItemLabel { font-size: var(--text-xs); color: var(--text-muted); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.splitItemActive .splitItemLabel { color: var(--accent-fg); font-weight: var(--weight-medium); }
|
||||||
|
.splitEmpty { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); padding: var(--sp-3); margin: 0; }
|
||||||
|
.splitContent { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
|
||||||
|
.splitContentHeader { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-3) var(--sp-4); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; gap: var(--sp-2); }
|
||||||
|
.splitContentTitle { font-size: var(--text-base); font-weight: var(--weight-medium); color: var(--text-secondary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; letter-spacing: var(--tracking-tight); }
|
||||||
|
.splitResultCount { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); flex-shrink: 0; }
|
||||||
|
.tagActiveBar { display: flex; align-items: flex-start; gap: var(--sp-2); padding: var(--sp-2) var(--sp-4); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; flex-wrap: wrap; }
|
||||||
|
.tagPillRow { display: flex; flex-wrap: wrap; gap: var(--sp-1); flex: 1; min-width: 0; }
|
||||||
|
.tagPill { display: inline-flex; align-items: center; gap: 4px; padding: 2px 7px; background: var(--accent-muted); border: 1px solid var(--accent-dim); border-radius: var(--radius-sm); font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--accent-fg); }
|
||||||
|
.tagPillStatus { background: color-mix(in srgb, var(--color-info, #4a90d9) 12%, transparent); border-color: color-mix(in srgb, var(--color-info, #4a90d9) 30%, transparent); color: var(--color-info, #4a90d9); }
|
||||||
|
.tagPillRemove { color: currentColor; opacity: 0.6; font-size: 13px; line-height: 1; background: none; border: none; cursor: pointer; padding: 0; transition: opacity var(--t-base); }
|
||||||
|
.tagPillRemove:hover { opacity: 1; }
|
||||||
|
.tagBarRight { display: flex; align-items: center; gap: 4px; flex-shrink: 0; }
|
||||||
|
.tagModeToggle { display: flex; border: 1px solid var(--border-dim); border-radius: var(--radius-md); overflow: hidden; }
|
||||||
|
.tagModeBtn { display: flex; align-items: center; gap: 4px; padding: 4px 8px; font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--text-faint); background: none; border: none; border-right: 1px solid var(--border-dim); cursor: pointer; transition: color var(--t-base), background var(--t-base); }
|
||||||
|
.tagModeBtn:last-child { border-right: none; }
|
||||||
|
.tagModeBtn:hover { color: var(--text-muted); background: var(--bg-raised); }
|
||||||
|
.tagModeBtnActive { color: var(--accent-fg); background: var(--accent-muted); }
|
||||||
|
.tagModeBtnActive:hover { color: var(--accent-fg); background: var(--accent-muted); }
|
||||||
|
.tagClearAll { display: flex; align-items: center; font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--text-faint); padding: 4px 8px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: none; cursor: pointer; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
|
||||||
|
.tagClearAll:hover { color: var(--color-error); border-color: color-mix(in srgb, var(--color-error) 40%, transparent); background: var(--color-error-bg, color-mix(in srgb, var(--color-error) 8%, transparent)); }
|
||||||
|
.tagCheckMark { font-size: var(--text-xs); color: var(--accent-fg); margin-left: auto; }
|
||||||
|
.tagGrid { display: grid; grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); gap: var(--sp-4); padding: var(--sp-4); overflow-y: auto; flex: 1; align-content: start; will-change: scroll-position; }
|
||||||
|
.loadMoreRow { grid-column: 1 / -1; display: flex; justify-content: center; padding: var(--sp-2) 0 var(--sp-4); }
|
||||||
|
.loadMoreBtn { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); color: var(--text-faint); background: none; border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 6px 20px; cursor: pointer; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
|
||||||
|
.loadMoreBtn:hover { color: var(--text-muted); border-color: var(--border-strong); background: var(--bg-raised); }
|
||||||
|
.card { background: none; border: none; padding: 0; cursor: pointer; text-align: left; display: flex; flex-direction: column; gap: var(--sp-2); }
|
||||||
|
.coverWrap { position: relative; aspect-ratio: 2/3; overflow: hidden; border-radius: var(--radius-md); background: var(--bg-raised); border: 1px solid var(--border-dim); }
|
||||||
|
.cardTitle { font-size: var(--text-sm); color: var(--text-secondary); line-height: var(--leading-snug); display: -webkit-box; -webkit-line-clamp: 2; line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; transition: color var(--t-base); }
|
||||||
|
.inLibBadge { position: absolute; top: var(--sp-2); left: var(--sp-2); font-family: var(--font-ui); font-size: 9px; letter-spacing: var(--tracking-wide); background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim); border-radius: var(--radius-sm); padding: 1px 5px; }
|
||||||
|
.skCard { display: flex; flex-direction: column; gap: var(--sp-2); }
|
||||||
|
@keyframes shimmer { from { background-position: -200% 0 } to { background-position: 200% 0 } }
|
||||||
|
.skeleton { border-radius: var(--radius-sm); background: linear-gradient(90deg, var(--bg-raised) 25%, var(--bg-overlay, color-mix(in srgb, var(--bg-raised) 80%, var(--text-primary) 6%)) 50%, var(--bg-raised) 75%); background-size: 200% 100%; animation: shimmer 1.6s ease-in-out infinite; }
|
||||||
|
.skCover { aspect-ratio: 2/3; width: 100%; border-radius: var(--radius-md); }
|
||||||
|
.skTitle { height: 10px; width: 80%; }
|
||||||
|
.empty { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: var(--sp-2); padding: var(--sp-8); }
|
||||||
|
.emptyIcon { color: var(--text-faint); opacity: 0.5; }
|
||||||
|
.emptyText { font-size: var(--text-sm); color: var(--text-muted); font-weight: var(--weight-medium); margin: 0; }
|
||||||
|
.emptyHint { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); margin: 0; }
|
||||||
|
@keyframes anim-spin { from { transform: rotate(0deg) } to { transform: rotate(360deg) } }
|
||||||
|
.anim-spin { animation: anim-spin 0.8s linear infinite; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import type { Settings } from "$lib/types/settings";
|
||||||
|
import { shouldHideNsfw } from "$lib/core/util";
|
||||||
|
|
||||||
|
export { shouldHideNsfw };
|
||||||
|
|
||||||
|
export const PAGE_SIZE = 50;
|
||||||
|
export const INITIAL_PAGES = 3;
|
||||||
|
export const MAX_SOURCES = 12;
|
||||||
|
export const CONCURRENCY = 4;
|
||||||
|
|
||||||
|
export function parseTags(f: string): string[] {
|
||||||
|
return f.split("+").map((t) => t.trim()).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function tagsLabel(tags: string[]): string {
|
||||||
|
if (tags.length === 1) return tags[0];
|
||||||
|
return tags.slice(0, -1).join(", ") + " & " + tags[tags.length - 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function matchesAllTags(m: { genre?: string[] }, tags: string[]): boolean {
|
||||||
|
const g = (m.genre ?? []).map((x) => x.toLowerCase());
|
||||||
|
return tags.every((t) => g.includes(t.toLowerCase()));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runConcurrent<T>(
|
||||||
|
items: T[],
|
||||||
|
fn: (item: T) => Promise<void>,
|
||||||
|
signal: AbortSignal,
|
||||||
|
): Promise<void> {
|
||||||
|
let i = 0;
|
||||||
|
async function worker() {
|
||||||
|
while (i < items.length) {
|
||||||
|
if (signal.aborted) return;
|
||||||
|
await fn(items[i++]).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, items.length) }, worker));
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TagMode = "AND" | "OR";
|
||||||
|
|
||||||
|
export interface CachedManga {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
thumbnailUrl: string;
|
||||||
|
inLibrary: boolean;
|
||||||
|
status: string;
|
||||||
|
genre: string[];
|
||||||
|
lowerGenres: string[];
|
||||||
|
sourceId: string;
|
||||||
|
genreEnriched: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const COMMON_GENRES = [
|
||||||
|
"Action", "Adventure", "Comedy", "Drama", "Fantasy", "Romance",
|
||||||
|
"Sci-Fi", "Slice of Life", "Horror", "Mystery", "Thriller", "Sports",
|
||||||
|
"Supernatural", "Mecha", "Historical", "Psychological", "School Life",
|
||||||
|
"Shounen", "Seinen", "Josei", "Shoujo", "Isekai", "Martial Arts",
|
||||||
|
"Magic", "Music", "Cooking", "Medical", "Military", "Harem", "Ecchi",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const MANGA_STATUSES: { value: string; label: string }[] = [
|
||||||
|
{ value: "ONGOING", label: "Ongoing" },
|
||||||
|
{ value: "COMPLETED", label: "Completed" },
|
||||||
|
{ value: "HIATUS", label: "Hiatus" },
|
||||||
|
{ value: "ABANDONED", label: "Abandoned" },
|
||||||
|
{ value: "UNKNOWN", label: "Unknown" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function buildTagFilter(
|
||||||
|
tags: string[],
|
||||||
|
mode: TagMode,
|
||||||
|
statuses: string[],
|
||||||
|
): Record<string, unknown> {
|
||||||
|
const genrePart: Record<string, unknown> | null =
|
||||||
|
tags.length === 0 ? null :
|
||||||
|
mode === "AND"
|
||||||
|
? { and: tags.map((t) => ({ genre: { includesInsensitive: t } })) }
|
||||||
|
: { or: tags.map((t) => ({ genre: { includesInsensitive: t } })) };
|
||||||
|
|
||||||
|
const statusPart: Record<string, unknown> | null =
|
||||||
|
statuses.length === 0 ? null :
|
||||||
|
statuses.length === 1
|
||||||
|
? { status: { equalTo: statuses[0] } }
|
||||||
|
: { or: statuses.map((s) => ({ status: { equalTo: s } })) };
|
||||||
|
|
||||||
|
if (!genrePart && !statusPart) return {};
|
||||||
|
if (genrePart && !statusPart) return genrePart;
|
||||||
|
if (!genrePart && statusPart) return statusPart;
|
||||||
|
return { and: [genrePart, statusPart] };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterSourceCache(
|
||||||
|
sourceCache: Map<number, CachedManga>,
|
||||||
|
tags: string[],
|
||||||
|
mode: TagMode,
|
||||||
|
statuses: string[],
|
||||||
|
settings: Pick<Settings, "contentLevel" | "sourceOverridesEnabled" | "nsfwAllowedSourceIds" | "nsfwBlockedSourceIds">,
|
||||||
|
): CachedManga[] {
|
||||||
|
return [...sourceCache.values()].filter((m) => {
|
||||||
|
if (shouldHideNsfw(m as any, settings)) return false;
|
||||||
|
|
||||||
|
const statusMatch = statuses.length === 0 || statuses.includes(m.status);
|
||||||
|
|
||||||
|
let genreMatch = true;
|
||||||
|
if (tags.length > 0) {
|
||||||
|
const lower = m.lowerGenres;
|
||||||
|
genreMatch = mode === "AND"
|
||||||
|
? tags.every((t) => lower.some((g) => g.includes(t.toLowerCase())))
|
||||||
|
: tags.some((t) => lower.some((g) => g.includes(t.toLowerCase())));
|
||||||
|
}
|
||||||
|
|
||||||
|
return statusMatch && genreMatch;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toCachedManga(
|
||||||
|
m: { id: number; title: string; thumbnailUrl: string; inLibrary: boolean; genre?: string[]; status?: string },
|
||||||
|
srcId: string,
|
||||||
|
): CachedManga {
|
||||||
|
const genre = m.genre ?? [];
|
||||||
|
return {
|
||||||
|
id: m.id,
|
||||||
|
title: m.title,
|
||||||
|
thumbnailUrl: m.thumbnailUrl,
|
||||||
|
inLibrary: m.inLibrary,
|
||||||
|
status: m.status ?? "UNKNOWN",
|
||||||
|
genre,
|
||||||
|
lowerGenres: genre.map((g) => g.toLowerCase()),
|
||||||
|
sourceId: srcId,
|
||||||
|
genreEnriched: genre.length > 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import logoUrl from '$lib/assets/moku-icon-splash.svg'
|
||||||
|
import { appState } from '$lib/state/app.svelte'
|
||||||
|
import { boot, submitLogin, bypassBoot } from '$lib/state/boot.svelte'
|
||||||
|
|
||||||
|
function handleBypass() {
|
||||||
|
bypassBoot(appState.authMode, boot.loginUser)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if appState.status === 'auth'}
|
||||||
|
<div class="overlay overlay--clear">
|
||||||
|
<div class="card anim-scale-in">
|
||||||
|
<img src={logoUrl} alt="Moku" class="logo" />
|
||||||
|
<p class="title">moku</p>
|
||||||
|
<span class="mode-badge">
|
||||||
|
{appState.authMode === 'UI_LOGIN' ? 'UI Login' : 'Basic Auth'}
|
||||||
|
</span>
|
||||||
|
<p class="host">{appState.serverUrl || 'localhost:4567'}</p>
|
||||||
|
|
||||||
|
{#if boot.loginError}
|
||||||
|
<p class="error">{boot.loginError}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="fields">
|
||||||
|
<input
|
||||||
|
class="input"
|
||||||
|
type="text"
|
||||||
|
placeholder="Username"
|
||||||
|
bind:value={boot.loginUser}
|
||||||
|
disabled={boot.loginBusy}
|
||||||
|
autocomplete="username"
|
||||||
|
onkeydown={(e) => e.key === 'Enter' && submitLogin()}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
class="input"
|
||||||
|
type="password"
|
||||||
|
placeholder="Password"
|
||||||
|
bind:value={boot.loginPass}
|
||||||
|
disabled={boot.loginBusy}
|
||||||
|
autocomplete="current-password"
|
||||||
|
onkeydown={(e) => e.key === 'Enter' && submitLogin()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="btn"
|
||||||
|
onclick={submitLogin}
|
||||||
|
disabled={boot.loginBusy || !boot.loginUser.trim() || !boot.loginPass.trim()}
|
||||||
|
>
|
||||||
|
{boot.loginBusy ? 'Signing in…' : 'Sign in'}
|
||||||
|
</button>
|
||||||
|
<button class="btn btn--ghost" onclick={handleBypass}>Skip</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.overlay { position:fixed; inset:0; z-index:10000; display:flex; align-items:center; justify-content:center; background:rgba(0,0,0,0.7); backdrop-filter:blur(6px); animation:overlayIn 0.28s cubic-bezier(0,0,0.2,1) both; }
|
||||||
|
.overlay--clear { background:transparent; backdrop-filter:none; pointer-events:none; }
|
||||||
|
.overlay--clear .card { pointer-events:auto; }
|
||||||
|
|
||||||
|
.card { width:min(280px, calc(100vw - 48px)); background:var(--bg-surface); border:1px solid var(--border-base); border-radius:var(--radius-xl); padding:var(--sp-6) var(--sp-5); display:flex; flex-direction:column; align-items:center; gap:var(--sp-3); box-shadow:0 32px 80px rgba(0,0,0,0.75); text-align:center; animation:cardIn 0.38s cubic-bezier(0.22,1,0.36,1) 0.06s both; }
|
||||||
|
|
||||||
|
.logo { width:56px; height:56px; border-radius:14px; display:block; position:relative; }
|
||||||
|
|
||||||
|
.title { font-family:var(--font-ui); font-size:11px; font-weight:500; letter-spacing:0.26em; text-transform:uppercase; color:var(--text-secondary); margin:-6px 0 0; user-select:none; }
|
||||||
|
.mode-badge { font-family:var(--font-ui); font-size:var(--text-2xs); letter-spacing:var(--tracking-wider); text-transform:uppercase; color:var(--accent-fg); background:var(--accent-muted); border:1px solid var(--accent-dim); border-radius:var(--radius-full); padding:2px 10px; }
|
||||||
|
.host { font-family:var(--font-ui); font-size:var(--text-xs); color:var(--text-faint); letter-spacing:var(--tracking-wide); margin:-4px 0 0; }
|
||||||
|
.error { font-family:var(--font-ui); font-size:var(--text-xs); color:var(--color-error); background:var(--color-error-bg); border:1px solid var(--color-error); border-radius:var(--radius-sm); padding:var(--sp-2) var(--sp-3); width:100%; box-sizing:border-box; }
|
||||||
|
|
||||||
|
.fields { display:flex; flex-direction:column; gap:var(--sp-2); width:100%; }
|
||||||
|
.input { width:100%; background:var(--bg-raised); border:1px solid var(--border-strong); border-radius:var(--radius-md); padding:8px 12px; font-size:var(--text-sm); color:var(--text-primary); outline:none; box-sizing:border-box; transition:border-color var(--t-base), box-shadow var(--t-base); font-family:inherit; }
|
||||||
|
.input:focus { border-color:var(--border-focus); box-shadow:0 0 0 2px color-mix(in srgb, var(--accent) 20%, transparent); }
|
||||||
|
.input:disabled { opacity:0.5; }
|
||||||
|
|
||||||
|
.btn { width:100%; padding:9px; border-radius:var(--radius-md); background:var(--accent); border:1px solid var(--accent); color:var(--accent-fg); font-size:var(--text-sm); font-family:var(--font-ui); letter-spacing:var(--tracking-wide); cursor:pointer; transition:opacity var(--t-base); }
|
||||||
|
.btn:hover:not(:disabled) { opacity:0.85; }
|
||||||
|
.btn:disabled { opacity:0.35; cursor:default; }
|
||||||
|
.btn--ghost { background:none; border-color:transparent; color:var(--text-faint); font-size:var(--text-xs); padding:4px; }
|
||||||
|
.btn--ghost:hover:not(:disabled) { color:var(--text-muted); opacity:1; }
|
||||||
|
|
||||||
|
@keyframes overlayIn { from { opacity:0 } to { opacity:1 } }
|
||||||
|
@keyframes cardIn { from { opacity:0; transform:translateY(28px) scale(0.97) } to { opacity:1; transform:translateY(0) scale(1) } }
|
||||||
|
@keyframes anim-scale-in { from { opacity:0; transform:scale(0.96) } to { opacity:1; transform:scale(1) } }
|
||||||
|
.anim-scale-in { animation:anim-scale-in 0.2s cubic-bezier(0,0,0.2,1) both; }
|
||||||
|
</style>
|
||||||