Compare commits

..

49 Commits

Author SHA1 Message Date
Youwes09 5c703bdba5 Chore: Bump to v0.10.0 2026-06-12 17:59:11 -05:00
Youwes09 a041b182e5 Feat: Static & Flatpak Workflow + Recent Fix 2026-06-12 17:52:09 -05:00
Youwes09 9dad1fb329 Feat: Recent Tab (Unread State) + Bug Fixes 2026-06-12 17:27:08 -05:00
Youwes09 31a19687ce Fix: Browse Bug Fixes & Enhancements 2026-06-12 04:12:33 -05:00
Youwes09 437b52fd8b Feat: Longstrip Viewer(s) & Lag Improvements 2026-06-11 23:27:01 -05:00
Youwes09 1e159bbd73 Fix: Pass all Template Arguments on BugReporter 2026-06-10 13:12:25 -05:00
Youwes09 cb3d8d64fa Fix: DiscordRPC Toggle (Un-tested) 2026-06-10 13:06:46 -05:00
Youwes09 c0a95ff899 Fix: Github Workflows (P.2) 2026-06-10 12:59:08 -05:00
Youwes09 ddaca9d126 Fix: Workflow Failure (P.1) 2026-06-10 12:55:30 -05:00
Youwes09 77b28e97a4 Feat: Bug Reporter (Github Issue Templates) 2026-06-10 12:49:48 -05:00
Youwes09 f10b343108 Fix: Library Mappings 2026-06-09 22:57:50 -05:00
Youwes09 a8ad9034fc Fix: Reader Longstrip Bookmark + ProgressBar 2026-06-09 22:52:11 -05:00
Shozikan f99fa60e8e Merge pull request #95 from frozenKelp/main
fix: idle splash, Discord RPC, memory leaks, reader navigation, cover art, settings scroll
2026-06-09 21:11:29 -05:00
Youwes09 915ff66b2f Chore: ModalBlur Component 2026-06-09 21:08:57 -05:00
Youwes09 abd60f261f Fix: Splashscreen Idle & Dev 2026-06-09 19:24:16 -05:00
Youwes09 fc20835dde Fix: SplashScreen MemoryLeak + WebUI Bypass 2026-06-09 14:54:12 -05:00
frozenKelp 04631d93ef fix: settings modal scroll layer will-change 2026-06-09 21:45:39 +05:30
frozenKelp 5c09cd15ad fix: settings scroll parallax between text and row backgrounds 2026-06-09 20:59:52 +05:30
frozenKelp 7af69fd77c fix: manga detail page shows correct cover instead of stale one 2026-06-09 20:58:56 +05:30
frozenKelp 0b6372bd17 fix: X closes reader to origin page, not previous chapter 2026-06-09 20:58:43 +05:30
frozenKelp 32bdeb92ff fix: set rpc to idle when idle 2026-06-09 20:13:41 +05:30
frozenKelp 22c4a222d8 fix: idle splash exit animation works 2026-06-09 19:43:02 +05:30
frozenKelp 26cb16ec0f Merge branch 'main' of https://github.com/moku-project/Moku 2026-06-09 19:16:10 +05:30
Youwes09 8c2917b698 Chore: Update Server Version 2026-06-09 08:44:09 -05:00
frozenKelp 6d33fb7ae1 fix: make weel and touch passive 2026-06-09 19:10:52 +05:30
frozenKelp 0e7ff1a27c fix: add wheel and touchmove to idle activity listeners 2026-06-09 16:29:48 +05:30
frozenKelp 685bd9b9da fix: revoke page blob URLs on chapter change and reader close 2026-06-09 16:15:31 +05:30
frozenKelp 3926b5d064 chore: clean up discord RPC hooks 2026-06-09 15:16:27 +05:30
frozenKelp 9f6996dcdb fix: read idleTimeoutMin from settings; clean up idle + splash canvas fixes 2026-06-09 15:04:18 +05:30
frozenKelp 294865fe9d style: use specific imports for discord functions 2026-06-09 10:55:22 +05:30
frozenKelp 13e760594d fix: sync libraryShowAllInSaved setting to library state 2026-06-09 10:54:20 +05:30
frozenKelp b44b12ba86 fix: update discord rpc to emit presence when reading chapters 2026-06-09 10:53:34 +05:30
Youwes09 3b8c8dea38 Fix: WebUI Auth & Tauri Auth 2026-06-08 20:27:22 -05:00
Youwes09 615fa1e92f Chore: GQL Cleanup P.3 2026-06-07 18:37:52 -05:00
Youwes09 248b046627 Fix: Home-Screen Recommendations & GQL Cleanup P.2 2026-06-07 15:40:18 -05:00
Youwes09 79e5548879 Fix: Library Filtering + GQL Cleanup P.1 2026-06-07 00:18:45 -05:00
Youwes09 ed4c11ca7e Fix: Local-Source Popular Query + App-Pin Flow 2026-06-06 15:00:59 -05:00
Youwes09 5dfbc80bbe Fix: App Pin & Downloads (Filesystem Changes) 2026-06-05 17:42:32 -05:00
Youwes09 8aa92e6b54 Chore: Update Dependencies 2026-06-03 22:38:07 -05:00
Youwes09 b55dd16d0d Fix: Update to FetcherVersion 3 for NixOS 2026-06-03 22:12:42 -05:00
Youwes09 7c6aeb8f4c Chore: Re-make README + Stack Update 2026-06-03 21:49:01 -05:00
Youwes09 3e4d322fb7 Chore: Fix Nix Build (Improper) 2026-06-03 21:37:34 -05:00
Youwes09 db8a984270 Chore: Remove Old Directory (Prepare for Patches) 2026-06-02 20:04:01 -05:00
Youwes09 18027baee1 Chore: Completed Splash-Screen & Iniital Tauri Wire-Up 2026-06-02 08:27:37 -05:00
Youwes09 c5243ba30c Chore: Port over Reader & Tracking 2026-05-31 21:14:25 -05:00
Youwes09 13f2a483ca Chore: Port over Extensions & Search 2026-05-31 00:30:36 -05:00
Youwes09 6de5207ce7 Chore: Port over SeriesDetail + Panels 2026-05-29 20:07:07 -05:00
Youwes09 8c250021a0 Chore: Port over SeriesDetail (WIP Panels) 2026-05-28 23:05:02 -05:00
Zerebos 584b917f98 fix: Data-theme attribute on document body 2026-05-25 20:59:24 -04:00
367 changed files with 16244 additions and 30453 deletions
+78
View File
@@ -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.
+5
View File
@@ -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.
+18
View File
@@ -0,0 +1,18 @@
# 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
_nix="$( cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd )/nix/versions.nix"
_t=$(cat "$_nix")
_pick() { echo "$_t" | grep -oP "${1}\s*=\s*\"\K[^\"]+"; }
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
+80
View File
@@ -0,0 +1,80 @@
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
- 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, SDK and rust-stable extension
run: |
flatpak --user install -y --noninteractive flathub \
org.gnome.Platform//48 \
org.gnome.Sdk//48 \
org.freedesktop.Sdk.Extension.rust-stable//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: |
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/octet-stream" \
--data-binary @"moku.flatpak" \
"https://uploads.github.com/repos/moku-project/Moku/releases/$RELEASE_ID/assets?name=moku.flatpak"
+36 -79
View File
@@ -16,24 +16,15 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: latest
with: { version: latest }
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build
run: pnpm build
- name: Upload dist
uses: actions/upload-artifact@v4
- run: pnpm install --frozen-lockfile
- run: pnpm build:static
- uses: actions/upload-artifact@v4
with:
name: frontend-dist-linux
path: dist/
@@ -43,77 +34,56 @@ jobs:
name: Tauri (Linux x64)
needs: frontend
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- name: Download frontend dist
uses: actions/download-artifact@v4
with:
name: frontend-dist-linux
path: dist/
- 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
libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libfuse2
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
with:
targets: x86_64-unknown-linux-gnu
- uses: dtolnay/rust-toolchain@stable
with: { targets: x86_64-unknown-linux-gnu }
- name: Rust cache
uses: Swatinem/rust-cache@v2
with:
workspaces: src-tauri
- uses: Swatinem/rust-cache@v2
with: { workspaces: src-tauri }
- uses: pnpm/action-setup@v4
with:
version: latest
with: { version: latest }
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- name: Install JS dependencies
run: pnpm install --frozen-lockfile
- run: pnpm install --frozen-lockfile
- name: Download Suwayomi (Linux x64)
run: |
curl -fsSL \
"https://github.com/Suwayomi/Suwayomi-Server-preview/releases/download/v2.1.2087/Suwayomi-Server-v2.1.2087-linux-x64.tar.gz" \
"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 "888bee202649ce7e3e3468a729c4084fb465f024b4033cab3f8ab98b0c66fe76 suwayomi-linux.tar.gz" | sha256sum -c -
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
JAR="suwayomi-extracted/bin/Suwayomi-Server.jar"
JAVA="suwayomi-extracted/jre/bin/java"
CATCH="suwayomi-extracted/bin/catch_abort.so"
for f in "$JAR" "$JAVA" "$CATCH"; do
if [ ! -e "$f" ]; then
echo "ERROR: expected file not found: $f"
find suwayomi-extracted -type f | head -40
exit 1
fi
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
echo "JAR=$JAR JAVA=$JAVA CATCH=$CATCH"
cp -r suwayomi-extracted src-tauri/binaries/suwayomi-bundle
chmod +x src-tauri/binaries/suwayomi-bundle/jre/bin/java
@@ -129,43 +99,30 @@ jobs:
- 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"
env: { NO_STRIP: "true" }
- name: Upload Linux artifacts to release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ github.event.inputs.version }}
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'"$VERSION"'") | .id' | head -1)
if [ -n "$RELEASE_ID" ]; then break; fi
echo "Waiting for release to exist... attempt $i"
sleep 15
| 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; }
if [ -z "$RELEASE_ID" ]; then
echo "ERROR: Could not find release for v$VERSION after waiting"
exit 1
fi
echo "Found release ID: $RELEASE_ID"
upload_asset() {
local file="$1"
local name="$2"
echo "Uploading $name..."
upload() {
curl -s -X POST \
-H "Authorization: Bearer $GITHUB_TOKEN" \
-H "Content-Type: application/octet-stream" \
--data-binary @"$file" \
"https://uploads.github.com/repos/moku-project/Moku/releases/$RELEASE_ID/assets?name=$name"
--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_asset "$APPIMAGE" "moku-linux-x64-${VERSION}.AppImage"
[ -n "$DEB" ] && upload_asset "$DEB" "moku-linux-x64-${VERSION}.deb"
[ -n "$APPIMAGE" ] && upload "$APPIMAGE" "moku-linux-x64-${{ github.event.inputs.version }}.AppImage"
[ -n "$DEB" ] && upload "$DEB" "moku-linux-x64-${{ github.event.inputs.version }}.deb"
+56 -114
View File
@@ -4,7 +4,7 @@ on:
workflow_dispatch:
inputs:
version:
description: "Version to build (e.g. 0.4.0)"
description: "Version to build (e.g. 0.9.0)"
required: true
permissions:
@@ -16,28 +16,16 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: latest
with: { version: latest }
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build
run: pnpm build
- name: Upload dist
uses: actions/upload-artifact@v4
with:
name: frontend-dist
path: dist/
retention-days: 1
- run: pnpm install --frozen-lockfile
- run: pnpm build:static
- uses: actions/upload-artifact@v4
with: { name: frontend-dist, path: dist/, retention-days: 1 }
tauri:
name: Tauri (macOS)
@@ -46,149 +34,103 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Download frontend dist
uses: actions/download-artifact@v4
with:
name: frontend-dist
path: dist/
- uses: actions/download-artifact@v4
with: { name: frontend-dist, path: dist/ }
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
with:
targets: aarch64-apple-darwin,x86_64-apple-darwin
- name: Read versions
run: |
source .github/read_versions.sh
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: Swatinem/rust-cache@v2
- uses: dtolnay/rust-toolchain@stable
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
with:
version: latest
with: { version: latest }
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- name: Install JS dependencies
run: pnpm install --frozen-lockfile
- run: pnpm install --frozen-lockfile
- name: Download Suwayomi binaries
run: |
download_suwayomi() {
dl() {
local asset="$1" sha="$2" outdir="$3"
curl -fsSL \
"https://github.com/Suwayomi/Suwayomi-Server-preview/releases/download/v2.1.2087/${asset}" \
"https://github.com/Suwayomi/Suwayomi-Server-preview/releases/download/v${SUWA_VERSION}/${asset}" \
-o "${outdir}.tar.gz"
echo "${sha} ${outdir}.tar.gz" | shasum -a 256 -c -
mkdir -p "${outdir}"
tar -xzf "${outdir}.tar.gz" -C "${outdir}" --strip-components=1
}
download_suwayomi \
"Suwayomi-Server-v2.1.2087-macOS-arm64.tar.gz" \
"59f73a53a139d5d843e16cab4f3ac425a410add6bee0a60920fa26eb0a4b8a5c" \
"suwayomi-arm64"
download_suwayomi \
"Suwayomi-Server-v2.1.2087-macOS-x64.tar.gz" \
"da7e664e4c2615a0b9eac09ee38fe979feee1d6c0b266e19dba1ceea8ae3795c" \
"suwayomi-x64"
dl "Suwayomi-Server-v${SUWA_VERSION}-macOS-arm64.tar.gz" "$SUWA_HASH_ARM64" suwayomi-arm64
dl "Suwayomi-Server-v${SUWA_VERSION}-macOS-x64.tar.gz" "$SUWA_HASH_X64" suwayomi-x64
- name: Stage Suwayomi sidecars
run: |
mkdir -p src-tauri/binaries
stage_arch() {
local srcdir="$1"
local arch="$2"
local sidecar="src-tauri/binaries/suwayomi-server-${arch}"
local bundle_dest="src-tauri/binaries/suwayomi-bundle-${arch}"
stage() {
local srcdir="$1" arch="$2"
JAR=$(find "$srcdir" -name "Suwayomi-Server.jar" | head -1)
JAVA=$(find "$srcdir" -path "*/jre/bin/java" | head -1)
if [ -z "$JAR" ]; then
echo "ERROR: Suwayomi-Server.jar not found in $srcdir"
find "$srcdir" -type f | head -30
exit 1
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"
[ -z "$JAR" ] && { echo "ERROR: jar not found in $srcdir"; find "$srcdir" -type f | head -30; exit 1; }
[ -z "$JAVA" ] && { echo "ERROR: java not found in $srcdir"; find "$srcdir" -type f | head -30; exit 1; }
cp -r "$srcdir" "src-tauri/binaries/suwayomi-bundle-${arch}"
cp src-tauri/binaries/suwayomi-launcher.sh "src-tauri/binaries/suwayomi-server-${arch}"
chmod +x "src-tauri/binaries/suwayomi-server-${arch}"
}
stage_arch suwayomi-arm64 aarch64-apple-darwin
stage_arch suwayomi-x64 x86_64-apple-darwin
stage suwayomi-arm64 aarch64-apple-darwin
stage suwayomi-x64 x86_64-apple-darwin
- name: Patch tauri.conf.json for CI
run: |
sed -i '' 's/"beforeBuildCommand": "pnpm build"/"beforeBuildCommand": ""/' src-tauri/tauri.conf.json
- name: Swap bundle for aarch64
- name: Build Tauri app (aarch64)
run: |
rm -rf src-tauri/binaries/suwayomi-bundle
cp -r src-tauri/binaries/suwayomi-bundle-aarch64-apple-darwin \
src-tauri/binaries/suwayomi-bundle
- name: Build Tauri app (aarch64)
run: pnpm tauri build --target aarch64-apple-darwin --config src-tauri/tauri.macos.conf.json
cp -r src-tauri/binaries/suwayomi-bundle-aarch64-apple-darwin src-tauri/binaries/suwayomi-bundle
pnpm tauri build --target aarch64-apple-darwin --config src-tauri/tauri.macos.conf.json
env:
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY || '-' }}
- name: Swap bundle for x86_64
- name: Build Tauri app (x86_64)
run: |
rm -rf src-tauri/binaries/suwayomi-bundle
cp -r src-tauri/binaries/suwayomi-bundle-x86_64-apple-darwin \
src-tauri/binaries/suwayomi-bundle
- name: Build Tauri app (x86_64)
run: pnpm tauri build --target x86_64-apple-darwin --config src-tauri/tauri.macos.conf.json
cp -r src-tauri/binaries/suwayomi-bundle-x86_64-apple-darwin src-tauri/binaries/suwayomi-bundle
pnpm tauri build --target x86_64-apple-darwin --config src-tauri/tauri.macos.conf.json
env:
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY || '-' }}
- name: Upload macOS artifacts to release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ github.event.inputs.version }}
run: |
# Wait for the Windows workflow to have created the draft release
for i in $(seq 1 12); do
RELEASE_ID=$(curl -s -H "Authorization: Bearer $GITHUB_TOKEN" "https://api.github.com/repos/moku-project/Moku/releases" | jq -r '.[] | select(.tag_name == "v'"$VERSION"'") | .id' | head -1)
if [ -n "$RELEASE_ID" ]; then break; fi
echo "Waiting for release to exist... attempt $i"
sleep 15
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; }
if [ -z "$RELEASE_ID" ]; then
echo "ERROR: Could not find release for v$VERSION after waiting"
exit 1
fi
echo "Found release ID: $RELEASE_ID"
upload_asset() {
local file="$1"
local name="$2"
echo "Uploading $name..."
curl -s -X POST -H "Authorization: Bearer $GITHUB_TOKEN" -H "Content-Type: application/octet-stream" --data-binary @"$file" "https://uploads.github.com/repos/moku-project/Moku/releases/$RELEASE_ID/assets?name=$name"
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_DMG=$(find src-tauri/target/aarch64-apple-darwin/release/bundle/dmg -name "*.dmg" | head -1)
X64_DMG=$(find src-tauri/target/x86_64-apple-darwin/release/bundle/dmg -name "*.dmg" | head -1)
[ -n "$ARM64_DMG" ] && upload_asset "$ARM64_DMG" "moku-macos-arm64-${VERSION}.dmg"
[ -n "$X64_DMG" ] && upload_asset "$X64_DMG" "moku-macos-x64-${VERSION}.dmg"
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"
+51
View File
@@ -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: latest }
- 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"
+34 -77
View File
@@ -16,120 +16,81 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: latest
with: { version: latest }
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build
run: pnpm build
- name: Upload dist
uses: actions/upload-artifact@v4
with:
name: frontend-dist-windows
path: dist/
retention-days: 1
- run: pnpm install --frozen-lockfile
- run: pnpm build:static
- uses: actions/upload-artifact@v4
with: { name: frontend-dist-windows, path: dist/, retention-days: 1 }
tauri:
name: Tauri (Windows x64)
needs: frontend
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- name: Download frontend dist
uses: actions/download-artifact@v4
with:
name: frontend-dist-windows
path: dist/
- uses: actions/download-artifact@v4
with: { name: frontend-dist-windows, path: dist/ }
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
with:
targets: x86_64-pc-windows-msvc
- name: Read versions
shell: bash
run: |
source .github/read_versions.sh
echo "SUWA_VERSION=$SUWA_VERSION" >> $GITHUB_ENV
echo "SUWA_HASH=$SUWA_HASH_WINDOWS" >> $GITHUB_ENV
- name: Rust cache
uses: Swatinem/rust-cache@v2
with:
workspaces: src-tauri
- uses: dtolnay/rust-toolchain@stable
with: { targets: x86_64-pc-windows-msvc }
- uses: Swatinem/rust-cache@v2
with: { workspaces: src-tauri }
- uses: pnpm/action-setup@v4
with:
version: latest
with: { version: latest }
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- name: Install JS dependencies
run: pnpm install --frozen-lockfile
- run: pnpm install --frozen-lockfile
- name: Download Suwayomi (Windows x64)
shell: bash
run: |
curl -fsSL \
"https://github.com/Suwayomi/Suwayomi-Server-preview/releases/download/v2.1.2087/Suwayomi-Server-v2.1.2087-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
echo "65c3ec544190bc4e52f8ba05b49c87448421d9825aaaeb902cb4e34e69ff7207 suwayomi-windows.zip" | sha256sum -c -
echo "${SUWA_HASH} suwayomi-windows.zip" | sha256sum -c -
unzip -q suwayomi-windows.zip -d suwayomi-raw
- name: Extract Suwayomi bundle
- name: Stage Suwayomi bundle
shell: bash
run: |
mkdir -p suwayomi-extracted
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)
if [ "$TOP_DIRS" -eq 1 ] && [ "$TOP_FILES" -eq 0 ]; then
INNER=$(find suwayomi-raw -mindepth 1 -maxdepth 1 -type d | head -1)
cp -r "$INNER"/. suwayomi-extracted/
cp -r "$(find suwayomi-raw -mindepth 1 -maxdepth 1 -type d | head -1)"/. suwayomi-extracted/
else
cp -r suwayomi-raw/. suwayomi-extracted/
fi
- name: Stage Suwayomi bundle
shell: bash
run: |
mkdir -p src-tauri/binaries
JAVA=$(find suwayomi-extracted -path "*/jre/bin/java.exe" | head -1)
JAR=$(find suwayomi-extracted -name "Suwayomi-Server.jar" | head -1)
if [ -z "$JAVA" ]; then
echo "ERROR: jre/bin/java.exe not found"
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
find suwayomi-extracted -path "*/jre/bin/java.exe" | grep -q . \
|| { echo "ERROR: java.exe not found"; find suwayomi-extracted -type f | head -50; exit 1; }
find suwayomi-extracted -name "Suwayomi-Server.jar" | grep -q . \
|| { echo "ERROR: Suwayomi-Server.jar not found"; find suwayomi-extracted -type f | head -50; exit 1; }
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
shell: bash
run: |
sed -i 's/"beforeBuildCommand": "pnpm build"/"beforeBuildCommand": ""/' src-tauri/tauri.conf.json
- name: Delete existing draft release if present
- name: Delete existing draft release
shell: bash
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -138,14 +99,10 @@ jobs:
"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
echo "Deleting existing draft release $RELEASE_ID"
curl -s -X DELETE -H "Authorization: Bearer $GITHUB_TOKEN" \
"https://api.github.com/repos/moku-project/Moku/releases/$RELEASE_ID"
curl -s -X DELETE -H "Authorization: Bearer $GITHUB_TOKEN" \
"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
- name: Build Tauri app + create draft release
@@ -158,10 +115,10 @@ jobs:
releaseBody: |
Moku v${{ github.event.inputs.version }}
**Windows:** Download `Moku_${{ github.event.inputs.version }}_x64-setup.exe`
**macOS arm64:** Download `moku-macos-arm64-${{ github.event.inputs.version }}.dmg`
**macOS x64:** Download `moku-macos-x64-${{ github.event.inputs.version }}.dmg`
**Linux:** Download `moku.flatpak`
**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
prerelease: false
args: --target x86_64-pc-windows-msvc --config src-tauri/tauri.windows.conf.json
args: --target x86_64-pc-windows-msvc --config src-tauri/tauri.windows.conf.json
+1 -1
View File
@@ -1,5 +1,5 @@
pkgname=moku
pkgver=0.9.4
pkgver=0.10.0
pkgrel=1
pkgdesc="Native Linux manga reader frontend for Suwayomi-Server"
arch=('x86_64')
+157 -26
View File
@@ -1,42 +1,173 @@
# sv
<div align="center">
<img src="docs/banner.svg" width="100%" alt="Moku" />
</div>
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
<div align="center">
## Creating a project
[![Release](https://www.shieldcn.dev/github/release/moku-project/Moku.svg?variant=outline&size=default)](https://github.com/moku-project/Moku/releases/latest)
![GitHub Downloads](https://www.shieldcn.dev/github/downloads/moku-project/Moku.svg?variant=outline&size=default)
[![Stars](https://www.shieldcn.dev/github/stars/moku-project/Moku.svg?variant=outline&size=default)](https://github.com/moku-project/Moku)
[![Discord](https://www.shieldcn.dev/discord/members/x97hj8zR72.svg?variant=outline&size=default)](https://discord.gg/x97hj8zR72)
If you're seeing this, you've probably already done this step. Congrats!
</div>
```sh
# create a new project
npx sv create my-app
<br/>
Moku is a fast, minimal manga reader frontend for [Suwayomi-Server](https://github.com/Suwayomi/Suwayomi-Server). It wraps Suwayomi's GraphQL API in a lightweight Tauri app — no Electron overhead.
---
## Screenshots
<div align="center">
<img src="docs/screenshots/Moku-Home.png" width="100%" alt="Home" />
</div>
<div align="center">
<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-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 align="center">
<a href="docs/screenshots">View all screenshots →</a>
</div>
---
## Features
- **Library management** — organize manga into folders, track unread counts, filter by genre
- **Per-folder sorting & filtering** — each folder has its own independent sort (unread, AZ, 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
- **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
- **Download management** — queue and monitor chapter downloads with progress toasts
- **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
- **Multiple themes** — Dark, Light, Midnight, Warm, High Contrast, and more
- **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
<div align="center">
![Runs on Windows](https://www.shieldcn.dev/badge/Runs%20on-Windows-0078D4.svg?logo=windows&logoColor=fff)
![Runs on Linux](https://www.shieldcn.dev/badge/Runs%20on-Linux-FCC624.svg?logo=linux&logoColor=000)
![Runs on macOS](https://www.shieldcn.dev/badge/Runs%20on-MacOS-000000.svg?mode=light&logo=apple&logoColor=fff)
</div>
### Windows
**winget:**
```powershell
winget install Moku.Moku
```
To recreate this project with the same configuration:
> Thanks to [@frozenKelp](https://github.com/frozenKelp) for setting up and maintaining the winget package through v0.9.0.
```sh
# recreate this project
pnpm dlx sv@0.15.3 create --template minimal --types ts --install pnpm .
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.
```bash
flatpak install io.github.moku_app.Moku
```
## Developing
Or download the latest `moku.flatpak` from the [releases page](https://github.com/moku-project/Moku/releases/latest) and install manually:
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
```sh
npm run dev
# or start the server and open the app in a new browser tab
npm run dev -- --open
```bash
flatpak install moku.flatpak
```
## Building
### Nix
To create a production version of your app:
```sh
npm run build
```bash
nix run github:moku-project/Moku
```
You can preview the production build with `npm run preview`.
Add to your flake:
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
```nix
inputs.moku.url = "github:moku-project/Moku";
```
### macOS
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:
> ```bash
> xattr -rd com.apple.quarantine /Applications/Moku.app
> ```
---
## Requirements
If you're not using the bundled Flatpak or Windows installer, [Suwayomi-Server](https://github.com/Suwayomi/Suwayomi-Server) must be running separately. By default Moku connects to `http://127.0.0.1:4567`.
You can point Moku at any Suwayomi instance — local or remote — via **Settings → General → Server URL**.
---
## Development
**Prerequisites:** [Rust](https://rustup.rs), [Node.js](https://nodejs.org), [pnpm](https://pnpm.io), and [Tauri v2 prerequisites](https://tauri.app/start/prerequisites/).
```bash
git clone https://github.com/moku-project/Moku
cd Moku
pnpm install
pnpm tauri:dev
```
Or with Nix:
```bash
nix develop
pnpm install
pnpm tauri:dev
```
---
## Stack
| | |
|---|---|
| [Tauri v2](https://tauri.app) | Native app shell |
| [Svelte 5](https://svelte.dev) + [SvelteKit 2](https://kit.svelte.dev) + [TypeScript](https://www.typescriptlang.org) | UI |
| [Vite 8](https://vitejs.dev) | Frontend bundler |
| [Nixpkgs stdenv](https://nixos.org/manual/nixpkgs/stable/) | Nix builds |
---
## Community
Questions, feedback, or just want to hang out — join the Discord.
[![Discord](https://www.shieldcn.dev/discord/members/x97hj8zR72.svg?variant=secondary&size=large)](https://discord.gg/x97hj8zR72)
---
## License
Distributed under the [Apache 2.0 License](./LICENSE).
---
## Disclaimer
Moku does not host or distribute any content. The developers have no affiliation with any content providers accessible through connected sources.
+2 -37
View File
@@ -1,39 +1,4 @@
Major Revisions:
- Moku-Share to Easily Migrate/Share Manga (Investigate Usecase/Feasibility)
- Moku-Share allows exporting of Manga
- Compressed Format (Storage)
- Import as Local-Source
- Takes existing Local-Source or Creates Own
Revival of the TODO List!!!!!
Minor Revisions:
- Investigate feasibility of Multi-Page Screenshot (Reader)
- Revise Migration (https://github.com/Suwayomi/Suwayomi-WebUI/pull/1073)
Priority Bugs:
- Fix Library-Refresh System (TESTING)
- Suwayomi RESET
- Allow User to Wipe Suwayomi (Scratch)
- If Possible, Component based Wipe (Library, Etc)
Pending/On-Hold:
- Enable Cloudflare Bypass (Suwayomi Config) (Requires Patching)
- Working on 3D Display Cards
- Add Flathub Support (Pending Video)
- Change Auto-Link Threshold
- Fix Auto-Link De-dupe for Images
- Optimize Auto-Link Latency (IP)
In-Progress:
- Fix Tracking Login
- Pasting OAuth URL is not User-Friendly, Look for Alternatives
- Apply Syer's Fix for Library on Backup Load (Manga Metadata)
- Note User's have to always install extensions manually
- Create "Missing Source" for Manga
- Wire Series-Detail Refresh to Fix Manga-Metadata (MAJOR)
- UI LOGIN DOES NOT WORK OFFLINE
Notes from last time:
- Reminder to Completely Test Settings
-364
View File
@@ -1,364 +0,0 @@
<script lang="ts">
import { onMount } from "svelte";
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { platform } from "@tauri-apps/plugin-os";
import { store, updateSettings, setActiveDownloads } from "@store/state.svelte";
import { downloadStore } from "@features/downloads/store/downloadState.svelte";
import { boot, initStore, startProbe, stopProbe, retryBoot, bypassBoot } from "@store/boot.svelte";
import { initRpc, setIdle, clearReading, destroyRpc } from "@store/discord";
import { applyTheme } from "@core/theme";
import { applyZoom, mountZoomKey, mountIdleDetection } from "@core/ui";
import { checkForUpdateSilently } from "@core/updater";
import Layout from "@shared/chrome/Layout.svelte";
import Reader from "@features/reader/components/Reader.svelte";
import Settings from "@features/settings/components/Settings.svelte";
import ThemeEditor from "@features/settings/components/ThemeEditor.svelte";
import TitleBar from "@shared/chrome/TitleBar.svelte";
import Toaster from "@shared/chrome/Toaster.svelte";
import SplashScreen from "@shared/chrome/SplashScreen.svelte";
import MangaPreview from "@shared/manga/MangaPreview.svelte";
import AuthGate from "@shared/chrome/AuthGate.svelte";
const win = getCurrentWindow();
void platform();
let appReady = $state(false);
let idle = $state(false);
let devSplash = $state(false);
let themeEditorOpen = $state(false);
let themeEditorEditId = $state<string | null>(null);
let closeDialogOpen = $state(false);
let closeRemember = $state(false);
function openThemeEditor(id?: string | null) {
themeEditorEditId = id ?? null;
themeEditorOpen = true;
}
function closeThemeEditor() {
themeEditorOpen = false;
themeEditorEditId = null;
}
async function doQuit() {
if (store.settings.autoStartServer) {
await Promise.race([
invoke("kill_server").catch(() => {}),
new Promise(res => setTimeout(res, 2000)),
]);
}
await invoke("exit_app");
}
async function doHide() {
await win.hide();
}
async function handleCloseRequested() {
const action = store.settings.closeAction ?? "ask";
if (action === "tray") { await doHide(); return; }
if (action === "quit") { await doQuit(); return; }
closeDialogOpen = true;
}
async function confirmClose(choice: "tray" | "quit") {
closeDialogOpen = false;
if (closeRemember) updateSettings({ closeAction: choice });
closeRemember = false;
if (choice === "tray") await doHide();
else await doQuit();
}
$effect(() => { void store.settings.theme; applyTheme(); });
$effect(() => { void store.settings.uiZoom; applyZoom(); });
$effect(() => mountZoomKey());
$effect(() => {
if (!appReady) return;
return mountIdleDetection(
() => { idle = true; },
() => { if (idle) idle = false; },
);
});
$effect(() => {
if (!appReady) return;
const timer = setTimeout(checkForUpdateSilently, 5_000);
return () => clearTimeout(timer);
});
$effect(() => {
if (!appReady) return;
downloadStore.poll();
const dlInterval = setInterval(() => downloadStore.poll(), 2000);
return () => clearInterval(dlInterval);
});
$effect(() => {
if (store.settings.discordRpc) {
initRpc();
} else {
clearReading();
destroyRpc();
}
});
$effect(() => {
if (!store.activeChapter && store.settings.discordRpc) setIdle();
});
$effect(() => {
const next = downloadStore.queue.slice();
downloadStore.detectTransitions(next);
});
onMount(async () => {
document.addEventListener("contextmenu", e => e.preventDefault());
(window as any).__mokuShowSplash = () => { devSplash = true; };
applyZoom();
store.isFullscreen = await win.isFullscreen();
const unlistenResize = await win.onResized(async () => {
store.isFullscreen = await win.isFullscreen();
});
const unlistenScale = await win.onScaleChanged(async () => {
applyZoom();
});
const unlistenClose = await win.listen("tauri://close-requested", handleCloseRequested);
await initStore();
startProbe();
if (store.settings.autoStartServer) {
invoke<void>("spawn_server", {
binary: store.settings.serverBinary,
webUiEnabled: store.settings.suwayomiWebUI ?? false,
}).catch((err: any) => {
if (err?.kind === "NotConfigured") boot.notConfigured = true;
else console.warn("Could not start server:", err);
});
}
const unlistenDownload = await listen<{ chapterId: number; mangaId: number; progress: number }[]>(
"download-progress",
e => setActiveDownloads(e.payload),
);
return () => {
stopProbe();
unlistenResize();
unlistenScale();
unlistenDownload();
unlistenClose();
destroyRpc();
delete (window as any).__mokuShowSplash;
};
});
</script>
{#if devSplash}
<SplashScreen mode="idle" showFps showCards={store.settings.splashCards ?? true}
onDismiss={() => setTimeout(() => devSplash = false, 340)} />
{:else if !appReady && !boot.loginRequired}
<SplashScreen mode="loading" ringFull={boot.serverProbeOk}
failed={boot.failed} notConfigured={boot.notConfigured}
showCards={store.settings.splashCards ?? true}
onReady={() => { appReady = true; }}
onRetry={retryBoot}
onBypass={() => bypassBoot(() => { appReady = true; })} />
{:else if boot.loginRequired}
<SplashScreen mode="loading" ringFull={true} showCards={store.settings.splashCards ?? true} />
<AuthGate onReady={() => { appReady = true; }} />
{:else}
{#if idle && !store.activeChapter}
<SplashScreen mode="idle" showCards={store.settings.splashCards ?? true}
onDismiss={() => { idle = false; }} />
{/if}
{#if boot.sessionExpired}
<SplashScreen mode="loading" ringFull={true} showCards={store.settings.splashCards ?? true} />
<AuthGate onReady={() => { boot.sessionExpired = false; }} />
{/if}
<div id="app-shell" class="root">
{#if !store.activeChapter}<TitleBar onClose={handleCloseRequested} />{/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}
{#if closeDialogOpen}
<div class="close-backdrop" role="presentation" onclick={() => { closeDialogOpen = false; closeRemember = false; }}>
<div class="close-dialog" role="dialog" aria-modal="true" onclick={(e) => e.stopPropagation()}>
<div class="close-header">
<p class="close-title">Close Moku?</p>
<p class="close-sub">Choose how the app should exit.</p>
</div>
<div class="close-actions">
<button class="close-btn" onclick={() => confirmClose("tray")}>
<span class="close-btn-label">Minimize to Tray</span>
<span class="close-btn-desc">Keep running in the background</span>
</button>
<button class="close-btn close-btn-danger" onclick={() => confirmClose("quit")}>
<span class="close-btn-label">Quit</span>
<span class="close-btn-desc">Stop Moku entirely</span>
</button>
</div>
<button class="close-remember" onclick={() => closeRemember = !closeRemember}>
<span class="close-remember-toggle" class:on={closeRemember}><span class="close-remember-thumb"></span></span>
<span class="close-remember-label">Remember my choice</span>
</button>
</div>
</div>
{/if}
<style>
.root { display: flex; flex-direction: column; height: 100%; overflow: hidden; }
.content { flex: 1; overflow: hidden; }
.close-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.55);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
display: flex;
align-items: center;
justify-content: center;
z-index: var(--z-modal);
}
.close-dialog {
background: var(--bg-surface);
border: 1px solid var(--border-base);
border-radius: var(--radius-2xl);
padding: var(--sp-5);
display: flex;
flex-direction: column;
gap: var(--sp-3);
width: 300px;
box-shadow:
0 0 0 1px rgba(255,255,255,0.04) inset,
0 20px 60px rgba(0,0,0,0.65),
0 6px 20px rgba(0,0,0,0.35);
}
.close-header { display: flex; flex-direction: column; gap: 3px; }
.close-title {
font-family: var(--font-ui);
font-size: var(--text-base);
font-weight: var(--weight-medium);
color: var(--text-primary);
letter-spacing: var(--tracking-tight);
margin: 0;
}
.close-sub {
font-family: var(--font-ui);
font-size: var(--text-xs);
color: var(--text-faint);
letter-spacing: var(--tracking-wide);
margin: 0;
}
.close-actions { display: flex; flex-direction: column; gap: var(--sp-1); }
.close-btn {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 2px;
width: 100%;
padding: 10px var(--sp-3);
border-radius: var(--radius-lg);
border: 1px solid var(--border-dim);
background: var(--bg-raised);
cursor: pointer;
text-align: left;
transition: background var(--t-base), border-color var(--t-base);
}
.close-btn:hover { background: var(--bg-overlay); border-color: var(--border-strong); }
.close-btn-danger { border-color: color-mix(in srgb, var(--color-error) 30%, transparent); }
.close-btn-danger:hover { background: var(--color-error-bg); border-color: color-mix(in srgb, var(--color-error) 55%, transparent); }
.close-btn-danger .close-btn-label { color: var(--color-error); }
.close-btn-danger .close-btn-desc { color: color-mix(in srgb, var(--color-error) 55%, var(--text-faint)); }
.close-btn-label {
font-family: var(--font-ui);
font-size: var(--text-sm);
color: var(--text-secondary);
font-weight: var(--weight-medium);
}
.close-btn-desc {
font-family: var(--font-ui);
font-size: var(--text-2xs);
color: var(--text-faint);
letter-spacing: var(--tracking-wide);
}
.close-remember {
display: flex;
align-items: center;
gap: var(--sp-2);
padding: var(--sp-1) 0 0;
background: none;
border: none;
cursor: pointer;
user-select: none;
}
.close-remember-toggle {
position: relative;
width: 28px;
height: 16px;
border-radius: var(--radius-full);
border: 1px solid var(--border-strong);
background: var(--bg-overlay);
flex-shrink: 0;
transition: background var(--t-base), border-color var(--t-base);
}
.close-remember-toggle.on { background: var(--accent); border-color: var(--accent); }
.close-remember-thumb {
position: absolute;
top: 1px;
left: 1px;
width: 12px;
height: 12px;
border-radius: 50%;
background: var(--text-faint);
transition: transform var(--t-base), background var(--t-base);
}
.close-remember-toggle.on .close-remember-thumb {
transform: translateX(12px);
background: var(--bg-void);
}
.close-remember-label {
font-family: var(--font-ui);
font-size: var(--text-xs);
color: var(--text-faint);
letter-spacing: var(--tracking-wide);
}
</style>
-151
View File
@@ -1,151 +0,0 @@
import { store } from "@store/state.svelte";
import { fetchAuthenticated, AuthRequiredError, refreshUiAccessToken } from "../core/auth";
import { boot } from "@store/boot.svelte";
import { getBlobUrl } from "@core/cache/imageCache";
const DEFAULT_URL = "http://127.0.0.1:4567";
type ReauthResolver = () => void;
let _reauthQueue: ReauthResolver[] = [];
export function notifyReauthSuccess() {
const queue = _reauthQueue;
_reauthQueue = [];
queue.forEach(resolve => resolve());
}
function waitForReauth(): Promise<void> {
return new Promise(resolve => { _reauthQueue.push(resolve); });
}
export function getServerUrl(): string {
const url = store.settings.serverUrl;
return typeof url === "string" && url.trim() ? url.replace(/\/$/, "") : DEFAULT_URL;
}
export function plainThumbUrl(path: string): string {
if (!path) return "";
if (path.startsWith("http")) return path;
return `${getServerUrl()}${path}`;
}
export async function resolveImageUrl(path: string): Promise<string> {
if (!path) return "";
const url = path.startsWith("http") ? path : `${getServerUrl()}${path}`;
const mode = store.settings.serverAuthMode ?? "NONE";
if (mode === "NONE") return url;
return getBlobUrl(url);
}
export const thumbUrl = plainThumbUrl;
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 fetchAuthenticated(url, init, signal, boot.skipped);
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
return res;
} catch (e: any) {
if (e?.authRequired) throw e;
if (e?.name === "AbortError" || signal?.aborted) throw new DOMException("Aborted", "AbortError");
if (e instanceof AuthRequiredError) throw e;
if (i === retries - 1) throw e;
await abortableSleep(delayMs * Math.pow(1.5, i), signal);
}
}
throw new Error("unreachable");
}
export async function fetchImage(
path: string,
signal?: AbortSignal,
): Promise<{ src: string; revoke: () => void }> {
if (!path) return { src: "", revoke: () => {} };
const url = path.startsWith("http") ? path : `${getServerUrl()}${path}`;
const mode = store.settings.serverAuthMode ?? "NONE";
if (mode === "NONE") return { src: url, revoke: () => {} };
const res = await fetchWithRetry(url, { method: "GET" }, signal);
if (!res.ok) throw new Error(`Image fetch failed: ${res.status}`);
const blob = await res.blob();
const src = URL.createObjectURL(blob);
return { src, revoke: () => URL.revokeObjectURL(src) };
}
export async function gql<T>(
query: string,
variables?: Record<string, unknown>,
signal?: AbortSignal,
): Promise<T> {
const tryRefreshAndRetry = async (): Promise<T | null> => {
const mode = store.settings.serverAuthMode ?? "NONE";
if (mode !== "UI_LOGIN" || boot.skipped) return null;
const refreshed = await refreshUiAccessToken(true);
if (!refreshed) return null;
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
return attempt();
};
const attempt = async (): Promise<T> => {
const res = await fetchWithRetry(
`${getServerUrl()}/api/graphql`,
{ method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ query, variables }) },
signal,
);
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
if (!res.ok) {
if (res.status === 401 || res.status === 403) {
const retried = await tryRefreshAndRetry();
if (retried) return retried;
}
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) {
const isAuthError = json.errors.some(e => /unauthorized|unauthenticated/i.test(e.message));
if (isAuthError && !boot.skipped) {
const retried = await tryRefreshAndRetry();
if (retried) return retried;
boot.sessionExpired = true;
boot.loginRequired = true;
boot.loginUser = store.settings.serverAuthUser ?? "";
await waitForReauth();
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
return attempt();
}
throw new Error(json.errors[0].message);
}
return json.data;
};
return attempt();
}
-11
View File
@@ -1,11 +0,0 @@
export * from "./client";
export * from "./queries/manga";
export * from "./queries/chapters";
export * from "./queries/downloads";
export * from "./queries/extensions";
export * from "./queries/tracking";
export * from "./mutations/manga";
export * from "./mutations/chapters";
export * from "./mutations/downloads";
export * from "./mutations/extensions";
export * from "./mutations/tracking";
-64
View File
@@ -1,64 +0,0 @@
export const FETCH_CHAPTERS = `
mutation FetchChapters($mangaId: Int!) {
fetchChapters(input: { mangaId: $mangaId }) {
chapters {
id name chapterNumber sourceOrder isRead isDownloaded isBookmarked
pageCount mangaId uploadDate realUrl lastPageRead lastReadAt scanlator
}
}
}
`;
export const FETCH_CHAPTER_PAGES = `
mutation FetchChapterPages($chapterId: Int!) {
fetchChapterPages(input: { chapterId: $chapterId }) { pages }
}
`;
export const MARK_CHAPTER_READ = `
mutation MarkChapterRead($id: Int!, $isRead: Boolean!) {
updateChapter(input: { id: $id, patch: { isRead: $isRead } }) {
chapter { id isRead }
}
}
`;
export const MARK_CHAPTERS_READ = `
mutation MarkChaptersRead($ids: [Int!]!, $isRead: Boolean!) {
updateChapters(input: { ids: $ids, patch: { isRead: $isRead } }) {
chapters { id isRead }
}
}
`;
export const UPDATE_CHAPTERS_PROGRESS = `
mutation UpdateChaptersProgress($ids: [Int!]!, $isRead: Boolean, $isBookmarked: Boolean, $lastPageRead: Int) {
updateChapters(input: { ids: $ids, patch: { isRead: $isRead, isBookmarked: $isBookmarked, lastPageRead: $lastPageRead } }) {
chapters { id isRead isBookmarked lastPageRead }
}
}
`;
export const DELETE_DOWNLOADED_CHAPTERS = `
mutation DeleteDownloadedChapters($ids: [Int!]!) {
deleteDownloadedChapters(input: { ids: $ids }) {
chapters { id isDownloaded }
}
}
`;
export const SET_CHAPTER_META = `
mutation SetChapterMeta($chapterId: Int!, $key: String!, $value: String!) {
setChapterMeta(input: { meta: { chapterId: $chapterId, key: $key, value: $value } }) {
meta { key value }
}
}
`;
export const DELETE_CHAPTER_META = `
mutation DeleteChapterMeta($chapterId: Int!, $key: String!) {
deleteChapterMeta(input: { chapterId: $chapterId, key: $key }) {
meta { key value }
}
}
`;
-99
View File
@@ -1,99 +0,0 @@
const QUEUE_FRAGMENT = `
state
queue {
progress state tries
chapter {
id name pageCount mangaId
manga { id title thumbnailUrl }
}
}
`;
export const ENQUEUE_DOWNLOAD = `
mutation EnqueueDownload($chapterId: Int!) {
enqueueChapterDownload(input: { id: $chapterId }) {
downloadStatus { ${QUEUE_FRAGMENT} }
}
}
`;
export const ENQUEUE_CHAPTERS_DOWNLOAD = `
mutation EnqueueChaptersDownload($chapterIds: [Int!]!) {
enqueueChapterDownloads(input: { ids: $chapterIds }) {
downloadStatus { state }
}
}
`;
export const DEQUEUE_DOWNLOAD = `
mutation DequeueDownload($chapterId: Int!) {
dequeueChapterDownload(input: { id: $chapterId }) {
downloadStatus { state }
}
}
`;
export const DEQUEUE_CHAPTERS_DOWNLOAD = `
mutation DequeueChaptersDownload($chapterIds: [Int!]!) {
dequeueChapterDownloads(input: { ids: $chapterIds }) {
downloadStatus { ${QUEUE_FRAGMENT} }
}
}
`;
export const REORDER_DOWNLOAD = `
mutation ReorderDownload($chapterId: Int!, $to: Int!) {
reorderChapterDownload(input: { chapterId: $chapterId, to: $to }) {
downloadStatus { ${QUEUE_FRAGMENT} }
}
}
`;
export const START_DOWNLOADER = `
mutation StartDownloader {
startDownloader(input: {}) {
downloadStatus { ${QUEUE_FRAGMENT} }
}
}
`;
export const STOP_DOWNLOADER = `
mutation StopDownloader {
stopDownloader(input: {}) {
downloadStatus { ${QUEUE_FRAGMENT} }
}
}
`;
export const CLEAR_DOWNLOADER = `
mutation ClearDownloader {
clearDownloader(input: {}) {
downloadStatus { ${QUEUE_FRAGMENT} }
}
}
`;
export const FETCH_SOURCE_MANGA = `
mutation FetchSourceManga($source: LongString!, $type: FetchSourceMangaType!, $page: Int!, $query: String, $filters: [FilterChangeInput!]) {
fetchSourceManga(input: { source: $source, type: $type, page: $page, query: $query, filters: $filters }) {
mangas { id title thumbnailUrl inLibrary }
hasNextPage
}
}
`;
export const SET_DOWNLOADS_PATH = `
mutation SetDownloadsPath($path: String!) {
setSettings(input: { settings: { downloadsPath: $path } }) {
settings { downloadsPath }
}
}
`;
export const SET_LOCAL_SOURCE_PATH = `
mutation SetLocalSourcePath($path: String!) {
setSettings(input: { settings: { localSourcePath: $path } }) {
settings { localSourcePath }
}
}
`;
-215
View File
@@ -1,215 +0,0 @@
export const FETCH_EXTENSIONS = `
mutation FetchExtensions {
fetchExtensions(input: {}) {
extensions {
apkName pkgName name lang versionName
isInstalled isObsolete hasUpdate iconUrl
}
}
}
`;
export const UPDATE_EXTENSION = `
mutation UpdateExtension($id: String!, $install: Boolean, $uninstall: Boolean, $update: Boolean) {
updateExtension(input: { id: $id, patch: { install: $install, uninstall: $uninstall, update: $update } }) {
extension { apkName pkgName name isInstalled hasUpdate }
}
}
`;
export const UPDATE_EXTENSIONS = `
mutation UpdateExtensions($ids: [String!]!, $install: Boolean, $uninstall: Boolean, $update: Boolean) {
updateExtensions(input: { ids: $ids, patch: { install: $install, uninstall: $uninstall, update: $update } }) {
extensions { apkName pkgName name isInstalled hasUpdate }
}
}
`;
export const INSTALL_EXTERNAL_EXTENSION = `
mutation InstallExternalExtension($url: String!) {
installExternalExtension(input: { extensionUrl: $url }) {
extension { apkName pkgName name isInstalled }
}
}
`;
export const UPDATE_SOURCE_PREFERENCE = `
mutation UpdateSourcePreference($source: LongString!, $change: SourcePreferenceChangeInput!) {
updateSourcePreference(input: { source: $source, change: $change }) {
source { id displayName }
}
}
`;
export const SET_SOURCE_METAS = `
mutation SetSourceMetas($input: SetSourceMetasInput!) {
setSourceMetas(input: $input) {
metas { sourceId key value }
}
}
`;
export const DELETE_SOURCE_METAS = `
mutation DeleteSourceMetas($input: DeleteSourceMetasInput!) {
deleteSourceMetas(input: $input) {
metas { sourceId key value }
}
}
`;
export const UPDATE_SOURCE_METADATA = `
mutation UpdateSourceMetadata(
$preUpdateDeleteInput: DeleteSourceMetasInput!
$hasPreUpdateDeletions: Boolean!
$updateInput: SetSourceMetasInput!
$hasUpdates: Boolean!
$postUpdateDeleteInput: DeleteSourceMetasInput!
$hasPostUpdateDeletions: Boolean!
$migrateInput: SetSourceMetasInput!
$isMigration: Boolean!
) {
preUpdateDeletedMeta: deleteSourceMetas(input: $preUpdateDeleteInput) @include(if: $hasPreUpdateDeletions) {
metas { sourceId key value }
}
updatedMeta: setSourceMetas(input: $updateInput) @include(if: $hasUpdates) {
metas { sourceId key value }
}
postUpdateDeletedMeta: deleteSourceMetas(input: $postUpdateDeleteInput) @include(if: $hasPostUpdateDeletions) {
metas { sourceId key value }
}
migrationMeta: setSourceMetas(input: $migrateInput) @include(if: $isMigration) {
metas { sourceId key value }
}
}
`;
export const SET_SOURCE_META = `
mutation SetSourceMeta($sourceId: LongString!, $key: String!, $value: String!) {
setSourceMeta(input: { meta: { sourceId: $sourceId, key: $key, value: $value } }) {
meta { key value }
}
}
`;
export const DELETE_SOURCE_META = `
mutation DeleteSourceMeta($sourceId: LongString!, $key: String!) {
deleteSourceMeta(input: { sourceId: $sourceId, key: $key }) {
meta { key value }
}
}
`;
export const SET_CATEGORY_META = `
mutation SetCategoryMeta($categoryId: Int!, $key: String!, $value: String!) {
setCategoryMeta(input: { meta: { categoryId: $categoryId, key: $key, value: $value } }) {
meta { key value }
}
}
`;
export const DELETE_CATEGORY_META = `
mutation DeleteCategoryMeta($categoryId: Int!, $key: String!) {
deleteCategoryMeta(input: { categoryId: $categoryId, key: $key }) {
meta { key value }
}
}
`;
export const SET_GLOBAL_META = `
mutation SetGlobalMeta($key: String!, $value: String!) {
setGlobalMeta(input: { meta: { key: $key, value: $value } }) {
meta { key value }
}
}
`;
export const DELETE_GLOBAL_META = `
mutation DeleteGlobalMeta($key: String!) {
deleteGlobalMeta(input: { key: $key }) {
meta { key value }
}
}
`;
export const CLEAR_CACHED_IMAGES = `
mutation ClearCachedImages($cachedPages: Boolean, $cachedThumbnails: Boolean, $downloadedThumbnails: Boolean) {
clearCachedImages(input: {
cachedPages: $cachedPages
cachedThumbnails: $cachedThumbnails
downloadedThumbnails: $downloadedThumbnails
}) {
cachedPages cachedThumbnails downloadedThumbnails
}
}
`;
export const RESET_SETTINGS = `
mutation ResetSettings {
resetSettings(input: {}) {
settings { extensionRepos }
}
}
`;
export const SET_EXTENSION_REPOS = `
mutation SetExtensionRepos($repos: [String!]!) {
setSettings(input: { settings: { extensionRepos: $repos } }) {
settings { extensionRepos }
}
}
`;
export const SET_SERVER_AUTH = `
mutation SetServerAuth($authMode: AuthMode!, $authUsername: String!, $authPassword: String!) {
setSettings(input: { settings: { authMode: $authMode, authUsername: $authUsername, authPassword: $authPassword } }) {
settings { authMode authUsername }
}
}
`;
export const SET_SOCKS_PROXY = `
mutation SetSocksProxy(
$socksProxyEnabled: Boolean!
$socksProxyHost: String!
$socksProxyPort: String!
$socksProxyVersion: Int!
$socksProxyUsername: String!
$socksProxyPassword: String!
) {
setSettings(input: { settings: {
socksProxyEnabled: $socksProxyEnabled
socksProxyHost: $socksProxyHost
socksProxyPort: $socksProxyPort
socksProxyVersion: $socksProxyVersion
socksProxyUsername: $socksProxyUsername
socksProxyPassword: $socksProxyPassword
}}) {
settings { socksProxyEnabled socksProxyHost socksProxyPort socksProxyVersion socksProxyUsername }
}
}
`;
export const SET_FLARESOLVERR = `
mutation SetFlareSolverr(
$flareSolverrEnabled: Boolean!
$flareSolverrUrl: String!
$flareSolverrTimeout: Int!
$flareSolverrSessionName: String!
$flareSolverrSessionTtl: Int!
$flareSolverrAsResponseFallback: Boolean!
) {
setSettings(input: { settings: {
flareSolverrEnabled: $flareSolverrEnabled
flareSolverrUrl: $flareSolverrUrl
flareSolverrTimeout: $flareSolverrTimeout
flareSolverrSessionName: $flareSolverrSessionName
flareSolverrSessionTtl: $flareSolverrSessionTtl
flareSolverrAsResponseFallback: $flareSolverrAsResponseFallback
}}) {
settings {
flareSolverrEnabled flareSolverrUrl flareSolverrTimeout
flareSolverrSessionName flareSolverrSessionTtl flareSolverrAsResponseFallback
}
}
}
`;
-5
View File
@@ -1,5 +0,0 @@
export * from "./manga";
export * from "./chapters";
export * from "./downloads";
export * from "./extensions";
export * from "./tracking";
-153
View File
@@ -1,153 +0,0 @@
export const FETCH_MANGA = `
mutation FetchManga($id: Int!) {
fetchManga(input: { id: $id }) {
manga {
id title description thumbnailUrl status author artist genre inLibrary realUrl
source { id name displayName }
}
}
}
`;
export const UPDATE_MANGA = `
mutation UpdateManga($id: Int!, $inLibrary: Boolean) {
updateManga(input: { id: $id, patch: { inLibrary: $inLibrary } }) {
manga { id inLibrary }
}
}
`;
export const UPDATE_MANGAS = `
mutation UpdateMangas($ids: [Int!]!, $inLibrary: Boolean) {
updateMangas(input: { ids: $ids, patch: { inLibrary: $inLibrary } }) {
mangas { id inLibrary }
}
}
`;
export const UPDATE_MANGA_CATEGORIES = `
mutation UpdateMangaCategories($mangaId: Int!, $addTo: [Int!]!, $removeFrom: [Int!]!) {
updateMangaCategories(input: { id: $mangaId, patch: { addToCategories: $addTo, removeFromCategories: $removeFrom } }) {
manga { id }
}
}
`;
export const UPDATE_MANGAS_CATEGORIES = `
mutation UpdateMangasCategories($ids: [Int!]!, $addTo: [Int!]!, $removeFrom: [Int!]!) {
updateMangasCategories(input: { ids: $ids, patch: { addToCategories: $addTo, removeFromCategories: $removeFrom } }) {
mangas { id }
}
}
`;
export const CREATE_CATEGORY = `
mutation CreateCategory($name: String!) {
createCategory(input: { name: $name }) {
category { id name order default includeInUpdate includeInDownload }
}
}
`;
export const UPDATE_CATEGORY = `
mutation UpdateCategory($id: Int!, $name: String) {
updateCategory(input: { id: $id, patch: { name: $name } }) {
category { id name order }
}
}
`;
export const UPDATE_CATEGORIES = `
mutation UpdateCategories($ids: [Int!]!, $patch: UpdateCategoryPatchInput!) {
updateCategories(input: { ids: $ids, patch: $patch }) {
categories { id name order default includeInUpdate includeInDownload }
}
}
`;
export const DELETE_CATEGORY = `
mutation DeleteCategory($id: Int!) {
deleteCategory(input: { categoryId: $id }) {
category { id }
}
}
`;
export const UPDATE_CATEGORY_ORDER = `
mutation UpdateCategoryOrder($id: Int!, $position: Int!) {
updateCategoryOrder(input: { id: $id, position: $position }) {
categories { id name order default includeInUpdate includeInDownload }
}
}
`;
export const UPDATE_CATEGORY_MANGA = `
mutation UpdateCategoryManga($categoryId: Int!) {
updateCategoryManga(input: { categoryId: $categoryId }) {
updateStatus {
jobsInfo { isRunning finishedJobs totalJobs }
}
}
}
`;
export const UPDATE_LIBRARY = `
mutation UpdateLibrary {
updateLibrary(input: {}) {
updateStatus {
jobsInfo { isRunning finishedJobs totalJobs }
}
}
}
`;
export const UPDATE_LIBRARY_MANGA = `
mutation UpdateLibraryManga($mangaId: Int!) {
updateLibraryManga(input: { mangaId: $mangaId }) {
updateStatus {
jobsInfo { isRunning finishedJobs totalJobs }
}
}
}
`;
export const UPDATE_STOP = `
mutation UpdateStop {
updateStop(input: {}) {
updateStatus {
jobsInfo { isRunning finishedJobs totalJobs }
}
}
}
`;
export const CREATE_BACKUP = `
mutation CreateBackup {
createBackup(input: {}) { url }
}
`;
export const RESTORE_BACKUP = `
mutation RestoreBackup($backup: Upload!) {
restoreBackup(input: { backup: $backup }) {
id
status { mangaProgress state totalManga }
}
}
`;
export const SET_MANGA_META = `
mutation SetMangaMeta($mangaId: Int!, $key: String!, $value: String!) {
setMangaMeta(input: { meta: { mangaId: $mangaId, key: $key, value: $value } }) {
meta { key value }
}
}
`;
export const DELETE_MANGA_META = `
mutation DeleteMangaMeta($mangaId: Int!, $key: String!) {
deleteMangaMeta(input: { mangaId: $mangaId, key: $key }) {
meta { key value }
}
}
`;
-130
View File
@@ -1,130 +0,0 @@
# Mutations
## Manga (`mutations/manga.ts`)
| Mutation | Variables | Description |
|----------|-----------|-------------|
| `FETCH_MANGA` | `id: Int!` | Fetch and refresh manga metadata from its source |
| `UPDATE_MANGA` | `id: Int!`, `inLibrary: Boolean` | Update a single manga's library membership |
| `UPDATE_MANGAS` | `ids: [Int!]!`, `inLibrary: Boolean` | Bulk-update library membership for multiple manga |
| `UPDATE_MANGA_CATEGORIES` | `mangaId: Int!`, `addTo: [Int!]!`, `removeFrom: [Int!]!` | Add or remove a single manga from categories |
| `UPDATE_MANGAS_CATEGORIES` | `ids: [Int!]!`, `addTo: [Int!]!`, `removeFrom: [Int!]!` | Bulk add/remove multiple manga from categories |
| `CREATE_CATEGORY` | `name: String!` | Create a new category |
| `UPDATE_CATEGORY` | `id: Int!`, `name: String` | Update a category's name |
| `UPDATE_CATEGORIES` | `ids: [Int!]!`, `patch: UpdateCategoryPatchInput!` | Bulk-update multiple categories |
| `DELETE_CATEGORY` | `id: Int!` | Delete a category |
| `UPDATE_CATEGORY_ORDER` | `id: Int!`, `position: Int!` | Move a category to a new position |
| `UPDATE_CATEGORY_MANGA` | `categoryId: Int!` | Trigger a metadata update for all manga in a category |
| `UPDATE_LIBRARY` | — | Trigger a full library metadata refresh |
| `UPDATE_LIBRARY_MANGA` | `mangaId: Int!` | Trigger a metadata update for a single manga |
| `UPDATE_STOP` | — | Stop the currently running library update job |
| `CREATE_BACKUP` | — | Create a backup and return its download URL |
| `RESTORE_BACKUP` | `backup: Upload!` | Restore a backup file and return the restore job status |
| `SET_MANGA_META` | `mangaId: Int!`, `key: String!`, `value: String!` | Set a key/value meta entry on a manga |
| `DELETE_MANGA_META` | `mangaId: Int!`, `key: String!` | Delete a key/value meta entry from a manga |
---
## Chapters (`mutations/chapters.ts`)
| Mutation | Variables | Description |
|----------|-----------|-------------|
| `FETCH_CHAPTERS` | `mangaId: Int!` | Fetch/refresh the chapter list for a manga from its source |
| `FETCH_CHAPTER_PAGES` | `chapterId: Int!` | Fetch the page URLs for a specific chapter |
| `MARK_CHAPTER_READ` | `id: Int!`, `isRead: Boolean!` | Mark a single chapter read or unread |
| `MARK_CHAPTERS_READ` | `ids: [Int!]!`, `isRead: Boolean!` | Bulk mark chapters read or unread |
| `UPDATE_CHAPTERS_PROGRESS` | `ids: [Int!]!`, `isRead: Boolean`, `isBookmarked: Boolean`, `lastPageRead: Int` | Bulk update read state, bookmark state, and last page read |
| `DELETE_DOWNLOADED_CHAPTERS` | `ids: [Int!]!` | Delete downloaded chapter files |
| `SET_CHAPTER_META` | `chapterId: Int!`, `key: String!`, `value: String!` | Set a key/value meta entry on a chapter |
| `DELETE_CHAPTER_META` | `chapterId: Int!`, `key: String!` | Delete a key/value meta entry from a chapter |
---
## Downloads (`mutations/downloads.ts`)
| Mutation | Variables | Description |
|----------|-----------|-------------|
| `ENQUEUE_DOWNLOAD` | `chapterId: Int!` | Add a single chapter to the download queue |
| `ENQUEUE_CHAPTERS_DOWNLOAD` | `chapterIds: [Int!]!` | Add multiple chapters to the download queue |
| `DEQUEUE_DOWNLOAD` | `chapterId: Int!` | Remove a single chapter from the download queue |
| `DEQUEUE_CHAPTERS_DOWNLOAD` | `chapterIds: [Int!]!` | Remove multiple chapters from the download queue |
| `REORDER_DOWNLOAD` | `chapterId: Int!`, `to: Int!` | Move a queued chapter to a new position |
| `START_DOWNLOADER` | — | Start the downloader |
| `STOP_DOWNLOADER` | — | Stop the downloader |
| `CLEAR_DOWNLOADER` | — | Clear all items from the download queue |
| `FETCH_SOURCE_MANGA` | `source: LongString!`, `type: FetchSourceMangaType!`, `page: Int!`, `query: String`, `filters: [FilterChangeInput!]` | Fetch manga from a source (browse/search) with pagination |
| `SET_DOWNLOADS_PATH` | `path: String!` | Set the downloads directory path |
| `SET_LOCAL_SOURCE_PATH` | `path: String!` | Set the local source directory path |
---
## Extensions (`mutations/extensions.ts`)
| Mutation | Variables | Description |
|----------|-----------|-------------|
| `FETCH_EXTENSIONS` | — | Fetch the latest extension list from configured repos |
| `UPDATE_EXTENSION` | `id: String!`, `install: Boolean`, `uninstall: Boolean`, `update: Boolean` | Install, uninstall, or update a single extension |
| `UPDATE_EXTENSIONS` | `ids: [String!]!`, `install: Boolean`, `uninstall: Boolean`, `update: Boolean` | Bulk install, uninstall, or update multiple extensions |
| `INSTALL_EXTERNAL_EXTENSION` | `url: String!` | Install an extension from an external APK URL |
| `UPDATE_SOURCE_PREFERENCE` | `source: LongString!`, `change: SourcePreferenceChangeInput!` | Update a source-specific preference value |
| `SET_SOURCE_META` | `sourceId: LongString!`, `key: String!`, `value: String!` | Set a key/value meta entry on a source |
| `DELETE_SOURCE_META` | `sourceId: LongString!`, `key: String!` | Delete a key/value meta entry from a source |
| `SET_CATEGORY_META` | `categoryId: Int!`, `key: String!`, `value: String!` | Set a key/value meta entry on a category |
| `DELETE_CATEGORY_META` | `categoryId: Int!`, `key: String!` | Delete a key/value meta entry from a category |
| `SET_GLOBAL_META` | `key: String!`, `value: String!` | Set a global key/value meta entry |
| `DELETE_GLOBAL_META` | `key: String!` | Delete a global key/value meta entry |
| `CLEAR_CACHED_IMAGES` | `cachedPages: Boolean`, `cachedThumbnails: Boolean`, `downloadedThumbnails: Boolean` | Selectively clear cached page images, cached thumbnails, or downloaded thumbnails |
| `RESET_SETTINGS` | — | Reset all server settings to defaults |
| `UPDATE_WEBUI` | — | Trigger a WebUI update and return live status |
| `RESET_WEBUI_UPDATE_STATUS` | — | Reset the WebUI update status back to idle |
| `SET_EXTENSION_REPOS` | `repos: [String!]!` | Set the list of extension repository URLs |
| `SET_SERVER_AUTH` | `authMode: AuthMode!`, `authUsername: String!`, `authPassword: String!` | Configure server auth mode and credentials |
| `SET_SOCKS_PROXY` | `socksProxyEnabled: Boolean!`, `socksProxyHost: String!`, `socksProxyPort: String!`, `socksProxyVersion: Int!`, `socksProxyUsername: String!`, `socksProxyPassword: String!` | Configure SOCKS proxy settings |
| `SET_FLARESOLVERR` | `flareSolverrEnabled: Boolean!`, `flareSolverrUrl: String!`, `flareSolverrTimeout: Int!`, `flareSolverrSessionName: String!`, `flareSolverrSessionTtl: Int!`, `flareSolverrAsResponseFallback: Boolean!` | Configure FlareSolverr integration |
---
## Tracking (`mutations/tracking.ts`)
| Mutation | Variables | Description |
|----------|-----------|-------------|
| `BIND_TRACK` | `mangaId: Int!`, `trackerId: Int!`, `remoteId: LongString!` | Bind a manga to a remote tracker entry |
| `UPDATE_TRACK` | `recordId: Int!`, `status: Int`, `lastChapterRead: Float`, `scoreString: String`, `startDate: LongString`, `finishDate: LongString`, `private: Boolean` | Update tracking progress, status, score, and dates |
| `UNBIND_TRACK` | `recordId: Int!` | Unbind a manga from a tracker record |
| `FETCH_TRACK` | `recordId: Int!` | Refresh a track record from the remote tracker |
| `TRACK_PROGRESS` | `mangaId: Int!` | Sync current reading progress to all bound trackers for a manga |
| `LOGIN_TRACKER_OAUTH` | `trackerId: Int!`, `callbackUrl: String!` | Initiate OAuth login for a tracker |
| `LOGIN_TRACKER_CREDENTIALS` | `trackerId: Int!`, `username: String!`, `password: String!` | Log into a tracker with username and password |
| `LOGOUT_TRACKER` | `trackerId: Int!` | Log out of a tracker |
| `CONNECT_KOSYNC` | `username: String!`, `password: String!`, `serverAddress: String!` | Connect a KOReader sync account |
| `LOGOUT_KOSYNC` | — | Disconnect the KOReader sync account |
| `PULL_KOSYNC_PROGRESS` | `chapterId: Int!` | Pull reading progress from KOReader sync for a chapter |
| `PUSH_KOSYNC_PROGRESS` | `chapterId: Int!` | Push reading progress to KOReader sync for a chapter |
| `LOGIN_USER` | `username: String!`, `password: String!` | Authenticate and return access + refresh tokens |
| `REFRESH_TOKEN` | — | Refresh the current access token |
---
## New in Preview
Mutations now available and not yet wired to any feature in Moku:
| Mutation | Potential Feature |
|----------|-------------------|
| `UPDATE_MANGAS_CATEGORIES` | Bulk category editor — move/assign multiple manga at once |
| `UPDATE_CATEGORIES` | Bulk category settings — toggle update/download flags for multiple categories at once |
| `UPDATE_CATEGORY_MANGA` | Per-category refresh button — update only one category's manga |
| `UPDATE_LIBRARY_MANGA` | Single manga refresh — trigger from series detail without a full library update |
| `UPDATE_STOP` | Cancel button for library update jobs |
| `UPDATE_EXTENSIONS` | Bulk extension updater — "update all" button in extensions page |
| `UPDATE_SOURCE_PREFERENCE` | Source settings page — persist source-specific preferences |
| `SET_SOURCE_META` / `DELETE_SOURCE_META` | Per-source client state — store browse position, last filter, etc. |
| `SET_CATEGORY_META` / `DELETE_CATEGORY_META` | Per-category client state — store sort/filter preferences per category |
| `SET_CHAPTER_META` / `DELETE_CHAPTER_META` | Per-chapter client state — annotations, custom notes |
| `SET_GLOBAL_META` / `DELETE_GLOBAL_META` | Server-synced app state — replace local persistence for settings that should roam |
| `CLEAR_CACHED_IMAGES` | Storage settings — granular cache clearing (pages, thumbnails, downloaded) |
| `RESET_SETTINGS` | Settings page — factory reset button |
| `UPDATE_WEBUI` / `RESET_WEBUI_UPDATE_STATUS` | WebUI update flow in settings — trigger and monitor update progress |
| `TRACK_PROGRESS` | One-tap sync — push current reading position to all trackers without opening tracking panel |
| `CONNECT_KOSYNC` / `LOGOUT_KOSYNC` | KOReader sync settings section — connect/disconnect account |
| `PULL_KOSYNC_PROGRESS` / `PUSH_KOSYNC_PROGRESS` | KOReader sync — manual pull/push per chapter, or auto-sync on chapter open/close |
-127
View File
@@ -1,127 +0,0 @@
const TRACK_RECORD_FRAGMENT = `
id trackerId remoteId title status score displayScore
lastChapterRead totalChapters remoteUrl startDate finishDate private libraryId
`;
export const BIND_TRACK = `
mutation BindTrack($mangaId: Int!, $trackerId: Int!, $remoteId: LongString!) {
bindTrack(input: { mangaId: $mangaId, trackerId: $trackerId, remoteId: $remoteId }) {
trackRecord { ${TRACK_RECORD_FRAGMENT} }
}
}
`;
export const UPDATE_TRACK = `
mutation UpdateTrack($recordId: Int!, $status: Int, $lastChapterRead: Float, $scoreString: String, $startDate: LongString, $finishDate: LongString, $private: Boolean) {
updateTrack(input: { recordId: $recordId, status: $status, lastChapterRead: $lastChapterRead, scoreString: $scoreString, startDate: $startDate, finishDate: $finishDate, private: $private }) {
trackRecord {
id trackerId status score displayScore lastChapterRead totalChapters startDate finishDate private libraryId
}
}
}
`;
export const UNBIND_TRACK = `
mutation UnbindTrack($recordId: Int!) {
unbindTrack(input: { recordId: $recordId }) {
trackRecord { id }
}
}
`;
export const FETCH_TRACK = `
mutation FetchTrack($recordId: Int!) {
fetchTrack(input: { recordId: $recordId }) {
trackRecord {
id trackerId status score displayScore lastChapterRead totalChapters startDate finishDate libraryId
}
}
}
`;
export const TRACK_PROGRESS = `
mutation TrackProgress($mangaId: Int!) {
trackProgress(input: { mangaId: $mangaId }) {
trackRecords {
id trackerId lastChapterRead status
}
}
}
`;
export const LOGIN_TRACKER_OAUTH = `
mutation LoginTrackerOAuth($trackerId: Int!, $callbackUrl: String!) {
loginTrackerOAuth(input: { trackerId: $trackerId, callbackUrl: $callbackUrl }) {
isLoggedIn
tracker { id name isLoggedIn isTokenExpired authUrl }
}
}
`;
export const LOGIN_TRACKER_CREDENTIALS = `
mutation LoginTrackerCredentials($trackerId: Int!, $username: String!, $password: String!) {
loginTrackerCredentials(input: { trackerId: $trackerId, username: $username, password: $password }) {
isLoggedIn
tracker { id name isLoggedIn isTokenExpired authUrl }
}
}
`;
export const LOGOUT_TRACKER = `
mutation LogoutTracker($trackerId: Int!) {
logoutTracker(input: { trackerId: $trackerId }) {
tracker { id name isLoggedIn isTokenExpired authUrl }
}
}
`;
export const CONNECT_KOSYNC = `
mutation ConnectKoSync($username: String!, $password: String!, $serverAddress: String!) {
connectKoSyncAccount(input: { username: $username, password: $password, serverAddress: $serverAddress }) {
isConnected
}
}
`;
export const LOGOUT_KOSYNC = `
mutation LogoutKoSync {
logoutKoSyncAccount(input: {}) {
isConnected
}
}
`;
export const PULL_KOSYNC_PROGRESS = `
mutation PullKoSyncProgress($chapterId: Int!) {
pullKoSyncProgress(input: { chapterId: $chapterId }) {
chapter { id lastPageRead isRead }
}
}
`;
export const PUSH_KOSYNC_PROGRESS = `
mutation PushKoSyncProgress($chapterId: Int!) {
pushKoSyncProgress(input: { chapterId: $chapterId }) {
chapter { id lastPageRead isRead }
}
}
`;
export const LOGIN_USER = `
mutation Login($username: String!, $password: String!, $clientMutationId: String) {
login(input: { username: $username, password: $password, clientMutationId: $clientMutationId }) {
accessToken
refreshToken
clientMutationId
}
}
`;
export const REFRESH_TOKEN = `
mutation RefreshToken($refreshToken: String!, $clientMutationId: String) {
refreshToken(input: { refreshToken: $refreshToken, clientMutationId: $clientMutationId }) {
accessToken
clientMutationId
}
}
`;
-28
View File
@@ -1,28 +0,0 @@
export const GET_RECENTLY_UPDATED = `
query GetRecentlyUpdated {
chapters(orderBy: FETCHED_AT, orderByType: DESC, first: 300) {
nodes {
id
name
chapterNumber
sourceOrder
isRead
lastPageRead
mangaId
fetchedAt
manga { id title thumbnailUrl inLibrary }
}
}
}
`;
export const GET_CHAPTERS = `
query GetChapters($mangaId: Int!) {
chapters(condition: { mangaId: $mangaId }) {
nodes {
id name chapterNumber sourceOrder isRead isDownloaded isBookmarked
pageCount mangaId uploadDate realUrl lastPageRead lastReadAt scanlator
}
}
}
`;
-14
View File
@@ -1,14 +0,0 @@
export const GET_DOWNLOAD_STATUS = `
query GetDownloadStatus {
downloadStatus {
state
queue {
progress state tries
chapter {
id name pageCount mangaId
manga { id title thumbnailUrl }
}
}
}
}
`;
-117
View File
@@ -1,117 +0,0 @@
export const GET_LOCAL_MANGA = `
query GetLocalManga {
mangas(condition: { sourceId: "0" }) {
nodes { id title thumbnailUrl inLibrary }
}
}
`;
export const GET_EXTENSIONS = `
query GetExtensions {
extensions {
nodes {
apkName pkgName name lang versionName
isInstalled isObsolete hasUpdate iconUrl
}
}
}
`;
export const GET_SOURCES = `
query GetSources {
sources {
nodes {
id name lang displayName iconUrl isNsfw
isConfigurable supportsLatest
extension { pkgName }
}
}
}
`;
export const GET_SOURCE_SETTINGS = `
query GetSourceSettings($id: LongString!) {
source(id: $id) {
id
displayName
preferences {
... on CheckBoxPreference {
type: __typename
CheckBoxTitle: title
CheckBoxSummary: summary
CheckBoxDefault: default
CheckBoxCurrentValue: currentValue
key
}
... on SwitchPreference {
type: __typename
SwitchPreferenceTitle: title
SwitchPreferenceSummary: summary
SwitchPreferenceDefault: default
SwitchPreferenceCurrentValue: currentValue
key
}
... on ListPreference {
type: __typename
ListPreferenceTitle: title
ListPreferenceSummary: summary
ListPreferenceDefault: default
ListPreferenceCurrentValue: currentValue
entries
entryValues
key
}
... on EditTextPreference {
type: __typename
EditTextPreferenceTitle: title
EditTextPreferenceSummary: summary
EditTextPreferenceDefault: default
EditTextPreferenceCurrentValue: currentValue
dialogTitle
dialogMessage
key
}
... on MultiSelectListPreference {
type: __typename
MultiSelectListPreferenceTitle: title
MultiSelectListPreferenceSummary: summary
MultiSelectListPreferenceDefault: default
MultiSelectListPreferenceCurrentValue: currentValue
entries
entryValues
key
}
}
}
}
`;
export const GET_MIGRATABLE_SOURCES = `
query GetMigratableSources {
mangas(condition: { inLibrary: true }) {
nodes {
sourceId
source {
id name lang displayName iconUrl isNsfw isConfigurable supportsLatest
}
}
}
}
`;
export const GET_SETTINGS = `
query GetSettings {
settings { extensionRepos }
}
`;
export const GET_SERVER_SECURITY = `
query GetServerSecurity {
settings {
authMode authUsername
socksProxyEnabled socksProxyHost socksProxyPort socksProxyVersion socksProxyUsername
flareSolverrEnabled flareSolverrUrl flareSolverrTimeout
flareSolverrSessionName flareSolverrSessionTtl flareSolverrAsResponseFallback
}
}
`;
-7
View File
@@ -1,7 +0,0 @@
export * from "./manga";
export * from "./chapters";
export * from "./downloads";
export * from "./extensions";
export * from "./tracking";
export * from "./updater";
export * from "./meta";
-110
View File
@@ -1,110 +0,0 @@
export const GET_LIBRARY = `
query GetLibrary {
mangas(condition: { inLibrary: true }) {
nodes {
id title thumbnailUrl inLibrary downloadCount unreadCount bookmarkCount
description status author artist genre
inLibraryAt lastFetchedAt chaptersLastFetchedAt thumbnailUrlLastFetched
source { id name displayName }
chapters { totalCount }
latestFetchedChapter { id uploadDate }
latestUploadedChapter { id uploadDate }
lastReadChapter { id chapterNumber }
firstUnreadChapter { id chapterNumber }
}
}
}
`;
export const GET_ALL_MANGA = `
query GetAllManga {
mangas {
nodes { id title thumbnailUrl inLibrary downloadCount }
}
}
`;
export const GET_MANGA = `
query GetManga($id: Int!) {
manga(id: $id) {
id title description thumbnailUrl status author artist genre inLibrary realUrl
inLibraryAt lastFetchedAt thumbnailUrlLastFetched updateStrategy
source { id name displayName }
lastReadChapter { id chapterNumber lastPageRead }
firstUnreadChapter { id chapterNumber }
highestNumberedChapter { id chapterNumber }
}
}
`;
export const GET_CATEGORIES = `
query GetCategories {
categories {
nodes {
id name order default includeInUpdate includeInDownload
mangas {
nodes { id title thumbnailUrl inLibrary downloadCount unreadCount }
}
}
}
}
`;
export const GET_DOWNLOADED_CHAPTERS_PAGES = `
query GetDownloadedChaptersPages {
chapters(condition: { isDownloaded: true }) {
nodes { pageCount }
}
}
`;
export const GET_DOWNLOADS_PATH = `
query GetDownloadsPath {
settings { downloadsPath localSourcePath }
}
`;
export const LIBRARY_UPDATE_STATUS = `
query LibraryUpdateStatus {
libraryUpdateStatus {
jobsInfo {
isRunning finishedJobs totalJobs skippedMangasCount skippedCategoriesCount
}
mangaUpdates {
status
manga { id title thumbnailUrl unreadCount }
}
}
lastUpdateTimestamp {
timestamp
}
}
`;
export const GET_RESTORE_STATUS = `
query GetRestoreStatus($id: String!) {
restoreStatus(id: $id) { mangaProgress state totalManga }
}
`;
export const VALIDATE_BACKUP = `
query ValidateBackup($backup: Upload!) {
validateBackup(input: { backup: $backup }) {
missingSources { id name }
missingTrackers { name }
}
}
`;
export const MANGAS_BY_GENRE = `
query MangasByGenre($filter: MangaFilterInput, $first: Int, $offset: Int) {
mangas(filter: $filter, first: $first, offset: $offset, orderBy: IN_LIBRARY_AT, orderByType: DESC) {
nodes {
id title thumbnailUrl inLibrary genre status
source { id displayName }
}
pageInfo { hasNextPage }
totalCount
}
}
`;
-15
View File
@@ -1,15 +0,0 @@
export const GET_META = `
query GetMeta($key: String!) {
meta(key: $key) {
key value
}
}
`;
export const GET_METAS = `
query GetMetas {
metas {
nodes { key value }
}
}
`;
-117
View File
@@ -1,117 +0,0 @@
# Queries
## Manga (`queries/manga.ts`)
| Query | Variables | Description |
|-------|-----------|-------------|
| `GET_LIBRARY` | — | All in-library manga with metadata, source, chapter counts, download count, unread count, bookmark count, and read progress anchors (`lastReadChapter`, `firstUnreadChapter`) |
| `GET_ALL_MANGA` | — | Minimal manga list — id, title, thumbnail, library flag, download count |
| `GET_MANGA` | `id: Int!` | Full detail for a single manga — includes `updateStrategy`, `lastReadChapter`, `firstUnreadChapter`, `highestNumberedChapter` |
| `GET_CATEGORIES` | — | All categories with order/settings and their assigned manga (minimal fields) |
| `GET_DOWNLOADED_CHAPTERS_PAGES` | — | Page counts for all downloaded chapters — used for storage stats |
| `GET_DOWNLOADS_PATH` | — | `downloadsPath` and `localSourcePath` from settings |
| `LIBRARY_UPDATE_STATUS` | — | Current library update job (`jobsInfo`, `mangaUpdates`) plus `lastUpdateTimestamp` for server-side update timing |
| `GET_RESTORE_STATUS` | `id: String!` | Backup restore job status by job ID — `mangaProgress`, `state`, `totalManga` |
| `VALIDATE_BACKUP` | `backup: Upload!` | Validate a backup file before restore — returns missing sources and trackers |
| `MANGAS_BY_GENRE` | `filter: MangaFilterInput`, `first: Int`, `offset: Int` | Paginated manga filtered by genre, ordered by `IN_LIBRARY_AT DESC` |
---
## Chapters (`queries/chapters.ts`)
| Query | Variables | Description |
|-------|-----------|-------------|
| `GET_RECENTLY_UPDATED` | — | Latest 300 chapters ordered by `FETCHED_AT DESC` with parent manga info |
| `GET_CHAPTERS` | `mangaId: Int!` | All chapters for a manga — includes `lastReadAt`, `lastPageRead`, read/download/bookmark state, page count, scanlator |
---
## Downloads (`queries/downloads.ts`)
| Query | Variables | Description |
|-------|-----------|-------------|
| `GET_DOWNLOAD_STATUS` | — | Downloader state (`DownloaderState` enum) and full queue with chapter and manga info |
---
## Extensions (`queries/extensions.ts`)
| Query | Variables | Description |
|-------|-----------|-------------|
| `GET_LOCAL_MANGA` | — | Manga from the local source (`sourceId: "0"`) |
| `GET_EXTENSIONS` | — | All extensions — install status, update flag, obsolete flag, metadata |
| `GET_SOURCES` | — | All sources — id, name, lang, display name, icon, NSFW flag, `isConfigurable`, `supportsLatest`, `baseUrl` |
| `GET_SETTINGS` | — | `extensionRepos` from settings |
| `GET_SERVER_SECURITY` | — | Full security config — auth mode, SOCKS proxy settings, FlareSolverr settings |
---
## Tracking (`queries/tracking.ts`)
| Query | Variables | Description |
|-------|-----------|-------------|
| `GET_TRACKERS` | — | All trackers with login state, token expiry, capability flags (`supportsPrivateTracking`, `supportsReadingDates`, `supportsTrackDeletion`), scores, and statuses |
| `GET_MANGA_TRACK_RECORDS` | `mangaId: Int!` | All track records for a specific manga — includes `libraryId`, score, dates, privacy flag |
| `SEARCH_TRACKER` | `trackerId: Int!`, `query: String!` | Search a tracker by query string — returns id, title, cover, summary, publishing info |
| `GET_ALL_TRACKER_RECORDS` | — | All trackers and their full record lists with associated manga — includes `isTokenExpired`, `libraryId` |
| `GET_TRACKER_RECORDS` | `trackerId: Int!` | Records for a specific tracker with associated manga |
---
## Updater (`queries/updater.ts`)
| Query | Variables | Description |
|-------|-----------|-------------|
| `GET_ABOUT_SERVER` | — | Server name, version, build type, build time, GitHub and Discord links |
| `GET_ABOUT_WEBUI` | — | WebUI channel, tag, and last update timestamp |
| `CHECK_FOR_SERVER_UPDATES` | — | Available server updates — channel, tag, download URL |
| `CHECK_FOR_WEBUI_UPDATE` | — | Available WebUI updates — channel and tag |
| `GET_WEBUI_UPDATE_STATUS` | — | Live WebUI update state (`UpdateState` enum), progress percent, and info block |
---
## Meta (`queries/meta.ts`)
| Query | Variables | Description |
|-------|-----------|-------------|
| `GET_META` | `key: String!` | Single server-side key/value meta entry |
| `GET_METAS` | — | All global meta entries as a node list |
---
## KoSync (`queries/kosync.ts`)
| Query | Variables | Description |
|-------|-----------|-------------|
| `GET_KOSYNC_STATUS` | — | KOReader sync connection status |
---
## New in Preview
Queries and fields now available but not yet wired to any feature in Moku:
| Query / Field | Potential Feature |
|---------------|-------------------|
| `GET_ABOUT_SERVER` | About page — server version, build info, links to GitHub and Discord |
| `GET_ABOUT_WEBUI` | About page — WebUI version and release channel |
| `CHECK_FOR_SERVER_UPDATES` | Update available banner or settings badge |
| `CHECK_FOR_WEBUI_UPDATE` | Update available banner or settings badge |
| `GET_WEBUI_UPDATE_STATUS` | Update progress indicator in settings |
| `GET_META` / `GET_METAS` | Server-side persistence — sync app state across clients without local storage |
| `GET_KOSYNC_STATUS` | KOReader sync settings section — show connection state |
| `trackRecords` (top-level) | Flat tracker record browser — filter by score, privacy, tracker |
| `category` (single by id) | Direct category detail without fetching all categories |
| `chapter` (single by id) | Direct chapter lookup without fetching full manga chapter list |
| `source` (single by id) | Source detail page — preferences, filters, browse |
| `tracker` (single by id) | Individual tracker detail — statuses, records |
| `trackRecord` (single by id) | Direct track record lookup for deep linking |
| `lastUpdateTimestamp` | Stale data detection — poll before refetching library |
| `MangaType.hasDuplicateChapters` | Library health view — flag manga with duplicate chapter numbers |
| `MangaType.age` / `chaptersAge` | Stale manga indicator — highlight series with no updates in N days |
| `MangaType.initialized` | Loading skeleton gating — skip detail render until manga is fully fetched |
| `SourceType.isConfigurable` | Source list — show gear icon only when source is configurable |
| `SourceType.supportsLatest` | Source browse UI — conditionally show Latest tab |
| `TrackerType.supportsTrackDeletion` | Tracking panel — show remove button only when tracker supports it |
| `TrackerType.supportsReadingDates` | Tracking panel — show date fields only when tracker supports them |
| `TrackerType.isTokenExpired` | Re-auth prompt — detect expired tokens before a request fails |
-71
View File
@@ -1,71 +0,0 @@
export const GET_TRACKERS = `
query GetTrackers {
trackers {
nodes {
id name icon isLoggedIn isTokenExpired authUrl
supportsPrivateTracking supportsReadingDates supportsTrackDeletion
scores
statuses { value name }
}
}
}
`;
export const GET_MANGA_TRACK_RECORDS = `
query GetMangaTrackRecords($mangaId: Int!) {
manga(id: $mangaId) {
trackRecords {
nodes {
id trackerId remoteId title status score displayScore
lastChapterRead totalChapters remoteUrl startDate finishDate private libraryId
}
}
}
}
`;
export const SEARCH_TRACKER = `
query SearchTracker($trackerId: Int!, $query: String!) {
searchTracker(input: { trackerId: $trackerId, query: $query }) {
trackSearches {
id trackerId remoteId title coverUrl summary
publishingStatus publishingType startDate totalChapters trackingUrl
}
}
}
`;
export const GET_ALL_TRACKER_RECORDS = `
query GetAllTrackerRecords {
trackers {
nodes {
id name icon isLoggedIn isTokenExpired scores
statuses { value name }
trackRecords {
nodes {
id trackerId title status displayScore lastChapterRead
totalChapters remoteUrl private libraryId
manga { id title thumbnailUrl inLibrary }
}
}
}
}
}
`;
export const GET_TRACKER_RECORDS = `
query GetTrackerRecords($trackerId: Int!) {
trackers(condition: { id: $trackerId }) {
nodes {
id name
statuses { value name }
trackRecords {
nodes {
id title status displayScore lastChapterRead totalChapters remoteUrl
manga { id title thumbnailUrl }
}
}
}
}
}
`;
-23
View File
@@ -1,23 +0,0 @@
export const GET_ABOUT_SERVER = `
query GetAboutServer {
aboutServer {
name version buildType buildTime github discord
}
}
`;
export const GET_ABOUT_WEBUI = `
query GetAboutWebUI {
aboutWebUI {
channel tag updateTimestamp
}
}
`;
export const CHECK_FOR_SERVER_UPDATES = `
query CheckForServerUpdates {
checkForServerUpdates {
channel tag url
}
}
`;
-1
View File
@@ -1 +0,0 @@
export * from "./selectPortal";
-38
View File
@@ -1,38 +0,0 @@
import type { Attachment } from "svelte/attachments";
export function selectPortal(triggerEl: HTMLElement & { __selectMenuEl?: HTMLElement | null }): Attachment {
return (menuEl: HTMLElement) => {
function position() {
const zoom = parseFloat(document.documentElement.style.zoom) / 100 || 1;
const r = triggerEl.getBoundingClientRect();
const top = r.bottom / zoom + 4;
const right = r.right / zoom;
const width = menuEl.offsetWidth;
const left = Math.max(8, right - width);
menuEl.style.position = "fixed";
menuEl.style.top = `${top}px`;
menuEl.style.left = `${left}px`;
}
menuEl.style.visibility = "hidden";
document.body.appendChild(menuEl);
triggerEl.__selectMenuEl = menuEl;
requestAnimationFrame(() => {
position();
menuEl.style.visibility = "";
});
window.addEventListener("scroll", position, true);
window.addEventListener("resize", position);
return () => {
window.removeEventListener("scroll", position, true);
window.removeEventListener("resize", position);
triggerEl.__selectMenuEl = null;
menuEl.remove();
};
};
}
-5
View File
@@ -1,5 +0,0 @@
export { shouldHideNsfw, shouldHideSource, dedupeSourcesByLang } from "@core/util";
export function buildFilter<T>(...predicates: ((item: T) => boolean)[]): (item: T) => boolean {
return (item) => predicates.every((p) => p(item));
}
-5
View File
@@ -1,5 +0,0 @@
export * from './sort';
export * from './filter';
export * from './paginate';
export * from './search';
export * from './queue';
-29
View File
@@ -1,29 +0,0 @@
export interface PaginationState {
visible: number;
}
export interface PaginationResult<T> {
items: T[];
hasMore: boolean;
remaining: number;
}
export function createPaginator<T>(pageSize: number) {
return {
slice(all: T[], visible: number): PaginationResult<T> {
return {
items: all.slice(0, visible),
hasMore: all.length > visible,
remaining: Math.max(0, all.length - visible),
};
},
nextVisible(current: number): number {
return current + pageSize;
},
reset(): number {
return pageSize;
},
};
}
-29
View File
@@ -1,29 +0,0 @@
export interface AsyncQueue<T> {
enqueue(item: T): void;
drain(): void;
clear(): void;
size(): number;
}
export function createAsyncQueue<T>(
worker: (item: T) => Promise<void>,
concurrency = 1,
): AsyncQueue<T> {
const queue: T[] = [];
let active = 0;
function next() {
while (active < concurrency && queue.length > 0) {
const item = queue.shift()!;
active++;
worker(item).finally(() => { active--; next(); });
}
}
return {
enqueue(item) { queue.push(item); next(); },
drain() { next(); },
clear() { queue.length = 0; },
size() { return queue.length; },
};
}
-33
View File
@@ -1,33 +0,0 @@
export interface SearchResult<T> {
item: T;
score: number;
}
export function searchItems<T>(
items: T[],
query: string,
getField: (item: T) => string,
): T[] {
const q = query.trim().toLowerCase();
if (!q) return items;
return items.filter(item => getField(item).toLowerCase().includes(q));
}
export function searchWithScore<T>(
items: T[],
query: string,
getField: (item: T) => string,
): SearchResult<T>[] {
const q = query.trim().toLowerCase();
if (!q) return items.map(item => ({ item, score: 0 }));
return items
.map(item => {
const field = getField(item).toLowerCase();
if (!field.includes(q)) return null;
const score = field === q ? 2 : field.startsWith(q) ? 1 : 0;
return { item, score };
})
.filter((r): r is SearchResult<T> => r !== null)
.sort((a, b) => b.score - a.score);
}
-32
View File
@@ -1,32 +0,0 @@
export type SortDir = "asc" | "desc";
export interface SortField<T> {
key: string;
comparator: (a: T, b: T, context?: Record<string, unknown>) => number;
}
export interface SortConfig<T> {
fields: SortField<T>[];
defaultField: string;
defaultDir: SortDir;
}
export interface Sorter<T> {
sort(items: T[], field: string, dir: SortDir, context?: Record<string, unknown>): T[];
}
export function createSorter<T>(config: SortConfig<T>): Sorter<T> {
const fieldMap = new Map(config.fields.map(f => [f.key, f]));
return {
sort(items, field, dir, context) {
const f = fieldMap.get(field) ?? fieldMap.get(config.defaultField);
if (!f) return [...items];
const d = dir ?? config.defaultDir;
return [...items].sort((a, b) => {
const cmp = f.comparator(a, b, context);
return d === "asc" ? cmp : -cmp;
});
},
};
}
-61
View File
@@ -1,61 +0,0 @@
/**
* Runs an async task over every item in `items`, with at most `concurrency`
* tasks in-flight at once. Respects the provided AbortSignal — each worker
* exits early if the signal fires. Errors thrown by individual tasks are
* swallowed so one failure does not cancel the whole batch.
*/
export async function runConcurrent<T>(
items: T[],
fn: (item: T) => Promise<void>,
signal: AbortSignal,
concurrency = 6,
): Promise<void> {
let i = 0;
async function worker() {
while (i < items.length) {
if (signal.aborted) return;
const item = items[i++];
await fn(item).catch(() => {});
}
}
await Promise.all(
Array.from({ length: Math.min(concurrency, items.length) }, worker),
);
}
/**
* Deduplicates in-flight async calls by key.
*
* Two call signatures are supported:
*
* 1. Direct call — supply a key and a zero-arg factory each time:
* dedupeRequest("my-key", () => fetchSomething())
* If a request with that key is already pending, the existing Promise is
* returned and the factory is not called again.
*
* 2. Curried wrapper — supply a key-based fetcher once, get back a
* single-arg function you can call repeatedly:
* const get = dedupeRequest((key) => fetchSomething(key))
* get("my-key")
*/
const _inflight = new Map<string, Promise<unknown>>();
export function dedupeRequest<T>(key: string, factory: () => Promise<T>): Promise<T>;
export function dedupeRequest<T>(fn: (key: string) => Promise<T>): (key: string) => Promise<T>;
export function dedupeRequest<T>(
keyOrFn: string | ((key: string) => Promise<T>),
factory?: () => Promise<T>,
): Promise<T> | ((key: string) => Promise<T>) {
// Curried wrapper form
if (typeof keyOrFn === 'function') {
const fn = keyOrFn;
return (key: string) => dedupeRequest(key, () => fn(key));
}
// Direct call form
const key = keyOrFn;
if (_inflight.has(key)) return _inflight.get(key) as Promise<T>;
const p = factory!().finally(() => _inflight.delete(key));
_inflight.set(key, p);
return p;
}
-25
View File
@@ -1,25 +0,0 @@
export interface PaginatedQuery<T> {
fetchPage(page: number): Promise<T[]>;
reset(): void;
hasMore(): boolean;
}
export interface PaginatedQueryConfig<T> {
fetcher: (page: number) => Promise<{ items: T[]; hasNextPage: boolean }>;
}
export function createPaginatedQuery<T>(
config: PaginatedQueryConfig<T>,
): PaginatedQuery<T> {
let _hasMore = true;
return {
async fetchPage(page) {
const { items, hasNextPage } = await config.fetcher(page);
_hasMore = hasNextPage;
return items;
},
reset() { _hasMore = true; },
hasMore() { return _hasMore; },
};
}
-31
View File
@@ -1,31 +0,0 @@
export interface RetryOptions {
maxAttempts?: number;
baseDelayMs?: number;
maxDelayMs?: number;
shouldRetry?: (err: unknown, attempt: number) => boolean;
}
export async function fetchWithRetry<T>(
fetcher: () => Promise<T>,
options: RetryOptions = {},
): Promise<T> {
const {
maxAttempts = 3,
baseDelayMs = 500,
maxDelayMs = 10_000,
shouldRetry = () => true,
} = options;
let lastErr: unknown;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await fetcher();
} catch (err) {
lastErr = err;
if (attempt === maxAttempts || !shouldRetry(err, attempt)) throw err;
const delay = Math.min(baseDelayMs * 2 ** (attempt - 1), maxDelayMs);
await new Promise(r => setTimeout(r, delay));
}
}
throw lastErr;
}
-3
View File
@@ -1,3 +0,0 @@
export * from './fetchWithRetry';
export * from './batchRequests';
export * from './createPaginatedQuery';
-629
View File
@@ -1,629 +0,0 @@
import { store, updateSettings } from "@store/state.svelte";
export type AuthMode = "NONE" | "BASIC_AUTH" | "UI_LOGIN";
export class AuthRequiredError extends Error {
constructor(msg = "Authentication required") {
super(msg);
this.name = "AuthRequiredError";
}
}
const TOKEN_KEY = "moku_access_token";
const UI_SESSION_KEY = "moku_ui_auth_session";
const TOKEN_REFRESH_SKEW_MS = 30_000;
const AUTH_DEBUG = Boolean((import.meta as ImportMeta & { env?: { DEV?: boolean } }).env?.DEV);
interface StoredAccessToken {
base: string;
token: string;
}
interface StoredUiAuthSession {
base: string;
accessToken: string;
refreshToken?: string;
clientMutationId?: string;
accessExpiresAt?: number | null;
refreshExpiresAt?: number | null;
}
interface JwtSettings {
jwtAudience?: string | null;
jwtRefreshExpiry?: string | null;
jwtTokenExpiry?: string | null;
}
export interface UiAuthDebugStatus {
mode: AuthMode;
serverBase: string;
hasSession: boolean;
hasRefreshToken: boolean;
accessExpiresAt: number | null;
refreshExpiresAt: number | null;
accessExpiresInMs: number | null;
refreshExpiresInMs: number | null;
shouldRefreshSoon: boolean;
refreshInFlight: boolean;
skewMs: number;
}
let _accessToken: string | null = null;
let _accessTokenBase: string | null = null;
let _uiSession: StoredUiAuthSession | null = null;
let _refreshPromise: Promise<string | null> | null = null;
let _jwtSettingsBase: string | null = null;
let _jwtSettings: JwtSettings | null = null;
let _jwtSettingsFetchedAt = 0;
function authDebug(event: string, fields?: Record<string, unknown>) {
if (!AUTH_DEBUG) return;
if (fields) {
console.debug(`[auth] ${event}`, fields);
return;
}
console.debug(`[auth] ${event}`);
}
function parseIsoDuration(duration: string): number | null {
try {
const match = duration.match(
/^P(?:(\d+)Y)?(?:(\d+)M)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:([\d.]+)S)?)?$/
);
if (!match) return null;
const [, years, months, days, hours, minutes, seconds] = match;
let ms = 0;
if (years) ms += parseInt(years) * 365.25 * 24 * 60 * 60 * 1000;
if (months) ms += parseInt(months) * 30.44 * 24 * 60 * 60 * 1000;
if (days) ms += parseInt(days) * 24 * 60 * 60 * 1000;
if (hours) ms += parseInt(hours) * 60 * 60 * 1000;
if (minutes) ms += parseInt(minutes) * 60 * 1000;
if (seconds) ms += parseFloat(seconds) * 1000;
return ms;
} catch {
return null;
}
}
function decodeJwtExpiryMs(token: string): number | null {
try {
const payload = token.split(".")[1];
if (!payload) return null;
const normalized = payload.replace(/-/g, "+").replace(/_/g, "/");
const padded = normalized.padEnd(normalized.length + ((4 - (normalized.length % 4)) % 4), "=");
const decoded = atob(padded);
const json = JSON.parse(decoded) as { exp?: number };
return typeof json.exp === "number" ? json.exp * 1000 : null;
} catch {
return null;
}
}
function isExpired(expiresAt?: number | null, skewMs = TOKEN_REFRESH_SKEW_MS): boolean {
if (!expiresAt || !Number.isFinite(expiresAt)) return false;
return Date.now() >= expiresAt - skewMs;
}
function withExpiryFromSettings(
accessToken: string,
jwt: JwtSettings | null,
): Pick<StoredUiAuthSession, "accessExpiresAt" | "refreshExpiresAt"> {
const now = Date.now();
const accessExpiresAt =
decodeJwtExpiryMs(accessToken)
?? (typeof jwt?.jwtTokenExpiry === "string" ? now + (parseIsoDuration(jwt.jwtTokenExpiry) ?? 0) : null);
const refreshExpiresAt =
typeof jwt?.jwtRefreshExpiry === "string" ? now + (parseIsoDuration(jwt.jwtRefreshExpiry) ?? 0) : null;
return { accessExpiresAt, refreshExpiresAt };
}
async function fetchJwtSettings(base: string): Promise<JwtSettings | null> {
const res = await fetchAuthenticated(
`${base}/api/graphql`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: gqlBody(
`query GetJWTSettings {
settings {
jwtAudience
jwtRefreshExpiry
jwtTokenExpiry
}
}`,
),
},
timeoutSignal(5000),
);
if (!res.ok) {
authDebug("JWT settings fetch failed", { status: res.status });
return null;
}
const json = await res.json();
if (json?.errors?.length) {
authDebug("JWT settings query error", { errors: json.errors });
return null;
}
const settings = json?.data?.settings;
if (!settings || typeof settings !== "object") {
authDebug("JWT settings missing or invalid", { settings });
return null;
}
authDebug("JWT settings fetched", {
hasAudience: !!settings.jwtAudience,
tokenExpiry: settings.jwtTokenExpiry,
refreshExpiry: settings.jwtRefreshExpiry,
});
return {
jwtAudience: typeof settings.jwtAudience === "string" ? settings.jwtAudience : null,
jwtRefreshExpiry: typeof settings.jwtRefreshExpiry === "string" ? settings.jwtRefreshExpiry : null,
jwtTokenExpiry: typeof settings.jwtTokenExpiry === "string" ? settings.jwtTokenExpiry : null,
};
}
async function getJwtSettings(force = false): Promise<JwtSettings | null> {
const base = getServerBase();
const freshEnough = Date.now() - _jwtSettingsFetchedAt < 60_000;
if (!force && _jwtSettingsBase === base && _jwtSettings && freshEnough) return _jwtSettings;
const jwt = await fetchJwtSettings(base);
_jwtSettingsBase = base;
_jwtSettings = jwt;
_jwtSettingsFetchedAt = Date.now();
return jwt;
}
export const uiAuth = {
getSession: () => {
const base = getServerBase();
if (_uiSession && _uiSession.base === base) return _uiSession;
const stored = readStoredSession();
if (!stored) return null;
if (stored.base !== base) {
sessionStorage.removeItem(UI_SESSION_KEY);
sessionStorage.removeItem(TOKEN_KEY);
_uiSession = null;
_accessToken = null;
_accessTokenBase = null;
return null;
}
_uiSession = stored;
_accessToken = stored.accessToken;
_accessTokenBase = stored.base;
return _uiSession;
},
setSession: (session: Omit<StoredUiAuthSession, "base">) => {
const base = getServerBase();
_uiSession = { ...session, base };
_accessToken = session.accessToken;
_accessTokenBase = base;
sessionStorage.setItem(UI_SESSION_KEY, JSON.stringify(_uiSession));
sessionStorage.removeItem(TOKEN_KEY);
},
getToken: () => {
const session = uiAuth.getSession();
if (!session) return null;
if (isExpired(session.accessExpiresAt, 0)) return null;
const base = getServerBase();
if (_accessToken && _accessTokenBase === base) return _accessToken;
const stored = readStoredToken();
if (!stored) return null;
if (stored.base !== base) {
sessionStorage.removeItem(TOKEN_KEY);
sessionStorage.removeItem(UI_SESSION_KEY);
_accessToken = null;
_accessTokenBase = null;
_uiSession = null;
return null;
}
_accessToken = stored.token;
_accessTokenBase = stored.base;
return _accessToken;
},
setToken: (t: string) => {
const existing = uiAuth.getSession();
if (existing?.refreshToken) {
uiAuth.setSession({
...existing,
accessToken: t,
...withExpiryFromSettings(t, _jwtSettings),
});
return;
}
const base = getServerBase();
_accessToken = t;
_accessTokenBase = base;
sessionStorage.setItem(TOKEN_KEY, JSON.stringify({ base, token: t }));
},
setLoginSession: (payload: { accessToken: string; refreshToken: string; clientMutationId?: string }, jwt: JwtSettings | null) => {
uiAuth.setSession({
accessToken: payload.accessToken,
refreshToken: payload.refreshToken,
clientMutationId: payload.clientMutationId,
...withExpiryFromSettings(payload.accessToken, jwt),
});
},
updateAccessToken: (payload: { accessToken: string; clientMutationId?: string }, jwt: JwtSettings | null) => {
const existing = uiAuth.getSession();
if (!existing?.refreshToken) {
uiAuth.setToken(payload.accessToken);
return;
}
uiAuth.setSession({
...existing,
accessToken: payload.accessToken,
clientMutationId: payload.clientMutationId ?? existing.clientMutationId,
...withExpiryFromSettings(payload.accessToken, jwt),
refreshToken: existing.refreshToken,
});
},
clearToken: () => {
_accessToken = null;
_accessTokenBase = null;
_uiSession = null;
sessionStorage.removeItem(TOKEN_KEY);
sessionStorage.removeItem(UI_SESSION_KEY);
},
};
export const authSession = {
clearTokens() {
_refreshPromise = null;
_jwtSettings = null;
_jwtSettingsBase = null;
_jwtSettingsFetchedAt = 0;
uiAuth.clearToken();
},
hasSession(): boolean {
const mode = store.settings.serverAuthMode ?? "NONE";
if (mode === "UI_LOGIN") return uiAuth.getSession() !== null;
return true;
},
};
function getServerBase(): string {
const url = store.settings.serverUrl;
return typeof url === "string" && url.trim() ? url.replace(/\/$/, "") : "http://127.0.0.1:4567";
}
function readStoredToken(): StoredAccessToken | null {
const session = readStoredSession();
if (session) return { base: session.base, token: session.accessToken };
const raw = sessionStorage.getItem(TOKEN_KEY);
if (raw?.trim()) {
try {
const parsed = JSON.parse(raw);
if (typeof parsed?.base === "string" && typeof parsed?.token === "string")
return { base: parsed.base, token: parsed.token };
} catch {}
const migrated = { base: getServerBase(), token: raw.trim() };
sessionStorage.setItem(TOKEN_KEY, JSON.stringify(migrated));
return migrated;
}
return null;
}
function readStoredSession(): StoredUiAuthSession | null {
const raw = sessionStorage.getItem(UI_SESSION_KEY);
if (raw?.trim()) {
try {
const parsed = JSON.parse(raw);
if (typeof parsed?.base === "string" && typeof parsed?.accessToken === "string") {
return {
base: parsed.base,
accessToken: parsed.accessToken,
refreshToken: typeof parsed.refreshToken === "string" ? parsed.refreshToken : undefined,
clientMutationId: typeof parsed.clientMutationId === "string" ? parsed.clientMutationId : undefined,
accessExpiresAt: typeof parsed.accessExpiresAt === "number" ? parsed.accessExpiresAt : null,
refreshExpiresAt: typeof parsed.refreshExpiresAt === "number" ? parsed.refreshExpiresAt : null,
};
}
} catch {}
}
const legacy = sessionStorage.getItem(TOKEN_KEY);
if (!legacy?.trim()) return null;
try {
const parsed = JSON.parse(legacy);
if (typeof parsed?.base === "string" && typeof parsed?.token === "string") {
const migrated: StoredUiAuthSession = { base: parsed.base, accessToken: parsed.token };
sessionStorage.setItem(UI_SESSION_KEY, JSON.stringify(migrated));
return migrated;
}
} catch {}
const migrated: StoredUiAuthSession = { base: getServerBase(), accessToken: legacy.trim() };
sessionStorage.setItem(UI_SESSION_KEY, JSON.stringify(migrated));
return migrated;
}
function timeoutSignal(ms: number): AbortSignal {
const controller = new AbortController();
setTimeout(() => controller.abort(), ms);
return controller.signal;
}
function basicHeader(user: string, pass: string): Record<string, string> {
return { Authorization: `Basic ${btoa(`${user}:${pass}`)}` };
}
function bearerHeader(token: string): Record<string, string> {
return { Authorization: `Bearer ${token}` };
}
function gqlBody(query: string, variables?: Record<string, unknown>): string {
return JSON.stringify({ query, ...(variables ? { variables } : {}) });
}
export async function fetchAuthenticated(
url: string,
init: RequestInit,
signal?: AbortSignal,
skipped = false,
): Promise<Response> {
const mode = store.settings.serverAuthMode ?? "NONE";
const baseHeaders = (init.headers ?? {}) as Record<string, string>;
if (mode === "BASIC_AUTH") {
const user = store.settings.serverAuthUser?.trim() ?? "";
const pass = store.settings.serverAuthPass?.trim() ?? "";
return fetch(url, {
...init, signal, credentials: "omit",
headers: { ...baseHeaders, ...(user && pass ? basicHeader(user, pass) : {}) },
});
}
if (mode === "UI_LOGIN") {
const token = await getUIAccessToken();
if (!token) {
if (skipped) return fetch(url, { ...init, signal, credentials: "omit", headers: baseHeaders });
throw new AuthRequiredError();
}
let res = await fetch(url, {
...init, signal, credentials: "omit",
headers: { ...baseHeaders, ...bearerHeader(token) },
});
if (res.status !== 401 || skipped) return res;
const refreshed = await refreshUiAccessToken(true);
if (!refreshed) return res;
res = await fetch(url, {
...init, signal, credentials: "omit",
headers: { ...baseHeaders, ...bearerHeader(refreshed) },
});
return res;
}
return fetch(url, { ...init, signal, credentials: "omit" });
}
export async function getUIAccessToken(forceRefresh = false): Promise<string | null> {
const session = uiAuth.getSession();
if (!session) return null;
if (forceRefresh || isExpired(session.accessExpiresAt)) {
return refreshUiAccessToken(true);
}
return session.accessToken;
}
export async function refreshUiAccessToken(force = false): Promise<string | null> {
const session = uiAuth.getSession();
if (!session) return null;
if (!session.refreshToken) {
if (force && isExpired(session.accessExpiresAt, 0)) return null;
return session.accessToken;
}
if (!force && !isExpired(session.accessExpiresAt)) return session.accessToken;
if (isExpired(session.refreshExpiresAt)) {
authDebug("refresh skipped: refresh token expired", {
force,
refreshExpiresAt: session.refreshExpiresAt ?? null,
});
uiAuth.clearToken();
return null;
}
if (_refreshPromise) {
authDebug("refresh joined existing request");
return _refreshPromise;
}
authDebug("refresh start", {
force,
accessExpiresAt: session.accessExpiresAt ?? null,
refreshExpiresAt: session.refreshExpiresAt ?? null,
});
_refreshPromise = (async () => {
const base = getServerBase();
const jwt = await getJwtSettings().catch(() => null);
const res = await fetch(`${base}/api/graphql`, {
method: "POST",
credentials: "omit",
headers: { "Content-Type": "application/json" },
body: gqlBody(
`mutation RefreshToken($refreshToken: String!, $clientMutationId: String) {
refreshToken(input: { refreshToken: $refreshToken, clientMutationId: $clientMutationId }) {
accessToken
clientMutationId
}
}`,
{ refreshToken: session.refreshToken, clientMutationId: session.clientMutationId ?? undefined },
),
signal: timeoutSignal(5000),
});
if (!res.ok) {
if (res.status === 401 || res.status === 403) {
authDebug("refresh rejected by server", { status: res.status });
uiAuth.clearToken();
return null;
}
authDebug("refresh failed with HTTP error", { status: res.status });
throw new Error(`Token refresh failed (${res.status})`);
}
const json = await res.json();
const refreshed = json?.data?.refreshToken;
const nextAccessToken: string | undefined = refreshed?.accessToken;
if (!nextAccessToken) {
const msg = json?.errors?.[0]?.message;
if (msg && /unauthorized|unauthenticated|forbidden/i.test(msg)) {
authDebug("refresh rejected by GraphQL error", { message: msg });
uiAuth.clearToken();
return null;
}
authDebug("refresh returned no access token", { message: msg ?? null });
throw new Error(msg ?? "Token refresh failed");
}
uiAuth.updateAccessToken(
{
accessToken: nextAccessToken,
clientMutationId: typeof refreshed?.clientMutationId === "string"
? refreshed.clientMutationId
: session.clientMutationId,
},
jwt,
);
authDebug("refresh success", {
nextAccessExpiresAt: uiAuth.getSession()?.accessExpiresAt ?? null,
});
return nextAccessToken;
})()
.catch((e: unknown) => {
authDebug("refresh threw error", {
message: e instanceof Error ? e.message : String(e),
});
throw e;
})
.finally(() => {
_refreshPromise = null;
});
return _refreshPromise;
}
export function getUiAuthDebugStatus(now = Date.now()): UiAuthDebugStatus {
const session = uiAuth.getSession();
const accessExpiresAt = session?.accessExpiresAt ?? null;
const refreshExpiresAt = session?.refreshExpiresAt ?? null;
return {
mode: (store.settings.serverAuthMode ?? "NONE") as AuthMode,
serverBase: getServerBase(),
hasSession: !!session,
hasRefreshToken: !!session?.refreshToken,
accessExpiresAt,
refreshExpiresAt,
accessExpiresInMs: accessExpiresAt ? accessExpiresAt - now : null,
refreshExpiresInMs: refreshExpiresAt ? refreshExpiresAt - now : null,
shouldRefreshSoon: isExpired(accessExpiresAt),
refreshInFlight: _refreshPromise !== null,
skewMs: TOKEN_REFRESH_SKEW_MS,
};
}
export async function loginUI(user: string, pass: string): Promise<void> {
const res = await fetch(`${getServerBase()}/api/graphql`, {
method: "POST", credentials: "omit",
headers: { "Content-Type": "application/json" },
body: gqlBody(
`mutation Login($username: String!, $password: String!) {
login(input: { username: $username, password: $password }) {
accessToken
refreshToken
clientMutationId
}
}`,
{ username: user, password: pass },
),
signal: timeoutSignal(8000),
});
if (!res.ok) throw new Error(`Login request failed (${res.status})`);
const json = await res.json();
const payload = json?.data?.login;
const accessToken: string | undefined = payload?.accessToken;
const refreshToken: string | undefined = payload?.refreshToken;
if (!accessToken || !refreshToken) throw new Error(json?.errors?.[0]?.message ?? "Login failed");
authDebug("login success", { user });
const preliminarySession = {
accessToken,
refreshToken,
clientMutationId: typeof payload?.clientMutationId === "string" ? payload.clientMutationId : undefined,
};
uiAuth.setLoginSession(preliminarySession, null);
updateSettings({ serverAuthMode: "UI_LOGIN", serverAuthUser: user, serverAuthPass: "" });
const jwt = await getJwtSettings(true).catch(() => null);
uiAuth.setLoginSession(preliminarySession, jwt);
}
export async function loginBasic(user: string, pass: string): Promise<void> {
const res = await fetch(`${getServerBase()}/api/graphql`, {
method: "POST", credentials: "omit",
headers: { "Content-Type": "application/json", ...basicHeader(user, pass) },
body: gqlBody("{ __typename }"),
signal: timeoutSignal(5000),
});
if (!res.ok) throw new Error(`Authentication failed (${res.status})`);
updateSettings({ serverAuthMode: "BASIC_AUTH", serverAuthUser: user, serverAuthPass: pass });
}
export async function logout(): Promise<void> {
uiAuth.clearToken();
updateSettings({ serverAuthPass: "", serverAuthMode: "NONE" });
}
export async function probeServer(): Promise<"ok" | "auth_required" | "unreachable"> {
const base = getServerBase();
const mode = store.settings.serverAuthMode ?? "NONE";
const s = store.settings;
const token = mode === "UI_LOGIN" ? await getUIAccessToken() : null;
if (mode === "UI_LOGIN" && !token) return "auth_required";
try {
const headers: Record<string, string> = { "Content-Type": "application/json" };
if (mode === "BASIC_AUTH") {
const user = s.serverAuthUser?.trim() ?? "";
const pass = s.serverAuthPass?.trim() ?? "";
if (user && pass) Object.assign(headers, basicHeader(user, pass));
} else if (mode === "UI_LOGIN" && token) {
Object.assign(headers, bearerHeader(token));
}
const res = await fetch(`${base}/api/graphql`, {
method: "POST", credentials: "omit", headers,
body: gqlBody("{ __typename }"),
signal: timeoutSignal(5000),
});
if (res.ok) return "ok";
if (res.status === 401) return "auth_required";
return "unreachable";
} catch {
return "unreachable";
}
}
-256
View File
@@ -1,256 +0,0 @@
import { invoke } from "@tauri-apps/api/core";
import {
persistSettings,
persistLibrary,
persistUpdates,
} from "@core/persistence/persist";
const STORE_FILES = ["settings.json", "library.json", "updates.json"] as const;
export async function exportAppData(): Promise<void> {
const entries: [string, string][] = await invoke("read_store_files", {
names: [...STORE_FILES],
});
const zip = buildZip(
entries.map(([name, content]) => ({
name,
bytes: new TextEncoder().encode(content),
}))
);
await invoke("export_app_data", { bytes: Array.from(zip) });
}
export async function importAppData(): Promise<void> {
const raw: number[] = await invoke("import_app_data");
const files = parseZip(new Uint8Array(raw));
const decode = (name: string) => {
const bytes = files.get(name);
if (!bytes) throw new Error(`Backup is missing ${name}`);
return JSON.parse(new TextDecoder().decode(bytes));
};
const s = decode("settings.json");
const l = decode("library.json");
const u = decode("updates.json");
await Promise.all([
persistSettings({
settings: s.settings ?? null,
storeVersion: s.storeVersion ?? 1,
}),
persistLibrary({
history: l.history ?? [],
bookmarks: l.bookmarks ?? [],
markers: l.markers ?? [],
readLog: l.readLog ?? [],
readingStats: l.readingStats ?? null,
dailyReadCounts: l.dailyReadCounts ?? {},
}),
persistUpdates({
libraryUpdates: u.libraryUpdates ?? [],
lastLibraryRefresh: u.lastLibraryRefresh ?? 0,
acknowledgedUpdateIds: u.acknowledgedUpdateIds ?? [],
}),
]);
await showExitModal();
invoke("exit_app");
}
function showExitModal(): Promise<void> {
return new Promise(resolve => {
const backdrop = document.createElement("div");
backdrop.className = "s-backdrop";
backdrop.style.cssText = "z-index:99999";
const modal = document.createElement("div");
modal.style.cssText = [
"background:var(--bg-surface)",
"border:1px solid var(--border-base)",
"border-radius:var(--radius-2xl)",
"box-shadow:0 0 0 1px rgba(255,255,255,0.04) inset,0 24px 80px rgba(0,0,0,0.7),0 8px 24px rgba(0,0,0,0.4)",
"width:min(400px,calc(100vw - 40px))",
"display:flex",
"flex-direction:column",
"overflow:hidden",
"animation:s-scale-in 0.2s cubic-bezier(0.16,1,0.3,1) both",
].join(";");
const header = document.createElement("div");
header.style.cssText = "padding:var(--sp-4) var(--sp-5) var(--sp-3);border-bottom:1px solid var(--border-dim)";
const title = document.createElement("p");
title.style.cssText = "margin:0;font-size:var(--text-sm);font-weight:var(--weight-medium);color:var(--text-primary);letter-spacing:0.01em";
title.textContent = "Import complete";
header.appendChild(title);
const body = document.createElement("div");
body.style.cssText = "padding:var(--sp-4) var(--sp-5);display:flex;flex-direction:column;gap:var(--sp-2)";
const sub = document.createElement("p");
sub.style.cssText = "margin:0;font-family:var(--font-ui);font-size:var(--text-xs);color:var(--text-faint);letter-spacing:var(--tracking-wide);line-height:var(--leading-snug)";
sub.textContent = "Your settings have been restored. Moku will close so you can relaunch with the imported data.";
const counter = document.createElement("p");
counter.style.cssText = "margin:0;font-family:var(--font-ui);font-size:var(--text-xs);color:var(--text-faint);letter-spacing:var(--tracking-wide)";
counter.textContent = "Closing in 3…";
body.append(sub, counter);
const footer = document.createElement("div");
footer.style.cssText = "padding:var(--sp-3) var(--sp-5);border-top:1px solid var(--border-dim);display:flex;justify-content:flex-end";
const btn = document.createElement("button");
btn.className = "s-btn s-btn-danger";
btn.textContent = "Close now";
footer.appendChild(btn);
modal.append(header, body, footer);
backdrop.appendChild(modal);
document.body.appendChild(backdrop);
let secs = 3;
const tick = setInterval(() => {
secs--;
counter.textContent = secs > 0 ? `Closing in ${secs}` : "Closing…";
if (secs <= 0) { clearInterval(tick); backdrop.remove(); resolve(); }
}, 1000);
btn.addEventListener("click", () => { clearInterval(tick); backdrop.remove(); resolve(); });
});
}
export async function autoBackupAppData(): Promise<void> {
try {
const entries: [string, string][] = await invoke("read_store_files", {
names: [...STORE_FILES],
});
const zip = buildZip(
entries.map(([name, content]) => ({
name,
bytes: new TextEncoder().encode(content),
}))
);
await invoke("auto_backup_app_data", { bytes: Array.from(zip) });
} catch (e) {
console.warn("[moku] auto-backup failed:", e);
}
}
function crc32(data: Uint8Array): number {
let crc = 0xffffffff;
for (const byte of data) {
crc ^= byte;
for (let j = 0; j < 8; j++) {
crc = crc & 1 ? (crc >>> 1) ^ 0xedb88320 : crc >>> 1;
}
}
return (crc ^ 0xffffffff) >>> 0;
}
function localHeader(name: Uint8Array, data: Uint8Array): Uint8Array {
const buf = new ArrayBuffer(30 + name.byteLength);
const v = new DataView(buf);
v.setUint32(0, 0x04034b50, true);
v.setUint16(4, 20, true);
v.setUint16(6, 0, true);
v.setUint16(8, 0, true);
v.setUint16(10, 0, true);
v.setUint16(12, 0, true);
v.setUint32(14, crc32(data), true);
v.setUint32(18, data.byteLength, true);
v.setUint32(22, data.byteLength, true);
v.setUint16(26, name.byteLength, true);
v.setUint16(28, 0, true);
new Uint8Array(buf).set(name, 30);
return new Uint8Array(buf);
}
function centralHeader(name: Uint8Array, data: Uint8Array, offset: number): Uint8Array {
const buf = new ArrayBuffer(46 + name.byteLength);
const v = new DataView(buf);
v.setUint32(0, 0x02014b50, true);
v.setUint16(4, 20, true);
v.setUint16(6, 20, true);
v.setUint16(8, 0, true);
v.setUint16(10, 0, true);
v.setUint16(12, 0, true);
v.setUint16(14, 0, true);
v.setUint32(16, crc32(data), true);
v.setUint32(20, data.byteLength, true);
v.setUint32(24, data.byteLength, true);
v.setUint16(28, name.byteLength, true);
v.setUint16(30, 0, true);
v.setUint16(32, 0, true);
v.setUint16(34, 0, true);
v.setUint16(36, 0, true);
v.setUint32(38, 0, true);
v.setUint32(42, offset, true);
new Uint8Array(buf).set(name, 46);
return new Uint8Array(buf);
}
function eocd(count: number, cdSize: number, cdOffset: number): Uint8Array {
const buf = new ArrayBuffer(22);
const v = new DataView(buf);
v.setUint32(0, 0x06054b50, true);
v.setUint16(4, 0, true);
v.setUint16(6, 0, true);
v.setUint16(8, count, true);
v.setUint16(10, count, true);
v.setUint32(12, cdSize, true);
v.setUint32(16, cdOffset, true);
v.setUint16(20, 0, true);
return new Uint8Array(buf);
}
function buildZip(files: { name: string; bytes: Uint8Array }[]): Uint8Array {
const enc = new TextEncoder();
const parts: Uint8Array[] = [];
const offsets: number[] = [];
let pos = 0;
for (const { name, bytes } of files) {
const nameBytes = enc.encode(name);
const lh = localHeader(nameBytes, bytes);
offsets.push(pos);
parts.push(lh, bytes);
pos += lh.byteLength + bytes.byteLength;
}
const cdParts = files.map(({ name, bytes }, i) =>
centralHeader(enc.encode(name), bytes, offsets[i])
);
const cd = concat(cdParts);
return concat([...parts, cd, eocd(files.length, cd.byteLength, pos)]);
}
function parseZip(data: Uint8Array): Map<string, Uint8Array> {
const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
const files = new Map<string, Uint8Array>();
let pos = 0;
while (pos + 30 <= data.byteLength && view.getUint32(pos, true) === 0x04034b50) {
const fnLen = view.getUint16(pos + 26, true);
const exLen = view.getUint16(pos + 28, true);
const cSize = view.getUint32(pos + 18, true);
const name = new TextDecoder().decode(data.subarray(pos + 30, pos + 30 + fnLen));
const start = pos + 30 + fnLen + exLen;
files.set(name, data.subarray(start, start + cSize));
pos = start + cSize;
}
return files;
}
function concat(arrays: Uint8Array[]): Uint8Array {
const total = arrays.reduce((n, a) => n + a.byteLength, 0);
const out = new Uint8Array(total);
let pos = 0;
for (const a of arrays) { out.set(a, pos); pos += a.byteLength; }
return out;
}
-134
View File
@@ -1,134 +0,0 @@
import { fetch as tauriFetch } from "@tauri-apps/plugin-http";
import { store } from "@store/state.svelte";
import { getUIAccessToken } from "@core/auth";
const cache = new Map<string, string>();
const inflight = new Map<string, Promise<string>>();
const MAX_CONCURRENT = 6;
let active = 0;
let drainScheduled = false;
let clearing = false;
interface QueueEntry {
url: string;
priority: number;
resolve: (v: string) => void;
reject: (e: unknown) => void;
}
const queue: QueueEntry[] = [];
async function getAuthHeaders(): Promise<Record<string, string>> {
const mode = store.settings.serverAuthMode ?? "NONE";
if (mode === "UI_LOGIN") {
const token = await getUIAccessToken();
return token ? { Authorization: `Bearer ${token}` } : {};
}
if (mode === "BASIC_AUTH") {
const user = store.settings.serverAuthUser?.trim() ?? "";
const pass = store.settings.serverAuthPass?.trim() ?? "";
return user && pass ? { Authorization: `Basic ${btoa(`${user}:${pass}`)}` } : {};
}
return {};
}
async function doFetch(url: string): Promise<string> {
const headers = await getAuthHeaders();
const res = await tauriFetch(url, { method: "GET", headers });
if (!res.ok) throw new Error(`${res.status}`);
const blob = await res.blob();
if (clearing) throw new DOMException("Cancelled", "AbortError");
const blobUrl = URL.createObjectURL(blob);
cache.set(url, blobUrl);
return blobUrl;
}
function insertSorted(entry: QueueEntry) {
let lo = 0, hi = queue.length;
while (lo < hi) {
const mid = (lo + hi) >>> 1;
if (queue[mid].priority > entry.priority) lo = mid + 1;
else hi = mid;
}
queue.splice(lo, 0, entry);
}
function drain() {
drainScheduled = false;
while (active < MAX_CONCURRENT && queue.length > 0) {
const entry = queue.shift()!;
active++;
doFetch(entry.url)
.then(entry.resolve, entry.reject)
.finally(() => { active--; drain(); });
}
}
function scheduleDrain() {
if (drainScheduled) return;
drainScheduled = true;
requestAnimationFrame(drain);
}
function enqueue(url: string, priority: number): Promise<string> {
const promise = new Promise<string>((resolve, reject) => {
insertSorted({ url, priority, resolve, reject });
}).catch(err => {
inflight.delete(url);
return Promise.reject(err);
});
inflight.set(url, promise);
scheduleDrain();
return promise;
}
export function getBlobUrl(url: string, priority = 0): Promise<string> {
if (!url) return Promise.resolve("");
const cached = cache.get(url);
if (cached) return Promise.resolve(cached);
const existing = inflight.get(url);
if (existing) {
const idx = queue.findIndex(e => e.url === url);
if (idx !== -1 && priority > queue[idx].priority) {
const [entry] = queue.splice(idx, 1);
entry.priority = priority;
insertSorted(entry);
}
return existing;
}
return enqueue(url, priority);
}
export function preloadBlobUrls(urls: string[], basePriority = 0): void {
urls.forEach((url, i) => {
if (!url || cache.has(url) || inflight.has(url)) return;
enqueue(url, basePriority - i);
});
}
export function revokeBlobUrl(url: string): void {
const blob = cache.get(url);
if (blob) { URL.revokeObjectURL(blob); cache.delete(url); }
}
export function deprioritizeQueue(): void {
for (const entry of queue) entry.priority = 0;
queue.sort((a, b) => b.priority - a.priority);
}
export function cancelQueuedFetches(): void {
const dropped = queue.splice(0);
for (const entry of dropped) {
inflight.delete(entry.url);
entry.reject(new DOMException("Cancelled", "AbortError"));
}
}
export function clearBlobCache(): void {
clearing = true;
cancelQueuedFetches();
cache.forEach(blob => URL.revokeObjectURL(blob));
cache.clear();
inflight.clear();
clearing = false;
}
-4
View File
@@ -1,4 +0,0 @@
export * from './memoryCache';
export * from './pageCache';
export * from './imageCache';
export * from './queryCache';
-44
View File
@@ -1,44 +0,0 @@
interface MemEntry<T> {
value: T;
expiresAt: number;
key: string;
}
export class MemoryCache<T> {
readonly #cap: number;
readonly #ttl: number;
readonly #map = new Map<string, MemEntry<T>>();
constructor(capacity: number, ttlMs: number) {
this.#cap = capacity;
this.#ttl = ttlMs;
}
get(key: string): T | undefined {
const entry = this.#map.get(key);
if (!entry) return undefined;
if (Date.now() > entry.expiresAt) { this.#map.delete(key); return undefined; }
this.#map.delete(key);
this.#map.set(key, entry);
return entry.value;
}
set(key: string, value: T): void {
if (this.#map.has(key)) this.#map.delete(key);
else if (this.#map.size >= this.#cap) this.#map.delete(this.#map.keys().next().value!);
this.#map.set(key, { value, expiresAt: Date.now() + this.#ttl, key });
}
has(key: string): boolean {
const entry = this.#map.get(key);
if (!entry) return false;
if (Date.now() > entry.expiresAt) { this.#map.delete(key); return false; }
return true;
}
delete(key: string): void { this.#map.delete(key); }
clear(): void { this.#map.clear(); }
get size(): number { return this.#map.size; }
}
-241
View File
@@ -1,241 +0,0 @@
interface Entry<T> {
promise: Promise<T>;
fetchedAt: number;
fetcher?: () => Promise<T>;
ttl?: number;
}
const store = new Map<string, Entry<unknown>>();
const subs = new Map<string, Set<() => void>>();
const keyToGroups = new Map<string, Set<string>>();
const groups = new Map<string, Set<string>>();
export const DEFAULT_TTL_MS = 5 * 60 * 1_000;
function notify(key: string) { subs.get(key)?.forEach(cb => cb()); }
function registerGroups(key: string, group?: string | string[]) {
if (!group) return;
for (const tag of Array.isArray(group) ? group : [group]) {
if (!groups.has(tag)) groups.set(tag, new Set());
groups.get(tag)!.add(key);
if (!keyToGroups.has(key)) keyToGroups.set(key, new Set());
keyToGroups.get(key)!.add(tag);
}
}
function unregisterKey(key: string) {
const tags = keyToGroups.get(key);
if (tags) {
for (const tag of tags) groups.get(tag)?.delete(key);
keyToGroups.delete(key);
}
}
export const cache = {
get<T>(key: string, fetcher: () => Promise<T>, ttl = DEFAULT_TTL_MS, group?: string | string[]): Promise<T> {
const existing = store.get(key) as Entry<T> | undefined;
if (existing && Date.now() - existing.fetchedAt < ttl) return existing.promise;
const promise = fetcher().catch(err => {
if (err?.name !== "AbortError") store.delete(key);
return Promise.reject(err);
}) as Promise<T>;
store.set(key, { promise, fetchedAt: Date.now(), fetcher: fetcher as () => Promise<unknown>, ttl });
registerGroups(key, group);
promise.then(() => notify(key)).catch(() => {});
return promise;
},
set<T>(key: string, value: T, group?: string | string[]) {
const existing = store.get(key) as Entry<T> | undefined;
store.set(key, {
promise: Promise.resolve(value),
fetchedAt: Date.now(),
fetcher: existing?.fetcher,
ttl: existing?.ttl,
});
registerGroups(key, group);
notify(key);
},
update<T>(key: string, fn: (prev: T) => T) {
const existing = store.get(key) as Entry<T> | undefined;
if (!existing) return;
const next = existing.promise.then(fn);
store.set(key, { ...existing, promise: next, fetchedAt: Date.now() });
next.then(() => notify(key)).catch(() => {});
},
refresh<T>(key: string): Promise<T> | undefined {
const existing = store.get(key) as Entry<T> | undefined;
if (!existing?.fetcher) return undefined;
const promise = (existing.fetcher as () => Promise<T>)().catch(err => {
if (err?.name !== "AbortError") store.delete(key);
return Promise.reject(err);
});
store.set(key, { ...existing, promise: promise as Promise<unknown>, fetchedAt: Date.now() });
promise.then(() => notify(key)).catch(() => {});
return promise;
},
refreshGroup(tag: string): void {
const keys = groups.get(tag);
if (!keys) return;
for (const key of [...keys]) {
const existing = store.get(key);
if (existing?.fetcher) {
const promise = existing.fetcher().catch(err => {
if (err?.name !== "AbortError") store.delete(key);
return Promise.reject(err);
});
store.set(key, { ...existing, promise, fetchedAt: Date.now() });
promise.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;
},
isStale(key: string): boolean {
const e = store.get(key);
if (!e) return true;
return Date.now() - e.fetchedAt >= (e.ttl ?? DEFAULT_TTL_MS);
},
clear(key: string) {
unregisterKey(key);
store.delete(key);
notify(key);
},
clearGroup(tag: string) {
const keys = groups.get(tag);
if (!keys) return;
for (const key of [...keys]) {
keyToGroups.get(key)?.delete(tag);
if (keyToGroups.get(key)?.size === 0) keyToGroups.delete(key);
store.delete(key);
notify(key);
}
groups.delete(tag);
},
clearAll() {
const allKeys = [...store.keys()];
store.clear();
groups.clear();
keyToGroups.clear();
allKeys.forEach(notify);
},
subscribe(key: string, cb: () => void): () => void {
if (!subs.has(key)) subs.set(key, new Set());
subs.get(key)!.add(cb);
return () => subs.get(key)?.delete(cb);
},
};
export const CACHE_GROUPS = {
LIBRARY: "g:library",
SOURCES: "g:sources",
} as const;
export const CACHE_KEYS = {
LIBRARY: "library",
RECENT_UPDATES: "recent_updates",
ALL_MANGA: "all_manga_unfiltered",
CATEGORIES: "categories",
SEARCH: "search_all_manga",
SOURCES: "sources",
POPULAR: "popular",
GENRE: (genre: string) => `genre:${genre}`,
MANGA: (id: number) => `manga:${id}`,
CHAPTERS: (id: number) => `chapters:${id}`,
sourceMangaPages(sourceId: string, type: "POPULAR" | "LATEST" | "SEARCH", query?: string | string[]): string {
const q = Array.isArray(query) ? [...query].sort().join("+") : (query ?? "");
return `pages:${sourceId}:${type}:${q}`;
},
sourceMangaPage(sourceId: string, type: "POPULAR" | "LATEST" | "SEARCH", page: number, query?: string | string[]): string {
const q = Array.isArray(query) ? [...query].sort().join("+") : (query ?? "");
return `page:${sourceId}:${type}:${page}:${q}`;
},
} as const;
const inflight = new Map<string, Promise<unknown>>();
export function deduped<T>(key: string, fetcher: () => Promise<T>): Promise<T> {
if (inflight.has(key)) return inflight.get(key) as Promise<T>;
const p = fetcher().finally(() => inflight.delete(key));
inflight.set(key, p);
return p;
}
const _pageSets = new Map<string, Set<number>>();
export interface PageSet {
add(page: number): void;
pages(): Set<number>;
next(): number;
clear(): void;
}
export function getPageSet(sourceId: string, type: "POPULAR" | "LATEST" | "SEARCH", query?: string | string[]): PageSet {
const key = CACHE_KEYS.sourceMangaPages(sourceId, type, query);
return {
add(page) { if (!_pageSets.has(key)) _pageSets.set(key, new Set()); _pageSets.get(key)!.add(page); },
pages() { return new Set(_pageSets.get(key) ?? []); },
next() { const s = _pageSets.get(key); return s?.size ? Math.max(...s) + 1 : 1; },
clear() { _pageSets.delete(key); },
};
}
const FRECENCY_KEY = "moku-source-frecency";
const MAX_FRECENCY_SOURCES = 4;
type FrecencyMap = Record<string, number>;
function loadFrecency(): FrecencyMap {
try { const r = localStorage.getItem(FRECENCY_KEY); return r ? JSON.parse(r) : {}; }
catch { return {}; }
}
function saveFrecency(map: FrecencyMap) {
try { localStorage.setItem(FRECENCY_KEY, JSON.stringify(map)); } catch {}
}
export function recordSourceAccess(sourceId: string) {
if (!sourceId || sourceId === "0") return;
const map = loadFrecency();
map[sourceId] = (map[sourceId] ?? 0) + 1;
saveFrecency(map);
}
export function getTopSources<T extends { id: string }>(sources: T[]): T[] {
const map = loadFrecency();
const withScore = sources.map(s => ({ s, score: map[s.id] ?? 0 }));
if (withScore.some(x => x.score > 0)) {
return withScore.sort((a, b) => b.score - a.score).slice(0, MAX_FRECENCY_SOURCES).map(x => x.s);
}
return sources.slice(0, MAX_FRECENCY_SOURCES);
}
export async function refreshMangaCache(mangaId: number, thumbnailUrl?: string): Promise<void> {
const didRefresh = cache.refresh(CACHE_KEYS.MANGA(mangaId));
if (!didRefresh) cache.clear(CACHE_KEYS.MANGA(mangaId));
cache.clear(CACHE_KEYS.CHAPTERS(mangaId));
cache.clear(CACHE_KEYS.LIBRARY);
cache.clear(CACHE_KEYS.ALL_MANGA);
if (thumbnailUrl) {
const { revokeBlobUrl, getBlobUrl } = await import("@core/cache/imageCache");
revokeBlobUrl(thumbnailUrl);
getBlobUrl(thumbnailUrl, 999).catch(() => {});
}
}
-27
View File
@@ -1,27 +0,0 @@
import { store, linkManga } from "@store/state.svelte";
import type { Manga } from "@types";
export function autoLinkLibrary(focal: Manga, allManga: Manga[]): Promise<number> {
return new Promise((resolve) => {
const worker = new Worker(
new URL("./autoLinkWorker.ts", import.meta.url),
{ type: "module" },
);
worker.onmessage = (e: MessageEvent<number[]>) => {
const matches = e.data;
for (const id of matches) linkManga(focal.id, id);
worker.terminate();
resolve(matches.length);
};
worker.onerror = () => { worker.terminate(); resolve(0); };
worker.postMessage({
focalTitle: focal.title,
focalId: focal.id,
allManga: allManga.map(m => ({ id: m.id, title: m.title })),
linkedIds: store.settings.mangaLinks?.[focal.id] ?? [],
});
});
}
-29
View File
@@ -1,29 +0,0 @@
interface WorkerMsg {
focalTitle: string;
focalId: number;
allManga: { id: number; title: string }[];
linkedIds: number[];
}
function titleSimilarity(a: string, b: string): number {
const norm = (s: string) =>
s.toLowerCase().replace(/[^a-z0-9\s]/g, "").split(/\s+/).filter(Boolean);
const wa = new Set(norm(a));
const wb = new Set(norm(b));
if (!wa.size || !wb.size) return 0;
const intersection = [...wa].filter(w => wb.has(w)).length;
return intersection / new Set([...wa, ...wb]).size;
}
self.onmessage = (e: MessageEvent<WorkerMsg>) => {
const { focalTitle, focalId, allManga, linkedIds } = e.data;
const matches: number[] = [];
for (const m of allManga) {
if (m.id === focalId) continue;
if (linkedIds.includes(m.id)) continue;
if (titleSimilarity(focalTitle, m.title) >= 0.65) matches.push(m.id);
}
self.postMessage(matches);
};
-54
View File
@@ -1,54 +0,0 @@
const THUMB_SIZE = 16;
const DUPE_THRESH = 0.12;
const hashCache = new Map<string, Uint8ClampedArray>();
function toGray(data: Uint8ClampedArray, pixels: number): Uint8ClampedArray {
const gray = new Uint8ClampedArray(pixels);
for (let i = 0; i < pixels; i++) {
const o = i * 4;
gray[i] = (data[o] * 299 + data[o + 1] * 587 + data[o + 2] * 114) / 1000;
}
return gray;
}
function loadThumb(url: string): Promise<Uint8ClampedArray> {
return new Promise((resolve, reject) => {
const img = new Image();
img.crossOrigin = "anonymous";
img.onload = () => {
const canvas = document.createElement("canvas");
canvas.width = canvas.height = THUMB_SIZE;
const ctx = canvas.getContext("2d")!;
ctx.drawImage(img, 0, 0, THUMB_SIZE, THUMB_SIZE);
resolve(toGray(ctx.getImageData(0, 0, THUMB_SIZE, THUMB_SIZE).data, THUMB_SIZE * THUMB_SIZE));
};
img.onerror = reject;
img.src = url;
});
}
function similarity(a: Uint8ClampedArray, b: Uint8ClampedArray): number {
let diff = 0;
for (let i = 0; i < a.length; i++) diff += Math.abs(a[i] - b[i]);
return diff / (a.length * 255);
}
export async function getHash(url: string): Promise<Uint8ClampedArray | null> {
if (hashCache.has(url)) return hashCache.get(url)!;
try {
const thumb = await loadThumb(url);
hashCache.set(url, thumb);
return thumb;
} catch {
return null;
}
}
export function areDuplicates(a: Uint8ClampedArray, b: Uint8ClampedArray): boolean {
return similarity(a, b) <= DUPE_THRESH;
}
export function clearHashCache(): void {
hashCache.clear();
}
-95
View File
@@ -1,95 +0,0 @@
import { store } from "@store/state.svelte";
import { searchWithScore } from "@core/algorithms/search";
import { getHash, areDuplicates } from "@core/cover/coverHash";
type CoverManga = { id: number; thumbnailUrl: string; source?: { displayName: string } | null };
export type CoverCandidate = {
mangaId: number;
url: string;
label: string;
isActive: boolean;
};
const FUZZY_SCORE_THRESHOLD = 0.65;
function normalizeUrl(url: string): string {
try {
const u = new URL(url);
u.search = "";
return u.href.toLowerCase();
} catch {
return url.toLowerCase();
}
}
export function resolvedCover(mangaId: number, ownUrl: string): string {
return store.settings.mangaPrefs?.[mangaId]?.coverUrl ?? ownUrl;
}
function fuzzyMatchIds(
mangaId: number,
title: string,
mangaById: Map<number, CoverManga & { title: string }>,
): number[] {
const results = searchWithScore(
[...mangaById.values()].filter(m => m.id !== mangaId),
title,
m => m.title,
);
return results
.filter(r => r.score >= FUZZY_SCORE_THRESHOLD)
.map(r => r.item.id);
}
export function coverCandidatesSync(
mangaId: number,
title: string,
ownUrl: string,
mangaById: Map<number, CoverManga & { title: string }>,
): CoverCandidate[] {
const linkedIds = store.getLinkedMangaIds(mangaId);
const fuzzyIds = fuzzyMatchIds(mangaId, title, mangaById);
const current = store.settings.mangaPrefs?.[mangaId]?.coverUrl ?? ownUrl;
const allIds = Array.from(new Set([...linkedIds, ...fuzzyIds]));
const raw: { mangaId: number; url: string; label: string }[] = [
{ mangaId, url: ownUrl, label: "This source" },
...allIds.flatMap(id => {
const m = mangaById.get(id);
return m ? [{ mangaId: m.id, url: m.thumbnailUrl, label: m.source?.displayName ?? `ID ${m.id}` }] : [];
}),
];
const seen = new Set<string>();
return raw
.filter(c => {
const key = normalizeUrl(c.url);
if (seen.has(key)) return false;
seen.add(key);
return true;
})
.map(c => ({ ...c, isActive: normalizeUrl(c.url) === normalizeUrl(current) }));
}
export async function dedupeByImage(candidates: CoverCandidate[]): Promise<CoverCandidate[]> {
const hashes = await Promise.all(candidates.map(c => getHash(c.url)));
const groups: number[][] = [];
for (let i = 0; i < candidates.length; i++) {
const hi = hashes[i];
const existing = hi
? groups.find(g => { const hj = hashes[g[0]]; return hj ? areDuplicates(hi, hj) : false; })
: undefined;
if (existing) existing.push(i);
else groups.push([i]);
}
return groups.map(group => {
const active = group.find(i => candidates[i].isActive) ?? group[0];
const labels = [...new Set(group.map(i => candidates[i].label))];
return { ...candidates[active], label: labels.join(" · ") };
});
}
-4
View File
@@ -1,4 +0,0 @@
export { getHash, areDuplicates, clearHashCache } from "./coverHash";
export { resolvedCover, coverCandidatesSync, dedupeByImage } from "./coverResolver";
export type { CoverCandidate } from "./coverResolver";
export { autoLinkLibrary } from "./autoLink";
-50
View File
@@ -1,50 +0,0 @@
export interface Keybinds {
turnPageRight: string;
turnPageLeft: string;
firstPage: string;
lastPage: string;
turnChapterRight: string;
turnChapterLeft: string;
exitReader: string;
toggleReadingDirection: string;
togglePageStyle: string;
toggleFullscreen: string;
openSettings: string;
toggleBookmark: string;
toggleMarker: string;
toggleAutoScroll: string;
}
export const DEFAULT_KEYBINDS: Keybinds = {
turnPageRight: "ArrowRight",
turnPageLeft: "ArrowLeft",
firstPage: "ctrl+ArrowLeft",
lastPage: "ctrl+ArrowRight",
turnChapterRight: "]",
turnChapterLeft: "[",
exitReader: "Backspace",
toggleReadingDirection: "d",
togglePageStyle: "q",
toggleFullscreen: "f",
openSettings: "o",
toggleBookmark: "m",
toggleMarker: "n",
toggleAutoScroll: "s",
};
export const KEYBIND_LABELS: Record<keyof Keybinds, string> = {
turnPageRight: "Turn page right (→)",
turnPageLeft: "Turn page left (←)",
firstPage: "Jump to first page",
lastPage: "Jump to last page",
turnChapterRight: "Turn chapter right (→)",
turnChapterLeft: "Turn chapter left (←)",
exitReader: "Exit reader",
toggleReadingDirection: "Toggle reading direction",
togglePageStyle: "Toggle page style",
toggleFullscreen: "Toggle fullscreen",
openSettings: "Open settings",
toggleBookmark: "Toggle bookmark",
toggleMarker: "Toggle marker",
toggleAutoScroll: "Toggle auto scroll",
};
-3
View File
@@ -1,3 +0,0 @@
export { eventToKeybind, matchesKeybind, toggleFullscreen } from "./keybindEngine";
export { DEFAULT_KEYBINDS, KEYBIND_LABELS } from "./defaultBinds";
export type { Keybinds } from "./defaultBinds";
-25
View File
@@ -1,25 +0,0 @@
import { getCurrentWindow } from "@tauri-apps/api/window";
export function eventToKeybind(e: KeyboardEvent): string {
if (["Control", "Alt", "Shift", "Meta"].includes(e.key)) return "";
const parts: string[] = [];
if (e.ctrlKey) parts.push("ctrl");
if (e.altKey) parts.push("alt");
if (e.shiftKey) parts.push("shift");
if (e.metaKey) parts.push("meta");
parts.push(e.key);
return parts.join("+");
}
export function matchesKeybind(e: KeyboardEvent, bind: string): boolean {
return eventToKeybind(e) === bind;
}
export async function toggleFullscreen(): Promise<void> {
try {
const win = getCurrentWindow();
await win.setFullscreen(!await win.isFullscreen());
} catch (e) {
console.warn("toggleFullscreen unavailable:", e);
}
}
-88
View File
@@ -1,88 +0,0 @@
const VAULT_KEY = "moku-credential-vault";
const SALT_ITERATIONS = 200_000;
const KEY_USAGE: KeyUsage[] = ["encrypt", "decrypt"];
export interface VaultPayload {
refreshToken?: string;
basicUser?: string;
basicPass?: string;
authMode: "UI_LOGIN" | "BASIC_AUTH" | "NONE";
}
interface StoredVault {
salt: string;
iv: string;
data: string;
}
function toB64(buf: ArrayBuffer): string {
return btoa(String.fromCharCode(...new Uint8Array(buf)));
}
function fromB64(s: string): Uint8Array {
return Uint8Array.from(atob(s), (c) => c.charCodeAt(0));
}
async function deriveKey(pin: string, salt: Uint8Array): Promise<CryptoKey> {
const enc = new TextEncoder();
const keyMat = await crypto.subtle.importKey("raw", enc.encode(pin), "PBKDF2", false, ["deriveKey"]);
return crypto.subtle.deriveKey(
{ name: "PBKDF2", salt, iterations: SALT_ITERATIONS, hash: "SHA-256" },
keyMat,
{ name: "AES-GCM", length: 256 },
false,
KEY_USAGE,
);
}
export function vaultExists(): boolean {
return !!localStorage.getItem(VAULT_KEY);
}
export async function lockVault(pin: string, payload: VaultPayload): Promise<void> {
const salt = crypto.getRandomValues(new Uint8Array(16));
const iv = crypto.getRandomValues(new Uint8Array(12));
const key = await deriveKey(pin, salt);
const enc = new TextEncoder();
const cipher = await crypto.subtle.encrypt(
{ name: "AES-GCM", iv },
key,
enc.encode(JSON.stringify(payload)),
);
localStorage.setItem(VAULT_KEY, JSON.stringify({
salt: toB64(salt),
iv: toB64(iv),
data: toB64(cipher),
} satisfies StoredVault));
}
export async function unlockVault(pin: string): Promise<VaultPayload | null> {
const raw = localStorage.getItem(VAULT_KEY);
if (!raw) return null;
try {
const stored = JSON.parse(raw) as StoredVault;
const key = await deriveKey(pin, fromB64(stored.salt));
const plain = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv: fromB64(stored.iv) },
key,
fromB64(stored.data),
);
return JSON.parse(new TextDecoder().decode(plain)) as VaultPayload;
} catch {
return null;
}
}
export function clearVault(): void {
localStorage.removeItem(VAULT_KEY);
}
export async function rekeyVault(oldPin: string, newPin: string): Promise<boolean> {
const payload = await unlockVault(oldPin);
if (!payload) return false;
await lockVault(newPin, payload);
return true;
}
-5
View File
@@ -1,5 +0,0 @@
export { loadAllStores, persistSettings, persistLibrary, persistUpdates } from "./persist";
export type { PersistedData } from "./persist";
export { vaultExists, lockVault, unlockVault, clearVault, rekeyVault } from "./credentialVault";
export type { VaultPayload } from "./credentialVault";
-166
View File
@@ -1,166 +0,0 @@
import { LazyStore } from "@tauri-apps/plugin-store";
const settingsStore = new LazyStore("settings.json", { autoSave: false });
const libraryStore = new LazyStore("library.json", { autoSave: false });
const updatesStore = new LazyStore("updates.json", { autoSave: false });
const backupsStore = new LazyStore("backups.json", { autoSave: false });
export interface PersistedData {
settings: any;
storeVersion: number | null;
history: any[];
bookmarks: any[];
markers: any[];
readLog: any[];
readingStats: any | null;
dailyReadCounts: Record<string, number>;
libraryUpdates: any[];
lastLibraryRefresh: number;
acknowledgedUpdateIds: number[];
}
export async function loadAllStores(): Promise<PersistedData> {
const migrated = await migrateFromLocalStorage();
if (migrated) return migrated;
const [sv, s, hist, bk, mk, rl, rs, dc, lu, llr, au] = await Promise.all([
settingsStore.get<number>("storeVersion"),
settingsStore.get<any>("settings"),
libraryStore.get<any[]>("history"),
libraryStore.get<any[]>("bookmarks"),
libraryStore.get<any[]>("markers"),
libraryStore.get<any[]>("readLog"),
libraryStore.get<any>("readingStats"),
libraryStore.get<Record<string, number>>("dailyReadCounts"),
updatesStore.get<any[]>("libraryUpdates"),
updatesStore.get<number>("lastLibraryRefresh"),
updatesStore.get<number[]>("acknowledgedUpdateIds"),
]);
return {
storeVersion: sv ?? null,
settings: s ?? null,
history: hist ?? [],
bookmarks: bk ?? [],
markers: mk ?? [],
readLog: rl ?? [],
readingStats: rs ?? null,
dailyReadCounts: dc ?? {},
libraryUpdates: lu ?? [],
lastLibraryRefresh: llr ?? 0,
acknowledgedUpdateIds: au ?? [],
};
}
async function migrateFromLocalStorage(): Promise<PersistedData | null> {
try {
const raw = localStorage.getItem("moku-store");
if (!raw) return null;
const data = JSON.parse(raw);
await Promise.all([
persistSettings({ settings: data.settings ?? null, storeVersion: data.storeVersion ?? 1 }),
persistLibrary({
history: data.history ?? [],
bookmarks: data.bookmarks ?? [],
markers: data.markers ?? [],
readLog: data.readLog ?? [],
readingStats: data.readingStats ?? null,
dailyReadCounts: data.dailyReadCounts ?? {},
}),
persistUpdates({
libraryUpdates: data.libraryUpdates ?? [],
lastLibraryRefresh: data.lastLibraryRefresh ?? 0,
acknowledgedUpdateIds: data.acknowledgedUpdateIds ?? [],
}),
]);
localStorage.removeItem("moku-store");
return {
storeVersion: data.storeVersion ?? null,
settings: data.settings ?? null,
history: data.history ?? [],
bookmarks: data.bookmarks ?? [],
markers: data.markers ?? [],
readLog: data.readLog ?? [],
readingStats: data.readingStats ?? null,
dailyReadCounts: data.dailyReadCounts ?? {},
libraryUpdates: data.libraryUpdates ?? [],
lastLibraryRefresh: data.lastLibraryRefresh ?? 0,
acknowledgedUpdateIds: data.acknowledgedUpdateIds ?? [],
};
} catch {
return null;
}
}
export async function persistSettings(data: { settings: any; storeVersion: number }) {
await Promise.all([
settingsStore.set("settings", data.settings),
settingsStore.set("storeVersion", data.storeVersion),
]);
await settingsStore.save();
}
export async function persistLibrary(data: {
history: any[];
bookmarks: any[];
markers: any[];
readLog: any[];
readingStats: any;
dailyReadCounts: Record<string, number>;
}) {
await Promise.all([
libraryStore.set("history", data.history),
libraryStore.set("bookmarks", data.bookmarks),
libraryStore.set("markers", data.markers),
libraryStore.set("readLog", data.readLog),
libraryStore.set("readingStats", data.readingStats),
libraryStore.set("dailyReadCounts", data.dailyReadCounts),
]);
await libraryStore.save();
}
export async function persistUpdates(data: {
libraryUpdates: any[];
lastLibraryRefresh: number;
acknowledgedUpdateIds: number[];
}) {
await Promise.all([
updatesStore.set("libraryUpdates", data.libraryUpdates),
updatesStore.set("lastLibraryRefresh", data.lastLibraryRefresh),
updatesStore.set("acknowledgedUpdateIds", data.acknowledgedUpdateIds),
]);
await updatesStore.save();
}
export interface BackupEntry { url: string; name: string; }
export async function loadBackups(): Promise<BackupEntry[]> {
const fromStore = await backupsStore.get<BackupEntry[]>("backupList");
if (fromStore) return fromStore;
try {
const raw = localStorage.getItem("moku_backups");
if (!raw) return [];
const migrated: BackupEntry[] = JSON.parse(raw);
await persistBackups(migrated);
localStorage.removeItem("moku_backups");
return migrated;
} catch { return []; }
}
export async function persistBackups(list: BackupEntry[]): Promise<void> {
await backupsStore.set("backupList", list);
await backupsStore.save();
}
export async function resetAuthSettings(): Promise<void> {
const current = await settingsStore.get<any>("settings") ?? {};
current.serverAuthMode = "NONE";
current.serverAuthUser = "";
current.serverAuthPass = "";
await settingsStore.set("settings", current);
await settingsStore.save();
localStorage.removeItem("moku-credential-vault");
}
-67
View File
@@ -1,67 +0,0 @@
import { store, updateSettings } from "@store/state.svelte";
let themeStyleEl: HTMLStyleElement | null = null;
let mediaQuery: MediaQueryList | null = null;
let mediaHandler: (() => void) | null = null;
export function applyTheme() {
const themeId = store.settings.theme ?? "dark";
const isCustom = themeId.startsWith("custom:");
if (!isCustom) {
themeStyleEl?.remove();
themeStyleEl = null;
document.documentElement.setAttribute("data-theme", themeId);
return;
}
const custom = store.settings.customThemes?.find(t => t.id === themeId);
if (!custom) {
themeStyleEl?.remove();
themeStyleEl = null;
document.documentElement.setAttribute("data-theme", "dark");
return;
}
const vars = Object.entries(custom.tokens)
.map(([k, v]) => ` --${k}: ${v};`)
.join("\n");
const css = `[data-theme="custom"] {\n${vars}\n}`;
if (!themeStyleEl) {
themeStyleEl = document.createElement("style");
themeStyleEl.id = "moku-custom-theme";
document.head.appendChild(themeStyleEl);
}
themeStyleEl.textContent = css;
document.documentElement.setAttribute("data-theme", "custom");
}
function applySystemTheme(dark: boolean) {
const themeId = dark
? (store.settings.systemThemeDark ?? "dark")
: (store.settings.systemThemeLight ?? "light");
updateSettings({ theme: themeId });
}
export function mountSystemThemeSync() {
if (mediaQuery && mediaHandler) {
mediaQuery.removeEventListener("change", mediaHandler);
mediaHandler = null;
}
if (!store.settings.systemThemeSync) return;
mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
mediaHandler = () => applySystemTheme(mediaQuery!.matches);
mediaQuery.addEventListener("change", mediaHandler);
applySystemTheme(mediaQuery.matches);
}
export function unmountSystemThemeSync() {
if (mediaQuery && mediaHandler) {
mediaQuery.removeEventListener("change", mediaHandler);
mediaHandler = null;
mediaQuery = null;
}
}
-23
View File
@@ -1,23 +0,0 @@
import { store } from "@store/state.svelte";
const IDLE_EVENTS = ["mousemove", "mousedown", "keydown", "touchstart", "wheel"] as const;
export function mountIdleDetection(onIdle: () => void, onActive: () => void): () => void {
let timer: ReturnType<typeof setTimeout> | null = null;
function reset() {
if (timer) clearTimeout(timer);
const ms = (store.settings.idleTimeoutMin ?? 5) * 60 * 1000;
if (ms === 0) return;
timer = setTimeout(onIdle, ms);
onActive();
}
IDLE_EVENTS.forEach(e => window.addEventListener(e, reset, { passive: true }));
reset();
return () => {
if (timer) clearTimeout(timer);
IDLE_EVENTS.forEach(e => window.removeEventListener(e, reset));
};
}
-3
View File
@@ -1,3 +0,0 @@
export * from './idle';
export * from './zoom';
export * from './touchscreen';
-234
View File
@@ -1,234 +0,0 @@
export interface LongPressOptions {
onLongPress: (e: PointerEvent) => void;
duration?: number;
moveThreshold?: number;
}
export function longPress(node: HTMLElement, opts: LongPressOptions) {
const { onLongPress, duration = 500, moveThreshold = 8 } = opts;
let timer: ReturnType<typeof setTimeout> | null = null;
let startX = 0, startY = 0;
let fired = false;
function start(e: PointerEvent) {
if (e.button !== 0 && e.pointerType === "mouse") return;
startX = e.clientX; startY = e.clientY; fired = false;
timer = setTimeout(() => { timer = null; fired = true; onLongPress(e); }, duration);
}
function move(e: PointerEvent) {
if (!timer) return;
const dx = e.clientX - startX, dy = e.clientY - startY;
if (Math.sqrt(dx * dx + dy * dy) > moveThreshold) cancel();
}
function cancel() { if (timer) { clearTimeout(timer); timer = null; } }
node.addEventListener("pointerdown", start);
node.addEventListener("pointermove", move);
node.addEventListener("pointerup", cancel);
node.addEventListener("pointerleave", cancel);
node.addEventListener("pointercancel",cancel);
return {
get fired() { return fired; },
destroy() {
cancel();
node.removeEventListener("pointerdown", start);
node.removeEventListener("pointermove", move);
node.removeEventListener("pointerup", cancel);
node.removeEventListener("pointerleave", cancel);
node.removeEventListener("pointercancel",cancel);
},
};
}
export interface TapOptions {
onTap: (e: PointerEvent) => void;
onDoubleTap?: (e: PointerEvent) => void;
doubleTapGap?: number;
}
export function tap(node: HTMLElement, opts: TapOptions) {
const { onTap, onDoubleTap, doubleTapGap = 300 } = opts;
let lastTap = 0;
let pending: ReturnType<typeof setTimeout> | null = null;
let startX = 0, startY = 0;
const SLOP = 8;
function down(e: PointerEvent) { startX = e.clientX; startY = e.clientY; }
function up(e: PointerEvent) {
const dx = e.clientX - startX, dy = e.clientY - startY;
if (Math.sqrt(dx * dx + dy * dy) > SLOP) return;
const now = Date.now();
if (onDoubleTap && now - lastTap < doubleTapGap) {
if (pending) { clearTimeout(pending); pending = null; }
onDoubleTap(e);
lastTap = 0;
} else {
lastTap = now;
if (onDoubleTap) {
pending = setTimeout(() => { pending = null; onTap(e); }, doubleTapGap);
} else {
onTap(e);
}
}
}
node.addEventListener("pointerdown", down);
node.addEventListener("pointerup", up);
return { destroy() {
node.removeEventListener("pointerdown", down);
node.removeEventListener("pointerup", up);
}};
}
export interface SwipeOptions {
onSwipeLeft?: (e: PointerEvent) => void;
onSwipeRight?: (e: PointerEvent) => void;
onSwipeUp?: (e: PointerEvent) => void;
onSwipeDown?: (e: PointerEvent) => void;
threshold?: number;
lockAxis?: boolean;
}
export function swipe(node: HTMLElement, opts: SwipeOptions) {
const { onSwipeLeft, onSwipeRight, onSwipeUp, onSwipeDown, threshold = 40, lockAxis = true } = opts;
let startX = 0, startY = 0, active = false;
function down(e: PointerEvent) {
if (e.pointerType === "mouse") return;
startX = e.clientX; startY = e.clientY; active = true;
node.setPointerCapture(e.pointerId);
}
function up(e: PointerEvent) {
if (!active) return; active = false;
const dx = e.clientX - startX, dy = e.clientY - startY;
const ax = Math.abs(dx), ay = Math.abs(dy);
if (Math.max(ax, ay) < threshold) return;
if (lockAxis && ax > ay) {
if (dx < 0) onSwipeLeft?.(e); else onSwipeRight?.(e);
} else if (lockAxis && ay >= ax) {
if (dy < 0) onSwipeUp?.(e); else onSwipeDown?.(e);
} else {
if (ax >= ay) { if (dx < 0) onSwipeLeft?.(e); else onSwipeRight?.(e); }
else { if (dy < 0) onSwipeUp?.(e); else onSwipeDown?.(e); }
}
}
function cancel() { active = false; }
node.addEventListener("pointerdown", down);
node.addEventListener("pointerup", up);
node.addEventListener("pointercancel", cancel);
return { destroy() {
node.removeEventListener("pointerdown", down);
node.removeEventListener("pointerup", up);
node.removeEventListener("pointercancel", cancel);
}};
}
export interface PinchOptions {
onPinch: (scale: number, origin: { x: number; y: number }) => void;
onPinchEnd?: (scale: number) => void;
}
export interface PinchGestureOptions {
onPinch: (scale: number, origin: { x: number; y: number }) => void;
onPinchEnd?: (scale: number) => void;
}
export interface PinchGesture {
onPointerDown: (e: PointerEvent) => void;
onPointerMove: (e: PointerEvent) => void;
onPointerUp: (e: PointerEvent) => void;
isPinching: () => boolean;
}
export function createPinchGesture(opts: PinchGestureOptions): PinchGesture {
const { onPinch, onPinchEnd } = opts;
const pointers = new Map<number, PointerEvent>();
let initDist = 0;
function pdist(a: PointerEvent, b: PointerEvent) {
const dx = a.clientX - b.clientX, dy = a.clientY - b.clientY;
return Math.sqrt(dx * dx + dy * dy);
}
function pmid(a: PointerEvent, b: PointerEvent) {
return { x: (a.clientX + b.clientX) / 2, y: (a.clientY + b.clientY) / 2 };
}
function onPointerDown(e: PointerEvent) {
pointers.set(e.pointerId, e);
if (pointers.size === 2) {
const [a, b] = [...pointers.values()];
initDist = pdist(a, b);
}
}
function onPointerMove(e: PointerEvent) {
if (!pointers.has(e.pointerId)) return;
pointers.set(e.pointerId, e);
if (pointers.size !== 2 || initDist === 0) return;
const [a, b] = [...pointers.values()];
onPinch(pdist(a, b) / initDist, pmid(a, b));
}
function onPointerUp(e: PointerEvent) {
if (pointers.size === 2 && onPinchEnd) {
const [a, b] = [...pointers.values()];
onPinchEnd(pdist(a, b) / initDist);
}
pointers.delete(e.pointerId);
initDist = 0;
}
return { onPointerDown, onPointerMove, onPointerUp, isPinching: () => pointers.size >= 2 };
}
export function pinch(node: HTMLElement, opts: PinchOptions) {
const gesture = createPinchGesture(opts);
function down(e: PointerEvent) { node.setPointerCapture(e.pointerId); gesture.onPointerDown(e); }
node.addEventListener("pointerdown", down);
node.addEventListener("pointermove", gesture.onPointerMove);
node.addEventListener("pointerup", gesture.onPointerUp);
node.addEventListener("pointercancel", gesture.onPointerUp);
return { destroy() {
node.removeEventListener("pointerdown", down);
node.removeEventListener("pointermove", gesture.onPointerMove);
node.removeEventListener("pointerup", gesture.onPointerUp);
node.removeEventListener("pointercancel", gesture.onPointerUp);
}};
}
export interface DragScrollOptions {
direction?: "x" | "y" | "both";
onDragStart?: () => void;
onDragEnd?: () => void;
}
export function dragScroll(node: HTMLElement, opts: DragScrollOptions = {}) {
const { direction = "both", onDragStart, onDragEnd } = opts;
let active = false, startX = 0, startY = 0, scrollX = 0, scrollY = 0;
function down(e: PointerEvent) {
if (e.pointerType === "mouse") return;
active = true;
startX = e.clientX; startY = e.clientY;
scrollX = node.scrollLeft; scrollY = node.scrollTop;
node.setPointerCapture(e.pointerId);
onDragStart?.();
}
function move(e: PointerEvent) {
if (!active) return;
if (direction !== "x") node.scrollTop = scrollY - (e.clientY - startY);
if (direction !== "y") node.scrollLeft = scrollX - (e.clientX - startX);
}
function up() { if (active) { active = false; onDragEnd?.(); } }
node.addEventListener("pointerdown", down);
node.addEventListener("pointermove", move);
node.addEventListener("pointerup", up);
node.addEventListener("pointercancel", up);
return { destroy() {
node.removeEventListener("pointerdown", down);
node.removeEventListener("pointermove", move);
node.removeEventListener("pointerup", up);
node.removeEventListener("pointercancel", up);
}};
}
-61
View File
@@ -1,61 +0,0 @@
import { store } from "@store/state.svelte";
let _appliedZoom: number = -1;
let _vhRafId: number | null = null;
export function applyZoom() {
const uiZoom = store.settings.uiZoom ?? 1.0;
if (uiZoom === _appliedZoom) return;
_appliedZoom = uiZoom;
document.documentElement.style.setProperty("--ui-zoom", String(uiZoom));
document.documentElement.style.setProperty("--ui-scale", String(uiZoom));
document.documentElement.style.zoom = `${uiZoom * 100}%`;
if (_vhRafId !== null) cancelAnimationFrame(_vhRafId);
_vhRafId = requestAnimationFrame(() => {
_vhRafId = null;
document.documentElement.style.setProperty("--visual-vh", `${window.innerHeight / uiZoom}px`);
});
}
export function handleZoomKey(e: KeyboardEvent) {
if (!e.ctrlKey) return;
const current = store.settings.uiZoom ?? 1.0;
if (e.key === "=" || e.key === "+") { e.preventDefault(); store.settings.uiZoom = Math.min(2.0, Math.round((current + 0.1) * 10) / 10); }
else if (e.key === "-") { e.preventDefault(); store.settings.uiZoom = Math.max(0.5, Math.round((current - 0.1) * 10) / 10); }
else if (e.key === "0") { e.preventDefault(); store.settings.uiZoom = 1.0; }
}
export function mountZoomKey(): () => void {
window.addEventListener("keydown", handleZoomKey);
return () => window.removeEventListener("keydown", handleZoomKey);
}
export function clampZoom(z: number, min: number, max: number): number {
return Math.round(Math.min(max, Math.max(min, z)) * 1000) / 1000;
}
export function captureZoomAnchor(
containerEl: HTMLElement | null,
style: string,
out: { el: HTMLElement | null; offset: number },
) {
if (!containerEl || style !== "longstrip") return;
const containerTop = containerEl.getBoundingClientRect().top;
for (const img of containerEl.querySelectorAll<HTMLElement>("img[data-local-page]")) {
const rect = img.getBoundingClientRect();
if (rect.bottom > containerTop) { out.el = img; out.offset = rect.top - containerTop; return; }
}
}
export function restoreZoomAnchor(
containerEl: HTMLElement | null,
out: { el: HTMLElement | null; offset: number },
) {
if (!out.el || !containerEl) return;
const el = out.el;
out.el = null;
requestAnimationFrame(() => {
const containerTop = containerEl!.getBoundingClientRect().top;
containerEl!.scrollTop += (el.getBoundingClientRect().top - containerTop) - out.offset;
});
}
-40
View File
@@ -1,40 +0,0 @@
import { invoke } from "@tauri-apps/api/core";
import { getVersion } from "@tauri-apps/api/app";
import { addToast } from "@store/state.svelte";
function parse(tag: string): number[] {
return tag.replace(/^v/, "").split(".").map(Number);
}
function compare(a: number[], b: number[]): number {
for (let i = 0; i < 3; i++) {
if ((a[i] ?? 0) !== (b[i] ?? 0)) return (b[i] ?? 0) - (a[i] ?? 0);
}
return 0;
}
export async function checkForUpdateSilently(): Promise<void> {
try {
const [currentVersion, releases] = await Promise.all([
getVersion(),
invoke<Array<{ tag_name: string; html_url: string }>>("list_releases"),
]);
const valid = releases.filter(r => typeof r.tag_name === "string" && r.tag_name.trim());
if (!valid.length) return;
const latestTag = valid
.map(r => r.tag_name)
.sort((a, b) => compare(parse(a), parse(b)))[0]
.replace(/^v/, "");
if (compare(parse(latestTag), parse(currentVersion)) < 0) {
addToast({
kind: "info",
title: `Update available — v${latestTag}`,
body: "Open Settings → About to install.",
duration: 8000,
});
}
} catch {}
}
-223
View File
@@ -1,223 +0,0 @@
import type { Manga, Source } from "@types";
import type { Settings } from "@types";
export { clsx as cn } from "clsx";
export function timeAgo(ts: number): string {
const diff = Date.now() - ts, m = Math.floor(diff / 60000);
if (m < 1) return "Just now";
if (m < 60) return `${m}m ago`;
const h = Math.floor(m / 60);
if (h < 24) return `${h}h ago`;
const d = Math.floor(h / 24);
if (d < 7) return `${d}d ago`;
return new Date(ts).toLocaleDateString("en-US", { month: "short", day: "numeric" });
}
export function dayLabel(ts: number): string {
const d = new Date(ts), now = new Date();
if (d.toDateString() === now.toDateString()) return "Today";
const yest = new Date(now); yest.setDate(now.getDate() - 1);
if (d.toDateString() === yest.toDateString()) return "Yesterday";
return d.toLocaleDateString("en-US", { weekday: "long", month: "long", day: "numeric" });
}
export function formatReadTime(m: number): string {
if (m < 1) return "< 1 min";
if (m < 60) return `${m} min`;
const h = Math.floor(m / 60), r = m % 60;
return r === 0 ? `${h}h` : `${h}h ${r}m`;
}
const STRICT_TAGS: string[] = [
"adult", "mature", "hentai", "ecchi", "erotic", "pornograph",
"18+", "smut", "explicit", "sexual violence",
"gore", "guro", "graphic violence", "torture", "body horror",
];
const MODERATE_TAGS: string[] = [
"adult", "mature", "hentai", "ecchi", "erotic", "pornograph",
"18+", "smut", "explicit", "sexual violence",
];
type ContentFilterSettings = Pick<
Settings,
"contentLevel" | "sourceOverridesEnabled" | "nsfwAllowedSourceIds" | "nsfwBlockedSourceIds"
>;
function blockedTagsForSettings(settings: ContentFilterSettings): string[] {
if (settings.contentLevel === "strict") return STRICT_TAGS;
if (settings.contentLevel === "moderate") return MODERATE_TAGS;
return [];
}
function genreMatchesBlocklist(genre: string[], blockedTags: string[]): boolean {
if (!blockedTags.length) return false;
return genre.some(g => {
const norm = g.toLowerCase().trim();
return blockedTags.some(tag => {
const idx = norm.indexOf(tag);
if (idx === -1) return false;
const before = idx === 0 || /\W/.test(norm[idx - 1]);
const after = idx + tag.length === norm.length || /\W/.test(norm[idx + tag.length]);
return before && after;
});
});
}
export function shouldHideNsfw(
manga: Pick<Manga, "genre" | "source">,
settings: ContentFilterSettings,
): boolean {
if (settings.contentLevel === "unrestricted") return false;
const srcId = manga.source?.id;
const blocked = settings.sourceOverridesEnabled ? (settings.nsfwBlockedSourceIds ?? []) : [];
const allowed = settings.sourceOverridesEnabled ? (settings.nsfwAllowedSourceIds ?? []) : [];
if (srcId && blocked.includes(srcId)) return true;
const sourceAllowed = !!(srcId && allowed.includes(srcId));
if (!sourceAllowed && manga.source?.isNsfw) return true;
return genreMatchesBlocklist(manga.genre ?? [], blockedTagsForSettings(settings));
}
export function shouldHideSource(
source: Pick<Source, "id" | "isNsfw">,
settings: ContentFilterSettings,
): boolean {
if (settings.contentLevel === "unrestricted") return false;
if (settings.sourceOverridesEnabled) {
if ((settings.nsfwBlockedSourceIds ?? []).includes(source.id)) return true;
if ((settings.nsfwAllowedSourceIds ?? []).includes(source.id)) return false;
}
return source.isNsfw;
}
export function dedupeSourcesByLang(
sources: Source[],
preferredLang: string,
settings: ContentFilterSettings,
applyHide = false,
): Source[] {
const map = new Map<string, Source>();
for (const s of sources) {
if (s.id === "0") continue;
if (applyHide && shouldHideSource(s, settings)) continue;
const existing = map.get(s.name);
if (!existing) { map.set(s.name, s); continue; }
const existingPref = existing.lang === preferredLang;
const newPref = s.lang === preferredLang;
if (newPref && !existingPref) map.set(s.name, s);
else if (!existingPref && !newPref && s.lang < existing.lang) map.set(s.name, s);
}
return Array.from(map.values());
}
export function dedupeSources(sources: Source[], preferredLang: string): Source[] {
const byName = new Map<string, Source[]>();
for (const src of sources) {
if (src.id === "0") continue;
if (!byName.has(src.name)) byName.set(src.name, []);
byName.get(src.name)!.push(src);
}
const picked: Source[] = [];
for (const group of byName.values()) {
const preferred = group.find(s => s.lang === preferredLang);
picked.push(preferred ?? group.sort((a, b) => a.lang.localeCompare(b.lang))[0]);
}
return picked;
}
export function normalizeTitle(title: string): string {
return title
.toLowerCase()
.replace(/\(official\)|\(web comic\)|\(webtoon\)|\(manhwa\)|\(manhua\)/gi, "")
.replace(/[^a-z0-9\s]/g, " ")
.replace(/^(the|a|an)\s+/, "")
.replace(/\s+/g, " ")
.trim();
}
function norm(s: string): string {
return s.toLowerCase().replace(/[^a-z0-9]/g, " ").replace(/\s+/g, " ").trim();
}
function descFingerprint(desc: string | null | undefined): string | null {
if (!desc) return null;
const n = norm(desc);
return n.length >= 60 ? n.slice(0, 200) : null;
}
function authorFingerprint(author?: string | null, artist?: string | null): string | null {
const parts = [author, artist].filter(Boolean).map(s => norm(s!));
return parts.length ? parts.sort().join("|") : null;
}
export function dedupeMangaByTitle<T extends {
id: number;
title: string;
description?: string | null;
author?: string | null;
artist?: string | null;
inLibrary?: boolean;
downloadCount?: number;
}>(items: T[], links: Record<number, number[]> = {}): T[] {
const byTitle = new Map<string, number>();
const byDesc = new Map<string, number>();
const byAuthorDesc = new Map<string, number>();
const byId = new Map<number, number>();
const out: T[] = [];
for (const m of items) {
const tk = normalizeTitle(m.title);
const dk = descFingerprint(m.description);
const ak = (dk && m.author) ? `${authorFingerprint(m.author, m.artist)}||${dk}` : null;
const linkedIds = links[m.id] ?? [];
const linkedIdx = linkedIds.map(lid => byId.get(lid)).find(i => i !== undefined);
const existingIdx =
linkedIdx ??
byTitle.get(tk) ??
(dk ? byDesc.get(dk) : undefined) ??
(ak ? byAuthorDesc.get(ak) : undefined);
if (existingIdx !== undefined) {
const existing = out[existingIdx];
const mBetter =
(m.inLibrary && !existing.inLibrary) ||
(!existing.inLibrary && (m.downloadCount ?? 0) > (existing.downloadCount ?? 0));
if (mBetter) {
out[existingIdx] = m;
byTitle.set(tk, existingIdx);
byId.set(m.id, existingIdx);
if (dk) byDesc.set(dk, existingIdx);
if (ak) byAuthorDesc.set(ak, existingIdx);
}
continue;
}
const idx = out.length;
out.push(m);
byTitle.set(tk, idx);
byId.set(m.id, idx);
if (dk) byDesc.set(dk, idx);
if (ak) byAuthorDesc.set(ak, idx);
}
return out;
}
export function dedupeMangaById<T extends { id: number }>(items: T[]): T[] {
const seen = new Set<number>();
const out: T[] = [];
for (const m of items) {
if (!seen.has(m.id)) { seen.add(m.id); out.push(m); }
}
return out;
}
-48
View File
@@ -1,48 +0,0 @@
@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);
}
-4
View File
@@ -1,4 +0,0 @@
@import "./reset.css";
@import "./animations.css";
@import "./scrollbars.css";
@import "./typography.css";
-41
View File
@@ -1,41 +0,0 @@
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html, body {
height: 100%;
overflow: hidden;
background: var(--bg-void);
color: var(--text-primary);
}
#app {
height: 100%;
}
button {
cursor: pointer;
font: inherit;
color: inherit;
background: none;
border: none;
padding: 0;
}
input, textarea, select {
font: inherit;
color: inherit;
}
a {
color: inherit;
text-decoration: none;
}
ul, ol { list-style: none; }
img, svg { display: block; max-width: 100%; }
p { margin: 0; }
-9
View File
@@ -1,9 +0,0 @@
* {
scrollbar-width: thin;
scrollbar-color: transparent transparent;
}
*::-webkit-scrollbar { width: 4px; height: 4px; }
*::-webkit-scrollbar-track { background: transparent; }
*::-webkit-scrollbar-thumb { background: transparent; border-radius: 99px; }
*::-webkit-scrollbar-thumb:hover { background: transparent; }
-9
View File
@@ -1,9 +0,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;
}
-25
View File
@@ -1,25 +0,0 @@
[data-theme="dark"] {
--bg-void: #000000;
--bg-base: #080808;
--bg-surface: #0d0d0d;
--bg-raised: #111111;
--bg-overlay: #171717;
--bg-subtle: #1e1e1e;
--border-dim: #252525;
--border-base: #303030;
--border-strong: #3e3e3e;
--border-focus: #5a7a5a;
--text-primary: #ffffff;
--text-secondary: #e8e6e0;
--text-muted: #b0aea8;
--text-faint: #6e6c68;
--text-disabled: #303030;
--accent: #7aaa7a;
--accent-dim: #2e4a2e;
--accent-muted: #1e2e1e;
--accent-fg: #bcd8bc;
--accent-bright: #9fcf9f;
}
-5
View File
@@ -1,5 +0,0 @@
@import "./original.css";
@import "./dark.css";
@import "./light.css";
@import "./midnight.css";
@import "./warm.css";
-29
View File
@@ -1,29 +0,0 @@
[data-theme="light"] {
--bg-void: #d8d4ce;
--bg-base: #e2deda;
--bg-surface: #ece8e2;
--bg-raised: #f5f2ec;
--bg-overlay: #ffffff;
--bg-subtle: #e4e0d8;
--border-dim: #c4c0b8;
--border-base: #b0aca4;
--border-strong: #989490;
--border-focus: #3a5a3a;
--text-primary: #080806;
--text-secondary: #181612;
--text-muted: #38342e;
--text-faint: #706c64;
--text-disabled: #b0aca4;
--accent: #2a5a2a;
--accent-dim: #b0ccb0;
--accent-muted: #c8dcc8;
--accent-fg: #183818;
--accent-bright: #1e4e1e;
--color-error: #8a1a1a;
--color-error-bg: #f8e0e0;
--color-read: #e0dcd4;
}
-25
View File
@@ -1,25 +0,0 @@
[data-theme="midnight"] {
--bg-void: #050810;
--bg-base: #080c18;
--bg-surface: #0c1020;
--bg-raised: #101428;
--bg-overlay: #151a30;
--bg-subtle: #1a2038;
--border-dim: #1a2035;
--border-base: #222840;
--border-strong: #2c3450;
--border-focus: #4a5c8a;
--text-primary: #eeeef8;
--text-secondary: #c0c4d8;
--text-muted: #808498;
--text-faint: #404860;
--text-disabled: #202840;
--accent: #6a7ab8;
--accent-dim: #252d50;
--accent-muted: #181e38;
--accent-fg: #a8b4e8;
--accent-bright: #8896d0;
}
-31
View File
@@ -1,31 +0,0 @@
[data-theme="original"] {
--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;
}
-25
View File
@@ -1,25 +0,0 @@
[data-theme="warm"] {
--bg-void: #0c0a06;
--bg-base: #100e08;
--bg-surface: #16130c;
--bg-raised: #1c1810;
--bg-overlay: #221e14;
--bg-subtle: #28241a;
--border-dim: #201c10;
--border-base: #2c2818;
--border-strong: #3a3420;
--border-focus: #6a5a30;
--text-primary: #f5f0e0;
--text-secondary: #d8d0b0;
--text-muted: #988c60;
--text-faint: #584e30;
--text-disabled: #302a18;
--accent: #c0902a;
--accent-dim: #3a2c10;
--accent-muted: #261e0c;
--accent-fg: #e0b860;
--accent-bright: #d0a040;
}
-35
View File
@@ -1,35 +0,0 @@
:root {
--bg-void: #080808;
--bg-base: #0c0c0c;
--bg-surface: #101010;
--bg-raised: #151515;
--bg-overlay: #1a1a1a;
--bg-subtle: #202020;
--border-dim: #1c1c1c;
--border-base: #242424;
--border-strong: #2e2e2e;
--border-focus: #4a5c4a;
--text-primary: #f0efec;
--text-secondary: #c8c6c0;
--text-muted: #8a8880;
--text-faint: #4e4d4a;
--text-disabled: #2a2a28;
--accent: #6b8f6b;
--accent-dim: #2a3d2a;
--accent-muted: #1a251a;
--accent-fg: #a8c4a8;
--accent-bright: #8fb88f;
--color-error: #c47a7a;
--color-error-bg: #1f1212;
--color-success: #7aab7a;
--color-info: #7a9ec4;
--color-info-bg: #121a1f;
--color-read: #2e2e2c;
--dot-active: var(--accent);
--dot-inactive: var(--text-faint);
}
-8
View File
@@ -1,8 +0,0 @@
@import "./colors.css";
@import "./typography.css";
@import "./spacing.css";
@import "./radius.css";
@import "./motion.css";
@import "./shadows.css";
@import "./zindex.css";
@import "../themes/index.css";
-5
View File
@@ -1,5 +0,0 @@
:root {
--t-fast: 0.08s ease;
--t-base: 0.14s ease;
--t-slow: 0.22s ease;
}
-8
View File
@@ -1,8 +0,0 @@
:root {
--radius-sm: 3px;
--radius-md: 5px;
--radius-lg: 7px;
--radius-xl: 10px;
--radius-2xl: 14px;
--radius-full: 9999px;
}
-2
View File
@@ -1,2 +0,0 @@
:root {
}
-13
View File
@@ -1,13 +0,0 @@
:root {
--sp-1: 4px;
--sp-2: 8px;
--sp-3: 12px;
--sp-4: 16px;
--sp-5: 20px;
--sp-6: 24px;
--sp-8: 32px;
--sp-10: 40px;
--sidebar-width: 52px;
--titlebar-height: 36px;
}
-28
View File
@@ -1,28 +0,0 @@
:root {
--font-ui: "DM Mono", "Fira Mono", ui-monospace, monospace;
--font-sans: "DM Sans", ui-sans-serif, system-ui, sans-serif;
--text-2xs: 10px;
--text-xs: 11px;
--text-sm: 12px;
--text-base: 13px;
--text-md: 14px;
--text-lg: 15px;
--text-xl: 17px;
--text-2xl: 20px;
--text-3xl: 24px;
--weight-normal: 400;
--weight-medium: 500;
--weight-semi: 600;
--leading-none: 1;
--leading-tight: 1.3;
--leading-snug: 1.45;
--leading-base: 1.6;
--tracking-tight: -0.02em;
--tracking-normal: 0;
--tracking-wide: 0.06em;
--tracking-wider: 0.1em;
}
-5
View File
@@ -1,5 +0,0 @@
:root {
--z-reader: 50;
--z-modal: 100;
--z-settings: 150;
}
View File
View File
-2
View File
@@ -1,2 +0,0 @@
export { default as Search } from "./components/Search.svelte";
export * from "./lib/searchFilter";
-2
View File
@@ -1,2 +0,0 @@
export { downloadStore } from "./store/downloadState.svelte";
export { toActiveDownloads, optimisticRemove, isRunning, pageProgress } from "./lib/downloadQueue";
@@ -1,84 +0,0 @@
import type { DownloadQueueItem, ActiveDownload } from "@types/index";
export function toActiveDownloads(queue: DownloadQueueItem[]): ActiveDownload[] {
return queue.map((item) => ({
chapterId: item.chapter.id,
mangaId: item.chapter.mangaId,
progress: item.progress,
}));
}
export function optimisticRemove(queue: DownloadQueueItem[], chapterId: number): DownloadQueueItem[] {
return queue.filter((i) => i.chapter.id !== chapterId);
}
export function optimisticRemoveMany(queue: DownloadQueueItem[], chapterIds: Set<number>): DownloadQueueItem[] {
return queue.filter((i) => !chapterIds.has(i.chapter.id));
}
export function isRunning(state: string | undefined): boolean {
return state === "STARTED";
}
export function getErrored(queue: DownloadQueueItem[]): DownloadQueueItem[] {
return queue.filter((i) => i.state === "ERROR");
}
export function pageProgress(progress: number, pageCount: number): { done: number; total: number } {
return { done: Math.round(progress * pageCount), total: pageCount };
}
export interface SpeedSample {
ts: number;
progress: number;
pages: number;
}
export function calcSpeed(prev: SpeedSample | null, current: SpeedSample): number | null {
if (!prev) return null;
const dt = (current.ts - prev.ts) / 1000;
if (dt <= 0) return null;
const prevDone = Math.round(prev.progress * prev.pages);
const curDone = Math.round(current.progress * current.pages);
const delta = curDone - prevDone;
if (delta <= 0) return null;
return delta / dt;
}
export function estimateEta(pagesPerSec: number, queue: DownloadQueueItem[]): number | null {
if (pagesPerSec <= 0 || queue.length === 0) return null;
let remaining = 0;
for (const item of queue) {
const pages = item.chapter.pageCount ?? 0;
remaining += pages - Math.round(item.progress * pages);
}
return remaining / pagesPerSec;
}
export function reorderSelectedToEdge(
queue: DownloadQueueItem[],
selected: Set<number>,
edge: "top" | "bottom",
): DownloadQueueItem[] {
const pinned = queue.filter((i) => selected.has(i.chapter.id));
const rest = queue.filter((i) => !selected.has(i.chapter.id));
return edge === "top" ? [...pinned, ...rest] : [...rest, ...pinned];
}
const AVG_BYTES_PER_PAGE = 1_500_000;
export function estimateQueueBytes(queue: DownloadQueueItem[]): number {
let total = 0;
for (const item of queue) {
const pages = item.chapter.pageCount ?? 0;
const remaining = pages - Math.round(item.progress * pages);
total += remaining * AVG_BYTES_PER_PAGE;
}
return total;
}
export function formatEta(seconds: number): string {
if (seconds < 60) return `~${Math.ceil(seconds)}s`;
if (seconds < 3600) return `~${Math.ceil(seconds / 60)}m`;
return `~${(seconds / 3600).toFixed(1)}h`;
}
@@ -1,407 +0,0 @@
import { gql } from "@api/client";
import { GET_DOWNLOAD_STATUS } from "@api/queries";
import {
START_DOWNLOADER, STOP_DOWNLOADER, CLEAR_DOWNLOADER,
DEQUEUE_DOWNLOAD, DEQUEUE_CHAPTERS_DOWNLOAD,
ENQUEUE_DOWNLOAD, REORDER_DOWNLOAD,
} from "@api/mutations";
import { addToast, setActiveDownloads, store, updateSettings } from "@store/state.svelte";
import { boot } from "@store/boot.svelte";
import type { DownloadStatus, DownloadQueueItem } from "@types/index";
import {
toActiveDownloads, optimisticRemove, optimisticRemoveMany,
isRunning, getErrored, calcSpeed, estimateEta, estimateQueueBytes,
type SpeedSample,
} from "../lib/downloadQueue";
import { startAutoRetry, type AutoRetryHandle } from "../lib/autoRetry";
import { invoke } from "@tauri-apps/api/core";
class DownloadStore {
status: DownloadStatus | null = $state(null);
loading = $state(true);
togglingPlay = $state(false);
clearing = $state(false);
dequeueing = $state(new Set<number>());
selected = $state(new Set<number>());
batchWorking = $state(false);
pagesPerSec: number | null = $state(null);
eta: number | null = $state(null);
storageWarning: boolean = $state(false);
private freeBytes: number | null = null;
get toastsEnabled() { return store.settings.downloadToastsEnabled ?? true; }
get autoRetryEnabled() { return store.settings.downloadAutoRetry ?? false; }
private lastSample: SpeedSample | null = null;
private prevQueue: DownloadQueueItem[] = [];
private autoRetryHnd: AutoRetryHandle | null = null;
get queue() { return this.status?.queue ?? []; }
get isRunning() { return isRunning(this.status?.state); }
get erroredIds() { return new Set(getErrored(this.queue).map((i) => i.chapter.id)); }
get hasErrored() { return this.erroredIds.size > 0; }
toggleToasts() {
const next = !this.toastsEnabled;
updateSettings({ downloadToastsEnabled: next });
addToast({ kind: "info", title: next ? "Notifications enabled" : "Notifications muted", body: next ? "You'll be notified when chapters finish downloading" : "Download notifications are silenced", duration: 2500 });
}
toggleAutoRetry() {
if (this.autoRetryEnabled) {
this.autoRetryHnd?.stop();
this.autoRetryHnd = null;
updateSettings({ downloadAutoRetry: false });
addToast({ kind: "info", title: "Auto-retry disabled", body: "Failed downloads will no longer retry automatically", duration: 2500 });
} else {
updateSettings({ downloadAutoRetry: true });
this.autoRetryHnd = startAutoRetry(
() => this.queue,
() => this.isRunning,
() => this.retryAllErrored(),
);
addToast({ kind: "info", title: "Auto-retry enabled", body: "Errored downloads will retry automatically", duration: 3000 });
}
}
detectTransitions(next: DownloadQueueItem[]) {
if (!this.toastsEnabled) return;
const nextMap = new Map(next.map(i => [i.chapter.id, i]));
for (const item of this.prevQueue) {
if (item.state !== "DOWNLOADING") continue;
const nextItem = nextMap.get(item.chapter.id);
const manga = item.chapter.manga;
const label = manga ? `${manga.title}${item.chapter.name}` : item.chapter.name;
if (!nextItem) {
addToast({ kind: "download", title: "Chapter downloaded", body: label, duration: 4000 });
} else if (nextItem.state === "ERROR") {
addToast({ kind: "error", title: "Download failed", body: label, duration: 5000 });
}
}
this.prevQueue = next.slice();
}
applyStatus(ds: DownloadStatus) {
this.status = ds;
setActiveDownloads(toActiveDownloads(ds.queue));
this.updateSpeed(ds);
this.fetchFreeBytes(ds);
}
private async fetchFreeBytes(ds: DownloadStatus) {
const path = store.settings.serverDownloadsPath ?? "";
if (!path) return;
try {
const info = await invoke<{ free_bytes: number }>("get_storage_info", { downloadsPath: path });
this.freeBytes = info.free_bytes;
this.storageWarning = estimateQueueBytes(ds.queue) > info.free_bytes * 0.95;
} catch { }
}
private confirmStorageOverrun(): Promise<boolean> {
return new Promise(resolve => {
const backdrop = document.createElement("div");
backdrop.style.cssText = "position:fixed;inset:0;z-index:10000;background:rgba(0,0,0,0.5);display:flex;align-items:center;justify-content:center;animation:s-fade-in 0.15s ease both";
const panel = document.createElement("div");
panel.style.cssText = "background:var(--bg-surface);border:1px solid var(--border-base);border-radius:var(--radius-2xl);box-shadow:0 24px 80px rgba(0,0,0,0.7),0 0 0 1px rgba(255,255,255,0.04) inset;width:min(380px,calc(100vw - 40px));overflow:hidden;animation:s-scale-in 0.2s cubic-bezier(0.16,1,0.3,1) both";
panel.innerHTML = `
<div style="padding:var(--sp-4) var(--sp-5) var(--sp-3);border-bottom:1px solid var(--border-dim)">
<p style="margin:0;font-size:var(--text-sm);font-weight:var(--weight-medium);color:var(--text-primary);letter-spacing:0.01em">Low disk space</p>
</div>
<div style="padding:var(--sp-4) var(--sp-5);display:flex;flex-direction:column;gap:var(--sp-2)">
<p style="margin:0;font-family:var(--font-ui);font-size:var(--text-xs);color:var(--text-muted);letter-spacing:var(--tracking-wide);line-height:var(--leading-snug)">
The download queue is estimated to exceed 95% of your available storage. Download anyway?
</p>
</div>
<div style="padding:var(--sp-3) var(--sp-5);border-top:1px solid var(--border-dim);display:flex;justify-content:flex-end;gap:var(--sp-2)">
<button id="_moku-storage-cancel" style="font-family:var(--font-ui);font-size:var(--text-xs);letter-spacing:var(--tracking-wide);padding:5px var(--sp-3);border-radius:var(--radius-sm);border:1px solid var(--border-dim);background:none;color:var(--text-muted);cursor:pointer">Cancel</button>
<button id="_moku-storage-confirm" style="font-family:var(--font-ui);font-size:var(--text-xs);letter-spacing:var(--tracking-wide);padding:5px var(--sp-3);border-radius:var(--radius-sm);border:1px solid color-mix(in srgb,var(--color-error) 40%,transparent);background:color-mix(in srgb,var(--color-error) 10%,transparent);color:var(--color-error);cursor:pointer">Download anyway</button>
</div>
`;
backdrop.appendChild(panel);
document.body.appendChild(backdrop);
function finish(result: boolean) { backdrop.remove(); resolve(result); }
panel.querySelector("#_moku-storage-cancel")!.addEventListener("click", () => finish(false));
panel.querySelector("#_moku-storage-confirm")!.addEventListener("click", () => finish(true));
backdrop.addEventListener("click", (e) => { if (e.target === backdrop) finish(false); });
});
}
private async guardStorage(queueAfter: DownloadQueueItem[]): Promise<boolean> {
if (this.freeBytes === null) return true;
if (estimateQueueBytes(queueAfter) <= this.freeBytes * 0.95) return true;
return this.confirmStorageOverrun();
}
private updateSpeed(ds: DownloadStatus) {
const active = ds.queue[0];
if (!active || active.state !== "DOWNLOADING") {
this.lastSample = null;
this.pagesPerSec = null;
this.eta = null;
return;
}
const sample: SpeedSample = {
ts: Date.now(),
progress: active.progress,
pages: active.chapter.pageCount ?? 0,
};
const speed = calcSpeed(this.lastSample, sample);
this.lastSample = sample;
if (speed !== null) {
this.pagesPerSec = speed;
this.eta = estimateEta(speed, ds.queue);
}
}
async poll() {
if (boot.sessionExpired) return;
gql<{ downloadStatus: DownloadStatus }>(GET_DOWNLOAD_STATUS)
.then((d) => this.applyStatus(d.downloadStatus))
.catch(console.error)
.finally(() => { this.loading = false; });
}
async togglePlay() {
if (this.togglingPlay) return;
this.togglingPlay = true;
const wasRunning = this.isRunning;
if (this.status) this.status = { ...this.status, state: wasRunning ? "STOPPED" : "STARTED" };
try {
if (wasRunning) {
const d = await gql<{ stopDownloader: { downloadStatus: DownloadStatus } }>(STOP_DOWNLOADER);
this.applyStatus(d.stopDownloader.downloadStatus);
} else {
const d = await gql<{ startDownloader: { downloadStatus: DownloadStatus } }>(START_DOWNLOADER);
this.applyStatus(d.startDownloader.downloadStatus);
}
} catch (e) { console.error(e); this.poll(); }
finally {
this.togglingPlay = false;
addToast({ kind: "info", title: wasRunning ? "Downloads paused" : "Downloads resumed", body: wasRunning ? "The download queue has been paused" : "The download queue is running", duration: 2500 });
}
}
async clear() {
if (this.clearing) return;
this.clearing = true;
this.selected = new Set();
if (this.status) this.status = { ...this.status, queue: [] };
setActiveDownloads([]);
try {
const d = await gql<{ clearDownloader: { downloadStatus: DownloadStatus } }>(CLEAR_DOWNLOADER);
this.applyStatus(d.clearDownloader.downloadStatus);
addToast({ kind: "info", title: "Queue cleared", body: "All pending downloads have been removed", duration: 2500 });
} catch (e) { console.error(e); this.poll(); }
finally { this.clearing = false; }
}
async dequeue(chapterId: number) {
if (this.dequeueing.has(chapterId)) return;
this.dequeueing = new Set(this.dequeueing).add(chapterId);
if (this.status) this.status = { ...this.status, queue: optimisticRemove(this.status.queue, chapterId) };
this.selected.delete(chapterId);
this.selected = new Set(this.selected);
try { await gql(DEQUEUE_DOWNLOAD, { chapterId }); this.poll(); }
catch (e) { console.error(e); this.poll(); }
finally { this.dequeueing.delete(chapterId); this.dequeueing = new Set(this.dequeueing); }
}
async dequeueSelected() {
if (this.batchWorking || this.selected.size === 0) return;
this.batchWorking = true;
const ids = [...this.selected];
if (this.status) this.status = { ...this.status, queue: optimisticRemoveMany(this.status.queue, this.selected) };
this.selected = new Set();
try {
await gql(DEQUEUE_CHAPTERS_DOWNLOAD, { chapterIds: ids });
this.poll();
addToast({ kind: "info", title: `Removed ${ids.length} download${ids.length !== 1 ? "s" : ""}`, body: "Selected items have been removed from the queue", duration: 2500 });
} catch (e) { console.error(e); this.poll(); }
finally { this.batchWorking = false; }
}
async enqueue(chapterId: number): Promise<boolean> {
const projected = [...this.queue, { chapter: { id: chapterId, pageCount: 0 }, progress: 0, state: "QUEUED" } as any];
if (!(await this.guardStorage(projected))) return false;
try { await gql(ENQUEUE_DOWNLOAD, { chapterId }); this.poll(); }
catch (e) { console.error(e); }
return true;
}
async retryOne(chapterId: number) {
if (this.dequeueing.has(chapterId)) return;
this.dequeueing = new Set(this.dequeueing).add(chapterId);
try {
await gql(DEQUEUE_DOWNLOAD, { chapterId });
const projected = this.queue.filter(i => i.chapter.id !== chapterId);
if (!(await this.guardStorage(projected))) { this.poll(); return; }
await gql(ENQUEUE_DOWNLOAD, { chapterId });
this.poll();
} catch (e) { console.error(e); this.poll(); }
finally { this.dequeueing.delete(chapterId); this.dequeueing = new Set(this.dequeueing); }
}
async retryAllErrored() {
if (this.batchWorking || !this.hasErrored) return;
this.batchWorking = true;
const ids = [...this.erroredIds];
try {
await gql(DEQUEUE_CHAPTERS_DOWNLOAD, { chapterIds: ids });
const projected = this.queue.filter(i => !this.erroredIds.has(i.chapter.id));
if (!(await this.guardStorage(projected))) { this.poll(); return; }
for (const id of ids) await gql(ENQUEUE_DOWNLOAD, { chapterId: id });
this.poll();
addToast({ kind: "info", title: `Retrying ${ids.length} failed download${ids.length !== 1 ? "s" : ""}`, duration: 3000 });
} catch (e) { console.error(e); this.poll(); }
finally { this.batchWorking = false; }
}
async retrySelected() {
if (this.batchWorking || this.selected.size === 0) return;
this.batchWorking = true;
const ids = [...this.selected].filter((id) => this.erroredIds.has(id));
this.selected = new Set();
try {
if (ids.length > 0) {
await gql(DEQUEUE_CHAPTERS_DOWNLOAD, { chapterIds: ids });
const projected = this.queue.filter(i => !new Set(ids).has(i.chapter.id));
if (!(await this.guardStorage(projected))) { this.poll(); return; }
for (const id of ids) await gql(ENQUEUE_DOWNLOAD, { chapterId: id });
addToast({ kind: "info", title: `Retrying ${ids.length} failed download${ids.length !== 1 ? "s" : ""}`, duration: 3000 });
}
this.poll();
} catch (e) { console.error(e); this.poll(); }
finally { this.batchWorking = false; }
}
async reorder(chapterId: number, direction: "up" | "down") {
const idx = this.queue.findIndex((i) => i.chapter.id === chapterId);
if (idx === -1) return;
const to = direction === "up" ? idx - 1 : idx + 1;
if (to < 0 || to >= this.queue.length) return;
const newQueue = [...this.queue];
[newQueue[idx], newQueue[to]] = [newQueue[to], newQueue[idx]];
if (this.status) this.status = { ...this.status, queue: newQueue };
try {
const d = await gql<{ reorderChapterDownload: { downloadStatus: DownloadStatus } }>(
REORDER_DOWNLOAD, { chapterId, to },
);
this.applyStatus(d.reorderChapterDownload.downloadStatus);
} catch (e) { console.error(e); this.poll(); }
}
async reorderSelected(direction: "up" | "down") {
if (this.batchWorking || this.selected.size === 0) return;
this.batchWorking = true;
const queue = [...this.queue];
const selectedIndices = queue
.map((item, i) => ({ id: item.chapter.id, i }))
.filter(({ id }) => this.selected.has(id))
.map(({ i }) => i)
.sort((a, b) => direction === "up" ? a - b : b - a);
if (direction === "up" && selectedIndices[0] === 0) { this.batchWorking = false; return; }
if (direction === "down" && selectedIndices[0] === queue.length - 1) { this.batchWorking = false; return; }
const newQueue = [...queue];
for (const idx of selectedIndices) {
const to = direction === "up" ? idx - 1 : idx + 1;
if (to < 0 || to >= newQueue.length) break;
[newQueue[idx], newQueue[to]] = [newQueue[to], newQueue[idx]];
}
if (this.status) this.status = { ...this.status, queue: newQueue };
try {
for (const idx of selectedIndices) {
const to = direction === "up" ? idx - 1 : idx + 1;
if (to < 0 || to >= queue.length) break;
const chapterId = queue[idx].chapter.id;
await gql<{ reorderChapterDownload: { downloadStatus: DownloadStatus } }>(
REORDER_DOWNLOAD, { chapterId, to },
);
}
this.poll();
} catch (e) { console.error(e); this.poll(); }
finally { this.batchWorking = false; }
}
async reorderToEdge(chapterId: number, edge: "top" | "bottom") {
const idx = this.queue.findIndex((i) => i.chapter.id === chapterId);
if (idx === -1) return;
const first = this.isRunning ? 1 : 0;
const last = this.queue.length - 1;
const to = edge === "top" ? first : last;
if (idx === to) return;
const newQueue = [...this.queue];
newQueue.splice(idx, 1);
newQueue.splice(to, 0, this.queue[idx]);
if (this.status) this.status = { ...this.status, queue: newQueue };
try {
const d = await gql<{ reorderChapterDownload: { downloadStatus: DownloadStatus } }>(
REORDER_DOWNLOAD, { chapterId, to },
);
this.applyStatus(d.reorderChapterDownload.downloadStatus);
} catch (e) { console.error(e); this.poll(); }
}
async reorderSelectedToEdge(edge: "top" | "bottom") {
if (this.batchWorking || this.selected.size === 0) return;
this.batchWorking = true;
const first = this.isRunning ? 1 : 0;
const active = this.queue.slice(0, first);
const moveable = this.queue.slice(first);
const pinned = moveable.filter((i) => this.selected.has(i.chapter.id));
const rest = moveable.filter((i) => !this.selected.has(i.chapter.id));
const newQueue = edge === "top"
? [...active, ...pinned, ...rest]
: [...active, ...rest, ...pinned];
if (this.status) this.status = { ...this.status, queue: newQueue };
const last = this.queue.length - 1;
try {
if (edge === "top") {
for (let i = 0; i < pinned.length; i++) {
await gql<{ reorderChapterDownload: { downloadStatus: DownloadStatus } }>(
REORDER_DOWNLOAD, { chapterId: pinned[i].chapter.id, to: first + i },
);
}
} else {
for (let i = 0; i < pinned.length; i++) {
await gql<{ reorderChapterDownload: { downloadStatus: DownloadStatus } }>(
REORDER_DOWNLOAD, { chapterId: pinned[i].chapter.id, to: last - (pinned.length - 1 - i) },
);
}
}
this.poll();
} catch (e) { console.error(e); this.poll(); }
finally { this.batchWorking = false; }
}
selectOnly(chapterId: number) { this.selected = new Set([chapterId]); }
toggleSelect(chapterId: number) {
const next = new Set(this.selected);
if (next.has(chapterId)) next.delete(chapterId);
else next.add(chapterId);
this.selected = next;
}
selectRange(fromId: number, toId: number) {
const ids = this.queue.map((i) => i.chapter.id);
const a = ids.indexOf(fromId), b = ids.indexOf(toId);
if (a === -1 || b === -1) return;
const [lo, hi] = a < b ? [a, b] : [b, a];
const next = new Set(this.selected);
for (let i = lo; i <= hi; i++) next.add(ids[i]);
this.selected = next;
}
selectAll() { this.selected = new Set(this.queue.map((i) => i.chapter.id)); }
clearSelection() { this.selected = new Set(); }
}
export const downloadStore = new DownloadStore();
-2
View File
@@ -1,2 +0,0 @@
export { default as Extensions } from "./components/Extensions.svelte";
export * from "./lib/extensionHelpers";
@@ -1,201 +0,0 @@
<script lang="ts">
import { Play, ArrowRight, BookOpen, Clock } from "phosphor-svelte";
import Thumbnail from "@shared/manga/Thumbnail.svelte";
import type { HistoryEntry } from "@store/state.svelte";
import type { Manga } from "@types";
import { timeAgo } from "../lib/homeHelpers";
let {
entries,
libraryManga,
onresume,
onviewhistory,
onopenlibrary,
}: {
entries: HistoryEntry[];
libraryManga: Manga[];
onresume: (entry: HistoryEntry) => void;
onviewhistory: () => void;
onopenlibrary: () => void;
} = $props();
function thumbFor(entry: HistoryEntry): string {
return libraryManga.find(m => m.id === entry.mangaId)?.thumbnailUrl ?? entry.thumbnailUrl ?? "";
}
</script>
<div class="section">
<div class="section-header">
<span class="section-title"><Clock size={10} weight="bold" /> Recent Activity</span>
{#if entries.length > 0}
<button class="see-all" onclick={onviewhistory}>
Full History <ArrowRight size={9} weight="bold" />
</button>
{/if}
</div>
<div class="list">
{#if entries.length > 0}
{#each entries as entry (entry.chapterId)}
<button class="row" onclick={() => onresume(entry)}>
<Thumbnail src={thumbFor(entry)} alt={entry.mangaTitle} class="row-thumb" />
<div class="row-info">
<span class="row-title">{entry.mangaTitle}</span>
<span class="row-sub">
{entry.chapterName}{entry.pageNumber > 1 ? ` · p.${entry.pageNumber}` : ""}
</span>
</div>
<span class="row-time">{timeAgo(entry.readAt)}</span>
<span class="row-play"><Play size={10} weight="fill" /></span>
</button>
{/each}
{:else}
<div class="placeholder">
{#each Array(5) as _, i}
<div class="row row-sk">
<div class="sk-thumb"></div>
<div class="row-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="placeholder-overlay">
<button class="placeholder-cta" onclick={onopenlibrary}>
<BookOpen size={12} weight="light" /> Start reading
</button>
</div>
</div>
{/if}
</div>
</div>
<style>
.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-2xs);
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); }
.list { display: flex; flex-direction: column; padding: 0 var(--sp-3); overflow: hidden; }
.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);
}
.row:hover { background: var(--bg-raised); border-color: var(--border-dim); }
.row:hover .row-play { opacity: 1; }
:global(.row-thumb) {
width: 33px;
height: 48px;
border-radius: var(--radius-sm);
object-fit: cover;
flex-shrink: 0;
border: 1px solid var(--border-dim);
}
.row-info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; min-width: 0; }
.row-title {
font-size: var(--text-base);
font-weight: var(--weight-medium);
color: var(--text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.row-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;
}
.row-time {
font-family: var(--font-ui);
font-size: var(--text-sm);
color: var(--text-faint);
letter-spacing: var(--tracking-wide);
flex-shrink: 0;
}
.row-play { color: var(--accent-fg); flex-shrink: 0; opacity: 0; transition: opacity var(--t-base); }
.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); animation: pulse 1.6s ease-in-out infinite; }
.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); }
.placeholder { position: relative; }
.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%);
}
.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.13);
color: rgba(255,255,255,0.62);
cursor: pointer;
transition: background var(--t-base), color var(--t-base);
}
.placeholder-cta:hover { background: rgba(255,255,255,0.14); color: rgba(255,255,255,0.9); }
@keyframes pulse { 0%, 100% { opacity: 0.4 } 50% { opacity: 0.7 } }
</style>
@@ -1,285 +0,0 @@
<script lang="ts">
let {
dailyReadCounts,
}: {
dailyReadCounts: Record<string, number>;
} = $props();
function intensity(count: number): 0 | 1 | 2 | 3 | 4 {
if (count === 0) return 0;
if (count === 1) return 1;
if (count <= 3) return 2;
if (count <= 6) return 3;
return 4;
}
let tip: { text: string; x: number; y: number } | null = $state(null);
function showTip(e: MouseEvent, cell: { dateStr: string; count: number }) {
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
const label = cell.count === 0
? `No chapters — ${fmtDate(cell.dateStr)}`
: `${cell.count} chapter${cell.count !== 1 ? "s" : ""}${fmtDate(cell.dateStr)}`;
tip = { text: label, x: rect.left + rect.width / 2, y: rect.top - 6 };
}
function hideTip() { tip = null; }
function fmtDate(d: string): string {
return new Date(d + "T00:00:00").toLocaleDateString("en-US", { month: "short", day: "numeric" });
}
function localDateStr(d: Date): string {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
}
let wrapEl: HTMLElement;
let cellSize = $state(12);
let numWeeks = $state(26);
const GAP = 3;
const DAY_GUTTER = 28;
const LEGEND_H = 20;
const MONTH_H = 14;
const ROWS = 7;
$effect(() => {
if (!wrapEl) return;
const obs = new ResizeObserver(() => {
const h = wrapEl.clientHeight;
const w = wrapEl.clientWidth;
const cs = Math.max(8, Math.floor((h - LEGEND_H - MONTH_H - 2 * GAP - (ROWS - 1) * GAP) / ROWS));
cellSize = cs;
numWeeks = Math.max(4, Math.floor((w - DAY_GUTTER - GAP * 3) / (cs + GAP)));
});
obs.observe(wrapEl);
return () => obs.disconnect();
});
const visibleWeeks = $derived((() => {
const today = new Date();
today.setHours(0, 0, 0, 0);
const todayStr = localDateStr(today);
const endDow = today.getDay(); // 0=Sun ... 6=Sat
const weekEnd = new Date(today);
weekEnd.setDate(weekEnd.getDate() + (6 - endDow)); // advance to Saturday
const weeks: { dateStr: string; count: number; isToday: boolean; isFuture: boolean }[][] = [];
for (let wi = numWeeks - 1; wi >= 0; wi--) {
const week: typeof weeks[0] = [];
for (let di = 0; di < 7; di++) {
const d = new Date(weekEnd);
d.setDate(d.getDate() - wi * 7 - (6 - di));
const dateStr = localDateStr(d);
week.push({ dateStr, count: dailyReadCounts[dateStr] ?? 0, isToday: dateStr === todayStr, isFuture: d > today });
}
weeks.push(week);
}
return weeks;
})());
const monthLabels = $derived((() => {
const labels: { label: string; colIndex: number }[] = [];
let lastMonth = -1;
visibleWeeks.forEach((week, ci) => {
const first = week[0];
if (!first) return;
const m = new Date(first.dateStr + "T00:00:00").getMonth();
if (m !== lastMonth) {
labels.push({ label: new Date(first.dateStr + "T00:00:00").toLocaleDateString("en-US", { month: "short" }), colIndex: ci });
lastMonth = m;
}
});
return labels;
})());
const DAY_LABELS = ["Sun", "", "Tue", "", "Thu", "", "Sat"];
</script>
<div class="heatmap-wrap" bind:this={wrapEl} style="--cell:{cellSize}px; --cols:{numWeeks};">
<div class="month-row">
<div class="day-gutter"></div>
<div class="month-cells">
{#each visibleWeeks as _week, ci}
{@const lbl = monthLabels.find(l => l.colIndex === ci)}
<div class="month-label">{lbl?.label ?? ""}</div>
{/each}
</div>
</div>
<div class="grid-row">
<div class="day-labels">
{#each DAY_LABELS as d}
<span class="day-label">{d}</span>
{/each}
</div>
<div class="cell-grid">
{#each visibleWeeks as week}
<div class="week-col">
{#each week as cell}
<!-- svelte-ignore a11y_mouse_events_have_key_events -->
<button
class="cell intensity-{intensity(cell.count)}"
class:cell-today={cell.isToday}
class:cell-future={cell.isFuture}
onmouseover={(e) => showTip(e, cell)}
onmouseleave={hideTip}
aria-label="{cell.count} chapters on {cell.dateStr}"
></button>
{/each}
</div>
{/each}
</div>
</div>
<div class="legend">
<span class="legend-label">Less</span>
{#each [0, 1, 2, 3, 4] as lvl}
<div class="legend-cell intensity-{lvl}"></div>
{/each}
<span class="legend-label">More</span>
</div>
</div>
{#if tip}
<div class="heatmap-tip" style="left:{tip.x}px; top:{tip.y}px;">{tip.text}</div>
{/if}
<style>
.heatmap-wrap {
display: flex;
flex-direction: column;
justify-content: center;
gap: 4px;
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
overflow: hidden;
box-sizing: border-box;
}
.month-row {
display: flex;
gap: 4px;
flex-shrink: 0;
}
.day-gutter { width: 28px; flex-shrink: 0; }
.month-cells {
display: grid;
grid-template-columns: repeat(var(--cols), var(--cell));
gap: 3px;
overflow: hidden;
}
.month-label {
font-family: var(--font-ui);
font-size: 9px;
color: var(--text-faint);
letter-spacing: var(--tracking-wide);
padding-left: 1px;
white-space: nowrap;
overflow: hidden;
}
.grid-row {
display: flex;
gap: 4px;
align-items: flex-start;
flex-shrink: 0;
}
.day-labels {
display: flex;
flex-direction: column;
gap: 3px;
flex-shrink: 0;
width: 28px;
}
.day-label {
font-family: var(--font-ui);
font-size: 8px;
color: var(--text-faint);
letter-spacing: var(--tracking-wide);
height: var(--cell);
line-height: var(--cell);
text-align: right;
}
.cell-grid {
display: grid;
grid-template-columns: repeat(var(--cols), var(--cell));
gap: 3px;
overflow: visible;
padding: 4px;
margin: -4px;
}
.week-col {
display: flex;
flex-direction: column;
gap: 3px;
}
.cell {
width: var(--cell);
height: var(--cell);
border-radius: 3px;
border: none;
padding: 0;
cursor: pointer;
transition: filter var(--t-fast), transform var(--t-fast);
}
.cell:hover:not(.cell-future) {
filter: brightness(1.5);
transform: scale(1.2);
z-index: 1;
position: relative;
}
.intensity-0 { background: var(--bg-subtle); border: 1px solid var(--border-dim); }
.intensity-1 { background: var(--accent-muted); border: 1px solid var(--accent-dim); }
.intensity-2 { background: var(--accent-dim); border: 1px solid var(--accent); opacity: 0.7; }
.intensity-3 { background: var(--accent); border: 1px solid var(--accent-bright); opacity: 0.85; }
.intensity-4 { background: var(--accent-bright); border: 1px solid var(--accent-fg); }
.cell-today { outline: 1.5px solid var(--accent-fg); outline-offset: 1px; }
.cell-future { opacity: 0.2; cursor: default; pointer-events: none; }
.legend {
display: flex;
align-items: center;
gap: 3px;
justify-content: flex-end;
flex-shrink: 0;
padding-top: 2px;
}
.legend-cell {
width: 10px;
height: 10px;
border-radius: 3px;
flex-shrink: 0;
}
.legend-label {
font-family: var(--font-ui);
font-size: 9px;
color: var(--text-faint);
letter-spacing: var(--tracking-wide);
}
.heatmap-tip {
position: fixed;
transform: translate(-50%, -100%);
background: var(--bg-overlay);
border: 1px solid var(--border-base);
border-radius: var(--radius-sm);
padding: 4px 8px;
font-family: var(--font-ui);
font-size: var(--text-2xs);
color: var(--text-secondary);
letter-spacing: var(--tracking-wide);
white-space: nowrap;
pointer-events: none;
z-index: 9999;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5);
}
</style>
@@ -1,194 +0,0 @@
<script lang="ts">
import { MagnifyingGlass, X as XIcon } from "phosphor-svelte";
import Thumbnail from "@shared/manga/Thumbnail.svelte";
import type { Manga } from "@types";
let {
slotIndex,
libraryManga,
loading,
onpin,
onclose,
}: {
slotIndex: 1 | 2 | 3;
libraryManga: Manga[];
loading: boolean;
onpin: (m: Manga) => void;
onclose: () => void;
} = $props();
let search = $state("");
function focusEl(node: HTMLElement) { node.focus(); }
const results = $derived(
search.trim()
? libraryManga.filter(m => m.title.toLowerCase().includes(search.toLowerCase())).slice(0, 20)
: libraryManga.slice(0, 20)
);
</script>
<div
class="backdrop"
role="presentation"
onclick={(e) => { if (e.target === e.currentTarget) onclose(); }}
onkeydown={(e) => { if (e.key === "Escape") onclose(); }}
>
<div class="modal">
<div class="modal-header">
<span class="modal-title">Pin manga — slot {slotIndex + 1}</span>
<button class="modal-close" onclick={onclose}><XIcon size={13} weight="light" /></button>
</div>
<div class="search-wrap">
<MagnifyingGlass size={12} weight="light" style="color:var(--text-faint);flex-shrink:0" />
<input class="search-input" placeholder="Search library…" bind:value={search} use:focusEl />
</div>
<div class="list">
{#if loading}
<p class="empty-msg">Loading…</p>
{:else if results.length === 0}
<p class="empty-msg">No results</p>
{:else}
{#each results as m (m.id)}
<button class="list-row" onclick={() => onpin(m)}>
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="row-thumb" />
<div class="row-info">
<span class="row-title">{m.title}</span>
{#if m.source?.displayName}<span class="row-source">{m.source.displayName}</span>{/if}
</div>
</button>
{/each}
{/if}
</div>
</div>
</div>
<style>
.backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.62);
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);
}
.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;
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--sp-4) var(--sp-5);
border-bottom: 1px solid var(--border-dim);
flex-shrink: 0;
}
.modal-title {
font-size: var(--text-sm);
font-weight: var(--weight-medium);
color: var(--text-secondary);
}
.modal-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;
transition: background var(--t-fast), color var(--t-fast);
}
.modal-close:hover { color: var(--text-muted); background: var(--bg-raised); }
.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;
}
.search-input {
flex: 1;
background: none;
border: none;
outline: none;
color: var(--text-primary);
font-size: var(--text-sm);
}
.search-input::placeholder { color: var(--text-faint); }
.list {
flex: 1;
overflow-y: auto;
padding: var(--sp-2);
scrollbar-width: none;
}
.list::-webkit-scrollbar { display: none; }
.empty-msg {
font-family: var(--font-ui);
font-size: var(--text-xs);
color: var(--text-faint);
padding: var(--sp-4) var(--sp-3);
text-align: center;
}
.list-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);
}
.list-row:hover { background: var(--bg-raised); }
:global(.row-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;
}
.row-info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; min-width: 0; }
.row-title {
font-size: var(--text-sm);
color: var(--text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.row-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) } }
</style>
@@ -1,589 +0,0 @@
<script lang="ts">
import { Play, ArrowRight, ArrowLeft, BookOpen, Clock, ListBullets, PushPin, X as XIcon } from "phosphor-svelte";
import type { Manga, Chapter } from "@types";
import type { HistoryEntry } from "@store/state.svelte";
import { store, setGenreFilter, setNavPage } from "@store/state.svelte";
import { timeAgo } from "../lib/homeHelpers";
interface HeroSlot {
kind: "continue" | "pinned" | "empty";
entry?: HistoryEntry;
manga?: Manga;
slotIndex: number;
}
let {
resolvedSlots,
activeIdx = $bindable(),
heroThumb,
heroTitle,
heroManga,
heroEntry,
heroMangaId,
heroChapters,
heroNewChapter,
loadingHeroChapters,
resuming,
onresume,
onopenchapter,
oncyclenext,
oncycleprev,
ongotoslot,
onopenpicker,
onunpin,
onviewall,
}: {
resolvedSlots: HeroSlot[];
activeIdx: number;
heroThumb: string;
heroTitle: string;
heroManga: Manga | null | undefined;
heroEntry: HistoryEntry | null;
heroMangaId: number | null;
heroChapters: Chapter[];
heroNewChapter: Chapter | null;
loadingHeroChapters: boolean;
resuming: boolean;
onresume: () => void;
onopenchapter: (ch: Chapter) => void;
oncyclenext: () => void;
oncycleprev: () => void;
ongotoslot: (i: number) => void;
onopenpicker: (i: 1 | 2 | 3) => void;
onunpin: (i: 1 | 2 | 3) => void;
onviewall: () => void;
} = $props();
const activeSlot = $derived(resolvedSlots[activeIdx]);
const TOTAL_SLOTS = 4;
</script>
<div class="hero-stage">
{#key heroThumb}
{#if heroThumb}
<div class="hero-backdrop" style="background-image:url({heroThumb})"></div>
{:else}
<div class="hero-backdrop hero-bd-empty"></div>
{/if}
{/key}
<div class="hero-scrim"></div>
<button
class="hero-cover-col"
onclick={onresume}
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={20} 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={() => onopenpicker(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}
{#if heroNewChapter && !heroNewChapter.isRead}
<span class="hero-tag hero-tag-new">New ch.{Math.floor(heroNewChapter.chapterNumber)}</span>
{/if}
{#each (heroManga?.genre ?? []).slice(0, 3) as g}
<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={onresume} 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={() => onunpin(activeSlot.slotIndex as 1 | 2 | 3)}>
<XIcon size={10} weight="bold" /> Unpin
</button>
{:else}
<button class="hero-cta-ghost" onclick={() => onopenpicker(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={oncycleprev} 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={() => ongotoslot(i)}
aria-label="Slot {i + 1}"
></button>
{/each}
</div>
<button class="hero-nav-btn" onclick={oncyclenext} 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={() => onopenchapter(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={onviewall}>
All chapters <ArrowRight size={9} weight="bold" />
</button>
{/if}
{/if}
</div>
</div>
<style>
.hero-stage {
position: relative;
display: flex;
align-items: stretch;
height: 374px;
overflow: hidden;
background: var(--bg-raised);
border-bottom: 1px solid var(--border-dim);
}
.hero-backdrop {
position: absolute;
inset: -14px;
background-size: cover;
background-position: center 25%;
filter: blur(22px) saturate(2.4) brightness(0.4);
transform: scale(1.07);
pointer-events: none;
z-index: 0;
animation: backdropIn 0.5s ease both;
}
.hero-bd-empty { background: var(--bg-void); filter: none; }
.hero-scrim {
position: absolute;
inset: 0;
z-index: 1;
pointer-events: none;
background: linear-gradient(110deg, rgba(0,0,0,0.0) 0%, rgba(0,0,0,0.6) 100%);
}
.hero-cover-col {
position: relative;
z-index: 2;
flex-shrink: 0;
width: 256px;
height: 374px;
overflow: hidden;
cursor: pointer;
background: var(--bg-raised);
padding: 0;
border: none;
border-right: 1px solid rgba(255, 255, 255, 0.07);
}
.hero-cover-col:hover .hero-cover { filter: brightness(1.1) saturate(1.05); }
.hero-cover-col:hover .cover-resume-hint { opacity: 1; }
.hero-cover { width: 100%; height: 100%; object-fit: cover; display: block; transition: filter 0.22s 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: 0;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
background: rgba(0, 0, 0, 0.38);
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-5) var(--sp-5) var(--sp-4);
display: flex;
flex-direction: column;
gap: var(--sp-2);
overflow: hidden;
border-right: 1px solid rgba(255, 255, 255, 0.05);
}
.hero-tags { display: flex; flex-wrap: wrap; gap: 5px; flex-shrink: 0; }
.hero-tag {
display: inline-flex;
align-items: center;
gap: 5px;
font-family: var(--font-ui);
font-size: 9px;
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
padding: 3px 8px;
border-radius: var(--radius-sm);
background: rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.6);
border: 1px solid rgba(255, 255, 255, 0.13);
}
.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-new { background: rgba(74, 222, 128, 0.15); color: #86efac; border-color: rgba(74, 222, 128, 0.25); }
.hero-tag-genre { cursor: pointer; transition: background 0.15s, color 0.15s; }
.hero-tag-genre: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-semibold);
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 12px rgba(0, 0, 0, 0.55);
letter-spacing: -0.01em;
}
.hero-author {
font-family: var(--font-ui);
font-size: var(--text-xs);
color: rgba(255, 255, 255, 0.45);
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.55);
letter-spacing: var(--tracking-wide);
}
.hero-prog-page { color: rgba(255, 255, 255, 0.35); }
.hero-prog-time { margin-left: auto; color: rgba(255, 255, 255, 0.3); }
.hero-desc {
font-size: var(--text-xs);
color: rgba(255, 255, 255, 0.38);
line-height: 1.6;
display: -webkit-box;
-webkit-line-clamp: 3;
-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.48);
flex-shrink: 0;
}
.hero-empty-sub {
font-family: var(--font-ui);
font-size: var(--text-xs);
color: rgba(255, 255, 255, 0.26);
letter-spacing: var(--tracking-wide);
line-height: var(--leading-snug);
}
.hero-actions { display: flex; gap: var(--sp-2); flex-shrink: 0; flex-wrap: wrap; margin-top: var(--sp-1); }
.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 18px;
border-radius: var(--radius-md);
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.18); }
.hero-cta:disabled { opacity: 0.5; 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-md);
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.11);
color: rgba(255, 255, 255, 0.48);
cursor: pointer;
transition: background var(--t-base), color var(--t-base);
white-space: nowrap;
}
.hero-cta-ghost:hover { background: rgba(255, 255, 255, 0.12); 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-3);
border-top: 1px solid rgba(255, 255, 255, 0.07);
}
.hero-nav-btn {
display: flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.11);
color: rgba(255, 255, 255, 0.55);
cursor: pointer;
flex-shrink: 0;
transition: background var(--t-base), color var(--t-base);
}
.hero-nav-btn:hover { background: rgba(255, 255, 255, 0.18); 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.2);
border: none;
cursor: pointer;
padding: 0;
transition: background var(--t-base), transform var(--t-base), width var(--t-base);
}
.hero-dot:hover { background: rgba(255, 255, 255, 0.48); }
.hero-dot.active { background: #fff; width: 14px; border-radius: 3px; }
.hero-dot.pinned { background: rgba(168, 132, 232, 0.5); }
.hero-dot.pinned.active { background: #c4a8f0; }
.hero-counter {
font-family: var(--font-ui);
font-size: 10px;
color: rgba(255, 255, 255, 0.28);
letter-spacing: var(--tracking-wide);
margin-left: auto;
}
.hero-chapters {
position: relative;
z-index: 2;
width: clamp(180px, 30%, 232px);
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-2xs);
color: rgba(255, 255, 255, 0.35);
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.07);
flex-shrink: 0;
}
.hero-chapters-empty {
font-family: var(--font-ui);
font-size: var(--text-xs);
color: rgba(255, 255, 255, 0.22);
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.32);
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.72);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.chapter-row-read .ch-name { color: rgba(255, 255, 255, 0.32); }
.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.26);
letter-spacing: var(--tracking-wide);
}
.ch-read { color: rgba(255, 255, 255, 0.18); }
: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); animation: pulse 1.6s ease-in-out infinite; }
.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.28);
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); }
@keyframes backdropIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes pulse { 0%, 100% { opacity: 0.4 } 50% { opacity: 0.7 } }
</style>
-376
View File
@@ -1,376 +0,0 @@
<script lang="ts">
import { onMount, untrack } from "svelte";
import { gql, resolveImageUrl } from "@api/client";
import { GET_CHAPTERS } from "@api/queries/chapters";
import { GET_LIBRARY } from "@api/queries/manga";
import { cache, CACHE_KEYS } from "@core/cache";
import { store, openReader, setHeroSlot, setNavPage, setLibraryFilter, clearLibraryUpdates } from "@store/state.svelte";
import type { HistoryEntry } from "@store/state.svelte";
import type { Manga, Chapter } from "@types";
import { buildReaderChapterList } from "@features/series/lib/chapterList";
import HeroStage from "./HeroStage.svelte";
import HeroSlotPicker from "./HeroSlotPicker.svelte";
import ActivityFeed from "./ActivityFeed.svelte";
import ActivityHeatmap from "./ActivityHeatmap.svelte";
import RecsRow from "./RecsRow.svelte";
import StatsGrid from "./StatsGrid.svelte";
let libraryManga: Manga[] = $state([]);
let loadingLibrary: boolean = $state(true);
onMount(() => {
loadLibrary();
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
});
function loadLibrary() {
cache.get(CACHE_KEYS.LIBRARY, () =>
gql<{ mangas: { nodes: Manga[] } }>(GET_LIBRARY).then(d => d.mangas.nodes)
)
.then(m => { libraryManga = m; })
.catch(console.error)
.finally(() => loadingLibrary = false);
}
function resetAndReload() {
cache.clear(CACHE_KEYS.LIBRARY);
loadingLibrary = true;
heroChapters = [];
heroAllChapters = [];
heroChaptersFor = null;
loadLibrary();
}
$effect(() => {
if (store.navPage === "home") untrack(() => resetAndReload());
});
$effect(() => {
const sessionId = store.readerSessionId;
if (sessionId === 0) return;
untrack(() => resetAndReload());
});
const continueReading = $derived((() => {
const seen = new Set<number>();
const out: HistoryEntry[] = [];
for (const e of store.history) {
if (seen.has(e.mangaId)) continue;
seen.add(e.mangaId);
out.push(e);
if (out.length >= 10) break;
}
return out;
})());
const TOTAL_SLOTS = 4;
interface HeroSlot { kind: "continue" | "pinned" | "empty"; entry?: HistoryEntry; manga?: Manga; slotIndex: number; }
const resolvedSlots = $derived((() => {
const pins = store.settings.heroSlots ?? [null, null, null, null];
const slots: HeroSlot[] = [];
const first = continueReading[0];
slots.push(first ? { kind: "continue", entry: first, slotIndex: 0 } : { kind: "empty", slotIndex: 0 });
let hi = 1;
for (let i = 1; i < TOTAL_SLOTS; i++) {
const pinId = pins[i];
if (pinId != null) {
const manga = libraryManga.find(m => m.id === pinId);
if (manga) { slots.push({ kind: "pinned", manga, slotIndex: i }); continue; }
}
const entry = continueReading[hi++];
slots.push(entry ? { kind: "continue", entry, slotIndex: i } : { kind: "empty", slotIndex: i });
}
return slots;
})());
let activeIdx = $state(0);
const activeSlot = $derived(resolvedSlots[activeIdx]);
const 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(heroManga?.id ?? heroEntry?.mangaId ?? null);
const heroTitle = $derived(heroManga?.title ?? heroEntry?.mangaTitle ?? "");
const heroThumbSrc = $derived(
heroManga?.thumbnailUrl
?? (activeSlot?.kind === "continue" ? activeSlot.entry?.thumbnailUrl : undefined)
?? ""
);
let heroThumb = $state("");
$effect(() => {
const path = heroThumbSrc;
if (!path) { heroThumb = ""; return; }
resolveImageUrl(path)
.then(url => { heroThumb = url; })
.catch(() => { heroThumb = ""; });
});
const heroNewChapter = $derived(
heroManga ? (libraryManga.find(m => m.id === heroManga!.id) as any)?.latestUploadedChapter ?? null : null
);
function cycleNext() { activeIdx = (activeIdx + 1) % TOTAL_SLOTS; heroChapters = []; heroAllChapters = []; }
function 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();
}
let heroChapters: Chapter[] = $state([]);
let heroAllChapters: Chapter[] = $state([]);
let loadingHeroChapters = $state(false);
let heroChaptersFor: number | null = null;
$effect(() => {
const id = heroMangaId;
void store.settings.mangaPrefs?.[id!];
if (id) untrack(() => loadHeroChapters(id));
});
async function loadHeroChapters(mangaId: number) {
heroChaptersFor = mangaId;
loadingHeroChapters = true;
heroChapters = [];
heroAllChapters = [];
try {
const d = await gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId });
if (heroChaptersFor !== mangaId) return;
const all = [...d.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder);
heroAllChapters = all;
const filtered = buildReaderChapterList(all, store.settings.mangaPrefs?.[mangaId]);
const lastReadIdx = heroEntry ? filtered.findIndex(c => c.id === heroEntry!.chapterId) : filtered.findLastIndex(c => c.isRead);
const startIdx = Math.max(0, lastReadIdx);
heroChapters = filtered.slice(startIdx, startIdx + 5);
} catch { heroChapters = []; heroAllChapters = []; }
finally { loadingHeroChapters = false; }
}
let resuming = $state(false);
function liveMangaStub(): Manga {
return heroManga ?? { id: heroMangaId!, title: heroTitle, thumbnailUrl: heroThumbSrc } as any;
}
async function openChapter(chapter: Chapter) {
if (!heroMangaId) return;
resuming = true;
try {
let all = heroAllChapters;
if (!all.length) {
const d = await gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId: heroMangaId });
all = [...d.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder);
}
if (all.length) {
store.activeManga = liveMangaStub();
const list = buildReaderChapterList(all, store.settings.mangaPrefs?.[heroMangaId]);
const target = list.find(c => c.id === chapter.id) ?? list[0];
if (target) openReader(target, list);
}
} catch { store.activeManga = liveMangaStub(); }
finally { resuming = false; }
}
async function resumeActive() {
if (!heroEntry && heroManga) { store.activeManga = heroManga; return; }
if (!heroEntry) return;
const target = heroAllChapters.find(c => c.id === heroEntry!.chapterId) ?? heroAllChapters[0];
if (target && heroAllChapters.length) { await openChapter(target); return; }
resuming = true;
try {
const d = await gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId: heroEntry.mangaId });
const raw = [...d.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder);
const list = buildReaderChapterList(raw, store.settings.mangaPrefs?.[heroEntry.mangaId]);
const ch = list.find(c => c.id === heroEntry!.chapterId) ?? list[0];
if (ch) {
store.activeManga = liveMangaStub();
openReader(ch, list);
}
} catch { store.activeManga = liveMangaStub(); }
finally { resuming = false; }
}
async function resumeEntry(entry: HistoryEntry) {
const liveManga = libraryManga.find(m => m.id === entry.mangaId);
const stub = liveManga ?? { id: entry.mangaId, title: entry.mangaTitle, thumbnailUrl: liveManga?.thumbnailUrl ?? entry.thumbnailUrl } as any;
try {
const d = await gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId: entry.mangaId });
const raw = [...d.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder);
const list = buildReaderChapterList(raw, store.settings.mangaPrefs?.[entry.mangaId]);
const ch = list.find(c => c.id === entry.chapterId) ?? list[0];
store.activeManga = stub;
if (ch) openReader(ch, list);
} catch { store.activeManga = stub; }
}
let pickerOpen = $state(false);
let pickerSlotIndex: 1 | 2 | 3 | null = $state(null);
function openPicker(i: 1 | 2 | 3) { pickerSlotIndex = i; pickerOpen = true; }
function closePicker() { pickerOpen = false; pickerSlotIndex = null; }
function pinManga(m: Manga) { if (pickerSlotIndex !== null) { setHeroSlot(pickerSlotIndex, m.id); closePicker(); } }
function unpinSlot(i: 1 | 2 | 3) { setHeroSlot(i, null); }
const recentHistory = $derived(store.history.slice(0, 6));
const stats = $derived(store.readingStats);
const libraryUpdates = $derived(store.libraryUpdates.slice(0, 7));
const lastRefresh = $derived(store.lastLibraryRefresh);
</script>
<div class="root">
<div class="body">
<div class="hero-shrink-guard">
<HeroStage
{resolvedSlots}
bind:activeIdx
{heroThumb}
{heroTitle}
{heroManga}
{heroEntry}
{heroMangaId}
{heroChapters}
{heroNewChapter}
{loadingHeroChapters}
{resuming}
onresume={resumeActive}
onopenchapter={openChapter}
oncyclenext={cycleNext}
oncycleprev={cyclePrev}
ongotoslot={goToSlot}
onopenpicker={openPicker}
onunpin={unpinSlot}
onviewall={() => { if (heroManga) store.activeManga = heroManga; }}
/>
</div>
<div class="scroll-body">
<div class="mid-row">
<div class="mid-left">
<ActivityFeed
entries={recentHistory}
{libraryManga}
onresume={resumeEntry}
onviewhistory={() => setNavPage("history")}
onopenlibrary={() => setNavPage("library")}
/>
</div>
<div class="mid-divider"></div>
<div class="mid-right">
<RecsRow
{libraryManga}
history={store.history}
onopenrecommended={(m) => { store.previewManga = m; }}
/>
</div>
</div>
<div class="bottom-row">
<div class="bottom-heatmap">
<span class="bottom-label">Activity</span>
<ActivityHeatmap dailyReadCounts={store.dailyReadCounts} />
</div>
<div class="bottom-divider"></div>
<div class="bottom-stats">
<StatsGrid {stats} updateCount={libraryUpdates.length} />
</div>
</div>
</div>
</div>
</div>
{#if pickerOpen && pickerSlotIndex !== null}
<HeroSlotPicker
slotIndex={pickerSlotIndex}
{libraryManga}
loading={loadingLibrary}
onpin={pinManga}
onclose={closePicker}
/>
{/if}
<style>
.root {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
animation: fadeIn 0.4s ease both;
}
.body {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
min-height: 0;
}
.hero-shrink-guard { flex-shrink: 0; }
.scroll-body {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
min-height: 0;
scrollbar-width: none;
}
.scroll-body::-webkit-scrollbar { display: none; }
.mid-row {
display: grid;
grid-template-columns: 1fr 1px 1.4fr;
border-top: 1px solid var(--border-dim);
flex-shrink: 0;
min-height: 0;
}
.mid-left {
min-width: 0;
overflow: hidden;
}
.mid-left :global(.section) { border-top: none; }
.mid-divider { background: var(--border-dim); align-self: stretch; }
.mid-right {
min-width: 0;
overflow: hidden;
padding: var(--sp-3) var(--sp-4) var(--sp-4);
}
.bottom-row {
display: grid;
grid-template-columns: 1fr 1px 1fr;
border-top: 1px solid var(--border-dim);
flex-shrink: 0;
}
.bottom-divider { background: var(--border-dim); align-self: stretch; }
.bottom-heatmap {
display: flex;
flex-direction: column;
gap: var(--sp-2);
padding: var(--sp-4) var(--sp-4) var(--sp-5);
min-width: 0;
}
.bottom-stats {
padding: var(--sp-4) var(--sp-4) var(--sp-5);
min-width: 0;
overflow: hidden;
}
.bottom-label {
font-family: var(--font-ui);
font-size: var(--text-2xs);
color: var(--text-faint);
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: translateY(0); }
}
</style>
@@ -1,244 +0,0 @@
<script lang="ts">
import { ArrowLeft, ArrowRight, Sparkle } from "phosphor-svelte";
import Thumbnail from "@shared/manga/Thumbnail.svelte";
import type { Manga } from "@types";
import type { HistoryEntry } from "@store/state.svelte";
import { fetchRecommendations, topGenres } from "../lib/recommendations";
import type { RecommendedManga } from "../lib/recommendations";
let {
libraryManga,
history,
onopenrecommended,
}: {
libraryManga: Manga[];
history: HistoryEntry[];
onopenrecommended: (m: Manga) => void;
} = $props();
const CARD_MIN_WIDTH = 100;
const GAP = 12;
const ROWS = 2;
let containerEl: HTMLDivElement | undefined = $state();
let containerWidth = $state(0);
$effect(() => {
if (!containerEl) return;
const ro = new ResizeObserver(([entry]) => {
containerWidth = entry.contentRect.width;
});
ro.observe(containerEl);
return () => ro.disconnect();
});
const cols = $derived(containerWidth > 0 ? Math.max(1, Math.floor((containerWidth + GAP) / (CARD_MIN_WIDTH + GAP))) : 6);
const visibleCount = $derived(cols * ROWS);
const gridStyle = $derived(`grid-template-columns: repeat(${cols}, 1fr);`);
let allRecs: RecommendedManga[] = $state([]);
let loading = $state(false);
let _ctrl: AbortController | null = null;
$effect(() => {
const _history = history;
const _library = libraryManga;
if (!_history.length || !_library.length) { allRecs = []; return; }
_ctrl?.abort();
const ctrl = new AbortController();
_ctrl = ctrl;
loading = true;
fetchRecommendations(_history, _library, ctrl.signal)
.then(r => { if (!ctrl.signal.aborted) { allRecs = r; loading = false; } })
.catch(() => { if (!ctrl.signal.aborted) loading = false; });
});
const genres = $derived(topGenres(history, libraryManga));
let genreIdx = $state(0);
const activeGenre = $derived(genres[genreIdx] ?? null);
const visibleRecs = $derived(
(activeGenre
? allRecs.filter(r => r.matchedGenres.some(g => g.toLowerCase() === activeGenre.toLowerCase()))
: allRecs
).slice(0, visibleCount)
);
function prev() { genreIdx = (genreIdx - 1 + genres.length) % genres.length; }
function next() { genreIdx = (genreIdx + 1) % genres.length; }
</script>
<div class="col">
<div class="col-header">
<span class="col-title">
<Sparkle size={10} weight="bold" /> Recommended
</span>
{#if genres.length > 1}
<div class="genre-switcher">
<button class="nav-btn" onclick={prev}><ArrowLeft size={9} weight="bold" /></button>
<span class="genre-label">{activeGenre}</span>
<button class="nav-btn" onclick={next}><ArrowRight size={9} weight="bold" /></button>
</div>
{/if}
</div>
<div class="grid-container" bind:this={containerEl}>
{#if loading}
<p class="empty-msg">Loading…</p>
{:else if visibleRecs.length > 0}
<div class="card-grid" style={gridStyle}>
{#each visibleRecs as r (r.manga.id)}
<button class="card" onclick={() => onopenrecommended(r.manga)}>
<div class="card-cover-wrap">
<Thumbnail src={r.manga.thumbnailUrl} alt={r.manga.title} class="card-cover" />
<div class="card-gradient"></div>
<div class="card-footer">
<p class="card-title">{r.manga.title}</p>
<p class="card-badge">{r.matchedGenres.slice(0, 2).join(" · ")}</p>
</div>
</div>
</button>
{/each}
</div>
{:else}
<p class="empty-msg">No recommendations found</p>
{/if}
</div>
</div>
<style>
.col { display: flex; flex-direction: column; min-width: 0; height: 100%; }
.col-header {
display: flex;
align-items: center;
justify-content: space-between;
padding-bottom: var(--sp-2);
flex-shrink: 0;
}
.col-title {
display: inline-flex;
align-items: center;
gap: var(--sp-2);
font-family: var(--font-ui);
font-size: var(--text-2xs);
color: var(--text-faint);
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
}
.genre-switcher {
display: flex;
align-items: center;
gap: var(--sp-2);
}
.genre-label {
font-family: var(--font-ui);
font-size: var(--text-2xs);
color: var(--text-faint);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
min-width: 48px;
text-align: center;
}
.nav-btn {
display: flex;
align-items: center;
justify-content: center;
background: none;
border: none;
cursor: pointer;
padding: 2px;
color: var(--text-faint);
transition: color var(--t-base);
}
.nav-btn:hover { color: var(--accent-fg); }
.grid-container {
flex: 1;
min-width: 0;
min-height: 0;
overflow: hidden;
}
.card-grid {
display: grid;
grid-template-rows: repeat(2, auto);
grid-auto-rows: 0;
overflow: hidden;
gap: var(--sp-3);
align-content: start;
}
.card {
width: 100%;
background: none;
border: none;
padding: 0;
cursor: pointer;
text-align: left;
}
.card:hover :global(.card-cover) { filter: brightness(1.1) saturate(1.05); transform: scale(1.02); }
.card-cover-wrap {
position: relative;
aspect-ratio: 2 / 3;
overflow: hidden;
border-radius: var(--radius-md);
background: var(--bg-raised);
border: 1px solid var(--border-dim);
box-shadow: 0 2px 14px rgba(0, 0, 0, 0.38);
}
:global(.card-cover) {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
transition: filter 0.15s ease, transform 0.15s ease;
}
.card-gradient {
position: absolute;
inset: 0;
background: linear-gradient(to top, rgba(0,0,0,0.88) 0%, rgba(0,0,0,0.08) 55%, transparent 75%);
pointer-events: none;
}
.card-footer {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: var(--sp-2);
pointer-events: none;
}
.card-title {
font-size: var(--text-xs);
font-weight: var(--weight-medium);
color: rgba(255,255,255,0.92);
line-height: var(--leading-snug);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-shadow: 0 1px 4px rgba(0,0,0,0.7);
}
.card-badge {
font-family: var(--font-ui);
font-size: 9px;
color: rgba(255,255,255,0.45);
letter-spacing: var(--tracking-wide);
margin-top: 1px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.empty-msg {
font-family: var(--font-ui);
font-size: var(--text-sm);
color: var(--text-faint);
letter-spacing: var(--tracking-wide);
padding: var(--sp-1) 0;
}
</style>

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