Compare commits

..

4 Commits

Author SHA1 Message Date
Youwes09 6a8b6e7f93 Fix: Inject Bundle Resources Via --config Flag 2026-03-20 21:34:42 -05:00
Youwes09 a452cdc2e3 Fix: Branch Input to Windows Workflow 2026-03-20 21:25:23 -05:00
Youwes09 71a6eb02b5 Fix: Use jq in Workflow 2026-03-20 21:22:52 -05:00
Youwes09 50e981574a Add: Windows Build Workflow 2026-03-20 21:16:11 -05:00
351 changed files with 21732 additions and 44097 deletions
-78
View File
@@ -1,78 +0,0 @@
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
@@ -1,5 +0,0 @@
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.
@@ -1,47 +0,0 @@
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
@@ -1,18 +0,0 @@
# 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
-128
View File
@@ -1,128 +0,0 @@
name: Build Linux
on:
workflow_dispatch:
inputs:
version:
description: "Version to build (e.g. 0.9.0)"
required: true
permissions:
contents: write
jobs:
frontend:
name: Build frontend
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with: { version: latest }
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm build:static
- uses: actions/upload-artifact@v4
with:
name: frontend-dist-linux
path: dist/
retention-days: 1
tauri:
name: Tauri (Linux x64)
needs: frontend
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with: { name: frontend-dist-linux, path: dist/ }
- name: Read versions
run: |
source .github/read_versions.sh
echo "MOKU_VERSION=$MOKU_VERSION" >> $GITHUB_ENV
echo "SUWA_VERSION=$SUWA_VERSION" >> $GITHUB_ENV
echo "SUWA_HASH=$SUWA_HASH_LINUX" >> $GITHUB_ENV
- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y \
libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libfuse2
- uses: dtolnay/rust-toolchain@stable
with: { targets: x86_64-unknown-linux-gnu }
- uses: Swatinem/rust-cache@v2
with: { workspaces: src-tauri }
- uses: pnpm/action-setup@v4
with: { version: latest }
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- name: Download Suwayomi (Linux x64)
run: |
curl -fsSL \
"https://github.com/Suwayomi/Suwayomi-Server-preview/releases/download/v${SUWA_VERSION}/Suwayomi-Server-v${SUWA_VERSION}-linux-x64.tar.gz" \
-o suwayomi-linux.tar.gz
echo "${SUWA_HASH} suwayomi-linux.tar.gz" | sha256sum -c -
mkdir -p suwayomi-extracted
tar -xzf suwayomi-linux.tar.gz -C suwayomi-extracted --strip-components=1
- name: Stage Suwayomi bundle
run: |
mkdir -p src-tauri/binaries
for f in suwayomi-extracted/bin/Suwayomi-Server.jar \
suwayomi-extracted/jre/bin/java \
suwayomi-extracted/bin/catch_abort.so; do
[ -e "$f" ] || { echo "ERROR: missing $f"; find suwayomi-extracted -type f | head -40; exit 1; }
done
cp -r suwayomi-extracted src-tauri/binaries/suwayomi-bundle
chmod +x src-tauri/binaries/suwayomi-bundle/jre/bin/java
- name: Stage Linux launcher sidecar
run: |
cp src-tauri/binaries/suwayomi-launcher-linux.sh \
src-tauri/binaries/suwayomi-launcher-linux-x86_64-unknown-linux-gnu
chmod +x src-tauri/binaries/suwayomi-launcher-linux-x86_64-unknown-linux-gnu
- name: Patch tauri.conf.json for CI
run: |
sed -i 's/"beforeBuildCommand": "pnpm build"/"beforeBuildCommand": ""/' src-tauri/tauri.conf.json
- name: Build Tauri app
run: pnpm tauri build --target x86_64-unknown-linux-gnu --config src-tauri/tauri.linux.conf.json --verbose
env: { NO_STRIP: "true" }
- name: Upload Linux artifacts to release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
for i in $(seq 1 12); do
RELEASE_ID=$(curl -s -H "Authorization: Bearer $GITHUB_TOKEN" \
"https://api.github.com/repos/moku-project/Moku/releases" \
| jq -r '.[] | select(.tag_name == "v${{ github.event.inputs.version }}") | .id' | head -1)
[ -n "$RELEASE_ID" ] && break
echo "Waiting for release... attempt $i"; sleep 15
done
[ -z "$RELEASE_ID" ] && { echo "ERROR: release not found"; exit 1; }
upload() {
curl -s -X POST \
-H "Authorization: Bearer $GITHUB_TOKEN" \
-H "Content-Type: application/octet-stream" \
--data-binary @"$1" \
"https://uploads.github.com/repos/moku-project/Moku/releases/$RELEASE_ID/assets?name=$2"
}
APPIMAGE=$(find src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage -name "*.AppImage" | head -1)
DEB=$(find src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/deb -name "*.deb" | head -1)
[ -n "$APPIMAGE" ] && upload "$APPIMAGE" "moku-linux-x64-${{ github.event.inputs.version }}.AppImage"
[ -n "$DEB" ] && upload "$DEB" "moku-linux-x64-${{ github.event.inputs.version }}.deb"
+180 -68
View File
@@ -4,133 +4,245 @@ on:
workflow_dispatch:
inputs:
version:
description: "Version to build (e.g. 0.9.0)"
description: "Version to build (e.g. 0.3.0)"
required: true
permissions:
contents: write
jobs:
frontend:
name: Build frontend
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with: { version: latest }
with:
version: latest
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm build:static
- uses: actions/upload-artifact@v4
with: { name: frontend-dist, path: dist/, retention-days: 1 }
- 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
tauri:
name: Tauri (macOS)
needs: frontend
runs-on: macos-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with: { name: frontend-dist, path: dist/ }
- 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
- uses: dtolnay/rust-toolchain@stable
- name: Download frontend dist
uses: actions/download-artifact@v4
with:
targets: "aarch64-apple-darwin,x86_64-apple-darwin"
name: frontend-dist
path: dist/
- uses: Swatinem/rust-cache@v2
with: { workspaces: src-tauri }
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
with:
targets: aarch64-apple-darwin,x86_64-apple-darwin
- name: Rust cache
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
- run: pnpm install --frozen-lockfile
- name: Install JS dependencies
run: pnpm install --frozen-lockfile
- name: Download Suwayomi binaries
run: |
dl() {
download_suwayomi() {
local asset="$1" sha="$2" outdir="$3"
curl -fsSL \
"https://github.com/Suwayomi/Suwayomi-Server-preview/releases/download/v${SUWA_VERSION}/${asset}" \
"https://github.com/Suwayomi/Suwayomi-Server/releases/download/v2.1.1867/${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
}
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
download_suwayomi \
"Suwayomi-Server-v2.1.1867-macOS-arm64.tar.gz" \
"c80abdbba29f7895e9556c6c9481368557d5f930b5f69bcb30639ba498925f3c" \
"suwayomi-arm64"
download_suwayomi \
"Suwayomi-Server-v2.1.1867-macOS-x64.tar.gz" \
"c7590aeb645dd7135a05b9f3ea1fee384a4abeb465c0b3638d5b738d20dfe174" \
"suwayomi-x64"
- name: Stage Suwayomi sidecars
run: |
mkdir -p src-tauri/binaries
stage() {
local srcdir="$1" arch="$2"
JAR=$(find "$srcdir" -name "Suwayomi-Server.jar" | head -1)
JAVA=$(find "$srcdir" -path "*/jre/bin/java" | head -1)
[ -z "$JAR" ] && { echo "ERROR: jar not found in $srcdir"; find "$srcdir" -type f | head -30; exit 1; }
[ -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}"
find_launcher() {
local dir="$1"
# v2.1.1867 macOS tarball ships "Suwayomi Launcher.command" (space, .command)
find "$dir" -maxdepth 1 -type f -name "*.command" | head -1
}
stage suwayomi-arm64 aarch64-apple-darwin
stage suwayomi-x64 x86_64-apple-darwin
ARM_LAUNCHER=$(find_launcher suwayomi-arm64)
X64_LAUNCHER=$(find_launcher suwayomi-x64)
if [ -z "$ARM_LAUNCHER" ] || [ -z "$X64_LAUNCHER" ]; then
echo "ERROR: could not find launchers — tarball contents:"
ls -lR suwayomi-arm64 suwayomi-x64
exit 1
fi
echo "arm64 launcher: $ARM_LAUNCHER"
echo "x64 launcher: $X64_LAUNCHER"
cp "$ARM_LAUNCHER" src-tauri/binaries/suwayomi-server-aarch64-apple-darwin
cp "$X64_LAUNCHER" src-tauri/binaries/suwayomi-server-x86_64-apple-darwin
chmod +x src-tauri/binaries/suwayomi-server-aarch64-apple-darwin
chmod +x src-tauri/binaries/suwayomi-server-x86_64-apple-darwin
# tauri.conf.json expects exactly "binaries/suwayomi-bundle".
# We stage both arch bundles and swap the symlink before each build.
cp -r suwayomi-arm64 src-tauri/binaries/suwayomi-bundle-arm64
cp -r suwayomi-x64 src-tauri/binaries/suwayomi-bundle-x64
- name: Patch tauri.conf.json for CI
run: |
# dist/ is already built by the frontend job — suppress the rebuild.
# We patch in-place rather than using --config to avoid Tauri schema issues.
sed -i '' 's/"beforeBuildCommand": "pnpm build"/"beforeBuildCommand": ""/' src-tauri/tauri.conf.json
- name: Build Tauri app (aarch64)
- name: Swap bundle for aarch64
run: |
rm -rf src-tauri/binaries/suwayomi-bundle
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
cp -r src-tauri/binaries/suwayomi-bundle-arm64 src-tauri/binaries/suwayomi-bundle
- name: Build Tauri app (aarch64)
uses: tauri-apps/tauri-action@v0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Ad-hoc signing by default ("-"); override by setting APPLE_SIGNING_IDENTITY secret.
# Only set APPLE_CERTIFICATE/APPLE_ID/etc when you have a real Developer ID cert.
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY || '-' }}
with:
args: --target aarch64-apple-darwin
- name: Swap bundle for x86_64
run: |
rm -rf src-tauri/binaries/suwayomi-bundle
cp -r src-tauri/binaries/suwayomi-bundle-x64 src-tauri/binaries/suwayomi-bundle
- 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
pnpm tauri build --target x86_64-apple-darwin --config src-tauri/tauri.macos.conf.json
uses: tauri-apps/tauri-action@v0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Ad-hoc signing by default ("-"); override by setting APPLE_SIGNING_IDENTITY secret.
# Only set APPLE_CERTIFICATE/APPLE_ID/etc when you have a real Developer ID cert.
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY || '-' }}
with:
args: --target x86_64-apple-darwin
- name: Upload macOS artifacts to release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload arm64 .dmg
uses: actions/upload-artifact@v4
with:
name: moku-aarch64
path: src-tauri/target/aarch64-apple-darwin/release/bundle/dmg/*.dmg
retention-days: 7
- name: Upload x64 .dmg
uses: actions/upload-artifact@v4
with:
name: moku-x86_64
path: src-tauri/target/x86_64-apple-darwin/release/bundle/dmg/*.dmg
retention-days: 7
- name: Upload arm64 .app (for universal job)
uses: actions/upload-artifact@v4
with:
name: app-aarch64-apple-darwin
path: src-tauri/target/aarch64-apple-darwin/release/bundle/macos/
retention-days: 1
- name: Upload x64 .app (for universal job)
uses: actions/upload-artifact@v4
with:
name: app-x86_64-apple-darwin
path: src-tauri/target/x86_64-apple-darwin/release/bundle/macos/
retention-days: 1
universal:
name: Universal .dmg
needs: tauri
runs-on: macos-latest
steps:
- name: Download arm64 .app
uses: actions/download-artifact@v4
with:
name: app-aarch64-apple-darwin
path: apps/arm64/
- name: Download x64 .app
uses: actions/download-artifact@v4
with:
name: app-x86_64-apple-darwin
path: apps/x64/
- name: lipo into universal binary
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
ARM_APP=$(find apps/arm64 -name "*.app" -maxdepth 1 | head -1)
X64_APP=$(find apps/x64 -name "*.app" -maxdepth 1 | head -1)
APP_NAME=$(basename "$ARM_APP")
mkdir -p universal
cp -r "$ARM_APP" "universal/${APP_NAME}"
find "universal/${APP_NAME}" -type f | while read -r f; do
if file "$f" | grep -q "Mach-O"; then
X64_EQUIV="${X64_APP}${f#universal/${APP_NAME}}"
if [ -f "$X64_EQUIV" ]; then
lipo -create -output "$f" "$f" "$X64_EQUIV" 2>/dev/null || true
fi
fi
done
[ -z "$RELEASE_ID" ] && { echo "ERROR: release not found"; exit 1; }
upload() {
curl -s -X POST \
-H "Authorization: Bearer $GITHUB_TOKEN" \
-H "Content-Type: application/octet-stream" \
--data-binary @"$1" \
"https://uploads.github.com/repos/moku-project/Moku/releases/$RELEASE_ID/assets?name=$2"
}
- name: Package universal .dmg
run: |
APP_NAME=$(find universal -name "*.app" -maxdepth 1 | head -1 | xargs basename)
mkdir dmg-stage
cp -r "universal/${APP_NAME}" dmg-stage/
ln -s /Applications dmg-stage/Applications
hdiutil create \
-volname "Moku" \
-srcfolder dmg-stage \
-ov -format UDZO \
"moku-universal.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"
- name: Upload universal .dmg
uses: actions/upload-artifact@v4
with:
name: moku-universal
path: moku-universal.dmg
retention-days: 7
+84 -65
View File
@@ -4,11 +4,12 @@ on:
workflow_dispatch:
inputs:
version:
description: "Version to build (e.g. 0.9.0)"
description: "Version to build (e.g. 0.4.0)"
required: true
permissions:
contents: write
branch:
description: "Branch to build (e.g. svelte-rewrite)"
required: false
default: "main"
jobs:
frontend:
@@ -16,73 +17,108 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.inputs.branch }}
- uses: pnpm/action-setup@v4
with: { version: latest }
with:
version: latest
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm build:static
- uses: actions/upload-artifact@v4
with: { name: frontend-dist-windows, path: dist/, retention-days: 1 }
- 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
tauri:
name: Tauri (Windows x64)
needs: frontend
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.inputs.branch }}
- uses: actions/download-artifact@v4
with: { name: frontend-dist-windows, path: dist/ }
- name: Download frontend dist
uses: actions/download-artifact@v4
with:
name: frontend-dist-windows
path: dist/
- 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: Install Rust
uses: dtolnay/rust-toolchain@stable
with:
targets: x86_64-pc-windows-msvc
- uses: dtolnay/rust-toolchain@stable
with: { targets: x86_64-pc-windows-msvc }
- uses: Swatinem/rust-cache@v2
with: { workspaces: src-tauri }
- name: Rust cache
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
- run: pnpm install --frozen-lockfile
- name: Install JS dependencies
run: pnpm install --frozen-lockfile
- name: Download Suwayomi (Windows x64)
shell: bash
run: |
curl -fsSL \
"https://github.com/Suwayomi/Suwayomi-Server-preview/releases/download/v${SUWA_VERSION}/Suwayomi-Server-v${SUWA_VERSION}-windows-x64.zip" \
"https://github.com/Suwayomi/Suwayomi-Server/releases/download/v2.1.1867/Suwayomi-Server-v2.1.1867-windows-x64.zip" \
-o suwayomi-windows.zip
echo "${SUWA_HASH} suwayomi-windows.zip" | sha256sum -c -
echo "ab6687d278e0dd0984f67abbc853511a7e764f84b126a35d09bfd9b0307321ff suwayomi-windows.zip" | sha256sum -c -
unzip -q suwayomi-windows.zip -d suwayomi-raw
TOP_DIRS=$(find suwayomi-raw -mindepth 1 -maxdepth 1 -type d)
TOP_FILES=$(find suwayomi-raw -mindepth 1 -maxdepth 1 -type f)
TOP_DIR_COUNT=$(echo "$TOP_DIRS" | grep -c . || true)
mkdir -p suwayomi-extracted
if [ "$TOP_DIR_COUNT" -eq 1 ] && [ -z "$TOP_FILES" ]; then
mv "$TOP_DIRS"/* suwayomi-extracted/
else
mv suwayomi-raw/* suwayomi-extracted/
fi
- 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
cp -r "$(find suwayomi-raw -mindepth 1 -maxdepth 1 -type d | head -1)"/. suwayomi-extracted/
else
cp -r suwayomi-raw/. suwayomi-extracted/
fi
mkdir -p src-tauri/binaries
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; }
JAVAW=$(find suwayomi-extracted -path "*/jre/bin/javaw.exe" | head -1)
if [ -z "$JAVAW" ]; then
echo "ERROR: could not find jre/bin/javaw.exe — bundle contents:"
find suwayomi-extracted -type f | head -40
exit 1
fi
echo "Found javaw: $JAVAW"
# Copy full bundle so jar + jre tree are available at runtime.
# lib.rs looks for suwayomi-bundle/jre/bin/javaw.exe in the resource dir.
cp -r suwayomi-extracted src-tauri/binaries/suwayomi-bundle
- name: Patch tauri.conf.json for CI
@@ -90,35 +126,18 @@ jobs:
run: |
sed -i 's/"beforeBuildCommand": "pnpm build"/"beforeBuildCommand": ""/' src-tauri/tauri.conf.json
- name: Delete existing draft release
shell: bash
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
RELEASE_ID=$(curl -s -H "Authorization: Bearer $GITHUB_TOKEN" \
"https://api.github.com/repos/moku-project/Moku/releases" \
| jq -r '.[] | select(.tag_name == "v${{ github.event.inputs.version }}" and .draft == true) | .id')
if [ -n "$RELEASE_ID" ]; then
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 }}"
fi
- name: Build Tauri app + create draft release
- name: Build Tauri app (Windows x64)
uses: tauri-apps/tauri-action@v0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tagName: v${{ github.event.inputs.version }}
releaseName: Moku v${{ github.event.inputs.version }}
releaseBody: |
Moku v${{ github.event.inputs.version }}
args: >-
--target x86_64-pc-windows-msvc
--config '{"bundle":{"resources":["binaries/suwayomi-bundle/**"]}}'
**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
- name: Upload Windows installer
uses: actions/upload-artifact@v4
with:
name: moku-windows-x64
path: src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.exe
retention-days: 7
+8 -22
View File
@@ -1,33 +1,26 @@
# --- Build Artifacts ---
node_modules/
suwayomi-raw/
suwayomi-windows.zip
dist/
dist-tauri/
target/
bin/
out/
# --- Nix ---
.direnv/
result
result-*
# --- Logs ---
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.env
.env.*
!.env.example
!.env.test
.env.local
.env.*.local
.output
.vercel
.netlify
.wrangler
/.svelte-kit
/build
# --- IDEs & OS ---
.vscode/
.idea/
.DS_Store
@@ -37,19 +30,12 @@ yarn-debug.log*
*.sln
*.swp
# --- Tauri specific ---
src-tauri/target/
src-tauri/binaries/
src-tauri/gen/
.DS_Store
Thumbs.db
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
# --- Flatpak build artifacts ---
build-dir/
repo/
dist/
packaging/frontend-dist.tar.gz
*.flatpak
./flatpak-builder
.flatpak-builder/
-1
View File
@@ -1 +0,0 @@
engine-strict=true
+1 -1
View File
@@ -186,7 +186,7 @@
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [2026] [@Youwes09]
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
+53 -57
View File
@@ -1,10 +1,10 @@
pkgname=moku
pkgver=0.9.4
pkgver=0.3.0
pkgrel=1
pkgdesc="Native Linux manga reader frontend for Suwayomi-Server"
arch=('x86_64')
url="https://github.com/moku-project/Moku"
license=('Apache-2.0')
url="https://github.com/Youwes09/Moku"
license=('Apache 2.0')
depends=(
'webkit2gtk-4.1'
'gtk3'
@@ -13,46 +13,34 @@ depends=(
)
makedepends=(
'rust'
'cargo'
'nodejs'
'pnpm'
)
optdepends=(
'discord: Discord rich presence'
)
options=('!strip')
source=(
"$pkgname-$pkgver.tar.gz::https://github.com/moku-project/Moku/archive/refs/tags/v$pkgver.tar.gz"
"Suwayomi-Server-v2.1.2087.jar::https://github.com/Suwayomi/Suwayomi-Server-preview/releases/download/v2.1.2087/Suwayomi-Server-v2.1.2087.jar"
)
noextract=("Suwayomi-Server-v2.1.2087.jar")
sha256sums=(
'fc1c8268b812e70e56460c8930ca8ae83bcd30eea5903ddfef4e30a3a9a5c1cc'
'f589a422674252394c13b289a9c8be691905bf583efb7f4d5f1501ae5e91e6b3'
)
b2sums=(
'SKIP'
'SKIP'
"$pkgname-$pkgver.tar.gz::https://github.com/Youwes09/Moku/archive/refs/tags/v$pkgver.tar.gz"
"suwayomi-server.jar::https://github.com/Suwayomi/Suwayomi-Server/releases/download/v2.1.1867/suwayomi-server-v2.1.1867.jar"
"jdk.tar.gz::https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21.0.3%2B9/OpenJDK21U-jre_x64_linux_hotspot_21.0.3_9.tar.gz"
)
sha256sums=('2475d4bb4c7e8527384f7fcf9b0ace1c8a6354416f3af31398b844e35953fb73'
'51e307c2581e4e1a002991ab3e3a77503c8b074c42695987a984a7382d0ac5af'
'f1af100c4afca2035f446967323230150cfe5872b5a664d98c86963e5c066e0d')
prepare() {
cd "Moku-$pkgver"
pnpm install --frozen-lockfile
sed -i 's/^lto\s*=\s*true/lto = "thin"/' src-tauri/Cargo.toml
mkdir -p src-tauri/.cargo
cat > src-tauri/.cargo/config.toml << 'EOF'
[target.x86_64-unknown-linux-gnu]
linker = "x86_64-linux-gnu-gcc"
EOF
}
build() {
cd "Moku-$pkgver"
# Build frontend
pnpm build
local fixed_cflags="${CFLAGS/-march=native/-march=x86-64}"
fixed_cflags="${fixed_cflags/-flto=auto/}"
local fixed_cxxflags="${CXXFLAGS/-march=native/-march=x86-64}"
fixed_cxxflags="${fixed_cxxflags/-flto=auto/}"
CFLAGS="$fixed_cflags" \
CXXFLAGS="$fixed_cxxflags" \
# Repack dist for Tauri
tar -czf packaging/frontend-dist.tar.gz -C dist .
# Build Tauri binary
TAURI_SKIP_DEVSERVER_CHECK=true cargo build \
--release \
--manifest-path src-tauri/Cargo.toml
@@ -61,14 +49,21 @@ build() {
package() {
cd "Moku-$pkgver"
# Moku binary
install -Dm755 src-tauri/target/release/moku \
"$pkgdir/usr/bin/moku"
install -Dm644 "$srcdir/Suwayomi-Server-v2.1.2087.jar" \
# Bundled JRE
install -dm755 "$pkgdir/usr/lib/moku/jre"
tar -xf "$srcdir/jdk.tar.gz" -C "$pkgdir/usr/lib/moku/jre" --strip-components=1
# Suwayomi server jar
install -Dm644 "$srcdir/suwayomi-server.jar" \
"$pkgdir/usr/lib/moku/tachidesk/Suwayomi-Server.jar"
# tachidesk-server wrapper script
install -dm755 "$pkgdir/usr/lib/moku/tachidesk/default-conf"
cat > "$pkgdir/usr/lib/moku/tachidesk/default-conf/server.conf" << 'CONF'
cat > "$pkgdir/usr/lib/moku/tachidesk/default-conf/server.conf" << 'EOF'
server.ip = "127.0.0.1"
server.port = 4567
server.webUIEnabled = false
@@ -79,22 +74,22 @@ server.autoDownloadNewChapters = false
server.globalUpdateInterval = 12
server.maxSourcesInParallel = 6
server.extensionRepos = []
CONF
EOF
install -Dm755 /dev/stdin "$pkgdir/usr/bin/moku-suwayomi" << 'LAUNCHER'
install -Dm755 /dev/stdin "$pkgdir/usr/bin/tachidesk-server" << 'EOF'
#!/bin/sh
DATA_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/Tachidesk"
DATA_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/moku/tachidesk"
mkdir -p "$DATA_DIR"
if [ ! -f "$DATA_DIR/server.conf" ]; then
cp /usr/lib/moku/tachidesk/default-conf/server.conf "$DATA_DIR/server.conf"
cp /usr/lib/moku/tachidesk/default-conf/server.conf "$DATA_DIR/server.conf"
fi
sed -i \
-e 's|server\.webUIEnabled.*|server.webUIEnabled = false|' \
-e 's|server\.initialOpenInBrowserEnabled.*|server.initialOpenInBrowserEnabled = false|' \
-e 's|server\.systemTrayEnabled.*|server.systemTrayEnabled = false|' \
"$DATA_DIR/server.conf"
-e 's|server\.webUIEnabled.*|server.webUIEnabled = false|' \
-e 's|server\.initialOpenInBrowserEnabled.*|server.initialOpenInBrowserEnabled = false|' \
-e 's|server\.systemTrayEnabled.*|server.systemTrayEnabled = false|' \
"$DATA_DIR/server.conf"
grep -q 'server\.webUIEnabled' "$DATA_DIR/server.conf" || echo 'server.webUIEnabled = false' >> "$DATA_DIR/server.conf"
grep -q 'server\.initialOpenInBrowserEnabled' "$DATA_DIR/server.conf" || echo 'server.initialOpenInBrowserEnabled = false' >> "$DATA_DIR/server.conf"
@@ -105,25 +100,26 @@ unset WAYLAND_DISPLAY
export _JAVA_OPTIONS="-Djava.awt.headless=true"
export JAVA_TOOL_OPTIONS="-Djava.awt.headless=true"
exec java \
-Djava.awt.headless=true \
-Dapple.awt.UIElement=true \
-Dsun.java2d.noddraw=true \
-Dsun.awt.disablegui=true \
-Dsuwayomi.tachidesk.config.server.rootDir="$DATA_DIR" \
-jar /usr/lib/moku/tachidesk/Suwayomi-Server.jar
LAUNCHER
exec /usr/lib/moku/jre/bin/java \
-Djava.awt.headless=true \
-Dapple.awt.UIElement=true \
-Dsun.java2d.noddraw=true \
-Dsun.awt.disablegui=true \
-Dsuwayomi.tachidesk.config.server.rootDir="$DATA_DIR" \
-jar /usr/lib/moku/tachidesk/Suwayomi-Server.jar
EOF
install -Dm644 packaging/io.github.moku_project.Moku.desktop \
"$pkgdir/usr/share/applications/io.github.moku_project.Moku.desktop"
# Desktop entry and icons
install -Dm644 packaging/dev.moku.app.desktop \
"$pkgdir/usr/share/applications/dev.moku.app.desktop"
install -Dm644 src-tauri/icons/32x32.png \
"$pkgdir/usr/share/icons/hicolor/32x32/apps/io.github.moku_project.Moku.png"
"$pkgdir/usr/share/icons/hicolor/32x32/apps/dev.moku.app.png"
install -Dm644 src-tauri/icons/128x128.png \
"$pkgdir/usr/share/icons/hicolor/128x128/apps/io.github.moku_project.Moku.png"
"$pkgdir/usr/share/icons/hicolor/128x128/apps/dev.moku.app.png"
install -Dm644 src-tauri/icons/128x128@2x.png \
"$pkgdir/usr/share/icons/hicolor/256x256/apps/io.github.moku_project.Moku.png"
install -Dm644 packaging/io.github.moku_project.Moku.metainfo.xml \
"$pkgdir/usr/share/metainfo/io.github.moku_project.Moku.metainfo.xml"
install -Dm644 LICENSE \
"$pkgdir/usr/share/licenses/$pkgname/LICENSE"
"$pkgdir/usr/share/icons/hicolor/256x256/apps/dev.moku.app.png"
install -Dm644 packaging/dev.moku.app.metainfo.xml \
"$pkgdir/usr/share/metainfo/dev.moku.app.metainfo.xml"
install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
}
+93 -123
View File
@@ -1,164 +1,134 @@
<div align="center">
<img src="docs/banner.svg" width="100%" alt="Moku" />
</div>
<img src="src/assets/rounded-logo.png" width="96" />
<h1>Moku</h1>
<p>A fast, minimal manga reader for <a href="https://github.com/Suwayomi/Suwayomi-Server">Suwayomi-Server</a>.<br/>Built with Tauri v2 and React.</p>
<div align="center">
[![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)
</div>
<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>
<table>
<tr>
<td><img src=".github/screenshots/Library-Page.png" width="100%" /></td>
<td><img src=".github/screenshots/Libary-Browse.png" width="100%" /></td>
<td><img src=".github/screenshots/Series-Detail.png" width="100%" /></td>
</tr>
<tr>
<td><img src=".github/screenshots/Search-Bar.png" width="100%" /></td>
<td><img src=".github/screenshots/Download-Manager.png" width="100%" /></td>
<td><img src=".github/screenshots/Settings-1.png" width="100%" /></td>
</tr>
</table>
</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
### Reader
- **Single**, **double-page**, and **longstrip** reading modes
- **Infinite longstrip** — when Auto mode is enabled, the next chapter's pages are appended directly into the scroll without any re-render or gap; the entire series flows as one seamless ribbon
- Fit modes: fit width, fit height, fit screen, and 1:1 original
- Per-series zoom control via Ctrl+scroll or a slider popover
- RTL / LTR reading direction toggle
- Configurable page gaps
- Full keyboard navigation with rebindable keybinds
- UI auto-hides after 3 seconds of inactivity; reappears on cursor movement near edges
- Chapter-relative page counter that updates live as you scroll through the infinite strip
- Auto-mark chapters as read when the last page is reached
---
### Library
- Grid view of your entire manga collection with lazy-loaded cover art
- Filter tabs: **Saved**, **Downloaded**, and **All**
- Genre tag filter chips — multi-select to narrow by any combination of tags
- In-line search
- Context menu: open, add/remove from library
## Installation
### Series Detail
- Cover, author, artist, status badge, genres, and synopsis
- Read progress bar with percentage
- Continue / Start / Re-read button that picks up exactly where you left off (including mid-chapter page)
- Chapter list with scanlator, upload date, and in-progress page indicator
- **Grid view** — displays all chapters as numbered tiles; read/unread/in-progress states are visually distinct at a glance; switches between list and grid with a single click
- Sort by newest or oldest first
- Jump-to-chapter input
- Bulk download menu: from current chapter, unread only, or all
- Per-chapter context menu: mark read/unread, mark all above as read, download, delete, bulk download from here
- Collapsible source details panel with source ID, language, and source migration
<div align="center">
### Search
- Cross-source search running up to 3 concurrent requests
- Language filter bar (preferred language default, per-language, or all)
- Results grouped by source with skeleton loading states
![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)
### Sources & Extensions
- Browse and search installed sources, grouped by extension with per-language expansion
- Extension manager: install, update, remove, and install from external APK URL
- Repo refresh with update count badge
</div>
### Downloads
- Download queue with live progress
### Windows
**winget:**
```powershell
winget install Moku.Moku
```
> Thanks to [@frozenKelp](https://github.com/frozenKelp) for setting up and maintaining the winget package through v0.9.0.
Or download the `.exe` installer from the [releases page](https://github.com/moku-project/Moku/releases/latest). Suwayomi-Server and a JRE are bundled.
### Linux (Flatpak, recommended)
Suwayomi-Server and a bundled JRE are included — no separate install needed.
```bash
flatpak install io.github.moku_app.Moku
```
Or download the latest `moku.flatpak` from the [releases page](https://github.com/moku-project/Moku/releases/latest) and install manually:
```bash
flatpak install moku.flatpak
```
### Nix
```bash
nix run github:moku-project/Moku
```
Add to your flake:
```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
> ```
### History
- Reading history grouped by day with relative timestamps
- Per-entry thumbnail, chapter name, and last-read page
- Full-text search across titles and chapter names
- One-click clear
---
## 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`.
[Suwayomi-Server](https://github.com/Suwayomi/Suwayomi-Server) must be running. By default Moku expects it at `http://127.0.0.1:4567`.
You can point Moku at any Suwayomi instance — local or remote — via **Settings → General → Server URL**.
> Moku will attempt to launch the server automatically on startup if the `suwayomi-server` binary is on your `PATH`.
---
## Installation
**Nix (recommended)**
```bash
nix run github:Youwes09/moku
```
Add to your flake:
```nix
inputs.moku.url = "github:Youwes09/moku";
```
**From source**
```bash
git clone https://github.com/Youwes09/moku
cd moku
nix build
./result/bin/moku
```
---
## 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
```
> `tauri:dev` uses `src-tauri/tauri.dev.conf.json` to point at the Vite dev server, keeping the release build config clean for `nix build`.
---
## 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)
| [React](https://react.dev) + [TypeScript](https://www.typescriptlang.org) | UI |
| [Vite](https://vitejs.dev) | Frontend bundler |
| [Zustand](https://zustand-demo.pmnd.rs) | State management |
| [Phosphor Icons](https://phosphoricons.com) | Icon set |
| [Crane](https://github.com/ipetkov/crane) | Nix Rust builds |
---
+102 -2
View File
@@ -1,4 +1,104 @@
Revival of the TODO List!!!!!
Todo:
3. Explore Manga Upscaler & Other Image Processing
4. Font Weird on Flatpak, Investigate and Fix
5. Investigate "egl:failed to create dri2 screen" & more GPU Issues
- Reminder to Completely Test Settings
Bugs:
- Add Back after Search & Clear on Search
- Fix Tag-Based Search to Allow for Finding New Manga Rather than PURE-DB
- Add Download Location Change, Moku-Migration, Flatpak & Normal Installation Directory Checks
- Fix Infinite Scroll Hitting Button Non-reactive to Chapter State, hence Resulting in Error. (Doesn't work on single or double digit, but works on select chapters?) (doesnt work 1 - 2 qnd 51 - 52?) Cause unknow
- - Reader appears to be adding integers? Marks chapters incorrectly, need to stablize and patch. User is able to
skip chapters, etc
- Mark as Read no longer working on select chapters, choose more robust methodology.
- Reset to top when user clicks next chapter in reader.
- Fix Downloaded in Library (Tags Broken) & All
- Using Delete All Crashes App (But Works)
- Fix Folder Display in Library
- Add Version Tags (To Find Version)
- Sidebar Icon Highlighted
- Introduce Deduplication into Library & Search
Features:
- Add PDF Textbook Support
- Consider what to do empty space in sidebar (Maybe Daily Reading Time Brainstorm)
- Migration Features
- Multi-Page Long Screenshot
- Add Consumet Api (Anime & Light Novel Support)
Big Revisions:
0. Expand into fully-fledged reader, with modular manga support
1. Anime & Novel Support
2. Tracker Support
3. Cloudflare Bypass Enable Support
4. macOS Support (feasible)
Testing:
- Fix the Infinite Append/Scroll on Downloaded Manga, (Unable to Transfer Between Downloaded and Internet Based Manga Providing, hence resulting in feature breaking till toggled and retoggled)
- Fix the Mark as Read (Glitched)
Completed:
8. Fix Polling on Download Manager (Instantanous Response)
19. Debounce Time on Reader to improve lag (Toggle Setting)
10. Download Manager Pause and Cancel All Not Working + Download Lag on Series Detail Side
17. Change Library Text change to "No manga saved to library, browse sources to add some."
9. Fix CSS issue on Sidebar (Weird Green Overlay on Button)
7. Fix Scaling (100 = 125% and so forth)
2. Expand Criteria on Series Detail (Tags, Summaries) Make more Compact
14. Right-Click should have (Remove Library & Delete All) + Make New Folder (Context Menu)
15. Explorer Right-Click New Context Menu with Add to Library, Add to Folder, etc
11. Reader & UI needs download and other Notifications
- Fix Mark all Above as Read to Mark all Below as Read (Should be visual based) also add Unread Option, Sidebar Category for mark all above as read and mark all below as unread. (Series Detail)
- Add Refresh Details on Series Details.
- Patch GenreDrill & Integrate into Explore Folder
18. Disable NSFW Extensions option in settings
- Filtering by Genre (Accessed by Clicking tags on Manga)
- Remove Series Detail Mark Read & Unread
20. Expand History (Total Time Read, etc)
12. Delete all Downloads should also cancel all download queues
13. Cancel Download along with Queue & Download Timeout Feature
- Fix Initial Async Loading (Shows Suwayomi Not Loaded Till Refresh)
- Allow to move back from a window and return to previous state, not completely reset (Whole UI Issue)
- Opening Explore first time loads nothing, going to different page then going back loads everything relatively instantly? (Explore Page Bug)
- Extensions Page no Longer Loading efficiently
- Map out MangaPreview tags to GenreDrill
- GenreDrill & GenreFilter pages do not populate completely.
16. Contrast Adjustment Option in Settings for Users (UI FOCUSED)
- Fix Zoom in Reader changing Pages (Zoom Out Threshold in Reader causes Break)
- Clean up Migrate Model to be more initutive
6. Fix laggy renderer on single page (same cache as longscroll) & set default longstrip
5. Lock reader on valid chapters to avoid bugs, etc.
1. Cache issue when opening manga series detail first time (Loading screen addition) & Async Load
- Fix Grid View for Large Amounts of Chapter (Check Initial D) (Series Detail)
- Properly Kill Tachidesk-Server
- Fix scaling on splash screen
- Idle Screen Test Uses Animations, but Reality still uses old system with Mouse Movement = Dismiss + No Fade Out
- Idle Screen is Super laggy, needs minimum of 60 fps hence needs more optimization
- Add Scroll Wheel Compatibility to Explore, etc (Explore Page Bug)
Important Commands:
cd ~/Projects/Manga/Moku
pnpm build
tar -czf packaging/frontend-dist.tar.gz -C dist .
sha256sum packaging/frontend-dist.tar.gz | awk '{print $1}'
1. nix-shell -p "python311.withPackages(ps: [ ps.aiohttp ps.tomlkit ])" --run "python3 packaging/flatpak-cargo-generator.py src-tauri/Cargo.lock -o packaging/cargo-sources.json"
2. nix shell nixpkgs#appstream nixpkgs#flatpak-builder --command flatpak-builder --repo=repo --force-clean build-dir dev.moku.app.yml
3. flatpak build-bundle repo moku.flatpak dev.moku.app
+45
View File
@@ -0,0 +1,45 @@
#!/usr/bin/env bash
# build-scripts/pkgbuild-bump.sh
# ─────────────────────────────────────────────────────────────────────────────
# Run this AFTER the git tag has been pushed to GitHub.
#
# Usage:
# ./build-scripts/pkgbuild-bump.sh 0.3.0
set -euo pipefail
RED='\033[0;31m'; GREEN='\033[0;32m'; CYAN='\033[0;36m'; BOLD='\033[1m'; RESET='\033[0m'
info() { echo -e "${CYAN}${RESET} $*"; }
success() { echo -e "${GREEN}${RESET} $*"; }
die() { echo -e "${RED}${RESET} $*" >&2; exit 1; }
section() { echo -e "\n${BOLD}── $* ──${RESET}"; }
[[ $# -lt 1 ]] && die "Usage: $0 <version>"
VERSION="$1"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
PKGBUILD="${REPO_ROOT}/PKGBUILD"
command -v curl &>/dev/null || die "curl not found"
[[ -f "$PKGBUILD" ]] || die "PKGBUILD not found: $PKGBUILD"
section "Patching PKGBUILD → ${VERSION}"
TARBALL_URL="https://github.com/Youwes09/Moku/archive/refs/tags/v${VERSION}.tar.gz"
info "Fetching source tarball to compute sha256…"
TARBALL_SHA=$(curl -fsSL "$TARBALL_URL" | sha256sum | awk '{print $1}')
sed -i "s/^pkgver=.*/pkgver=${VERSION}/" "$PKGBUILD"
sed -i "s/^pkgrel=.*/pkgrel=1/" "$PKGBUILD"
# Replace only the first sha256 entry (source tarball) inside sha256sums=('...')
# The suwayomi jar and jdk hashes are pinned and stay untouched.
# Strategy: match the opening sha256sums=('' then swap just that first hash.
sed -i "s/\(sha256sums=('\)[0-9a-f]\{64\}/\1${TARBALL_SHA}/" "$PKGBUILD"
# Verify the replacement landed
if ! grep -q "$TARBALL_SHA" "$PKGBUILD"; then
die "sha256 replacement failed — check PKGBUILD sha256sums format"
fi
success "PKGBUILD patched (pkgver=${VERSION}, sha256=${TARBALL_SHA})"
info "PKGBUILD → ${PKGBUILD}"
+113
View File
@@ -0,0 +1,113 @@
#!/usr/bin/env bash
# build-scripts/release.sh
# ─────────────────────────────────────────────────────────────────────────────
# Usage:
# ./build-scripts/release.sh 0.2.0
#
# Requires: nix, flatpak-builder, appstream
set -euo pipefail
# ── Colour helpers ─────────────────────────────────────────────────────────────
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'
CYAN='\033[0;36m'; BOLD='\033[1m'; RESET='\033[0m'
info() { echo -e "${CYAN}${RESET} $*"; }
success() { echo -e "${GREEN}${RESET} $*"; }
warn() { echo -e "${YELLOW}${RESET} $*"; }
die() { echo -e "${RED}${RESET} $*" >&2; exit 1; }
section() { echo -e "\n${BOLD}── $* ──${RESET}"; }
# ── Args ───────────────────────────────────────────────────────────────────────
[[ $# -lt 1 ]] && die "Usage: $0 <version>"
VERSION="$1"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
FLATPAK_MANIFEST="${REPO_ROOT}/dev.moku.app.yml"
PKGBUILD="${REPO_ROOT}/PKGBUILD"
# ── Sanity checks ──────────────────────────────────────────────────────────────
section "Pre-flight"
command -v nix &>/dev/null || die "nix not found"
command -v curl &>/dev/null || die "curl not found"
[[ -f "$FLATPAK_MANIFEST" ]] || die "Flatpak manifest not found: $FLATPAK_MANIFEST"
[[ -f "$PKGBUILD" ]] || die "PKGBUILD not found: $PKGBUILD"
success "OK"
# ── Bump versions ──────────────────────────────────────────────────────────────
section "Bumping version → ${VERSION}"
sed -i "s/\"version\": \"[^\"]*\"/\"version\": \"${VERSION}\"/" \
"${REPO_ROOT}/src-tauri/tauri.conf.json"
success "tauri.conf.json → ${VERSION}"
sed -i "0,/^version = \"[^\"]*\"/s//version = \"${VERSION}\"/" \
"${REPO_ROOT}/src-tauri/Cargo.toml"
success "Cargo.toml → ${VERSION}"
# flake.nix has two `version = "x.y.z";` strings inside the frontend
# derivation and fetchPnpmDeps — both need to match.
sed -i "s/version = \"[^\"]*\";/version = \"${VERSION}\";/g" \
"${REPO_ROOT}/flake.nix"
success "flake.nix → ${VERSION}"
# ── Build frontend ─────────────────────────────────────────────────────────────
section "Building frontend"
cd "$REPO_ROOT"
nix develop --command pnpm install --frozen-lockfile
nix develop --command pnpm build
success "Frontend built → dist/"
# ── Flatpak ────────────────────────────────────────────────────────────────────
section "Regenerating cargo-sources.json"
cd "$REPO_ROOT"
nix-shell \
-p "python311.withPackages(ps: [ ps.aiohttp ps.tomlkit ])" \
--run "python3 packaging/flatpak-cargo-generator.py src-tauri/Cargo.lock -o packaging/cargo-sources.json"
success "cargo-sources.json updated"
section "Rebuilding frontend-dist.tar.gz"
tar -czf packaging/frontend-dist.tar.gz -C dist .
FRONTEND_SHA=$(sha256sum packaging/frontend-dist.tar.gz | awk '{print $1}')
success "frontend-dist.tar.gz rebuilt sha256: ${FRONTEND_SHA}"
section "Patching frontend-dist sha256 in dev.moku.app.yml"
PATCH_SCRIPT=$(mktemp /tmp/patch-sha256-XXXXXX.py)
cat > "$PATCH_SCRIPT" << PYEOF
import re, sys
path = "${FLATPAK_MANIFEST}"
new_sha = "${FRONTEND_SHA}"
text = open(path).read()
pattern = r'(path:\s*packaging/frontend-dist\.tar\.gz\s*\n\s*sha256:\s*)[0-9a-f]+'
replacement = r'\g<1>' + new_sha
updated, n = re.subn(pattern, replacement, text)
if n == 0:
sys.exit("Could not find frontend-dist sha256 in dev.moku.app.yml")
open(path, 'w').write(updated)
PYEOF
nix-shell -p python3 --run "python3 '$PATCH_SCRIPT'"
rm -f "$PATCH_SCRIPT"
success "dev.moku.app.yml sha256 updated"
section "Building Flatpak bundle"
rm -rf "${REPO_ROOT}/build-dir" "${REPO_ROOT}/repo"
nix shell nixpkgs#appstream nixpkgs#flatpak-builder --command \
flatpak-builder \
--repo="${REPO_ROOT}/repo" \
--force-clean \
"${REPO_ROOT}/build-dir" \
"$FLATPAK_MANIFEST"
flatpak build-bundle \
"${REPO_ROOT}/repo" \
"${REPO_ROOT}/moku.flatpak" \
dev.moku.app
rm -rf "${REPO_ROOT}/build-dir" "${REPO_ROOT}/repo"
success "moku.flatpak created"
# ── Done ───────────────────────────────────────────────────────────────────────
echo ""
success "v${VERSION} ready"
info "Flatpak bundle → ${REPO_ROOT}/moku.flatpak"
echo ""
warn "PKGBUILD not patched yet — tag must exist on GitHub first."
info "After pushing the tag, run:"
echo -e " ${CYAN}./build-scripts/pkgbuild-bump.sh ${VERSION}${RESET}"
-99
View File
@@ -1,99 +0,0 @@
#Requires -Version 7
param(
[switch]$SkipFrontend,
[switch]$SkipSuwayomi
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
function Step($msg) { Write-Host "`n==> $msg" -ForegroundColor Cyan }
function Need($cmd) {
if (-not (Get-Command $cmd -ErrorAction SilentlyContinue)) {
Write-Error "Required tool not found: $cmd"
}
}
$Root = Split-Path -Parent $MyInvocation.MyCommand.Path
Set-Location $Root
Step "Reading nix/versions.nix"
$nix = Get-Content "$Root\nix\versions.nix" -Raw
$MOKU_VERSION = if ($nix -match 'moku\s*=\s*"([^"]+)"') { $Matches[1] } else { Write-Error "moku version not found" }
$SUWA_VERSION = if ($nix -match 'version\s*=\s*"([^"]+)"') { $Matches[1] } else { Write-Error "suwayomi version not found" }
$SUWA_HASH = if ($nix -match 'windowsHash\s*=\s*"([^"]+)"') { $Matches[1] } else { Write-Error "windowsHash not found" }
Write-Host " moku=$MOKU_VERSION suwayomi=$SUWA_VERSION"
Need "pnpm"; Need "cargo"; Need "node"
if (-not $SkipFrontend) {
Step "pnpm install"
pnpm install --frozen-lockfile
if ($LASTEXITCODE -ne 0) { Write-Error "pnpm install failed" }
Step "Frontend build"
$env:MOKU_TARGET = "static"
pnpm build:static
if ($LASTEXITCODE -ne 0) { Write-Error "Frontend build failed" }
}
$BundleDir = "$Root\src-tauri\binaries\suwayomi-bundle"
$ZipPath = "$env:TEMP\suwayomi-windows-$SUWA_VERSION.zip"
$ExtractDir = "$env:TEMP\suwayomi-extracted-$SUWA_VERSION"
if (-not $SkipSuwayomi) {
Step "Downloading Suwayomi v$SUWA_VERSION"
$ZipUrl = "https://github.com/Suwayomi/Suwayomi-Server-preview/releases/download/v${SUWA_VERSION}/Suwayomi-Server-v${SUWA_VERSION}-windows-x64.zip"
if (-not (Test-Path $ZipPath)) {
Invoke-WebRequest -Uri $ZipUrl -OutFile $ZipPath -UseBasicParsing
}
$actual = (Get-FileHash $ZipPath -Algorithm SHA256).Hash.ToLower()
if ($actual -ne $SUWA_HASH.ToLower()) {
Write-Error "Hash mismatch`n expected: $SUWA_HASH`n got: $actual"
}
Step "Staging bundle"
if (Test-Path $ExtractDir) { Remove-Item $ExtractDir -Recurse -Force }
Expand-Archive -Path $ZipPath -DestinationPath $ExtractDir
$topDirs = @(Get-ChildItem $ExtractDir -Directory)
$topFiles = @(Get-ChildItem $ExtractDir -File)
$SrcDir = if ($topDirs.Count -eq 1 -and $topFiles.Count -eq 0) { $topDirs[0].FullName } else { $ExtractDir }
if (Test-Path $BundleDir) { Remove-Item $BundleDir -Recurse -Force }
Copy-Item $SrcDir $BundleDir -Recurse
$java = Get-ChildItem $BundleDir -Recurse -Filter "java.exe" | Where-Object { $_.FullName -match "jre.bin" } | Select-Object -First 1
$jar = Get-ChildItem $BundleDir -Recurse -Filter "Suwayomi-Server.jar" | Select-Object -First 1
if (-not $java) { Write-Error "java.exe not found in staged bundle" }
if (-not $jar) { Write-Error "Suwayomi-Server.jar not found in staged bundle" }
Write-Host " java: $($java.FullName)"
Write-Host " jar: $($jar.FullName)"
} elseif (-not (Test-Path $BundleDir)) {
Write-Error "Bundle dir missing at $BundleDir — run without -SkipSuwayomi first"
}
Step "Patching tauri.conf.json"
$tauriConf = "$Root\src-tauri\tauri.conf.json"
$original = Get-Content $tauriConf -Raw
Set-Content $tauriConf ($original -replace '"beforeBuildCommand":\s*"pnpm build"', '"beforeBuildCommand": ""') -NoNewline
Step "Tauri build"
$env:TAURI_SKIP_DEVSERVER_CHECK = "true"
pnpm tauri build --target x86_64-pc-windows-msvc --config src-tauri/tauri.windows.conf.json
$buildExit = $LASTEXITCODE
Set-Content $tauriConf $original -NoNewline
if ($buildExit -ne 0) { Write-Error "Tauri build failed (exit $buildExit)" }
Step "Artifacts"
$out = "$Root\src-tauri\target\x86_64-pc-windows-msvc\release\bundle"
$msi = Get-ChildItem "$out\msi" -Filter "*.msi" -ErrorAction SilentlyContinue | Select-Object -First 1
$exe = Get-ChildItem "$out\nsis" -Filter "*.exe" -ErrorAction SilentlyContinue | Select-Object -First 1
Write-Host "`nDone — Moku $MOKU_VERSION" -ForegroundColor Green
if ($msi) { Write-Host " MSI: $($msi.FullName)" -ForegroundColor Yellow }
if ($exe) { Write-Host " EXE: $($exe.FullName)" -ForegroundColor Yellow }
if (-not $msi -and -not $exe) { Write-Host " No artifacts found in $out" -ForegroundColor Red }
@@ -1,4 +1,4 @@
app-id: io.github.moku_project.Moku
app-id: dev.moku.app
runtime: org.gnome.Platform
runtime-version: '48'
sdk: org.gnome.Sdk
@@ -9,22 +9,16 @@ separate-locales: false
finish-args:
- --socket=wayland
- --socket=x11
- --socket=fallback-x11
- --share=ipc
- --device=dri
- --share=network
- --talk-name=org.freedesktop.Notifications
- --talk-name=org.freedesktop.portal.Desktop
- --talk-name=org.freedesktop.portal.FileTransfer
- --talk-name=org.kde.StatusNotifierWatcher
- --talk-name=com.canonical.AppMenu.Registrar
- --talk-name=com.canonical.indicator.application
- --filesystem=xdg-run/discord-ipc-0:ro
- --socket=session-bus
- --socket=system-bus
- --filesystem=home
- --filesystem=xdg-data/moku:create
- --filesystem=xdg-download
- --talk-name=org.freedesktop.Flatpak
build-options:
append-path: /usr/lib/sdk/rust-stable/bin
@@ -32,77 +26,6 @@ build-options:
CARGO_HOME: /run/build/moku/cargo
modules:
- name: intltool
buildsystem: autotools
sources:
- type: archive
url: https://launchpad.net/intltool/trunk/0.51.0/+download/intltool-0.51.0.tar.gz
sha256: 67c74d94196b153b774ab9f89b2fa6c6ba79352407037c8c14d5aeb334e959cd
- name: libdbusmenu
buildsystem: autotools
build-options:
cflags: -Wno-error
env:
HAVE_VALGRIND_FALSE: '#'
HAVE_VALGRIND_TRUE: ''
config-opts:
- --with-gtk=3
- --disable-static
- --disable-dumper
- --disable-tests
- --disable-gtk-doc
- --disable-vala
- --disable-introspection
cleanup:
- /include
- /libexec
- /lib/pkgconfig
- /lib/*.la
- /share/doc
- /share/libdbusmenu
- /share/gtk-doc
- /share/gir-1.0
sources:
- type: archive
url: https://launchpad.net/libdbusmenu/16.04/16.04.0/+download/libdbusmenu-16.04.0.tar.gz
sha256: b9cc4a2acd74509435892823607d966d424bd9ad5d0b00938f27240a1bfa878a
- name: libayatana-ido
buildsystem: cmake-ninja
config-opts:
- -DENABLE_TESTS=OFF
- -DGSETTINGS_COMPILE=OFF
sources:
- type: git
url: https://github.com/AyatanaIndicators/ayatana-ido.git
tag: 0.10.3
- name: libayatana-indicator
buildsystem: cmake-ninja
config-opts:
- -DENABLE_TESTS=OFF
- -DGSETTINGS_COMPILE=OFF
sources:
- type: git
url: https://github.com/AyatanaIndicators/libayatana-indicator.git
tag: 0.9.4
- name: libayatana-appindicator
buildsystem: cmake-ninja
config-opts:
- -DENABLE_TESTS=OFF
- -DENABLE_BINDINGS_MONO=OFF
- -DENABLE_BINDINGS_VALA=OFF
- -DGSETTINGS_COMPILE=OFF
sources:
- type: git
url: https://github.com/AyatanaIndicators/libayatana-appindicator.git
tag: 0.5.93
- type: shell
commands:
- sed -i '/add_subdirectory(docs)/d' CMakeLists.txt
- name: openjdk
buildsystem: simple
build-commands:
@@ -110,10 +33,13 @@ modules:
- tar -xf jdk.tar.gz -C /app/jre --strip-components=1
sources:
- type: file
url: https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21.0.5%2B11/OpenJDK21U-jre_x64_linux_hotspot_21.0.5_11.tar.gz
sha256: 553dda64b3b1c3c16f8afe402377ffebe64fb4a1721a46ed426a91fd18185e62
url: https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21.0.3%2B9/OpenJDK21U-jre_x64_linux_hotspot_21.0.3_9.tar.gz
sha256: f1af100c4afca2035f446967323230150cfe5872b5a664d98c86963e5c066e0d
dest-filename: jdk.tar.gz
# catch_abort.so — intercepts SIGTRAP/SIGILL from KCEF's CEF subprocess and
# exits just that thread instead of killing the whole JVM. Official Suwayomi
# fix for headless environments. Source inlined to avoid upstream drift.
- name: catch-abort
buildsystem: simple
build-commands:
@@ -123,6 +49,9 @@ modules:
- type: inline
dest-filename: catch_abort.c
contents: |
// Linux only:
// Attempts to catch SIGTRAP and exit the thread instead of bringing down the whole process
#define _GNU_SOURCE
#include <stdio.h>
#include <dlfcn.h>
@@ -163,12 +92,12 @@ modules:
cat > /app/tachidesk/default-conf/server.conf << 'EOF'
server.ip = "127.0.0.1"
server.port = 4567
server.webUIEnabled = true
server.webUIEnabled = false
server.initialOpenInBrowserEnabled = false
server.systemTrayEnabled = false
server.webUIInterface = "browser"
server.webUIFlavor = "WebUI"
server.webUIChannel = "PREVIEW"
server.webUIChannel = "stable"
server.electronPath = ""
server.debugLogsEnabled = false
server.downloadAsCbz = true
@@ -182,20 +111,24 @@ modules:
cat > /app/bin/tachidesk-server << 'EOF'
#!/bin/sh
DATA_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/Tachidesk"
DATA_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/moku/tachidesk"
mkdir -p "$DATA_DIR"
# Seed conf on first run
if [ ! -f "$DATA_DIR/server.conf" ]; then
cp /app/tachidesk/default-conf/server.conf "$DATA_DIR/server.conf"
fi
# Force-patch the three keys that cause JCEF/GUI crashes every launch.
# Suwayomi ignores -D JVM flags when a conf file exists on disk.
sed -i \
-e 's|server\.webUIEnabled.*|server.webUIEnabled = false|' \
-e 's|server\.initialOpenInBrowserEnabled.*|server.initialOpenInBrowserEnabled = false|' \
-e 's|server\.systemTrayEnabled.*|server.systemTrayEnabled = false|' \
"$DATA_DIR/server.conf"
grep -q 'server\.webUIEnabled' "$DATA_DIR/server.conf" || echo 'server.webUIEnabled = true' >> "$DATA_DIR/server.conf"
# Append keys if absent
grep -q 'server\.webUIEnabled' "$DATA_DIR/server.conf" || echo 'server.webUIEnabled = false' >> "$DATA_DIR/server.conf"
grep -q 'server\.initialOpenInBrowserEnabled' "$DATA_DIR/server.conf" || echo 'server.initialOpenInBrowserEnabled = false' >> "$DATA_DIR/server.conf"
grep -q 'server\.systemTrayEnabled' "$DATA_DIR/server.conf" || echo 'server.systemTrayEnabled = false' >> "$DATA_DIR/server.conf"
@@ -205,6 +138,8 @@ modules:
export _JAVA_OPTIONS="-Djava.awt.headless=true"
export JAVA_TOOL_OPTIONS="-Djava.awt.headless=true"
# Intercepts SIGTRAP/SIGILL from KCEF's CEF subprocess, exits just
# that thread instead of crashing the whole JVM process.
export LD_PRELOAD="/app/lib/catch_abort.so"
exec /app/jre/bin/java \
@@ -220,8 +155,8 @@ modules:
sources:
- type: file
url: https://github.com/Suwayomi/Suwayomi-Server-preview/releases/download/v2.2.2196/Suwayomi-Server-v2.2.2196.jar
sha256: 8e7244c269456661a87705f746f0d87275770aa976bab7c6920e4d513e97c3f6
url: https://github.com/Suwayomi/Suwayomi-Server/releases/download/v2.1.1867/suwayomi-server-v2.1.1867.jar
sha256: 51e307c2581e4e1a002991ab3e3a77503c8b074c42695987a984a7382d0ac5af
dest-filename: Suwayomi-Server.jar
- name: moku
@@ -231,24 +166,22 @@ modules:
CARGO_HOME: /run/build/moku/cargo
XDG_DATA_HOME: /run/build/moku/xdg-data
TAURI_SKIP_DEVSERVER_CHECK: 'true'
PKG_CONFIG_PATH: /usr/lib/pkgconfig:/usr/share/pkgconfig:/app/lib/pkgconfig
PKG_CONFIG_PATH: /usr/lib/pkgconfig:/usr/share/pkgconfig
build-commands:
- tar -xzf frontend-dist.tar.gz
- . /usr/lib/sdk/rust-stable/enable.sh && PKG_CONFIG_PATH=/usr/lib/pkgconfig:/usr/share/pkgconfig:/app/lib/pkgconfig cargo build --release --manifest-path src-tauri/Cargo.toml
- . /usr/lib/sdk/rust-stable/enable.sh && PKG_CONFIG_PATH=/usr/lib/pkgconfig:/usr/share/pkgconfig cargo build --release --manifest-path src-tauri/Cargo.toml
- install -Dm755 src-tauri/target/release/moku /app/bin/moku
- install -Dm644 packaging/io.github.moku_project.Moku.desktop /app/share/applications/io.github.moku_project.Moku.desktop
- install -Dm644 src-tauri/icons/32x32.png /app/share/icons/hicolor/32x32/apps/io.github.moku_project.Moku.png
- install -Dm644 src-tauri/icons/128x128.png /app/share/icons/hicolor/128x128/apps/io.github.moku_project.Moku.png
- install -Dm644 src-tauri/icons/128x128@2x.png /app/share/icons/hicolor/256x256/apps/io.github.moku_project.Moku.png
- install -Dm644 packaging/io.github.moku_project.Moku.metainfo.xml /app/share/metainfo/io.github.moku_project.Moku.metainfo.xml
- install -Dm644 packaging/dev.moku.app.desktop /app/share/applications/dev.moku.app.desktop
- install -Dm644 src-tauri/icons/32x32.png /app/share/icons/hicolor/32x32/apps/dev.moku.app.png
- install -Dm644 src-tauri/icons/128x128.png /app/share/icons/hicolor/128x128/apps/dev.moku.app.png
- install -Dm644 src-tauri/icons/128x128@2x.png /app/share/icons/hicolor/256x256/apps/dev.moku.app.png
- install -Dm644 packaging/dev.moku.app.metainfo.xml /app/share/metainfo/dev.moku.app.metainfo.xml
sources:
- type: git
url: https://github.com/moku-project/Moku.git
tag: v0.9.4
commit: 239960683b6c7f1347e1798b0e179a8a46628728
- type: dir
path: .
- type: file
path: packaging/frontend-dist.tar.gz
sha256: 7db288b4b54277aa82b6ec5b21fc31a1e71f8246c50a74777500083b806c1fa5
sha256: c9bb5ee6613b2bc61e69a92cc1ef0029da3b61138d51b01d363f8ea524e51996
- packaging/cargo-sources.json
- type: inline
dest: src-tauri/.cargo
-52
View File
@@ -1,52 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 320" width="1280" height="320">
<defs>
<linearGradient id="leafHero" x1="0.3" y1="0" x2="0.7" y2="1">
<stop offset="0%" stop-color="#52b888"/>
<stop offset="100%" stop-color="#1e5840"/>
</linearGradient>
<clipPath id="roundedBounds">
<rect width="1280" height="320" rx="18" ry="18"/>
</clipPath>
</defs>
<g clip-path="url(#roundedBounds)">
<rect width="1280" height="320" fill="#070e09"/>
<!-- Icon — rotate(7) from moku-icon-splash.svg -->
<g transform="translate(640, 148) rotate(7) scale(0.065,-0.065) translate(-5000,-4800)"
fill="url(#leafHero)" opacity="0.97">
<path d="M4908 6615 c-597 -629 -935 -1264 -934 -1755 0 -508 195 -778 790
-1098 l194 -104 22 -114 c33 -164 76 -271 140 -347 80 -94 106 -62 77 94 -47
255 43 443 294 613 797 539 714 1392 -250 2551 -230 278 -224 275 -333 160z
m52 -945 c0 -472 -8 -850 -17 -850 -36 0 -693 703 -692 740 2 48 167 321 309
509 175 233 359 451 380 451 13 0 20 -304 20 -850z m183 775 c120 -126 357
-447 356 -482 0 -18 -47 -83 -105 -144 -57 -61 -151 -163 -208 -225 -151 -166
-146 -180 -146 406 0 286 7 520 16 520 9 0 48 -34 87 -75z m541 -750 c86 -150
196 -391 196 -430 0 -8 -25 -42 -55 -75 -31 -33 -149 -163 -264 -290 -339
-374 -480 -520 -501 -520 -13 0 -20 167 -20 465 l1 465 151 170 c83 94 193
217 244 275 122 138 137 135 248 -60z m-1098 -633 l374 -378 0 -462 c0 -254
-5 -462 -11 -462 -6 0 -55 23 -110 50 l-99 51 0 208 c0 259 -11 288 -176 457
-196 200 -188 199 -326 62 l-117 -116 -35 79 c-71 161 -39 569 64 816 42 100
20 116 436 -305z m1349 23 c109 -390 -87 -848 -475 -1111 -207 -139 -305 -262
-338 -420 -7 -31 -21 -56 -32 -56 -36 -1 -46 86 -48 405 l-2 313 123 137 c68
75 214 236 325 357 454 493 421 466 447 375z m-1292 -785 c12 -29 15 -118 7
-215 l-14 -166 -73 44 c-174 104 -403 341 -403 416 0 15 47 70 105 123 l104
98 127 -125 c70 -69 136 -147 147 -175z"/>
</g>
<!-- Stack text pinned to bottom -->
<text
x="640" y="300"
text-anchor="middle"
font-family="'SF Mono', 'JetBrains Mono', 'Fira Code', monospace"
font-size="14"
letter-spacing="5"
fill="#a8c4a8"
opacity="0.32">TAURI v2 · SVELTE 5 · TYPESCRIPT</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 287 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 MiB

Generated
+100 -12
View File
@@ -1,15 +1,46 @@
{
"nodes": {
"crane": {
"locked": {
"lastModified": 1771438068,
"narHash": "sha256-nGBbXvEZVe/egCPVPFcu89RFtd8Rf6J+4RFoVCFec0A=",
"owner": "ipetkov",
"repo": "crane",
"rev": "b5090e53e9d68c523a4bb9ad42b4737ee6747597",
"type": "github"
},
"original": {
"owner": "ipetkov",
"repo": "crane",
"type": "github"
}
},
"flake-compat": {
"flake": false,
"locked": {
"lastModified": 1733328505,
"narHash": "sha256-NeCCThCEP3eCl2l/+27kNNK7QrwZB1IJCrXfrbv5oqU=",
"owner": "edolstra",
"repo": "flake-compat",
"rev": "ff81ac966bb2cae68946d5ed5fc4994f96d0ffec",
"type": "github"
},
"original": {
"owner": "edolstra",
"repo": "flake-compat",
"type": "github"
}
},
"flake-parts": {
"inputs": {
"nixpkgs-lib": "nixpkgs-lib"
},
"locked": {
"lastModified": 1778716662,
"narHash": "sha256-m1Yf0wZ8j1OHjTc2UwHwyQRSnNeSgLJOd7q5Y45hzi4=",
"lastModified": 1769996383,
"narHash": "sha256-AnYjnFWgS49RlqX7LrC4uA+sCCDBj0Ry/WOJ5XWAsa0=",
"owner": "hercules-ci",
"repo": "flake-parts",
"rev": "f7c1a2d347e4c52d5fb8d10cb4d94b5884e546fb",
"rev": "57928607ea566b5db3ad13af0e57e921e6b12381",
"type": "github"
},
"original": {
@@ -18,13 +49,53 @@
"type": "github"
}
},
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nix-appimage": {
"inputs": {
"flake-compat": "flake-compat",
"flake-utils": "flake-utils",
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1757920913,
"narHash": "sha256-jd0QwCVz4O1sHHkeaZILD/7D6oyalceEJ4EFnWCgm0k=",
"owner": "ralismark",
"repo": "nix-appimage",
"rev": "7946addbc0d97e358a6d7aefe5e82310f0fe6b18",
"type": "github"
},
"original": {
"owner": "ralismark",
"repo": "nix-appimage",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1780243769,
"narHash": "sha256-x5UQuRsH3MqI0U9afaXSNqzTPSeZlRLvFAav2Ux1pNw=",
"lastModified": 1771369470,
"narHash": "sha256-0NBlEBKkN3lufyvFegY4TYv5mCNHbi5OmBDrzihbBMQ=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "331800de5053fcebacf6813adb5db9c9dca22a0c",
"rev": "0182a361324364ae3f436a63005877674cf45efb",
"type": "github"
},
"original": {
@@ -36,11 +107,11 @@
},
"nixpkgs-lib": {
"locked": {
"lastModified": 1777168982,
"narHash": "sha256-GOkGPcboWE9BmGCRMLX3worL4EMnsnG8MyKmXNeYuhQ=",
"lastModified": 1769909678,
"narHash": "sha256-cBEymOf4/o3FD5AZnzC3J9hLbiZ+QDT/KDuyHXVJOpM=",
"owner": "nix-community",
"repo": "nixpkgs.lib",
"rev": "f5901329dade4a6ea039af1433fb087bd9c1fe14",
"rev": "72716169fe93074c333e8d0173151350670b824c",
"type": "github"
},
"original": {
@@ -51,7 +122,9 @@
},
"root": {
"inputs": {
"crane": "crane",
"flake-parts": "flake-parts",
"nix-appimage": "nix-appimage",
"nixpkgs": "nixpkgs",
"rust-overlay": "rust-overlay"
}
@@ -63,11 +136,11 @@
]
},
"locked": {
"lastModified": 1780543271,
"narHash": "sha256-oPJ7eJN1sM37v92Rp/eyQL7/rUm0BOvXEBAoq/zN0cM=",
"lastModified": 1771556776,
"narHash": "sha256-zKprqMQDl3xVfhSSYvgru1IGXjFdxryWk+KqK0I20Xk=",
"owner": "oxalica",
"repo": "rust-overlay",
"rev": "c30ca201c5093540cf792f6982f81ba1aa0f3514",
"rev": "8b3f46b8a6d17ab46e533a5e3d5b1cc2ff228860",
"type": "github"
},
"original": {
@@ -75,6 +148,21 @@
"repo": "rust-overlay",
"type": "github"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
+155 -51
View File
@@ -4,14 +4,19 @@
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
flake-parts.url = "github:hercules-ci/flake-parts";
crane.url = "github:ipetkov/crane";
rust-overlay = {
url = "github:oxalica/rust-overlay";
inputs.nixpkgs.follows = "nixpkgs";
};
nix-appimage = {
url = "github:ralismark/nix-appimage";
inputs.nixpkgs.follows = "nixpkgs";
};
};
outputs =
inputs@{ flake-parts, rust-overlay, ... }:
inputs@{ flake-parts, crane, rust-overlay, nix-appimage, ... }:
flake-parts.lib.mkFlake { inherit inputs; } {
systems = [
"x86_64-linux"
@@ -19,23 +24,22 @@
];
perSystem =
{ system, lib, ... }:
{ system, pkgs, lib, ... }:
let
versions = import ./nix/versions.nix;
version = versions.moku;
pkgs = import inputs.nixpkgs {
pkgs' = import inputs.nixpkgs {
inherit system;
overlays = [ rust-overlay.overlays.default ];
};
rustToolchain = pkgs.rust-bin.stable.latest.default.override {
rustToolchain = pkgs'.rust-bin.stable.latest.default.override {
extensions = [
"rust-src"
"rust-analyzer"
];
};
craneLib = (crane.mkLib pkgs').overrideToolchain rustToolchain;
runtimeLibs = with pkgs; [
webkitgtk_4_1
gtk3
@@ -51,54 +55,151 @@
gsettings-desktop-schemas
];
src = lib.cleanSourceWith {
frontendSrc = lib.cleanSourceWith {
src = ./.;
filter =
path: type:
let
base = builtins.baseNameOf path;
filter = path: type:
let base = builtins.baseNameOf path;
in
(lib.hasInfix "/src" path)
|| (lib.hasInfix "/src-tauri/src" path)
|| (lib.hasInfix "/src-tauri/icons" path)
|| (lib.hasInfix "/src-tauri/capabilities" path)
|| (lib.hasInfix "/static" path)
|| base == "index.html"
|| base == "package.json"
|| base == "pnpm-lock.yaml"
|| base == "pnpm-workspace.yaml"
|| base == "tsconfig.json"
|| base == "tsconfig.node.json"
|| base == "vite.config.ts"
|| base == "svelte.config.js"
|| base == "Cargo.toml"
|| base == "Cargo.lock"
|| base == "build.rs"
|| base == "tauri.conf.json";
|| base == "postcss.config.js"
|| base == "postcss.config.cjs"
|| base == "tailwind.config.js"
|| base == "tailwind.config.ts";
};
suwayomiServer = pkgs.callPackage ./nix/server.nix { inherit versions; };
frontend = pkgs.stdenv.mkDerivation {
pname = "moku-frontend";
version = "0.3.0";
src = frontendSrc;
moku = pkgs.callPackage ./nix/moku.nix {
inherit lib pkgs rustToolchain runtimeLibs suwayomiServer version src versions;
appIcon = ./src/lib/assets/moku-icon.svg;
nativeBuildInputs = with pkgs; [
nodejs_22
pnpm
pnpmConfigHook
];
pnpmDeps = pkgs.fetchPnpmDeps {
pname = "moku-frontend";
version = "0.3.0";
src = frontendSrc;
fetcherVersion = 1;
hash = "sha256-bpGYsB534RPNNAcYR9BA61vvFpSG6Xu2hY923PakCyY=";
};
buildPhase = "pnpm build";
installPhase = "cp -r dist $out";
};
scripts = import ./nix/scripts.nix { inherit pkgs rustToolchain version versions; };
cargoSrc = lib.cleanSourceWith {
src = ./src-tauri;
filter = path: type:
(craneLib.filterCargoSources path type)
|| (lib.hasInfix "/icons/" path)
|| (lib.hasInfix "/capabilities/" path)
|| (builtins.baseNameOf path == "tauri.conf.json");
};
commonArgs = {
src = cargoSrc;
cargoToml = ./src-tauri/Cargo.toml;
cargoLock = ./src-tauri/Cargo.lock;
strictDeps = true;
buildInputs = runtimeLibs;
nativeBuildInputs = with pkgs; [
pkg-config
wrapGAppsHook3
];
preBuild = ''
cp -r ${frontend} ../dist
'';
};
cargoArtifacts = craneLib.buildDepsOnly commonArgs;
moku = craneLib.buildPackage (commonArgs // {
inherit cargoArtifacts;
meta.mainProgram = "moku";
postInstall = ''
wrapProgram $out/bin/moku \
--prefix XDG_DATA_DIRS : "${lib.makeSearchPath "share/gsettings-schemas" [
pkgs.gsettings-desktop-schemas
pkgs.gtk3
]}" \
--prefix LD_LIBRARY_PATH : "${lib.makeLibraryPath runtimeLibs}" \
--prefix PATH : "${lib.makeBinPath [ pkgs.suwayomi-server ]}" \
--set GDK_BACKEND wayland \
--set WEBKIT_FORCE_SANDBOX 0
# Icon
# Tauri bakes several sizes into src-tauri/icons/. We prefer the
# largest PNG (512x512) for the hicolor theme, and also install the
# rounded 32x32 used as the in-app logo so small sizes look right.
# Adjust the source filenames if yours differ.
for size in 32x32 128x128 256x256 512x512; do
src="icons/$size.png"
if [ -f "$src" ]; then
install -Dm644 "$src" \
"$out/share/icons/hicolor/$size/apps/moku.png"
fi
done
# @2x variants that Tauri also generates
for size in 128x128 256x256; do
src="icons/''${size}@2x.png"
if [ -f "$src" ]; then
install -Dm644 "$src" \
"$out/share/icons/hicolor/''${size}@2/apps/moku.png"
fi
done
# Scalable SVG src/assets/moku-icon.svg is the rounded version
# referenced in SplashScreen.tsx. Pull it straight from the source
# tree so the launcher always uses the same rounded artwork.
install -Dm644 "${./src/assets/moku-icon.svg}" \
"$out/share/icons/hicolor/scalable/apps/moku.svg"
# .desktop entry
install -Dm644 /dev/stdin \
"$out/share/applications/moku.desktop" <<EOF
[Desktop Entry]
Version=1.0
Type=Application
Name=Moku
Comment=Manga reader frontend for Suwayomi
Exec=$out/bin/moku
Icon=moku
Terminal=false
Categories=Graphics;Viewer;
Keywords=manga;comic;reader;suwayomi;
StartupWMClass=moku
EOF
'';
});
in
{
packages = {
inherit moku suwayomiServer;
default = moku;
# Expose as both a runnable app and installable packages.
apps = {
default = {
type = "app";
program = "${moku}/bin/moku";
};
moku = {
type = "app";
program = "${moku}/bin/moku";
};
};
apps = {
default = { type = "app"; program = "${moku}/bin/moku"; };
moku = { type = "app"; program = "${moku}/bin/moku"; };
bump = { type = "app"; program = "${scripts.bump}/bin/moku-bump"; };
update = { type = "app"; program = "${scripts.update}/bin/moku-update"; };
flatpak = { type = "app"; program = "${scripts.flatpak}/bin/moku-flatpak"; };
tunnel = { type = "app"; program = "${scripts.tunnel}/bin/moku-tunnel"; };
packages = {
inherit moku frontend;
default = moku;
appimage = nix-appimage.bundlers."${system}".default moku;
};
devShells.default = pkgs.mkShell {
@@ -109,27 +210,30 @@
wrapGAppsHook3
nodejs_22
pnpm
suwayomiServer
cloudflared
suwayomi-server
xdg-utils
(python3.withPackages (ps: [
ps.aiohttp
ps.tomlkit
]))
];
shellHook = ''
export APPIMAGE_EXTRACT_AND_RUN=1
export NO_STRIP=true
export PKG_CONFIG_PATH="${pkgs.openssl.dev}/lib/pkgconfig''${PKG_CONFIG_PATH:+:$PKG_CONFIG_PATH}"
export XDG_DATA_DIRS="${pkgs.gsettings-desktop-schemas}/share/gsettings-schemas/${pkgs.gsettings-desktop-schemas.name}:${pkgs.gtk3}/share/gsettings-schemas/${pkgs.gtk3.name}''${XDG_DATA_DIRS:+:$XDG_DATA_DIRS}"
export LD_LIBRARY_PATH="${pkgs.lib.makeLibraryPath runtimeLibs}''${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
echo "Moku dev shell pnpm install && pnpm tauri:dev"
echo ""
echo " nix run .#bump -- <ver> bump version + rebuild frontend"
echo " git commit && git tag && git push"
echo " nix run .#update -- <ver> fetch hashes + patch all configs"
echo " nix run .#flatpak build flatpak bundle"
echo " nix run .#tunnel -- [port] expose local server via cloudflare"
if [ ! -e /usr/bin/xdg-open ]; then
sudo ln -sf ${pkgs.xdg-utils}/bin/xdg-open /usr/bin/xdg-open
fi
LINUXDEPLOY="$HOME/.cache/tauri/linuxdeploy-x86_64.AppImage"
LINUXDEPLOY_REAL="$HOME/.cache/tauri/linuxdeploy-x86_64.AppImage.real"
if [ -f "$LINUXDEPLOY" ] && [ ! -f "$LINUXDEPLOY_REAL" ]; then
mv "$LINUXDEPLOY" "$LINUXDEPLOY_REAL"
printf '#!/bin/sh\nexec ${pkgs.appimage-run}/bin/appimage-run "%s" "$@"\n' "$LINUXDEPLOY_REAL" > "$LINUXDEPLOY"
chmod +x "$LINUXDEPLOY"
echo "linuxdeploy wrapped with appimage-run"
fi
echo "Moku dev shell"
echo " pnpm install && pnpm tauri:dev"
'';
};
+2 -2
View File
@@ -6,7 +6,7 @@
<title>Moku</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
-99
View File
@@ -1,99 +0,0 @@
{
lib,
pkgs,
rustToolchain,
runtimeLibs,
suwayomiServer,
version,
versions,
src,
appIcon,
}:
pkgs.stdenv.mkDerivation {
pname = "moku";
inherit version src;
nativeBuildInputs = with pkgs; [
rustToolchain
nodejs_22
pnpm
pnpmConfigHook
pkg-config
wrapGAppsHook3
rustPlatform.cargoSetupHook
];
buildInputs = runtimeLibs;
pnpmDeps = pkgs.fetchPnpmDeps {
pname = "moku";
inherit version src;
fetcherVersion = 3;
hash = versions.frontend.pnpmHash;
};
cargoDeps = pkgs.rustPlatform.importCargoLock {
lockFile = ../src-tauri/Cargo.lock;
outputHashes = {
"tauri-plugin-discord-rpc-0.1.0" = versions.gitDeps.tauri-plugin-discord-rpc;
};
};
env = {
PKG_CONFIG_PATH = "${pkgs.openssl.dev}/lib/pkgconfig";
TAURI_SKIP_DEVSERVER_CHECK = "true";
cargoRoot = "src-tauri";
};
buildPhase = ''
export HOME=$(mktemp -d)
pnpm tauri:build
'';
installPhase = ''
install -Dm755 src-tauri/target/release/moku $out/bin/moku
mkdir -p "$out/share/applications"
cat > "$out/share/applications/moku.desktop" << EOF2
[Desktop Entry]
Version=1.0
Type=Application
Name=Moku
Comment=Manga reader frontend for Suwayomi
Exec=$out/bin/moku
Icon=moku
Terminal=false
Categories=Graphics;Viewer;
Keywords=manga;comic;reader;suwayomi;
StartupWMClass=moku
EOF2
for size in 32x32 128x128 256x256 512x512; do
f="src-tauri/icons/$size.png"
[ -f "$f" ] && install -Dm644 "$f" \
"$out/share/icons/hicolor/$size/apps/moku.png"
done
for size in 128x128 256x256; do
f="src-tauri/icons/''${size}@2x.png"
[ -f "$f" ] && install -Dm644 "$f" \
"$out/share/icons/hicolor/''${size}@2/apps/moku.png"
done
install -Dm644 "${appIcon}" \
"$out/share/icons/hicolor/scalable/apps/moku.svg"
wrapProgram $out/bin/moku \
--prefix XDG_DATA_DIRS : "${lib.makeSearchPath "share/gsettings-schemas" [
pkgs.gsettings-desktop-schemas
pkgs.gtk3
]}" \
--prefix LD_LIBRARY_PATH : "${lib.makeLibraryPath runtimeLibs}" \
--prefix PATH : "${lib.makeBinPath [ suwayomiServer ]}" \
--set GDK_BACKEND wayland \
--set WEBKIT_DISABLE_SANDBOX_THIS_IS_DANGEROUS 1
'';
meta.mainProgram = "moku";
}
-156
View File
@@ -1,156 +0,0 @@
{ pkgs, rustToolchain, version, versions }:
{
bump = pkgs.writeShellApplication {
name = "moku-bump";
runtimeInputs = with pkgs; [
gnused
coreutils
git
rustToolchain
nodejs_22
pnpm
(python3.withPackages (ps: [ ps.aiohttp ps.tomlkit ]))
];
text = ''
[[ $# -lt 1 ]] && { echo "Usage: nix run .#bump -- <version>"; exit 1; }
VERSION="$1"
REPO="$(git rev-parse --show-toplevel)"
sed -i "s/moku = \"[^\"]*\"/moku = \"$VERSION\"/" "$REPO/nix/versions.nix"
sed -i "s/\"version\": \"[^\"]*\"/\"version\": \"$VERSION\"/" "$REPO/src-tauri/tauri.conf.json"
sed -i "0,/^version = \"[^\"]*\"/s//version = \"$VERSION\"/" "$REPO/src-tauri/Cargo.toml"
sed -i "s/^pkgver=.*/pkgver=$VERSION/" "$REPO/PKGBUILD"
sed -i "s/^pkgrel=.*/pkgrel=1/" "$REPO/PKGBUILD"
(cd "$REPO/src-tauri" && cargo generate-lockfile)
cd "$REPO"
pnpm install --frozen-lockfile
pnpm build:static
tar -czf "$REPO/packaging/frontend-dist.tar.gz" -C "$REPO" dist
FRONTEND_SHA_HEX=$(sha256sum "$REPO/packaging/frontend-dist.tar.gz" | awk '{print $1}')
FRONTEND_SHA_SRI=$(echo "$FRONTEND_SHA_HEX" | xxd -r -p | base64 -w0 | sed 's/^/sha256-/')
sed -i "s|distHash = \"[^\"]*\"|distHash = \"$FRONTEND_SHA_HEX\"|" "$REPO/nix/versions.nix"
sed -i "s|distHashSri = \"[^\"]*\"|distHashSri = \"$FRONTEND_SHA_SRI\"|" "$REPO/nix/versions.nix"
MANIFEST="$REPO/io.github.moku_project.Moku.yml"
sed -i "s/tag: v[^[:space:]]*/tag: v$VERSION/" "$MANIFEST"
python3 - "$MANIFEST" "$FRONTEND_SHA_HEX" <<'PYEOF'
import re, sys
path, sha = sys.argv[1], sys.argv[2]
text = open(path).read()
updated, n = re.subn(
r'(path:\s*packaging/frontend-dist\.tar\.gz\s*\n\s*sha256:\s*)[0-9a-f]+',
r'\g<1>' + sha, text)
if n == 0:
sys.exit("ERROR: could not find frontend-dist sha256 in manifest")
open(path, 'w').write(updated)
PYEOF
python3 "$REPO/packaging/flatpak-cargo-generator.py" \
"$REPO/src-tauri/Cargo.lock" \
-o "$REPO/packaging/cargo-sources.json"
echo "Bumped to v$VERSION commit, tag, push, then: nix run .#update -- $VERSION"
'';
};
update = pkgs.writeShellApplication {
name = "moku-update";
runtimeInputs = with pkgs; [ gnused coreutils git curl nix xxd ];
text = ''
REPO="$(git rev-parse --show-toplevel)"
VERSIONS="$REPO/nix/versions.nix"
MANIFEST="$REPO/io.github.moku_project.Moku.yml"
PKGBUILD="$REPO/PKGBUILD"
if [[ $# -ge 1 ]]; then
VERSION="$1"
else
VERSION=$(grep 'moku = "' "$VERSIONS" | head -1 | sed 's/.*"\(.*\)".*/\1/')
fi
COMMIT=$(git ls-remote https://github.com/moku-project/Moku.git "refs/tags/v$VERSION" | awk '{print $1}')
[[ -z "$COMMIT" ]] && { echo "ERROR: tag v$VERSION not found on remote"; exit 1; }
sed -i "s/gitCommit = \"[^\"]*\"/gitCommit = \"$COMMIT\"/" "$VERSIONS"
sed -i "s/commit: [0-9a-f]\{40\}/commit: $COMMIT/" "$MANIFEST"
TARBALL_URL="https://github.com/moku-project/Moku/archive/refs/tags/v$VERSION.tar.gz"
TARBALL_SHA=$(curl -fsSL "$TARBALL_URL" | sha256sum | awk '{print $1}')
sed -i "s/tarballHash = \"[^\"]*\"/tarballHash = \"$TARBALL_SHA\"/" "$VERSIONS"
sed -i "/sha256sums=/,/)/{ 0,/'/s/'[^']*'/'$TARBALL_SHA'/ }" "$PKGBUILD"
if [[ $# -ge 2 ]]; then
SUWA_VER="$2"
BASE="https://github.com/Suwayomi/Suwayomi-Server-preview/releases/download/v${SUWA_VER}"
echo "Fetching Suwayomi v${SUWA_VER} hashes (5 downloads)..."
sha_of() { curl -fsSL "$1" | sha256sum | awk '{print $1}'; }
to_sri() { echo "$1" | xxd -r -p | base64 -w0 | sed 's/^/sha256-/'; }
JAR_SHA=$(sha_of "${BASE}/Suwayomi-Server-v${SUWA_VER}.jar")
WIN_SHA=$(sha_of "${BASE}/Suwayomi-Server-v${SUWA_VER}-windows-x64.zip")
LINUX_SHA=$(sha_of "${BASE}/Suwayomi-Server-v${SUWA_VER}-linux-x64.tar.gz")
ARM64_SHA=$(sha_of "${BASE}/Suwayomi-Server-v${SUWA_VER}-macOS-arm64.tar.gz")
X64_SHA=$(sha_of "${BASE}/Suwayomi-Server-v${SUWA_VER}-macOS-x64.tar.gz")
JAR_SRI=$(to_sri "$JAR_SHA")
sed -i "s/version = \"[^\"]*\"/version = \"${SUWA_VER}\"/" "$VERSIONS"
sed -i "s|hash = \"sha256-[^\"]*\"|hash = \"${JAR_SRI}\"|" "$VERSIONS"
sed -i "s|windowsHash = \"[^\"]*\"|windowsHash = \"${WIN_SHA}\"|" "$VERSIONS"
sed -i "s|linuxHash = \"[^\"]*\"|linuxHash = \"${LINUX_SHA}\"|" "$VERSIONS"
sed -i "s|macosArm64Hash = \"[^\"]*\"|macosArm64Hash = \"${ARM64_SHA}\"|" "$VERSIONS"
sed -i "s|macosX64Hash = \"[^\"]*\"|macosX64Hash = \"${X64_SHA}\"|" "$VERSIONS"
sed -i "s|Suwayomi-Server-preview/releases/download/v[^/]*/|Suwayomi-Server-preview/releases/download/v${SUWA_VER}/|" "$MANIFEST"
sed -i "s|Suwayomi-Server-v[0-9.]*\.jar|Suwayomi-Server-v${SUWA_VER}.jar|g" "$MANIFEST"
python3 - "$MANIFEST" "$JAR_SHA" <<'PYEOF'
import re, sys
path, sha = sys.argv[1], sys.argv[2]
text = open(path).read()
updated, n = re.subn(
r'(dest-filename:\s*Suwayomi-Server\.jar\s*\n\s*sha256:\s*)[0-9a-f]+',
r'\g<1>' + sha, text)
if n == 0:
sys.exit("ERROR: could not find Suwayomi jar sha256 in manifest")
open(path, 'w').write(updated)
PYEOF
echo "Suwayomi hashes written."
fi
echo "Done versions.nix, flatpak manifest, and PKGBUILD patched for v$VERSION"
'';
};
flatpak = pkgs.writeShellApplication {
name = "moku-flatpak";
runtimeInputs = with pkgs; [ coreutils git appstream flatpak-builder flatpak ];
text = ''
REPO="$(git rev-parse --show-toplevel)"
rm -rf "$REPO/build-dir" "$REPO/repo"
flatpak-builder \
--repo="$REPO/repo" \
--force-clean \
"$REPO/build-dir" \
"$REPO/io.github.moku_project.Moku.yml"
flatpak build-bundle "$REPO/repo" "$REPO/moku.flatpak" io.github.moku_project.Moku
rm -rf "$REPO/build-dir" "$REPO/repo"
echo "moku.flatpak created"
'';
};
tunnel = pkgs.writeShellApplication {
name = "moku-tunnel";
runtimeInputs = with pkgs; [ cloudflared ];
text = ''
PORT="''${1:-4567}"
cloudflared tunnel --url "http://localhost:$PORT"
'';
};
}
-48
View File
@@ -1,48 +0,0 @@
{
lib,
stdenvNoCC,
fetchurl,
makeWrapper,
jdk21_headless,
versions,
}:
let
jdk = jdk21_headless;
ver = versions.suwayomi;
in
stdenvNoCC.mkDerivation {
pname = "suwayomi-server";
version = ver.version;
src = fetchurl {
url = "https://github.com/Suwayomi/Suwayomi-Server-preview/releases/download/v${ver.version}/Suwayomi-Server-v${ver.version}.jar";
hash = ver.hash;
};
nativeBuildInputs = [ makeWrapper ];
dontUnpack = true;
buildPhase = ''
runHook preBuild
install -Dm644 $src $out/share/suwayomi-server/suwayomi-server.jar
makeWrapper ${jdk}/bin/java $out/bin/suwayomi-server \
--add-flags "-Dsuwayomi.tachidesk.config.server.initialOpenInBrowserEnabled=false" \
--add-flags "-jar $out/share/suwayomi-server/suwayomi-server.jar"
runHook postBuild
'';
meta = {
description = "Free and open source manga reader server that runs extensions built for Mihon (Tachiyomi)";
homepage = "https://github.com/Suwayomi/Suwayomi-Server";
downloadPage = "https://github.com/Suwayomi/Suwayomi-Server-preview/releases";
changelog = "https://github.com/Suwayomi/Suwayomi-Server-preview/releases/tag/v${ver.version}";
license = lib.licenses.mpl20;
platforms = jdk.meta.platforms;
sourceProvenance = [ lib.sourceTypes.binaryBytecode ];
mainProgram = "suwayomi-server";
};
}
-25
View File
@@ -1,25 +0,0 @@
{
moku = "0.9.4";
suwayomi = {
version = "2.2.2196";
hash = "sha256-jnJEwmlFZmGodwX3RvDYcnV3Cql2urfGkg5NUT6Xw/Y=";
windowsHash = "457ca4a64a57e0d274a87203d25e962103bcb456ee30ada3ea47328a3093329d";
linuxHash = "e13d63ceb7e2b15e83d0a78281e8c1c04ac4a833caa73e5a2b68fbaf0cb20c1f";
macosArm64Hash = "9e3dbebc7475707e8d11c56a473385c00b09bde0103d013bc1cb3d06c89e5c43";
macosX64Hash = "eadee02060b780a5febfb8dada2f89c7bd7db5905cfd20d47eaca02fcde8c9c5";
};
frontend = {
pnpmHash = "sha256-18twdFhprV9v9hzvqxuVDHD6Tm4zHNDJs7s6l/7ClBo=";
distHash = "7db288b4b54277aa82b6ec5b21fc31a1e71f8246c50a74777500083b806c1fa5";
distHashSri = "sha256-fbiiu0tCd6qCtu+SIfw+aR8Yj2bFCnR3dQAIO4BvwfM=";
};
gitDeps = {
tauri-plugin-discord-rpc = "sha256-xq0qyK2NrwSAFDhXo0vbvcygRD2/7uqBaLpqfpfxkrc=";
};
gitCommit = "239960683b6c7f1347e1798b0e179a8a46628728";
tarballHash = "";
}
+28 -36
View File
@@ -1,49 +1,41 @@
{
"name": "moku",
"private": true,
"version": "0.9.4",
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite dev",
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"build:static": "MOKU_TARGET=static vite build",
"build:node": "MOKU_TARGET=node vite build",
"build:android": "MOKU_TARGET=static vite build",
"tauri": "tauri",
"tauri:dev": "tauri dev --config src-tauri/tauri.dev.conf.json",
"tauri:build": "tauri build"
},
"devDependencies": {
"@sveltejs/adapter-node": "^5.5.4",
"@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.62.0",
"@sveltejs/vite-plugin-svelte": "^7.1.2",
"@tauri-apps/cli": "^2.11.2",
"@types/node": "^25.9.3",
"phosphor-svelte": "^3.1.0",
"svelte": "^5.56.1",
"svelte-check": "^4.5.0",
"typescript": "^6.0.3",
"vite": "^8.0.16"
},
"dependencies": {
"@capacitor/app": "^8.1.0",
"@capacitor/browser": "^8.0.3",
"@capacitor/core": "^8.4.0",
"@capacitor/filesystem": "^8.1.2",
"@capacitor/preferences": "^8.0.1",
"@tauri-apps/api": "^2.11.0",
"@tauri-apps/plugin-dialog": "^2.7.1",
"@tauri-apps/plugin-fs": "^2.5.1",
"@tauri-apps/plugin-http": "^2.5.9",
"@tauri-apps/plugin-os": "^2.3.2",
"@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-shell": "^2.3.5",
"@tauri-apps/plugin-store": "^2.4.3",
"capacitor-native-biometric": "^4.2.2",
"@phosphor-icons/react": "^2.1.10",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-virtual": "^3.13.18",
"@tauri-apps/api": "^2.0.0",
"@tauri-apps/plugin-shell": "~2",
"clsx": "^2.1.1",
"tauri-plugin-discord-rpc-api": "github:Youwes09/tauri-plugin-discord-rpc"
"lucide-react": "^0.575.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.26.0",
"zustand": "^5.0.0"
},
"devDependencies": {
"@tauri-apps/cli": "^2.0.0",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.40",
"tailwindcss": "^3.4.7",
"typescript": "^5.5.3",
"vite": "^5.4.0"
}
}
+950 -1936
View File
File diff suppressed because it is too large Load Diff
@@ -2,7 +2,7 @@
Name=Moku
Comment=Manga reader powered by Suwayomi
Exec=moku
Icon=io.github.moku_project.Moku
Icon=dev.moku.app
Terminal=false
Type=Application
Categories=Graphics;Viewer;
+36
View File
@@ -0,0 +1,36 @@
<?xml version="1.0" encoding="UTF-8"?>
<component type="desktop-application">
<id>dev.moku.app</id>
<metadata_license>MIT</metadata_license>
<project_license>MIT</project_license>
<name>Moku</name>
<summary>Manga reader powered by Suwayomi</summary>
<description>
<p>
Moku is a desktop manga reader built on top of Suwayomi-Server (Tachidesk),
providing a clean native interface for browsing, reading, and managing your
manga library across hundreds of sources.
</p>
</description>
<launchable type="desktop-id">dev.moku.app.desktop</launchable>
<url type="homepage">https://github.com/shozikan/Moku</url>
<url type="bugtracker">https://github.com/shozikan/Moku/issues</url>
<provides>
<binary>moku</binary>
</provides>
<content_rating type="oars-1.1" />
<releases>
<release version="0.1.0" date="2025-01-01">
<description>
<p>Initial release.</p>
</description>
</release>
</releases>
</component>
Binary file not shown.
@@ -1,73 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<component type="desktop-application">
<id>io.github.moku_project.Moku</id>
<metadata_license>MIT</metadata_license>
<project_license>MIT</project_license>
<name>Moku</name>
<summary>Manga reader powered by Suwayomi</summary>
<description>
<p>
Moku is a desktop manga reader built on top of Suwayomi-Server (Tachidesk),
providing a clean native interface for browsing, reading, and managing your
manga library across hundreds of sources.
</p>
<p>
Features include library management, chapter tracking, extension support,
reading history, notifications, and Discord Rich Presence integration.
</p>
</description>
<launchable type="desktop-id">io.github.moku_project.Moku.desktop</launchable>
<url type="homepage">https://github.com/moku-project/Moku</url>
<url type="bugtracker">https://github.com/moku-project/Moku/issues</url>
<screenshots>
<screenshot type="default">
<image>https://raw.githubusercontent.com/moku-project/Moku/main/docs/screenshots/Moku-Home.png</image>
<caption>Home screen showing your manga library</caption>
</screenshot>
<screenshot>
<image>https://raw.githubusercontent.com/moku-project/Moku/main/docs/screenshots/Moku-Reader.png</image>
<caption>Built-in manga reader</caption>
</screenshot>
<screenshot>
<image>https://raw.githubusercontent.com/moku-project/Moku/main/docs/screenshots/Moku-Discover.png</image>
<caption>Discover new manga across hundreds of sources</caption>
</screenshot>
<screenshot>
<image>https://raw.githubusercontent.com/moku-project/Moku/main/docs/screenshots/Moku-Downloads.png</image>
<caption>Download manager</caption>
</screenshot>
<screenshot>
<image>https://raw.githubusercontent.com/moku-project/Moku/main/docs/screenshots/Moku-Settings.png</image>
<caption>Settings</caption>
</screenshot>
</screenshots>
<provides>
<binary>moku</binary>
</provides>
<content_rating type="oars-1.1" />
<releases>
<release version="0.9.0" date="2025-04-01">
<description>
<p>Latest release with improved stability and UI refinements.</p>
</description>
</release>
<release version="0.8.0" date="2025-04-01">
<description>
<p>Old release with improved stability and UI refinements.</p>
</description>
</release>
<release version="0.4.0" date="2025-03-22">
<description>
<p>Svelte rewrite with improved UI, bundled server, and cross-platform fixes.</p>
</description>
</release>
</releases>
</component>
+2207 -1118
View File
File diff suppressed because it is too large Load Diff
-2
View File
@@ -1,2 +0,0 @@
onlyBuiltDependencies:
- esbuild
+671 -1486
View File
File diff suppressed because it is too large Load Diff
+13 -28
View File
@@ -1,11 +1,11 @@
[package]
name = "moku"
version = "0.9.4"
version = "0.3.0"
edition = "2021"
[lib]
name = "moku_lib"
crate-type = ["cdylib", "rlib"]
crate-type = ["staticlib", "cdylib", "rlib"]
[[bin]]
name = "moku"
@@ -15,32 +15,17 @@ path = "src/main.rs"
tauri-build = { version = "2.0", features = [] }
[dependencies]
tauri = { version = "2.0", features = ["tray-icon"] }
tauri-plugin-shell = "2"
tauri-plugin-process = "2"
tauri-plugin-http = "2"
tauri-plugin-dialog = "2"
tauri-plugin-os = "2.3.2"
tauri-plugin-store = "2"
tauri-plugin-discord-rpc = { git = "https://github.com/Youwes09/tauri-plugin-discord-rpc" }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
walkdir = "2"
sysinfo = "0.32"
dirs = "5"
urlencoding = "2"
tokio = { version = "1", features = ["rt-multi-thread"] }
reqwest = { version = "0.12", features = ["blocking"] }
[target.'cfg(target_os = "windows")'.dependencies]
windows = { version = "0.58", features = [
"Security_Credentials_UI",
"Win32_UI_WindowsAndMessaging",
] }
tauri = { version = "2.0", features = [] }
tauri-plugin-shell = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
walkdir = "2"
sysinfo = "0.32"
dirs = "5"
[profile.release]
codegen-units = 1
lto = true
opt-level = "s"
panic = "abort"
strip = true
lto = true
opt-level = "s"
panic = "abort"
strip = true
@@ -1,112 +0,0 @@
#!/bin/sh
# — Suwayomi launcher for Linux AppImage/deb.
# Tauri resolves this via resolve_server_binary() in lib.rs, which looks for
# "suwayomi-launcher" or "suwayomi-launcher.sh" in the resource directory.
set -e
# ── Locate our resource directory ─────────────────────────────────────────────
# In an AppImage: resources sit at <mountpoint>/resources/
# In a deb install: /usr/lib/moku/resources/ (Tauri's default)
# We resolve relative to this script's own location.
SELF="$0"
while [ -L "$SELF" ]; do
SELF="$(readlink "$SELF")"
done
SCRIPT_DIR="$(cd "$(dirname "$SELF")" && pwd)"
# Tauri places resources one level up from the binary on Linux.
# Try a few candidates so this works in both AppImage and installed layouts.
find_resource() {
for candidate in \
"${SCRIPT_DIR}" \
"${SCRIPT_DIR}/../resources" \
"${SCRIPT_DIR}/resources"
do
if [ -f "${candidate}/Suwayomi-Server.jar" ]; then
echo "$(cd "$candidate" && pwd)"
return 0
fi
done
return 1
}
RESOURCE_DIR=$(find_resource) || {
echo "[launcher] ERROR: cannot locate Suwayomi-Server.jar relative to $SCRIPT_DIR" >&2
exit 1
}
JAR="${RESOURCE_DIR}/Suwayomi-Server.jar"
JAVA="${RESOURCE_DIR}/jre/bin/java"
CATCH_ABORT="${RESOURCE_DIR}/catch_abort.so"
echo "[launcher] RESOURCE_DIR=$RESOURCE_DIR" >&2
echo "[launcher] JAVA=$JAVA" >&2
echo "[launcher] JAR=$JAR" >&2
if [ ! -x "$JAVA" ]; then
echo "[launcher] ERROR: java not executable at $JAVA" >&2
exit 1
fi
if [ ! -f "$JAR" ]; then
echo "[launcher] ERROR: jar not found at $JAR" >&2
exit 1
fi
# ── Data directory ─────────────────────────────────────────────────────────────
DATA_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/Tachidesk"
mkdir -p "$DATA_DIR"
# ── Seed server.conf on first run ──────────────────────────────────────────────
if [ ! -f "$DATA_DIR/server.conf" ]; then
cat > "$DATA_DIR/server.conf" << 'EOF'
server.ip = "127.0.0.1"
server.port = 4567
server.webUIEnabled = true
server.initialOpenInBrowserEnabled = false
server.systemTrayEnabled = false
server.webUIInterface = "browser"
server.webUIFlavor = "WebUI"
server.webUIChannel = "PREVIEW"
server.electronPath = ""
server.debugLogsEnabled = false
server.downloadAsCbz = true
server.autoDownloadNewChapters = false
server.globalUpdateInterval = 12
server.maxSourcesInParallel = 6
server.extensionRepos = []
EOF
fi
# ── Force-patch the three keys that cause JCEF/GUI crashes ────────────────────
sed -i \
-e 's|server\.webUIEnabled.*|server.webUIEnabled = true|' \
-e 's|server\.initialOpenInBrowserEnabled.*|server.initialOpenInBrowserEnabled = false|' \
-e 's|server\.systemTrayEnabled.*|server.systemTrayEnabled = false|' \
"$DATA_DIR/server.conf"
# Append keys if absent (e.g. user-managed conf missing them)
grep -q 'server\.webUIEnabled' "$DATA_DIR/server.conf" || echo 'server.webUIEnabled = true' >> "$DATA_DIR/server.conf"
grep -q 'server\.initialOpenInBrowserEnabled' "$DATA_DIR/server.conf" || echo 'server.initialOpenInBrowserEnabled = false' >> "$DATA_DIR/server.conf"
grep -q 'server\.systemTrayEnabled' "$DATA_DIR/server.conf" || echo 'server.systemTrayEnabled = false' >> "$DATA_DIR/server.conf"
# ── Suppress any GUI environment that would confuse the JVM ───────────────────
unset DISPLAY
unset WAYLAND_DISPLAY
export _JAVA_OPTIONS="-Djava.awt.headless=true"
export JAVA_TOOL_OPTIONS="-Djava.awt.headless=true"
# ── LD_PRELOAD catch_abort.so if present ──────────────────────────────────────
# Catches SIGTRAP/SIGILL from KCEF/Webview so a bad extension can't
# bring down the whole server process (mirrors the Flatpak build).
if [ -f "$CATCH_ABORT" ]; then
export LD_PRELOAD="${CATCH_ABORT}${LD_PRELOAD:+:$LD_PRELOAD}"
fi
exec "$JAVA" \
-Djava.awt.headless=true \
-Dapple.awt.UIElement=true \
-Dsun.java2d.noddraw=true \
-Dsun.awt.disablegui=true \
-Dsuwayomi.tachidesk.config.server.rootDir="$DATA_DIR" \
-jar "$JAR"
-66
View File
@@ -1,66 +0,0 @@
#!/bin/sh
# Moku — Suwayomi launcher sidecar for macOS.
# Tauri calls this script directly as a sidecar (Contents/MacOS/suwayomi-server-{arch}).
# The Suwayomi bundle is placed by Tauri into Contents/Resources/suwayomi-bundle/.
set -e
# Resolve the real directory of this script, following symlinks.
SELF="$0"
while [ -L "$SELF" ]; do
SELF="$(readlink "$SELF")"
done
DIR="$(cd "$(dirname "$SELF")" && pwd)"
# ── Locate the bundle ─────────────────────────────────────────────────────────
# Inside .app: sidecar = Contents/MacOS/suwayomi-server-{arch}
# bundle = Contents/Resources/suwayomi-bundle/
# Dev / flat layout: bundle sits next to the sidecar, or one level up.
find_bundle() {
local base="$1"
for candidate in \
"${base}/../Resources/suwayomi-bundle" \
"${base}/suwayomi-bundle" \
"${base}/../suwayomi-bundle"
do
# The jar lives at <bundle>/bin/Suwayomi-Server.jar
if [ -f "${candidate}/bin/Suwayomi-Server.jar" ]; then
# Canonicalise (no readlink -f on older macOS sh, use cd trick)
echo "$(cd "$candidate" && pwd)"
return 0
fi
done
return 1
}
BUNDLE=$(find_bundle "$DIR") || {
echo "[sidecar] ERROR: cannot locate suwayomi-bundle relative to $DIR" >&2
echo "[sidecar] Tried:" >&2
echo " $DIR/../Resources/suwayomi-bundle" >&2
echo " $DIR/suwayomi-bundle" >&2
echo " $DIR/../suwayomi-bundle" >&2
exit 1
}
JAVA="${BUNDLE}/jre/bin/java"
JAR="${BUNDLE}/bin/Suwayomi-Server.jar"
echo "[sidecar] BUNDLE=$BUNDLE" >&2
echo "[sidecar] JAVA=$JAVA" >&2
echo "[sidecar] JAR=$JAR" >&2
if [ ! -x "$JAVA" ]; then
echo "[sidecar] ERROR: java not executable at $JAVA" >&2
exit 1
fi
if [ ! -f "$JAR" ]; then
echo "[sidecar] ERROR: jar not found at $JAR" >&2
exit 1
fi
# "$@" will contain the -Dsuwayomi.tachidesk.config.server.rootDir=... flag
# prepended by spawn_server in lib.rs, followed by -jar <path>.
# We call java directly so all JVM flags reach it properly.
exec "$JAVA" \
-Djava.awt.headless=true \
"$@" \
-jar "$JAR"
+11 -41
View File
@@ -1,50 +1,20 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Default permissions for Moku",
"windows": [
"main"
],
"description": "Allow launching suwayomi-server sidecar",
"windows": ["main"],
"permissions": [
"core:default",
"core:tray:default",
"core:app:allow-default-window-icon",
"core:window:allow-hide",
"core:window:allow-show",
"shell:allow-open",
"shell:allow-kill",
"shell:allow-spawn",
"shell:allow-execute",
"core:window:allow-minimize",
"core:window:allow-unminimize",
"core:window:allow-maximize",
"core:window:allow-unmaximize",
"core:window:allow-toggle-maximize",
"core:window:allow-close",
"core:window:allow-start-dragging",
"core:window:allow-set-focus",
"core:window:allow-set-fullscreen",
"core:window:allow-is-fullscreen",
"core:window:allow-is-maximized",
"core:window:allow-is-minimized",
"core:window:allow-inner-size",
"core:window:allow-outer-size",
"core:window:allow-inner-position",
"core:window:allow-outer-position",
"core:window:allow-scale-factor",
"process:default",
"process:allow-exit",
"process:allow-restart",
"http:default",
"http:allow-fetch",
"store:default",
"discord-rpc:default",
"discord-rpc:allow-connect",
"discord-rpc:allow-disconnect",
"discord-rpc:allow-set-activity",
"discord-rpc:allow-clear-activity",
"discord-rpc:allow-is-running",
"dialog:default",
"dialog:allow-open"
{
"identifier": "shell:allow-spawn",
"allow": [
{
"name": "binaries/suwayomi-server",
"sidecar": true
}
]
}
]
}
-17
View File
@@ -1,17 +0,0 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "http-scope",
"description": "HTTP fetch scope",
"windows": ["main"],
"permissions": [
{
"identifier": "http:default",
"allow": [
{ "url": "http://*:*/*" },
{ "url": "https://*:*/*" },
{ "url": "http://*/*" },
{ "url": "https://*/*" }
]
}
]
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.2 KiB

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 740 B

After

Width:  |  Height:  |  Size: 803 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 706 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 7.8 KiB

-100
View File
@@ -1,100 +0,0 @@
use std::path::PathBuf;
use tauri::Manager;
fn backup_dir(app: &tauri::AppHandle) -> PathBuf {
app.path()
.app_data_dir()
.unwrap_or_else(|_| PathBuf::from("."))
.join("backups")
}
fn unix_now() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
#[tauri::command]
pub async fn export_app_data(app: tauri::AppHandle, bytes: Vec<u8>) -> Result<(), String> {
use tauri_plugin_dialog::DialogExt;
let filename = format!("moku-backup-{}.zip", unix_now());
let path = app
.dialog()
.file()
.set_title("Save Moku app data backup")
.set_file_name(&filename)
.add_filter("Moku Backup", &["zip"])
.blocking_save_file()
.ok_or("Cancelled")?;
std::fs::write(PathBuf::from(path.to_string()), &bytes).map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn import_app_data(app: tauri::AppHandle) -> Result<Vec<u8>, String> {
use tauri_plugin_dialog::DialogExt;
let path = app
.dialog()
.file()
.set_title("Open Moku app data backup")
.add_filter("Moku Backup", &["zip"])
.blocking_pick_file()
.ok_or("Cancelled")?;
std::fs::read(PathBuf::from(path.to_string())).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn auto_backup_app_data(app: tauri::AppHandle, bytes: Vec<u8>) -> Result<(), String> {
let dir = backup_dir(&app);
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
let dest = dir.join(format!("auto-moku-backup-{}.zip", unix_now()));
std::fs::write(&dest, &bytes).map_err(|e| e.to_string())?;
let mut entries: Vec<_> = std::fs::read_dir(&dir)
.map_err(|e| e.to_string())?
.filter_map(|e| e.ok())
.filter(|e| {
e.file_name()
.to_string_lossy()
.starts_with("auto-moku-backup-")
})
.collect();
entries.sort_by_key(|e| e.file_name());
for old in entries.iter().take(entries.len().saturating_sub(5)) {
let _ = std::fs::remove_file(old.path());
}
Ok(())
}
#[tauri::command]
pub fn get_auto_backup_dir(app: tauri::AppHandle) -> String {
let dir = backup_dir(&app);
let _ = std::fs::create_dir_all(&dir);
dir.to_string_lossy().into_owned()
}
#[tauri::command]
pub fn read_store_files(app: tauri::AppHandle, names: Vec<String>) -> Vec<(String, String)> {
let base = app
.path()
.app_local_data_dir()
.unwrap_or_else(|_| PathBuf::from("."));
names
.into_iter()
.map(|name| {
let content = std::fs::read_to_string(base.join(&name))
.unwrap_or_else(|_| "{}".to_string());
(name, content)
})
.collect()
}
-103
View File
@@ -1,103 +0,0 @@
#[cfg(target_os = "windows")]
mod windows_hello {
use windows::{
core::HSTRING,
Security::Credentials::UI::{UserConsentVerificationResult, UserConsentVerifier},
Win32::UI::WindowsAndMessaging::{
BringWindowToTop, FindWindowW, IsIconic, SetForegroundWindow, ShowWindow, SW_RESTORE,
},
};
fn to_wide(s: &str) -> Vec<u16> {
use std::os::windows::ffi::OsStrExt;
std::ffi::OsStr::new(s)
.encode_wide()
.chain(std::iter::once(0))
.collect()
}
fn try_focus_hello_dialog() -> bool {
let cls = to_wide("Credential Dialog Xaml Host");
unsafe {
let Ok(hwnd) = FindWindowW(
windows::core::PCWSTR(cls.as_ptr()),
windows::core::PCWSTR::null(),
) else {
return false;
};
if IsIconic(hwnd).as_bool() {
let _ = ShowWindow(hwnd, SW_RESTORE);
}
let _ = BringWindowToTop(hwnd);
let _ = SetForegroundWindow(hwnd);
true
}
}
fn nudge_focus(retries: u32, delay_ms: u64) {
std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_millis(delay_ms));
for _ in 0..retries {
if try_focus_hello_dialog() {
break;
}
std::thread::sleep(std::time::Duration::from_millis(delay_ms));
}
});
}
pub fn authenticate(reason: &str) -> Result<(), String> {
let reason = reason.to_owned();
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
nudge_focus(5, 250);
let outcome = UserConsentVerifier::RequestVerificationAsync(&HSTRING::from(reason.as_str()))
.and_then(|op| {
nudge_focus(5, 250);
op.get()
});
let _ = tx.send(outcome);
});
let result = rx
.recv()
.map_err(|e| format!("internalError:{e:?}"))?
.map_err(|e| format!("internalError:{e:?}"))?;
match result {
UserConsentVerificationResult::Verified => Ok(()),
UserConsentVerificationResult::Canceled => Err("userCancel".into()),
UserConsentVerificationResult::RetriesExhausted => Err("biometryLockout".into()),
UserConsentVerificationResult::DeviceBusy => Err("systemCancel".into()),
UserConsentVerificationResult::DeviceNotPresent => Err("biometryNotAvailable".into()),
UserConsentVerificationResult::DisabledByPolicy => Err("biometryNotAvailable".into()),
UserConsentVerificationResult::NotConfiguredForUser => Err("biometryNotEnrolled".into()),
_ => Err("authenticationFailed".into()),
}
}
pub fn is_available() -> bool {
use windows::Security::Credentials::UI::UserConsentVerifierAvailability;
UserConsentVerifier::CheckAvailabilityAsync()
.and_then(|op| op.get())
.map(|a| a == UserConsentVerifierAvailability::Available)
.unwrap_or(false)
}
}
#[tauri::command]
pub fn windows_hello_authenticate(_reason: String) -> Result<(), String> {
#[cfg(target_os = "windows")]
return windows_hello::authenticate(&_reason);
#[cfg(not(target_os = "windows"))]
Err("notSupported".into())
}
#[tauri::command]
pub fn windows_hello_available() -> bool {
#[cfg(target_os = "windows")]
return windows_hello::is_available();
#[cfg(not(target_os = "windows"))]
false
}
-6
View File
@@ -1,6 +0,0 @@
pub mod backup;
pub mod biometric;
pub mod server;
pub mod storage;
pub mod system;
pub mod updater;
-97
View File
@@ -1,97 +0,0 @@
use crate::server::{self, resolve::suwayomi_data_dir, SpawnError};
use crate::ServerState;
use tauri::Manager;
#[tauri::command]
pub fn spawn_server(
binary: String,
binary_args: Option<String>,
web_ui_enabled: bool,
app: tauri::AppHandle,
) -> Result<(), SpawnError> {
{
let state = app.state::<ServerState>();
if state.0.lock().unwrap().is_some() {
return Ok(());
}
}
let data_dir = suwayomi_data_dir();
let log_path = data_dir.join("moku-spawn.log");
let _ = std::fs::create_dir_all(&data_dir);
let mut log = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&log_path)
.ok();
let binary_args = binary_args.unwrap_or_default();
server::do_log(
&mut log,
&format!(
"[spawn_server] binary={:?} binary_args={:?} web_ui_enabled={} data_dir={:?}",
binary, binary_args, web_ui_enabled, data_dir
),
);
server::conf::seed_server_conf(&data_dir, web_ui_enabled);
let mut invocation =
server::resolve::resolve_server_binary(&binary, &app, &mut log).map_err(|e| {
server::do_log(&mut log, &format!("[spawn_server] resolve failed: {:?}", e));
e
})?;
if !binary_args.trim().is_empty() {
let extra: Vec<String> = binary_args.split_whitespace().map(|s| s.to_string()).collect();
let mut merged = extra;
merged.extend(invocation.args);
invocation.args = merged;
}
if invocation.bin.ends_with("java") || invocation.bin.ends_with("java.exe") {
let rootdir_flag = format!(
"-Dsuwayomi.tachidesk.config.server.rootDir={}",
data_dir.to_string_lossy()
);
invocation.args.insert(0, rootdir_flag);
}
let working_dir = invocation
.working_dir
.unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
server::do_log(
&mut log,
&format!(
"[spawn_server] bin={:?} args={:?} cwd={:?}",
invocation.bin, invocation.args, working_dir
),
);
use tauri_plugin_shell::ShellExt;
let cmd = app
.shell()
.command(&invocation.bin)
.env("JAVA_TOOL_OPTIONS", "-Djava.awt.headless=true")
.args(&invocation.args)
.current_dir(&working_dir);
match cmd.spawn() {
Ok((_rx, child)) => {
*app.state::<ServerState>().0.lock().unwrap() = Some(child);
Ok(())
}
Err(e) => {
server::do_log(&mut log, &format!("[spawn_server] spawn failed: {}", e));
Err(SpawnError::SpawnFailed(e.to_string()))
}
}
}
#[tauri::command]
pub fn kill_server(app: tauri::AppHandle) -> Result<(), String> {
server::kill_tachidesk(&app);
Ok(())
}
-178
View File
@@ -1,178 +0,0 @@
use serde::Serialize;
use std::path::PathBuf;
use sysinfo::Disks;
use tauri::Emitter;
use tauri_plugin_store::StoreExt;
use walkdir::WalkDir;
use crate::server::resolve::suwayomi_data_dir;
// ── Key-value store (used by the frontend via platformService) ────────────────
#[tauri::command]
pub fn load_store(app: tauri::AppHandle, key: String) -> Result<Option<String>, String> {
let store = app
.store(format!("{}.json", key))
.map_err(|e| e.to_string())?;
let value = store.get(&key);
Ok(value.map(|v| v.to_string()))
}
#[tauri::command]
pub fn save_store(app: tauri::AppHandle, key: String, value: String) -> Result<(), String> {
let store = app
.store(format!("{}.json", key))
.map_err(|e| e.to_string())?;
let parsed: serde_json::Value =
serde_json::from_str(&value).map_err(|e| e.to_string())?;
store.set(key, parsed);
store.save().map_err(|e| e.to_string())
}
// ── Credential store (PIN-encrypted vault, auth tokens) ──────────────────────
#[tauri::command]
pub fn store_credential(app: tauri::AppHandle, key: String, value: String) -> Result<(), String> {
let store = app
.store("credentials.json")
.map_err(|e| e.to_string())?;
if value.is_empty() {
store.delete(&key);
} else {
store.set(&key, serde_json::Value::String(value));
}
store.save().map_err(|e| e.to_string())
}
#[tauri::command]
pub fn get_credential(app: tauri::AppHandle, key: String) -> Result<Option<String>, String> {
let store = app
.store("credentials.json")
.map_err(|e| e.to_string())?;
Ok(store.get(&key).and_then(|v| v.as_str().map(|s| s.to_owned())))
}
// ── Disk / downloads storage ─────────────────────────────────────────────────
#[derive(Serialize)]
pub struct StorageInfo {
pub manga_bytes: u64,
pub total_bytes: u64,
pub free_bytes: u64,
pub path: String,
}
fn resolve_downloads_path(downloads_path: &str) -> PathBuf {
if !downloads_path.trim().is_empty() {
return PathBuf::from(downloads_path.trim());
}
suwayomi_data_dir().join("downloads")
}
#[tauri::command]
pub fn get_storage_info(downloads_path: String) -> Result<StorageInfo, String> {
let path = resolve_downloads_path(&downloads_path);
let manga_bytes = if path.exists() {
WalkDir::new(&path)
.into_iter()
.filter_map(|e| e.ok())
.filter_map(|e| e.metadata().ok())
.filter(|m| m.is_file())
.map(|m| m.len())
.sum()
} else {
0
};
let stat_path = if path.exists() {
path.clone()
} else {
dirs::home_dir().unwrap_or_else(|| PathBuf::from("/"))
};
let disks = Disks::new_with_refreshed_list();
let disk = disks
.iter()
.filter(|d| stat_path.starts_with(d.mount_point()))
.max_by_key(|d| d.mount_point().as_os_str().len())
.ok_or_else(|| "Could not find disk for path".to_string())?;
Ok(StorageInfo {
manga_bytes,
total_bytes: disk.total_space(),
free_bytes: disk.available_space(),
path: path.to_string_lossy().into_owned(),
})
}
#[tauri::command]
pub fn get_default_downloads_path() -> String {
resolve_downloads_path("").to_string_lossy().into_owned()
}
#[tauri::command]
pub fn check_path_exists(path: String) -> bool {
std::path::Path::new(path.trim()).is_dir()
}
#[tauri::command]
pub fn create_directory(path: String) -> Result<(), String> {
std::fs::create_dir_all(path.trim()).map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn migrate_downloads(
app: tauri::AppHandle,
src: String,
dst: String,
) -> Result<(), String> {
use std::fs;
let src_path = PathBuf::from(src.trim());
let dst_path = PathBuf::from(dst.trim());
if !src_path.is_dir() {
return Ok(());
}
let total: u64 = WalkDir::new(&src_path)
.into_iter()
.filter_map(|e| e.ok())
.filter(|e| e.file_type().is_file())
.count() as u64;
let _ = app.emit(
"migrate_progress",
serde_json::json!({ "done": 0u64, "total": total, "current": "" }),
);
let mut done: u64 = 0;
for entry in WalkDir::new(&src_path).into_iter().filter_map(|e| e.ok()) {
let rel = entry
.path()
.strip_prefix(&src_path)
.map_err(|e| e.to_string())?;
let target = dst_path.join(rel);
if entry.file_type().is_dir() {
fs::create_dir_all(&target).map_err(|e| e.to_string())?;
} else {
if let Some(parent) = target.parent() {
fs::create_dir_all(parent).map_err(|e| e.to_string())?;
}
fs::copy(entry.path(), &target).map_err(|e| e.to_string())?;
done += 1;
let _ = app.emit(
"migrate_progress",
serde_json::json!({
"done": done, "total": total, "current": rel.to_string_lossy()
}),
);
}
}
fs::remove_dir_all(&src_path).map_err(|e| e.to_string())?;
Ok(())
}
-192
View File
@@ -1,192 +0,0 @@
#[cfg(target_os = "windows")]
use crate::server::resolve::strip_unc;
use std::path::PathBuf;
use tauri::Manager;
#[tauri::command]
pub fn get_platform_ui_scale(window: tauri::Window) -> f64 {
window.scale_factor().unwrap_or(1.0)
}
#[tauri::command]
pub fn restart_app(app: tauri::AppHandle) {
tauri::process::restart(&app.env());
}
#[tauri::command]
pub fn open_path(path: String) -> Result<(), String> {
#[cfg(target_os = "windows")]
{
let p = strip_unc(PathBuf::from(path.trim()));
std::process::Command::new("explorer")
.arg(p)
.spawn()
.map_err(|e| e.to_string())?;
}
#[cfg(target_os = "macos")]
{
let p = std::path::Path::new(path.trim());
std::process::Command::new("open")
.arg(p)
.spawn()
.map_err(|e| e.to_string())?;
}
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
{
let p = std::path::Path::new(path.trim());
std::process::Command::new("xdg-open")
.arg(p)
.spawn()
.map_err(|e| e.to_string())?;
}
Ok(())
}
#[tauri::command]
pub async fn pick_downloads_folder(app: tauri::AppHandle) -> Option<String> {
use tauri_plugin_dialog::DialogExt;
app.dialog()
.file()
.set_title("Choose Downloads Folder")
.blocking_pick_folder()
.map(|p| p.to_string())
}
#[tauri::command]
pub async fn pick_server_binary(app: tauri::AppHandle) -> Option<String> {
use tauri_plugin_dialog::DialogExt;
#[cfg(target_os = "windows")]
let dialog = app
.dialog()
.file()
.set_title("Choose Server Binary")
.add_filter("Executable", &["exe", "jar", "bat", "cmd"]);
#[cfg(target_os = "macos")]
let dialog = app
.dialog()
.file()
.set_title("Choose Server Binary")
.add_filter("Executable or JAR", &["jar", "command", "sh", "app"]);
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
let dialog = app
.dialog()
.file()
.set_title("Choose Server Binary")
.add_filter("Executable or JAR", &["jar", "sh"]);
dialog.blocking_pick_file().map(|p| p.to_string())
}
#[tauri::command]
pub fn exit_app(app: tauri::AppHandle) {
app.exit(0);
}
fn remove_dir_best_effort(path: &std::path::Path) {
if path.is_file() {
if let Err(e) = std::fs::remove_file(path) {
if e.raw_os_error() == Some(32) {
return;
}
}
} else if path.is_dir() {
if let Ok(entries) = std::fs::read_dir(path) {
for entry in entries.flatten() {
remove_dir_best_effort(&entry.path());
}
}
let _ = std::fs::remove_dir(path);
}
}
fn wait_until_deletable(path: &std::path::Path, timeout_secs: u64) -> bool {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout_secs);
while std::time::Instant::now() < deadline {
let locked = if path.is_file() {
std::fs::OpenOptions::new().write(true).open(path).is_err()
} else if path.is_dir() {
std::fs::read_dir(path).is_err()
} else {
return true;
};
if !locked {
return true;
}
std::thread::sleep(std::time::Duration::from_millis(200));
}
false
}
#[tauri::command]
pub async fn clear_moku_cache(app: tauri::AppHandle) -> Result<(), String> {
let window = app.get_webview_window("main").ok_or("no main window")?;
let (tx, rx) = tokio::sync::oneshot::channel::<Result<(), String>>();
window
.with_webview(move |_wv| {
let _ = tx.send(Ok(()));
})
.map_err(|e| e.to_string())?;
rx.await.map_err(|e| e.to_string())??;
let cache_dir = app.path().app_cache_dir().map_err(|e| e.to_string())?;
if cache_dir.exists() {
wait_until_deletable(&cache_dir, 3);
remove_dir_best_effort(&cache_dir);
std::fs::create_dir_all(&cache_dir).map_err(|e| e.to_string())?;
}
Ok(())
}
#[tauri::command]
pub fn clear_suwayomi_cache() -> Result<(), String> {
use crate::server::resolve::suwayomi_data_dir;
let data_dir = suwayomi_data_dir();
for dir in &["cache/kcef", "logs"] {
let p = data_dir.join(dir);
if p.exists() {
remove_dir_best_effort(&p);
}
}
for dir in &["downloads/thumbnails"] {
let p = data_dir.join(dir);
if p.exists() {
remove_dir_best_effort(&p);
let _ = std::fs::create_dir_all(&p);
}
}
Ok(())
}
#[tauri::command]
pub fn reset_suwayomi_data(app: tauri::AppHandle) -> Result<(), String> {
use crate::server::resolve::suwayomi_data_dir;
crate::server::kill_tachidesk(&app);
let data_dir = suwayomi_data_dir();
let targets = ["database.mv.db", "extensions", "settings", "logs", "local"];
for entry_name in &targets {
let p = data_dir.join(entry_name);
if p.exists() {
wait_until_deletable(&p, 10);
}
}
for entry_name in &targets {
let p = data_dir.join(entry_name);
if p.is_dir() {
std::fs::remove_dir_all(&p).map_err(|e| format!("{entry_name}: {e}"))?;
} else if p.exists() {
std::fs::remove_file(&p).map_err(|e| format!("{entry_name}: {e}"))?;
}
}
Ok(())
}
-150
View File
@@ -1,150 +0,0 @@
use serde::{Deserialize, Serialize};
#[derive(Serialize, Clone)]
pub struct ReleaseInfo {
pub tag_name: String,
pub name: String,
pub body: String,
pub published_at: String,
pub html_url: String,
}
#[derive(Clone, Serialize)]
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
struct UpdateProgress {
downloaded: u64,
total: Option<u64>,
}
#[tauri::command]
pub async fn list_releases() -> Result<Vec<ReleaseInfo>, String> {
use tauri_plugin_http::reqwest;
#[derive(Deserialize)]
struct GhRelease {
tag_name: String,
name: Option<String>,
body: Option<String>,
published_at: Option<String>,
html_url: String,
}
let client = reqwest::Client::builder()
.user_agent("Moku")
.build()
.map_err(|e| e.to_string())?;
let resp = client
.get("https://api.github.com/repos/moku-project/Moku/releases?per_page=30")
.send()
.await
.map_err(|e| e.to_string())?;
if !resp.status().is_success() {
return Err(format!("GitHub API returned {}", resp.status()));
}
let releases: Vec<GhRelease> =
serde_json::from_str(&resp.text().await.map_err(|e| e.to_string())?)
.map_err(|e| e.to_string())?;
Ok(releases
.into_iter()
.map(|r| ReleaseInfo {
tag_name: r.tag_name.clone(),
name: r.name.unwrap_or_else(|| r.tag_name.clone()),
body: r.body.unwrap_or_default(),
published_at: r.published_at.unwrap_or_default(),
html_url: r.html_url,
})
.collect())
}
#[tauri::command]
#[allow(unused_variables)]
pub async fn download_and_install_update(app: tauri::AppHandle, tag: String) -> Result<(), String> {
#[cfg(not(target_os = "windows"))]
return Err("Native install is Windows-only; open the GitHub release page instead.".into());
#[cfg(target_os = "windows")]
{
use std::io::Write;
use tauri::Emitter;
use tauri_plugin_http::reqwest;
#[derive(Deserialize)]
struct Asset {
name: String,
browser_download_url: String,
size: u64,
}
#[derive(Deserialize)]
struct Release {
assets: Vec<Asset>,
}
let client = reqwest::Client::builder()
.user_agent("Moku")
.build()
.map_err(|e| e.to_string())?;
let resp = client
.get(format!(
"https://api.github.com/repos/moku-project/Moku/releases/tags/{}",
tag
))
.send()
.await
.map_err(|e| e.to_string())?;
if !resp.status().is_success() {
return Err(format!(
"GitHub API returned {} for tag {}",
resp.status(),
tag
));
}
let release: Release = serde_json::from_str(&resp.text().await.map_err(|e| e.to_string())?)
.map_err(|e| e.to_string())?;
let asset = release
.assets
.into_iter()
.find(|a| a.name.ends_with("_x64-setup.exe"))
.ok_or_else(|| format!("No x64-setup.exe asset found in release {}", tag))?;
let total = if asset.size > 0 {
Some(asset.size)
} else {
None
};
let mut resp = client
.get(&asset.browser_download_url)
.send()
.await
.map_err(|e| e.to_string())?;
let tmp_path = std::env::temp_dir().join(&asset.name);
let mut file = std::fs::File::create(&tmp_path).map_err(|e| e.to_string())?;
let mut downloaded: u64 = 0;
while let Some(chunk) = resp.chunk().await.map_err(|e| e.to_string())? {
file.write_all(&chunk).map_err(|e| e.to_string())?;
downloaded += chunk.len() as u64;
let _ = app.emit("update-progress", UpdateProgress { downloaded, total });
}
drop(file);
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x08000000;
std::process::Command::new(&tmp_path)
.creation_flags(CREATE_NO_WINDOW)
.spawn()
.map_err(|e| e.to_string())?;
let _ = app.emit("update-launching", ());
Ok(())
}
}
+323 -138
View File
@@ -1,168 +1,353 @@
mod commands;
mod server;
use std::path::PathBuf;
use std::sync::Mutex;
use std::io::{Read, Write};
use std::net::{TcpListener, TcpStream};
use tauri::{
menu::{Menu, MenuItem, PredefinedMenuItem},
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
Manager, WindowEvent,
};
use tauri_plugin_shell::process::CommandChild;
use sysinfo::Disks;
use serde::Serialize;
use tauri::{Manager, WindowEvent};
use tauri_plugin_shell::{ShellExt, process::CommandChild};
use walkdir::WalkDir;
pub struct ServerState(pub Mutex<Option<CommandChild>>);
struct ServerState(Mutex<Option<CommandChild>>);
const IPC_PORT: u16 = 47823;
const HANDSHAKE: &[u8] = b"MOKU:1\n";
const FOCUS_CMD: &[u8] = b"focus\n";
fn do_quit(app: &tauri::AppHandle) {
server::kill_tachidesk(app);
app.exit(0);
#[derive(Serialize)]
pub struct StorageInfo {
manga_bytes: u64,
total_bytes: u64,
free_bytes: u64,
path: String,
}
fn start_instance_listener(app: tauri::AppHandle) {
std::thread::spawn(move || {
let Ok(listener) = TcpListener::bind(("127.0.0.1", IPC_PORT)) else {
fn resolve_downloads_path(downloads_path: &str) -> PathBuf {
if !downloads_path.trim().is_empty() {
return PathBuf::from(downloads_path);
}
let base = std::env::var("XDG_DATA_HOME")
.map(PathBuf::from)
.unwrap_or_else(|_| {
dirs::data_dir()
.unwrap_or_else(|| PathBuf::from("/"))
});
base.join("Tachidesk/downloads")
}
#[tauri::command]
fn get_storage_info(downloads_path: String) -> Result<StorageInfo, String> {
let path = resolve_downloads_path(&downloads_path);
let manga_bytes = if path.exists() {
WalkDir::new(&path)
.into_iter()
.filter_map(|e| e.ok())
.filter_map(|e| e.metadata().ok())
.filter(|m| m.is_file())
.map(|m| m.len())
.sum()
} else {
0
};
let stat_path = if path.exists() { path.clone() } else {
dirs::home_dir().unwrap_or_else(|| PathBuf::from("/"))
};
let disks = Disks::new_with_refreshed_list();
let disk = disks
.iter()
.filter(|d| stat_path.starts_with(d.mount_point()))
.max_by_key(|d| d.mount_point().as_os_str().len())
.ok_or_else(|| "Could not find disk for path".to_string())?;
let total_bytes = disk.total_space();
let free_bytes = disk.available_space();
Ok(StorageInfo {
manga_bytes,
total_bytes,
free_bytes,
path: path.to_string_lossy().into_owned(),
})
}
/// Returns the true OS-level scale factor for the main window.
/// On Linux this bypasses WebKitGTK's unreliable devicePixelRatio.
/// On macOS the value comes directly from the native window.
#[tauri::command]
fn get_scale_factor(window: tauri::Window) -> f64 {
window.scale_factor().unwrap_or(1.0)
}
fn kill_tachidesk(app: &tauri::AppHandle) {
let state = app.state::<ServerState>();
let mut guard = state.0.lock().unwrap();
if let Some(child) = guard.take() {
let _ = child.kill();
println!("Killed tracked server child.");
}
#[cfg(target_os = "windows")]
let _ = std::process::Command::new("taskkill")
.args(["/F", "/FI", "IMAGENAME eq tachidesk*"])
.status();
#[cfg(not(target_os = "windows"))]
let _ = std::process::Command::new("pkill")
.arg("-f")
.arg("tachidesk")
.status();
}
/// The default server.conf we seed on first launch.
/// Mirrors the Flatpak wrapper: headless, no tray, no browser pop-up.
const DEFAULT_SERVER_CONF: &str = r#"server.ip = "127.0.0.1"
server.port = 4567
server.webUIEnabled = false
server.initialOpenInBrowserEnabled = false
server.systemTrayEnabled = false
server.webUIInterface = "browser"
server.webUIFlavor = "WebUI"
server.webUIChannel = "stable"
server.electronPath = ""
server.debugLogsEnabled = false
server.downloadAsCbz = true
server.autoDownloadNewChapters = false
server.globalUpdateInterval = 12
server.maxSourcesInParallel = 6
server.extensionRepos = []
"#;
/// Ensure the Suwayomi data dir and server.conf exist, and that the three
/// keys that cause GUI/JCEF crashes are always set to safe values.
/// This mirrors the shell-script logic in the Flatpak's tachidesk-server wrapper.
fn seed_server_conf(data_dir: &PathBuf) {
let conf_path = data_dir.join("server.conf");
if !conf_path.exists() {
if let Err(e) = std::fs::create_dir_all(data_dir) {
eprintln!("Could not create Suwayomi data dir: {e}");
return;
};
for stream in listener.incoming().flatten() {
handle_ipc_connection(stream, &app);
}
});
}
fn handle_ipc_connection(mut stream: TcpStream, app: &tauri::AppHandle) {
let mut buf = [0u8; 32];
let Ok(n) = stream.read(&mut buf) else { return };
let msg = &buf[..n];
if !msg.starts_with(HANDSHAKE) {
if let Err(e) = std::fs::write(&conf_path, DEFAULT_SERVER_CONF) {
eprintln!("Could not write server.conf: {e}");
}
return;
}
let cmd = &msg[HANDSHAKE.len()..];
if cmd.starts_with(b"focus") {
let _ = stream.write_all(b"ok\n");
if let Some(win) = app.get_webview_window("main") {
let _ = win.show();
let _ = win.unminimize();
let _ = win.set_focus();
// Conf already exists — patch the three critical keys in-place.
let Ok(contents) = std::fs::read_to_string(&conf_path) else { return };
let patched = patch_conf_key(
patch_conf_key(
patch_conf_key(
contents,
"server.webUIEnabled",
"false",
),
"server.initialOpenInBrowserEnabled",
"false",
),
"server.systemTrayEnabled",
"false",
);
let _ = std::fs::write(&conf_path, patched);
}
/// Replace `key = <value>` in a HOCON/properties-style conf, or append it
/// if the key is absent.
fn patch_conf_key(mut text: String, key: &str, value: &str) -> String {
let replacement = format!("{key} = {value}");
// Find a line that starts with the key (tolerant of surrounding whitespace)
if let Some(pos) = text.lines().position(|l| l.trim_start().starts_with(key)) {
let lines: Vec<&str> = text.lines().collect();
// We need an owned replacement; rebuild from scratch.
let owned: Vec<String> = lines
.iter()
.enumerate()
.map(|(i, l)| {
if i == pos { replacement.clone() } else { l.to_string() }
})
.collect();
return owned.join("\n");
}
// Key absent — append.
if !text.ends_with('\n') { text.push('\n'); }
text.push_str(&replacement);
text.push('\n');
text
}
/// Resolve the Suwayomi data directory.
///
/// - Linux: $XDG_DATA_HOME/moku/tachidesk (matches Flatpak path)
/// - macOS: ~/Library/Application Support/dev.moku.app/tachidesk
fn suwayomi_data_dir() -> PathBuf {
#[cfg(target_os = "macos")]
{
dirs::data_dir()
.unwrap_or_else(|| dirs::home_dir().unwrap_or_else(|| PathBuf::from("~")))
.join("dev.moku.app/tachidesk")
}
#[cfg(not(target_os = "macos"))]
{
let base = std::env::var("XDG_DATA_HOME")
.map(PathBuf::from)
.unwrap_or_else(|_| {
dirs::data_dir().unwrap_or_else(|| PathBuf::from("/tmp"))
});
base.join("moku/tachidesk")
}
}
/// Everything needed to spawn the server process.
struct ServerInvocation {
/// Path to the executable (javaw.exe on Windows, the sidecar script on macOS/Linux).
bin: std::ffi::OsString,
/// Extra args prepended before the Suwayomi rootDir flag.
/// On Windows: ["-jar", "<path-to-jar>"]
/// Elsewhere: []
prefix_args: Vec<String>,
/// Working directory for the child process.
/// On Windows this must be the bundle folder so javaw can find the JRE and jar.
/// Elsewhere: None (inherit).
working_dir: Option<PathBuf>,
}
/// Resolve the server binary path.
///
/// If the frontend passes a non-empty `binary` string (user override in
/// Settings) we always use that — on Linux this is the nixpkgs/Flatpak path.
///
/// Otherwise we look for the Tauri-bundled sidecar inside the resource dir
/// and, on Windows, build the javaw + jar invocation from the suwayomi-bundle.
fn resolve_server_binary(
binary: &str,
app: &tauri::AppHandle,
) -> Result<ServerInvocation, String> {
if !binary.trim().is_empty() {
return Ok(ServerInvocation {
bin: std::ffi::OsString::from(binary),
prefix_args: vec![],
working_dir: None,
});
}
let resource_dir = app
.path()
.resource_dir()
.map_err(|e| format!("Could not locate resource dir: {e}"))?;
// ── Windows: invoke the bundled javaw.exe with -jar Suwayomi-Launcher.jar ──
#[cfg(target_os = "windows")]
{
let sidecar = resource_dir.join("suwayomi-server-x86_64-pc-windows-msvc.exe");
let bundle_dir = resource_dir.join("suwayomi-bundle");
let jar = bundle_dir.join("Suwayomi-Launcher.jar");
if sidecar.exists() && jar.exists() {
return Ok(ServerInvocation {
bin: sidecar.into_os_string(),
prefix_args: vec![
"-jar".to_string(),
jar.to_string_lossy().into_owned(),
],
working_dir: Some(bundle_dir),
});
}
}
// ── macOS / Linux: sidecar script is self-contained ──
let candidates = [
"suwayomi-server-aarch64-apple-darwin",
"suwayomi-server-x86_64-apple-darwin",
// plain name as a dev/Linux fallback
"suwayomi-server",
];
for name in &candidates {
let p = resource_dir.join(name);
if p.exists() {
return Ok(ServerInvocation {
bin: p.into_os_string(),
prefix_args: vec![],
working_dir: None,
});
}
}
Err("Suwayomi server binary not found. Please set the path in Settings.".to_string())
}
#[tauri::command]
fn spawn_server(binary: String, app: tauri::AppHandle) -> Result<(), String> {
let state = app.state::<ServerState>();
{
let guard = state.0.lock().unwrap();
if guard.is_some() {
println!("Server already running, skipping spawn.");
return Ok(());
}
}
// Seed server.conf before launching so Suwayomi starts in headless mode.
let data_dir = suwayomi_data_dir();
seed_server_conf(&data_dir);
let invocation = resolve_server_binary(&binary, &app)?;
let shell = app.shell();
let rootdir_flag = format!(
"-Dsuwayomi.tachidesk.config.server.rootDir={}",
data_dir.to_string_lossy()
);
// Build the full arg list: prefix_args (e.g. -jar foo.jar) + rootDir flag.
let args: Vec<String> = invocation.prefix_args.into_iter().chain(std::iter::once(rootdir_flag)).collect();
// On Windows, set the working directory to the bundle folder so javaw.exe
// can resolve the JRE and jar relative paths correctly.
let cmd = shell
.command(&invocation.bin)
.env("JAVA_TOOL_OPTIONS", "-Djava.awt.headless=true")
.args(&args)
.current_dir(invocation.working_dir.unwrap_or_else(|| std::env::current_dir().unwrap_or_default()));
match cmd.spawn() {
Ok((_rx, child)) => {
println!("Spawned server: {:?}", invocation.bin);
let mut guard = state.0.lock().unwrap();
*guard = Some(child);
Ok(())
}
Err(e) => {
eprintln!("Failed to spawn {:?}: {}", invocation.bin, e);
Err(e.to_string())
}
}
}
fn signal_existing_instance() -> bool {
let Ok(mut stream) = TcpStream::connect(("127.0.0.1", IPC_PORT)) else {
return false;
};
stream.set_read_timeout(Some(std::time::Duration::from_millis(500))).ok();
let mut msg = Vec::new();
msg.extend_from_slice(HANDSHAKE);
msg.extend_from_slice(FOCUS_CMD);
if stream.write_all(&msg).is_err() {
return false;
}
let mut resp = [0u8; 4];
matches!(stream.read(&mut resp), Ok(n) if resp[..n].starts_with(b"ok"))
#[tauri::command]
fn kill_server(app: tauri::AppHandle) -> Result<(), String> {
kill_tachidesk(&app);
Ok(())
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
if signal_existing_instance() {
std::process::exit(0);
}
tauri::Builder::default()
.plugin(tauri_plugin_store::Builder::new().build())
.plugin(tauri_plugin_discord_rpc::init())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_os::init())
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_http::init())
.plugin(tauri_plugin_process::init())
.manage(ServerState(Mutex::new(None)))
.invoke_handler(tauri::generate_handler![
commands::storage::get_storage_info,
commands::storage::get_default_downloads_path,
commands::storage::check_path_exists,
commands::storage::create_directory,
commands::storage::migrate_downloads,
commands::server::spawn_server,
commands::server::kill_server,
commands::system::get_platform_ui_scale,
commands::system::restart_app,
commands::system::exit_app,
commands::system::clear_moku_cache,
commands::system::clear_suwayomi_cache,
commands::system::reset_suwayomi_data,
commands::system::open_path,
commands::system::pick_downloads_folder,
commands::system::pick_server_binary,
commands::backup::export_app_data,
commands::backup::import_app_data,
commands::backup::auto_backup_app_data,
commands::backup::get_auto_backup_dir,
commands::backup::read_store_files,
commands::storage::load_store,
commands::storage::save_store,
commands::storage::store_credential,
commands::storage::get_credential,
commands::updater::list_releases,
commands::updater::download_and_install_update,
commands::biometric::windows_hello_authenticate,
commands::biometric::windows_hello_available,
get_storage_info,
spawn_server,
kill_server,
get_scale_factor,
])
.setup(|app| {
start_instance_listener(app.handle().clone());
let show = MenuItem::with_id(app, "show", "Show Moku", true, None::<&str>)?;
let sep = PredefinedMenuItem::separator(app)?;
let quit = MenuItem::with_id(app, "quit", "Quit Moku", true, None::<&str>)?;
let menu = Menu::with_items(app, &[&show, &sep, &quit])?;
TrayIconBuilder::new()
.icon(app.default_window_icon().unwrap().clone())
.menu(&menu)
.show_menu_on_left_click(false)
.tooltip("Moku")
.on_menu_event(|app, event| match event.id.as_ref() {
"show" => {
if let Some(win) = app.get_webview_window("main") {
let _ = win.show();
let _ = win.set_focus();
}
}
"quit" => do_quit(app),
_ => {}
})
.on_tray_icon_event(|tray, event| {
if let TrayIconEvent::Click {
button: MouseButton::Left,
button_state: MouseButtonState::Up,
..
} = event
{
let app = tray.app_handle();
if let Some(win) = app.get_webview_window("main") {
let _ = win.show();
let _ = win.set_focus();
}
}
})
.build(app)?;
Ok(())
})
.setup(|_app| Ok(()))
.on_window_event(|window, event| {
if let WindowEvent::Destroyed = event {
server::kill_tachidesk(window.app_handle());
kill_tachidesk(window.app_handle());
}
})
.run(tauri::generate_context!())
.expect("error while running moku")
.expect("error while running moku");
}
-82
View File
@@ -1,82 +0,0 @@
use std::path::PathBuf;
const DEFAULT_SERVER_CONF: &str = r#"server.ip = "127.0.0.1"
server.port = 4567
server.webUIEnabled = false
server.initialOpenInBrowserEnabled = false
server.systemTrayEnabled = false
server.webUIInterface = "browser"
server.webUIFlavor = "WebUI"
server.webUIChannel = "preview"
server.electronPath = ""
server.debugLogsEnabled = false
server.downloadAsCbz = true
server.autoDownloadNewChapters = false
server.globalUpdateInterval = 12
server.maxSourcesInParallel = 6
server.extensionRepos = []
"#;
pub fn seed_server_conf(data_dir: &PathBuf, web_ui_enabled: bool) {
let conf_path = data_dir.join("server.conf");
if !conf_path.exists() {
if let Err(e) = std::fs::create_dir_all(data_dir) {
eprintln!("Could not create Suwayomi data dir: {e}");
return;
}
let initial = patch_conf_key(
DEFAULT_SERVER_CONF.to_string(),
"server.webUIEnabled",
if web_ui_enabled { "true" } else { "false" },
);
if let Err(e) = std::fs::write(&conf_path, initial) {
eprintln!("Could not write server.conf: {e}");
}
return;
}
let Ok(contents) = std::fs::read_to_string(&conf_path) else {
return;
};
let patched = patch_conf_key(
patch_conf_key(
patch_conf_key(
contents,
"server.webUIEnabled",
if web_ui_enabled { "true" } else { "false" },
),
"server.initialOpenInBrowserEnabled",
"false",
),
"server.systemTrayEnabled",
"false",
);
let _ = std::fs::write(&conf_path, patched);
}
fn patch_conf_key(text: String, key: &str, value: &str) -> String {
let replacement = format!("{key} = {value}");
let lines: Vec<&str> = text.lines().collect();
if let Some(pos) = lines.iter().position(|l| l.trim_start().starts_with(key)) {
let mut out = lines
.iter()
.enumerate()
.map(|(i, l)| if i == pos { replacement.as_str() } else { l })
.collect::<Vec<_>>()
.join("\n");
out.push('\n');
return out;
}
let mut out = text;
if !out.ends_with('\n') {
out.push('\n');
}
out.push_str(&replacement);
out.push('\n');
out
}
-53
View File
@@ -1,53 +0,0 @@
pub mod conf;
pub mod resolve;
use std::io::Write;
use tauri::Manager;
use crate::ServerState;
pub use resolve::SpawnError;
pub fn do_log(log: &mut Option<std::fs::File>, msg: &str) {
eprintln!("{}", msg);
if let Some(f) = log {
let _ = writeln!(f, "{}", msg);
}
}
pub fn kill_tachidesk(app: &tauri::AppHandle) {
let state = app.state::<ServerState>();
if let Some(child) = state.0.lock().unwrap().take() {
let _ = child.kill();
}
#[cfg(target_os = "windows")]
{
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x08000000;
let _ = std::process::Command::new("taskkill")
.args(["/F", "/FI", "IMAGENAME eq java.exe"])
.creation_flags(CREATE_NO_WINDOW)
.status();
for _ in 0..30 {
let still_running = std::process::Command::new("tasklist")
.args(["/FI", "IMAGENAME eq java.exe", "/NH"])
.creation_flags(CREATE_NO_WINDOW)
.output()
.map(|o| String::from_utf8_lossy(&o.stdout).contains("java.exe"))
.unwrap_or(false);
if !still_running {
break;
}
std::thread::sleep(std::time::Duration::from_millis(100));
}
}
#[cfg(not(target_os = "windows"))]
let _ = std::process::Command::new("pkill")
.args(["-f", "tachidesk"])
.status();
}
-240
View File
@@ -1,240 +0,0 @@
use crate::server::do_log;
use serde::Serialize;
use std::path::PathBuf;
use tauri::Manager;
#[derive(Serialize, Debug)]
#[serde(tag = "kind", content = "message")]
pub enum SpawnError {
NotConfigured(String),
SpawnFailed(String),
}
pub struct ServerInvocation {
pub bin: String,
pub args: Vec<String>,
pub working_dir: Option<PathBuf>,
}
pub fn suwayomi_data_dir() -> PathBuf {
#[cfg(target_os = "windows")]
{
dirs::data_dir()
.unwrap_or_else(|| PathBuf::from("C:\\ProgramData"))
.join("Tachidesk")
}
#[cfg(target_os = "macos")]
{
dirs::data_dir()
.unwrap_or_else(|| dirs::home_dir().unwrap_or_else(|| PathBuf::from("~")))
.join("Tachidesk")
}
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
{
let base = std::env::var("XDG_DATA_HOME")
.map(PathBuf::from)
.unwrap_or_else(|_| dirs::data_dir().unwrap_or_else(|| PathBuf::from("/tmp")));
base.join("Tachidesk")
}
}
pub fn strip_unc(path: PathBuf) -> PathBuf {
let s = path.to_string_lossy();
if let Some(stripped) = s.strip_prefix(r"\\?\") {
PathBuf::from(stripped)
} else {
path
}
}
fn java_bin_name() -> &'static str {
if cfg!(target_os = "windows") { "java.exe" } else { "java" }
}
fn find_java_in_bundle(bundle_dir: &PathBuf, log: &mut Option<std::fs::File>) -> Option<PathBuf> {
let java = bundle_dir.join("jre").join("bin").join(java_bin_name());
do_log(log, &format!("[find_java] {:?} exists={}", java, java.exists()));
if java.exists() { Some(java) } else { None }
}
fn data_root_args() -> Vec<String> {
vec!["--dataRoot".to_string(), suwayomi_data_dir().to_string_lossy().into_owned()]
}
fn jar_data_root_flag() -> String {
format!("-Dsuwayomi.server.rootDir={}", suwayomi_data_dir().to_string_lossy())
}
fn jar_invocation(java: PathBuf, jar: PathBuf, working_dir: PathBuf) -> ServerInvocation {
ServerInvocation {
bin: java.to_string_lossy().into_owned(),
args: vec![
jar_data_root_flag(),
"-jar".to_string(),
jar.to_string_lossy().into_owned(),
],
working_dir: Some(working_dir),
}
}
pub fn resolve_server_binary(
binary: &str,
app: &tauri::AppHandle,
log: &mut Option<std::fs::File>,
) -> Result<ServerInvocation, SpawnError> {
do_log(log, &format!("[resolve] binary={:?}", binary));
if !binary.trim().is_empty() {
let path = strip_unc(PathBuf::from(binary.trim()));
do_log(log, &format!("[resolve] user path={:?} exists={}", path, path.exists()));
if path.exists() {
let working_dir = path.parent().map(|p| p.to_path_buf());
return Ok(ServerInvocation {
bin: path.to_string_lossy().into_owned(),
args: data_root_args(),
working_dir,
});
}
do_log(log, "[resolve] user path not found, falling through");
}
if let Ok(exe) = std::env::current_exe() {
if let Some(bin_dir) = exe.parent() {
for name in &["tachidesk-server", "suwayomi-launcher"] {
let p = bin_dir.join(name);
do_log(log, &format!("[resolve] sibling={:?} exists={}", p, p.exists()));
if p.exists() {
return Ok(ServerInvocation {
bin: p.to_string_lossy().into_owned(),
args: data_root_args(),
working_dir: Some(bin_dir.to_path_buf()),
});
}
}
}
}
#[cfg(not(target_os = "macos"))]
{
let resource_dir = {
let raw = app.path().resource_dir().unwrap_or_default();
let stripped = strip_unc(raw);
do_log(log, &format!("[resolve] resource_dir={:?}", stripped));
stripped
};
let bundle_dir = resource_dir.join("binaries").join("suwayomi-bundle");
let jar = bundle_dir.join("bin").join("Suwayomi-Server.jar");
do_log(log, &format!("[resolve] bundle_dir={:?} exists={}", bundle_dir, bundle_dir.exists()));
do_log(log, &format!("[resolve] jar={:?} exists={}", jar, jar.exists()));
if let Some(java) = find_java_in_bundle(&bundle_dir, log) {
if jar.exists() {
do_log(log, "[resolve] using bundled JRE + jar");
return Ok(jar_invocation(java, jar, bundle_dir));
}
}
for name in &["suwayomi-launcher", "suwayomi-launcher.sh", "tachidesk-server"] {
let p = resource_dir.join(name);
do_log(log, &format!("[resolve] sidecar={:?} exists={}", p, p.exists()));
if p.exists() {
return Ok(ServerInvocation {
bin: p.to_string_lossy().into_owned(),
args: data_root_args(),
working_dir: Some(resource_dir.clone()),
});
}
}
if let Some(java) = find_java_in_bundle(&resource_dir, log) {
let jar = std::fs::read_dir(&resource_dir)
.ok()
.and_then(|mut rd| {
rd.find(|e| e.as_ref().map(|e| e.file_name().to_string_lossy().ends_with(".jar")).unwrap_or(false))
.and_then(|e| e.ok())
.map(|e| e.path())
});
if let Some(jar_path) = jar {
do_log(log, &format!("[resolve] generic JRE java={:?} jar={:?}", java, jar_path));
return Ok(jar_invocation(java, jar_path, resource_dir));
}
}
}
#[cfg(target_os = "macos")]
{
let resource_dir = app.path().resource_dir().unwrap_or_default();
let bundle_dir = resource_dir.join("suwayomi-bundle");
do_log(log, &format!("[resolve] macOS resource_dir={:?}", resource_dir));
do_log(log, &format!("[resolve] macOS bundle_dir={:?} exists={}", bundle_dir, bundle_dir.exists()));
let java = bundle_dir.join("jre").join("bin").join("java");
let jar = bundle_dir.join("bin").join("Suwayomi-Server.jar");
let launcher_sh = bundle_dir.join("Suwayomi Launcher.command");
let launcher_jar = bundle_dir.join("Suwayomi-Launcher.jar");
do_log(log, &format!("[resolve] java={:?} exists={}", java, java.exists()));
do_log(log, &format!("[resolve] jar={:?} exists={}", jar, jar.exists()));
do_log(log, &format!("[resolve] launcher.command={:?} exists={}", launcher_sh, launcher_sh.exists()));
do_log(log, &format!("[resolve] launcher.jar={:?} exists={}", launcher_jar, launcher_jar.exists()));
if java.exists() && jar.exists() {
do_log(log, "[resolve] macOS using bundled JRE + Suwayomi-Server.jar");
return Ok(jar_invocation(java, jar, bundle_dir));
}
if launcher_sh.exists() {
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(&launcher_sh, std::fs::Permissions::from_mode(0o755));
do_log(log, "[resolve] macOS using Suwayomi Launcher.command");
return Ok(ServerInvocation {
bin: launcher_sh.to_string_lossy().into_owned(),
args: vec![],
working_dir: Some(bundle_dir),
});
}
if java.exists() && launcher_jar.exists() {
do_log(log, "[resolve] macOS using bundled JRE + Suwayomi-Launcher.jar");
return Ok(jar_invocation(java, launcher_jar, bundle_dir));
}
do_log(log, "[resolve] macOS bundle not found, falling through to PATH");
}
for name in &["suwayomi-server", "tachidesk-server"] {
#[cfg(target_os = "windows")]
let resolved = std::process::Command::new("where")
.arg(name)
.output()
.ok()
.filter(|o| o.status.success())
.and_then(|o| String::from_utf8(o.stdout).ok())
.and_then(|s| s.lines().next().map(|l| l.trim().to_string()));
#[cfg(not(target_os = "windows"))]
let resolved = std::process::Command::new("which")
.arg(name)
.output()
.ok()
.filter(|o| o.status.success())
.and_then(|o| String::from_utf8(o.stdout).ok())
.and_then(|s| s.lines().next().map(|l| l.trim().to_string()));
if let Some(bin_path) = resolved {
do_log(log, &format!("[resolve] found on PATH: {:?}", bin_path));
return Ok(ServerInvocation {
bin: bin_path,
args: vec![],
working_dir: None,
});
}
}
Err(SpawnError::NotConfigured(
"Server binary not found. Install Suwayomi-Server or set the path in Settings.".to_string(),
))
}
+7 -17
View File
@@ -1,12 +1,11 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Moku",
"version": "0.9.4",
"identifier": "io.github.MokuProject.Moku",
"version": "0.3.0",
"identifier": "dev.moku.app",
"build": {
"devUrl": "http://localhost:1420",
"frontendDist": "../dist",
"beforeBuildCommand": "pnpm build:static"
"beforeBuildCommand": "pnpm build"
},
"app": {
"windows": [
@@ -18,8 +17,7 @@
"minHeight": 600,
"resizable": true,
"fullscreen": false,
"decorations": false,
"center": true
"decorations": false
}
],
"security": {
@@ -28,22 +26,14 @@
},
"bundle": {
"active": true,
"targets": ["nsis"],
"targets": ["appimage"],
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico",
"icons/icon.png"
],
"externalBin": [],
"windows": {
"nsis": {
"installerIcon": "icons/icon.ico",
"installMode": "currentUser"
}
}
"icons/icon.ico"
]
},
"plugins": {
"shell": {
+1 -3
View File
@@ -1,5 +1,6 @@
{
"build": {
"devUrl": "http://localhost:1420",
"beforeDevCommand": "pnpm dev"
},
"app": {
@@ -8,8 +9,5 @@
"devtools": true
}
]
},
"bundle": {
"externalBin": []
}
}
-13
View File
@@ -1,13 +0,0 @@
{
"bundle": {
"targets": ["appimage", "deb"],
"externalBin": [
"binaries/suwayomi-launcher-linux"
],
"resources": {
"binaries/suwayomi-bundle/bin/Suwayomi-Server.jar": "Suwayomi-Server.jar",
"binaries/suwayomi-bundle/bin/catch_abort.so": "catch_abort.so",
"binaries/suwayomi-bundle/jre": "jre"
}
}
}
-25
View File
@@ -1,25 +0,0 @@
{
"app": {
"windows": [
{
"decorations": true,
"titleBarStyle": "Overlay",
"hiddenTitle": true
}
]
},
"bundle": {
"targets": ["dmg"],
"externalBin": [
"binaries/suwayomi-server"
],
"resources": {
"binaries/suwayomi-bundle": "suwayomi-bundle"
},
"macOS": {
"minimumSystemVersion": "11.0",
"exceptionDomain": "localhost",
"frameworks": []
}
}
}
-8
View File
@@ -1,8 +0,0 @@
{
"bundle": {
"resources": [
"binaries/suwayomi-bundle/bin/Suwayomi-Server.jar",
"binaries/suwayomi-bundle/jre/**/*"
]
}
}
+12
View File
@@ -0,0 +1,12 @@
.root {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.content {
flex: 1;
overflow: hidden;
min-height: 0;
}
+199
View File
@@ -0,0 +1,199 @@
import { useEffect, useRef, useState } from "react";
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { gql } from "./lib/client";
import { GET_DOWNLOAD_STATUS } from "./lib/queries";
import "./styles/global.css";
import { useStore } from "./store";
import Layout from "./components/layout/Layout";
import Reader from "./components/pages/Reader";
import Settings from "./components/settings/Settings";
import MangaPreview from "./components/explore/MangaPreview";
import TitleBar from "./components/layout/TitleBar";
import Toaster from "./components/layout/Toaster";
import SplashScreen, { EXIT_MS as SPLASH_EXIT_MS } from "./components/layout/SplashScreen";
import type { DownloadStatus, DownloadQueueItem } from "./lib/types";
import s from "./App.module.css";
const MAX_ATTEMPTS = 30;
export default function App() {
const activeChapter = useStore((s) => s.activeChapter);
const settingsOpen = useStore((s) => s.settingsOpen);
const settings = useStore((s) => s.settings);
const setActiveDownloads = useStore((s) => s.setActiveDownloads);
const addToast = useStore((s) => s.addToast);
// serverProbeOk = server responded, but we wait for ring to finish before showing UI
const [serverProbeOk, setServerProbeOk] = useState(!settings.autoStartServer);
// appReady = ring filled + transition done, show main UI
const [appReady, setAppReady] = useState(!settings.autoStartServer);
const [failed, setFailed] = useState(false);
const [retryKey, setRetryKey] = useState(0);
const [idle, setIdle] = useState(false);
// dev tools: force show splash
const [devSplash, setDevSplash] = useState(false);
const prevQueueRef = useRef<DownloadQueueItem[]>([]);
const idleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const idleRef = useRef(false);
// expose devSplash trigger via window for settings
useEffect(() => {
(window as any).__mokuShowSplash = () => setDevSplash(true);
return () => { delete (window as any).__mokuShowSplash; };
}, []);
// Keep idleRef in sync so resetIdle can check it without a stale closure
useEffect(() => { idleRef.current = idle; }, [idle]);
useEffect(() => {
if (!appReady) return;
function resetIdle() {
// While the idle splash is visible, don't reset — let SplashScreen's own
// dismiss flow handle teardown so the exit animation plays fully.
if (idleRef.current) return;
if (idleTimerRef.current) clearTimeout(idleTimerRef.current);
const idleTimeoutMs = (settings.idleTimeoutMin ?? 5) * 60 * 1000;
if (idleTimeoutMs === 0) return;
idleTimerRef.current = setTimeout(() => setIdle(true), idleTimeoutMs);
}
const events = ["mousemove","mousedown","keydown","touchstart","wheel"];
events.forEach(e => window.addEventListener(e, resetIdle, { passive:true }));
resetIdle();
return () => {
events.forEach(e => window.removeEventListener(e, resetIdle));
if (idleTimerRef.current) clearTimeout(idleTimerRef.current);
};
}, [appReady, settings.idleTimeoutMin]);
function detectCompletions(prev: DownloadQueueItem[], next: DownloadQueueItem[]) {
for (const item of prev) {
if (item.state !== "DOWNLOADING") continue;
if (!next.some(q => q.chapter.id === item.chapter.id)) {
const manga = item.chapter.manga;
addToast({ kind:"success", title:"Chapter downloaded",
body: manga ? `${manga.title}${item.chapter.name}` : item.chapter.name,
duration: 4000 });
}
}
}
function applyQueue(next: DownloadQueueItem[]) {
detectCompletions(prevQueueRef.current, next);
prevQueueRef.current = next;
setActiveDownloads(next.map(item => ({
chapterId: item.chapter.id, mangaId: item.chapter.mangaId, progress: item.progress,
})));
}
useEffect(() => {
document.documentElement.style.zoom = `${settings.uiScale * 1.5}%`;
}, [settings.uiScale]);
useEffect(() => {
const theme = settings.theme ?? "dark";
document.documentElement.setAttribute("data-theme", theme);
}, [settings.theme]);
useEffect(() => {
const p = (e: MouseEvent) => e.preventDefault();
document.addEventListener("contextmenu", p);
return () => document.removeEventListener("contextmenu", p);
}, []);
useEffect(() => {
if (!settings.autoStartServer) return;
invoke("spawn_server", { binary: settings.serverBinary }).catch(err =>
console.warn("Could not start server:", err));
return () => { invoke("kill_server").catch(() => {}); };
}, [settings.autoStartServer, settings.serverBinary]);
// Poll until server responds
useEffect(() => {
if (serverProbeOk) return;
let cancelled = false, tries = 0;
async function probe() {
if (cancelled) return;
tries++;
try {
const res = await fetch(`${settings.serverUrl}/api/graphql`, {
method:"POST", headers:{"Content-Type":"application/json"},
body: JSON.stringify({ query:"{ __typename }" }),
signal: AbortSignal.timeout(2000),
});
if (res.ok && !cancelled) { setServerProbeOk(true); return; }
} catch {}
if (tries >= MAX_ATTEMPTS && !cancelled) { setFailed(true); return; }
if (!cancelled) setTimeout(probe, 800);
}
const t = setTimeout(probe, 800);
return () => { cancelled = true; clearTimeout(t); };
}, [serverProbeOk, settings.serverUrl, retryKey]);
useEffect(() => {
if (!appReady) return;
function poll() {
gql<{ downloadStatus: DownloadStatus }>(GET_DOWNLOAD_STATUS)
.then(d => applyQueue(d.downloadStatus.queue)).catch(console.error);
}
poll();
const id = setInterval(poll, 2000);
return () => clearInterval(id);
}, [appReady]);
useEffect(() => {
type P = { chapterId:number; mangaId:number; progress:number }[];
const unsub = listen<P>("download-progress", e => setActiveDownloads(e.payload));
return () => { unsub.then(fn => fn()); };
}, [setActiveDownloads]);
// Dev splash overlay — shows idle mode so you can dismiss with any interaction
if (devSplash) {
return (
<SplashScreen
mode="idle"
showFps
showCards={settings.splashCards ?? true}
onDismiss={() => { setTimeout(() => setDevSplash(false), SPLASH_EXIT_MS + 20); }}
/>
);
}
// Loading splash — shown until ring fills + transition completes
if (!appReady) {
return (
<SplashScreen
mode="loading"
ringFull={serverProbeOk}
failed={failed}
showCards={settings.splashCards ?? true}
onReady={() => setAppReady(true)}
onRetry={() => {
setFailed(false);
setServerProbeOk(false);
setRetryKey(k => k+1);
}}
/>
);
}
return (
<div className={s.root}>
{idle && !activeChapter && (
<SplashScreen
mode="idle"
showCards={settings.splashCards ?? true}
onDismiss={() => { setTimeout(() => { setIdle(false); }, SPLASH_EXIT_MS + 20); }}
/>
)}
{!activeChapter && <TitleBar/>}
<div className={s.content}>
{activeChapter ? <Reader/> : <Layout/>}
</div>
{settingsOpen && <Settings/>}
<MangaPreview/>
<Toaster/>
</div>
);
}
-203
View File
@@ -1,203 +0,0 @@
@import '$lib/components/settings/Settings.css';
@import '$lib/styles/themes.css';
:root {
--bg-void: #080808;
--bg-base: #0c0c0c;
--bg-surface: #101010;
--bg-raised: #151515;
--bg-overlay: #1a1a1a;
--bg-subtle: #202020;
--border-dim: #1c1c1c;
--border-base: #242424;
--border-strong: #2e2e2e;
--border-focus: #4a5c4a;
--text-primary: #f0efec;
--text-secondary: #c8c6c0;
--text-muted: #8a8880;
--text-faint: #4e4d4a;
--text-disabled: #2a2a28;
--accent: #6b8f6b;
--accent-dim: #2a3d2a;
--accent-muted: #1a251a;
--accent-fg: #a8c4a8;
--accent-bright: #8fb88f;
--color-error: #c47a7a;
--color-error-bg: #1f1212;
--color-success: #7aab7a;
--color-info: #7a9ec4;
--color-info-bg: #121a1f;
--color-read: #2e2e2c;
--dot-active: var(--accent);
--dot-inactive: var(--text-faint);
--t-fast: 0.08s ease;
--t-base: 0.14s ease;
--t-slow: 0.22s ease;
--radius-sm: 3px;
--radius-md: 5px;
--radius-lg: 7px;
--radius-xl: 10px;
--radius-2xl: 14px;
--radius-full: 9999px;
--sp-1: 4px;
--sp-2: 8px;
--sp-3: 12px;
--sp-4: 16px;
--sp-5: 20px;
--sp-6: 24px;
--sp-8: 32px;
--sp-10: 40px;
--sidebar-width: 52px;
--titlebar-height: 36px;
--font-ui: "DM Mono", "Fira Mono", ui-monospace, monospace;
--font-sans: "DM Sans", ui-sans-serif, system-ui, sans-serif;
--text-2xs: 10px;
--text-xs: 11px;
--text-sm: 12px;
--text-base: 13px;
--text-md: 14px;
--text-lg: 15px;
--text-xl: 17px;
--text-2xl: 20px;
--text-3xl: 24px;
--weight-normal: 400;
--weight-medium: 500;
--weight-semi: 600;
--leading-none: 1;
--leading-tight: 1.3;
--leading-snug: 1.45;
--leading-base: 1.6;
--tracking-tight: -0.02em;
--tracking-normal: 0;
--tracking-wide: 0.06em;
--tracking-wider: 0.1em;
--z-reader: 50;
--z-modal: 100;
--z-settings: 150;
}
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html, body {
height: 100%;
overflow: hidden;
background: var(--bg-void);
color: var(--text-primary);
}
#svelte {
height: 100%;
}
button {
cursor: pointer;
font: inherit;
color: inherit;
background: none;
border: none;
padding: 0;
}
input, textarea, select {
font: inherit;
color: inherit;
}
a {
color: inherit;
text-decoration: none;
}
ul, ol { list-style: none; }
img, svg { display: block; max-width: 100%; }
p { margin: 0; }
body {
font-family: var(--font-sans);
font-size: var(--text-base);
font-weight: var(--weight-normal);
line-height: var(--leading-base);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
}
* {
scrollbar-width: thin;
scrollbar-color: transparent transparent;
}
*::-webkit-scrollbar { width: 4px; height: 4px; }
*::-webkit-scrollbar-track { background: transparent; }
*::-webkit-scrollbar-thumb { background: transparent; border-radius: 99px; }
*::-webkit-scrollbar-thumb:hover { background: transparent; }
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes fadeUp {
from { opacity: 0; transform: translateY(5px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes fadeDown {
from { opacity: 0; transform: translateY(-5px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes scaleIn {
from { opacity: 0; transform: scale(0.97); }
to { opacity: 1; transform: scale(1); }
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.35; }
}
@keyframes shimmer {
from { background-position: -200% 0; }
to { background-position: 200% 0; }
}
.anim-fade-in { animation: fadeIn 0.14s ease both; }
.anim-fade-up { animation: fadeUp 0.18s ease both; }
.anim-fade-down { animation: fadeDown 0.18s ease both; }
.anim-scale-in { animation: scaleIn 0.14s ease both; }
.anim-pulse { animation: pulse 1.6s ease infinite; }
.anim-spin { animation: spin 0.7s linear infinite; }
.skeleton {
background: linear-gradient(90deg, var(--bg-raised) 25%, var(--bg-overlay) 50%, var(--bg-raised) 75%);
background-size: 200% 100%;
animation: shimmer 1.4s ease infinite;
border-radius: var(--radius-sm);
}
-5
View File
@@ -1,5 +0,0 @@
declare global {
namespace App {}
const __APP_VERSION__: string
}
export {}
-12
View File
@@ -1,12 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="%sveltekit.assets%/favicon.svg" />
%sveltekit.head%
</head>
<body data-theme="dark">
<div id="svelte">%sveltekit.body%</div>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

+27
View File
@@ -0,0 +1,27 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
width="512.000000pt" height="512.000000pt" viewBox="0 0 500.000000 500.000000"
preserveAspectRatio="xMidYMid meet">
<g transform="translate(0.000000,500.000000) scale(0.050000,-0.050000)"
fill="#2d7a5f" stroke="none">
<path d="M4908 6615 c-597 -629 -935 -1264 -934 -1755 0 -508 195 -778 790
-1098 l194 -104 22 -114 c33 -164 76 -271 140 -347 80 -94 106 -62 77 94 -47
255 43 443 294 613 797 539 714 1392 -250 2551 -230 278 -224 275 -333 160z
m52 -945 c0 -472 -8 -850 -17 -850 -36 0 -693 703 -692 740 2 48 167 321 309
509 175 233 359 451 380 451 13 0 20 -304 20 -850z m183 775 c120 -126 357
-447 356 -482 0 -18 -47 -83 -105 -144 -57 -61 -151 -163 -208 -225 -151 -166
-146 -180 -146 406 0 286 7 520 16 520 9 0 48 -34 87 -75z m541 -750 c86 -150
196 -391 196 -430 0 -8 -25 -42 -55 -75 -31 -33 -149 -163 -264 -290 -339
-374 -480 -520 -501 -520 -13 0 -20 167 -20 465 l1 465 151 170 c83 94 193
217 244 275 122 138 137 135 248 -60z m-1098 -633 l374 -378 0 -462 c0 -254
-5 -462 -11 -462 -6 0 -55 23 -110 50 l-99 51 0 208 c0 259 -11 288 -176 457
-196 200 -188 199 -326 62 l-117 -116 -35 79 c-71 161 -39 569 64 816 42 100
20 116 436 -305z m1349 23 c109 -390 -87 -848 -475 -1111 -207 -139 -305 -262
-338 -420 -7 -31 -21 -56 -32 -56 -36 -1 -46 86 -48 405 l-2 313 123 137 c68
75 214 236 325 357 454 493 421 466 447 375z m-1292 -785 c12 -29 15 -118 7
-215 l-14 -166 -73 44 c-174 104 -403 341 -403 416 0 15 47 70 105 123 l104
98 127 -125 c70 -69 136 -147 147 -175z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

@@ -0,0 +1,83 @@
.menu {
position: fixed;
z-index: 200;
background: var(--bg-raised);
border: 1px solid var(--border-base);
border-radius: var(--radius-lg);
padding: var(--sp-1);
min-width: 190px;
box-shadow:
0 0 0 1px rgba(0,0,0,0.08),
0 4px 12px rgba(0,0,0,0.35),
0 16px 40px rgba(0,0,0,0.25);
animation: scaleIn 0.1s ease both;
transform-origin: top left;
}
.item {
display: flex;
align-items: center;
gap: var(--sp-2);
width: 100%;
padding: 5px var(--sp-2);
border-radius: var(--radius-md);
font-size: var(--text-sm);
color: var(--text-secondary);
text-align: left;
cursor: pointer;
transition: background var(--t-fast), color var(--t-fast);
border: none;
background: none;
outline: none;
}
.item:hover:not(:disabled),
.itemFocused:not(:disabled) {
background: var(--bg-overlay);
color: var(--text-primary);
}
/* Icon area — fixed-width column so labels align */
.itemIconWrap {
display: flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
flex-shrink: 0;
color: var(--text-faint);
transition: color var(--t-fast);
border-radius: var(--radius-sm);
}
.item:hover .itemIconWrap,
.itemFocused .itemIconWrap {
color: var(--text-muted);
}
.itemLabel {
flex: 1;
line-height: 1.3;
}
/* Danger variant */
.itemDanger { color: var(--color-error); }
.itemDanger:hover:not(:disabled),
.itemDanger.itemFocused:not(:disabled) {
background: var(--color-error-bg);
color: var(--color-error);
}
.itemIconDanger { color: var(--color-error) !important; opacity: 0.7; }
/* Disabled */
.itemDisabled {
opacity: 0.3;
cursor: default;
pointer-events: none;
}
.separator {
height: 1px;
background: var(--border-dim);
margin: 3px var(--sp-1);
}
+133
View File
@@ -0,0 +1,133 @@
import { useEffect, useRef, useCallback, useState } from "react";
import { createPortal } from "react-dom";
import s from "./ContextMenu.module.css";
export interface ContextMenuItem {
label: string;
icon?: React.ReactNode;
onClick: () => void;
danger?: boolean;
disabled?: boolean;
separator?: never;
}
export interface ContextMenuSeparator {
separator: true;
label?: never;
icon?: never;
onClick?: never;
danger?: never;
disabled?: never;
}
export type ContextMenuEntry = ContextMenuItem | ContextMenuSeparator;
interface Props {
x: number;
y: number;
items: ContextMenuEntry[];
onClose: () => void;
}
export default function ContextMenu({ x, y, items, onClose }: Props) {
const menuRef = useRef<HTMLDivElement>(null);
const [focused, setFocused] = useState<number>(-1);
// Build list of actionable (non-separator, non-disabled) indices for keyboard nav
const actionable = items
.map((_, i) => i)
.filter((i) => !("separator" in items[i]) && !(items[i] as ContextMenuItem).disabled);
useEffect(() => {
function onDown(e: MouseEvent) {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) onClose();
}
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") { e.stopPropagation(); onClose(); return; }
if (e.key === "ArrowDown") {
e.preventDefault();
setFocused((prev) => {
const cur = actionable.indexOf(prev);
return actionable[(cur + 1) % actionable.length] ?? actionable[0];
});
return;
}
if (e.key === "ArrowUp") {
e.preventDefault();
setFocused((prev) => {
const cur = actionable.indexOf(prev);
return actionable[(cur - 1 + actionable.length) % actionable.length] ?? actionable[0];
});
return;
}
if (e.key === "Enter" && focused >= 0) {
e.preventDefault();
const item = items[focused] as ContextMenuItem;
if (item && !item.disabled) { item.onClick(); onClose(); }
return;
}
}
document.addEventListener("mousedown", onDown, true);
document.addEventListener("keydown", onKey, true);
return () => {
document.removeEventListener("mousedown", onDown, true);
document.removeEventListener("keydown", onKey, true);
};
}, [onClose, focused, actionable, items]);
// Focus first item on open
useEffect(() => {
if (actionable.length) setFocused(actionable[0]);
}, []);
const getPosition = useCallback(() => {
const zoom = parseFloat(document.documentElement.style.zoom || "1") / 100 || 1;
const scaledX = x / zoom;
const scaledY = y / zoom;
const menuW = 200;
const menuH = items.length * 34;
const vw = window.innerWidth / zoom;
const vh = window.innerHeight / zoom;
const left = scaledX + menuW > vw ? scaledX - menuW : scaledX;
const top = scaledY + menuH > vh ? scaledY - menuH : scaledY;
return { left: Math.max(4, left), top: Math.max(4, top) };
}, [x, y, items.length]);
return createPortal(
<div
ref={menuRef}
className={s.menu}
style={getPosition()}
onContextMenu={(e) => e.preventDefault()}
>
{items.map((item, i) => {
if ("separator" in item && item.separator) {
return <div key={i} className={s.separator} />;
}
const mi = item as ContextMenuItem;
const isFocused = focused === i;
return (
<button
key={i}
className={[
s.item,
mi.danger ? s.itemDanger : "",
mi.disabled ? s.itemDisabled : "",
isFocused ? s.itemFocused : "",
].filter(Boolean).join(" ")}
onClick={() => { if (!mi.disabled) { mi.onClick(); onClose(); } }}
onMouseEnter={() => !mi.disabled && setFocused(i)}
onMouseLeave={() => setFocused(-1)}
disabled={mi.disabled}
>
<span className={[s.itemIconWrap, mi.danger ? s.itemIconDanger : ""].filter(Boolean).join(" ")}>
{mi.icon ?? null}
</span>
<span className={s.itemLabel}>{mi.label}</span>
</button>
);
})}
</div>,
document.body
);
}
@@ -0,0 +1,215 @@
.root {
padding: var(--sp-6);
overflow-y: auto;
height: 100%;
animation: fadeIn 0.14s ease both;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--sp-5);
}
.heading {
font-family: var(--font-ui);
font-size: var(--text-xs);
font-weight: var(--weight-normal);
color: var(--text-faint);
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
}
.headerActions { display: flex; gap: var(--sp-2); }
.iconBtn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: var(--radius-md);
border: 1px solid var(--border-dim);
color: var(--text-muted);
transition: color var(--t-base), border-color var(--t-base), background var(--t-base);
}
.iconBtn:hover:not(:disabled) { color: var(--text-secondary); border-color: var(--border-strong); background: var(--bg-raised); }
.iconBtn:disabled { opacity: 0.3; cursor: default; }
/* Loading state — accent tint so it's visually distinct */
.iconBtnLoading {
border-color: var(--accent-dim);
color: var(--accent-fg);
background: var(--accent-muted);
}
.iconBtnLoading:hover:not(:disabled) {
border-color: var(--accent-dim);
color: var(--accent-fg);
background: var(--accent-muted);
}
.statusBar {
display: flex;
align-items: center;
gap: var(--sp-3);
padding: var(--sp-3);
background: var(--bg-raised);
border: 1px solid var(--border-dim);
border-radius: var(--radius-md);
margin-bottom: var(--sp-4);
}
.statusDot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--text-faint);
flex-shrink: 0;
transition: background var(--t-base);
}
.statusDotActive {
background: var(--accent);
animation: pulse 1.6s ease infinite;
}
.statusText {
font-family: var(--font-ui);
font-size: var(--text-xs);
color: var(--text-muted);
flex: 1;
letter-spacing: var(--tracking-wide);
transition: color var(--t-base);
}
.statusCount {
font-family: var(--font-ui);
font-size: var(--text-xs);
color: var(--text-faint);
letter-spacing: var(--tracking-wide);
}
.list { display: flex; flex-direction: column; gap: var(--sp-2); }
.row {
display: flex;
align-items: center;
gap: var(--sp-3);
padding: var(--sp-3);
background: var(--bg-raised);
border: 1px solid var(--border-dim);
border-radius: var(--radius-md);
transition: border-color var(--t-fast), opacity var(--t-base);
}
.rowActive { border-color: var(--accent-dim); }
/* Fade out rows being removed */
.rowRemoving { opacity: 0.4; pointer-events: none; }
/* Thumbnail */
.thumb {
width: 36px;
height: 54px;
border-radius: var(--radius-sm);
overflow: hidden;
background: var(--bg-overlay);
flex-shrink: 0;
border: 1px solid var(--border-dim);
}
.thumbImg {
width: 100%;
height: 100%;
object-fit: cover;
}
/* Info block */
.info {
flex: 1;
display: flex;
flex-direction: column;
gap: 3px;
overflow: hidden;
min-width: 0;
}
.mangaTitle {
font-size: var(--text-sm);
font-weight: var(--weight-medium);
color: var(--text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.chapterName {
font-size: var(--text-xs);
color: var(--text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.pagesLabel {
font-family: var(--font-ui);
font-size: var(--text-2xs);
color: var(--text-faint);
letter-spacing: var(--tracking-wide);
}
.progressWrap {
height: 2px;
background: var(--border-base);
border-radius: var(--radius-full);
overflow: hidden;
margin-top: 4px;
}
.progressBar {
height: 100%;
background: var(--accent);
border-radius: var(--radius-full);
transition: width 0.4s ease;
}
/* Right side */
.rowRight {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: var(--sp-1);
flex-shrink: 0;
}
.stateLabel {
font-family: var(--font-ui);
font-size: var(--text-2xs);
color: var(--text-faint);
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
}
.removeBtn {
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border-radius: var(--radius-sm);
color: var(--text-faint);
transition: color var(--t-base), background var(--t-base);
}
.removeBtn:hover:not(:disabled) { color: var(--color-error); background: var(--color-error-bg); }
.removeBtn:disabled { opacity: 0.5; cursor: default; }
.empty {
display: flex;
align-items: center;
justify-content: center;
height: 160px;
color: var(--text-faint);
font-family: var(--font-ui);
font-size: var(--text-xs);
letter-spacing: var(--tracking-wide);
}
+230
View File
@@ -0,0 +1,230 @@
import { useEffect, useState, useCallback } from "react";
import { Play, Pause, Trash, CircleNotch, X } from "@phosphor-icons/react";
import { gql, thumbUrl } from "../../lib/client";
import {
GET_DOWNLOAD_STATUS, START_DOWNLOADER, STOP_DOWNLOADER,
CLEAR_DOWNLOADER, DEQUEUE_DOWNLOAD,
} from "../../lib/queries";
import { useStore } from "../../store";
import type { DownloadStatus } from "../../lib/types";
import s from "./DownloadQueue.module.css";
export default function DownloadQueue() {
const [status, setStatus] = useState<DownloadStatus | null>(null);
const [loading, setLoading] = useState(true);
const [togglingPlay, setTogglingPlay] = useState(false);
const [clearing, setClearing] = useState(false);
const [dequeueing, setDequeueing] = useState<Set<number>>(new Set());
const setActiveDownloads = useStore((s) => s.setActiveDownloads);
// Apply status to local state + global store.
// Completion toasting is handled globally in App.tsx — no duplication here.
const applyStatus = useCallback((ds: DownloadStatus) => {
setStatus(ds);
setActiveDownloads(
ds.queue.map((item) => ({
chapterId: item.chapter.id,
mangaId: item.chapter.mangaId,
progress: item.progress,
}))
);
}, [setActiveDownloads]);
async function poll() {
gql<{ downloadStatus: DownloadStatus }>(GET_DOWNLOAD_STATUS)
.then((d) => applyStatus(d.downloadStatus))
.catch(console.error)
.finally(() => setLoading(false));
}
useEffect(() => {
poll();
const id = setInterval(poll, 2000);
return () => clearInterval(id);
}, []);
// ── Actions ─────────────────────────────────────────────────────────────────
async function togglePlay() {
if (togglingPlay) return;
setTogglingPlay(true);
const wasRunning = status?.state === "STARTED";
setStatus((prev) => prev ? { ...prev, state: wasRunning ? "STOPPED" : "STARTED" } : prev);
try {
if (wasRunning) {
const d = await gql<{ stopDownloader: { downloadStatus: DownloadStatus } }>(STOP_DOWNLOADER);
applyStatus(d.stopDownloader.downloadStatus);
} else {
const d = await gql<{ startDownloader: { downloadStatus: DownloadStatus } }>(START_DOWNLOADER);
applyStatus(d.startDownloader.downloadStatus);
}
} catch (e) {
console.error(e);
poll();
} finally {
setTogglingPlay(false);
}
}
async function clear() {
if (clearing) return;
setClearing(true);
setStatus((prev) => prev ? { ...prev, queue: [] } : prev);
setActiveDownloads([]);
try {
const d = await gql<{ clearDownloader: { downloadStatus: DownloadStatus } }>(CLEAR_DOWNLOADER);
applyStatus(d.clearDownloader.downloadStatus);
} catch (e) {
console.error(e);
poll();
} finally {
setClearing(false);
}
}
async function dequeue(chapterId: number) {
if (dequeueing.has(chapterId)) return;
setDequeueing((prev) => new Set(prev).add(chapterId));
setStatus((prev) =>
prev ? { ...prev, queue: prev.queue.filter((i) => i.chapter.id !== chapterId) } : prev
);
try {
await gql(DEQUEUE_DOWNLOAD, { chapterId });
poll();
} catch (e) {
console.error(e);
poll();
} finally {
setDequeueing((prev) => {
const next = new Set(prev);
next.delete(chapterId);
return next;
});
}
}
const queue = status?.queue ?? [];
const isRunning = status?.state === "STARTED";
function pagesDownloaded(progress: number, pageCount: number): number {
return Math.round(progress * pageCount);
}
return (
<div className={s.root}>
<div className={s.header}>
<h1 className={s.heading}>Downloads</h1>
<div className={s.headerActions}>
<button
className={[s.iconBtn, togglingPlay ? s.iconBtnLoading : ""].join(" ").trim()}
onClick={togglePlay}
disabled={togglingPlay || (queue.length === 0 && !isRunning)}
title={isRunning ? "Pause" : "Resume"}
>
{togglingPlay ? (
<CircleNotch size={14} weight="light" className="anim-spin" />
) : isRunning ? (
<Pause size={14} weight="fill" />
) : (
<Play size={14} weight="fill" />
)}
</button>
<button
className={[s.iconBtn, clearing ? s.iconBtnLoading : ""].join(" ").trim()}
onClick={clear}
disabled={clearing || queue.length === 0}
title="Clear queue"
>
{clearing ? (
<CircleNotch size={14} weight="light" className="anim-spin" />
) : (
<Trash size={14} weight="regular" />
)}
</button>
</div>
</div>
<div className={s.statusBar}>
<div className={[s.statusDot, isRunning ? s.statusDotActive : ""].join(" ").trim()} />
<span className={s.statusText}>
{togglingPlay
? (isRunning ? "Pausing…" : "Starting…")
: isRunning ? "Downloading" : "Paused"}
</span>
<span className={s.statusCount}>{queue.length} queued</span>
</div>
{loading ? (
<div className={s.empty}>
<CircleNotch size={16} weight="light" className="anim-spin" style={{ color: "var(--text-faint)" }} />
</div>
) : queue.length === 0 ? (
<div className={s.empty}>Queue is empty.</div>
) : (
<div className={s.list}>
{queue.map((item, i) => {
const isActive = i === 0 && isRunning;
const pages = item.chapter.pageCount ?? 0;
const done = pagesDownloaded(item.progress, pages);
const manga = item.chapter.manga;
const isRemoving = dequeueing.has(item.chapter.id);
return (
<div
key={item.chapter.id}
className={[s.row, isActive ? s.rowActive : "", isRemoving ? s.rowRemoving : ""].join(" ").trim()}
>
{manga?.thumbnailUrl && (
<div className={s.thumb}>
<img
src={thumbUrl(manga.thumbnailUrl)}
alt={manga.title}
className={s.thumbImg}
loading="lazy"
decoding="async"
/>
</div>
)}
<div className={s.info}>
{manga?.title && <span className={s.mangaTitle}>{manga.title}</span>}
<span className={s.chapterName}>{item.chapter.name}</span>
{pages > 0 && (
<span className={s.pagesLabel}>
{isActive ? `${done} / ${pages} pages` : `${pages} pages`}
</span>
)}
{isActive && (
<div className={s.progressWrap}>
<div
className={s.progressBar}
style={{ width: `${Math.round(item.progress * 100)}%` }}
/>
</div>
)}
</div>
<div className={s.rowRight}>
<span className={s.stateLabel}>{item.state}</span>
{!isActive && (
<button
className={s.removeBtn}
onClick={() => dequeue(item.chapter.id)}
disabled={isRemoving}
title="Remove from queue"
>
{isRemoving
? <CircleNotch size={11} weight="light" className="anim-spin" />
: <X size={12} weight="light" />}
</button>
)}
</div>
</div>
);
})}
</div>
)}
</div>
);
}
+441
View File
@@ -0,0 +1,441 @@
.root {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
animation: fadeIn 0.14s ease both;
}
/* ── Header / Tab switcher ───────────────────────────────────────────────── */
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--sp-4) var(--sp-6);
border-bottom: 1px solid var(--border-dim);
flex-shrink: 0;
gap: var(--sp-4);
}
.headerLeft {
display: flex;
align-items: center;
gap: var(--sp-4);
}
.heading {
font-family: var(--font-ui);
font-size: var(--text-xs);
font-weight: var(--weight-normal);
color: var(--text-faint);
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
flex-shrink: 0;
}
.tabs {
display: flex;
gap: 2px;
background: var(--bg-raised);
border: 1px solid var(--border-dim);
border-radius: var(--radius-md);
padding: 2px;
}
.tab {
display: flex;
align-items: center;
gap: 5px;
font-family: var(--font-ui);
font-size: var(--text-2xs);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
padding: 4px 10px;
border-radius: var(--radius-sm);
border: none;
background: none;
color: var(--text-faint);
cursor: pointer;
transition: background var(--t-base), color var(--t-base);
white-space: nowrap;
}
.tab:hover { color: var(--text-muted); }
.tabActive {
background: var(--accent-muted);
color: var(--accent-fg);
border: 1px solid var(--accent-dim);
}
.tabActive:hover { color: var(--accent-fg); }
/* Source picker */
.sourcePicker {
display: flex;
align-items: center;
gap: var(--sp-2);
}
.sourcePickerLabel {
font-family: var(--font-ui);
font-size: var(--text-2xs);
color: var(--text-faint);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
white-space: nowrap;
}
.sourceSelect {
background: var(--bg-raised);
border: 1px solid var(--border-dim);
border-radius: var(--radius-md);
padding: 4px 8px;
color: var(--text-secondary);
font-size: var(--text-sm);
font-family: var(--font-ui);
outline: none;
cursor: pointer;
transition: border-color var(--t-base);
max-width: 160px;
}
.sourceSelect:focus { border-color: var(--border-strong); }
/* ── Scrollable body ─────────────────────────────────────────────────────── */
.body {
flex: 1;
overflow-y: auto;
padding: var(--sp-5) 0 var(--sp-6);
will-change: scroll-position;
-webkit-overflow-scrolling: touch;
}
/* ── Section ─────────────────────────────────────────────────────────────── */
.section {
margin-bottom: var(--sp-6);
}
.sectionHeader {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 var(--sp-6) var(--sp-3);
}
.sectionTitle {
font-family: var(--font-ui);
font-size: var(--text-xs);
font-weight: var(--weight-normal);
color: var(--text-faint);
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
}
.sectionTitleIcon {
display: inline-flex;
align-items: center;
gap: var(--sp-2);
}
.seeAll {
display: flex;
align-items: center;
gap: 4px;
font-family: var(--font-ui);
font-size: var(--text-2xs);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
color: var(--text-faint);
background: none;
border: none;
cursor: pointer;
padding: 2px 0;
transition: color var(--t-base);
}
.seeAll:hover { color: var(--accent-fg); }
/* ── Horizontal scroll row ───────────────────────────────────────────────── */
.row {
display: flex;
gap: var(--sp-3);
padding: 0 var(--sp-6);
overflow-x: auto;
scrollbar-width: none;
-ms-overflow-style: none;
scroll-behavior: smooth;
}
.row::-webkit-scrollbar { display: none; }
/* ── Card (shared by all rows) ───────────────────────────────────────────── */
.card {
flex-shrink: 0;
width: 110px;
background: none;
border: none;
padding: 0;
cursor: pointer;
text-align: left;
}
.card:hover .cover { filter: brightness(1.06); }
.card:hover .title { color: var(--text-primary); }
.coverWrap {
position: relative;
aspect-ratio: 2 / 3;
overflow: hidden;
border-radius: var(--radius-md);
background: var(--bg-raised);
border: 1px solid var(--border-dim);
transform: translateZ(0);
}
.cover {
width: 100%;
height: 100%;
object-fit: cover;
transition: filter var(--t-base);
will-change: filter;
}
.inLibraryBadge {
position: absolute;
bottom: var(--sp-1);
left: var(--sp-1);
font-family: var(--font-ui);
font-size: var(--text-2xs);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
background: var(--accent-muted);
color: var(--accent-fg);
border: 1px solid var(--accent-dim);
padding: 2px 5px;
border-radius: var(--radius-sm);
}
.progressBar {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 3px;
background: var(--bg-overlay);
}
.progressFill {
height: 100%;
background: var(--accent-fg);
border-radius: 0 2px 0 0;
transition: width 0.2s ease;
}
.title {
margin-top: var(--sp-2);
font-size: var(--text-sm);
color: var(--text-secondary);
line-height: var(--leading-snug);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
transition: color var(--t-base);
}
.subtitle {
font-family: var(--font-ui);
font-size: var(--text-2xs);
color: var(--text-faint);
margin-top: 2px;
letter-spacing: var(--tracking-wide);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Ghost card — invisible placeholder to fill row trailing space */
.ghostCard {
flex-shrink: 0;
width: 110px;
aspect-ratio: 2 / 3;
pointer-events: none;
visibility: hidden;
}
/* ── Skeleton ─────────────────────────────────────────────────────────────── */
.skeletonRow {
display: flex;
gap: var(--sp-3);
padding: 0 var(--sp-6);
overflow: hidden;
}
.cardSkeleton { flex-shrink: 0; width: 110px; }
.coverSkeleton {
aspect-ratio: 2 / 3;
border-radius: var(--radius-md);
}
.titleSkeleton {
height: 11px;
margin-top: var(--sp-2);
width: 80%;
}
/* ── Genre drill-down grid ───────────────────────────────────────────────── */
.drillRoot {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
animation: fadeIn 0.14s ease both;
}
.drillHeader {
display: flex;
align-items: center;
gap: var(--sp-3);
padding: var(--sp-4) var(--sp-6);
border-bottom: 1px solid var(--border-dim);
flex-shrink: 0;
}
.back {
display: flex;
align-items: center;
gap: var(--sp-2);
color: var(--text-muted);
font-size: var(--text-xs);
font-family: var(--font-ui);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
background: none;
border: none;
cursor: pointer;
padding: 0;
transition: color var(--t-base);
flex-shrink: 0;
}
.back:hover { color: var(--text-secondary); }
.drillTitle {
font-size: var(--text-base);
font-weight: var(--weight-medium);
color: var(--text-secondary);
letter-spacing: var(--tracking-tight);
}
.drillGrid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(clamp(100px, 14vw, 140px), 1fr));
gap: var(--sp-4);
padding: var(--sp-5) var(--sp-6);
overflow-y: auto;
flex: 1;
align-content: start;
will-change: scroll-position;
-webkit-overflow-scrolling: touch;
contain: layout style;
}
.drillCard {
background: none;
border: none;
padding: 0;
cursor: pointer;
text-align: left;
}
.drillCard:hover .cover { filter: brightness(1.06); }
.drillCard:hover .title { color: var(--text-primary); }
/* ── Empty state ─────────────────────────────────────────────────────────── */
.empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: var(--sp-8) var(--sp-6);
color: var(--text-faint);
font-family: var(--font-ui);
font-size: var(--text-xs);
letter-spacing: var(--tracking-wide);
gap: var(--sp-2);
text-align: center;
}
.emptyHint {
font-size: var(--text-2xs);
color: var(--text-faint);
opacity: 0.6;
}
/* ── No source state ─────────────────────────────────────────────────────── */
.noSource {
display: flex;
align-items: center;
justify-content: center;
padding: var(--sp-4) var(--sp-6);
color: var(--text-faint);
font-family: var(--font-ui);
font-size: var(--text-xs);
letter-spacing: var(--tracking-wide);
}
/* ── Explore More end-cap card ───────────────────────────────────────────── */
.exploreMoreCard {
flex-shrink: 0;
width: 110px;
aspect-ratio: 2 / 3;
border-radius: var(--radius-md);
border: 1px dashed var(--border-strong);
background: var(--bg-raised);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: border-color var(--t-base), background var(--t-base);
padding: 0;
}
.exploreMoreCard:hover {
border-color: var(--accent);
background: var(--accent-muted);
}
.exploreMoreCard:hover .exploreMoreIcon { color: var(--accent-fg); }
.exploreMoreCard:hover .exploreMoreLabel { color: var(--accent-fg); }
.exploreMoreInner {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--sp-2);
padding: var(--sp-3);
pointer-events: none;
}
.exploreMoreIcon {
color: var(--text-faint);
transition: color var(--t-base);
}
.exploreMoreLabel {
font-family: var(--font-ui);
font-size: var(--text-2xs);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
color: var(--text-faint);
transition: color var(--t-base);
text-align: center;
}
.exploreMoreGenre {
font-size: var(--text-2xs);
color: var(--text-faint);
opacity: 0.6;
text-align: center;
font-family: var(--font-ui);
letter-spacing: var(--tracking-wide);
}
+507
View File
@@ -0,0 +1,507 @@
import { useEffect, useState, useMemo, useRef, memo } from "react";
import { ArrowRight, Compass, List, BookOpen, Star, Fire, BookmarkSimple, FolderSimplePlus, Folder } from "@phosphor-icons/react";
import GenreDrillPage from "./GenreDrillPage";
import { gql, thumbUrl } from "../../lib/client";
import { UPDATE_MANGA } from "../../lib/queries";
import { cache, CACHE_KEYS, getTopSources } from "../../lib/cache";
import { dedupeSources, dedupeMangaByTitle } from "../../lib/sourceUtils";
import ContextMenu, { type ContextMenuEntry } from "../context/ContextMenu";
import { GET_SOURCES, FETCH_SOURCE_MANGA } from "../../lib/queries";
import { useStore } from "../../store";
import type { Manga, Source } from "../../lib/types";
import SourceList from "../sources/SourceList";
import SourceBrowse from "../sources/SourceBrowse";
import s from "./Explore.module.css";
// ── Frecency score ────────────────────────────────────────────────────────────
function frecencyScore(readAt: number, count: number): number {
const hoursSince = (Date.now() - readAt) / 3_600_000;
return count / Math.log(hoursSince + 2);
}
// ── Ghost / Skeleton ──────────────────────────────────────────────────────────
function GhostCard() { return <div className={s.ghostCard} aria-hidden />; }
const GHOST_COUNT = 3;
const ROW_CAP = 25;
// Hijack vertical wheel delta → horizontal scroll on .row divs
function handleRowWheel(e: React.WheelEvent<HTMLDivElement>) {
if (Math.abs(e.deltaY) <= Math.abs(e.deltaX)) return;
const el = e.currentTarget;
const canScrollLeft = el.scrollLeft > 0;
const canScrollRight = el.scrollLeft < el.scrollWidth - el.clientWidth - 1;
if (!canScrollLeft && !canScrollRight) return;
e.stopPropagation();
el.scrollLeft += e.deltaY;
}
function SkeletonRow({ count = 8 }: { count?: number }) {
return (
<div className={s.skeletonRow}>
{Array.from({ length: count }).map((_, i) => (
<div key={i} className={s.cardSkeleton}>
<div className={["skeleton", s.coverSkeleton].join(" ")} />
<div className={["skeleton", s.titleSkeleton].join(" ")} />
</div>
))}
</div>
);
}
// ── Cover image with fade-in ──────────────────────────────────────────────────
const CoverImg = memo(function CoverImg({ src, alt, className }: { src: string; alt: string; className?: string }) {
const [loaded, setLoaded] = useState(false);
return (
<img
src={src} alt={alt} className={className}
loading="lazy" decoding="async"
onLoad={() => setLoaded(true)}
style={{ opacity: loaded ? 1 : 0, transition: loaded ? "opacity 0.15s ease" : "none" }}
/>
);
});
// ── Mini card ─────────────────────────────────────────────────────────────────
const MiniCard = memo(function MiniCard({
manga, onClick, onContextMenu, subtitle, progress,
}: {
manga: Manga;
onClick: () => void;
onContextMenu?: (e: React.MouseEvent) => void;
subtitle?: string;
progress?: number;
}) {
return (
<button className={s.card} onClick={onClick} onContextMenu={onContextMenu}>
<div className={s.coverWrap}>
<CoverImg src={thumbUrl(manga.thumbnailUrl)} alt={manga.title} className={s.cover} />
{manga.inLibrary && <span className={s.inLibraryBadge}>Saved</span>}
{progress !== undefined && progress > 0 && (
<div className={s.progressBar}>
<div className={s.progressFill} style={{ width: `${progress * 100}%` }} />
</div>
)}
</div>
<p className={s.title}>{manga.title}</p>
{subtitle && <p className={s.subtitle}>{subtitle}</p>}
</button>
);
});
// ── Explore More end-cap ──────────────────────────────────────────────────────
const ExploreMoreCard = memo(function ExploreMoreCard({
genre, onClick,
}: { genre: string; onClick: () => void }) {
return (
<button className={s.exploreMoreCard} onClick={onClick} title={`See all ${genre} manga`}>
<div className={s.exploreMoreInner}>
<ArrowRight size={20} weight="light" className={s.exploreMoreIcon} />
<span className={s.exploreMoreLabel}>Explore more</span>
<span className={s.exploreMoreGenre}>{genre}</span>
</div>
</button>
);
});
// ── Section ───────────────────────────────────────────────────────────────────
function Section({
title, icon, onSeeAll, loading, children,
}: {
title: string; icon?: React.ReactNode; onSeeAll?: () => void;
loading?: boolean; children: React.ReactNode;
}) {
return (
<div className={s.section}>
<div className={s.sectionHeader}>
<span className={s.sectionTitle}>
<span className={s.sectionTitleIcon}>{icon}{title}</span>
</span>
{onSeeAll && (
<button className={s.seeAll} onClick={onSeeAll}>
See all <ArrowRight size={11} weight="light" />
</button>
)}
</div>
{loading ? <SkeletonRow /> : children}
</div>
);
}
// ── Main component ────────────────────────────────────────────────────────────
type ExploreMode = "explore" | "sources";
export default function Explore() {
const [mode, setMode] = useState<ExploreMode>("explore");
const activeSource = useStore((s) => s.activeSource);
const genreFilter = useStore((s) => s.genreFilter);
if (activeSource) return <SourceBrowse />;
if (genreFilter) return <GenreDrillPage />;
return (
<div className={s.root}>
<div className={s.header}>
<div className={s.headerLeft}>
<h1 className={s.heading}>Explore</h1>
<div className={s.tabs}>
<button
className={[s.tab, mode === "explore" ? s.tabActive : ""].join(" ").trim()}
onClick={() => setMode("explore")}
>
<Compass size={11} weight="bold" /> Explore
</button>
<button
className={[s.tab, mode === "sources" ? s.tabActive : ""].join(" ").trim()}
onClick={() => setMode("sources")}
>
<List size={11} weight="bold" /> Sources
</button>
</div>
</div>
</div>
{/* Keep ExploreFeed always mounted so data survives tab switches */}
<div style={{ display: mode === "explore" ? "contents" : "none" }}><ExploreFeed /></div>
{mode === "sources" && <SourceList />}
</div>
);
}
// ── Explore feed ──────────────────────────────────────────────────────────────
const FOUNDATIONAL_GENRES = ["Action", "Romance", "Fantasy", "Adventure", "Comedy", "Drama"];
// Single query replacing GET_ALL_MANGA + GET_LIBRARY merge
const EXPLORE_ALL_MANGA = `
query ExploreAllManga {
mangas(orderBy: IN_LIBRARY_AT, orderByType: DESC) {
nodes {
id title thumbnailUrl inLibrary genre status
source { id displayName }
}
}
}
`;
// Fast genre row query against the local DB
const MANGAS_BY_GENRE_EXPLORE = `
query MangasByGenreExplore($genre: String!, $first: Int) {
mangas(
filter: { genre: { includesInsensitive: $genre } }
first: $first
orderBy: IN_LIBRARY_AT
orderByType: DESC
) {
nodes {
id title thumbnailUrl inLibrary genre status
source { id displayName }
}
}
}
`;
function ExploreFeed() {
const [allManga, setAllManga] = useState<Manga[]>([]);
const [loadingLib, setLoadingLib] = useState(true);
const [popularManga, setPopularManga] = useState<Manga[]>([]);
const [loadingPopular, setLoadingPopular] = useState(true);
const [genreResults, setGenreResults] = useState<Map<string, Manga[]>>(new Map());
const [loadingGenres, setLoadingGenres] = useState(false);
const [sources, setSources] = useState<Source[]>([]);
const [loadError, setLoadError] = useState(false);
const [retryCount, setRetryCount] = useState(0);
const abortRef = useRef<AbortController | null>(null);
const fetchedGenresRef = useRef<string>("");
const history = useStore((s) => s.history);
const settings = useStore((s) => s.settings);
const setPreviewManga = useStore((s) => s.setPreviewManga);
const setGenreFilter = useStore((s) => s.setGenreFilter);
const folders = useStore((s) => s.settings.folders);
const addFolder = useStore((s) => s.addFolder);
const assignMangaToFolder = useStore((s) => s.assignMangaToFolder);
const [ctx, setCtx] = useState<{ x: number; y: number; manga: Manga } | null>(null);
useEffect(() => {
return () => { abortRef.current?.abort(); };
}, []);
function openCtx(e: React.MouseEvent, m: Manga) {
e.preventDefault(); e.stopPropagation();
setCtx({ x: e.clientX, y: e.clientY, manga: m });
}
function buildCtxItems(m: Manga): ContextMenuEntry[] {
return [
{
label: m.inLibrary ? "In Library" : "Add to library",
icon: <BookmarkSimple size={13} weight={m.inLibrary ? "fill" : "light"} />,
disabled: m.inLibrary,
onClick: () => gql(UPDATE_MANGA, { id: m.id, inLibrary: true })
.then(() => { cache.clear(CACHE_KEYS.LIBRARY); })
.catch(console.error),
},
...(folders.length > 0 ? [
{ separator: true } as ContextMenuEntry,
...folders.map((f): ContextMenuEntry => ({
label: f.mangaIds.includes(m.id) ? `${f.name}` : f.name,
icon: <Folder size={13} weight={f.mangaIds.includes(m.id) ? "fill" : "light"} />,
onClick: () => assignMangaToFolder(f.id, m.id),
})),
] : []),
{ separator: true },
{
label: "New folder & add",
icon: <FolderSimplePlus size={13} weight="light" />,
onClick: () => {
const name = prompt("Folder name:");
if (name?.trim()) { const id = addFolder(name.trim()); assignMangaToFolder(id, m.id); }
},
},
];
}
// ── Data load ─────────────────────────────────────────────────────────────
// Library + genre rows: single local DB query each — instant, no source calls.
// Popular: still needs fetchSourceManga since there's no local equivalent.
useEffect(() => {
const alreadyLoaded = allManga.length > 0;
if (alreadyLoaded) return;
setLoadingLib(true);
setLoadingPopular(true);
setLoadError(false);
const preferredLang = settings.preferredExtensionLang || "en";
if (retryCount > 0) {
cache.clear(CACHE_KEYS.LIBRARY);
cache.clear(CACHE_KEYS.SOURCES);
fetchedGenresRef.current = "";
}
// Single query for all manga — library flag included
cache.get(CACHE_KEYS.LIBRARY, () =>
gql<{ mangas: { nodes: Manga[] } }>(EXPLORE_ALL_MANGA)
.then((d) => d.mangas.nodes)
).then(setAllManga)
.catch((e) => { console.error(e); setLoadError(true); })
.finally(() => setLoadingLib(false));
// Sources — only needed for Popular section
cache.get(CACHE_KEYS.SOURCES, () =>
gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
.then((d) => dedupeSources(d.sources.nodes, preferredLang))
).then((allSources) => {
if (allSources.length === 0) { setLoadingPopular(false); return; }
const topSources = getTopSources(allSources).slice(0, 2);
setSources(allSources);
cache.get(CACHE_KEYS.POPULAR, () =>
Promise.allSettled(
topSources.map((src) =>
gql<{ fetchSourceManga: { mangas: Manga[] } }>(FETCH_SOURCE_MANGA, {
source: src.id, type: "POPULAR", page: 1, query: null,
}).then((d) => d.fetchSourceManga.mangas)
)
).then((results) => {
const merged: Manga[] = [];
for (const r of results)
if (r.status === "fulfilled") merged.push(...r.value);
return dedupeMangaByTitle(merged).slice(0, 30);
})
).then(setPopularManga).catch(console.error).finally(() => setLoadingPopular(false));
}).catch((e) => { console.error(e); setLoadError(true); setLoadingPopular(false); });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [retryCount]);
// ── Frecency genres (derived from history + library) ──────────────────────
const frecencyGenres = useMemo(() => {
const mangaScores = new Map<number, number>();
const mangaReadAt = new Map<number, number>();
for (const entry of history) {
mangaScores.set(entry.mangaId, (mangaScores.get(entry.mangaId) ?? 0) + 1);
if (entry.readAt > (mangaReadAt.get(entry.mangaId) ?? 0))
mangaReadAt.set(entry.mangaId, entry.readAt);
}
const genreWeights = new Map<string, number>();
const mangaMap = new Map(allManga.map((m) => [m.id, m]));
for (const [mangaId, count] of mangaScores.entries()) {
const score = frecencyScore(mangaReadAt.get(mangaId) ?? 0, count);
for (const genre of mangaMap.get(mangaId)?.genre ?? [])
genreWeights.set(genre, (genreWeights.get(genre) ?? 0) + score);
}
if (genreWeights.size === 0)
allManga.filter((m) => m.inLibrary).forEach((m) =>
(m.genre ?? []).forEach((g) => genreWeights.set(g, (genreWeights.get(g) ?? 0) + 1)));
if (genreWeights.size === 0) return FOUNDATIONAL_GENRES.slice(0, 3);
return Array.from(genreWeights.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, 3)
.map(([g]) => g);
}, [allManga, history]);
// ── Genre rows: query local DB directly ─────────────────────────────────
// One query per genre against the local mangas table — instant, no source I/O.
useEffect(() => {
if (frecencyGenres.length === 0 || allManga.length === 0) return;
const genreKey = frecencyGenres.join(",");
if (fetchedGenresRef.current === genreKey) return;
fetchedGenresRef.current = genreKey;
setLoadingGenres(true);
abortRef.current?.abort();
const ctrl = new AbortController();
abortRef.current = ctrl;
const streamingMap = new Map<string, Manga[]>();
Promise.allSettled(
frecencyGenres.map((genre) =>
cache.get(CACHE_KEYS.GENRE(genre), () =>
gql<{ mangas: { nodes: Manga[] } }>(
MANGAS_BY_GENRE_EXPLORE,
{ genre, first: 25 },
ctrl.signal,
).then((d) => d.mangas.nodes)
).then((mangas) => {
if (ctrl.signal.aborted) return;
streamingMap.set(genre, mangas);
setGenreResults(new Map(streamingMap));
})
)
)
.catch((e) => { if (e?.name !== "AbortError") console.error(e); })
.finally(() => { if (!ctrl.signal.aborted) setLoadingGenres(false); });
}, [frecencyGenres, allManga]);
function openManga(m: Manga) { setPreviewManga(m); }
// ── Continue reading ──────────────────────────────────────────────────────
const continueReading = useMemo(() => {
const mangaMap = new Map(allManga.map((m) => [m.id, m]));
const seen = new Set<number>();
const result: { manga: Manga; chapterName: string; progress: number }[] = [];
for (const entry of history) {
if (seen.has(entry.mangaId)) continue;
seen.add(entry.mangaId);
const manga = mangaMap.get(entry.mangaId);
if (!manga) continue;
result.push({ manga, chapterName: entry.chapterName, progress: entry.pageNumber > 0 ? Math.min(entry.pageNumber / 20, 1) : 0 });
if (result.length >= 12) break;
}
return result;
}, [history, allManga]);
// ── Recommended ───────────────────────────────────────────────────────────
const recommended = useMemo(() => {
if (allManga.length === 0 || frecencyGenres.length === 0) return [];
const continueIds = new Set(continueReading.map((r) => r.manga.id));
return allManga
.filter((m) => m.inLibrary && !continueIds.has(m.id) &&
frecencyGenres.some((g) => (m.genre ?? []).includes(g)))
.slice(0, 20);
}, [allManga, frecencyGenres, continueReading]);
const genresLoading = loadingGenres;
return (
<div className={s.body}>
{(continueReading.length > 0 || loadingLib) && (
<Section title="Continue Reading" icon={<BookOpen size={11} weight="bold" />} loading={loadingLib}>
<div className={s.row} onWheel={handleRowWheel}>
{continueReading.slice(0, ROW_CAP).map(({ manga, chapterName, progress }) => (
<MiniCard key={manga.id} manga={manga} onClick={() => openManga(manga)}
onContextMenu={(e) => openCtx(e, manga)} subtitle={chapterName} progress={progress} />
))}
{Array.from({ length: GHOST_COUNT }).map((_, i) => <GhostCard key={`ghost-cr-${i}`} />)}
</div>
</Section>
)}
{(recommended.length > 0 || loadingLib) && (
<Section title="Recommended for You" icon={<Star size={11} weight="bold" />} loading={loadingLib}>
<div className={s.row} onWheel={handleRowWheel}>
{recommended.slice(0, ROW_CAP).map((m) => (
<MiniCard key={m.id} manga={m} onClick={() => openManga(m)} onContextMenu={(e) => openCtx(e, m)} />
))}
{Array.from({ length: GHOST_COUNT }).map((_, i) => <GhostCard key={`ghost-rec-${i}`} />)}
</div>
</Section>
)}
{(popularManga.length > 0 || loadingPopular) && (
<Section
title={sources.length === 1 ? `Popular on ${sources[0].displayName}` : sources.length > 1 ? `Popular across ${sources.length} sources` : "Popular"}
icon={<Fire size={11} weight="bold" />}
loading={loadingPopular}
>
{sources.length === 0 ? (
<div className={s.noSource}>No sources installed. Add extensions first.</div>
) : (
<div className={s.row} onWheel={handleRowWheel}>
{popularManga.slice(0, ROW_CAP).map((m) => (
<MiniCard key={m.id} manga={m} onClick={() => openManga(m)} onContextMenu={(e) => openCtx(e, m)} />
))}
{Array.from({ length: GHOST_COUNT }).map((_, i) => <GhostCard key={`ghost-pop-${i}`} />)}
</div>
)}
</Section>
)}
{frecencyGenres.map((genre) => {
const items = genreResults.get(genre) ?? [];
const isLoading = genresLoading && items.length === 0;
if (!isLoading && items.length === 0) return null;
return (
<Section key={genre} title={genre} onSeeAll={() => setGenreFilter(genre)} loading={isLoading}>
<div className={s.row} onWheel={handleRowWheel}>
{items.slice(0, ROW_CAP).map((m) => (
<MiniCard key={m.id} manga={m} onClick={() => openManga(m)} onContextMenu={(e) => openCtx(e, m)} />
))}
{items.length >= ROW_CAP && (
<ExploreMoreCard genre={genre} onClick={() => setGenreFilter(genre)} />
)}
{Array.from({ length: GHOST_COUNT }).map((_, i) => <GhostCard key={`ghost-${genre}-${i}`} />)}
</div>
</Section>
);
})}
{!loadingLib && !loadingPopular && !loadingGenres &&
continueReading.length === 0 && recommended.length === 0 &&
popularManga.length === 0 && frecencyGenres.every((g) => !genreResults.get(g)?.length) && (
<div className={s.empty}>
{loadError ? (
<>
<span>Could not reach Suwayomi</span>
<span className={s.emptyHint}>Make sure the server is running, then try again.</span>
<button
style={{ marginTop: "var(--sp-3)", padding: "6px 16px", borderRadius: "var(--radius-md)", border: "1px solid var(--border-dim)", background: "var(--bg-raised)", color: "var(--text-muted)", cursor: "pointer", fontFamily: "var(--font-ui)", fontSize: "var(--text-xs)", letterSpacing: "var(--tracking-wide)" }}
onClick={() => { setLoadingLib(true); setLoadingPopular(true); setRetryCount((c) => c + 1); }}
>
Retry
</button>
</>
) : (
<>
<span>Nothing to explore yet</span>
<span className={s.emptyHint}>Add manga to your library or install sources to get started.</span>
</>
)}
</div>
)}
{ctx && (
<ContextMenu x={ctx.x} y={ctx.y} items={buildCtxItems(ctx.manga)} onClose={() => setCtx(null)} />
)}
</div>
);
}
@@ -0,0 +1,176 @@
.root {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
animation: fadeIn 0.14s ease both;
}
.header {
display: flex;
align-items: center;
gap: var(--sp-3);
padding: var(--sp-4) var(--sp-6);
border-bottom: 1px solid var(--border-dim);
flex-shrink: 0;
}
.back {
display: flex;
align-items: center;
gap: var(--sp-2);
color: var(--text-muted);
font-size: var(--text-xs);
font-family: var(--font-ui);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
background: none;
border: none;
cursor: pointer;
padding: 0;
transition: color var(--t-base);
flex-shrink: 0;
}
.back:hover { color: var(--text-secondary); }
.title {
font-size: var(--text-base);
font-weight: var(--weight-medium);
color: var(--text-secondary);
letter-spacing: var(--tracking-tight);
}
.loadingHint {
margin-left: auto;
font-family: var(--font-ui);
font-size: var(--text-2xs);
color: var(--text-faint);
letter-spacing: var(--tracking-wide);
}
/* Grid fills entire remaining height, no show-more needed */
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(clamp(100px, 13vw, 140px), 1fr));
gap: var(--sp-4);
padding: var(--sp-5) var(--sp-6) var(--sp-6);
overflow-y: auto;
flex: 1;
align-content: start;
/* Smooth GPU-accelerated scrolling */
will-change: scroll-position;
-webkit-overflow-scrolling: touch;
contain: layout style;
}
.card {
background: none;
border: none;
padding: 0;
cursor: pointer;
text-align: left;
}
.card:hover .cover { filter: brightness(1.06); }
.card:hover .cardTitle { color: var(--text-primary); }
.coverWrap {
position: relative;
aspect-ratio: 2 / 3;
overflow: hidden;
border-radius: var(--radius-md);
/* Solid bg shown while image fades in — matches skeleton color */
background: var(--bg-raised);
border: 1px solid var(--border-dim);
transform: translateZ(0);
}
.cover {
width: 100%;
height: 100%;
object-fit: cover;
transition: filter var(--t-base);
will-change: filter;
}
.inLibraryBadge {
position: absolute;
bottom: var(--sp-1);
left: var(--sp-1);
font-family: var(--font-ui);
font-size: var(--text-2xs);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
background: var(--accent-muted);
color: var(--accent-fg);
border: 1px solid var(--accent-dim);
padding: 2px 5px;
border-radius: var(--radius-sm);
}
.cardTitle {
margin-top: var(--sp-2);
font-size: var(--text-sm);
color: var(--text-secondary);
line-height: var(--leading-snug);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
transition: color var(--t-base);
}
/* Skeletons */
.cardSkeleton { padding: 0; }
.coverSkeleton { aspect-ratio: 2 / 3; border-radius: var(--radius-md); }
.titleSkeleton { height: 11px; margin-top: var(--sp-2); width: 75%; }
.empty {
display: flex;
align-items: center;
justify-content: center;
flex: 1;
color: var(--text-faint);
font-family: var(--font-ui);
font-size: var(--text-xs);
letter-spacing: var(--tracking-wide);
}
.resultCount {
margin-left: auto;
font-family: var(--font-ui);
font-size: var(--text-2xs);
color: var(--text-faint);
letter-spacing: var(--tracking-wide);
}
/* Show more — spans full grid width */
.showMoreCell {
grid-column: 1 / -1;
display: flex;
justify-content: center;
padding: var(--sp-2) 0 var(--sp-4);
}
.showMoreBtn {
display: flex;
align-items: center;
gap: var(--sp-2);
font-family: var(--font-ui);
font-size: var(--text-xs);
letter-spacing: var(--tracking-wide);
padding: 7px 20px;
border-radius: var(--radius-md);
background: var(--bg-raised);
color: var(--text-muted);
border: 1px solid var(--border-dim);
cursor: pointer;
transition: color var(--t-base), border-color var(--t-base);
}
.showMoreBtn:hover:not(:disabled) {
color: var(--text-secondary);
border-color: var(--border-strong);
}
.showMoreBtn:disabled {
opacity: 0.5;
cursor: default;
}
+384
View File
@@ -0,0 +1,384 @@
import { useEffect, useState, useMemo, useRef, memo, useCallback } from "react";
import { ArrowLeft, BookmarkSimple, FolderSimplePlus, Folder, CircleNotch } from "@phosphor-icons/react";
import { gql, thumbUrl } from "../../lib/client";
import { GET_ALL_MANGA, GET_LIBRARY, GET_SOURCES, FETCH_SOURCE_MANGA, UPDATE_MANGA } from "../../lib/queries";
import { cache, CACHE_KEYS, getPageSet } from "../../lib/cache";
import { dedupeSources, dedupeMangaById } from "../../lib/sourceUtils";
import { useStore } from "../../store";
import ContextMenu, { type ContextMenuEntry } from "../context/ContextMenu";
import type { Manga, Source } from "../../lib/types";
import s from "./GenreDrillPage.module.css";
// ── Constants ─────────────────────────────────────────────────────────────────
const PAGE_SIZE = 50;
const INITIAL_PAGES = 3;
const MAX_SOURCES = 12;
const CONCURRENCY = 4;
// ── Helpers ───────────────────────────────────────────────────────────────────
/**
* genreFilter in the store is either a single tag ("Action") or a `+`-joined
* multi-tag string ("Action+Romance"). Parse it into an array.
*
* Callers set multi-tag filters via:
* setGenreFilter("Action+Romance")
*
* The Explore feed's "See all" button continues to pass single strings and
* requires no change.
*/
function parseTags(genreFilter: string): string[] {
return genreFilter.split("+").map((t) => t.trim()).filter(Boolean);
}
/** "Action", "Action & Romance", "Action, Romance & Isekai" */
function tagsLabel(tags: string[]): string {
if (tags.length === 1) return tags[0];
return tags.slice(0, -1).join(", ") + " & " + tags[tags.length - 1];
}
/**
* Client-side AND filter.
* Sources only accept a single query string, so we send the first tag and
* drop results that don't also have the remaining tags in their genre list.
*/
function matchesAllTags(m: Manga, tags: string[]): boolean {
const genres = (m.genre ?? []).map((g) => g.toLowerCase());
return tags.every((t) => genres.includes(t.toLowerCase()));
}
async function runConcurrent<T>(
items: T[],
fn: (item: T) => Promise<void>,
signal: AbortSignal,
): Promise<void> {
let i = 0;
async function worker() {
while (i < items.length) {
if (signal.aborted) return;
const item = items[i++];
await fn(item).catch(() => {});
}
}
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, items.length) }, worker));
}
// ── CoverImg ──────────────────────────────────────────────────────────────────
const CoverImg = memo(function CoverImg({ src, alt, className }: { src: string; alt: string; className?: string }) {
const [loaded, setLoaded] = useState(false);
return (
<img
src={src} alt={alt} className={className}
loading="lazy" decoding="async"
onLoad={() => setLoaded(true)}
style={{ opacity: loaded ? 1 : 0, transition: loaded ? "opacity 0.15s ease" : "none" }}
/>
);
});
// ── GenreDrillPage ────────────────────────────────────────────────────────────
export default function GenreDrillPage() {
const genreFilter = useStore((st) => st.genreFilter);
const setGenreFilter = useStore((st) => st.setGenreFilter);
const setPreviewManga = useStore((st) => st.setPreviewManga);
const settings = useStore((st) => st.settings);
const folders = useStore((st) => st.settings.folders);
const addFolder = useStore((st) => st.addFolder);
const assignMangaToFolder = useStore((st) => st.assignMangaToFolder);
// Parse the filter string into individual tags
const tags = useMemo(() => parseTags(genreFilter), [genreFilter]);
// First tag is sent as the source query string (sources accept only one term)
const primaryTag = tags[0] ?? "";
const [libraryManga, setLibraryManga] = useState<Manga[]>([]);
const [sourceManga, setSourceManga] = useState<Manga[]>([]);
const [loadingInitial, setLoadingInitial] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [visibleCount, setVisibleCount] = useState(PAGE_SIZE);
const [ctx, setCtx] = useState<{ x: number; y: number; manga: Manga } | null>(null);
// Per-source next-page tracker; -1 means exhausted
const nextPageRef = useRef<Map<string, number>>(new Map());
const sourcesRef = useRef<Source[]>([]);
const abortRef = useRef<AbortController | null>(null);
// ── Initial load ─────────────────────────────────────────────────────────
useEffect(() => {
if (tags.length === 0) return;
abortRef.current?.abort();
const ctrl = new AbortController();
abortRef.current = ctrl;
setLoadingInitial(true);
setSourceManga([]);
setLibraryManga([]);
setVisibleCount(PAGE_SIZE);
nextPageRef.current = new Map();
const preferredLang = settings.preferredExtensionLang || "en";
// ── Library (local DB, instant) ───────────────────────────────────────
cache.get(CACHE_KEYS.LIBRARY, () =>
Promise.all([
gql<{ mangas: { nodes: Manga[] } }>(GET_ALL_MANGA),
gql<{ mangas: { nodes: Manga[] } }>(GET_LIBRARY),
]).then(([all, lib]) => {
const libMap = new Map(lib.mangas.nodes.map((m) => [m.id, m]));
return all.mangas.nodes.map((m) => libMap.get(m.id) ?? m);
})
)
.then((manga) => { if (!ctrl.signal.aborted) setLibraryManga(manga); })
.catch((e) => { if (e?.name !== "AbortError") console.error(e); });
// ── Sources: stream results as each source responds ───────────────────
// Source list is stable within a session — cache indefinitely.
cache.get(CACHE_KEYS.SOURCES, () =>
gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
.then((d) => dedupeSources(d.sources.nodes.filter((src) => src.id !== "0"), preferredLang)),
Infinity,
).then(async (allSources) => {
const sources = allSources.slice(0, MAX_SOURCES);
sourcesRef.current = sources;
for (const src of sources) nextPageRef.current.set(src.id, -1);
await runConcurrent(sources, async (src) => {
if (ctrl.signal.aborted) return;
// PageSet tracks which pages we've already fetched for this (source, tags) bucket.
// On navigation-away → back the pages are still in the TTL store, so fetchPage
// returns the cached promise immediately without hitting the network.
const ps = getPageSet(src.id, "SEARCH", tags);
const pageItems: Manga[] = [];
for (let page = 1; page <= INITIAL_PAGES; page++) {
if (ctrl.signal.aborted) return;
const pageKey = CACHE_KEYS.sourceMangaPage(src.id, "SEARCH", page, tags);
const result = await cache
.get<{ mangas: Manga[]; hasNextPage: boolean }>(
pageKey,
() => gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>(
FETCH_SOURCE_MANGA,
{ source: src.id, type: "SEARCH", page, query: primaryTag },
ctrl.signal,
).then((d) => d.fetchSourceManga),
)
.catch((e: any) => {
if (e?.name !== "AbortError") console.error(e);
return null;
});
if (!result || ctrl.signal.aborted) break;
ps.add(page);
// For multi-tag searches: client-side AND filter for tags beyond the first.
// Sources only support a single query string, so we send primaryTag and
// drop results that don't contain the remaining tags in their genre array.
const matching = tags.length > 1
? result.mangas.filter((m) => matchesAllTags(m, tags))
: result.mangas;
pageItems.push(...matching);
if (!result.hasNextPage) {
nextPageRef.current.set(src.id, -1);
break;
} else if (page === INITIAL_PAGES) {
nextPageRef.current.set(src.id, INITIAL_PAGES + 1);
}
}
if (!ctrl.signal.aborted && pageItems.length > 0) {
setSourceManga((prev) => dedupeMangaById([...prev, ...pageItems]));
setLoadingInitial(false);
}
}, ctrl.signal);
if (!ctrl.signal.aborted) setLoadingInitial(false);
}).catch((e) => {
if (e?.name !== "AbortError") console.error(e);
if (!ctrl.signal.aborted) setLoadingInitial(false);
});
return () => { ctrl.abort(); };
// genreFilter (not tags) as the dep — tags is derived from it and would
// cause an extra render on every parse; genreFilter is the stable identity.
}, [genreFilter]); // eslint-disable-line react-hooks/exhaustive-deps
// ── Derived merged list ───────────────────────────────────────────────────
const filtered = useMemo(() => {
// For multi-tag: library results must match ALL tags
const libMatches = libraryManga.filter((m) => matchesAllTags(m, tags));
const libIds = new Set(libMatches.map((m) => m.id));
const srcOnly = sourceManga.filter((m) => !libIds.has(m.id));
return dedupeMangaById([...libMatches, ...srcOnly]);
}, [libraryManga, sourceManga, tags]);
// ── Load more ─────────────────────────────────────────────────────────────
const hasMoreVisible = visibleCount < filtered.length;
const hasMoreNetwork = sourcesRef.current.some((src) => (nextPageRef.current.get(src.id) ?? -1) > 0);
const hasMore = hasMoreVisible || hasMoreNetwork;
const loadMore = useCallback(async () => {
if (loadingMore) return;
// Fast path: buffered results already in memory
if (hasMoreVisible) {
setVisibleCount((v) => v + PAGE_SIZE);
return;
}
// Slow path: fetch next pages from sources
const sources = sourcesRef.current.filter((src) => (nextPageRef.current.get(src.id) ?? -1) > 0);
if (!sources.length) return;
setLoadingMore(true);
abortRef.current?.abort();
const ctrl = new AbortController();
abortRef.current = ctrl;
try {
await runConcurrent(sources, async (src) => {
const page = nextPageRef.current.get(src.id)!;
if (ctrl.signal.aborted) return;
const ps = getPageSet(src.id, "SEARCH", tags);
const pageKey = CACHE_KEYS.sourceMangaPage(src.id, "SEARCH", page, tags);
const result = await cache
.get<{ mangas: Manga[]; hasNextPage: boolean }>(
pageKey,
() => gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>(
FETCH_SOURCE_MANGA,
{ source: src.id, type: "SEARCH", page, query: primaryTag },
ctrl.signal,
).then((d) => d.fetchSourceManga),
)
.catch((e: any) => {
if (e?.name !== "AbortError") nextPageRef.current.set(src.id, -1);
return null;
});
if (!result || ctrl.signal.aborted) return;
ps.add(page);
nextPageRef.current.set(src.id, result.hasNextPage ? page + 1 : -1);
const matching = tags.length > 1
? result.mangas.filter((m) => matchesAllTags(m, tags))
: result.mangas;
if (matching.length > 0)
setSourceManga((prev) => dedupeMangaById([...prev, ...matching]));
}, ctrl.signal);
} finally {
if (!ctrl.signal.aborted) {
setVisibleCount((v) => v + PAGE_SIZE);
setLoadingMore(false);
}
}
}, [loadingMore, hasMoreVisible, primaryTag, tags]);
// ── Context menu ──────────────────────────────────────────────────────────
function openCtx(e: React.MouseEvent, m: Manga) {
e.preventDefault(); e.stopPropagation();
setCtx({ x: e.clientX, y: e.clientY, manga: m });
}
function buildCtxItems(m: Manga): ContextMenuEntry[] {
return [
{
label: m.inLibrary ? "In Library" : "Add to library",
icon: <BookmarkSimple size={13} weight={m.inLibrary ? "fill" : "light"} />,
disabled: m.inLibrary,
onClick: () => gql(UPDATE_MANGA, { id: m.id, inLibrary: true })
.then(() => {
setSourceManga((prev) => prev.map((x) => x.id === m.id ? { ...x, inLibrary: true } : x));
cache.clear(CACHE_KEYS.LIBRARY);
})
.catch(console.error),
},
...(folders.length > 0 ? [
{ separator: true } as ContextMenuEntry,
...folders.map((f): ContextMenuEntry => ({
label: f.mangaIds.includes(m.id) ? `${f.name}` : f.name,
icon: <Folder size={13} weight={f.mangaIds.includes(m.id) ? "fill" : "light"} />,
onClick: () => assignMangaToFolder(f.id, m.id),
})),
] : []),
{ separator: true },
{
label: "New folder & add",
icon: <FolderSimplePlus size={13} weight="light" />,
onClick: () => {
const name = prompt("Folder name:");
if (name?.trim()) { const id = addFolder(name.trim()); assignMangaToFolder(id, m.id); }
},
},
];
}
const visibleItems = filtered.slice(0, visibleCount);
const label = tagsLabel(tags);
return (
<div className={s.root}>
<div className={s.header}>
<button className={s.back} onClick={() => setGenreFilter("")}>
<ArrowLeft size={13} weight="light" />
<span>Back</span>
</button>
<span className={s.title}>{label}</span>
{loadingInitial && filtered.length === 0 ? null : (
<span className={s.resultCount}>
{visibleItems.length}{filtered.length > visibleCount ? "+" : ""} of {filtered.length}
</span>
)}
{!loadingInitial && hasMoreNetwork && (
<span className={s.loadingHint}>More loading</span>
)}
</div>
{loadingInitial && filtered.length === 0 ? (
<div className={s.grid}>
{Array.from({ length: 50 }).map((_, i) => (
<div key={i} className={s.cardSkeleton}>
<div className={["skeleton", s.coverSkeleton].join(" ")} />
<div className={["skeleton", s.titleSkeleton].join(" ")} />
</div>
))}
</div>
) : filtered.length === 0 ? (
<div className={s.empty}>No manga found for "{label}".</div>
) : (
<div className={s.grid}>
{visibleItems.map((m) => (
<button key={m.id} className={s.card} onClick={() => setPreviewManga(m)} onContextMenu={(e) => openCtx(e, m)}>
<div className={s.coverWrap}>
<CoverImg src={thumbUrl(m.thumbnailUrl)} alt={m.title} className={s.cover} />
{m.inLibrary && <span className={s.inLibraryBadge}>Saved</span>}
</div>
<p className={s.cardTitle}>{m.title}</p>
</button>
))}
{hasMore && (
<div className={s.showMoreCell}>
<button className={s.showMoreBtn} onClick={loadMore} disabled={loadingMore}>
{loadingMore
? <><CircleNotch size={13} weight="light" className="anim-spin" style={{ display: "inline-block" }} /> Loading</>
: "Show more"}
</button>
</div>
)}
</div>
)}
{ctx && (
<ContextMenu x={ctx.x} y={ctx.y} items={buildCtxItems(ctx.manga)} onClose={() => setCtx(null)} />
)}
</div>
);
}
@@ -0,0 +1,395 @@
/* ── Animations ──────────────────────────────────────────────────────────── */
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
@keyframes scaleIn { from { opacity: 0; transform: scale(0.97) } to { opacity: 1; transform: scale(1) } }
@keyframes pulse { 0%,100% { opacity: 0.4 } 50% { opacity: 0.8 } }
/* ── Backdrop ────────────────────────────────────────────────────────────── */
.backdrop {
position: fixed; inset: 0;
background: rgba(0,0,0,0.72);
z-index: var(--z-settings);
display: flex; align-items: center; justify-content: center;
animation: fadeIn 0.12s ease both;
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
}
/* ── Modal shell ─────────────────────────────────────────────────────────── */
.modal {
width: min(800px, calc(100vw - 48px));
height: min(560px, calc(100vh - 80px));
display: flex;
background: var(--bg-surface);
border: 1px solid var(--border-base);
border-radius: var(--radius-xl);
overflow: hidden;
animation: scaleIn 0.16s ease both;
box-shadow: 0 0 0 1px var(--border-dim), 0 24px 64px rgba(0,0,0,0.6), 0 8px 24px rgba(0,0,0,0.4);
}
/* ── Cover column ────────────────────────────────────────────────────────── */
.coverCol {
width: 190px; flex-shrink: 0;
background: var(--bg-raised);
border-right: 1px solid var(--border-dim);
display: flex; flex-direction: column;
padding: var(--sp-5) var(--sp-4) var(--sp-4);
gap: var(--sp-3);
overflow-y: auto; overflow-x: hidden;
scrollbar-width: none;
}
.coverCol::-webkit-scrollbar { display: none; }
.coverWrap {
position: relative;
width: 100%;
}
.cover {
width: 100%; aspect-ratio: 2 / 3; object-fit: cover;
border-radius: var(--radius-md);
border: 1px solid var(--border-dim);
display: block;
}
.coverSpinner {
position: absolute; inset: 0;
display: flex; align-items: center; justify-content: center;
background: rgba(0,0,0,0.35);
border-radius: var(--radius-md);
color: var(--text-faint);
}
.coverActions {
display: flex; flex-direction: column; gap: var(--sp-2);
}
/* ── Cover action buttons ────────────────────────────────────────────────── */
.actionBtn {
display: flex; align-items: center; justify-content: center; gap: var(--sp-2);
width: 100%; padding: 7px var(--sp-3);
border-radius: var(--radius-md);
font-family: var(--font-ui); font-size: var(--text-xs);
letter-spacing: var(--tracking-wide);
border: 1px solid var(--border-strong);
background: none; color: var(--text-muted);
cursor: pointer;
transition: color var(--t-base), border-color var(--t-base), background var(--t-base);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
text-align: center;
}
.actionBtn:hover:not(:disabled) { color: var(--accent-fg); border-color: var(--accent); background: var(--accent-muted); }
.actionBtn:disabled { opacity: 0.4; cursor: default; }
.actionBtnActive {
background: var(--accent-muted);
border-color: var(--accent-dim);
color: var(--accent-fg);
}
.actionBtnActive:hover:not(:disabled) { filter: brightness(1.1); }
.actionBtnFolder { color: var(--text-secondary); border-color: var(--border-strong); }
.actionBtnLabel {
flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0;
}
/* ── Folder picker ───────────────────────────────────────────────────────── */
.folderWrap { position: relative; width: 100%; }
.folderMenu {
position: absolute;
bottom: calc(100% + 4px); left: 0; right: 0;
background: var(--bg-raised);
border: 1px solid var(--border-base);
border-radius: var(--radius-md);
padding: var(--sp-1);
display: flex; flex-direction: column; gap: 1px;
box-shadow: 0 8px 24px rgba(0,0,0,0.5);
z-index: 10;
animation: scaleIn 0.1s ease both;
transform-origin: bottom center;
}
.folderEmpty {
font-family: var(--font-ui); font-size: var(--text-xs);
color: var(--text-faint); padding: var(--sp-2) var(--sp-3);
}
.folderItem {
display: flex; align-items: center; gap: var(--sp-2);
padding: 6px var(--sp-3); border-radius: var(--radius-sm);
font-family: var(--font-ui); font-size: var(--text-xs);
color: var(--text-muted);
background: none; border: none; cursor: pointer; text-align: left;
transition: background var(--t-fast), color var(--t-fast);
}
.folderItem:hover { background: var(--bg-overlay); color: var(--text-primary); }
.folderItemOn { color: var(--accent-fg); }
.folderDivider { height: 1px; background: var(--border-dim); margin: var(--sp-1) 0; }
.folderCreateRow {
display: flex; gap: var(--sp-1); padding: var(--sp-1);
}
.folderInput {
flex: 1; background: var(--bg-overlay);
border: 1px solid var(--border-strong);
border-radius: var(--radius-sm); padding: 4px 8px;
color: var(--text-secondary); font-family: var(--font-ui); font-size: var(--text-xs);
outline: none; min-width: 0;
}
.folderInput:focus { border-color: var(--border-focus); }
.folderOkBtn {
font-family: var(--font-ui); font-size: var(--text-xs);
padding: 4px 8px; border-radius: var(--radius-sm);
border: 1px solid var(--border-strong);
background: none; color: var(--text-muted); cursor: pointer; flex-shrink: 0;
transition: color var(--t-base);
}
.folderOkBtn:disabled { opacity: 0.4; cursor: default; }
.folderOkBtn:not(:disabled):hover { color: var(--accent-fg); border-color: var(--accent); }
.folderNewBtn {
padding: 6px var(--sp-3); border-radius: var(--radius-sm);
font-family: var(--font-ui); font-size: var(--text-xs);
color: var(--text-faint); background: none; border: none;
cursor: pointer; text-align: left; width: 100%;
transition: color var(--t-fast);
}
.folderNewBtn:hover { color: var(--accent-fg); }
/* ── Content column ──────────────────────────────────────────────────────── */
.content {
flex: 1; display: flex; flex-direction: column; overflow: hidden; min-width: 0;
}
/* ── Header ──────────────────────────────────────────────────────────────── */
.contentHeader {
display: flex; align-items: flex-start; justify-content: space-between;
gap: var(--sp-4); padding: var(--sp-5) var(--sp-6) var(--sp-4);
border-bottom: 1px solid var(--border-dim); flex-shrink: 0;
}
.titleBlock {
flex: 1; min-width: 0; display: flex; flex-direction: column; gap: var(--sp-1);
}
.title {
font-size: var(--text-lg); font-weight: var(--weight-medium);
color: var(--text-primary); letter-spacing: var(--tracking-tight);
line-height: var(--leading-tight);
}
.byline {
font-size: var(--text-sm); color: var(--text-muted); line-height: var(--leading-snug);
}
.skByline {
height: 14px; width: 55%;
background: var(--bg-overlay); border-radius: var(--radius-sm);
animation: pulse 1.4s ease infinite;
}
.closeBtn {
display: flex; align-items: center; justify-content: center;
width: 28px; height: 28px; border-radius: var(--radius-sm);
color: var(--text-faint); border: none; background: none;
cursor: pointer; flex-shrink: 0;
transition: color var(--t-base), background var(--t-base);
}
.closeBtn:hover { color: var(--text-muted); background: var(--bg-raised); }
/* ── Scrollable body ─────────────────────────────────────────────────────── */
.contentBody {
flex: 1; overflow-y: auto;
padding: var(--sp-5) var(--sp-6);
display: flex; flex-direction: column; gap: var(--sp-4);
scrollbar-width: thin;
}
/* ── Error banner ────────────────────────────────────────────────────────── */
.errorBanner {
font-family: var(--font-ui); font-size: var(--text-xs);
color: var(--color-warn, #f59e0b);
background: color-mix(in srgb, var(--color-warn, #f59e0b) 10%, transparent);
border: 1px solid color-mix(in srgb, var(--color-warn, #f59e0b) 25%, transparent);
border-radius: var(--radius-sm); padding: 6px var(--sp-3);
}
/* ── Skeleton rows ───────────────────────────────────────────────────────── */
.skRow {
display: flex; gap: var(--sp-2); align-items: center;
}
.skBadge {
height: 20px; width: 54px;
background: var(--bg-overlay); border-radius: var(--radius-sm);
animation: pulse 1.4s ease infinite;
}
.skDesc {
display: flex; flex-direction: column; gap: 8px; padding: var(--sp-2) 0;
}
.skLine {
height: 13px; background: var(--bg-overlay);
border-radius: var(--radius-sm);
animation: pulse 1.4s ease infinite;
}
/* ── Badges ──────────────────────────────────────────────────────────────── */
.badges { display: flex; flex-wrap: wrap; gap: var(--sp-2); }
.badge {
font-family: var(--font-ui); font-size: var(--text-2xs);
letter-spacing: var(--tracking-wide); text-transform: uppercase;
padding: 3px 8px; border-radius: var(--radius-sm);
border: 1px solid var(--border-dim);
background: var(--bg-raised); color: var(--text-faint);
}
.badgeGreen {
background: color-mix(in srgb, #22c55e 12%, transparent);
border-color: color-mix(in srgb, #22c55e 30%, transparent);
color: #22c55e;
}
.badgeDim { /* default */ }
.badgeAccent {
background: var(--accent-muted); border-color: var(--accent-dim); color: var(--accent-fg);
}
.badgeUnread {
background: color-mix(in srgb, #f59e0b 12%, transparent);
border-color: color-mix(in srgb, #f59e0b 30%, transparent);
color: #f59e0b;
}
.badgeNsfw {
background: color-mix(in srgb, #ef4444 12%, transparent);
border-color: color-mix(in srgb, #ef4444 30%, transparent);
color: #ef4444;
}
/* ── Chapter box — clearly separated from description ────────────────────── */
.chapterBox {
display: flex; flex-direction: column; gap: var(--sp-3);
padding: var(--sp-4);
background: var(--bg-raised);
border: 1px solid var(--border-dim);
border-radius: var(--radius-md);
}
.chapterLoading {
display: flex; align-items: center; gap: var(--sp-2);
}
.chapterLoadingLabel {
font-family: var(--font-ui); font-size: var(--text-xs);
color: var(--text-faint); letter-spacing: var(--tracking-wide);
}
.chapterMeta {
display: flex; align-items: center; justify-content: space-between; gap: var(--sp-3);
}
.chapterLabel {
font-family: var(--font-ui); font-size: var(--text-xs);
color: var(--text-muted); letter-spacing: var(--tracking-wide);
}
.dlAllBtn {
display: flex; align-items: center; gap: var(--sp-1);
font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide);
padding: 3px 10px; border-radius: var(--radius-sm);
border: 1px solid var(--border-dim); background: none; color: var(--text-faint);
cursor: pointer; flex-shrink: 0;
transition: color var(--t-base), border-color var(--t-base);
}
.dlAllBtn:hover:not(:disabled) { color: var(--text-muted); border-color: var(--border-strong); }
.dlAllBtn:disabled { opacity: 0.5; cursor: default; }
.progressTrack {
height: 3px; background: var(--bg-overlay);
border-radius: var(--radius-full); overflow: hidden;
}
.progressFill {
height: 100%; background: var(--accent);
border-radius: var(--radius-full);
transition: width 0.3s ease;
}
.readBtn {
display: flex; align-items: center; gap: var(--sp-2);
padding: 8px var(--sp-4);
border-radius: var(--radius-md);
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
background: var(--accent-muted); border: 1px solid var(--accent-dim); color: var(--accent-fg);
cursor: pointer; align-self: flex-start;
transition: filter var(--t-base);
}
.readBtn:hover { filter: brightness(1.1); }
/* ── Description block ───────────────────────────────────────────────────── */
.descBlock {
display: flex; flex-direction: column; gap: var(--sp-2);
border-top: 1px solid var(--border-dim); padding-top: var(--sp-3);
}
.desc {
font-size: var(--text-sm); color: var(--text-muted);
line-height: var(--leading-base);
display: -webkit-box; -webkit-line-clamp: 5; -webkit-box-orient: vertical; overflow: hidden;
}
.descOpen {
display: block; -webkit-line-clamp: unset; overflow: visible;
}
.descToggle {
display: flex; align-items: center; gap: var(--sp-1);
font-family: var(--font-ui); font-size: var(--text-xs);
color: var(--text-faint); background: none; border: none;
cursor: pointer; padding: 0; align-self: flex-start;
transition: color var(--t-base);
}
.descToggle:hover { color: var(--accent-fg); }
/* ── Genre tags ──────────────────────────────────────────────────────────── */
.genres { display: flex; flex-wrap: wrap; gap: var(--sp-1); }
.genreTag {
font-family: var(--font-ui); font-size: var(--text-2xs);
letter-spacing: var(--tracking-wide); padding: 3px 8px;
border-radius: var(--radius-sm); border: 1px solid var(--border-dim);
background: var(--bg-raised); color: var(--text-faint);
}
.genreTagClickable {
cursor: pointer;
transition: color var(--t-base), border-color var(--t-base), background var(--t-base);
}
.genreTagClickable:hover {
color: var(--accent-fg);
border-color: var(--accent-dim);
background: var(--accent-muted);
}
/* ── Metadata table ──────────────────────────────────────────────────────── */
.metaTable {
display: flex; flex-direction: column; gap: 1px;
border-top: 1px solid var(--border-dim); padding-top: var(--sp-3);
}
.metaRow {
display: flex; align-items: baseline; gap: var(--sp-3); padding: 5px 0;
}
.metaKey {
font-family: var(--font-ui); font-size: var(--text-xs);
color: var(--text-faint); letter-spacing: var(--tracking-wide);
text-transform: uppercase; min-width: 56px; flex-shrink: 0;
}
.metaVal {
font-size: var(--text-sm); color: var(--text-secondary);
line-height: var(--leading-snug);
}
.metaLink {
display: inline-flex; align-items: center; gap: 4px;
font-size: var(--text-sm); color: var(--accent-fg);
text-decoration: none; transition: opacity var(--t-base);
}
.metaLink:hover { opacity: 0.75; }
+569
View File
@@ -0,0 +1,569 @@
import { useEffect, useRef, useState, useCallback } from "react";
import {
X, BookmarkSimple, ArrowSquareOut, Play,
CircleNotch, Books, CaretDown, FolderSimplePlus, Folder,
} from "@phosphor-icons/react";
import { gql, thumbUrl } from "../../lib/client";
import {
GET_MANGA, GET_CHAPTERS, FETCH_MANGA, FETCH_CHAPTERS, UPDATE_MANGA, ENQUEUE_CHAPTERS_DOWNLOAD,
} from "../../lib/queries";
import { cache, CACHE_KEYS } from "../../lib/cache";
import { useStore } from "../../store";
import type { Manga, Chapter } from "../../lib/types";
import s from "./MangaPreview.module.css";
export default function MangaPreview() {
const previewManga = useStore((st) => st.previewManga);
const setPreviewManga = useStore((st) => st.setPreviewManga);
const setActiveManga = useStore((st) => st.setActiveManga);
const setNavPage = useStore((st) => st.setNavPage);
const setGenreFilter = useStore((st) => st.setGenreFilter);
const openReader = useStore((st) => st.openReader);
const addToast = useStore((st) => st.addToast);
const folders = useStore((st) => st.settings.folders);
const addFolder = useStore((st) => st.addFolder);
const assignMangaToFolder = useStore((st) => st.assignMangaToFolder);
const removeMangaFromFolder = useStore((st) => st.removeMangaFromFolder);
const [manga, setManga] = useState<Manga | null>(null);
const [chapters, setChapters] = useState<Chapter[]>([]);
const [loadingDetail, setLoadingDetail] = useState(false);
const [loadingChapters, setLoadingChapters] = useState(false);
const [togglingLib, setTogglingLib] = useState(false);
const [descExpanded, setDescExpanded] = useState(false);
const [folderOpen, setFolderOpen] = useState(false);
const [newFolderName, setNewFolderName] = useState("");
const [creatingFolder, setCreatingFolder] = useState(false);
const [queueingAll, setQueueingAll] = useState(false);
const [fetchError, setFetchError] = useState<string | null>(null);
const backdropRef = useRef<HTMLDivElement>(null);
const detailAbort = useRef<AbortController | null>(null);
const chapterAbort = useRef<AbortController | null>(null);
const folderRef = useRef<HTMLDivElement>(null);
const close = useCallback(() => {
detailAbort.current?.abort();
chapterAbort.current?.abort();
setPreviewManga(null);
setManga(null);
setChapters([]);
setDescExpanded(false);
setFolderOpen(false);
setCreatingFolder(false);
setNewFolderName("");
setFetchError(null);
}, [setPreviewManga]);
// ── Fetch detail + chapters on open ──────────────────────────────────────
useEffect(() => {
if (!previewManga) return;
// Abort any in-flight requests from previous manga
detailAbort.current?.abort();
chapterAbort.current?.abort();
const dCtrl = new AbortController();
const cCtrl = new AbortController();
detailAbort.current = dCtrl;
chapterAbort.current = cCtrl;
setManga(null);
setChapters([]);
setDescExpanded(false);
setFetchError(null);
setLoadingDetail(true);
setLoadingChapters(true);
const id = previewManga.id;
// ── Detail fetch strategy ─────────────────────────────────────────────
// For source/explore manga we must call FETCH_MANGA (mutation that
// hits the source and syncs to the local DB). GET_MANGA only works for
// manga already in the local DB with full metadata.
//
// Fast path: if we already cached a full record, use it directly.
// Slow path: always try FETCH_MANGA first — it never fails for valid IDs
// and returns the richest data. Fall back to GET_MANGA if it errors.
//
(async (): Promise<Manga> => {
const cacheKey = CACHE_KEYS.MANGA(id);
// Already have a cached rich record — no network needed
if (cache.has(cacheKey)) {
return cache.get(cacheKey, () =>
Promise.resolve(previewManga as Manga)
) as Promise<Manga>;
}
// Try FETCH_MANGA first — works for all manga regardless of whether
// they are in the local DB yet (it fetches from source and syncs).
try {
const d = await gql<{ fetchManga: { manga: Manga } }>(
FETCH_MANGA, { id }, dCtrl.signal
);
return d.fetchManga.manga;
} catch (e: any) {
if (e?.name === "AbortError") throw e;
// FETCH_MANGA failed (e.g. source offline) — fall back to local DB
const local = await gql<{ manga: Manga }>(
GET_MANGA, { id }, dCtrl.signal
).then((d) => d.manga);
if (local) return local;
throw new Error("Could not load manga details");
}
})()
.then((fullManga) => {
if (dCtrl.signal.aborted) return;
// Cache the rich record so re-opening is instant
if (!cache.has(CACHE_KEYS.MANGA(id))) {
cache.get(CACHE_KEYS.MANGA(id), () => Promise.resolve(fullManga));
}
setManga(fullManga);
setLoadingDetail(false);
})
.catch((e) => {
if (e?.name === "AbortError") return;
console.error("MangaPreview detail fetch:", e);
// Show whatever sparse data we have from previewManga
setManga(previewManga as Manga);
setFetchError("Could not load full details — showing cached data");
setLoadingDetail(false);
});
// ── Chapter fetch — local DB first, fall back to source fetch ────────
gql<{ chapters: { nodes: Chapter[] } }>(
GET_CHAPTERS, { mangaId: id }, cCtrl.signal
)
.then(async (d) => {
if (cCtrl.signal.aborted) return;
let nodes = [...d.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder);
// If no local chapters yet (explore/source manga), fetch from source
if (nodes.length === 0) {
try {
const fetched = await gql<{ fetchChapters: { chapters: Chapter[] } }>(
FETCH_CHAPTERS, { mangaId: id }, cCtrl.signal
);
if (!cCtrl.signal.aborted)
nodes = [...fetched.fetchChapters.chapters].sort((a, b) => a.sourceOrder - b.sourceOrder);
} catch (e: any) {
if (e?.name === "AbortError") return;
// Leave nodes empty — not a fatal error
}
}
if (!cCtrl.signal.aborted) setChapters(nodes);
})
.catch((e) => { if (e?.name !== "AbortError") console.error(e); })
.finally(() => { if (!cCtrl.signal.aborted) setLoadingChapters(false); });
return () => { dCtrl.abort(); cCtrl.abort(); };
}, [previewManga?.id]); // eslint-disable-line react-hooks/exhaustive-deps
// ── Keyboard close ────────────────────────────────────────────────────────
useEffect(() => {
if (!previewManga) return;
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") close(); };
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [previewManga, close]);
// ── Folder outside click ──────────────────────────────────────────────────
useEffect(() => {
if (!folderOpen) return;
const handler = (e: MouseEvent) => {
if (folderRef.current && !folderRef.current.contains(e.target as Node)) {
setFolderOpen(false); setCreatingFolder(false); setNewFolderName("");
}
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, [folderOpen]);
if (!previewManga) return null;
// Always show title/cover from previewManga immediately; upgrade to fetched manga when ready
const displayManga = manga ?? previewManga;
const totalCount = chapters.length;
const readCount = chapters.filter((c) => c.isRead).length;
const unreadCount = totalCount - readCount;
const downloadedCount = chapters.filter((c) => c.isDownloaded).length;
const bookmarkCount = chapters.filter((c) => c.isBookmarked).length;
const inLibrary = manga?.inLibrary ?? previewManga.inLibrary ?? false;
// Scanlators — deduplicated, non-empty
const scanlators = [...new Set(
chapters.map((c) => c.scanlator).filter((sc): sc is string => !!sc?.trim())
)];
// Publication date range from chapter upload dates
const uploadDates = chapters
.map((c) => c.uploadDate ? new Date(c.uploadDate).getTime() : null)
.filter((d): d is number => d !== null && !isNaN(d));
const firstUpload = uploadDates.length ? new Date(Math.min(...uploadDates)) : null;
const lastUpload = uploadDates.length ? new Date(Math.max(...uploadDates)) : null;
function formatDate(d: Date) {
return d.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" });
}
const statusLabel = displayManga.status
? displayManga.status.charAt(0) + displayManga.status.slice(1).toLowerCase()
: null;
const continueChapter = (() => {
if (!chapters.length) return null;
const asc = [...chapters];
const inProgress = asc.find((c) => !c.isRead && (c.lastPageRead ?? 0) > 0);
if (inProgress) return { ch: inProgress, label: `Continue · Ch.${inProgress.chapterNumber}` };
const firstUnread = asc.find((c) => !c.isRead);
if (firstUnread) return { ch: firstUnread, label: `Start · Ch.${firstUnread.chapterNumber}` };
return { ch: asc[0], label: "Read again" };
})();
async function toggleLibrary() {
if (!manga) return;
setTogglingLib(true);
const next = !manga.inLibrary;
await gql(UPDATE_MANGA, { id: manga.id, inLibrary: next }).catch(console.error);
const updated = { ...manga, inLibrary: next };
setManga(updated);
// Update cache so subsequent opens reflect new state
cache.clear(CACHE_KEYS.MANGA(manga.id));
cache.get(CACHE_KEYS.MANGA(manga.id), () => Promise.resolve(updated));
cache.clear(CACHE_KEYS.LIBRARY);
setTogglingLib(false);
addToast({ kind: "success", title: next ? "Added to library" : "Removed from library" });
}
async function downloadAll() {
const ids = chapters.filter((c) => !c.isDownloaded && !c.isRead).map((c) => c.id);
if (!ids.length) return;
setQueueingAll(true);
await gql(ENQUEUE_CHAPTERS_DOWNLOAD, { chapterIds: ids }).catch(console.error);
addToast({ kind: "download", title: "Downloading", body: `${ids.length} chapters queued` });
setQueueingAll(false);
}
function openSeriesDetail() {
setActiveManga(displayManga);
setNavPage("library");
close();
}
function handleFolderCreate() {
const name = newFolderName.trim();
if (!name || !previewManga) return;
const newId = addFolder(name);
assignMangaToFolder(newId, previewManga.id);
setNewFolderName("");
setCreatingFolder(false);
}
const assignedFolders = folders.filter((f) => f.mangaIds.includes(previewManga.id));
return (
<div
className={s.backdrop}
ref={backdropRef}
onClick={(e) => { if (e.target === backdropRef.current) close(); }}
>
<div className={s.modal} role="dialog" aria-label="Manga preview">
{/* ── Cover column ── */}
<div className={s.coverCol}>
<div className={s.coverWrap}>
<img
src={thumbUrl(previewManga.thumbnailUrl)}
alt={displayManga.title}
className={s.cover}
/>
{loadingDetail && (
<div className={s.coverSpinner}>
<CircleNotch size={18} weight="light" className="anim-spin" />
</div>
)}
</div>
<div className={s.coverActions}>
<button
className={[s.actionBtn, inLibrary ? s.actionBtnActive : ""].join(" ")}
onClick={toggleLibrary}
disabled={togglingLib || loadingDetail}
>
<BookmarkSimple size={13} weight={inLibrary ? "fill" : "light"} />
{togglingLib ? "…" : inLibrary ? "In Library" : "Add to Library"}
</button>
<button className={s.actionBtn} onClick={openSeriesDetail}>
<Books size={13} weight="light" />
Series Detail
</button>
{/* Folder picker */}
<div className={s.folderWrap} ref={folderRef}>
<button
className={[s.actionBtn, assignedFolders.length > 0 ? s.actionBtnFolder : ""].join(" ")}
onClick={() => setFolderOpen((p) => !p)}
>
<FolderSimplePlus size={13} weight={assignedFolders.length > 0 ? "fill" : "light"} />
<span className={s.actionBtnLabel}>
{assignedFolders.length > 0 ? assignedFolders.map((f) => f.name).join(", ") : "Add to folder"}
</span>
</button>
{folderOpen && (
<div className={s.folderMenu}>
{folders.length === 0 && !creatingFolder && (
<p className={s.folderEmpty}>No folders yet</p>
)}
{folders.map((f) => {
const isIn = f.mangaIds.includes(previewManga.id);
return (
<button key={f.id}
className={[s.folderItem, isIn ? s.folderItemOn : ""].join(" ")}
onClick={() => isIn
? removeMangaFromFolder(f.id, previewManga.id)
: assignMangaToFolder(f.id, previewManga.id)}
>
<Folder size={12} weight={isIn ? "fill" : "light"} />
{isIn ? "✓ " : ""}{f.name}
</button>
);
})}
<div className={s.folderDivider} />
{creatingFolder ? (
<div className={s.folderCreateRow}>
<input autoFocus className={s.folderInput} placeholder="Folder name…"
value={newFolderName} onChange={(e) => setNewFolderName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") handleFolderCreate();
if (e.key === "Escape") { setCreatingFolder(false); setNewFolderName(""); }
}}
/>
<button className={s.folderOkBtn} onClick={handleFolderCreate} disabled={!newFolderName.trim()}>Add</button>
</div>
) : (
<button className={s.folderNewBtn} onClick={() => setCreatingFolder(true)}>+ New folder</button>
)}
</div>
)}
</div>
</div>
</div>
{/* ── Content column ── */}
<div className={s.content}>
{/* Header — title visible immediately from previewManga */}
<div className={s.contentHeader}>
<div className={s.titleBlock}>
<h2 className={s.title}>{displayManga.title}</h2>
{loadingDetail
? <div className={s.skByline} />
: (displayManga.author || displayManga.artist)
? <p className={s.byline}>
{[displayManga.author, displayManga.artist]
.filter(Boolean).filter((v, i, a) => a.indexOf(v) === i).join(" · ")}
</p>
: null}
</div>
<button className={s.closeBtn} onClick={close}><X size={15} weight="light" /></button>
</div>
{/* Scrollable body */}
<div className={s.contentBody}>
{/* Error banner */}
{fetchError && (
<div className={s.errorBanner}>{fetchError}</div>
)}
{/* ── Badges ── */}
{loadingDetail ? (
<div className={s.skRow}>
<div className={s.skBadge} />
<div className={s.skBadge} style={{ width: 72 }} />
</div>
) : (
<div className={s.badges}>
{statusLabel && (
<span className={[s.badge,
displayManga.status === "ONGOING" ? s.badgeGreen : s.badgeDim
].join(" ")}>{statusLabel}</span>
)}
{displayManga.source && (
<span className={[s.badge, (displayManga.source as any).isNsfw ? s.badgeNsfw : ""].join(" ").trim()}>
{displayManga.source.displayName}{(displayManga.source as any).isNsfw ? " · 18+" : ""}
</span>
)}
{inLibrary && <span className={[s.badge, s.badgeAccent].join(" ")}>In Library</span>}
{!loadingChapters && unreadCount > 0 && (
<span className={[s.badge, s.badgeUnread].join(" ")}>{unreadCount} unread</span>
)}
{!loadingChapters && bookmarkCount > 0 && (
<span className={s.badge}>{bookmarkCount} bookmarked</span>
)}
</div>
)}
{/* ── Chapter section — visually separated box ── */}
<div className={s.chapterBox}>
{loadingChapters ? (
<div className={s.chapterLoading}>
<CircleNotch size={13} weight="light" className="anim-spin" style={{ color: "var(--text-faint)" }} />
<span className={s.chapterLoadingLabel}>Loading chapters</span>
</div>
) : totalCount > 0 ? (
<>
<div className={s.chapterMeta}>
<span className={s.chapterLabel}>
{totalCount} {totalCount === 1 ? "chapter" : "chapters"}
{readCount > 0 && ` · ${readCount} read`}
{unreadCount > 0 && readCount > 0 && ` · ${unreadCount} left`}
{downloadedCount > 0 && ` · ${downloadedCount} dl`}
</span>
{unreadCount > 0 && (
<button className={s.dlAllBtn} onClick={downloadAll} disabled={queueingAll}>
{queueingAll && <CircleNotch size={11} weight="light" className="anim-spin" />}
{queueingAll ? "Queuing…" : "Download unread"}
</button>
)}
</div>
{readCount > 0 && (
<div className={s.progressTrack}>
<div className={s.progressFill} style={{ width: `${(readCount / totalCount) * 100}%` }} />
</div>
)}
{continueChapter && (
<button className={s.readBtn}
onClick={() => { openReader(continueChapter.ch, chapters); close(); }}
>
<Play size={12} weight="fill" />
{continueChapter.label}
</button>
)}
</>
) : !loadingDetail ? (
<span className={s.chapterLabel} style={{ color: "var(--text-faint)" }}>
No chapters in local library
</span>
) : null}
</div>
{/* ── Description — clearly separated from chapter block ── */}
{loadingDetail ? (
<div className={s.skDesc}>
<div className={s.skLine} style={{ width: "100%" }} />
<div className={s.skLine} style={{ width: "88%" }} />
<div className={s.skLine} style={{ width: "70%" }} />
</div>
) : displayManga.description ? (
<div className={s.descBlock}>
<p className={[s.desc, descExpanded ? s.descOpen : ""].join(" ")}>
{displayManga.description}
</p>
{displayManga.description.length > 220 && (
<button className={s.descToggle} onClick={() => setDescExpanded((p) => !p)}>
{descExpanded ? "Show less" : "Show more"}
<CaretDown size={10} weight="light" style={{
transform: descExpanded ? "rotate(180deg)" : "none",
transition: "transform 0.15s ease",
}} />
</button>
)}
</div>
) : null}
{/* ── Genre tags ── */}
{!loadingDetail && displayManga.genre && displayManga.genre.length > 0 && (
<div className={s.genres}>
{displayManga.genre.map((g) => (
<button
key={g}
className={[s.genreTag, s.genreTagClickable].join(" ")}
title={`Browse "${g}"`}
onClick={() => {
setGenreFilter(g);
setNavPage("explore");
close();
}}
>
{g}
</button>
))}
</div>
)}
{/* ── Metadata table ── */}
{!loadingDetail && (
<div className={s.metaTable}>
{displayManga.author && (
<div className={s.metaRow}>
<span className={s.metaKey}>Author</span>
<span className={s.metaVal}>{displayManga.author}</span>
</div>
)}
{displayManga.artist && displayManga.artist !== displayManga.author && (
<div className={s.metaRow}>
<span className={s.metaKey}>Artist</span>
<span className={s.metaVal}>{displayManga.artist}</span>
</div>
)}
{statusLabel && (
<div className={s.metaRow}>
<span className={s.metaKey}>Status</span>
<span className={s.metaVal}>{statusLabel}</span>
</div>
)}
{displayManga.source && (
<div className={s.metaRow}>
<span className={s.metaKey}>Source</span>
<span className={s.metaVal}>{displayManga.source.displayName}</span>
</div>
)}
{!loadingChapters && scanlators.length > 0 && (
<div className={s.metaRow}>
<span className={s.metaKey}>{scanlators.length === 1 ? "Scanlator" : "Scanlators"}</span>
<span className={s.metaVal}>{scanlators.join(", ")}</span>
</div>
)}
{!loadingChapters && firstUpload && lastUpload && (
<div className={s.metaRow}>
<span className={s.metaKey}>Published</span>
<span className={s.metaVal}>
{firstUpload.getTime() === lastUpload.getTime()
? formatDate(firstUpload)
: `${formatDate(firstUpload)} ${formatDate(lastUpload)}`}
</span>
</div>
)}
{!loadingChapters && downloadedCount > 0 && (
<div className={s.metaRow}>
<span className={s.metaKey}>Downloaded</span>
<span className={s.metaVal}>{downloadedCount} / {totalCount} chapters</span>
</div>
)}
{!loadingChapters && bookmarkCount > 0 && (
<div className={s.metaRow}>
<span className={s.metaKey}>Bookmarks</span>
<span className={s.metaVal}>{bookmarkCount} chapter{bookmarkCount !== 1 ? "s" : ""}</span>
</div>
)}
{displayManga.realUrl && (
<div className={s.metaRow}>
<span className={s.metaKey}>Link</span>
<a href={displayManga.realUrl} target="_blank" rel="noreferrer" className={s.metaLink}>
Open <ArrowSquareOut size={11} weight="light" />
</a>
</div>
)}
</div>
)}
</div>
</div>
</div>
</div>
);
}

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