mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 09:19:56 -05:00
Compare commits
63 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3e4d322fb7 | |||
| db8a984270 | |||
| 18027baee1 | |||
| c5243ba30c | |||
| 13f2a483ca | |||
| 6de5207ce7 | |||
| 8c250021a0 | |||
| 584b917f98 | |||
| e9929747d2 | |||
| cbdf9e8be1 | |||
| d9a9427e3b | |||
| ae5d9748c7 | |||
| 6c39ef538f | |||
| 081becdd60 | |||
| c891cb349c | |||
| 8cef74bb98 | |||
| bf071dcfc7 | |||
| da788e90ba | |||
| b0efb183e8 | |||
| 745b6993de | |||
| bd79169f71 | |||
| 6fccf02614 | |||
| fa7cfdc4e6 | |||
| 9c614b38f8 | |||
| 30e50b5a1b | |||
| 8ef0a14363 | |||
| 4e2ad6cae7 | |||
| 9e56b1176c | |||
| d025d07e07 | |||
| f988641446 | |||
| 3dad4bc729 | |||
| 1af21efebd | |||
| b7197a09a7 | |||
| 50dd8d7e35 | |||
| b2eaea6552 | |||
| 35aae6d85a | |||
| 28e5f5625e | |||
| b99e4d9a3d | |||
| d5f50c6495 | |||
| 89cfa50aff | |||
| 5e591411e4 | |||
| 8aaaf2451a | |||
| 75cc767b58 | |||
| d30c623200 | |||
| 017e9bc6da | |||
| 3b8088a2bf | |||
| 2c5320dd1f | |||
| 1e35f304b6 | |||
| 61339ea006 | |||
| f161fc08a2 | |||
| 239960683b | |||
| 3b5efc85d0 | |||
| 7df3846e75 | |||
| 01f123f5be | |||
| 0e2371096b | |||
| 47ae80a7d2 | |||
| d98547d540 | |||
| 897ecfd316 | |||
| e3abc72f1b | |||
| 6b56db7cf2 | |||
| 93cedca6b5 | |||
| bee8117aac | |||
| 0bea9c22cb |
+20
-8
@@ -1,26 +1,33 @@
|
||||
# --- 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
|
||||
|
||||
# --- IDEs & OS ---
|
||||
.output
|
||||
.vercel
|
||||
.netlify
|
||||
.wrangler
|
||||
/.svelte-kit
|
||||
/build
|
||||
|
||||
.vscode/
|
||||
.idea/
|
||||
.DS_Store
|
||||
@@ -30,14 +37,19 @@ yarn-error.log*
|
||||
*.sln
|
||||
*.swp
|
||||
|
||||
# --- Tauri specific ---
|
||||
src-tauri/target/
|
||||
src-tauri/binaries/
|
||||
src-tauri/gen/
|
||||
|
||||
# --- Flatpak build artifacts ---
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
|
||||
build-dir/
|
||||
repo/
|
||||
dist/
|
||||
packaging/frontend-dist.tar.gz
|
||||
*.flatpak
|
||||
.flatpak-builder/\n# --- Staged sidecar binaries (platform-specific, never commit) ---\nsrc-tauri/binaries/
|
||||
./flatpak-builder
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
pkgname=moku
|
||||
pkgver=0.9.3
|
||||
pkgver=0.9.4
|
||||
pkgrel=1
|
||||
pkgdesc="Native Linux manga reader frontend for Suwayomi-Server"
|
||||
arch=('x86_64')
|
||||
@@ -13,27 +13,46 @@ 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=(
|
||||
'e7f3d70c81af2afd9933aab55372a8b0122bfd201dcf6077a61f2c69990aecf9'
|
||||
'fc1c8268b812e70e56460c8930ca8ae83bcd30eea5903ddfef4e30a3a9a5c1cc'
|
||||
'f589a422674252394c13b289a9c8be691905bf583efb7f4d5f1501ae5e91e6b3'
|
||||
)
|
||||
b2sums=(
|
||||
'SKIP'
|
||||
'SKIP'
|
||||
)
|
||||
|
||||
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"
|
||||
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" \
|
||||
TAURI_SKIP_DEVSERVER_CHECK=true cargo build \
|
||||
--release \
|
||||
--manifest-path src-tauri/Cargo.toml
|
||||
@@ -52,7 +71,7 @@ package() {
|
||||
cat > "$pkgdir/usr/lib/moku/tachidesk/default-conf/server.conf" << 'CONF'
|
||||
server.ip = "127.0.0.1"
|
||||
server.port = 4567
|
||||
server.webUIEnabled = true
|
||||
server.webUIEnabled = false
|
||||
server.initialOpenInBrowserEnabled = false
|
||||
server.systemTrayEnabled = false
|
||||
server.downloadAsCbz = true
|
||||
@@ -68,14 +87,14 @@ DATA_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/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"
|
||||
@@ -87,12 +106,12 @@ 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
|
||||
-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
|
||||
|
||||
install -Dm644 packaging/io.github.moku_project.Moku.desktop \
|
||||
@@ -105,6 +124,6 @@ LAUNCHER
|
||||
"$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"
|
||||
install -Dm644 LICENSE \
|
||||
"$pkgdir/usr/share/licenses/$pkgname/LICENSE"
|
||||
}
|
||||
|
||||
@@ -1,173 +1,42 @@
|
||||
<div align="center">
|
||||
<img src="docs/banner.svg" width="100%" alt="Moku" />
|
||||
</div>
|
||||
# sv
|
||||
|
||||
<div align="center">
|
||||
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
|
||||
|
||||
[](https://github.com/moku-project/Moku/releases/latest)
|
||||

|
||||
[](https://github.com/moku-project/Moku)
|
||||
[](https://discord.gg/x97hj8zR72)
|
||||
## Creating a project
|
||||
|
||||
</div>
|
||||
If you're seeing this, you've probably already done this step. Congrats!
|
||||
|
||||
<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" style="color: #a8c4a8;">View all screenshots →</a>
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
- **Library management** — organize manga into folders, track unread counts, filter by genre
|
||||
- **Per-folder sorting & filtering** — each folder has its own independent sort (unread, A–Z, recently read, latest chapter, and more) and publication status filter (Ongoing, Completed, Hiatus, etc.)
|
||||
- **Built-in reader** — single page, long strip, configurable fit modes, customizable keybinds
|
||||
- **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 they're marked as read (accessible from Series Detail)
|
||||
- **Discord Rich Presence** — shows the manga title, current chapter, and an elapsed timer in your Discord status; configurable in Settings → General
|
||||
- **Auto-start server** — optionally launch Suwayomi in the background on startup
|
||||
- **Multiple themes** — Dark, Light, Midnight, Warm, High Contrast, and more
|
||||
- **Auto-updates** — in-app update checker with silent background notifications
|
||||
- **Improved NSFW filtering** — expanded tag parser gives the Hide NSFW setting better coverage across sources
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
<div align="center">
|
||||
|
||||

|
||||

|
||||

|
||||
|
||||
</div>
|
||||
|
||||
### Windows
|
||||
|
||||
**winget:**
|
||||
|
||||
```powershell
|
||||
winget install Moku.Moku
|
||||
```sh
|
||||
# create a new project
|
||||
npx sv create my-app
|
||||
```
|
||||
|
||||
> Thanks to [@frozenKelp](https://github.com/frozenKelp) for setting up and maintaining the winget package through v0.9.0.
|
||||
To recreate this project with the same configuration:
|
||||
|
||||
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
|
||||
```sh
|
||||
# recreate this project
|
||||
pnpm dlx sv@0.15.3 create --template minimal --types ts --install pnpm .
|
||||
```
|
||||
|
||||
Or download the latest `moku.flatpak` from the [releases page](https://github.com/moku-project/Moku/releases/latest) and install manually:
|
||||
## Developing
|
||||
|
||||
```bash
|
||||
flatpak install moku.flatpak
|
||||
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
|
||||
|
||||
```sh
|
||||
npm run dev
|
||||
|
||||
# or start the server and open the app in a new browser tab
|
||||
npm run dev -- --open
|
||||
```
|
||||
|
||||
### Nix
|
||||
## Building
|
||||
|
||||
```bash
|
||||
nix run github:moku-project/Moku
|
||||
To create a production version of your app:
|
||||
|
||||
```sh
|
||||
npm run build
|
||||
```
|
||||
|
||||
Add to your flake:
|
||||
You can preview the production build with `npm run preview`.
|
||||
|
||||
```nix
|
||||
inputs.moku.url = "github:moku-project/Moku";
|
||||
```
|
||||
|
||||
### macOS
|
||||
|
||||
Download the `.dmg` from the [releases page](https://github.com/moku-project/Moku/releases/latest).
|
||||
|
||||
> **Note:** Builds are ad-hoc signed. On first launch you may need to run:
|
||||
> ```bash
|
||||
> xattr -rd com.apple.quarantine /Applications/Moku.app
|
||||
> ```
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
If you're not using the bundled Flatpak or Windows installer, [Suwayomi-Server](https://github.com/Suwayomi/Suwayomi-Server) must be running separately. By default Moku connects to `http://127.0.0.1:4567`.
|
||||
|
||||
You can point Moku at any Suwayomi instance — local or remote — via **Settings → General → Server URL**.
|
||||
|
||||
---
|
||||
|
||||
## Development
|
||||
|
||||
**Prerequisites:** [Rust](https://rustup.rs), [Node.js](https://nodejs.org), [pnpm](https://pnpm.io), and [Tauri v2 prerequisites](https://tauri.app/start/prerequisites/).
|
||||
|
||||
```bash
|
||||
git clone https://github.com/moku-project/Moku
|
||||
cd Moku
|
||||
pnpm install
|
||||
pnpm tauri:dev
|
||||
```
|
||||
|
||||
Or with Nix:
|
||||
|
||||
```bash
|
||||
nix develop
|
||||
pnpm install
|
||||
pnpm tauri:dev
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Stack
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| [Tauri v2](https://tauri.app) | Native app shell |
|
||||
| [Svelte 5](https://svelte.dev) + [TypeScript](https://www.typescriptlang.org) | UI |
|
||||
| [Vite](https://vitejs.dev) | Frontend bundler |
|
||||
| [Crane](https://github.com/ipetkov/crane) | Nix Rust builds |
|
||||
|
||||
---
|
||||
|
||||
## Community
|
||||
|
||||
Questions, feedback, or just want to hang out — join the Discord.
|
||||
|
||||
[](https://discord.gg/x97hj8zR72)
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
Distributed under the [Apache 2.0 License](./LICENSE).
|
||||
|
||||
---
|
||||
|
||||
## Disclaimer
|
||||
|
||||
Moku does not host or distribute any content. The developers have no affiliation with any content providers accessible through connected sources.
|
||||
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
|
||||
|
||||
@@ -1,39 +1,4 @@
|
||||
Major Revisions:
|
||||
- Moku-Share to Easily Migrate/Share Manga (Investigate Usecase/Feasibility)
|
||||
- Moku-Share allows exporting of Manga
|
||||
- Compressed Format (Storage)
|
||||
- Import as Local-Source
|
||||
- Takes existing Local-Source or Creates Own
|
||||
Revival of the TODO List!!!!!
|
||||
|
||||
Minor Revisions:
|
||||
- Investigate feasibility of Multi-Page Screenshot (Reader)
|
||||
- Revise Migration (https://github.com/Suwayomi/Suwayomi-WebUI/pull/1073)
|
||||
|
||||
Priority Bugs:
|
||||
- Fix Library-Refresh System (TESTING)
|
||||
|
||||
- Suwayomi RESET
|
||||
- Allow User to Wipe Suwayomi (Scratch)
|
||||
- If Possible, Component based Wipe (Library, Etc)
|
||||
|
||||
Pending/On-Hold:
|
||||
- Enable Cloudflare Bypass (Suwayomi Config) (Requires Patching)
|
||||
- Working on 3D Display Cards
|
||||
- Add Flathub Support (Pending Video)
|
||||
|
||||
- Change Auto-Link Threshold
|
||||
- Fix Auto-Link De-dupe for Images
|
||||
- Optimize Auto-Link Latency (IP)
|
||||
|
||||
In-Progress:
|
||||
- Fix Tracking Login
|
||||
- Pasting OAuth URL is not User-Friendly, Look for Alternatives
|
||||
|
||||
- Apply Syer's Fix for Library on Backup Load (Manga Metadata)
|
||||
- Note User's have to always install extensions manually
|
||||
- Create "Missing Source" for Manga
|
||||
|
||||
- Wire Series-Detail Refresh to Fix Manga-Metadata (MAJOR)
|
||||
|
||||
- UI LOGIN DOES NOT WORK OFFLINE
|
||||
Notes from last time:
|
||||
- Reminder to Completely Test Settings
|
||||
|
||||
Generated
-16
@@ -1,20 +1,5 @@
|
||||
{
|
||||
"nodes": {
|
||||
"crane": {
|
||||
"locked": {
|
||||
"lastModified": 1773857772,
|
||||
"narHash": "sha256-5xsK26KRHf0WytBtsBnQYC/lTWDhQuT57HJ7SzuqZcM=",
|
||||
"owner": "ipetkov",
|
||||
"repo": "crane",
|
||||
"rev": "b556d7bbae5ff86e378451511873dfd07e4504cd",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "ipetkov",
|
||||
"repo": "crane",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"flake-parts": {
|
||||
"inputs": {
|
||||
"nixpkgs-lib": "nixpkgs-lib"
|
||||
@@ -66,7 +51,6 @@
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"crane": "crane",
|
||||
"flake-parts": "flake-parts",
|
||||
"nixpkgs": "nixpkgs",
|
||||
"rust-overlay": "rust-overlay"
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
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";
|
||||
@@ -12,7 +11,7 @@
|
||||
};
|
||||
|
||||
outputs =
|
||||
inputs@{ flake-parts, crane, rust-overlay, ... }:
|
||||
inputs@{ flake-parts, rust-overlay, ... }:
|
||||
flake-parts.lib.mkFlake { inherit inputs; } {
|
||||
systems = [
|
||||
"x86_64-linux"
|
||||
@@ -22,7 +21,8 @@
|
||||
perSystem =
|
||||
{ system, lib, ... }:
|
||||
let
|
||||
version = "0.9.3";
|
||||
versions = import ./nix/versions.nix;
|
||||
version = versions.moku;
|
||||
|
||||
pkgs = import inputs.nixpkgs {
|
||||
inherit system;
|
||||
@@ -36,8 +36,6 @@
|
||||
];
|
||||
};
|
||||
|
||||
craneLib = (crane.mkLib pkgs).overrideToolchain rustToolchain;
|
||||
|
||||
runtimeLibs = with pkgs; [
|
||||
webkitgtk_4_1
|
||||
gtk3
|
||||
@@ -53,7 +51,7 @@
|
||||
gsettings-desktop-schemas
|
||||
];
|
||||
|
||||
frontendSrc = lib.cleanSourceWith {
|
||||
src = lib.cleanSourceWith {
|
||||
src = ./.;
|
||||
filter =
|
||||
path: type:
|
||||
@@ -61,51 +59,46 @@
|
||||
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 == "vite.config.ts";
|
||||
|| base == "vite.config.ts"
|
||||
|| base == "svelte.config.js"
|
||||
|| base == "Cargo.toml"
|
||||
|| base == "Cargo.lock"
|
||||
|| base == "build.rs"
|
||||
|| base == "tauri.conf.json";
|
||||
};
|
||||
|
||||
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");
|
||||
suwayomiServer = pkgs.callPackage ./nix/server.nix { inherit versions; };
|
||||
|
||||
moku = pkgs.callPackage ./nix/moku.nix {
|
||||
inherit lib pkgs rustToolchain runtimeLibs suwayomiServer version src versions;
|
||||
appIcon = ./src/lib/assets/moku-icon.svg;
|
||||
};
|
||||
|
||||
suwayomiServer = pkgs.callPackage ./nix/server.nix { };
|
||||
|
||||
frontend = pkgs.callPackage ./nix/frontend.nix {
|
||||
inherit version;
|
||||
src = frontendSrc;
|
||||
};
|
||||
|
||||
moku = import ./nix/moku.nix {
|
||||
inherit lib craneLib pkgs runtimeLibs frontend suwayomiServer version cargoSrc;
|
||||
appIcon = ./src/assets/moku-icon.svg;
|
||||
};
|
||||
|
||||
scripts = import ./nix/scripts.nix { inherit pkgs rustToolchain version; };
|
||||
scripts = import ./nix/scripts.nix { inherit pkgs rustToolchain version versions; };
|
||||
|
||||
in
|
||||
{
|
||||
packages = {
|
||||
inherit moku frontend suwayomiServer;
|
||||
inherit moku suwayomiServer;
|
||||
default = 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"; };
|
||||
post-tag-bump = { type = "app"; program = "${scripts.postTagBump}/bin/moku-post-tag-bump"; };
|
||||
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"; };
|
||||
tunnel = { type = "app"; program = "${scripts.tunnel}/bin/moku-tunnel"; };
|
||||
};
|
||||
|
||||
devShells.default = pkgs.mkShell {
|
||||
@@ -132,11 +125,11 @@
|
||||
|
||||
echo "Moku dev shell — pnpm install && pnpm tauri:dev"
|
||||
echo ""
|
||||
echo " nix run .#bump -- <ver>"
|
||||
echo " nix run .#bump -- <ver> bump version + rebuild frontend"
|
||||
echo " git commit && git tag && git push"
|
||||
echo " nix run .#post-tag-bump -- <ver>"
|
||||
echo " nix run .#flatpak -- <ver>"
|
||||
echo " nix run .#tunnel -- [port]"
|
||||
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"
|
||||
'';
|
||||
};
|
||||
|
||||
|
||||
@@ -32,6 +32,77 @@ 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:
|
||||
@@ -52,9 +123,6 @@ 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>
|
||||
@@ -117,19 +185,16 @@ modules:
|
||||
DATA_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/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.
|
||||
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"
|
||||
|
||||
# Append keys if absent
|
||||
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"
|
||||
@@ -166,10 +231,10 @@ 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
|
||||
PKG_CONFIG_PATH: /usr/lib/pkgconfig:/usr/share/pkgconfig:/app/lib/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 cargo build --release --manifest-path src-tauri/Cargo.toml
|
||||
- . /usr/lib/sdk/rust-stable/enable.sh && PKG_CONFIG_PATH=/usr/lib/pkgconfig:/usr/share/pkgconfig:/app/lib/pkgconfig cargo build --release --manifest-path src-tauri/Cargo.toml
|
||||
- install -Dm755 src-tauri/target/release/moku /app/bin/moku
|
||||
- install -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
|
||||
@@ -179,11 +244,11 @@ modules:
|
||||
sources:
|
||||
- type: git
|
||||
url: https://github.com/moku-project/Moku.git
|
||||
tag: v0.9.3
|
||||
commit: 83711c155d3e60ab4e2411ea6e0098231d76f8b9
|
||||
tag: v0.9.4
|
||||
commit: 239960683b6c7f1347e1798b0e179a8a46628728
|
||||
- type: file
|
||||
path: packaging/frontend-dist.tar.gz
|
||||
sha256: c690eb3cb24e89fec3f4e92f7a4a82d9a465b58f6680a332c1e44f1361ac96af
|
||||
sha256: 7db288b4b54277aa82b6ec5b21fc31a1e71f8246c50a74777500083b806c1fa5
|
||||
- packaging/cargo-sources.json
|
||||
- type: inline
|
||||
dest: src-tauri/.cargo
|
||||
|
||||
+7
-3
@@ -1,4 +1,4 @@
|
||||
{ lib, stdenv, nodejs_22, pnpm, pnpmConfigHook, fetchPnpmDeps, version, src }:
|
||||
{ lib, stdenv, nodejs_22, pnpm, pnpmConfigHook, fetchPnpmDeps, version, src, versions }:
|
||||
|
||||
stdenv.mkDerivation {
|
||||
pname = "moku-frontend";
|
||||
@@ -10,9 +10,13 @@ stdenv.mkDerivation {
|
||||
pname = "moku-frontend";
|
||||
inherit version src;
|
||||
fetcherVersion = 1;
|
||||
hash = "sha256-eRuSSRhNmJ09mp/uhbG+NFeiOZ5dTOdJ94OwdP6IkN0=";
|
||||
hash = versions.frontend.pnpmHash;
|
||||
};
|
||||
|
||||
buildPhase = "pnpm build";
|
||||
buildPhase = ''
|
||||
export HOME=$(mktemp -d)
|
||||
pnpm build:static
|
||||
'';
|
||||
|
||||
installPhase = "cp -r dist $out";
|
||||
}
|
||||
+51
-26
@@ -1,38 +1,61 @@
|
||||
{
|
||||
lib,
|
||||
craneLib,
|
||||
pkgs,
|
||||
rustToolchain,
|
||||
runtimeLibs,
|
||||
frontend,
|
||||
suwayomiServer,
|
||||
version,
|
||||
cargoSrc,
|
||||
versions,
|
||||
src,
|
||||
appIcon,
|
||||
}:
|
||||
|
||||
let
|
||||
commonArgs = {
|
||||
src = cargoSrc;
|
||||
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;
|
||||
strictDeps = true;
|
||||
buildInputs = runtimeLibs;
|
||||
nativeBuildInputs = with pkgs; [ pkg-config wrapGAppsHook3 ];
|
||||
preBuild = ''
|
||||
cp -r ${frontend} ../dist
|
||||
'';
|
||||
inherit version src;
|
||||
fetcherVersion = 1;
|
||||
hash = versions.frontend.pnpmHash;
|
||||
};
|
||||
|
||||
cargoArtifacts = craneLib.buildDepsOnly commonArgs;
|
||||
in
|
||||
craneLib.buildPackage (commonArgs // {
|
||||
inherit cargoArtifacts;
|
||||
cargoDeps = pkgs.rustPlatform.importCargoLock {
|
||||
lockFile = ../src-tauri/Cargo.lock;
|
||||
outputHashes = {
|
||||
"tauri-plugin-discord-rpc-0.1.0" = versions.gitDeps.tauri-plugin-discord-rpc;
|
||||
};
|
||||
};
|
||||
|
||||
meta.mainProgram = "moku";
|
||||
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
|
||||
|
||||
postInstall = ''
|
||||
mkdir -p "$out/share/applications"
|
||||
cat > "$out/share/applications/moku.desktop" << EOF
|
||||
cat > "$out/share/applications/moku.desktop" << EOF2
|
||||
[Desktop Entry]
|
||||
Version=1.0
|
||||
Type=Application
|
||||
@@ -44,17 +67,17 @@ Terminal=false
|
||||
Categories=Graphics;Viewer;
|
||||
Keywords=manga;comic;reader;suwayomi;
|
||||
StartupWMClass=moku
|
||||
EOF
|
||||
EOF2
|
||||
|
||||
for size in 32x32 128x128 256x256 512x512; do
|
||||
src="icons/$size.png"
|
||||
[ -f "$src" ] && install -Dm644 "$src" \
|
||||
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
|
||||
src="icons/''${size}@2x.png"
|
||||
[ -f "$src" ] && install -Dm644 "$src" \
|
||||
f="src-tauri/icons/''${size}@2x.png"
|
||||
[ -f "$f" ] && install -Dm644 "$f" \
|
||||
"$out/share/icons/hicolor/''${size}@2/apps/moku.png"
|
||||
done
|
||||
|
||||
@@ -71,4 +94,6 @@ EOF
|
||||
--set GDK_BACKEND wayland \
|
||||
--set WEBKIT_DISABLE_SANDBOX_THIS_IS_DANGEROUS 1
|
||||
'';
|
||||
})
|
||||
|
||||
meta.mainProgram = "moku";
|
||||
}
|
||||
|
||||
+68
-61
@@ -1,4 +1,4 @@
|
||||
{ pkgs, rustToolchain, version }:
|
||||
{ pkgs, rustToolchain, version, versions }:
|
||||
|
||||
{
|
||||
bump = pkgs.writeShellApplication {
|
||||
@@ -17,87 +17,98 @@
|
||||
VERSION="$1"
|
||||
REPO="$(git rev-parse --show-toplevel)"
|
||||
|
||||
echo "── Bumping version fields to $VERSION ──"
|
||||
sed -i "s/\"version\": \"[^\"]*\"/\"version\": \"$VERSION\"/" \
|
||||
"$REPO/src-tauri/tauri.conf.json"
|
||||
sed -i "0,/^version = \"[^\"]*\"/s//version = \"$VERSION\"/" \
|
||||
"$REPO/src-tauri/Cargo.toml"
|
||||
sed -i "s/version = \"[^\"]*\";/version = \"$VERSION\";/g" \
|
||||
"$REPO/flake.nix"
|
||||
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"
|
||||
echo "Done"
|
||||
sed -i "s/^pkgrel=.*/pkgrel=1/" "$REPO/PKGBUILD"
|
||||
|
||||
echo "── Regenerating Cargo.lock ──"
|
||||
(cd "$REPO/src-tauri" && cargo generate-lockfile)
|
||||
echo "Done"
|
||||
|
||||
echo "── Building frontend ──"
|
||||
cd "$REPO"
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm build
|
||||
echo "Done"
|
||||
pnpm build:static
|
||||
|
||||
echo "── Repacking frontend-dist.tar.gz ──"
|
||||
tar -czf "$REPO/packaging/frontend-dist.tar.gz" -C "$REPO" dist
|
||||
FRONTEND_SHA=$(sha256sum "$REPO/packaging/frontend-dist.tar.gz" | awk '{print $1}')
|
||||
echo "sha256: $FRONTEND_SHA"
|
||||
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
|
||||
|
||||
echo "── Regenerating cargo-sources.json ──"
|
||||
python3 "$REPO/packaging/flatpak-cargo-generator.py" \
|
||||
"$REPO/src-tauri/Cargo.lock" \
|
||||
-o "$REPO/packaging/cargo-sources.json"
|
||||
echo "Done"
|
||||
|
||||
echo "── Patching flatpak manifest ──"
|
||||
MANIFEST="$REPO/io.github.moku_project.Moku.yml"
|
||||
sed -i "s/tag: v[^[:space:]]*/tag: v$VERSION/" "$MANIFEST"
|
||||
python3 - "$MANIFEST" "$FRONTEND_SHA" <<'PYEOF'
|
||||
import re, sys
|
||||
path, sha = sys.argv[1], sys.argv[2]
|
||||
text = open(path).read()
|
||||
updated, n = re.subn(
|
||||
r'(path:\s*packaging/frontend-dist\.tar\.gz\s*\n\s*sha256:\s*)[0-9a-f]+',
|
||||
r'\g<1>' + sha, text)
|
||||
if n == 0:
|
||||
sys.exit("ERROR: could not find frontend-dist sha256 in manifest")
|
||||
open(path, 'w').write(updated)
|
||||
PYEOF
|
||||
echo "Done"
|
||||
|
||||
echo ""
|
||||
echo "Bumped to v$VERSION — commit, tag, push, then: nix run .#post-tag-bump -- $VERSION"
|
||||
echo "Bumped to v$VERSION — commit, tag, push, then: nix run .#update -- $VERSION"
|
||||
'';
|
||||
};
|
||||
|
||||
postTagBump = pkgs.writeShellApplication {
|
||||
name = "moku-post-tag-bump";
|
||||
runtimeInputs = with pkgs; [ gnused coreutils git curl ];
|
||||
update = pkgs.writeShellApplication {
|
||||
name = "moku-update";
|
||||
runtimeInputs = with pkgs; [ gnused coreutils git curl nix xxd ];
|
||||
text = ''
|
||||
[[ $# -lt 1 ]] && { echo "Usage: nix run .#post-tag-bump -- <version>"; exit 1; }
|
||||
VERSION="$1"
|
||||
REPO="$(git rev-parse --show-toplevel)"
|
||||
VERSIONS="$REPO/nix/versions.nix"
|
||||
MANIFEST="$REPO/io.github.moku_project.Moku.yml"
|
||||
PKGBUILD="$REPO/PKGBUILD"
|
||||
|
||||
echo "── Resolving commit for v$VERSION ──"
|
||||
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; }
|
||||
echo "commit: $COMMIT"
|
||||
sed -i "s/commit: [0-9a-f]\{40\}/commit: $COMMIT/" "$MANIFEST"
|
||||
echo "Done"
|
||||
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"
|
||||
|
||||
echo "── Fetching PKGBUILD tarball sha256 ──"
|
||||
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"
|
||||
grep -q "$TARBALL_SHA" "$PKGBUILD" \
|
||||
|| { echo "ERROR: PKGBUILD sha256 replacement failed"; exit 1; }
|
||||
echo "Done"
|
||||
|
||||
echo ""
|
||||
echo "post-tag-bump complete for v$VERSION"
|
||||
if [[ $# -ge 2 ]]; then
|
||||
SUWA_VER="$2"
|
||||
JAR_URL="https://github.com/Suwayomi/Suwayomi-Server-preview/releases/download/v${SUWA_VER}/Suwayomi-Server-v${SUWA_VER}.jar"
|
||||
|
||||
SUWA_SHA_HEX=$(curl -fsSL "$JAR_URL" | sha256sum | awk '{print $1}')
|
||||
SUWA_SHA_SRI=$(echo "$SUWA_SHA_HEX" | xxd -r -p | base64 -w0 | sed 's/^/sha256-/')
|
||||
|
||||
sed -i "s/version = \"[^\"]*\"/version = \"$SUWA_VER\"/" "$VERSIONS"
|
||||
sed -i "s|hash = \"sha256-[^\"]*\"|hash = \"$SUWA_SHA_SRI\"|" "$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" "$SUWA_SHA_HEX" <<'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
|
||||
fi
|
||||
|
||||
echo "Done — versions.nix, flatpak manifest, and PKGBUILD patched for v$VERSION"
|
||||
'';
|
||||
};
|
||||
|
||||
@@ -105,19 +116,15 @@
|
||||
name = "moku-flatpak";
|
||||
runtimeInputs = with pkgs; [ coreutils git appstream flatpak-builder flatpak ];
|
||||
text = ''
|
||||
[[ $# -lt 1 ]] && { echo "Usage: nix run .#flatpak -- <version>"; exit 1; }
|
||||
REPO="$(git rev-parse --show-toplevel)"
|
||||
MANIFEST="$REPO/io.github.moku_project.Moku.yml"
|
||||
|
||||
rm -rf "$REPO/build-dir" "$REPO/repo"
|
||||
flatpak-builder \
|
||||
--repo="$REPO/repo" \
|
||||
--force-clean \
|
||||
"$REPO/build-dir" \
|
||||
"$MANIFEST"
|
||||
"$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"
|
||||
'';
|
||||
};
|
||||
|
||||
+8
-6
@@ -4,17 +4,19 @@
|
||||
fetchurl,
|
||||
makeWrapper,
|
||||
jdk21_headless,
|
||||
versions,
|
||||
}:
|
||||
let
|
||||
jdk = jdk21_headless;
|
||||
ver = versions.suwayomi;
|
||||
in
|
||||
stdenvNoCC.mkDerivation (finalAttrs: {
|
||||
stdenvNoCC.mkDerivation {
|
||||
pname = "suwayomi-server";
|
||||
version = "2.1.2087";
|
||||
version = ver.version;
|
||||
|
||||
src = fetchurl {
|
||||
url = "https://github.com/Suwayomi/Suwayomi-Server-preview/releases/download/v${finalAttrs.version}/Suwayomi-Server-v${finalAttrs.version}.jar";
|
||||
hash = "sha256-9YmkImdCUjlME7KJqci+aRkFv1g++39NXxUBrl6R5rM=";
|
||||
url = "https://github.com/Suwayomi/Suwayomi-Server-preview/releases/download/v${ver.version}/Suwayomi-Server-v${ver.version}.jar";
|
||||
hash = ver.hash;
|
||||
};
|
||||
|
||||
nativeBuildInputs = [ makeWrapper ];
|
||||
@@ -37,10 +39,10 @@ stdenvNoCC.mkDerivation (finalAttrs: {
|
||||
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${finalAttrs.version}";
|
||||
changelog = "https://github.com/Suwayomi/Suwayomi-Server-preview/releases/tag/v${ver.version}";
|
||||
license = lib.licenses.mpl20;
|
||||
platforms = jdk.meta.platforms;
|
||||
sourceProvenance = [ lib.sourceTypes.binaryBytecode ];
|
||||
mainProgram = "suwayomi-server";
|
||||
};
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
moku = "0.9.4";
|
||||
|
||||
suwayomi = {
|
||||
version = "2.1.2087";
|
||||
hash = "sha256-9YmkImdCUjlME7KJqci+aRkFv1g++39NXxUBrl6R5rM=";
|
||||
};
|
||||
|
||||
frontend = {
|
||||
pnpmHash = "sha256-8bkwONUrr+U2OXYXvcsGytKhcImnehu+2bI/hmoFjJ4=";
|
||||
distHash = "7db288b4b54277aa82b6ec5b21fc31a1e71f8246c50a74777500083b806c1fa5";
|
||||
distHashSri = "sha256-fbiiu0tCd6qCtu+SIfw+aR8Yj2bFCnR3dQAIO4BvwfM=";
|
||||
};
|
||||
|
||||
gitDeps = {
|
||||
tauri-plugin-discord-rpc = "sha256-xq0qyK2NrwSAFDhXo0vbvcygRD2/7uqBaLpqfpfxkrc=";
|
||||
};
|
||||
|
||||
gitCommit = "239960683b6c7f1347e1798b0e179a8a46628728";
|
||||
tarballHash = "";
|
||||
}
|
||||
+38
-22
@@ -1,32 +1,48 @@
|
||||
{
|
||||
"name": "moku",
|
||||
"version": "0.5.0",
|
||||
"private": true,
|
||||
"version": "0.9.4",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"dev": "vite dev",
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri",
|
||||
"tauri:dev": "tauri dev --config src-tauri/tauri.dev.conf.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.11.0",
|
||||
"@tauri-apps/plugin-http": "^2.5.8",
|
||||
"@tauri-apps/plugin-os": "^2.3.2",
|
||||
"@tauri-apps/plugin-shell": "^2.3.5",
|
||||
"@tauri-apps/plugin-store": "~2.4.2",
|
||||
"clsx": "^2.1.1",
|
||||
"phosphor-svelte": "^3.1.0",
|
||||
"svelte-spa-router": "^5.1.0",
|
||||
"tauri-plugin-discord-rpc-api": "github:Youwes09/tauri-plugin-discord-rpc",
|
||||
"tauri-plugin-drpc": "^1.0.3"
|
||||
"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: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.57.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
||||
"@tauri-apps/cli": "^2.11.0",
|
||||
"svelte": "^5.55.5",
|
||||
"svelte-check": "^4.4.7",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^8.0.10"
|
||||
"@tauri-apps/cli": "^2.1.0",
|
||||
"svelte": "^5.55.2",
|
||||
"svelte-check": "^4.4.6",
|
||||
"typescript": "^6.0.2",
|
||||
"vite": "^8.0.7"
|
||||
},
|
||||
"dependencies": {
|
||||
"@capacitor/app": "^8.1.0",
|
||||
"@capacitor/browser": "^8.0.3",
|
||||
"@capacitor/core": "^8.3.4",
|
||||
"@capacitor/filesystem": "^8.1.2",
|
||||
"@capacitor/preferences": "^8.0.1",
|
||||
"@tauri-apps/api": "^2.0.0",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.1",
|
||||
"@tauri-apps/plugin-fs": "^2.5.1",
|
||||
"@tauri-apps/plugin-http": "^2.5.9",
|
||||
"@tauri-apps/plugin-os": "^2.3.2",
|
||||
"@tauri-apps/plugin-process": "^2.3.1",
|
||||
"@tauri-apps/plugin-shell": "^2.3.5",
|
||||
"@tauri-apps/plugin-store": "^2.4.3",
|
||||
"capacitor-native-biometric": "^4.2.2",
|
||||
"clsx": "^2.1.1",
|
||||
"phosphor-svelte": "^3.1.0",
|
||||
"tauri-plugin-discord-rpc-api": "github:Youwes09/tauri-plugin-discord-rpc"
|
||||
}
|
||||
}
|
||||
@@ -3024,14 +3024,14 @@
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
"url": "https://static.crates.io/crates/openssl/openssl-0.10.79.crate",
|
||||
"sha256": "bf0b434746ee2832f4f0baf10137e1cabb18cbe6912c69e2e33263c45250f542",
|
||||
"dest": "cargo/vendor/openssl-0.10.79"
|
||||
"url": "https://static.crates.io/crates/openssl/openssl-0.10.80.crate",
|
||||
"sha256": "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967",
|
||||
"dest": "cargo/vendor/openssl-0.10.80"
|
||||
},
|
||||
{
|
||||
"type": "inline",
|
||||
"contents": "{\"package\": \"bf0b434746ee2832f4f0baf10137e1cabb18cbe6912c69e2e33263c45250f542\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/openssl-0.10.79",
|
||||
"contents": "{\"package\": \"a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/openssl-0.10.80",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
@@ -3063,14 +3063,14 @@
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
"url": "https://static.crates.io/crates/openssl-sys/openssl-sys-0.9.115.crate",
|
||||
"sha256": "158fe5b292746440aa6e7a7e690e55aeb72d41505e2804c23c6973ad0e9c9781",
|
||||
"dest": "cargo/vendor/openssl-sys-0.9.115"
|
||||
"url": "https://static.crates.io/crates/openssl-sys/openssl-sys-0.9.116.crate",
|
||||
"sha256": "f28a22dc7140cda5f096e5e7724a6962ca81a7f8bfd2979f9b18c11af56318c4",
|
||||
"dest": "cargo/vendor/openssl-sys-0.9.116"
|
||||
},
|
||||
{
|
||||
"type": "inline",
|
||||
"contents": "{\"package\": \"158fe5b292746440aa6e7a7e690e55aeb72d41505e2804c23c6973ad0e9c9781\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/openssl-sys-0.9.115",
|
||||
"contents": "{\"package\": \"f28a22dc7140cda5f096e5e7724a6962ca81a7f8bfd2979f9b18c11af56318c4\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/openssl-sys-0.9.116",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
@@ -4688,66 +4688,66 @@
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
"url": "https://static.crates.io/crates/tauri/tauri-2.11.1.crate",
|
||||
"sha256": "b93bd86d231f0a8138f11a02a584769fe4b703dc36ae133d783228dbc4801405",
|
||||
"dest": "cargo/vendor/tauri-2.11.1"
|
||||
"url": "https://static.crates.io/crates/tauri/tauri-2.11.2.crate",
|
||||
"sha256": "437404997acf375d85f1177afa7e11bb971f274ed6a7b83a2a3e339015f4cc28",
|
||||
"dest": "cargo/vendor/tauri-2.11.2"
|
||||
},
|
||||
{
|
||||
"type": "inline",
|
||||
"contents": "{\"package\": \"b93bd86d231f0a8138f11a02a584769fe4b703dc36ae133d783228dbc4801405\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/tauri-2.11.1",
|
||||
"contents": "{\"package\": \"437404997acf375d85f1177afa7e11bb971f274ed6a7b83a2a3e339015f4cc28\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/tauri-2.11.2",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
"url": "https://static.crates.io/crates/tauri-build/tauri-build-2.6.1.crate",
|
||||
"sha256": "3a318b234cc2dea65f575467bafcfb76286bce228ebc3778e337d61d03213007",
|
||||
"dest": "cargo/vendor/tauri-build-2.6.1"
|
||||
"url": "https://static.crates.io/crates/tauri-build/tauri-build-2.6.2.crate",
|
||||
"sha256": "4aa1f9055fc23919a54e4e125052bed16ed04aef0487086e758fe01a67b451c7",
|
||||
"dest": "cargo/vendor/tauri-build-2.6.2"
|
||||
},
|
||||
{
|
||||
"type": "inline",
|
||||
"contents": "{\"package\": \"3a318b234cc2dea65f575467bafcfb76286bce228ebc3778e337d61d03213007\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/tauri-build-2.6.1",
|
||||
"contents": "{\"package\": \"4aa1f9055fc23919a54e4e125052bed16ed04aef0487086e758fe01a67b451c7\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/tauri-build-2.6.2",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
"url": "https://static.crates.io/crates/tauri-codegen/tauri-codegen-2.6.1.crate",
|
||||
"sha256": "6bd11644962add2549a60b7e7c6800f17d7020156e02f516021d8103e80cc528",
|
||||
"dest": "cargo/vendor/tauri-codegen-2.6.1"
|
||||
"url": "https://static.crates.io/crates/tauri-codegen/tauri-codegen-2.6.2.crate",
|
||||
"sha256": "e4a0319528a025a38c4078e7dae2c446f4e63620ddb0659a643ede1cb38f90e9",
|
||||
"dest": "cargo/vendor/tauri-codegen-2.6.2"
|
||||
},
|
||||
{
|
||||
"type": "inline",
|
||||
"contents": "{\"package\": \"6bd11644962add2549a60b7e7c6800f17d7020156e02f516021d8103e80cc528\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/tauri-codegen-2.6.1",
|
||||
"contents": "{\"package\": \"e4a0319528a025a38c4078e7dae2c446f4e63620ddb0659a643ede1cb38f90e9\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/tauri-codegen-2.6.2",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
"url": "https://static.crates.io/crates/tauri-macros/tauri-macros-2.6.1.crate",
|
||||
"sha256": "fed9d3742a37a355d2e47c9af924e9fbc112abb76f9835d35d4780e318419502",
|
||||
"dest": "cargo/vendor/tauri-macros-2.6.1"
|
||||
"url": "https://static.crates.io/crates/tauri-macros/tauri-macros-2.6.2.crate",
|
||||
"sha256": "ae6cb4e3896c21d2f6da5b31251d2faea0153bba56ed0e970f918115dbee4924",
|
||||
"dest": "cargo/vendor/tauri-macros-2.6.2"
|
||||
},
|
||||
{
|
||||
"type": "inline",
|
||||
"contents": "{\"package\": \"fed9d3742a37a355d2e47c9af924e9fbc112abb76f9835d35d4780e318419502\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/tauri-macros-2.6.1",
|
||||
"contents": "{\"package\": \"ae6cb4e3896c21d2f6da5b31251d2faea0153bba56ed0e970f918115dbee4924\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/tauri-macros-2.6.2",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
"url": "https://static.crates.io/crates/tauri-plugin/tauri-plugin-2.6.1.crate",
|
||||
"sha256": "eefb2c18e8a605c23edb48fc56bb77381199e1a1e7f6ff0c9b970afe7b3cb8ee",
|
||||
"dest": "cargo/vendor/tauri-plugin-2.6.1"
|
||||
"url": "https://static.crates.io/crates/tauri-plugin/tauri-plugin-2.6.2.crate",
|
||||
"sha256": "e126abc9e84e35cdfd01596140a73a1850cdb0df0a23acf0185776c30b469a6e",
|
||||
"dest": "cargo/vendor/tauri-plugin-2.6.2"
|
||||
},
|
||||
{
|
||||
"type": "inline",
|
||||
"contents": "{\"package\": \"eefb2c18e8a605c23edb48fc56bb77381199e1a1e7f6ff0c9b970afe7b3cb8ee\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/tauri-plugin-2.6.1",
|
||||
"contents": "{\"package\": \"e126abc9e84e35cdfd01596140a73a1850cdb0df0a23acf0185776c30b469a6e\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/tauri-plugin-2.6.2",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
@@ -4862,40 +4862,40 @@
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
"url": "https://static.crates.io/crates/tauri-runtime/tauri-runtime-2.11.1.crate",
|
||||
"sha256": "8fef478ba1d2ac21c2d528740b24d0cb315e1e8b1111aae53fafac34804371fc",
|
||||
"dest": "cargo/vendor/tauri-runtime-2.11.1"
|
||||
"url": "https://static.crates.io/crates/tauri-runtime/tauri-runtime-2.11.2.crate",
|
||||
"sha256": "48222d7116c8807eaa6fe2f372e023fae125084e61e6eca6d70b7961cdf129ef",
|
||||
"dest": "cargo/vendor/tauri-runtime-2.11.2"
|
||||
},
|
||||
{
|
||||
"type": "inline",
|
||||
"contents": "{\"package\": \"8fef478ba1d2ac21c2d528740b24d0cb315e1e8b1111aae53fafac34804371fc\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/tauri-runtime-2.11.1",
|
||||
"contents": "{\"package\": \"48222d7116c8807eaa6fe2f372e023fae125084e61e6eca6d70b7961cdf129ef\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/tauri-runtime-2.11.2",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
"url": "https://static.crates.io/crates/tauri-runtime-wry/tauri-runtime-wry-2.11.1.crate",
|
||||
"sha256": "a3989df2ae1c476404fe0a2e8ffc4cfbde97e51efd613c2bb5355fbc9ab52cf0",
|
||||
"dest": "cargo/vendor/tauri-runtime-wry-2.11.1"
|
||||
"url": "https://static.crates.io/crates/tauri-runtime-wry/tauri-runtime-wry-2.11.2.crate",
|
||||
"sha256": "b83849ee63ecb27a8e8d0fe51915ca215076914aca43f96db1179f0f415f6cd9",
|
||||
"dest": "cargo/vendor/tauri-runtime-wry-2.11.2"
|
||||
},
|
||||
{
|
||||
"type": "inline",
|
||||
"contents": "{\"package\": \"a3989df2ae1c476404fe0a2e8ffc4cfbde97e51efd613c2bb5355fbc9ab52cf0\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/tauri-runtime-wry-2.11.1",
|
||||
"contents": "{\"package\": \"b83849ee63ecb27a8e8d0fe51915ca215076914aca43f96db1179f0f415f6cd9\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/tauri-runtime-wry-2.11.2",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
"url": "https://static.crates.io/crates/tauri-utils/tauri-utils-2.9.1.crate",
|
||||
"sha256": "d57200389a2f82b4b0a40ae29ca19b6978116e8f4d4e974c3234ce40c0ffbdec",
|
||||
"dest": "cargo/vendor/tauri-utils-2.9.1"
|
||||
"url": "https://static.crates.io/crates/tauri-utils/tauri-utils-2.9.2.crate",
|
||||
"sha256": "092379df9a707631978e6c56b1bc2401d387f01e2d4a3c123360d167bbb9aa95",
|
||||
"dest": "cargo/vendor/tauri-utils-2.9.2"
|
||||
},
|
||||
{
|
||||
"type": "inline",
|
||||
"contents": "{\"package\": \"d57200389a2f82b4b0a40ae29ca19b6978116e8f4d4e974c3234ce40c0ffbdec\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/tauri-utils-2.9.1",
|
||||
"contents": "{\"package\": \"092379df9a707631978e6c56b1bc2401d387f01e2d4a3c123360d167bbb9aa95\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/tauri-utils-2.9.2",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
|
||||
Generated
+694
-102
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,2 @@
|
||||
onlyBuiltDependencies:
|
||||
- esbuild
|
||||
Generated
+21
-21
@@ -2005,7 +2005,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "moku"
|
||||
version = "0.9.3"
|
||||
version = "0.9.4"
|
||||
dependencies = [
|
||||
"dirs 5.0.1",
|
||||
"reqwest 0.12.28",
|
||||
@@ -2379,9 +2379,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openssl"
|
||||
version = "0.10.79"
|
||||
version = "0.10.80"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bf0b434746ee2832f4f0baf10137e1cabb18cbe6912c69e2e33263c45250f542"
|
||||
checksum = "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"cfg-if",
|
||||
@@ -2410,9 +2410,9 @@ checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
|
||||
|
||||
[[package]]
|
||||
name = "openssl-sys"
|
||||
version = "0.9.115"
|
||||
version = "0.9.116"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "158fe5b292746440aa6e7a7e690e55aeb72d41505e2804c23c6973ad0e9c9781"
|
||||
checksum = "f28a22dc7140cda5f096e5e7724a6962ca81a7f8bfd2979f9b18c11af56318c4"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"libc",
|
||||
@@ -3811,9 +3811,9 @@ checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1"
|
||||
|
||||
[[package]]
|
||||
name = "tauri"
|
||||
version = "2.11.1"
|
||||
version = "2.11.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b93bd86d231f0a8138f11a02a584769fe4b703dc36ae133d783228dbc4801405"
|
||||
checksum = "437404997acf375d85f1177afa7e11bb971f274ed6a7b83a2a3e339015f4cc28"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bytes",
|
||||
@@ -3862,9 +3862,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tauri-build"
|
||||
version = "2.6.1"
|
||||
version = "2.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3a318b234cc2dea65f575467bafcfb76286bce228ebc3778e337d61d03213007"
|
||||
checksum = "4aa1f9055fc23919a54e4e125052bed16ed04aef0487086e758fe01a67b451c7"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"cargo_toml",
|
||||
@@ -3883,9 +3883,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tauri-codegen"
|
||||
version = "2.6.1"
|
||||
version = "2.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6bd11644962add2549a60b7e7c6800f17d7020156e02f516021d8103e80cc528"
|
||||
checksum = "e4a0319528a025a38c4078e7dae2c446f4e63620ddb0659a643ede1cb38f90e9"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"brotli",
|
||||
@@ -3910,9 +3910,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tauri-macros"
|
||||
version = "2.6.1"
|
||||
version = "2.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fed9d3742a37a355d2e47c9af924e9fbc112abb76f9835d35d4780e318419502"
|
||||
checksum = "ae6cb4e3896c21d2f6da5b31251d2faea0153bba56ed0e970f918115dbee4924"
|
||||
dependencies = [
|
||||
"heck 0.5.0",
|
||||
"proc-macro2",
|
||||
@@ -3924,9 +3924,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin"
|
||||
version = "2.6.1"
|
||||
version = "2.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eefb2c18e8a605c23edb48fc56bb77381199e1a1e7f6ff0c9b970afe7b3cb8ee"
|
||||
checksum = "e126abc9e84e35cdfd01596140a73a1850cdb0df0a23acf0185776c30b469a6e"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"glob",
|
||||
@@ -4088,9 +4088,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tauri-runtime"
|
||||
version = "2.11.1"
|
||||
version = "2.11.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8fef478ba1d2ac21c2d528740b24d0cb315e1e8b1111aae53fafac34804371fc"
|
||||
checksum = "48222d7116c8807eaa6fe2f372e023fae125084e61e6eca6d70b7961cdf129ef"
|
||||
dependencies = [
|
||||
"cookie",
|
||||
"dpi",
|
||||
@@ -4113,9 +4113,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tauri-runtime-wry"
|
||||
version = "2.11.1"
|
||||
version = "2.11.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a3989df2ae1c476404fe0a2e8ffc4cfbde97e51efd613c2bb5355fbc9ab52cf0"
|
||||
checksum = "b83849ee63ecb27a8e8d0fe51915ca215076914aca43f96db1179f0f415f6cd9"
|
||||
dependencies = [
|
||||
"gtk",
|
||||
"http",
|
||||
@@ -4139,9 +4139,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tauri-utils"
|
||||
version = "2.9.1"
|
||||
version = "2.9.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d57200389a2f82b4b0a40ae29ca19b6978116e8f4d4e974c3234ce40c0ffbdec"
|
||||
checksum = "092379df9a707631978e6c56b1bc2401d387f01e2d4a3c123360d167bbb9aa95"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"brotli",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "moku"
|
||||
version = "0.9.3"
|
||||
version = "0.9.4"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Default permissions for Moku",
|
||||
"windows": ["main"],
|
||||
"windows": [
|
||||
"main"
|
||||
],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"core:tray:default",
|
||||
@@ -31,6 +33,7 @@
|
||||
"core:window:allow-outer-position",
|
||||
"core:window:allow-scale-factor",
|
||||
"process:default",
|
||||
"process:allow-exit",
|
||||
"process:allow-restart",
|
||||
"http:default",
|
||||
"http:allow-fetch",
|
||||
|
||||
@@ -3,7 +3,12 @@ use crate::ServerState;
|
||||
use tauri::Manager;
|
||||
|
||||
#[tauri::command]
|
||||
pub fn spawn_server(binary: String, app: tauri::AppHandle) -> Result<(), SpawnError> {
|
||||
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() {
|
||||
@@ -20,12 +25,17 @@ pub fn spawn_server(binary: String, app: tauri::AppHandle) -> Result<(), SpawnEr
|
||||
.open(&log_path)
|
||||
.ok();
|
||||
|
||||
let binary_args = binary_args.unwrap_or_default();
|
||||
|
||||
server::do_log(
|
||||
&mut log,
|
||||
&format!("[spawn_server] binary={:?} data_dir={:?}", binary, data_dir),
|
||||
&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);
|
||||
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| {
|
||||
@@ -33,6 +43,13 @@ pub fn spawn_server(binary: String, app: tauri::AppHandle) -> Result<(), SpawnEr
|
||||
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={}",
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
#[cfg(target_os = "windows")]
|
||||
use crate::server::resolve::strip_unc;
|
||||
#[cfg(target_os = "windows")]
|
||||
use std::path::PathBuf;
|
||||
use tauri::Manager;
|
||||
|
||||
@@ -53,19 +52,95 @@ pub async fn pick_downloads_folder(app: tauri::AppHandle) -> Option<String> {
|
||||
.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 fn clear_moku_cache(app: tauri::AppHandle) -> Result<(), String> {
|
||||
use tauri::Manager;
|
||||
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() {
|
||||
std::fs::remove_dir_all(&cache_dir).map_err(|e| e.to_string())?;
|
||||
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(())
|
||||
}
|
||||
|
||||
@@ -73,10 +148,17 @@ pub fn clear_moku_cache(app: tauri::AppHandle) -> Result<(), String> {
|
||||
pub fn clear_suwayomi_cache() -> Result<(), String> {
|
||||
use crate::server::resolve::suwayomi_data_dir;
|
||||
let data_dir = suwayomi_data_dir();
|
||||
for dir in &["cache", "bin/kcef", "cache/kcef"] {
|
||||
for dir in &["cache/kcef", "logs"] {
|
||||
let p = data_dir.join(dir);
|
||||
if p.exists() {
|
||||
std::fs::remove_dir_all(&p).map_err(|e| e.to_string())?;
|
||||
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(())
|
||||
@@ -87,10 +169,18 @@ pub fn reset_suwayomi_data(app: tauri::AppHandle) -> Result<(), String> {
|
||||
use crate::server::resolve::suwayomi_data_dir;
|
||||
|
||||
crate::server::kill_tachidesk(&app);
|
||||
std::thread::sleep(std::time::Duration::from_millis(500));
|
||||
|
||||
let data_dir = suwayomi_data_dir();
|
||||
for entry_name in &["database.mv.db", "extensions", "settings", "logs", "local"] {
|
||||
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}"))?;
|
||||
|
||||
+112
-3
@@ -2,13 +2,81 @@ mod commands;
|
||||
mod server;
|
||||
|
||||
use std::sync::Mutex;
|
||||
use tauri::{Manager, WindowEvent};
|
||||
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;
|
||||
|
||||
pub struct ServerState(pub 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);
|
||||
}
|
||||
|
||||
fn start_instance_listener(app: tauri::AppHandle) {
|
||||
std::thread::spawn(move || {
|
||||
let Ok(listener) = TcpListener::bind(("127.0.0.1", IPC_PORT)) else {
|
||||
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) {
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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"))
|
||||
}
|
||||
|
||||
#[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())
|
||||
@@ -34,6 +102,7 @@ pub fn run() {
|
||||
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,
|
||||
@@ -44,12 +113,52 @@ pub fn run() {
|
||||
commands::biometric::windows_hello_authenticate,
|
||||
commands::biometric::windows_hello_available,
|
||||
])
|
||||
.setup(|_app| Ok(()))
|
||||
.setup(|app| {
|
||||
start_instance_listener(app.handle().clone());
|
||||
|
||||
let show = MenuItem::with_id(app, "show", "Show Moku", true, None::<&str>)?;
|
||||
let sep = PredefinedMenuItem::separator(app)?;
|
||||
let quit = MenuItem::with_id(app, "quit", "Quit Moku", true, None::<&str>)?;
|
||||
let menu = Menu::with_items(app, &[&show, &sep, &quit])?;
|
||||
|
||||
TrayIconBuilder::new()
|
||||
.icon(app.default_window_icon().unwrap().clone())
|
||||
.menu(&menu)
|
||||
.show_menu_on_left_click(false)
|
||||
.tooltip("Moku")
|
||||
.on_menu_event(|app, event| match event.id.as_ref() {
|
||||
"show" => {
|
||||
if let Some(win) = app.get_webview_window("main") {
|
||||
let _ = win.show();
|
||||
let _ = win.set_focus();
|
||||
}
|
||||
}
|
||||
"quit" => do_quit(app),
|
||||
_ => {}
|
||||
})
|
||||
.on_tray_icon_event(|tray, event| {
|
||||
if let TrayIconEvent::Click {
|
||||
button: MouseButton::Left,
|
||||
button_state: MouseButtonState::Up,
|
||||
..
|
||||
} = event
|
||||
{
|
||||
let app = tray.app_handle();
|
||||
if let Some(win) = app.get_webview_window("main") {
|
||||
let _ = win.show();
|
||||
let _ = win.set_focus();
|
||||
}
|
||||
}
|
||||
})
|
||||
.build(app)?;
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.on_window_event(|window, event| {
|
||||
if let WindowEvent::Destroyed = event {
|
||||
server::kill_tachidesk(window.app_handle());
|
||||
}
|
||||
})
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running moku");
|
||||
.expect("error while running moku")
|
||||
}
|
||||
@@ -2,7 +2,7 @@ use std::path::PathBuf;
|
||||
|
||||
const DEFAULT_SERVER_CONF: &str = r#"server.ip = "127.0.0.1"
|
||||
server.port = 4567
|
||||
server.webUIEnabled = true
|
||||
server.webUIEnabled = false
|
||||
server.initialOpenInBrowserEnabled = false
|
||||
server.systemTrayEnabled = false
|
||||
server.webUIInterface = "browser"
|
||||
@@ -17,7 +17,7 @@ server.maxSourcesInParallel = 6
|
||||
server.extensionRepos = []
|
||||
"#;
|
||||
|
||||
pub fn seed_server_conf(data_dir: &PathBuf) {
|
||||
pub fn seed_server_conf(data_dir: &PathBuf, web_ui_enabled: bool) {
|
||||
let conf_path = data_dir.join("server.conf");
|
||||
|
||||
if !conf_path.exists() {
|
||||
@@ -25,7 +25,12 @@ pub fn seed_server_conf(data_dir: &PathBuf) {
|
||||
eprintln!("Could not create Suwayomi data dir: {e}");
|
||||
return;
|
||||
}
|
||||
if let Err(e) = std::fs::write(&conf_path, DEFAULT_SERVER_CONF) {
|
||||
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;
|
||||
@@ -37,7 +42,11 @@ pub fn seed_server_conf(data_dir: &PathBuf) {
|
||||
|
||||
let patched = patch_conf_key(
|
||||
patch_conf_key(
|
||||
patch_conf_key(contents, "server.webUIEnabled", "true"),
|
||||
patch_conf_key(
|
||||
contents,
|
||||
"server.webUIEnabled",
|
||||
if web_ui_enabled { "true" } else { "false" },
|
||||
),
|
||||
"server.initialOpenInBrowserEnabled",
|
||||
"false",
|
||||
),
|
||||
|
||||
+76
-175
@@ -1,7 +1,6 @@
|
||||
use crate::server::do_log;
|
||||
use serde::Serialize;
|
||||
use std::path::PathBuf;
|
||||
use walkdir::WalkDir;
|
||||
use tauri::Manager;
|
||||
|
||||
#[derive(Serialize, Debug)]
|
||||
@@ -48,22 +47,14 @@ pub fn strip_unc(path: PathBuf) -> PathBuf {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
fn find_java_in_bundle(bundle_dir: &PathBuf, log: &mut Option<std::fs::File>) -> Option<PathBuf> {
|
||||
#[cfg(target_os = "windows")]
|
||||
let java = strip_unc(bundle_dir.join("jre").join("bin").join("java.exe"));
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
let java = bundle_dir.join("jre").join("bin").join("java");
|
||||
fn java_bin_name() -> &'static str {
|
||||
if cfg!(target_os = "windows") { "java.exe" } else { "java" }
|
||||
}
|
||||
|
||||
do_log(
|
||||
log,
|
||||
&format!("[find_java] path: {:?} exists: {}", java, java.exists()),
|
||||
);
|
||||
if java.exists() {
|
||||
Some(java)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
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> {
|
||||
@@ -74,24 +65,34 @@ 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));
|
||||
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()),
|
||||
);
|
||||
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: path.parent().map(|p| p.to_path_buf()),
|
||||
working_dir,
|
||||
});
|
||||
}
|
||||
do_log(log, "[resolve] user path not found, falling through");
|
||||
@@ -101,10 +102,7 @@ pub fn resolve_server_binary(
|
||||
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()),
|
||||
);
|
||||
do_log(log, &format!("[resolve] sibling={:?} exists={}", p, p.exists()));
|
||||
if p.exists() {
|
||||
return Ok(ServerInvocation {
|
||||
bin: p.to_string_lossy().into_owned(),
|
||||
@@ -116,54 +114,31 @@ pub fn resolve_server_binary(
|
||||
}
|
||||
}
|
||||
|
||||
#[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
|
||||
};
|
||||
|
||||
#[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()),
|
||||
);
|
||||
do_log(log, &format!("[resolve] bundle_dir={:?} exists={}", bundle_dir, bundle_dir.exists()));
|
||||
do_log(log, &format!("[resolve] jar={:?} exists={}", jar, jar.exists()));
|
||||
|
||||
match find_java_in_bundle(&bundle_dir, log) {
|
||||
Some(java) if jar.exists() => {
|
||||
do_log(log, "[resolve] using bundled JRE");
|
||||
return Ok(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(bundle_dir),
|
||||
});
|
||||
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));
|
||||
}
|
||||
_ => do_log(log, "[resolve] bundled JRE/jar not found, falling through"),
|
||||
}
|
||||
|
||||
for name in &[
|
||||
"suwayomi-launcher",
|
||||
"suwayomi-launcher.sh",
|
||||
"tachidesk-server",
|
||||
] {
|
||||
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()),
|
||||
);
|
||||
do_log(log, &format!("[resolve] sidecar={:?} exists={}", p, p.exists()));
|
||||
if p.exists() {
|
||||
return Ok(ServerInvocation {
|
||||
bin: p.to_string_lossy().into_owned(),
|
||||
@@ -174,26 +149,16 @@ pub fn resolve_server_binary(
|
||||
}
|
||||
|
||||
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(ServerInvocation {
|
||||
bin: java.to_string_lossy().into_owned(),
|
||||
args: vec![jar_data_root_flag(), "-jar".to_string(), jar_path.to_string_lossy().into_owned()],
|
||||
working_dir: Some(resource_dir),
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -201,108 +166,43 @@ pub fn resolve_server_binary(
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let resource_dir = app.path().resource_dir().unwrap_or_default();
|
||||
let contents_dir = resource_dir.parent().unwrap_or(&resource_dir).to_path_buf();
|
||||
let bundle_dir = resource_dir.join("suwayomi-bundle");
|
||||
|
||||
do_log(
|
||||
log,
|
||||
&format!("[resolve] macOS contents_dir = {:?}", contents_dir),
|
||||
);
|
||||
do_log(log, &format!("[resolve] macOS resource_dir={:?}", resource_dir));
|
||||
do_log(log, &format!("[resolve] macOS bundle_dir={:?} exists={}", bundle_dir, bundle_dir.exists()));
|
||||
|
||||
const NATIVE_NAMES: &[&str] = &[
|
||||
"suwayomi-server-aarch64-apple-darwin",
|
||||
"suwayomi-server-x86_64-apple-darwin",
|
||||
"suwayomi-server",
|
||||
"suwayomi-launcher",
|
||||
"suwayomi-launcher.sh",
|
||||
"tachidesk-server",
|
||||
];
|
||||
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");
|
||||
|
||||
let mut found_binary: Option<ServerInvocation> = None;
|
||||
let mut found_java: Option<(PathBuf, PathBuf)> = None;
|
||||
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()));
|
||||
|
||||
'outer: for depth in 0u8..=8 {
|
||||
let entries: Vec<PathBuf> = WalkDir::new(&contents_dir)
|
||||
.min_depth(depth as usize)
|
||||
.max_depth(depth as usize)
|
||||
.into_iter()
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| e.file_type().is_dir())
|
||||
.map(|e| e.into_path())
|
||||
.collect();
|
||||
|
||||
for dir in &entries {
|
||||
do_log(
|
||||
log,
|
||||
&format!("[resolve] scanning depth={} dir={:?}", depth, dir),
|
||||
);
|
||||
|
||||
for name in NATIVE_NAMES {
|
||||
let p = dir.join(name);
|
||||
if p.exists() {
|
||||
do_log(log, &format!("[resolve] found native binary: {:?}", p));
|
||||
found_binary = Some(ServerInvocation {
|
||||
bin: p.to_string_lossy().into_owned(),
|
||||
args: data_root_args(),
|
||||
working_dir: Some(dir.clone()),
|
||||
});
|
||||
break 'outer;
|
||||
}
|
||||
}
|
||||
|
||||
if found_java.is_none() {
|
||||
let java_exe = dir.join("bin").join("java");
|
||||
if java_exe.exists() {
|
||||
do_log(log, &format!("[resolve] found java: {:?}", java_exe));
|
||||
let mut search = dir.as_path();
|
||||
'jar: for _ in 0..5 {
|
||||
if let Ok(rd) = std::fs::read_dir(search) {
|
||||
for entry in rd.filter_map(|e| e.ok()) {
|
||||
if entry.file_name().to_string_lossy().ends_with(".jar") {
|
||||
let jar = entry.path();
|
||||
do_log(log, &format!("[resolve] found jar: {:?}", jar));
|
||||
found_java = Some((java_exe.clone(), jar));
|
||||
break 'jar;
|
||||
}
|
||||
}
|
||||
}
|
||||
let bin_sibling = search.join("bin");
|
||||
if let Ok(rd) = std::fs::read_dir(&bin_sibling) {
|
||||
for entry in rd.filter_map(|e| e.ok()) {
|
||||
if entry.file_name().to_string_lossy().ends_with(".jar") {
|
||||
let jar = entry.path();
|
||||
do_log(
|
||||
log,
|
||||
&format!("[resolve] found jar in bin/: {:?}", jar),
|
||||
);
|
||||
found_java = Some((java_exe.clone(), jar));
|
||||
break 'jar;
|
||||
}
|
||||
}
|
||||
}
|
||||
match search.parent() {
|
||||
Some(p) => search = p,
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if java.exists() && jar.exists() {
|
||||
do_log(log, "[resolve] macOS using bundled JRE + Suwayomi-Server.jar");
|
||||
return Ok(jar_invocation(java, jar, bundle_dir));
|
||||
}
|
||||
|
||||
if let Some(inv) = found_binary {
|
||||
return Ok(inv);
|
||||
}
|
||||
|
||||
if let Some((java, jar)) = found_java {
|
||||
let working_dir = jar.parent().map(|p| p.to_path_buf());
|
||||
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: java.to_string_lossy().into_owned(),
|
||||
args: vec![jar_data_root_flag(), "-jar".to_string(), jar.to_string_lossy().into_owned()],
|
||||
working_dir,
|
||||
bin: launcher_sh.to_string_lossy().into_owned(),
|
||||
args: vec![],
|
||||
working_dir: Some(bundle_dir),
|
||||
});
|
||||
}
|
||||
|
||||
do_log(log, "[resolve] macOS scan found nothing in bundle");
|
||||
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"] {
|
||||
@@ -314,6 +214,7 @@ pub fn resolve_server_binary(
|
||||
.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)
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Moku",
|
||||
"version": "0.9.3",
|
||||
"version": "0.9.4",
|
||||
"identifier": "io.github.MokuProject.Moku",
|
||||
"build": {
|
||||
"devUrl": "http://localhost:1420",
|
||||
"frontendDist": "../dist",
|
||||
"beforeBuildCommand": "pnpm build"
|
||||
"beforeBuildCommand": "pnpm build:static"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
{
|
||||
"build": {
|
||||
"devUrl": "http://localhost:1420",
|
||||
"beforeDevCommand": "pnpm dev"
|
||||
},
|
||||
"app": {
|
||||
|
||||
-387
@@ -1,387 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import { defaultWindowIcon } from "@tauri-apps/api/app";
|
||||
import { TrayIcon } from "@tauri-apps/api/tray";
|
||||
import { Menu } from "@tauri-apps/api/menu";
|
||||
import { platform } from "@tauri-apps/plugin-os";
|
||||
import { store, updateSettings, setActiveDownloads } from "@store/state.svelte";
|
||||
import { downloadStore } from "@features/downloads/store/downloadState.svelte";
|
||||
import { boot, initStore, startProbe, stopProbe, retryBoot, bypassBoot } from "@store/boot.svelte";
|
||||
import { initRpc, setIdle, clearReading, destroyRpc } from "@store/discord";
|
||||
import { applyTheme } from "@core/theme";
|
||||
import { applyZoom, mountZoomKey, mountIdleDetection } from "@core/ui";
|
||||
import { checkForUpdateSilently } from "@core/updater";
|
||||
import Layout from "@shared/chrome/Layout.svelte";
|
||||
import Reader from "@features/reader/components/Reader.svelte";
|
||||
import Settings from "@features/settings/components/Settings.svelte";
|
||||
import ThemeEditor from "@features/settings/components/ThemeEditor.svelte";
|
||||
import TitleBar from "@shared/chrome/TitleBar.svelte";
|
||||
import Toaster from "@shared/chrome/Toaster.svelte";
|
||||
import SplashScreen from "@shared/chrome/SplashScreen.svelte";
|
||||
import MangaPreview from "@shared/manga/MangaPreview.svelte";
|
||||
import AuthGate from "@shared/chrome/AuthGate.svelte";
|
||||
|
||||
const win = getCurrentWindow();
|
||||
void platform();
|
||||
|
||||
let appReady = $state(false);
|
||||
let idle = $state(false);
|
||||
let devSplash = $state(false);
|
||||
|
||||
let themeEditorOpen = $state(false);
|
||||
let themeEditorEditId = $state<string | null>(null);
|
||||
|
||||
let closeDialogOpen = $state(false);
|
||||
let closeRemember = $state(false);
|
||||
|
||||
function openThemeEditor(id?: string | null) {
|
||||
themeEditorEditId = id ?? null;
|
||||
themeEditorOpen = true;
|
||||
}
|
||||
|
||||
function closeThemeEditor() {
|
||||
themeEditorOpen = false;
|
||||
themeEditorEditId = null;
|
||||
}
|
||||
|
||||
async function doQuit() {
|
||||
if (store.settings.autoStartServer) await invoke("kill_server").catch(() => {});
|
||||
await win.destroy();
|
||||
}
|
||||
|
||||
async function doHide() {
|
||||
await win.hide();
|
||||
}
|
||||
|
||||
async function handleCloseRequested() {
|
||||
const action = store.settings.closeAction ?? "ask";
|
||||
if (action === "tray") { await doHide(); return; }
|
||||
if (action === "quit") { await doQuit(); return; }
|
||||
closeDialogOpen = true;
|
||||
}
|
||||
|
||||
async function confirmClose(choice: "tray" | "quit") {
|
||||
closeDialogOpen = false;
|
||||
if (closeRemember) updateSettings({ closeAction: choice });
|
||||
closeRemember = false;
|
||||
if (choice === "tray") await doHide();
|
||||
else await doQuit();
|
||||
}
|
||||
|
||||
$effect(() => { void store.settings.theme; applyTheme(); });
|
||||
$effect(() => { void store.settings.uiZoom; applyZoom(); });
|
||||
$effect(() => mountZoomKey());
|
||||
|
||||
$effect(() => {
|
||||
if (!appReady) return;
|
||||
return mountIdleDetection(
|
||||
() => { idle = true; },
|
||||
() => { if (idle) idle = false; },
|
||||
);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!appReady) return;
|
||||
const timer = setTimeout(checkForUpdateSilently, 5_000);
|
||||
return () => clearTimeout(timer);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (store.settings.discordRpc) {
|
||||
initRpc();
|
||||
} else {
|
||||
clearReading();
|
||||
destroyRpc();
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!store.activeChapter && store.settings.discordRpc) setIdle();
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const next = downloadStore.queue.slice();
|
||||
downloadStore.detectTransitions(next);
|
||||
});
|
||||
|
||||
onMount(async () => {
|
||||
document.addEventListener("contextmenu", e => e.preventDefault());
|
||||
(window as any).__mokuShowSplash = () => { devSplash = true; };
|
||||
|
||||
applyZoom();
|
||||
|
||||
store.isFullscreen = await win.isFullscreen();
|
||||
|
||||
const unlistenResize = await win.onResized(async () => {
|
||||
store.isFullscreen = await win.isFullscreen();
|
||||
});
|
||||
|
||||
const unlistenScale = await win.onScaleChanged(async () => {
|
||||
applyZoom();
|
||||
});
|
||||
|
||||
const menu = await Menu.new({
|
||||
items: [
|
||||
{
|
||||
id: "show",
|
||||
text: "Show Moku",
|
||||
action: async () => {
|
||||
await win.show();
|
||||
await win.setFocus();
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "quit",
|
||||
text: "Quit",
|
||||
action: doQuit,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await TrayIcon.new({
|
||||
icon: await defaultWindowIcon(),
|
||||
menu,
|
||||
menuOnLeftClick: false,
|
||||
tooltip: "Moku",
|
||||
action: async (e) => {
|
||||
if (e.type === "Click") {
|
||||
await win.show();
|
||||
await win.setFocus();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const unlistenClose = await win.listen("tauri://close-requested", handleCloseRequested);
|
||||
|
||||
if (store.settings.autoStartServer) {
|
||||
invoke<void>("spawn_server", { binary: store.settings.serverBinary }).catch((err: any) => {
|
||||
if (err?.kind === "NotConfigured") boot.notConfigured = true;
|
||||
else console.warn("Could not start server:", err);
|
||||
});
|
||||
}
|
||||
|
||||
await initStore();
|
||||
startProbe();
|
||||
|
||||
const unlistenDownload = await listen<{ chapterId: number; mangaId: number; progress: number }[]>(
|
||||
"download-progress",
|
||||
e => setActiveDownloads(e.payload),
|
||||
);
|
||||
|
||||
await downloadStore.poll();
|
||||
const dlInterval = setInterval(() => downloadStore.poll(), 2000);
|
||||
|
||||
return () => {
|
||||
stopProbe();
|
||||
clearInterval(dlInterval);
|
||||
unlistenResize();
|
||||
unlistenScale();
|
||||
unlistenDownload();
|
||||
unlistenClose();
|
||||
destroyRpc();
|
||||
delete (window as any).__mokuShowSplash;
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if devSplash}
|
||||
<SplashScreen mode="idle" showFps showCards={store.settings.splashCards ?? true}
|
||||
onDismiss={() => setTimeout(() => devSplash = false, 340)} />
|
||||
|
||||
{:else if !appReady && !boot.loginRequired}
|
||||
<SplashScreen mode="loading" ringFull={boot.serverProbeOk}
|
||||
failed={boot.failed} notConfigured={boot.notConfigured}
|
||||
showCards={store.settings.splashCards ?? true}
|
||||
onReady={() => { appReady = true; }}
|
||||
onRetry={retryBoot}
|
||||
onBypass={() => bypassBoot(() => { appReady = true; })} />
|
||||
|
||||
{:else if boot.loginRequired}
|
||||
<SplashScreen mode="loading" ringFull={true} showCards={store.settings.splashCards ?? true} />
|
||||
<AuthGate onReady={() => { appReady = true; }} />
|
||||
|
||||
{:else}
|
||||
{#if idle && !store.activeChapter}
|
||||
<SplashScreen mode="idle" showCards={store.settings.splashCards ?? true}
|
||||
onDismiss={() => { idle = false; }} />
|
||||
{/if}
|
||||
|
||||
{#if boot.sessionExpired}
|
||||
<SplashScreen mode="loading" ringFull={true} showCards={store.settings.splashCards ?? true} />
|
||||
<AuthGate onReady={() => { boot.sessionExpired = false; }} />
|
||||
{/if}
|
||||
|
||||
<div id="app-shell" class="root">
|
||||
{#if !store.activeChapter}<TitleBar />{/if}
|
||||
<div class="content">
|
||||
{#if store.activeChapter}<Reader />{:else}<Layout />{/if}
|
||||
</div>
|
||||
{#if store.settingsOpen}<Settings onOpenThemeEditor={openThemeEditor} />{/if}
|
||||
{#if themeEditorOpen}
|
||||
<ThemeEditor bind:editingId={themeEditorEditId} onClose={closeThemeEditor} />
|
||||
{/if}
|
||||
<MangaPreview />
|
||||
<Toaster />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if closeDialogOpen}
|
||||
<div class="close-backdrop" role="presentation" onclick={() => { closeDialogOpen = false; closeRemember = false; }}>
|
||||
<div class="close-dialog" role="dialog" aria-modal="true" onclick={(e) => e.stopPropagation()}>
|
||||
<div class="close-header">
|
||||
<p class="close-title">Close Moku?</p>
|
||||
<p class="close-sub">Choose how the app should exit.</p>
|
||||
</div>
|
||||
<div class="close-actions">
|
||||
<button class="close-btn" onclick={() => confirmClose("tray")}>
|
||||
<span class="close-btn-label">Minimize to Tray</span>
|
||||
<span class="close-btn-desc">Keep running in the background</span>
|
||||
</button>
|
||||
<button class="close-btn close-btn-danger" onclick={() => confirmClose("quit")}>
|
||||
<span class="close-btn-label">Quit</span>
|
||||
<span class="close-btn-desc">Stop Moku entirely</span>
|
||||
</button>
|
||||
</div>
|
||||
<button class="close-remember" onclick={() => closeRemember = !closeRemember}>
|
||||
<span class="close-remember-toggle" class:on={closeRemember}><span class="close-remember-thumb"></span></span>
|
||||
<span class="close-remember-label">Remember my choice</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.root { display: flex; flex-direction: column; height: 100%; overflow: hidden; }
|
||||
.content { flex: 1; overflow: hidden; }
|
||||
|
||||
.close-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
backdrop-filter: blur(6px);
|
||||
-webkit-backdrop-filter: blur(6px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: var(--z-modal);
|
||||
}
|
||||
|
||||
.close-dialog {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-base);
|
||||
border-radius: var(--radius-2xl);
|
||||
padding: var(--sp-5);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-3);
|
||||
width: 300px;
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(255,255,255,0.04) inset,
|
||||
0 20px 60px rgba(0,0,0,0.65),
|
||||
0 6px 20px rgba(0,0,0,0.35);
|
||||
}
|
||||
|
||||
.close-header { display: flex; flex-direction: column; gap: 3px; }
|
||||
|
||||
.close-title {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-base);
|
||||
font-weight: var(--weight-medium);
|
||||
color: var(--text-primary);
|
||||
letter-spacing: var(--tracking-tight);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.close-sub {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-faint);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.close-actions { display: flex; flex-direction: column; gap: var(--sp-1); }
|
||||
|
||||
.close-btn {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 2px;
|
||||
width: 100%;
|
||||
padding: 10px var(--sp-3);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--border-dim);
|
||||
background: var(--bg-raised);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: background var(--t-base), border-color var(--t-base);
|
||||
}
|
||||
.close-btn:hover { background: var(--bg-overlay); border-color: var(--border-strong); }
|
||||
|
||||
.close-btn-danger { border-color: color-mix(in srgb, var(--color-error) 30%, transparent); }
|
||||
.close-btn-danger:hover { background: var(--color-error-bg); border-color: color-mix(in srgb, var(--color-error) 55%, transparent); }
|
||||
.close-btn-danger .close-btn-label { color: var(--color-error); }
|
||||
.close-btn-danger .close-btn-desc { color: color-mix(in srgb, var(--color-error) 55%, var(--text-faint)); }
|
||||
|
||||
.close-btn-label {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
font-weight: var(--weight-medium);
|
||||
}
|
||||
|
||||
.close-btn-desc {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-2xs);
|
||||
color: var(--text-faint);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
}
|
||||
|
||||
.close-remember {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
padding: var(--sp-1) 0 0;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.close-remember-toggle {
|
||||
position: relative;
|
||||
width: 28px;
|
||||
height: 16px;
|
||||
border-radius: var(--radius-full);
|
||||
border: 1px solid var(--border-strong);
|
||||
background: var(--bg-overlay);
|
||||
flex-shrink: 0;
|
||||
transition: background var(--t-base), border-color var(--t-base);
|
||||
}
|
||||
.close-remember-toggle.on { background: var(--accent); border-color: var(--accent); }
|
||||
|
||||
.close-remember-thumb {
|
||||
position: absolute;
|
||||
top: 1px;
|
||||
left: 1px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-faint);
|
||||
transition: transform var(--t-base), background var(--t-base);
|
||||
}
|
||||
.close-remember-toggle.on .close-remember-thumb {
|
||||
transform: translateX(12px);
|
||||
background: var(--bg-void);
|
||||
}
|
||||
|
||||
.close-remember-label {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-faint);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
}
|
||||
</style>
|
||||
@@ -1,133 +0,0 @@
|
||||
import { store } from "@store/state.svelte";
|
||||
import { fetchAuthenticated, AuthRequiredError, uiAuth } from "../core/auth";
|
||||
import { boot } from "@store/boot.svelte";
|
||||
import { getBlobUrl } from "@core/cache/imageCache";
|
||||
|
||||
const DEFAULT_URL = "http://127.0.0.1:4567";
|
||||
|
||||
type ReauthResolver = () => void;
|
||||
let _reauthQueue: ReauthResolver[] = [];
|
||||
|
||||
export function notifyReauthSuccess() {
|
||||
const queue = _reauthQueue;
|
||||
_reauthQueue = [];
|
||||
queue.forEach(resolve => resolve());
|
||||
}
|
||||
|
||||
function waitForReauth(): Promise<void> {
|
||||
return new Promise(resolve => { _reauthQueue.push(resolve); });
|
||||
}
|
||||
|
||||
export function getServerUrl(): string {
|
||||
const url = store.settings.serverUrl;
|
||||
return typeof url === "string" && url.trim() ? url.replace(/\/$/, "") : DEFAULT_URL;
|
||||
}
|
||||
|
||||
export function plainThumbUrl(path: string): string {
|
||||
if (!path) return "";
|
||||
if (path.startsWith("http")) return path;
|
||||
return `${getServerUrl()}${path}`;
|
||||
}
|
||||
|
||||
export async function resolveImageUrl(path: string): Promise<string> {
|
||||
if (!path) return "";
|
||||
const url = path.startsWith("http") ? path : `${getServerUrl()}${path}`;
|
||||
const mode = store.settings.serverAuthMode ?? "NONE";
|
||||
if (mode === "NONE") return url;
|
||||
return getBlobUrl(url);
|
||||
}
|
||||
|
||||
export const thumbUrl = plainThumbUrl;
|
||||
|
||||
interface GQLResponse<T> {
|
||||
data: T;
|
||||
errors?: { message: string }[];
|
||||
}
|
||||
|
||||
function abortableSleep(ms: number, signal?: AbortSignal): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (signal?.aborted) { reject(new DOMException("Aborted", "AbortError")); return; }
|
||||
const timer = setTimeout(resolve, ms);
|
||||
signal?.addEventListener("abort", () => {
|
||||
clearTimeout(timer);
|
||||
reject(new DOMException("Aborted", "AbortError"));
|
||||
}, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchWithRetry(
|
||||
url: string,
|
||||
init: RequestInit,
|
||||
signal?: AbortSignal,
|
||||
retries = 3,
|
||||
delayMs = 300,
|
||||
): Promise<Response> {
|
||||
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
||||
for (let i = 0; i < retries; i++) {
|
||||
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
||||
try {
|
||||
const res = await fetchAuthenticated(url, init, signal, boot.skipped);
|
||||
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
||||
return res;
|
||||
} catch (e: any) {
|
||||
if (e?.authRequired) throw e;
|
||||
if (e?.name === "AbortError" || signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
||||
if (e instanceof AuthRequiredError) throw e;
|
||||
if (i === retries - 1) throw e;
|
||||
await abortableSleep(delayMs * Math.pow(1.5, i), signal);
|
||||
}
|
||||
}
|
||||
throw new Error("unreachable");
|
||||
}
|
||||
|
||||
export async function fetchImage(
|
||||
path: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<{ src: string; revoke: () => void }> {
|
||||
if (!path) return { src: "", revoke: () => {} };
|
||||
|
||||
const url = path.startsWith("http") ? path : `${getServerUrl()}${path}`;
|
||||
const mode = store.settings.serverAuthMode ?? "NONE";
|
||||
|
||||
if (mode === "NONE") return { src: url, revoke: () => {} };
|
||||
|
||||
const res = await fetchWithRetry(url, { method: "GET" }, signal);
|
||||
if (!res.ok) throw new Error(`Image fetch failed: ${res.status}`);
|
||||
|
||||
const blob = await res.blob();
|
||||
const src = URL.createObjectURL(blob);
|
||||
return { src, revoke: () => URL.revokeObjectURL(src) };
|
||||
}
|
||||
|
||||
export async function gql<T>(
|
||||
query: string,
|
||||
variables?: Record<string, unknown>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<T> {
|
||||
const attempt = async (): Promise<T> => {
|
||||
const res = await fetchWithRetry(
|
||||
`${getServerUrl()}/api/graphql`,
|
||||
{ method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ query, variables }) },
|
||||
signal,
|
||||
);
|
||||
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
||||
if (!res.ok) throw new Error(`Suwayomi HTTP ${res.status}`);
|
||||
const json: GQLResponse<T> = await res.json();
|
||||
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
||||
if (json.errors?.length) {
|
||||
const isAuthError = json.errors.some(e => /unauthorized|unauthenticated/i.test(e.message));
|
||||
if (isAuthError && !boot.skipped) {
|
||||
boot.sessionExpired = true;
|
||||
boot.loginRequired = true;
|
||||
boot.loginUser = store.settings.serverAuthUser ?? "";
|
||||
await waitForReauth();
|
||||
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
||||
return attempt();
|
||||
}
|
||||
throw new Error(json.errors[0].message);
|
||||
}
|
||||
return json.data;
|
||||
};
|
||||
|
||||
return attempt();
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
export * from "./client";
|
||||
export * from "./queries/manga";
|
||||
export * from "./queries/chapters";
|
||||
export * from "./queries/downloads";
|
||||
export * from "./queries/extensions";
|
||||
export * from "./queries/tracking";
|
||||
export * from "./mutations/manga";
|
||||
export * from "./mutations/chapters";
|
||||
export * from "./mutations/downloads";
|
||||
export * from "./mutations/extensions";
|
||||
export * from "./mutations/tracking";
|
||||
@@ -1,215 +0,0 @@
|
||||
export const FETCH_EXTENSIONS = `
|
||||
mutation FetchExtensions {
|
||||
fetchExtensions(input: {}) {
|
||||
extensions {
|
||||
apkName pkgName name lang versionName
|
||||
isInstalled isObsolete hasUpdate iconUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const UPDATE_EXTENSION = `
|
||||
mutation UpdateExtension($id: String!, $install: Boolean, $uninstall: Boolean, $update: Boolean) {
|
||||
updateExtension(input: { id: $id, patch: { install: $install, uninstall: $uninstall, update: $update } }) {
|
||||
extension { apkName pkgName name isInstalled hasUpdate }
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const UPDATE_EXTENSIONS = `
|
||||
mutation UpdateExtensions($ids: [String!]!, $install: Boolean, $uninstall: Boolean, $update: Boolean) {
|
||||
updateExtensions(input: { ids: $ids, patch: { install: $install, uninstall: $uninstall, update: $update } }) {
|
||||
extensions { apkName pkgName name isInstalled hasUpdate }
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const INSTALL_EXTERNAL_EXTENSION = `
|
||||
mutation InstallExternalExtension($url: String!) {
|
||||
installExternalExtension(input: { extensionUrl: $url }) {
|
||||
extension { apkName pkgName name isInstalled }
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const UPDATE_SOURCE_PREFERENCE = `
|
||||
mutation UpdateSourcePreference($source: LongString!, $change: SourcePreferenceChangeInput!) {
|
||||
updateSourcePreference(input: { source: $source, change: $change }) {
|
||||
source { id displayName }
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const SET_SOURCE_METAS = `
|
||||
mutation SetSourceMetas($input: SetSourceMetasInput!) {
|
||||
setSourceMetas(input: $input) {
|
||||
metas { sourceId key value }
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const DELETE_SOURCE_METAS = `
|
||||
mutation DeleteSourceMetas($input: DeleteSourceMetasInput!) {
|
||||
deleteSourceMetas(input: $input) {
|
||||
metas { sourceId key value }
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const UPDATE_SOURCE_METADATA = `
|
||||
mutation UpdateSourceMetadata(
|
||||
$preUpdateDeleteInput: DeleteSourceMetasInput!
|
||||
$hasPreUpdateDeletions: Boolean!
|
||||
$updateInput: SetSourceMetasInput!
|
||||
$hasUpdates: Boolean!
|
||||
$postUpdateDeleteInput: DeleteSourceMetasInput!
|
||||
$hasPostUpdateDeletions: Boolean!
|
||||
$migrateInput: SetSourceMetasInput!
|
||||
$isMigration: Boolean!
|
||||
) {
|
||||
preUpdateDeletedMeta: deleteSourceMetas(input: $preUpdateDeleteInput) @include(if: $hasPreUpdateDeletions) {
|
||||
metas { sourceId key value }
|
||||
}
|
||||
updatedMeta: setSourceMetas(input: $updateInput) @include(if: $hasUpdates) {
|
||||
metas { sourceId key value }
|
||||
}
|
||||
postUpdateDeletedMeta: deleteSourceMetas(input: $postUpdateDeleteInput) @include(if: $hasPostUpdateDeletions) {
|
||||
metas { sourceId key value }
|
||||
}
|
||||
migrationMeta: setSourceMetas(input: $migrateInput) @include(if: $isMigration) {
|
||||
metas { sourceId key value }
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const SET_SOURCE_META = `
|
||||
mutation SetSourceMeta($sourceId: LongString!, $key: String!, $value: String!) {
|
||||
setSourceMeta(input: { meta: { sourceId: $sourceId, key: $key, value: $value } }) {
|
||||
meta { key value }
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const DELETE_SOURCE_META = `
|
||||
mutation DeleteSourceMeta($sourceId: LongString!, $key: String!) {
|
||||
deleteSourceMeta(input: { sourceId: $sourceId, key: $key }) {
|
||||
meta { key value }
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const SET_CATEGORY_META = `
|
||||
mutation SetCategoryMeta($categoryId: Int!, $key: String!, $value: String!) {
|
||||
setCategoryMeta(input: { meta: { categoryId: $categoryId, key: $key, value: $value } }) {
|
||||
meta { key value }
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const DELETE_CATEGORY_META = `
|
||||
mutation DeleteCategoryMeta($categoryId: Int!, $key: String!) {
|
||||
deleteCategoryMeta(input: { categoryId: $categoryId, key: $key }) {
|
||||
meta { key value }
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const SET_GLOBAL_META = `
|
||||
mutation SetGlobalMeta($key: String!, $value: String!) {
|
||||
setGlobalMeta(input: { meta: { key: $key, value: $value } }) {
|
||||
meta { key value }
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const DELETE_GLOBAL_META = `
|
||||
mutation DeleteGlobalMeta($key: String!) {
|
||||
deleteGlobalMeta(input: { key: $key }) {
|
||||
meta { key value }
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const CLEAR_CACHED_IMAGES = `
|
||||
mutation ClearCachedImages($cachedPages: Boolean, $cachedThumbnails: Boolean, $downloadedThumbnails: Boolean) {
|
||||
clearCachedImages(input: {
|
||||
cachedPages: $cachedPages
|
||||
cachedThumbnails: $cachedThumbnails
|
||||
downloadedThumbnails: $downloadedThumbnails
|
||||
}) {
|
||||
cachedPages cachedThumbnails downloadedThumbnails
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const RESET_SETTINGS = `
|
||||
mutation ResetSettings {
|
||||
resetSettings(input: {}) {
|
||||
settings { extensionRepos }
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const SET_EXTENSION_REPOS = `
|
||||
mutation SetExtensionRepos($repos: [String!]!) {
|
||||
setSettings(input: { settings: { extensionRepos: $repos } }) {
|
||||
settings { extensionRepos }
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const SET_SERVER_AUTH = `
|
||||
mutation SetServerAuth($authMode: AuthMode!, $authUsername: String!, $authPassword: String!) {
|
||||
setSettings(input: { settings: { authMode: $authMode, authUsername: $authUsername, authPassword: $authPassword } }) {
|
||||
settings { authMode authUsername }
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const SET_SOCKS_PROXY = `
|
||||
mutation SetSocksProxy(
|
||||
$socksProxyEnabled: Boolean!
|
||||
$socksProxyHost: String!
|
||||
$socksProxyPort: String!
|
||||
$socksProxyVersion: Int!
|
||||
$socksProxyUsername: String!
|
||||
$socksProxyPassword: String!
|
||||
) {
|
||||
setSettings(input: { settings: {
|
||||
socksProxyEnabled: $socksProxyEnabled
|
||||
socksProxyHost: $socksProxyHost
|
||||
socksProxyPort: $socksProxyPort
|
||||
socksProxyVersion: $socksProxyVersion
|
||||
socksProxyUsername: $socksProxyUsername
|
||||
socksProxyPassword: $socksProxyPassword
|
||||
}}) {
|
||||
settings { socksProxyEnabled socksProxyHost socksProxyPort socksProxyVersion socksProxyUsername }
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const SET_FLARESOLVERR = `
|
||||
mutation SetFlareSolverr(
|
||||
$flareSolverrEnabled: Boolean!
|
||||
$flareSolverrUrl: String!
|
||||
$flareSolverrTimeout: Int!
|
||||
$flareSolverrSessionName: String!
|
||||
$flareSolverrSessionTtl: Int!
|
||||
$flareSolverrAsResponseFallback: Boolean!
|
||||
) {
|
||||
setSettings(input: { settings: {
|
||||
flareSolverrEnabled: $flareSolverrEnabled
|
||||
flareSolverrUrl: $flareSolverrUrl
|
||||
flareSolverrTimeout: $flareSolverrTimeout
|
||||
flareSolverrSessionName: $flareSolverrSessionName
|
||||
flareSolverrSessionTtl: $flareSolverrSessionTtl
|
||||
flareSolverrAsResponseFallback: $flareSolverrAsResponseFallback
|
||||
}}) {
|
||||
settings {
|
||||
flareSolverrEnabled flareSolverrUrl flareSolverrTimeout
|
||||
flareSolverrSessionName flareSolverrSessionTtl flareSolverrAsResponseFallback
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -1,5 +0,0 @@
|
||||
export * from "./manga";
|
||||
export * from "./chapters";
|
||||
export * from "./downloads";
|
||||
export * from "./extensions";
|
||||
export * from "./tracking";
|
||||
@@ -1,130 +0,0 @@
|
||||
# Mutations
|
||||
|
||||
## Manga (`mutations/manga.ts`)
|
||||
|
||||
| Mutation | Variables | Description |
|
||||
|----------|-----------|-------------|
|
||||
| `FETCH_MANGA` | `id: Int!` | Fetch and refresh manga metadata from its source |
|
||||
| `UPDATE_MANGA` | `id: Int!`, `inLibrary: Boolean` | Update a single manga's library membership |
|
||||
| `UPDATE_MANGAS` | `ids: [Int!]!`, `inLibrary: Boolean` | Bulk-update library membership for multiple manga |
|
||||
| `UPDATE_MANGA_CATEGORIES` | `mangaId: Int!`, `addTo: [Int!]!`, `removeFrom: [Int!]!` | Add or remove a single manga from categories |
|
||||
| `UPDATE_MANGAS_CATEGORIES` | `ids: [Int!]!`, `addTo: [Int!]!`, `removeFrom: [Int!]!` | Bulk add/remove multiple manga from categories |
|
||||
| `CREATE_CATEGORY` | `name: String!` | Create a new category |
|
||||
| `UPDATE_CATEGORY` | `id: Int!`, `name: String` | Update a category's name |
|
||||
| `UPDATE_CATEGORIES` | `ids: [Int!]!`, `patch: UpdateCategoryPatchInput!` | Bulk-update multiple categories |
|
||||
| `DELETE_CATEGORY` | `id: Int!` | Delete a category |
|
||||
| `UPDATE_CATEGORY_ORDER` | `id: Int!`, `position: Int!` | Move a category to a new position |
|
||||
| `UPDATE_CATEGORY_MANGA` | `categoryId: Int!` | Trigger a metadata update for all manga in a category |
|
||||
| `UPDATE_LIBRARY` | — | Trigger a full library metadata refresh |
|
||||
| `UPDATE_LIBRARY_MANGA` | `mangaId: Int!` | Trigger a metadata update for a single manga |
|
||||
| `UPDATE_STOP` | — | Stop the currently running library update job |
|
||||
| `CREATE_BACKUP` | — | Create a backup and return its download URL |
|
||||
| `RESTORE_BACKUP` | `backup: Upload!` | Restore a backup file and return the restore job status |
|
||||
| `SET_MANGA_META` | `mangaId: Int!`, `key: String!`, `value: String!` | Set a key/value meta entry on a manga |
|
||||
| `DELETE_MANGA_META` | `mangaId: Int!`, `key: String!` | Delete a key/value meta entry from a manga |
|
||||
|
||||
---
|
||||
|
||||
## Chapters (`mutations/chapters.ts`)
|
||||
|
||||
| Mutation | Variables | Description |
|
||||
|----------|-----------|-------------|
|
||||
| `FETCH_CHAPTERS` | `mangaId: Int!` | Fetch/refresh the chapter list for a manga from its source |
|
||||
| `FETCH_CHAPTER_PAGES` | `chapterId: Int!` | Fetch the page URLs for a specific chapter |
|
||||
| `MARK_CHAPTER_READ` | `id: Int!`, `isRead: Boolean!` | Mark a single chapter read or unread |
|
||||
| `MARK_CHAPTERS_READ` | `ids: [Int!]!`, `isRead: Boolean!` | Bulk mark chapters read or unread |
|
||||
| `UPDATE_CHAPTERS_PROGRESS` | `ids: [Int!]!`, `isRead: Boolean`, `isBookmarked: Boolean`, `lastPageRead: Int` | Bulk update read state, bookmark state, and last page read |
|
||||
| `DELETE_DOWNLOADED_CHAPTERS` | `ids: [Int!]!` | Delete downloaded chapter files |
|
||||
| `SET_CHAPTER_META` | `chapterId: Int!`, `key: String!`, `value: String!` | Set a key/value meta entry on a chapter |
|
||||
| `DELETE_CHAPTER_META` | `chapterId: Int!`, `key: String!` | Delete a key/value meta entry from a chapter |
|
||||
|
||||
---
|
||||
|
||||
## Downloads (`mutations/downloads.ts`)
|
||||
|
||||
| Mutation | Variables | Description |
|
||||
|----------|-----------|-------------|
|
||||
| `ENQUEUE_DOWNLOAD` | `chapterId: Int!` | Add a single chapter to the download queue |
|
||||
| `ENQUEUE_CHAPTERS_DOWNLOAD` | `chapterIds: [Int!]!` | Add multiple chapters to the download queue |
|
||||
| `DEQUEUE_DOWNLOAD` | `chapterId: Int!` | Remove a single chapter from the download queue |
|
||||
| `DEQUEUE_CHAPTERS_DOWNLOAD` | `chapterIds: [Int!]!` | Remove multiple chapters from the download queue |
|
||||
| `REORDER_DOWNLOAD` | `chapterId: Int!`, `to: Int!` | Move a queued chapter to a new position |
|
||||
| `START_DOWNLOADER` | — | Start the downloader |
|
||||
| `STOP_DOWNLOADER` | — | Stop the downloader |
|
||||
| `CLEAR_DOWNLOADER` | — | Clear all items from the download queue |
|
||||
| `FETCH_SOURCE_MANGA` | `source: LongString!`, `type: FetchSourceMangaType!`, `page: Int!`, `query: String`, `filters: [FilterChangeInput!]` | Fetch manga from a source (browse/search) with pagination |
|
||||
| `SET_DOWNLOADS_PATH` | `path: String!` | Set the downloads directory path |
|
||||
| `SET_LOCAL_SOURCE_PATH` | `path: String!` | Set the local source directory path |
|
||||
|
||||
---
|
||||
|
||||
## Extensions (`mutations/extensions.ts`)
|
||||
|
||||
| Mutation | Variables | Description |
|
||||
|----------|-----------|-------------|
|
||||
| `FETCH_EXTENSIONS` | — | Fetch the latest extension list from configured repos |
|
||||
| `UPDATE_EXTENSION` | `id: String!`, `install: Boolean`, `uninstall: Boolean`, `update: Boolean` | Install, uninstall, or update a single extension |
|
||||
| `UPDATE_EXTENSIONS` | `ids: [String!]!`, `install: Boolean`, `uninstall: Boolean`, `update: Boolean` | Bulk install, uninstall, or update multiple extensions |
|
||||
| `INSTALL_EXTERNAL_EXTENSION` | `url: String!` | Install an extension from an external APK URL |
|
||||
| `UPDATE_SOURCE_PREFERENCE` | `source: LongString!`, `change: SourcePreferenceChangeInput!` | Update a source-specific preference value |
|
||||
| `SET_SOURCE_META` | `sourceId: LongString!`, `key: String!`, `value: String!` | Set a key/value meta entry on a source |
|
||||
| `DELETE_SOURCE_META` | `sourceId: LongString!`, `key: String!` | Delete a key/value meta entry from a source |
|
||||
| `SET_CATEGORY_META` | `categoryId: Int!`, `key: String!`, `value: String!` | Set a key/value meta entry on a category |
|
||||
| `DELETE_CATEGORY_META` | `categoryId: Int!`, `key: String!` | Delete a key/value meta entry from a category |
|
||||
| `SET_GLOBAL_META` | `key: String!`, `value: String!` | Set a global key/value meta entry |
|
||||
| `DELETE_GLOBAL_META` | `key: String!` | Delete a global key/value meta entry |
|
||||
| `CLEAR_CACHED_IMAGES` | `cachedPages: Boolean`, `cachedThumbnails: Boolean`, `downloadedThumbnails: Boolean` | Selectively clear cached page images, cached thumbnails, or downloaded thumbnails |
|
||||
| `RESET_SETTINGS` | — | Reset all server settings to defaults |
|
||||
| `UPDATE_WEBUI` | — | Trigger a WebUI update and return live status |
|
||||
| `RESET_WEBUI_UPDATE_STATUS` | — | Reset the WebUI update status back to idle |
|
||||
| `SET_EXTENSION_REPOS` | `repos: [String!]!` | Set the list of extension repository URLs |
|
||||
| `SET_SERVER_AUTH` | `authMode: AuthMode!`, `authUsername: String!`, `authPassword: String!` | Configure server auth mode and credentials |
|
||||
| `SET_SOCKS_PROXY` | `socksProxyEnabled: Boolean!`, `socksProxyHost: String!`, `socksProxyPort: String!`, `socksProxyVersion: Int!`, `socksProxyUsername: String!`, `socksProxyPassword: String!` | Configure SOCKS proxy settings |
|
||||
| `SET_FLARESOLVERR` | `flareSolverrEnabled: Boolean!`, `flareSolverrUrl: String!`, `flareSolverrTimeout: Int!`, `flareSolverrSessionName: String!`, `flareSolverrSessionTtl: Int!`, `flareSolverrAsResponseFallback: Boolean!` | Configure FlareSolverr integration |
|
||||
|
||||
---
|
||||
|
||||
## Tracking (`mutations/tracking.ts`)
|
||||
|
||||
| Mutation | Variables | Description |
|
||||
|----------|-----------|-------------|
|
||||
| `BIND_TRACK` | `mangaId: Int!`, `trackerId: Int!`, `remoteId: LongString!` | Bind a manga to a remote tracker entry |
|
||||
| `UPDATE_TRACK` | `recordId: Int!`, `status: Int`, `lastChapterRead: Float`, `scoreString: String`, `startDate: LongString`, `finishDate: LongString`, `private: Boolean` | Update tracking progress, status, score, and dates |
|
||||
| `UNBIND_TRACK` | `recordId: Int!` | Unbind a manga from a tracker record |
|
||||
| `FETCH_TRACK` | `recordId: Int!` | Refresh a track record from the remote tracker |
|
||||
| `TRACK_PROGRESS` | `mangaId: Int!` | Sync current reading progress to all bound trackers for a manga |
|
||||
| `LOGIN_TRACKER_OAUTH` | `trackerId: Int!`, `callbackUrl: String!` | Initiate OAuth login for a tracker |
|
||||
| `LOGIN_TRACKER_CREDENTIALS` | `trackerId: Int!`, `username: String!`, `password: String!` | Log into a tracker with username and password |
|
||||
| `LOGOUT_TRACKER` | `trackerId: Int!` | Log out of a tracker |
|
||||
| `CONNECT_KOSYNC` | `username: String!`, `password: String!`, `serverAddress: String!` | Connect a KOReader sync account |
|
||||
| `LOGOUT_KOSYNC` | — | Disconnect the KOReader sync account |
|
||||
| `PULL_KOSYNC_PROGRESS` | `chapterId: Int!` | Pull reading progress from KOReader sync for a chapter |
|
||||
| `PUSH_KOSYNC_PROGRESS` | `chapterId: Int!` | Push reading progress to KOReader sync for a chapter |
|
||||
| `LOGIN_USER` | `username: String!`, `password: String!` | Authenticate and return access + refresh tokens |
|
||||
| `REFRESH_TOKEN` | — | Refresh the current access token |
|
||||
|
||||
---
|
||||
|
||||
## New in Preview
|
||||
|
||||
Mutations now available and not yet wired to any feature in Moku:
|
||||
|
||||
| Mutation | Potential Feature |
|
||||
|----------|-------------------|
|
||||
| `UPDATE_MANGAS_CATEGORIES` | Bulk category editor — move/assign multiple manga at once |
|
||||
| `UPDATE_CATEGORIES` | Bulk category settings — toggle update/download flags for multiple categories at once |
|
||||
| `UPDATE_CATEGORY_MANGA` | Per-category refresh button — update only one category's manga |
|
||||
| `UPDATE_LIBRARY_MANGA` | Single manga refresh — trigger from series detail without a full library update |
|
||||
| `UPDATE_STOP` | Cancel button for library update jobs |
|
||||
| `UPDATE_EXTENSIONS` | Bulk extension updater — "update all" button in extensions page |
|
||||
| `UPDATE_SOURCE_PREFERENCE` | Source settings page — persist source-specific preferences |
|
||||
| `SET_SOURCE_META` / `DELETE_SOURCE_META` | Per-source client state — store browse position, last filter, etc. |
|
||||
| `SET_CATEGORY_META` / `DELETE_CATEGORY_META` | Per-category client state — store sort/filter preferences per category |
|
||||
| `SET_CHAPTER_META` / `DELETE_CHAPTER_META` | Per-chapter client state — annotations, custom notes |
|
||||
| `SET_GLOBAL_META` / `DELETE_GLOBAL_META` | Server-synced app state — replace local persistence for settings that should roam |
|
||||
| `CLEAR_CACHED_IMAGES` | Storage settings — granular cache clearing (pages, thumbnails, downloaded) |
|
||||
| `RESET_SETTINGS` | Settings page — factory reset button |
|
||||
| `UPDATE_WEBUI` / `RESET_WEBUI_UPDATE_STATUS` | WebUI update flow in settings — trigger and monitor update progress |
|
||||
| `TRACK_PROGRESS` | One-tap sync — push current reading position to all trackers without opening tracking panel |
|
||||
| `CONNECT_KOSYNC` / `LOGOUT_KOSYNC` | KOReader sync settings section — connect/disconnect account |
|
||||
| `PULL_KOSYNC_PROGRESS` / `PUSH_KOSYNC_PROGRESS` | KOReader sync — manual pull/push per chapter, or auto-sync on chapter open/close |
|
||||
@@ -1,122 +0,0 @@
|
||||
const TRACK_RECORD_FRAGMENT = `
|
||||
id trackerId remoteId title status score displayScore
|
||||
lastChapterRead totalChapters remoteUrl startDate finishDate private libraryId
|
||||
`;
|
||||
|
||||
export const BIND_TRACK = `
|
||||
mutation BindTrack($mangaId: Int!, $trackerId: Int!, $remoteId: LongString!) {
|
||||
bindTrack(input: { mangaId: $mangaId, trackerId: $trackerId, remoteId: $remoteId }) {
|
||||
trackRecord { ${TRACK_RECORD_FRAGMENT} }
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const UPDATE_TRACK = `
|
||||
mutation UpdateTrack($recordId: Int!, $status: Int, $lastChapterRead: Float, $scoreString: String, $startDate: LongString, $finishDate: LongString, $private: Boolean) {
|
||||
updateTrack(input: { recordId: $recordId, status: $status, lastChapterRead: $lastChapterRead, scoreString: $scoreString, startDate: $startDate, finishDate: $finishDate, private: $private }) {
|
||||
trackRecord {
|
||||
id trackerId status score displayScore lastChapterRead totalChapters startDate finishDate private libraryId
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const UNBIND_TRACK = `
|
||||
mutation UnbindTrack($recordId: Int!) {
|
||||
unbindTrack(input: { recordId: $recordId }) {
|
||||
trackRecord { id }
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const FETCH_TRACK = `
|
||||
mutation FetchTrack($recordId: Int!) {
|
||||
fetchTrack(input: { recordId: $recordId }) {
|
||||
trackRecord {
|
||||
id trackerId status score displayScore lastChapterRead totalChapters startDate finishDate libraryId
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const TRACK_PROGRESS = `
|
||||
mutation TrackProgress($mangaId: Int!) {
|
||||
trackProgress(input: { mangaId: $mangaId }) {
|
||||
trackRecords {
|
||||
id trackerId lastChapterRead status
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const LOGIN_TRACKER_OAUTH = `
|
||||
mutation LoginTrackerOAuth($trackerId: Int!, $callbackUrl: String!) {
|
||||
loginTrackerOAuth(input: { trackerId: $trackerId, callbackUrl: $callbackUrl }) {
|
||||
isLoggedIn
|
||||
tracker { id name isLoggedIn isTokenExpired authUrl }
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const LOGIN_TRACKER_CREDENTIALS = `
|
||||
mutation LoginTrackerCredentials($trackerId: Int!, $username: String!, $password: String!) {
|
||||
loginTrackerCredentials(input: { trackerId: $trackerId, username: $username, password: $password }) {
|
||||
isLoggedIn
|
||||
tracker { id name isLoggedIn isTokenExpired authUrl }
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const LOGOUT_TRACKER = `
|
||||
mutation LogoutTracker($trackerId: Int!) {
|
||||
logoutTracker(input: { trackerId: $trackerId }) {
|
||||
tracker { id name isLoggedIn isTokenExpired authUrl }
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const CONNECT_KOSYNC = `
|
||||
mutation ConnectKoSync($username: String!, $password: String!, $serverAddress: String!) {
|
||||
connectKoSyncAccount(input: { username: $username, password: $password, serverAddress: $serverAddress }) {
|
||||
isConnected
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const LOGOUT_KOSYNC = `
|
||||
mutation LogoutKoSync {
|
||||
logoutKoSyncAccount(input: {}) {
|
||||
isConnected
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const PULL_KOSYNC_PROGRESS = `
|
||||
mutation PullKoSyncProgress($chapterId: Int!) {
|
||||
pullKoSyncProgress(input: { chapterId: $chapterId }) {
|
||||
chapter { id lastPageRead isRead }
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const PUSH_KOSYNC_PROGRESS = `
|
||||
mutation PushKoSyncProgress($chapterId: Int!) {
|
||||
pushKoSyncProgress(input: { chapterId: $chapterId }) {
|
||||
chapter { id lastPageRead isRead }
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const LOGIN_USER = `
|
||||
mutation Login($username: String!, $password: String!) {
|
||||
login(input: { username: $username, password: $password }) {
|
||||
accessToken
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const REFRESH_TOKEN = `
|
||||
mutation RefreshToken {
|
||||
refreshToken(input: {}) { accessToken }
|
||||
}
|
||||
`;
|
||||
@@ -1,22 +0,0 @@
|
||||
export const GET_RECENTLY_UPDATED = `
|
||||
query GetRecentlyUpdated {
|
||||
chapters(orderBy: FETCHED_AT, orderByType: DESC, first: 300) {
|
||||
nodes {
|
||||
mangaId
|
||||
fetchedAt
|
||||
manga { id title thumbnailUrl inLibrary }
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const GET_CHAPTERS = `
|
||||
query GetChapters($mangaId: Int!) {
|
||||
chapters(condition: { mangaId: $mangaId }) {
|
||||
nodes {
|
||||
id name chapterNumber sourceOrder isRead isDownloaded isBookmarked
|
||||
pageCount mangaId uploadDate realUrl lastPageRead lastReadAt scanlator
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -1,14 +0,0 @@
|
||||
export const GET_DOWNLOAD_STATUS = `
|
||||
query GetDownloadStatus {
|
||||
downloadStatus {
|
||||
state
|
||||
queue {
|
||||
progress state tries
|
||||
chapter {
|
||||
id name pageCount mangaId
|
||||
manga { id title thumbnailUrl }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -1,117 +0,0 @@
|
||||
export const GET_LOCAL_MANGA = `
|
||||
query GetLocalManga {
|
||||
mangas(condition: { sourceId: "0" }) {
|
||||
nodes { id title thumbnailUrl inLibrary }
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const GET_EXTENSIONS = `
|
||||
query GetExtensions {
|
||||
extensions {
|
||||
nodes {
|
||||
apkName pkgName name lang versionName
|
||||
isInstalled isObsolete hasUpdate iconUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const GET_SOURCES = `
|
||||
query GetSources {
|
||||
sources {
|
||||
nodes {
|
||||
id name lang displayName iconUrl isNsfw
|
||||
isConfigurable supportsLatest baseUrl
|
||||
extension { pkgName }
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const GET_SOURCE_SETTINGS = `
|
||||
query GetSourceSettings($id: LongString!) {
|
||||
source(id: $id) {
|
||||
id
|
||||
displayName
|
||||
preferences {
|
||||
... on CheckBoxPreference {
|
||||
type: __typename
|
||||
CheckBoxTitle: title
|
||||
CheckBoxSummary: summary
|
||||
CheckBoxDefault: default
|
||||
CheckBoxCurrentValue: currentValue
|
||||
key
|
||||
}
|
||||
... on SwitchPreference {
|
||||
type: __typename
|
||||
SwitchPreferenceTitle: title
|
||||
SwitchPreferenceSummary: summary
|
||||
SwitchPreferenceDefault: default
|
||||
SwitchPreferenceCurrentValue: currentValue
|
||||
key
|
||||
}
|
||||
... on ListPreference {
|
||||
type: __typename
|
||||
ListPreferenceTitle: title
|
||||
ListPreferenceSummary: summary
|
||||
ListPreferenceDefault: default
|
||||
ListPreferenceCurrentValue: currentValue
|
||||
entries
|
||||
entryValues
|
||||
key
|
||||
}
|
||||
... on EditTextPreference {
|
||||
type: __typename
|
||||
EditTextPreferenceTitle: title
|
||||
EditTextPreferenceSummary: summary
|
||||
EditTextPreferenceDefault: default
|
||||
EditTextPreferenceCurrentValue: currentValue
|
||||
dialogTitle
|
||||
dialogMessage
|
||||
key
|
||||
}
|
||||
... on MultiSelectListPreference {
|
||||
type: __typename
|
||||
MultiSelectListPreferenceTitle: title
|
||||
MultiSelectListPreferenceSummary: summary
|
||||
MultiSelectListPreferenceDefault: default
|
||||
MultiSelectListPreferenceCurrentValue: currentValue
|
||||
entries
|
||||
entryValues
|
||||
key
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const GET_MIGRATABLE_SOURCES = `
|
||||
query GetMigratableSources {
|
||||
mangas(condition: { inLibrary: true }) {
|
||||
nodes {
|
||||
sourceId
|
||||
source {
|
||||
id name lang displayName iconUrl isNsfw isConfigurable supportsLatest baseUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const GET_SETTINGS = `
|
||||
query GetSettings {
|
||||
settings { extensionRepos }
|
||||
}
|
||||
`;
|
||||
|
||||
export const GET_SERVER_SECURITY = `
|
||||
query GetServerSecurity {
|
||||
settings {
|
||||
authMode authUsername
|
||||
socksProxyEnabled socksProxyHost socksProxyPort socksProxyVersion socksProxyUsername
|
||||
flareSolverrEnabled flareSolverrUrl flareSolverrTimeout
|
||||
flareSolverrSessionName flareSolverrSessionTtl flareSolverrAsResponseFallback
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -1,7 +0,0 @@
|
||||
export * from "./manga";
|
||||
export * from "./chapters";
|
||||
export * from "./downloads";
|
||||
export * from "./extensions";
|
||||
export * from "./tracking";
|
||||
export * from "./updater";
|
||||
export * from "./meta";
|
||||
@@ -1,107 +0,0 @@
|
||||
export const GET_LIBRARY = `
|
||||
query GetLibrary {
|
||||
mangas(condition: { inLibrary: true }) {
|
||||
nodes {
|
||||
id title thumbnailUrl inLibrary downloadCount unreadCount bookmarkCount
|
||||
description status author artist genre
|
||||
inLibraryAt lastFetchedAt chaptersLastFetchedAt thumbnailUrlLastFetched
|
||||
source { id name displayName }
|
||||
chapters { totalCount }
|
||||
latestFetchedChapter { id uploadDate }
|
||||
latestUploadedChapter { id uploadDate }
|
||||
lastReadChapter { id chapterNumber }
|
||||
firstUnreadChapter { id chapterNumber }
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const GET_ALL_MANGA = `
|
||||
query GetAllManga {
|
||||
mangas {
|
||||
nodes { id title thumbnailUrl inLibrary downloadCount }
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const GET_MANGA = `
|
||||
query GetManga($id: Int!) {
|
||||
manga(id: $id) {
|
||||
id title description thumbnailUrl status author artist genre inLibrary realUrl
|
||||
inLibraryAt lastFetchedAt thumbnailUrlLastFetched updateStrategy
|
||||
source { id name displayName }
|
||||
lastReadChapter { id chapterNumber lastPageRead }
|
||||
firstUnreadChapter { id chapterNumber }
|
||||
highestNumberedChapter { id chapterNumber }
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const GET_CATEGORIES = `
|
||||
query GetCategories {
|
||||
categories {
|
||||
nodes {
|
||||
id name order default includeInUpdate includeInDownload
|
||||
mangas {
|
||||
nodes { id title thumbnailUrl inLibrary downloadCount unreadCount }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const GET_DOWNLOADED_CHAPTERS_PAGES = `
|
||||
query GetDownloadedChaptersPages {
|
||||
chapters(condition: { isDownloaded: true }) {
|
||||
nodes { pageCount }
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const GET_DOWNLOADS_PATH = `
|
||||
query GetDownloadsPath {
|
||||
settings { downloadsPath localSourcePath }
|
||||
}
|
||||
`;
|
||||
|
||||
export const LIBRARY_UPDATE_STATUS = `
|
||||
query LibraryUpdateStatus {
|
||||
libraryUpdateStatus {
|
||||
jobsInfo {
|
||||
isRunning finishedJobs totalJobs skippedMangasCount skippedCategoriesCount
|
||||
}
|
||||
mangaUpdates {
|
||||
status
|
||||
manga { id title thumbnailUrl unreadCount }
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const GET_RESTORE_STATUS = `
|
||||
query GetRestoreStatus($id: String!) {
|
||||
restoreStatus(id: $id) { mangaProgress state totalManga }
|
||||
}
|
||||
`;
|
||||
|
||||
export const VALIDATE_BACKUP = `
|
||||
query ValidateBackup($backup: Upload!) {
|
||||
validateBackup(input: { backup: $backup }) {
|
||||
missingSources { id name }
|
||||
missingTrackers { name }
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const MANGAS_BY_GENRE = `
|
||||
query MangasByGenre($filter: MangaFilterInput, $first: Int, $offset: Int) {
|
||||
mangas(filter: $filter, first: $first, offset: $offset, orderBy: IN_LIBRARY_AT, orderByType: DESC) {
|
||||
nodes {
|
||||
id title thumbnailUrl inLibrary genre status
|
||||
source { id displayName }
|
||||
}
|
||||
pageInfo { hasNextPage }
|
||||
totalCount
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -1,15 +0,0 @@
|
||||
export const GET_META = `
|
||||
query GetMeta($key: String!) {
|
||||
meta(key: $key) {
|
||||
key value
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const GET_METAS = `
|
||||
query GetMetas {
|
||||
metas {
|
||||
nodes { key value }
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -1,117 +0,0 @@
|
||||
# Queries
|
||||
|
||||
## Manga (`queries/manga.ts`)
|
||||
|
||||
| Query | Variables | Description |
|
||||
|-------|-----------|-------------|
|
||||
| `GET_LIBRARY` | — | All in-library manga with metadata, source, chapter counts, download count, unread count, bookmark count, and read progress anchors (`lastReadChapter`, `firstUnreadChapter`) |
|
||||
| `GET_ALL_MANGA` | — | Minimal manga list — id, title, thumbnail, library flag, download count |
|
||||
| `GET_MANGA` | `id: Int!` | Full detail for a single manga — includes `updateStrategy`, `lastReadChapter`, `firstUnreadChapter`, `highestNumberedChapter` |
|
||||
| `GET_CATEGORIES` | — | All categories with order/settings and their assigned manga (minimal fields) |
|
||||
| `GET_DOWNLOADED_CHAPTERS_PAGES` | — | Page counts for all downloaded chapters — used for storage stats |
|
||||
| `GET_DOWNLOADS_PATH` | — | `downloadsPath` and `localSourcePath` from settings |
|
||||
| `LIBRARY_UPDATE_STATUS` | — | Current library update job — `jobsInfo` progress and `mangaUpdates` list with new chapters |
|
||||
| `GET_RESTORE_STATUS` | `id: String!` | Backup restore job status by job ID — `mangaProgress`, `state`, `totalManga` |
|
||||
| `VALIDATE_BACKUP` | `backup: Upload!` | Validate a backup file before restore — returns missing sources and trackers |
|
||||
| `MANGAS_BY_GENRE` | `filter: MangaFilterInput`, `first: Int`, `offset: Int` | Paginated manga filtered by genre, ordered by `IN_LIBRARY_AT DESC` |
|
||||
|
||||
---
|
||||
|
||||
## Chapters (`queries/chapters.ts`)
|
||||
|
||||
| Query | Variables | Description |
|
||||
|-------|-----------|-------------|
|
||||
| `GET_RECENTLY_UPDATED` | — | Latest 300 chapters ordered by `FETCHED_AT DESC` with parent manga info |
|
||||
| `GET_CHAPTERS` | `mangaId: Int!` | All chapters for a manga — includes `lastReadAt`, `lastPageRead`, read/download/bookmark state, page count, scanlator |
|
||||
|
||||
---
|
||||
|
||||
## Downloads (`queries/downloads.ts`)
|
||||
|
||||
| Query | Variables | Description |
|
||||
|-------|-----------|-------------|
|
||||
| `GET_DOWNLOAD_STATUS` | — | Downloader state (`DownloaderState` enum) and full queue with chapter and manga info |
|
||||
|
||||
---
|
||||
|
||||
## Extensions (`queries/extensions.ts`)
|
||||
|
||||
| Query | Variables | Description |
|
||||
|-------|-----------|-------------|
|
||||
| `GET_LOCAL_MANGA` | — | Manga from the local source (`sourceId: "0"`) |
|
||||
| `GET_EXTENSIONS` | — | All extensions — install status, update flag, obsolete flag, metadata |
|
||||
| `GET_SOURCES` | — | All sources — id, name, lang, display name, icon, NSFW flag, `isConfigurable`, `supportsLatest`, `baseUrl` |
|
||||
| `GET_SETTINGS` | — | `extensionRepos` from settings |
|
||||
| `GET_SERVER_SECURITY` | — | Full security config — auth mode, SOCKS proxy settings, FlareSolverr settings |
|
||||
|
||||
---
|
||||
|
||||
## Tracking (`queries/tracking.ts`)
|
||||
|
||||
| Query | Variables | Description |
|
||||
|-------|-----------|-------------|
|
||||
| `GET_TRACKERS` | — | All trackers with login state, token expiry, capability flags (`supportsPrivateTracking`, `supportsReadingDates`, `supportsTrackDeletion`), scores, and statuses |
|
||||
| `GET_MANGA_TRACK_RECORDS` | `mangaId: Int!` | All track records for a specific manga — includes `libraryId`, score, dates, privacy flag |
|
||||
| `SEARCH_TRACKER` | `trackerId: Int!`, `query: String!` | Search a tracker by query string — returns id, title, cover, summary, publishing info |
|
||||
| `GET_ALL_TRACKER_RECORDS` | — | All trackers and their full record lists with associated manga — includes `isTokenExpired`, `libraryId` |
|
||||
| `GET_TRACKER_RECORDS` | `trackerId: Int!` | Records for a specific tracker with associated manga |
|
||||
|
||||
---
|
||||
|
||||
## Updater (`queries/updater.ts`)
|
||||
|
||||
| Query | Variables | Description |
|
||||
|-------|-----------|-------------|
|
||||
| `GET_ABOUT_SERVER` | — | Server name, version, build type, build time, GitHub and Discord links |
|
||||
| `GET_ABOUT_WEBUI` | — | WebUI channel, tag, and last update timestamp |
|
||||
| `CHECK_FOR_SERVER_UPDATES` | — | Available server updates — channel, tag, download URL |
|
||||
| `CHECK_FOR_WEBUI_UPDATE` | — | Available WebUI updates — channel and tag |
|
||||
| `GET_WEBUI_UPDATE_STATUS` | — | Live WebUI update state (`UpdateState` enum), progress percent, and info block |
|
||||
|
||||
---
|
||||
|
||||
## Meta (`queries/meta.ts`)
|
||||
|
||||
| Query | Variables | Description |
|
||||
|-------|-----------|-------------|
|
||||
| `GET_META` | `key: String!` | Single server-side key/value meta entry |
|
||||
| `GET_METAS` | — | All global meta entries as a node list |
|
||||
|
||||
---
|
||||
|
||||
## KoSync (`queries/kosync.ts`)
|
||||
|
||||
| Query | Variables | Description |
|
||||
|-------|-----------|-------------|
|
||||
| `GET_KOSYNC_STATUS` | — | KOReader sync connection status |
|
||||
|
||||
---
|
||||
|
||||
## New in Preview
|
||||
|
||||
Queries and fields now available but not yet wired to any feature in Moku:
|
||||
|
||||
| Query / Field | Potential Feature |
|
||||
|---------------|-------------------|
|
||||
| `GET_ABOUT_SERVER` | About page — server version, build info, links to GitHub and Discord |
|
||||
| `GET_ABOUT_WEBUI` | About page — WebUI version and release channel |
|
||||
| `CHECK_FOR_SERVER_UPDATES` | Update available banner or settings badge |
|
||||
| `CHECK_FOR_WEBUI_UPDATE` | Update available banner or settings badge |
|
||||
| `GET_WEBUI_UPDATE_STATUS` | Update progress indicator in settings |
|
||||
| `GET_META` / `GET_METAS` | Server-side persistence — sync app state across clients without local storage |
|
||||
| `GET_KOSYNC_STATUS` | KOReader sync settings section — show connection state |
|
||||
| `trackRecords` (top-level) | Flat tracker record browser — filter by score, privacy, tracker |
|
||||
| `category` (single by id) | Direct category detail without fetching all categories |
|
||||
| `chapter` (single by id) | Direct chapter lookup without fetching full manga chapter list |
|
||||
| `source` (single by id) | Source detail page — preferences, filters, browse |
|
||||
| `tracker` (single by id) | Individual tracker detail — statuses, records |
|
||||
| `trackRecord` (single by id) | Direct track record lookup for deep linking |
|
||||
| `lastUpdateTimestamp` | Stale data detection — poll before refetching library |
|
||||
| `MangaType.hasDuplicateChapters` | Library health view — flag manga with duplicate chapter numbers |
|
||||
| `MangaType.age` / `chaptersAge` | Stale manga indicator — highlight series with no updates in N days |
|
||||
| `MangaType.initialized` | Loading skeleton gating — skip detail render until manga is fully fetched |
|
||||
| `SourceType.isConfigurable` | Source list — show gear icon only when source is configurable |
|
||||
| `SourceType.supportsLatest` | Source browse UI — conditionally show Latest tab |
|
||||
| `TrackerType.supportsTrackDeletion` | Tracking panel — show remove button only when tracker supports it |
|
||||
| `TrackerType.supportsReadingDates` | Tracking panel — show date fields only when tracker supports them |
|
||||
| `TrackerType.isTokenExpired` | Re-auth prompt — detect expired tokens before a request fails |
|
||||
@@ -1,71 +0,0 @@
|
||||
export const GET_TRACKERS = `
|
||||
query GetTrackers {
|
||||
trackers {
|
||||
nodes {
|
||||
id name icon isLoggedIn isTokenExpired authUrl
|
||||
supportsPrivateTracking supportsReadingDates supportsTrackDeletion
|
||||
scores
|
||||
statuses { value name }
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const GET_MANGA_TRACK_RECORDS = `
|
||||
query GetMangaTrackRecords($mangaId: Int!) {
|
||||
manga(id: $mangaId) {
|
||||
trackRecords {
|
||||
nodes {
|
||||
id trackerId remoteId title status score displayScore
|
||||
lastChapterRead totalChapters remoteUrl startDate finishDate private libraryId
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const SEARCH_TRACKER = `
|
||||
query SearchTracker($trackerId: Int!, $query: String!) {
|
||||
searchTracker(input: { trackerId: $trackerId, query: $query }) {
|
||||
trackSearches {
|
||||
id trackerId remoteId title coverUrl summary
|
||||
publishingStatus publishingType startDate totalChapters trackingUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const GET_ALL_TRACKER_RECORDS = `
|
||||
query GetAllTrackerRecords {
|
||||
trackers {
|
||||
nodes {
|
||||
id name icon isLoggedIn isTokenExpired scores
|
||||
statuses { value name }
|
||||
trackRecords {
|
||||
nodes {
|
||||
id trackerId title status displayScore lastChapterRead
|
||||
totalChapters remoteUrl private libraryId
|
||||
manga { id title thumbnailUrl inLibrary }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const GET_TRACKER_RECORDS = `
|
||||
query GetTrackerRecords($trackerId: Int!) {
|
||||
trackers(condition: { id: $trackerId }) {
|
||||
nodes {
|
||||
id name
|
||||
statuses { value name }
|
||||
trackRecords {
|
||||
nodes {
|
||||
id title status displayScore lastChapterRead totalChapters remoteUrl
|
||||
manga { id title thumbnailUrl }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
+203
@@ -0,0 +1,203 @@
|
||||
@import '$lib/components/settings/Settings.css';
|
||||
@import '$lib/styles/themes.css';
|
||||
|
||||
:root {
|
||||
--bg-void: #080808;
|
||||
--bg-base: #0c0c0c;
|
||||
--bg-surface: #101010;
|
||||
--bg-raised: #151515;
|
||||
--bg-overlay: #1a1a1a;
|
||||
--bg-subtle: #202020;
|
||||
|
||||
--border-dim: #1c1c1c;
|
||||
--border-base: #242424;
|
||||
--border-strong: #2e2e2e;
|
||||
--border-focus: #4a5c4a;
|
||||
|
||||
--text-primary: #f0efec;
|
||||
--text-secondary: #c8c6c0;
|
||||
--text-muted: #8a8880;
|
||||
--text-faint: #4e4d4a;
|
||||
--text-disabled: #2a2a28;
|
||||
|
||||
--accent: #6b8f6b;
|
||||
--accent-dim: #2a3d2a;
|
||||
--accent-muted: #1a251a;
|
||||
--accent-fg: #a8c4a8;
|
||||
--accent-bright: #8fb88f;
|
||||
|
||||
--color-error: #c47a7a;
|
||||
--color-error-bg: #1f1212;
|
||||
--color-success: #7aab7a;
|
||||
--color-info: #7a9ec4;
|
||||
--color-info-bg: #121a1f;
|
||||
--color-read: #2e2e2c;
|
||||
|
||||
--dot-active: var(--accent);
|
||||
--dot-inactive: var(--text-faint);
|
||||
|
||||
--t-fast: 0.08s ease;
|
||||
--t-base: 0.14s ease;
|
||||
--t-slow: 0.22s ease;
|
||||
|
||||
--radius-sm: 3px;
|
||||
--radius-md: 5px;
|
||||
--radius-lg: 7px;
|
||||
--radius-xl: 10px;
|
||||
--radius-2xl: 14px;
|
||||
--radius-full: 9999px;
|
||||
|
||||
--sp-1: 4px;
|
||||
--sp-2: 8px;
|
||||
--sp-3: 12px;
|
||||
--sp-4: 16px;
|
||||
--sp-5: 20px;
|
||||
--sp-6: 24px;
|
||||
--sp-8: 32px;
|
||||
--sp-10: 40px;
|
||||
|
||||
--sidebar-width: 52px;
|
||||
--titlebar-height: 36px;
|
||||
|
||||
--font-ui: "DM Mono", "Fira Mono", ui-monospace, monospace;
|
||||
--font-sans: "DM Sans", ui-sans-serif, system-ui, sans-serif;
|
||||
|
||||
--text-2xs: 10px;
|
||||
--text-xs: 11px;
|
||||
--text-sm: 12px;
|
||||
--text-base: 13px;
|
||||
--text-md: 14px;
|
||||
--text-lg: 15px;
|
||||
--text-xl: 17px;
|
||||
--text-2xl: 20px;
|
||||
--text-3xl: 24px;
|
||||
|
||||
--weight-normal: 400;
|
||||
--weight-medium: 500;
|
||||
--weight-semi: 600;
|
||||
|
||||
--leading-none: 1;
|
||||
--leading-tight: 1.3;
|
||||
--leading-snug: 1.45;
|
||||
--leading-base: 1.6;
|
||||
|
||||
--tracking-tight: -0.02em;
|
||||
--tracking-normal: 0;
|
||||
--tracking-wide: 0.06em;
|
||||
--tracking-wider: 0.1em;
|
||||
|
||||
--z-reader: 50;
|
||||
--z-modal: 100;
|
||||
--z-settings: 150;
|
||||
}
|
||||
|
||||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html, body {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: var(--bg-void);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
#svelte {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
input, textarea, select {
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
ul, ol { list-style: none; }
|
||||
|
||||
img, svg { display: block; max-width: 100%; }
|
||||
|
||||
p { margin: 0; }
|
||||
|
||||
body {
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-base);
|
||||
font-weight: var(--weight-normal);
|
||||
line-height: var(--leading-base);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: transparent transparent;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar { width: 4px; height: 4px; }
|
||||
*::-webkit-scrollbar-track { background: transparent; }
|
||||
*::-webkit-scrollbar-thumb { background: transparent; border-radius: 99px; }
|
||||
*::-webkit-scrollbar-thumb:hover { background: transparent; }
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes fadeUp {
|
||||
from { opacity: 0; transform: translateY(5px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes fadeDown {
|
||||
from { opacity: 0; transform: translateY(-5px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes scaleIn {
|
||||
from { opacity: 0; transform: scale(0.97); }
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.35; }
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
from { background-position: -200% 0; }
|
||||
to { background-position: 200% 0; }
|
||||
}
|
||||
|
||||
.anim-fade-in { animation: fadeIn 0.14s ease both; }
|
||||
.anim-fade-up { animation: fadeUp 0.18s ease both; }
|
||||
.anim-fade-down { animation: fadeDown 0.18s ease both; }
|
||||
.anim-scale-in { animation: scaleIn 0.14s ease both; }
|
||||
.anim-pulse { animation: pulse 1.6s ease infinite; }
|
||||
.anim-spin { animation: spin 0.7s linear infinite; }
|
||||
|
||||
.skeleton {
|
||||
background: linear-gradient(90deg, var(--bg-raised) 25%, var(--bg-overlay) 50%, var(--bg-raised) 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.4s ease infinite;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
declare global {
|
||||
namespace App {}
|
||||
const __APP_VERSION__: string
|
||||
}
|
||||
export {}
|
||||
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.svg" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-theme="dark">
|
||||
<div id="svelte">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1 +0,0 @@
|
||||
export * from "./selectPortal";
|
||||
@@ -1,40 +0,0 @@
|
||||
import type { Attachment } from "svelte/attachments";
|
||||
|
||||
/**
|
||||
* {@attach selectPortal(triggerEl)}
|
||||
*
|
||||
* Moves the decorated element to <body> and positions it below `triggerEl`.
|
||||
* The element stays reactive — Svelte still owns its DOM, we just re-parent it.
|
||||
*
|
||||
* The portalled menu element is stored on `triggerEl.__selectMenuEl` so that
|
||||
* the outside-click guard in Settings.svelte can exclude it from dismissal.
|
||||
*/
|
||||
export function selectPortal(triggerEl: HTMLElement & { __selectMenuEl?: HTMLElement | null }): Attachment {
|
||||
return (menuEl: HTMLElement) => {
|
||||
// Position & move to body
|
||||
function position() {
|
||||
const r = triggerEl.getBoundingClientRect();
|
||||
menuEl.style.position = "fixed";
|
||||
menuEl.style.top = `${r.bottom + 4}px`;
|
||||
menuEl.style.left = `${r.right - menuEl.offsetWidth}px`;
|
||||
// clamp to viewport left edge
|
||||
const left = parseFloat(menuEl.style.left);
|
||||
if (left < 8) menuEl.style.left = "8px";
|
||||
}
|
||||
|
||||
document.body.appendChild(menuEl);
|
||||
triggerEl.__selectMenuEl = menuEl;
|
||||
position();
|
||||
|
||||
// Reposition on scroll / resize while open
|
||||
window.addEventListener("scroll", position, true);
|
||||
window.addEventListener("resize", position);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("scroll", position, true);
|
||||
window.removeEventListener("resize", position);
|
||||
triggerEl.__selectMenuEl = null;
|
||||
menuEl.remove();
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
export * from './sort';
|
||||
export * from './filter';
|
||||
export * from './paginate';
|
||||
export * from './search';
|
||||
export * from './queue';
|
||||
@@ -1,29 +0,0 @@
|
||||
export interface AsyncQueue<T> {
|
||||
enqueue(item: T): void;
|
||||
drain(): void;
|
||||
clear(): void;
|
||||
size(): number;
|
||||
}
|
||||
|
||||
export function createAsyncQueue<T>(
|
||||
worker: (item: T) => Promise<void>,
|
||||
concurrency = 1,
|
||||
): AsyncQueue<T> {
|
||||
const queue: T[] = [];
|
||||
let active = 0;
|
||||
|
||||
function next() {
|
||||
while (active < concurrency && queue.length > 0) {
|
||||
const item = queue.shift()!;
|
||||
active++;
|
||||
worker(item).finally(() => { active--; next(); });
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
enqueue(item) { queue.push(item); next(); },
|
||||
drain() { next(); },
|
||||
clear() { queue.length = 0; },
|
||||
size() { return queue.length; },
|
||||
};
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
export interface SearchResult<T> {
|
||||
item: T;
|
||||
score: number;
|
||||
}
|
||||
|
||||
export function searchItems<T>(
|
||||
items: T[],
|
||||
query: string,
|
||||
getField: (item: T) => string,
|
||||
): T[] {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return items;
|
||||
return items.filter(item => getField(item).toLowerCase().includes(q));
|
||||
}
|
||||
|
||||
export function searchWithScore<T>(
|
||||
items: T[],
|
||||
query: string,
|
||||
getField: (item: T) => string,
|
||||
): SearchResult<T>[] {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return items.map(item => ({ item, score: 0 }));
|
||||
|
||||
return items
|
||||
.map(item => {
|
||||
const field = getField(item).toLowerCase();
|
||||
if (!field.includes(q)) return null;
|
||||
const score = field === q ? 2 : field.startsWith(q) ? 1 : 0;
|
||||
return { item, score };
|
||||
})
|
||||
.filter((r): r is SearchResult<T> => r !== null)
|
||||
.sort((a, b) => b.score - a.score);
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
/**
|
||||
* Runs an async task over every item in `items`, with at most `concurrency`
|
||||
* tasks in-flight at once. Respects the provided AbortSignal — each worker
|
||||
* exits early if the signal fires. Errors thrown by individual tasks are
|
||||
* swallowed so one failure does not cancel the whole batch.
|
||||
*/
|
||||
export async function runConcurrent<T>(
|
||||
items: T[],
|
||||
fn: (item: T) => Promise<void>,
|
||||
signal: AbortSignal,
|
||||
concurrency = 6,
|
||||
): Promise<void> {
|
||||
let i = 0;
|
||||
async function worker() {
|
||||
while (i < items.length) {
|
||||
if (signal.aborted) return;
|
||||
const item = items[i++];
|
||||
await fn(item).catch(() => {});
|
||||
}
|
||||
}
|
||||
await Promise.all(
|
||||
Array.from({ length: Math.min(concurrency, items.length) }, worker),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deduplicates in-flight async calls by key.
|
||||
*
|
||||
* Two call signatures are supported:
|
||||
*
|
||||
* 1. Direct call — supply a key and a zero-arg factory each time:
|
||||
* dedupeRequest("my-key", () => fetchSomething())
|
||||
* If a request with that key is already pending, the existing Promise is
|
||||
* returned and the factory is not called again.
|
||||
*
|
||||
* 2. Curried wrapper — supply a key-based fetcher once, get back a
|
||||
* single-arg function you can call repeatedly:
|
||||
* const get = dedupeRequest((key) => fetchSomething(key))
|
||||
* get("my-key")
|
||||
*/
|
||||
const _inflight = new Map<string, Promise<unknown>>();
|
||||
|
||||
export function dedupeRequest<T>(key: string, factory: () => Promise<T>): Promise<T>;
|
||||
export function dedupeRequest<T>(fn: (key: string) => Promise<T>): (key: string) => Promise<T>;
|
||||
export function dedupeRequest<T>(
|
||||
keyOrFn: string | ((key: string) => Promise<T>),
|
||||
factory?: () => Promise<T>,
|
||||
): Promise<T> | ((key: string) => Promise<T>) {
|
||||
// Curried wrapper form
|
||||
if (typeof keyOrFn === 'function') {
|
||||
const fn = keyOrFn;
|
||||
return (key: string) => dedupeRequest(key, () => fn(key));
|
||||
}
|
||||
|
||||
// Direct call form
|
||||
const key = keyOrFn;
|
||||
if (_inflight.has(key)) return _inflight.get(key) as Promise<T>;
|
||||
const p = factory!().finally(() => _inflight.delete(key));
|
||||
_inflight.set(key, p);
|
||||
return p;
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
export interface PaginatedQuery<T> {
|
||||
fetchPage(page: number): Promise<T[]>;
|
||||
reset(): void;
|
||||
hasMore(): boolean;
|
||||
}
|
||||
|
||||
export interface PaginatedQueryConfig<T> {
|
||||
fetcher: (page: number) => Promise<{ items: T[]; hasNextPage: boolean }>;
|
||||
}
|
||||
|
||||
export function createPaginatedQuery<T>(
|
||||
config: PaginatedQueryConfig<T>,
|
||||
): PaginatedQuery<T> {
|
||||
let _hasMore = true;
|
||||
|
||||
return {
|
||||
async fetchPage(page) {
|
||||
const { items, hasNextPage } = await config.fetcher(page);
|
||||
_hasMore = hasNextPage;
|
||||
return items;
|
||||
},
|
||||
reset() { _hasMore = true; },
|
||||
hasMore() { return _hasMore; },
|
||||
};
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export * from './fetchWithRetry';
|
||||
export * from './batchRequests';
|
||||
export * from './createPaginatedQuery';
|
||||
@@ -1,151 +0,0 @@
|
||||
import { store, updateSettings } from "@store/state.svelte";
|
||||
|
||||
export type AuthMode = "NONE" | "BASIC_AUTH" | "UI_LOGIN";
|
||||
|
||||
export class AuthRequiredError extends Error {
|
||||
constructor(msg = "Authentication required") {
|
||||
super(msg);
|
||||
this.name = "AuthRequiredError";
|
||||
}
|
||||
}
|
||||
|
||||
const TOKEN_KEY = "moku_access_token";
|
||||
let _accessToken: string | null = sessionStorage.getItem(TOKEN_KEY);
|
||||
|
||||
export const uiAuth = {
|
||||
getToken: () => _accessToken,
|
||||
setToken: (t: string) => { _accessToken = t; sessionStorage.setItem(TOKEN_KEY, t); },
|
||||
clearToken: () => { _accessToken = null; sessionStorage.removeItem(TOKEN_KEY); },
|
||||
};
|
||||
|
||||
export const authSession = {
|
||||
clearTokens() { uiAuth.clearToken(); },
|
||||
hasSession(): boolean {
|
||||
const mode = store.settings.serverAuthMode ?? "NONE";
|
||||
if (mode === "UI_LOGIN") return _accessToken !== null;
|
||||
return true;
|
||||
},
|
||||
};
|
||||
|
||||
function getServerBase(): string {
|
||||
const url = store.settings.serverUrl;
|
||||
return typeof url === "string" && url.trim() ? url.replace(/\/$/, "") : "http://127.0.0.1:4567";
|
||||
}
|
||||
|
||||
function timeoutSignal(ms: number): AbortSignal {
|
||||
const controller = new AbortController();
|
||||
setTimeout(() => controller.abort(), ms);
|
||||
return controller.signal;
|
||||
}
|
||||
|
||||
function basicHeader(user: string, pass: string): Record<string, string> {
|
||||
return { Authorization: `Basic ${btoa(`${user}:${pass}`)}` };
|
||||
}
|
||||
|
||||
function bearerHeader(token: string): Record<string, string> {
|
||||
return { Authorization: `Bearer ${token}` };
|
||||
}
|
||||
|
||||
function gqlBody(query: string, variables?: Record<string, unknown>): string {
|
||||
return JSON.stringify({ query, ...(variables ? { variables } : {}) });
|
||||
}
|
||||
|
||||
export async function fetchAuthenticated(
|
||||
url: string,
|
||||
init: RequestInit,
|
||||
signal?: AbortSignal,
|
||||
skipped = false,
|
||||
): Promise<Response> {
|
||||
const mode = store.settings.serverAuthMode ?? "NONE";
|
||||
const baseHeaders = (init.headers ?? {}) as Record<string, string>;
|
||||
|
||||
if (mode === "BASIC_AUTH") {
|
||||
const user = store.settings.serverAuthUser?.trim() ?? "";
|
||||
const pass = store.settings.serverAuthPass?.trim() ?? "";
|
||||
return fetch(url, {
|
||||
...init, signal, credentials: "omit",
|
||||
headers: { ...baseHeaders, ...(user && pass ? basicHeader(user, pass) : {}) },
|
||||
});
|
||||
}
|
||||
|
||||
if (mode === "UI_LOGIN") {
|
||||
const token = uiAuth.getToken();
|
||||
if (!token) {
|
||||
if (skipped) return fetch(url, { ...init, signal, credentials: "omit", headers: baseHeaders });
|
||||
throw new AuthRequiredError();
|
||||
}
|
||||
return fetch(url, {
|
||||
...init, signal, credentials: "omit",
|
||||
headers: { ...baseHeaders, ...bearerHeader(token) },
|
||||
});
|
||||
}
|
||||
|
||||
return fetch(url, { ...init, signal, credentials: "omit" });
|
||||
}
|
||||
|
||||
export async function loginUI(user: string, pass: string): Promise<void> {
|
||||
const res = await fetch(`${getServerBase()}/api/graphql`, {
|
||||
method: "POST", credentials: "omit",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: gqlBody(
|
||||
`mutation Login($username: String!, $password: String!) {
|
||||
login(input: { username: $username, password: $password }) { accessToken }
|
||||
}`,
|
||||
{ username: user, password: pass },
|
||||
),
|
||||
signal: timeoutSignal(8000),
|
||||
});
|
||||
if (!res.ok) throw new Error(`Login request failed (${res.status})`);
|
||||
const json = await res.json();
|
||||
const token: string | undefined = json?.data?.login?.accessToken;
|
||||
if (!token) throw new Error(json?.errors?.[0]?.message ?? "Login failed");
|
||||
uiAuth.setToken(token);
|
||||
updateSettings({ serverAuthMode: "UI_LOGIN" });
|
||||
}
|
||||
|
||||
export async function loginBasic(user: string, pass: string): Promise<void> {
|
||||
const res = await fetch(`${getServerBase()}/api/graphql`, {
|
||||
method: "POST", credentials: "omit",
|
||||
headers: { "Content-Type": "application/json", ...basicHeader(user, pass) },
|
||||
body: gqlBody("{ __typename }"),
|
||||
signal: timeoutSignal(5000),
|
||||
});
|
||||
if (!res.ok) throw new Error(`Authentication failed (${res.status})`);
|
||||
updateSettings({ serverAuthMode: "BASIC_AUTH", serverAuthUser: user, serverAuthPass: pass });
|
||||
}
|
||||
|
||||
export async function logout(): Promise<void> {
|
||||
uiAuth.clearToken();
|
||||
updateSettings({ serverAuthPass: "", serverAuthMode: "NONE" });
|
||||
}
|
||||
|
||||
export async function probeServer(): Promise<"ok" | "auth_required" | "unreachable"> {
|
||||
const base = getServerBase();
|
||||
const mode = store.settings.serverAuthMode ?? "NONE";
|
||||
const s = store.settings;
|
||||
|
||||
if (mode === "UI_LOGIN" && !_accessToken) return "auth_required";
|
||||
|
||||
try {
|
||||
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
||||
if (mode === "BASIC_AUTH") {
|
||||
const user = s.serverAuthUser?.trim() ?? "";
|
||||
const pass = s.serverAuthPass?.trim() ?? "";
|
||||
if (user && pass) Object.assign(headers, basicHeader(user, pass));
|
||||
} else if (mode === "UI_LOGIN" && _accessToken) {
|
||||
Object.assign(headers, bearerHeader(_accessToken));
|
||||
}
|
||||
|
||||
const res = await fetch(`${base}/api/graphql`, {
|
||||
method: "POST", credentials: "omit", headers,
|
||||
body: gqlBody("{ __typename }"),
|
||||
signal: timeoutSignal(5000),
|
||||
});
|
||||
|
||||
if (res.ok) return "ok";
|
||||
if (res.status === 401) return "auth_required";
|
||||
return "unreachable";
|
||||
} catch {
|
||||
return "unreachable";
|
||||
}
|
||||
}
|
||||
Vendored
-4
@@ -1,4 +0,0 @@
|
||||
export * from './memoryCache';
|
||||
export * from './pageCache';
|
||||
export * from './imageCache';
|
||||
export * from './queryCache';
|
||||
Vendored
@@ -1,27 +0,0 @@
|
||||
import { store, linkManga } from "@store/state.svelte";
|
||||
import type { Manga } from "@types";
|
||||
|
||||
export function autoLinkLibrary(focal: Manga, allManga: Manga[]): Promise<number> {
|
||||
return new Promise((resolve) => {
|
||||
const worker = new Worker(
|
||||
new URL("./autoLinkWorker.ts", import.meta.url),
|
||||
{ type: "module" },
|
||||
);
|
||||
|
||||
worker.onmessage = (e: MessageEvent<number[]>) => {
|
||||
const matches = e.data;
|
||||
for (const id of matches) linkManga(focal.id, id);
|
||||
worker.terminate();
|
||||
resolve(matches.length);
|
||||
};
|
||||
|
||||
worker.onerror = () => { worker.terminate(); resolve(0); };
|
||||
|
||||
worker.postMessage({
|
||||
focalTitle: focal.title,
|
||||
focalId: focal.id,
|
||||
allManga: allManga.map(m => ({ id: m.id, title: m.title })),
|
||||
linkedIds: store.settings.mangaLinks?.[focal.id] ?? [],
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
interface WorkerMsg {
|
||||
focalTitle: string;
|
||||
focalId: number;
|
||||
allManga: { id: number; title: string }[];
|
||||
linkedIds: number[];
|
||||
}
|
||||
|
||||
function titleSimilarity(a: string, b: string): number {
|
||||
const norm = (s: string) =>
|
||||
s.toLowerCase().replace(/[^a-z0-9\s]/g, "").split(/\s+/).filter(Boolean);
|
||||
const wa = new Set(norm(a));
|
||||
const wb = new Set(norm(b));
|
||||
if (!wa.size || !wb.size) return 0;
|
||||
const intersection = [...wa].filter(w => wb.has(w)).length;
|
||||
return intersection / new Set([...wa, ...wb]).size;
|
||||
}
|
||||
|
||||
self.onmessage = (e: MessageEvent<WorkerMsg>) => {
|
||||
const { focalTitle, focalId, allManga, linkedIds } = e.data;
|
||||
const matches: number[] = [];
|
||||
|
||||
for (const m of allManga) {
|
||||
if (m.id === focalId) continue;
|
||||
if (linkedIds.includes(m.id)) continue;
|
||||
if (titleSimilarity(focalTitle, m.title) >= 0.65) matches.push(m.id);
|
||||
}
|
||||
|
||||
self.postMessage(matches);
|
||||
};
|
||||
@@ -1,54 +0,0 @@
|
||||
const THUMB_SIZE = 16;
|
||||
const DUPE_THRESH = 0.12;
|
||||
|
||||
const hashCache = new Map<string, Uint8ClampedArray>();
|
||||
|
||||
function toGray(data: Uint8ClampedArray, pixels: number): Uint8ClampedArray {
|
||||
const gray = new Uint8ClampedArray(pixels);
|
||||
for (let i = 0; i < pixels; i++) {
|
||||
const o = i * 4;
|
||||
gray[i] = (data[o] * 299 + data[o + 1] * 587 + data[o + 2] * 114) / 1000;
|
||||
}
|
||||
return gray;
|
||||
}
|
||||
|
||||
function loadThumb(url: string): Promise<Uint8ClampedArray> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.crossOrigin = "anonymous";
|
||||
img.onload = () => {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = canvas.height = THUMB_SIZE;
|
||||
const ctx = canvas.getContext("2d")!;
|
||||
ctx.drawImage(img, 0, 0, THUMB_SIZE, THUMB_SIZE);
|
||||
resolve(toGray(ctx.getImageData(0, 0, THUMB_SIZE, THUMB_SIZE).data, THUMB_SIZE * THUMB_SIZE));
|
||||
};
|
||||
img.onerror = reject;
|
||||
img.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
function similarity(a: Uint8ClampedArray, b: Uint8ClampedArray): number {
|
||||
let diff = 0;
|
||||
for (let i = 0; i < a.length; i++) diff += Math.abs(a[i] - b[i]);
|
||||
return diff / (a.length * 255);
|
||||
}
|
||||
|
||||
export async function getHash(url: string): Promise<Uint8ClampedArray | null> {
|
||||
if (hashCache.has(url)) return hashCache.get(url)!;
|
||||
try {
|
||||
const thumb = await loadThumb(url);
|
||||
hashCache.set(url, thumb);
|
||||
return thumb;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function areDuplicates(a: Uint8ClampedArray, b: Uint8ClampedArray): boolean {
|
||||
return similarity(a, b) <= DUPE_THRESH;
|
||||
}
|
||||
|
||||
export function clearHashCache(): void {
|
||||
hashCache.clear();
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
import { store } from "@store/state.svelte";
|
||||
import { searchWithScore } from "@core/algorithms/search";
|
||||
import { getHash, areDuplicates } from "@core/cover/coverHash";
|
||||
|
||||
type CoverManga = { id: number; thumbnailUrl: string; source?: { displayName: string } | null };
|
||||
|
||||
export type CoverCandidate = {
|
||||
mangaId: number;
|
||||
url: string;
|
||||
label: string;
|
||||
isActive: boolean;
|
||||
};
|
||||
|
||||
const FUZZY_SCORE_THRESHOLD = 0.65;
|
||||
|
||||
function normalizeUrl(url: string): string {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
u.search = "";
|
||||
return u.href.toLowerCase();
|
||||
} catch {
|
||||
return url.toLowerCase();
|
||||
}
|
||||
}
|
||||
|
||||
export function resolvedCover(mangaId: number, ownUrl: string): string {
|
||||
return store.settings.mangaPrefs?.[mangaId]?.coverUrl ?? ownUrl;
|
||||
}
|
||||
|
||||
function fuzzyMatchIds(
|
||||
mangaId: number,
|
||||
title: string,
|
||||
mangaById: Map<number, CoverManga & { title: string }>,
|
||||
): number[] {
|
||||
const results = searchWithScore(
|
||||
[...mangaById.values()].filter(m => m.id !== mangaId),
|
||||
title,
|
||||
m => m.title,
|
||||
);
|
||||
return results
|
||||
.filter(r => r.score >= FUZZY_SCORE_THRESHOLD)
|
||||
.map(r => r.item.id);
|
||||
}
|
||||
|
||||
export function coverCandidatesSync(
|
||||
mangaId: number,
|
||||
title: string,
|
||||
ownUrl: string,
|
||||
mangaById: Map<number, CoverManga & { title: string }>,
|
||||
): CoverCandidate[] {
|
||||
const linkedIds = store.getLinkedMangaIds(mangaId);
|
||||
const fuzzyIds = fuzzyMatchIds(mangaId, title, mangaById);
|
||||
const current = store.settings.mangaPrefs?.[mangaId]?.coverUrl ?? ownUrl;
|
||||
|
||||
const allIds = Array.from(new Set([...linkedIds, ...fuzzyIds]));
|
||||
|
||||
const raw: { mangaId: number; url: string; label: string }[] = [
|
||||
{ mangaId, url: ownUrl, label: "This source" },
|
||||
...allIds.flatMap(id => {
|
||||
const m = mangaById.get(id);
|
||||
return m ? [{ mangaId: m.id, url: m.thumbnailUrl, label: m.source?.displayName ?? `ID ${m.id}` }] : [];
|
||||
}),
|
||||
];
|
||||
|
||||
const seen = new Set<string>();
|
||||
return raw
|
||||
.filter(c => {
|
||||
const key = normalizeUrl(c.url);
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
})
|
||||
.map(c => ({ ...c, isActive: normalizeUrl(c.url) === normalizeUrl(current) }));
|
||||
}
|
||||
|
||||
export async function dedupeByImage(candidates: CoverCandidate[]): Promise<CoverCandidate[]> {
|
||||
const hashes = await Promise.all(candidates.map(c => getHash(c.url)));
|
||||
|
||||
const groups: number[][] = [];
|
||||
|
||||
for (let i = 0; i < candidates.length; i++) {
|
||||
const hi = hashes[i];
|
||||
const existing = hi
|
||||
? groups.find(g => { const hj = hashes[g[0]]; return hj ? areDuplicates(hi, hj) : false; })
|
||||
: undefined;
|
||||
if (existing) existing.push(i);
|
||||
else groups.push([i]);
|
||||
}
|
||||
|
||||
return groups.map(group => {
|
||||
const active = group.find(i => candidates[i].isActive) ?? group[0];
|
||||
const labels = [...new Set(group.map(i => candidates[i].label))];
|
||||
return { ...candidates[active], label: labels.join(" · ") };
|
||||
});
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
export { getHash, areDuplicates, clearHashCache } from "./coverHash";
|
||||
export { resolvedCover, coverCandidatesSync, dedupeByImage } from "./coverResolver";
|
||||
export type { CoverCandidate } from "./coverResolver";
|
||||
export { autoLinkLibrary } from "./autoLink";
|
||||
@@ -1,3 +0,0 @@
|
||||
export { eventToKeybind, matchesKeybind, toggleFullscreen } from "./keybindEngine";
|
||||
export { DEFAULT_KEYBINDS, KEYBIND_LABELS } from "./defaultBinds";
|
||||
export type { Keybinds } from "./defaultBinds";
|
||||
@@ -1,88 +0,0 @@
|
||||
const VAULT_KEY = "moku-credential-vault";
|
||||
const SALT_ITERATIONS = 200_000;
|
||||
const KEY_USAGE: KeyUsage[] = ["encrypt", "decrypt"];
|
||||
|
||||
export interface VaultPayload {
|
||||
refreshToken?: string;
|
||||
basicUser?: string;
|
||||
basicPass?: string;
|
||||
authMode: "UI_LOGIN" | "BASIC_AUTH" | "NONE";
|
||||
}
|
||||
|
||||
interface StoredVault {
|
||||
salt: string;
|
||||
iv: string;
|
||||
data: string;
|
||||
}
|
||||
|
||||
function toB64(buf: ArrayBuffer): string {
|
||||
return btoa(String.fromCharCode(...new Uint8Array(buf)));
|
||||
}
|
||||
|
||||
function fromB64(s: string): Uint8Array {
|
||||
return Uint8Array.from(atob(s), (c) => c.charCodeAt(0));
|
||||
}
|
||||
|
||||
async function deriveKey(pin: string, salt: Uint8Array): Promise<CryptoKey> {
|
||||
const enc = new TextEncoder();
|
||||
const keyMat = await crypto.subtle.importKey("raw", enc.encode(pin), "PBKDF2", false, ["deriveKey"]);
|
||||
return crypto.subtle.deriveKey(
|
||||
{ name: "PBKDF2", salt, iterations: SALT_ITERATIONS, hash: "SHA-256" },
|
||||
keyMat,
|
||||
{ name: "AES-GCM", length: 256 },
|
||||
false,
|
||||
KEY_USAGE,
|
||||
);
|
||||
}
|
||||
|
||||
export function vaultExists(): boolean {
|
||||
return !!localStorage.getItem(VAULT_KEY);
|
||||
}
|
||||
|
||||
export async function lockVault(pin: string, payload: VaultPayload): Promise<void> {
|
||||
const salt = crypto.getRandomValues(new Uint8Array(16));
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||
const key = await deriveKey(pin, salt);
|
||||
|
||||
const enc = new TextEncoder();
|
||||
const cipher = await crypto.subtle.encrypt(
|
||||
{ name: "AES-GCM", iv },
|
||||
key,
|
||||
enc.encode(JSON.stringify(payload)),
|
||||
);
|
||||
|
||||
localStorage.setItem(VAULT_KEY, JSON.stringify({
|
||||
salt: toB64(salt),
|
||||
iv: toB64(iv),
|
||||
data: toB64(cipher),
|
||||
} satisfies StoredVault));
|
||||
}
|
||||
|
||||
export async function unlockVault(pin: string): Promise<VaultPayload | null> {
|
||||
const raw = localStorage.getItem(VAULT_KEY);
|
||||
if (!raw) return null;
|
||||
|
||||
try {
|
||||
const stored = JSON.parse(raw) as StoredVault;
|
||||
const key = await deriveKey(pin, fromB64(stored.salt));
|
||||
const plain = await crypto.subtle.decrypt(
|
||||
{ name: "AES-GCM", iv: fromB64(stored.iv) },
|
||||
key,
|
||||
fromB64(stored.data),
|
||||
);
|
||||
return JSON.parse(new TextDecoder().decode(plain)) as VaultPayload;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function clearVault(): void {
|
||||
localStorage.removeItem(VAULT_KEY);
|
||||
}
|
||||
|
||||
export async function rekeyVault(oldPin: string, newPin: string): Promise<boolean> {
|
||||
const payload = await unlockVault(oldPin);
|
||||
if (!payload) return false;
|
||||
await lockVault(newPin, payload);
|
||||
return true;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
export { loadAllStores, persistSettings, persistLibrary, persistUpdates } from "./persist";
|
||||
export type { PersistedData } from "./persist";
|
||||
|
||||
export { vaultExists, lockVault, unlockVault, clearVault, rekeyVault } from "./credentialVault";
|
||||
export type { VaultPayload } from "./credentialVault";
|
||||
@@ -1,166 +0,0 @@
|
||||
import { LazyStore } from "@tauri-apps/plugin-store";
|
||||
|
||||
const settingsStore = new LazyStore("settings.json", { autoSave: false });
|
||||
const libraryStore = new LazyStore("library.json", { autoSave: false });
|
||||
const updatesStore = new LazyStore("updates.json", { autoSave: false });
|
||||
const backupsStore = new LazyStore("backups.json", { autoSave: false });
|
||||
|
||||
export interface PersistedData {
|
||||
settings: any;
|
||||
storeVersion: number | null;
|
||||
history: any[];
|
||||
bookmarks: any[];
|
||||
markers: any[];
|
||||
readLog: any[];
|
||||
readingStats: any | null;
|
||||
dailyReadCounts: Record<string, number>;
|
||||
libraryUpdates: any[];
|
||||
lastLibraryRefresh: number;
|
||||
acknowledgedUpdateIds: number[];
|
||||
}
|
||||
|
||||
export async function loadAllStores(): Promise<PersistedData> {
|
||||
const migrated = await migrateFromLocalStorage();
|
||||
if (migrated) return migrated;
|
||||
|
||||
const [sv, s, hist, bk, mk, rl, rs, dc, lu, llr, au] = await Promise.all([
|
||||
settingsStore.get<number>("storeVersion"),
|
||||
settingsStore.get<any>("settings"),
|
||||
libraryStore.get<any[]>("history"),
|
||||
libraryStore.get<any[]>("bookmarks"),
|
||||
libraryStore.get<any[]>("markers"),
|
||||
libraryStore.get<any[]>("readLog"),
|
||||
libraryStore.get<any>("readingStats"),
|
||||
libraryStore.get<Record<string, number>>("dailyReadCounts"),
|
||||
updatesStore.get<any[]>("libraryUpdates"),
|
||||
updatesStore.get<number>("lastLibraryRefresh"),
|
||||
updatesStore.get<number[]>("acknowledgedUpdateIds"),
|
||||
]);
|
||||
|
||||
return {
|
||||
storeVersion: sv ?? null,
|
||||
settings: s ?? null,
|
||||
history: hist ?? [],
|
||||
bookmarks: bk ?? [],
|
||||
markers: mk ?? [],
|
||||
readLog: rl ?? [],
|
||||
readingStats: rs ?? null,
|
||||
dailyReadCounts: dc ?? {},
|
||||
libraryUpdates: lu ?? [],
|
||||
lastLibraryRefresh: llr ?? 0,
|
||||
acknowledgedUpdateIds: au ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
async function migrateFromLocalStorage(): Promise<PersistedData | null> {
|
||||
try {
|
||||
const raw = localStorage.getItem("moku-store");
|
||||
if (!raw) return null;
|
||||
const data = JSON.parse(raw);
|
||||
|
||||
await Promise.all([
|
||||
persistSettings({ settings: data.settings ?? null, storeVersion: data.storeVersion ?? 1 }),
|
||||
persistLibrary({
|
||||
history: data.history ?? [],
|
||||
bookmarks: data.bookmarks ?? [],
|
||||
markers: data.markers ?? [],
|
||||
readLog: data.readLog ?? [],
|
||||
readingStats: data.readingStats ?? null,
|
||||
dailyReadCounts: data.dailyReadCounts ?? {},
|
||||
}),
|
||||
persistUpdates({
|
||||
libraryUpdates: data.libraryUpdates ?? [],
|
||||
lastLibraryRefresh: data.lastLibraryRefresh ?? 0,
|
||||
acknowledgedUpdateIds: data.acknowledgedUpdateIds ?? [],
|
||||
}),
|
||||
]);
|
||||
|
||||
localStorage.removeItem("moku-store");
|
||||
|
||||
return {
|
||||
storeVersion: data.storeVersion ?? null,
|
||||
settings: data.settings ?? null,
|
||||
history: data.history ?? [],
|
||||
bookmarks: data.bookmarks ?? [],
|
||||
markers: data.markers ?? [],
|
||||
readLog: data.readLog ?? [],
|
||||
readingStats: data.readingStats ?? null,
|
||||
dailyReadCounts: data.dailyReadCounts ?? {},
|
||||
libraryUpdates: data.libraryUpdates ?? [],
|
||||
lastLibraryRefresh: data.lastLibraryRefresh ?? 0,
|
||||
acknowledgedUpdateIds: data.acknowledgedUpdateIds ?? [],
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function persistSettings(data: { settings: any; storeVersion: number }) {
|
||||
await Promise.all([
|
||||
settingsStore.set("settings", data.settings),
|
||||
settingsStore.set("storeVersion", data.storeVersion),
|
||||
]);
|
||||
await settingsStore.save();
|
||||
}
|
||||
|
||||
export async function persistLibrary(data: {
|
||||
history: any[];
|
||||
bookmarks: any[];
|
||||
markers: any[];
|
||||
readLog: any[];
|
||||
readingStats: any;
|
||||
dailyReadCounts: Record<string, number>;
|
||||
}) {
|
||||
await Promise.all([
|
||||
libraryStore.set("history", data.history),
|
||||
libraryStore.set("bookmarks", data.bookmarks),
|
||||
libraryStore.set("markers", data.markers),
|
||||
libraryStore.set("readLog", data.readLog),
|
||||
libraryStore.set("readingStats", data.readingStats),
|
||||
libraryStore.set("dailyReadCounts", data.dailyReadCounts),
|
||||
]);
|
||||
await libraryStore.save();
|
||||
}
|
||||
|
||||
export async function persistUpdates(data: {
|
||||
libraryUpdates: any[];
|
||||
lastLibraryRefresh: number;
|
||||
acknowledgedUpdateIds: number[];
|
||||
}) {
|
||||
await Promise.all([
|
||||
updatesStore.set("libraryUpdates", data.libraryUpdates),
|
||||
updatesStore.set("lastLibraryRefresh", data.lastLibraryRefresh),
|
||||
updatesStore.set("acknowledgedUpdateIds", data.acknowledgedUpdateIds),
|
||||
]);
|
||||
await updatesStore.save();
|
||||
}
|
||||
|
||||
export interface BackupEntry { url: string; name: string; }
|
||||
|
||||
export async function loadBackups(): Promise<BackupEntry[]> {
|
||||
const fromStore = await backupsStore.get<BackupEntry[]>("backupList");
|
||||
if (fromStore) return fromStore;
|
||||
try {
|
||||
const raw = localStorage.getItem("moku_backups");
|
||||
if (!raw) return [];
|
||||
const migrated: BackupEntry[] = JSON.parse(raw);
|
||||
await persistBackups(migrated);
|
||||
localStorage.removeItem("moku_backups");
|
||||
return migrated;
|
||||
} catch { return []; }
|
||||
}
|
||||
|
||||
export async function persistBackups(list: BackupEntry[]): Promise<void> {
|
||||
await backupsStore.set("backupList", list);
|
||||
await backupsStore.save();
|
||||
}
|
||||
|
||||
export async function resetAuthSettings(): Promise<void> {
|
||||
const current = await settingsStore.get<any>("settings") ?? {};
|
||||
current.serverAuthMode = "NONE";
|
||||
current.serverAuthUser = "";
|
||||
current.serverAuthPass = "";
|
||||
await settingsStore.set("settings", current);
|
||||
await settingsStore.save();
|
||||
localStorage.removeItem("moku-credential-vault");
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
import { store, updateSettings } from "@store/state.svelte";
|
||||
|
||||
let themeStyleEl: HTMLStyleElement | null = null;
|
||||
let mediaQuery: MediaQueryList | null = null;
|
||||
let mediaHandler: (() => void) | null = null;
|
||||
|
||||
export function applyTheme() {
|
||||
const themeId = store.settings.theme ?? "dark";
|
||||
const isCustom = themeId.startsWith("custom:");
|
||||
|
||||
if (!isCustom) {
|
||||
themeStyleEl?.remove();
|
||||
themeStyleEl = null;
|
||||
document.documentElement.setAttribute("data-theme", themeId);
|
||||
return;
|
||||
}
|
||||
|
||||
const custom = store.settings.customThemes?.find(t => t.id === themeId);
|
||||
if (!custom) {
|
||||
themeStyleEl?.remove();
|
||||
themeStyleEl = null;
|
||||
document.documentElement.setAttribute("data-theme", "dark");
|
||||
return;
|
||||
}
|
||||
|
||||
const vars = Object.entries(custom.tokens)
|
||||
.map(([k, v]) => ` --${k}: ${v};`)
|
||||
.join("\n");
|
||||
const css = `[data-theme="custom"] {\n${vars}\n}`;
|
||||
|
||||
if (!themeStyleEl) {
|
||||
themeStyleEl = document.createElement("style");
|
||||
themeStyleEl.id = "moku-custom-theme";
|
||||
document.head.appendChild(themeStyleEl);
|
||||
}
|
||||
themeStyleEl.textContent = css;
|
||||
document.documentElement.setAttribute("data-theme", "custom");
|
||||
}
|
||||
|
||||
function applySystemTheme(dark: boolean) {
|
||||
const themeId = dark
|
||||
? (store.settings.systemThemeDark ?? "dark")
|
||||
: (store.settings.systemThemeLight ?? "light");
|
||||
updateSettings({ theme: themeId });
|
||||
}
|
||||
|
||||
export function mountSystemThemeSync() {
|
||||
if (mediaQuery && mediaHandler) {
|
||||
mediaQuery.removeEventListener("change", mediaHandler);
|
||||
mediaHandler = null;
|
||||
}
|
||||
|
||||
if (!store.settings.systemThemeSync) return;
|
||||
|
||||
mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
mediaHandler = () => applySystemTheme(mediaQuery!.matches);
|
||||
mediaQuery.addEventListener("change", mediaHandler);
|
||||
applySystemTheme(mediaQuery.matches);
|
||||
}
|
||||
|
||||
export function unmountSystemThemeSync() {
|
||||
if (mediaQuery && mediaHandler) {
|
||||
mediaQuery.removeEventListener("change", mediaHandler);
|
||||
mediaHandler = null;
|
||||
mediaQuery = null;
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import { store } from "@store/state.svelte";
|
||||
|
||||
const IDLE_EVENTS = ["mousemove", "mousedown", "keydown", "touchstart", "wheel"] as const;
|
||||
|
||||
export function mountIdleDetection(onIdle: () => void, onActive: () => void): () => void {
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function reset() {
|
||||
if (timer) clearTimeout(timer);
|
||||
const ms = (store.settings.idleTimeoutMin ?? 5) * 60 * 1000;
|
||||
if (ms === 0) return;
|
||||
timer = setTimeout(onIdle, ms);
|
||||
onActive();
|
||||
}
|
||||
|
||||
IDLE_EVENTS.forEach(e => window.addEventListener(e, reset, { passive: true }));
|
||||
reset();
|
||||
|
||||
return () => {
|
||||
if (timer) clearTimeout(timer);
|
||||
IDLE_EVENTS.forEach(e => window.removeEventListener(e, reset));
|
||||
};
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export * from './idle';
|
||||
export * from './zoom';
|
||||
export * from './touchscreen';
|
||||
@@ -1,40 +0,0 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { getVersion } from "@tauri-apps/api/app";
|
||||
import { addToast } from "@store/state.svelte";
|
||||
|
||||
function parse(tag: string): number[] {
|
||||
return tag.replace(/^v/, "").split(".").map(Number);
|
||||
}
|
||||
|
||||
function compare(a: number[], b: number[]): number {
|
||||
for (let i = 0; i < 3; i++) {
|
||||
if ((a[i] ?? 0) !== (b[i] ?? 0)) return (b[i] ?? 0) - (a[i] ?? 0);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
export async function checkForUpdateSilently(): Promise<void> {
|
||||
try {
|
||||
const [currentVersion, releases] = await Promise.all([
|
||||
getVersion(),
|
||||
invoke<Array<{ tag_name: string; html_url: string }>>("list_releases"),
|
||||
]);
|
||||
|
||||
const valid = releases.filter(r => typeof r.tag_name === "string" && r.tag_name.trim());
|
||||
if (!valid.length) return;
|
||||
|
||||
const latestTag = valid
|
||||
.map(r => r.tag_name)
|
||||
.sort((a, b) => compare(parse(a), parse(b)))[0]
|
||||
.replace(/^v/, "");
|
||||
|
||||
if (compare(parse(latestTag), parse(currentVersion)) < 0) {
|
||||
addToast({
|
||||
kind: "info",
|
||||
title: `Update available — v${latestTag}`,
|
||||
body: "Open Settings → About to install.",
|
||||
duration: 8000,
|
||||
});
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes fadeUp {
|
||||
from { opacity: 0; transform: translateY(5px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes fadeDown {
|
||||
from { opacity: 0; transform: translateY(-5px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes scaleIn {
|
||||
from { opacity: 0; transform: scale(0.97); }
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.35; }
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
from { background-position: -200% 0; }
|
||||
to { background-position: 200% 0; }
|
||||
}
|
||||
|
||||
.anim-fade-in { animation: fadeIn 0.14s ease both; }
|
||||
.anim-fade-up { animation: fadeUp 0.18s ease both; }
|
||||
.anim-fade-down { animation: fadeDown 0.18s ease both; }
|
||||
.anim-scale-in { animation: scaleIn 0.14s ease both; }
|
||||
.anim-pulse { animation: pulse 1.6s ease infinite; }
|
||||
.anim-spin { animation: spin 0.7s linear infinite; }
|
||||
|
||||
.skeleton {
|
||||
background: linear-gradient(90deg, var(--bg-raised) 25%, var(--bg-overlay) 50%, var(--bg-raised) 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.4s ease infinite;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
@import "./reset.css";
|
||||
@import "./animations.css";
|
||||
@import "./scrollbars.css";
|
||||
@import "./typography.css";
|
||||
@@ -1,41 +0,0 @@
|
||||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html, body {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: var(--bg-void);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
#app {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
input, textarea, select {
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
ul, ol { list-style: none; }
|
||||
|
||||
img, svg { display: block; max-width: 100%; }
|
||||
|
||||
p { margin: 0; }
|
||||
@@ -1,9 +0,0 @@
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: transparent transparent;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar { width: 4px; height: 4px; }
|
||||
*::-webkit-scrollbar-track { background: transparent; }
|
||||
*::-webkit-scrollbar-thumb { background: transparent; border-radius: 99px; }
|
||||
*::-webkit-scrollbar-thumb:hover { background: transparent; }
|
||||
@@ -1,9 +0,0 @@
|
||||
body {
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-base);
|
||||
font-weight: var(--weight-normal);
|
||||
line-height: var(--leading-base);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
[data-theme="dark"] {
|
||||
--bg-void: #000000;
|
||||
--bg-base: #080808;
|
||||
--bg-surface: #0d0d0d;
|
||||
--bg-raised: #111111;
|
||||
--bg-overlay: #171717;
|
||||
--bg-subtle: #1e1e1e;
|
||||
|
||||
--border-dim: #252525;
|
||||
--border-base: #303030;
|
||||
--border-strong: #3e3e3e;
|
||||
--border-focus: #5a7a5a;
|
||||
|
||||
--text-primary: #ffffff;
|
||||
--text-secondary: #e8e6e0;
|
||||
--text-muted: #b0aea8;
|
||||
--text-faint: #6e6c68;
|
||||
--text-disabled: #303030;
|
||||
|
||||
--accent: #7aaa7a;
|
||||
--accent-dim: #2e4a2e;
|
||||
--accent-muted: #1e2e1e;
|
||||
--accent-fg: #bcd8bc;
|
||||
--accent-bright: #9fcf9f;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
@import "./original.css";
|
||||
@import "./dark.css";
|
||||
@import "./light.css";
|
||||
@import "./midnight.css";
|
||||
@import "./warm.css";
|
||||
@@ -1,29 +0,0 @@
|
||||
[data-theme="light"] {
|
||||
--bg-void: #d8d4ce;
|
||||
--bg-base: #e2deda;
|
||||
--bg-surface: #ece8e2;
|
||||
--bg-raised: #f5f2ec;
|
||||
--bg-overlay: #ffffff;
|
||||
--bg-subtle: #e4e0d8;
|
||||
|
||||
--border-dim: #c4c0b8;
|
||||
--border-base: #b0aca4;
|
||||
--border-strong: #989490;
|
||||
--border-focus: #3a5a3a;
|
||||
|
||||
--text-primary: #080806;
|
||||
--text-secondary: #181612;
|
||||
--text-muted: #38342e;
|
||||
--text-faint: #706c64;
|
||||
--text-disabled: #b0aca4;
|
||||
|
||||
--accent: #2a5a2a;
|
||||
--accent-dim: #b0ccb0;
|
||||
--accent-muted: #c8dcc8;
|
||||
--accent-fg: #183818;
|
||||
--accent-bright: #1e4e1e;
|
||||
|
||||
--color-error: #8a1a1a;
|
||||
--color-error-bg: #f8e0e0;
|
||||
--color-read: #e0dcd4;
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
[data-theme="midnight"] {
|
||||
--bg-void: #050810;
|
||||
--bg-base: #080c18;
|
||||
--bg-surface: #0c1020;
|
||||
--bg-raised: #101428;
|
||||
--bg-overlay: #151a30;
|
||||
--bg-subtle: #1a2038;
|
||||
|
||||
--border-dim: #1a2035;
|
||||
--border-base: #222840;
|
||||
--border-strong: #2c3450;
|
||||
--border-focus: #4a5c8a;
|
||||
|
||||
--text-primary: #eeeef8;
|
||||
--text-secondary: #c0c4d8;
|
||||
--text-muted: #808498;
|
||||
--text-faint: #404860;
|
||||
--text-disabled: #202840;
|
||||
|
||||
--accent: #6a7ab8;
|
||||
--accent-dim: #252d50;
|
||||
--accent-muted: #181e38;
|
||||
--accent-fg: #a8b4e8;
|
||||
--accent-bright: #8896d0;
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
[data-theme="original"] {
|
||||
--bg-void: #080808;
|
||||
--bg-base: #0c0c0c;
|
||||
--bg-surface: #101010;
|
||||
--bg-raised: #151515;
|
||||
--bg-overlay: #1a1a1a;
|
||||
--bg-subtle: #202020;
|
||||
|
||||
--border-dim: #1c1c1c;
|
||||
--border-base: #242424;
|
||||
--border-strong: #2e2e2e;
|
||||
--border-focus: #4a5c4a;
|
||||
|
||||
--text-primary: #f0efec;
|
||||
--text-secondary: #c8c6c0;
|
||||
--text-muted: #8a8880;
|
||||
--text-faint: #4e4d4a;
|
||||
--text-disabled: #2a2a28;
|
||||
|
||||
--accent: #6b8f6b;
|
||||
--accent-dim: #2a3d2a;
|
||||
--accent-muted: #1a251a;
|
||||
--accent-fg: #a8c4a8;
|
||||
--accent-bright: #8fb88f;
|
||||
|
||||
--color-error: #c47a7a;
|
||||
--color-error-bg: #1f1212;
|
||||
--color-success: #7aab7a;
|
||||
--color-info: #7a9ec4;
|
||||
--color-info-bg: #121a1f;
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
[data-theme="warm"] {
|
||||
--bg-void: #0c0a06;
|
||||
--bg-base: #100e08;
|
||||
--bg-surface: #16130c;
|
||||
--bg-raised: #1c1810;
|
||||
--bg-overlay: #221e14;
|
||||
--bg-subtle: #28241a;
|
||||
|
||||
--border-dim: #201c10;
|
||||
--border-base: #2c2818;
|
||||
--border-strong: #3a3420;
|
||||
--border-focus: #6a5a30;
|
||||
|
||||
--text-primary: #f5f0e0;
|
||||
--text-secondary: #d8d0b0;
|
||||
--text-muted: #988c60;
|
||||
--text-faint: #584e30;
|
||||
--text-disabled: #302a18;
|
||||
|
||||
--accent: #c0902a;
|
||||
--accent-dim: #3a2c10;
|
||||
--accent-muted: #261e0c;
|
||||
--accent-fg: #e0b860;
|
||||
--accent-bright: #d0a040;
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
:root {
|
||||
--bg-void: #080808;
|
||||
--bg-base: #0c0c0c;
|
||||
--bg-surface: #101010;
|
||||
--bg-raised: #151515;
|
||||
--bg-overlay: #1a1a1a;
|
||||
--bg-subtle: #202020;
|
||||
|
||||
--border-dim: #1c1c1c;
|
||||
--border-base: #242424;
|
||||
--border-strong: #2e2e2e;
|
||||
--border-focus: #4a5c4a;
|
||||
|
||||
--text-primary: #f0efec;
|
||||
--text-secondary: #c8c6c0;
|
||||
--text-muted: #8a8880;
|
||||
--text-faint: #4e4d4a;
|
||||
--text-disabled: #2a2a28;
|
||||
|
||||
--accent: #6b8f6b;
|
||||
--accent-dim: #2a3d2a;
|
||||
--accent-muted: #1a251a;
|
||||
--accent-fg: #a8c4a8;
|
||||
--accent-bright: #8fb88f;
|
||||
|
||||
--color-error: #c47a7a;
|
||||
--color-error-bg: #1f1212;
|
||||
--color-success: #7aab7a;
|
||||
--color-info: #7a9ec4;
|
||||
--color-info-bg: #121a1f;
|
||||
--color-read: #2e2e2c;
|
||||
|
||||
--dot-active: var(--accent);
|
||||
--dot-inactive: var(--text-faint);
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
@import "./colors.css";
|
||||
@import "./typography.css";
|
||||
@import "./spacing.css";
|
||||
@import "./radius.css";
|
||||
@import "./motion.css";
|
||||
@import "./shadows.css";
|
||||
@import "./zindex.css";
|
||||
@import "../themes/index.css";
|
||||
@@ -1,5 +0,0 @@
|
||||
:root {
|
||||
--t-fast: 0.08s ease;
|
||||
--t-base: 0.14s ease;
|
||||
--t-slow: 0.22s ease;
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
:root {
|
||||
--radius-sm: 3px;
|
||||
--radius-md: 5px;
|
||||
--radius-lg: 7px;
|
||||
--radius-xl: 10px;
|
||||
--radius-2xl: 14px;
|
||||
--radius-full: 9999px;
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
:root {
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
:root {
|
||||
--sp-1: 4px;
|
||||
--sp-2: 8px;
|
||||
--sp-3: 12px;
|
||||
--sp-4: 16px;
|
||||
--sp-5: 20px;
|
||||
--sp-6: 24px;
|
||||
--sp-8: 32px;
|
||||
--sp-10: 40px;
|
||||
|
||||
--sidebar-width: 52px;
|
||||
--titlebar-height: 36px;
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
:root {
|
||||
--font-ui: "DM Mono", "Fira Mono", ui-monospace, monospace;
|
||||
--font-sans: "DM Sans", ui-sans-serif, system-ui, sans-serif;
|
||||
|
||||
--text-2xs: 10px;
|
||||
--text-xs: 11px;
|
||||
--text-sm: 12px;
|
||||
--text-base: 13px;
|
||||
--text-md: 14px;
|
||||
--text-lg: 15px;
|
||||
--text-xl: 17px;
|
||||
--text-2xl: 20px;
|
||||
--text-3xl: 24px;
|
||||
|
||||
--weight-normal: 400;
|
||||
--weight-medium: 500;
|
||||
--weight-semi: 600;
|
||||
|
||||
--leading-none: 1;
|
||||
--leading-tight: 1.3;
|
||||
--leading-snug: 1.45;
|
||||
--leading-base: 1.6;
|
||||
|
||||
--tracking-tight: -0.02em;
|
||||
--tracking-normal: 0;
|
||||
--tracking-wide: 0.06em;
|
||||
--tracking-wider: 0.1em;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
:root {
|
||||
--z-reader: 50;
|
||||
--z-modal: 100;
|
||||
--z-settings: 150;
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export { default as Search } from "./components/Search.svelte";
|
||||
export * from "./lib/searchFilter";
|
||||
@@ -1,52 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { CircleNotch } from "phosphor-svelte";
|
||||
import DownloadItem from "./DownloadItem.svelte";
|
||||
import type { DownloadQueueItem } from "@types/index";
|
||||
|
||||
interface Props {
|
||||
queue: DownloadQueueItem[];
|
||||
loading: boolean;
|
||||
isRunning: boolean;
|
||||
dequeueing: Set<number>;
|
||||
selected: Set<number>;
|
||||
onRemove: (chapterId: number) => void;
|
||||
onRetry: (chapterId: number) => void;
|
||||
onReorder: (chapterId: number, dir: "up" | "down") => void;
|
||||
onReorderEdge: (chapterId: number, edge: "top" | "bottom") => void;
|
||||
onSelect: (chapterId: number, e: MouseEvent) => void;
|
||||
}
|
||||
|
||||
const {
|
||||
queue, loading, isRunning, dequeueing, selected,
|
||||
onRemove, onRetry, onReorder, onReorderEdge, onSelect,
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
{#if loading}
|
||||
<div class="empty">
|
||||
<CircleNotch size={16} weight="light" class="anim-spin" style="color:var(--text-faint)" />
|
||||
</div>
|
||||
{:else if queue.length === 0}
|
||||
<div class="empty">Queue is empty.</div>
|
||||
{:else}
|
||||
<div class="list">
|
||||
{#each queue as item, i (item.chapter.id)}
|
||||
<DownloadItem
|
||||
{item}
|
||||
isActive={i === 0 && isRunning}
|
||||
isRemoving={dequeueing.has(item.chapter.id)}
|
||||
isSelected={selected.has(item.chapter.id)}
|
||||
{onRemove}
|
||||
{onRetry}
|
||||
{onReorder}
|
||||
{onReorderEdge}
|
||||
{onSelect}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.list { display: flex; flex-direction: column; gap: var(--sp-2); }
|
||||
.empty { display: flex; align-items: center; justify-content: center; height: 160px; color: var(--text-faint); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); }
|
||||
</style>
|
||||
@@ -1,2 +0,0 @@
|
||||
export { downloadStore } from "./store/downloadState.svelte";
|
||||
export { toActiveDownloads, optimisticRemove, isRunning, pageProgress } from "./lib/downloadQueue";
|
||||
@@ -1,72 +0,0 @@
|
||||
import type { DownloadQueueItem, ActiveDownload } from "@types/index";
|
||||
|
||||
export function toActiveDownloads(queue: DownloadQueueItem[]): ActiveDownload[] {
|
||||
return queue.map((item) => ({
|
||||
chapterId: item.chapter.id,
|
||||
mangaId: item.chapter.mangaId,
|
||||
progress: item.progress,
|
||||
}));
|
||||
}
|
||||
|
||||
export function optimisticRemove(queue: DownloadQueueItem[], chapterId: number): DownloadQueueItem[] {
|
||||
return queue.filter((i) => i.chapter.id !== chapterId);
|
||||
}
|
||||
|
||||
export function optimisticRemoveMany(queue: DownloadQueueItem[], chapterIds: Set<number>): DownloadQueueItem[] {
|
||||
return queue.filter((i) => !chapterIds.has(i.chapter.id));
|
||||
}
|
||||
|
||||
export function isRunning(state: string | undefined): boolean {
|
||||
return state === "STARTED";
|
||||
}
|
||||
|
||||
export function getErrored(queue: DownloadQueueItem[]): DownloadQueueItem[] {
|
||||
return queue.filter((i) => i.state === "ERROR");
|
||||
}
|
||||
|
||||
export function pageProgress(progress: number, pageCount: number): { done: number; total: number } {
|
||||
return { done: Math.round(progress * pageCount), total: pageCount };
|
||||
}
|
||||
|
||||
export interface SpeedSample {
|
||||
ts: number;
|
||||
progress: number;
|
||||
pages: number;
|
||||
}
|
||||
|
||||
export function calcSpeed(prev: SpeedSample | null, current: SpeedSample): number | null {
|
||||
if (!prev) return null;
|
||||
const dt = (current.ts - prev.ts) / 1000;
|
||||
if (dt <= 0) return null;
|
||||
const prevDone = Math.round(prev.progress * prev.pages);
|
||||
const curDone = Math.round(current.progress * current.pages);
|
||||
const delta = curDone - prevDone;
|
||||
if (delta <= 0) return null;
|
||||
return delta / dt;
|
||||
}
|
||||
|
||||
export function estimateEta(pagesPerSec: number, queue: DownloadQueueItem[]): number | null {
|
||||
if (pagesPerSec <= 0 || queue.length === 0) return null;
|
||||
let remaining = 0;
|
||||
for (const item of queue) {
|
||||
const pages = item.chapter.pageCount ?? 0;
|
||||
remaining += pages - Math.round(item.progress * pages);
|
||||
}
|
||||
return remaining / pagesPerSec;
|
||||
}
|
||||
|
||||
export function reorderSelectedToEdge(
|
||||
queue: DownloadQueueItem[],
|
||||
selected: Set<number>,
|
||||
edge: "top" | "bottom",
|
||||
): DownloadQueueItem[] {
|
||||
const pinned = queue.filter((i) => selected.has(i.chapter.id));
|
||||
const rest = queue.filter((i) => !selected.has(i.chapter.id));
|
||||
return edge === "top" ? [...pinned, ...rest] : [...rest, ...pinned];
|
||||
}
|
||||
|
||||
export function formatEta(seconds: number): string {
|
||||
if (seconds < 60) return `~${Math.ceil(seconds)}s`;
|
||||
if (seconds < 3600) return `~${Math.ceil(seconds / 60)}m`;
|
||||
return `~${(seconds / 3600).toFixed(1)}h`;
|
||||
}
|
||||
@@ -1,343 +0,0 @@
|
||||
import { gql } from "@api/client";
|
||||
import { GET_DOWNLOAD_STATUS } from "@api/queries";
|
||||
import {
|
||||
START_DOWNLOADER, STOP_DOWNLOADER, CLEAR_DOWNLOADER,
|
||||
DEQUEUE_DOWNLOAD, DEQUEUE_CHAPTERS_DOWNLOAD,
|
||||
ENQUEUE_DOWNLOAD, REORDER_DOWNLOAD,
|
||||
} from "@api/mutations";
|
||||
import { addToast, setActiveDownloads, store, updateSettings } from "@store/state.svelte";
|
||||
import { boot } from "@store/boot.svelte";
|
||||
import type { DownloadStatus, DownloadQueueItem } from "@types/index";
|
||||
import {
|
||||
toActiveDownloads, optimisticRemove, optimisticRemoveMany,
|
||||
isRunning, getErrored, calcSpeed, estimateEta,
|
||||
type SpeedSample,
|
||||
} from "../lib/downloadQueue";
|
||||
import { startAutoRetry, type AutoRetryHandle } from "../lib/autoRetry";
|
||||
|
||||
class DownloadStore {
|
||||
status: DownloadStatus | null = $state(null);
|
||||
loading = $state(true);
|
||||
togglingPlay = $state(false);
|
||||
clearing = $state(false);
|
||||
dequeueing = $state(new Set<number>());
|
||||
selected = $state(new Set<number>());
|
||||
batchWorking = $state(false);
|
||||
pagesPerSec: number | null = $state(null);
|
||||
eta: number | null = $state(null);
|
||||
|
||||
get toastsEnabled() { return store.settings.downloadToastsEnabled ?? true; }
|
||||
get autoRetryEnabled() { return store.settings.downloadAutoRetry ?? false; }
|
||||
|
||||
private lastSample: SpeedSample | null = null;
|
||||
private prevQueue: DownloadQueueItem[] = [];
|
||||
private autoRetryHnd: AutoRetryHandle | null = null;
|
||||
|
||||
get queue() { return this.status?.queue ?? []; }
|
||||
get isRunning() { return isRunning(this.status?.state); }
|
||||
get erroredIds() { return new Set(getErrored(this.queue).map((i) => i.chapter.id)); }
|
||||
get hasErrored() { return this.erroredIds.size > 0; }
|
||||
|
||||
toggleToasts() {
|
||||
const next = !this.toastsEnabled;
|
||||
updateSettings({ downloadToastsEnabled: next });
|
||||
addToast({ kind: "info", title: next ? "Notifications enabled" : "Notifications muted", body: next ? "You'll be notified when chapters finish downloading" : "Download notifications are silenced", duration: 2500 });
|
||||
}
|
||||
|
||||
toggleAutoRetry() {
|
||||
if (this.autoRetryEnabled) {
|
||||
this.autoRetryHnd?.stop();
|
||||
this.autoRetryHnd = null;
|
||||
updateSettings({ downloadAutoRetry: false });
|
||||
addToast({ kind: "info", title: "Auto-retry disabled", body: "Failed downloads will no longer retry automatically", duration: 2500 });
|
||||
} else {
|
||||
updateSettings({ downloadAutoRetry: true });
|
||||
this.autoRetryHnd = startAutoRetry(
|
||||
() => this.queue,
|
||||
() => this.isRunning,
|
||||
() => this.retryAllErrored(),
|
||||
);
|
||||
addToast({ kind: "info", title: "Auto-retry enabled", body: "Errored downloads will retry automatically", duration: 3000 });
|
||||
}
|
||||
}
|
||||
|
||||
detectTransitions(next: DownloadQueueItem[]) {
|
||||
if (!this.toastsEnabled) return;
|
||||
const nextMap = new Map(next.map(i => [i.chapter.id, i]));
|
||||
for (const item of this.prevQueue) {
|
||||
if (item.state !== "DOWNLOADING") continue;
|
||||
const nextItem = nextMap.get(item.chapter.id);
|
||||
const manga = item.chapter.manga;
|
||||
const label = manga ? `${manga.title} — ${item.chapter.name}` : item.chapter.name;
|
||||
if (!nextItem) {
|
||||
addToast({ kind: "download", title: "Chapter downloaded", body: label, duration: 4000 });
|
||||
} else if (nextItem.state === "ERROR") {
|
||||
addToast({ kind: "error", title: "Download failed", body: label, duration: 5000 });
|
||||
}
|
||||
}
|
||||
this.prevQueue = next.slice();
|
||||
}
|
||||
|
||||
applyStatus(ds: DownloadStatus) {
|
||||
this.status = ds;
|
||||
setActiveDownloads(toActiveDownloads(ds.queue));
|
||||
this.updateSpeed(ds);
|
||||
}
|
||||
|
||||
private updateSpeed(ds: DownloadStatus) {
|
||||
const active = ds.queue[0];
|
||||
if (!active || active.state !== "DOWNLOADING") {
|
||||
this.lastSample = null;
|
||||
this.pagesPerSec = null;
|
||||
this.eta = null;
|
||||
return;
|
||||
}
|
||||
const sample: SpeedSample = {
|
||||
ts: Date.now(),
|
||||
progress: active.progress,
|
||||
pages: active.chapter.pageCount ?? 0,
|
||||
};
|
||||
const speed = calcSpeed(this.lastSample, sample);
|
||||
this.lastSample = sample;
|
||||
if (speed !== null) {
|
||||
this.pagesPerSec = speed;
|
||||
this.eta = estimateEta(speed, ds.queue);
|
||||
}
|
||||
}
|
||||
|
||||
async poll() {
|
||||
if (boot.sessionExpired) return;
|
||||
gql<{ downloadStatus: DownloadStatus }>(GET_DOWNLOAD_STATUS)
|
||||
.then((d) => this.applyStatus(d.downloadStatus))
|
||||
.catch(console.error)
|
||||
.finally(() => { this.loading = false; });
|
||||
}
|
||||
|
||||
async togglePlay() {
|
||||
if (this.togglingPlay) return;
|
||||
this.togglingPlay = true;
|
||||
const wasRunning = this.isRunning;
|
||||
if (this.status) this.status = { ...this.status, state: wasRunning ? "STOPPED" : "STARTED" };
|
||||
try {
|
||||
if (wasRunning) {
|
||||
const d = await gql<{ stopDownloader: { downloadStatus: DownloadStatus } }>(STOP_DOWNLOADER);
|
||||
this.applyStatus(d.stopDownloader.downloadStatus);
|
||||
} else {
|
||||
const d = await gql<{ startDownloader: { downloadStatus: DownloadStatus } }>(START_DOWNLOADER);
|
||||
this.applyStatus(d.startDownloader.downloadStatus);
|
||||
}
|
||||
} catch (e) { console.error(e); this.poll(); }
|
||||
finally {
|
||||
this.togglingPlay = false;
|
||||
addToast({ kind: "info", title: wasRunning ? "Downloads paused" : "Downloads resumed", body: wasRunning ? "The download queue has been paused" : "The download queue is running", duration: 2500 });
|
||||
}
|
||||
}
|
||||
|
||||
async clear() {
|
||||
if (this.clearing) return;
|
||||
this.clearing = true;
|
||||
this.selected = new Set();
|
||||
if (this.status) this.status = { ...this.status, queue: [] };
|
||||
setActiveDownloads([]);
|
||||
try {
|
||||
const d = await gql<{ clearDownloader: { downloadStatus: DownloadStatus } }>(CLEAR_DOWNLOADER);
|
||||
this.applyStatus(d.clearDownloader.downloadStatus);
|
||||
addToast({ kind: "info", title: "Queue cleared", body: "All pending downloads have been removed", duration: 2500 });
|
||||
} catch (e) { console.error(e); this.poll(); }
|
||||
finally { this.clearing = false; }
|
||||
}
|
||||
|
||||
async dequeue(chapterId: number) {
|
||||
if (this.dequeueing.has(chapterId)) return;
|
||||
this.dequeueing = new Set(this.dequeueing).add(chapterId);
|
||||
if (this.status) this.status = { ...this.status, queue: optimisticRemove(this.status.queue, chapterId) };
|
||||
this.selected.delete(chapterId);
|
||||
this.selected = new Set(this.selected);
|
||||
try { await gql(DEQUEUE_DOWNLOAD, { chapterId }); this.poll(); }
|
||||
catch (e) { console.error(e); this.poll(); }
|
||||
finally { this.dequeueing.delete(chapterId); this.dequeueing = new Set(this.dequeueing); }
|
||||
}
|
||||
|
||||
async dequeueSelected() {
|
||||
if (this.batchWorking || this.selected.size === 0) return;
|
||||
this.batchWorking = true;
|
||||
const ids = [...this.selected];
|
||||
if (this.status) this.status = { ...this.status, queue: optimisticRemoveMany(this.status.queue, this.selected) };
|
||||
this.selected = new Set();
|
||||
try {
|
||||
await gql(DEQUEUE_CHAPTERS_DOWNLOAD, { chapterIds: ids });
|
||||
this.poll();
|
||||
addToast({ kind: "info", title: `Removed ${ids.length} download${ids.length !== 1 ? "s" : ""}`, body: "Selected items have been removed from the queue", duration: 2500 });
|
||||
} catch (e) { console.error(e); this.poll(); }
|
||||
finally { this.batchWorking = false; }
|
||||
}
|
||||
|
||||
async retryOne(chapterId: number) {
|
||||
if (this.dequeueing.has(chapterId)) return;
|
||||
this.dequeueing = new Set(this.dequeueing).add(chapterId);
|
||||
try {
|
||||
await gql(DEQUEUE_DOWNLOAD, { chapterId });
|
||||
await gql(ENQUEUE_DOWNLOAD, { chapterId });
|
||||
this.poll();
|
||||
} catch (e) { console.error(e); this.poll(); }
|
||||
finally { this.dequeueing.delete(chapterId); this.dequeueing = new Set(this.dequeueing); }
|
||||
}
|
||||
|
||||
async retryAllErrored() {
|
||||
if (this.batchWorking || !this.hasErrored) return;
|
||||
this.batchWorking = true;
|
||||
const ids = [...this.erroredIds];
|
||||
try {
|
||||
await gql(DEQUEUE_CHAPTERS_DOWNLOAD, { chapterIds: ids });
|
||||
for (const id of ids) await gql(ENQUEUE_DOWNLOAD, { chapterId: id });
|
||||
this.poll();
|
||||
addToast({ kind: "info", title: `Retrying ${ids.length} failed download${ids.length !== 1 ? "s" : ""}`, duration: 3000 });
|
||||
} catch (e) { console.error(e); this.poll(); }
|
||||
finally { this.batchWorking = false; }
|
||||
}
|
||||
|
||||
async retrySelected() {
|
||||
if (this.batchWorking || this.selected.size === 0) return;
|
||||
this.batchWorking = true;
|
||||
const ids = [...this.selected].filter((id) => this.erroredIds.has(id));
|
||||
this.selected = new Set();
|
||||
try {
|
||||
if (ids.length > 0) {
|
||||
await gql(DEQUEUE_CHAPTERS_DOWNLOAD, { chapterIds: ids });
|
||||
for (const id of ids) await gql(ENQUEUE_DOWNLOAD, { chapterId: id });
|
||||
addToast({ kind: "info", title: `Retrying ${ids.length} failed download${ids.length !== 1 ? "s" : ""}`, duration: 3000 });
|
||||
}
|
||||
this.poll();
|
||||
} catch (e) { console.error(e); this.poll(); }
|
||||
finally { this.batchWorking = false; }
|
||||
}
|
||||
|
||||
async reorder(chapterId: number, direction: "up" | "down") {
|
||||
const idx = this.queue.findIndex((i) => i.chapter.id === chapterId);
|
||||
if (idx === -1) return;
|
||||
const to = direction === "up" ? idx - 1 : idx + 1;
|
||||
if (to < 0 || to >= this.queue.length) return;
|
||||
const newQueue = [...this.queue];
|
||||
[newQueue[idx], newQueue[to]] = [newQueue[to], newQueue[idx]];
|
||||
if (this.status) this.status = { ...this.status, queue: newQueue };
|
||||
try {
|
||||
const d = await gql<{ reorderChapterDownload: { downloadStatus: DownloadStatus } }>(
|
||||
REORDER_DOWNLOAD, { chapterId, to },
|
||||
);
|
||||
this.applyStatus(d.reorderChapterDownload.downloadStatus);
|
||||
} catch (e) { console.error(e); this.poll(); }
|
||||
}
|
||||
|
||||
async reorderSelected(direction: "up" | "down") {
|
||||
if (this.batchWorking || this.selected.size === 0) return;
|
||||
this.batchWorking = true;
|
||||
|
||||
const queue = [...this.queue];
|
||||
const selectedIndices = queue
|
||||
.map((item, i) => ({ id: item.chapter.id, i }))
|
||||
.filter(({ id }) => this.selected.has(id))
|
||||
.map(({ i }) => i)
|
||||
.sort((a, b) => direction === "up" ? a - b : b - a);
|
||||
|
||||
if (direction === "up" && selectedIndices[0] === 0) { this.batchWorking = false; return; }
|
||||
if (direction === "down" && selectedIndices[0] === queue.length - 1) { this.batchWorking = false; return; }
|
||||
|
||||
const newQueue = [...queue];
|
||||
for (const idx of selectedIndices) {
|
||||
const to = direction === "up" ? idx - 1 : idx + 1;
|
||||
if (to < 0 || to >= newQueue.length) break;
|
||||
[newQueue[idx], newQueue[to]] = [newQueue[to], newQueue[idx]];
|
||||
}
|
||||
if (this.status) this.status = { ...this.status, queue: newQueue };
|
||||
|
||||
try {
|
||||
for (const idx of selectedIndices) {
|
||||
const to = direction === "up" ? idx - 1 : idx + 1;
|
||||
if (to < 0 || to >= queue.length) break;
|
||||
const chapterId = queue[idx].chapter.id;
|
||||
await gql<{ reorderChapterDownload: { downloadStatus: DownloadStatus } }>(
|
||||
REORDER_DOWNLOAD, { chapterId, to },
|
||||
);
|
||||
}
|
||||
this.poll();
|
||||
} catch (e) { console.error(e); this.poll(); }
|
||||
finally { this.batchWorking = false; }
|
||||
}
|
||||
|
||||
async reorderToEdge(chapterId: number, edge: "top" | "bottom") {
|
||||
const idx = this.queue.findIndex((i) => i.chapter.id === chapterId);
|
||||
if (idx === -1) return;
|
||||
const first = this.isRunning ? 1 : 0;
|
||||
const last = this.queue.length - 1;
|
||||
const to = edge === "top" ? first : last;
|
||||
if (idx === to) return;
|
||||
const newQueue = [...this.queue];
|
||||
newQueue.splice(idx, 1);
|
||||
newQueue.splice(to, 0, this.queue[idx]);
|
||||
if (this.status) this.status = { ...this.status, queue: newQueue };
|
||||
try {
|
||||
const d = await gql<{ reorderChapterDownload: { downloadStatus: DownloadStatus } }>(
|
||||
REORDER_DOWNLOAD, { chapterId, to },
|
||||
);
|
||||
this.applyStatus(d.reorderChapterDownload.downloadStatus);
|
||||
} catch (e) { console.error(e); this.poll(); }
|
||||
}
|
||||
|
||||
async reorderSelectedToEdge(edge: "top" | "bottom") {
|
||||
if (this.batchWorking || this.selected.size === 0) return;
|
||||
this.batchWorking = true;
|
||||
|
||||
const first = this.isRunning ? 1 : 0;
|
||||
const active = this.queue.slice(0, first);
|
||||
const moveable = this.queue.slice(first);
|
||||
const pinned = moveable.filter((i) => this.selected.has(i.chapter.id));
|
||||
const rest = moveable.filter((i) => !this.selected.has(i.chapter.id));
|
||||
const newQueue = edge === "top"
|
||||
? [...active, ...pinned, ...rest]
|
||||
: [...active, ...rest, ...pinned];
|
||||
if (this.status) this.status = { ...this.status, queue: newQueue };
|
||||
|
||||
const last = this.queue.length - 1;
|
||||
|
||||
try {
|
||||
if (edge === "top") {
|
||||
for (let i = 0; i < pinned.length; i++) {
|
||||
await gql<{ reorderChapterDownload: { downloadStatus: DownloadStatus } }>(
|
||||
REORDER_DOWNLOAD, { chapterId: pinned[i].chapter.id, to: first + i },
|
||||
);
|
||||
}
|
||||
} else {
|
||||
for (let i = 0; i < pinned.length; i++) {
|
||||
await gql<{ reorderChapterDownload: { downloadStatus: DownloadStatus } }>(
|
||||
REORDER_DOWNLOAD, { chapterId: pinned[i].chapter.id, to: last - (pinned.length - 1 - i) },
|
||||
);
|
||||
}
|
||||
}
|
||||
this.poll();
|
||||
} catch (e) { console.error(e); this.poll(); }
|
||||
finally { this.batchWorking = false; }
|
||||
}
|
||||
|
||||
selectOnly(chapterId: number) { this.selected = new Set([chapterId]); }
|
||||
toggleSelect(chapterId: number) {
|
||||
const next = new Set(this.selected);
|
||||
if (next.has(chapterId)) next.delete(chapterId);
|
||||
else next.add(chapterId);
|
||||
this.selected = next;
|
||||
}
|
||||
|
||||
selectRange(fromId: number, toId: number) {
|
||||
const ids = this.queue.map((i) => i.chapter.id);
|
||||
const a = ids.indexOf(fromId), b = ids.indexOf(toId);
|
||||
if (a === -1 || b === -1) return;
|
||||
const [lo, hi] = a < b ? [a, b] : [b, a];
|
||||
const next = new Set(this.selected);
|
||||
for (let i = lo; i <= hi; i++) next.add(ids[i]);
|
||||
this.selected = next;
|
||||
}
|
||||
|
||||
selectAll() { this.selected = new Set(this.queue.map((i) => i.chapter.id)); }
|
||||
clearSelection() { this.selected = new Set(); }
|
||||
}
|
||||
|
||||
export const downloadStore = new DownloadStore();
|
||||
@@ -1,2 +0,0 @@
|
||||
export { default as Extensions } from "./components/Extensions.svelte";
|
||||
export * from "./lib/extensionHelpers";
|
||||
@@ -1,526 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { X, CircleNotch, CaretUpDown, Check, CaretLeft } from "phosphor-svelte";
|
||||
import Thumbnail from "@shared/manga/Thumbnail.svelte";
|
||||
import { gql } from "@api/client";
|
||||
import { addToast } from "@store/state.svelte";
|
||||
import { GET_SOURCE_SETTINGS } from "@api/queries";
|
||||
import { UPDATE_SOURCE_PREFERENCE } from "@api/mutations";
|
||||
|
||||
interface Preference {
|
||||
type: string;
|
||||
key: string;
|
||||
CheckBoxTitle?: string;
|
||||
CheckBoxSummary?: string;
|
||||
CheckBoxDefault?: boolean;
|
||||
CheckBoxCurrentValue?: boolean;
|
||||
SwitchPreferenceTitle?: string;
|
||||
SwitchPreferenceSummary?: string;
|
||||
SwitchPreferenceDefault?: boolean;
|
||||
SwitchPreferenceCurrentValue?: boolean;
|
||||
ListPreferenceTitle?: string;
|
||||
ListPreferenceSummary?: string;
|
||||
ListPreferenceDefault?: string;
|
||||
ListPreferenceCurrentValue?: string;
|
||||
entries?: string[];
|
||||
entryValues?: string[];
|
||||
EditTextPreferenceTitle?: string;
|
||||
EditTextPreferenceSummary?: string;
|
||||
EditTextPreferenceDefault?: string;
|
||||
EditTextPreferenceCurrentValue?: string;
|
||||
dialogTitle?: string;
|
||||
dialogMessage?: string;
|
||||
MultiSelectListPreferenceTitle?: string;
|
||||
MultiSelectListPreferenceSummary?: string;
|
||||
MultiSelectListPreferenceDefault?: string[];
|
||||
MultiSelectListPreferenceCurrentValue?: string[];
|
||||
}
|
||||
|
||||
export type SourceEntry = { id: string; displayName: string };
|
||||
|
||||
interface Props {
|
||||
extensionName: string;
|
||||
iconUrl: string;
|
||||
sources: SourceEntry[];
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let { extensionName, iconUrl, sources, onClose }: Props = $props();
|
||||
|
||||
let phase = $state<"pick" | "settings">("pick");
|
||||
let activeSource = $state<SourceEntry | null>(null);
|
||||
let prefs = $state<Preference[]>([]);
|
||||
let loading = $state(false);
|
||||
let saving = $state<string | null>(null);
|
||||
let editKey = $state<string | null>(null);
|
||||
let editValue = $state("");
|
||||
let listOpen = $state<string | null>(null);
|
||||
|
||||
$effect(() => {
|
||||
if (sources.length === 1) openSource(sources[0]);
|
||||
});
|
||||
|
||||
async function openSource(src: SourceEntry) {
|
||||
activeSource = src;
|
||||
phase = "settings";
|
||||
loading = true;
|
||||
prefs = [];
|
||||
editKey = null;
|
||||
listOpen = null;
|
||||
try {
|
||||
const d = await gql<{ source: { preferences: Preference[] } }>(
|
||||
GET_SOURCE_SETTINGS,
|
||||
{ id: String(src.id) },
|
||||
);
|
||||
prefs = d.source.preferences ?? [];
|
||||
} catch (e: any) {
|
||||
addToast({ kind: "error", title: "Failed to load settings", body: e?.message ?? "" });
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function backToPicker() {
|
||||
phase = "pick";
|
||||
activeSource = null;
|
||||
prefs = [];
|
||||
editKey = null;
|
||||
listOpen = null;
|
||||
}
|
||||
|
||||
async function save(position: number, changeType: string, value: unknown) {
|
||||
if (!activeSource) return;
|
||||
const pref = prefs[position];
|
||||
saving = pref.key;
|
||||
try {
|
||||
await gql(UPDATE_SOURCE_PREFERENCE, {
|
||||
source: String(activeSource.id),
|
||||
change: { position, [changeType]: value },
|
||||
});
|
||||
const d = await gql<{ source: { preferences: Preference[] } }>(
|
||||
GET_SOURCE_SETTINGS,
|
||||
{ id: String(activeSource.id) },
|
||||
);
|
||||
prefs = d.source.preferences ?? [];
|
||||
} catch (e: any) {
|
||||
addToast({ kind: "error", title: "Failed to save", body: e?.message ?? "" });
|
||||
} finally {
|
||||
saving = null;
|
||||
}
|
||||
}
|
||||
|
||||
function getTitle(p: Preference) {
|
||||
return p.CheckBoxTitle ?? p.SwitchPreferenceTitle ?? p.ListPreferenceTitle
|
||||
?? p.EditTextPreferenceTitle ?? p.MultiSelectListPreferenceTitle ?? p.key;
|
||||
}
|
||||
function getSummary(p: Preference) {
|
||||
return p.CheckBoxSummary ?? p.SwitchPreferenceSummary ?? p.ListPreferenceSummary
|
||||
?? p.EditTextPreferenceSummary ?? p.MultiSelectListPreferenceSummary ?? null;
|
||||
}
|
||||
function getBoolValue(p: Preference) {
|
||||
if (p.type === "CheckBoxPreference")
|
||||
return p.CheckBoxCurrentValue ?? p.CheckBoxDefault ?? false;
|
||||
return p.SwitchPreferenceCurrentValue ?? p.SwitchPreferenceDefault ?? false;
|
||||
}
|
||||
function getListValue(p: Preference) {
|
||||
return p.ListPreferenceCurrentValue ?? p.ListPreferenceDefault ?? "";
|
||||
}
|
||||
function getListLabel(p: Preference, val: string) {
|
||||
const idx = p.entryValues?.indexOf(val) ?? -1;
|
||||
return idx >= 0 ? (p.entries?.[idx] ?? val) : val;
|
||||
}
|
||||
function getMultiValue(p: Preference): string[] {
|
||||
return p.MultiSelectListPreferenceCurrentValue ?? p.MultiSelectListPreferenceDefault ?? [];
|
||||
}
|
||||
function toggleMulti(position: number, p: Preference, val: string) {
|
||||
const current = getMultiValue(p);
|
||||
const next = current.includes(val) ? current.filter(v => v !== val) : [...current, val];
|
||||
save(position, "multiSelectState", next);
|
||||
}
|
||||
function submitEdit(position: number) {
|
||||
save(position, "editTextState", editValue);
|
||||
editKey = null;
|
||||
}
|
||||
function openEdit(p: Preference) {
|
||||
editKey = p.key;
|
||||
editValue = p.EditTextPreferenceCurrentValue ?? p.EditTextPreferenceDefault ?? "";
|
||||
}
|
||||
|
||||
function langTag(displayName: string) {
|
||||
const m = displayName.match(/\(([^)]+)\)$/);
|
||||
return m ? m[1].toUpperCase() : null;
|
||||
}
|
||||
|
||||
function onBackdrop(e: MouseEvent) {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
}
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") {
|
||||
if (editKey) { editKey = null; return; }
|
||||
if (listOpen) { listOpen = null; return; }
|
||||
if (phase === "settings" && sources.length > 1) { backToPicker(); return; }
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={onKeydown} />
|
||||
|
||||
<div class="backdrop" role="dialog" aria-modal="true" onmousedown={onBackdrop}>
|
||||
<div class="modal">
|
||||
|
||||
<div class="modal-header">
|
||||
<div class="modal-title-wrap">
|
||||
{#if phase === "settings" && sources.length > 1}
|
||||
<button class="icon-btn" onclick={backToPicker} title="Back">
|
||||
<CaretLeft size={13} weight="bold" />
|
||||
</button>
|
||||
{/if}
|
||||
{#if iconUrl}
|
||||
<Thumbnail src={iconUrl} alt={extensionName} class="modal-ext-icon" onerror={(e) => ((e.target as HTMLImageElement).style.display = "none")} />
|
||||
{/if}
|
||||
<div class="modal-titles">
|
||||
<span class="modal-eyebrow">Extension Settings</span>
|
||||
<span class="modal-title">
|
||||
{phase === "pick" ? extensionName : (activeSource?.displayName ?? extensionName)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="icon-btn" onclick={onClose}>
|
||||
<X size={14} weight="bold" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
|
||||
{#if phase === "pick"}
|
||||
<div class="source-list">
|
||||
{#each sources as src}
|
||||
{@const tag = langTag(src.displayName)}
|
||||
{@const baseName = src.displayName.replace(/\s*\([^)]+\)$/, "")}
|
||||
<button class="source-row" onclick={() => openSource(src)}>
|
||||
<span class="source-name">{baseName}</span>
|
||||
{#if tag}<span class="lang-badge">{tag}</span>{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{:else}
|
||||
{#if loading}
|
||||
<div class="center-state">
|
||||
<CircleNotch size={16} weight="light" class="anim-spin" style="color:var(--text-faint)" />
|
||||
</div>
|
||||
{:else if prefs.length === 0}
|
||||
<div class="center-state empty-state">No configurable settings.</div>
|
||||
{:else}
|
||||
<div class="pref-list">
|
||||
{#each prefs as pref, i}
|
||||
{@const title = getTitle(pref)}
|
||||
{@const summary = getSummary(pref)}
|
||||
{@const isSaving = saving === pref.key}
|
||||
|
||||
{#if pref.type === "CheckBoxPreference" || pref.type === "SwitchPreference"}
|
||||
{@const checked = getBoolValue(pref)}
|
||||
<div class="pref-row">
|
||||
<div class="pref-text">
|
||||
<span class="pref-title">{title}</span>
|
||||
{#if summary}<span class="pref-summary">{summary}</span>{/if}
|
||||
</div>
|
||||
<button
|
||||
class="toggle" class:toggle-on={checked}
|
||||
disabled={isSaving}
|
||||
onclick={() => save(i, pref.type === "CheckBoxPreference" ? "checkBoxState" : "switchState", !checked)}
|
||||
>
|
||||
{#if isSaving}
|
||||
<CircleNotch size={10} weight="light" class="anim-spin" />
|
||||
{:else}
|
||||
<span class="toggle-thumb"></span>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{:else if pref.type === "ListPreference"}
|
||||
{@const current = getListValue(pref)}
|
||||
<div class="pref-row pref-row-col">
|
||||
<div class="pref-text">
|
||||
<span class="pref-title">{title}</span>
|
||||
{#if summary}<span class="pref-summary">{summary}</span>{/if}
|
||||
</div>
|
||||
<div class="select-wrap">
|
||||
<button
|
||||
class="select-btn" class:select-open={listOpen === pref.key}
|
||||
disabled={isSaving}
|
||||
onclick={() => listOpen = listOpen === pref.key ? null : pref.key}
|
||||
>
|
||||
<span class="select-val">{getListLabel(pref, current)}</span>
|
||||
{#if isSaving}
|
||||
<CircleNotch size={11} weight="light" class="anim-spin" />
|
||||
{:else}
|
||||
<CaretUpDown size={11} weight="bold" />
|
||||
{/if}
|
||||
</button>
|
||||
{#if listOpen === pref.key}
|
||||
<div class="dropdown">
|
||||
{#each (pref.entries ?? []) as entry, j}
|
||||
{@const val = pref.entryValues?.[j] ?? entry}
|
||||
<button
|
||||
class="dropdown-item" class:dropdown-item-active={val === current}
|
||||
onclick={() => { save(i, "listState", val); listOpen = null; }}
|
||||
>
|
||||
{entry}
|
||||
{#if val === current}<Check size={11} weight="bold" />{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{:else if pref.type === "EditTextPreference"}
|
||||
{#if editKey === pref.key}
|
||||
<div class="pref-row pref-row-col edit-active">
|
||||
<div class="pref-text">
|
||||
{#if pref.dialogTitle}<span class="pref-title">{pref.dialogTitle}</span>{/if}
|
||||
{#if pref.dialogMessage}<span class="pref-summary">{pref.dialogMessage}</span>{/if}
|
||||
</div>
|
||||
<div class="edit-row">
|
||||
<input
|
||||
class="edit-input"
|
||||
bind:value={editValue}
|
||||
disabled={isSaving}
|
||||
onkeydown={(e) => { if (e.key === "Enter") submitEdit(i); if (e.key === "Escape") editKey = null; }}
|
||||
autofocus
|
||||
/>
|
||||
<button class="action-btn-dim" onclick={() => editKey = null}>Cancel</button>
|
||||
<button class="action-btn" onclick={() => submitEdit(i)} disabled={isSaving}>
|
||||
{#if isSaving}<CircleNotch size={11} weight="light" class="anim-spin" />{:else}Save{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<button class="pref-row pref-row-btn" onclick={() => openEdit(pref)}>
|
||||
<div class="pref-text">
|
||||
<span class="pref-title">{title}</span>
|
||||
{#if summary}<span class="pref-summary">{summary}</span>{/if}
|
||||
</div>
|
||||
<span class="pref-value-hint">
|
||||
{pref.EditTextPreferenceCurrentValue ?? pref.EditTextPreferenceDefault ?? "—"}
|
||||
</span>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{:else if pref.type === "MultiSelectListPreference"}
|
||||
{@const selected = getMultiValue(pref)}
|
||||
<div class="pref-row pref-row-col">
|
||||
<div class="pref-text">
|
||||
<span class="pref-title">{title}</span>
|
||||
{#if summary}<span class="pref-summary">{summary}</span>{/if}
|
||||
</div>
|
||||
<div class="multi-list">
|
||||
{#each (pref.entries ?? []) as entry, j}
|
||||
{@const val = pref.entryValues?.[j] ?? entry}
|
||||
{@const on = selected.includes(val)}
|
||||
<button
|
||||
class="multi-item" class:multi-item-on={on}
|
||||
disabled={isSaving}
|
||||
onclick={() => toggleMulti(i, pref, val)}
|
||||
>
|
||||
<span class="multi-check">{#if on}<Check size={10} weight="bold" />{/if}</span>
|
||||
{entry}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.backdrop {
|
||||
position: fixed; inset: 0;
|
||||
background: rgba(0,0,0,0.45);
|
||||
backdrop-filter: blur(4px);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
z-index: var(--z-modal);
|
||||
animation: fadeIn 0.15s ease both;
|
||||
}
|
||||
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
|
||||
|
||||
.modal {
|
||||
display: flex; flex-direction: column;
|
||||
width: 400px; max-width: calc(100vw - 32px); max-height: 78vh;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-dim);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
animation: slideUp 0.18s cubic-bezier(0.16,1,0.3,1) both;
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
@keyframes slideUp { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: translateY(0); } }
|
||||
|
||||
.modal-header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: var(--sp-3) var(--sp-3) var(--sp-3) var(--sp-4);
|
||||
border-bottom: 1px solid var(--border-dim);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.modal-title-wrap { display: flex; align-items: center; gap: var(--sp-2); }
|
||||
:global(.modal-ext-icon) { width: 22px; height: 22px; border-radius: var(--radius-sm); object-fit: cover; flex-shrink: 0; background: var(--bg-raised); }
|
||||
.modal-titles { display: flex; flex-direction: column; gap: 1px; }
|
||||
.modal-eyebrow {
|
||||
font-family: var(--font-ui); font-size: var(--text-2xs);
|
||||
color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase;
|
||||
}
|
||||
.modal-title { font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-primary); }
|
||||
|
||||
.icon-btn {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
width: 26px; height: 26px;
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-faint); flex-shrink: 0;
|
||||
transition: color var(--t-base), background var(--t-base);
|
||||
}
|
||||
.icon-btn:hover { color: var(--text-primary); background: var(--bg-overlay); }
|
||||
|
||||
.modal-body { overflow-y: auto; flex: 1; }
|
||||
|
||||
.source-list { display: flex; flex-direction: column; padding: var(--sp-2) 0; }
|
||||
.source-row {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 10px var(--sp-4);
|
||||
text-align: left;
|
||||
transition: background var(--t-fast);
|
||||
gap: var(--sp-3);
|
||||
}
|
||||
.source-row:hover { background: var(--bg-raised); }
|
||||
.source-name { flex: 1; font-size: var(--text-sm); color: var(--text-secondary); font-weight: var(--weight-medium); }
|
||||
.lang-badge {
|
||||
font-family: var(--font-ui); font-size: 10px; letter-spacing: var(--tracking-wide);
|
||||
color: var(--text-faint); background: var(--bg-overlay);
|
||||
border: 1px solid var(--border-dim);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 1px 6px; flex-shrink: 0;
|
||||
}
|
||||
|
||||
.center-state { display: flex; align-items: center; justify-content: center; padding: var(--sp-8); }
|
||||
.empty-state { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||
|
||||
.pref-list { display: flex; flex-direction: column; padding: var(--sp-2) 0; }
|
||||
.pref-row {
|
||||
display: flex; align-items: center; gap: var(--sp-3);
|
||||
padding: 10px var(--sp-4);
|
||||
border-bottom: 1px solid var(--border-dim);
|
||||
}
|
||||
.pref-row:last-child { border-bottom: none; }
|
||||
.pref-row-col { flex-direction: column; align-items: stretch; gap: var(--sp-2); }
|
||||
.pref-row-btn { width: 100%; text-align: left; transition: background var(--t-fast); }
|
||||
.pref-row-btn:hover { background: var(--bg-raised); }
|
||||
.edit-active { background: var(--bg-raised); }
|
||||
|
||||
.pref-text { display: flex; flex-direction: column; gap: 2px; flex: 1; min-width: 0; }
|
||||
.pref-title { font-size: var(--text-sm); color: var(--text-secondary); font-weight: var(--weight-medium); }
|
||||
.pref-summary {
|
||||
font-family: var(--font-ui); font-size: var(--text-2xs);
|
||||
color: var(--text-faint); letter-spacing: var(--tracking-wide); line-height: 1.5;
|
||||
}
|
||||
.pref-value-hint {
|
||||
font-family: var(--font-ui); font-size: var(--text-2xs);
|
||||
color: var(--text-muted); letter-spacing: var(--tracking-wide);
|
||||
flex-shrink: 0; max-width: 120px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
|
||||
.toggle {
|
||||
position: relative; width: 32px; height: 18px; border-radius: 9px;
|
||||
background: var(--bg-overlay); border: 1px solid var(--border-strong);
|
||||
flex-shrink: 0; transition: background var(--t-base), border-color var(--t-base);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.toggle-on { background: var(--accent-muted); border-color: var(--accent-dim); }
|
||||
.toggle-thumb {
|
||||
position: absolute; left: 2px; width: 12px; height: 12px;
|
||||
border-radius: 50%; background: var(--text-faint);
|
||||
transition: left var(--t-base), background var(--t-base); pointer-events: none;
|
||||
}
|
||||
.toggle-on .toggle-thumb { left: 16px; background: var(--accent-fg); }
|
||||
.toggle:disabled { opacity: 0.4; cursor: default; }
|
||||
|
||||
.select-wrap { position: relative; }
|
||||
.select-btn {
|
||||
display: flex; align-items: center; justify-content: space-between; gap: var(--sp-2);
|
||||
width: 100%; padding: 6px var(--sp-3);
|
||||
background: var(--bg-base); border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-md); color: var(--text-secondary); font-size: var(--text-sm);
|
||||
transition: border-color var(--t-base);
|
||||
}
|
||||
.select-btn:hover:not(:disabled) { border-color: var(--border-focus); }
|
||||
.select-btn:disabled { opacity: 0.4; cursor: default; }
|
||||
.select-open { border-color: var(--border-focus); }
|
||||
.select-val { flex: 1; text-align: left; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
.dropdown {
|
||||
position: absolute; top: calc(100% + 4px); left: 0; right: 0;
|
||||
background: var(--bg-surface); border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-md); overflow: hidden;
|
||||
box-shadow: var(--shadow-lg); z-index: 10;
|
||||
animation: dropIn 0.12s cubic-bezier(0.16,1,0.3,1) both;
|
||||
}
|
||||
@keyframes dropIn { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: translateY(0); } }
|
||||
.dropdown-item {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
width: 100%; padding: 7px var(--sp-3);
|
||||
font-size: var(--text-sm); color: var(--text-secondary);
|
||||
transition: background var(--t-fast);
|
||||
}
|
||||
.dropdown-item:hover { background: var(--bg-raised); }
|
||||
.dropdown-item-active { color: var(--accent-fg); }
|
||||
|
||||
.edit-row { display: flex; gap: var(--sp-2); }
|
||||
.edit-input {
|
||||
flex: 1; background: var(--bg-base); border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-md); padding: 6px var(--sp-3);
|
||||
color: var(--text-primary); font-size: var(--text-sm);
|
||||
outline: none; transition: border-color var(--t-base);
|
||||
}
|
||||
.edit-input:focus { border-color: var(--border-focus); }
|
||||
|
||||
.action-btn {
|
||||
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
|
||||
padding: 5px 12px; border-radius: var(--radius-md);
|
||||
background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim);
|
||||
flex-shrink: 0; display: flex; align-items: center; gap: var(--sp-1);
|
||||
transition: filter var(--t-base);
|
||||
}
|
||||
.action-btn:hover:not(:disabled) { filter: brightness(1.1); }
|
||||
.action-btn:disabled { opacity: 0.4; cursor: default; }
|
||||
|
||||
.action-btn-dim {
|
||||
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
|
||||
padding: 5px 12px; border-radius: var(--radius-md);
|
||||
background: none; color: var(--text-faint); border: 1px solid var(--border-dim);
|
||||
flex-shrink: 0; transition: color var(--t-base), border-color var(--t-base);
|
||||
}
|
||||
.action-btn-dim:hover { color: var(--text-secondary); border-color: var(--border-strong); }
|
||||
|
||||
.multi-list { display: flex; flex-direction: column; gap: 1px; }
|
||||
.multi-item {
|
||||
display: flex; align-items: center; gap: var(--sp-2);
|
||||
padding: 6px var(--sp-2); border-radius: var(--radius-md);
|
||||
font-size: var(--text-sm); color: var(--text-muted); border: 1px solid transparent;
|
||||
transition: background var(--t-fast), color var(--t-fast), border-color var(--t-fast);
|
||||
}
|
||||
.multi-item:hover:not(:disabled) { background: var(--bg-raised); color: var(--text-secondary); }
|
||||
.multi-item-on { color: var(--accent-fg); background: var(--accent-muted); border-color: var(--accent-dim); }
|
||||
.multi-check {
|
||||
width: 14px; height: 14px; border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border-strong); background: var(--bg-base);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
flex-shrink: 0; color: var(--accent-fg);
|
||||
transition: background var(--t-fast), border-color var(--t-fast);
|
||||
}
|
||||
.multi-item-on .multi-check { background: var(--accent-muted); border-color: var(--accent-dim); }
|
||||
</style>
|
||||
@@ -1,285 +0,0 @@
|
||||
<script lang="ts">
|
||||
let {
|
||||
dailyReadCounts,
|
||||
}: {
|
||||
dailyReadCounts: Record<string, number>;
|
||||
} = $props();
|
||||
|
||||
function intensity(count: number): 0 | 1 | 2 | 3 | 4 {
|
||||
if (count === 0) return 0;
|
||||
if (count === 1) return 1;
|
||||
if (count <= 3) return 2;
|
||||
if (count <= 6) return 3;
|
||||
return 4;
|
||||
}
|
||||
|
||||
let tip: { text: string; x: number; y: number } | null = $state(null);
|
||||
|
||||
function showTip(e: MouseEvent, cell: { dateStr: string; count: number }) {
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
const label = cell.count === 0
|
||||
? `No chapters — ${fmtDate(cell.dateStr)}`
|
||||
: `${cell.count} chapter${cell.count !== 1 ? "s" : ""} — ${fmtDate(cell.dateStr)}`;
|
||||
tip = { text: label, x: rect.left + rect.width / 2, y: rect.top - 6 };
|
||||
}
|
||||
|
||||
function hideTip() { tip = null; }
|
||||
|
||||
function fmtDate(d: string): string {
|
||||
return new Date(d + "T00:00:00").toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
||||
}
|
||||
|
||||
function localDateStr(d: Date): string {
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
let wrapEl: HTMLElement;
|
||||
let cellSize = $state(12);
|
||||
let numWeeks = $state(26);
|
||||
|
||||
const GAP = 3;
|
||||
const DAY_GUTTER = 28;
|
||||
const LEGEND_H = 20;
|
||||
const MONTH_H = 14;
|
||||
const ROWS = 7;
|
||||
|
||||
$effect(() => {
|
||||
if (!wrapEl) return;
|
||||
const obs = new ResizeObserver(() => {
|
||||
const h = wrapEl.clientHeight;
|
||||
const w = wrapEl.clientWidth;
|
||||
const cs = Math.max(8, Math.floor((h - LEGEND_H - MONTH_H - 2 * GAP - (ROWS - 1) * GAP) / ROWS));
|
||||
cellSize = cs;
|
||||
numWeeks = Math.max(4, Math.floor((w - DAY_GUTTER - GAP * 3) / (cs + GAP)));
|
||||
});
|
||||
obs.observe(wrapEl);
|
||||
return () => obs.disconnect();
|
||||
});
|
||||
|
||||
const visibleWeeks = $derived((() => {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const todayStr = localDateStr(today);
|
||||
const endDow = today.getDay(); // 0=Sun ... 6=Sat
|
||||
const weekEnd = new Date(today);
|
||||
weekEnd.setDate(weekEnd.getDate() + (6 - endDow)); // advance to Saturday
|
||||
|
||||
const weeks: { dateStr: string; count: number; isToday: boolean; isFuture: boolean }[][] = [];
|
||||
for (let wi = numWeeks - 1; wi >= 0; wi--) {
|
||||
const week: typeof weeks[0] = [];
|
||||
for (let di = 0; di < 7; di++) {
|
||||
const d = new Date(weekEnd);
|
||||
d.setDate(d.getDate() - wi * 7 - (6 - di));
|
||||
const dateStr = localDateStr(d);
|
||||
week.push({ dateStr, count: dailyReadCounts[dateStr] ?? 0, isToday: dateStr === todayStr, isFuture: d > today });
|
||||
}
|
||||
weeks.push(week);
|
||||
}
|
||||
return weeks;
|
||||
})());
|
||||
|
||||
const monthLabels = $derived((() => {
|
||||
const labels: { label: string; colIndex: number }[] = [];
|
||||
let lastMonth = -1;
|
||||
visibleWeeks.forEach((week, ci) => {
|
||||
const first = week[0];
|
||||
if (!first) return;
|
||||
const m = new Date(first.dateStr + "T00:00:00").getMonth();
|
||||
if (m !== lastMonth) {
|
||||
labels.push({ label: new Date(first.dateStr + "T00:00:00").toLocaleDateString("en-US", { month: "short" }), colIndex: ci });
|
||||
lastMonth = m;
|
||||
}
|
||||
});
|
||||
return labels;
|
||||
})());
|
||||
|
||||
const DAY_LABELS = ["Sun", "", "Tue", "", "Thu", "", "Sat"];
|
||||
</script>
|
||||
|
||||
<div class="heatmap-wrap" bind:this={wrapEl} style="--cell:{cellSize}px; --cols:{numWeeks};">
|
||||
|
||||
<div class="month-row">
|
||||
<div class="day-gutter"></div>
|
||||
<div class="month-cells">
|
||||
{#each visibleWeeks as _week, ci}
|
||||
{@const lbl = monthLabels.find(l => l.colIndex === ci)}
|
||||
<div class="month-label">{lbl?.label ?? ""}</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid-row">
|
||||
<div class="day-labels">
|
||||
{#each DAY_LABELS as d}
|
||||
<span class="day-label">{d}</span>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="cell-grid">
|
||||
{#each visibleWeeks as week}
|
||||
<div class="week-col">
|
||||
{#each week as cell}
|
||||
<!-- svelte-ignore a11y_mouse_events_have_key_events -->
|
||||
<button
|
||||
class="cell intensity-{intensity(cell.count)}"
|
||||
class:cell-today={cell.isToday}
|
||||
class:cell-future={cell.isFuture}
|
||||
onmouseover={(e) => showTip(e, cell)}
|
||||
onmouseleave={hideTip}
|
||||
aria-label="{cell.count} chapters on {cell.dateStr}"
|
||||
></button>
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="legend">
|
||||
<span class="legend-label">Less</span>
|
||||
{#each [0, 1, 2, 3, 4] as lvl}
|
||||
<div class="legend-cell intensity-{lvl}"></div>
|
||||
{/each}
|
||||
<span class="legend-label">More</span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{#if tip}
|
||||
<div class="heatmap-tip" style="left:{tip.x}px; top:{tip.y}px;">{tip.text}</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.heatmap-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.month-row {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.day-gutter { width: 28px; flex-shrink: 0; }
|
||||
.month-cells {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(var(--cols), var(--cell));
|
||||
gap: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.month-label {
|
||||
font-family: var(--font-ui);
|
||||
font-size: 9px;
|
||||
color: var(--text-faint);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
padding-left: 1px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.grid-row {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: flex-start;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.day-labels {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
flex-shrink: 0;
|
||||
width: 28px;
|
||||
}
|
||||
.day-label {
|
||||
font-family: var(--font-ui);
|
||||
font-size: 8px;
|
||||
color: var(--text-faint);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
height: var(--cell);
|
||||
line-height: var(--cell);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.cell-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(var(--cols), var(--cell));
|
||||
gap: 3px;
|
||||
overflow: visible;
|
||||
padding: 4px;
|
||||
margin: -4px;
|
||||
}
|
||||
.week-col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.cell {
|
||||
width: var(--cell);
|
||||
height: var(--cell);
|
||||
border-radius: 3px;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
transition: filter var(--t-fast), transform var(--t-fast);
|
||||
}
|
||||
.cell:hover:not(.cell-future) {
|
||||
filter: brightness(1.5);
|
||||
transform: scale(1.2);
|
||||
z-index: 1;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.intensity-0 { background: var(--bg-subtle); border: 1px solid var(--border-dim); }
|
||||
.intensity-1 { background: var(--accent-muted); border: 1px solid var(--accent-dim); }
|
||||
.intensity-2 { background: var(--accent-dim); border: 1px solid var(--accent); opacity: 0.7; }
|
||||
.intensity-3 { background: var(--accent); border: 1px solid var(--accent-bright); opacity: 0.85; }
|
||||
.intensity-4 { background: var(--accent-bright); border: 1px solid var(--accent-fg); }
|
||||
|
||||
.cell-today { outline: 1.5px solid var(--accent-fg); outline-offset: 1px; }
|
||||
.cell-future { opacity: 0.2; cursor: default; pointer-events: none; }
|
||||
|
||||
.legend {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
justify-content: flex-end;
|
||||
flex-shrink: 0;
|
||||
padding-top: 2px;
|
||||
}
|
||||
.legend-cell {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 3px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.legend-label {
|
||||
font-family: var(--font-ui);
|
||||
font-size: 9px;
|
||||
color: var(--text-faint);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
}
|
||||
|
||||
.heatmap-tip {
|
||||
position: fixed;
|
||||
transform: translate(-50%, -100%);
|
||||
background: var(--bg-overlay);
|
||||
border: 1px solid var(--border-base);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 4px 8px;
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-2xs);
|
||||
color: var(--text-secondary);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
z-index: 9999;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
</style>
|
||||
@@ -1,194 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { MagnifyingGlass, X as XIcon } from "phosphor-svelte";
|
||||
import Thumbnail from "@shared/manga/Thumbnail.svelte";
|
||||
import type { Manga } from "@types";
|
||||
|
||||
let {
|
||||
slotIndex,
|
||||
libraryManga,
|
||||
loading,
|
||||
onpin,
|
||||
onclose,
|
||||
}: {
|
||||
slotIndex: 1 | 2 | 3;
|
||||
libraryManga: Manga[];
|
||||
loading: boolean;
|
||||
onpin: (m: Manga) => void;
|
||||
onclose: () => void;
|
||||
} = $props();
|
||||
|
||||
let search = $state("");
|
||||
|
||||
function focusEl(node: HTMLElement) { node.focus(); }
|
||||
|
||||
const results = $derived(
|
||||
search.trim()
|
||||
? libraryManga.filter(m => m.title.toLowerCase().includes(search.toLowerCase())).slice(0, 20)
|
||||
: libraryManga.slice(0, 20)
|
||||
);
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="backdrop"
|
||||
role="presentation"
|
||||
onclick={(e) => { if (e.target === e.currentTarget) onclose(); }}
|
||||
onkeydown={(e) => { if (e.key === "Escape") onclose(); }}
|
||||
>
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<span class="modal-title">Pin manga — slot {slotIndex + 1}</span>
|
||||
<button class="modal-close" onclick={onclose}><XIcon size={13} weight="light" /></button>
|
||||
</div>
|
||||
<div class="search-wrap">
|
||||
<MagnifyingGlass size={12} weight="light" style="color:var(--text-faint);flex-shrink:0" />
|
||||
<input class="search-input" placeholder="Search library…" bind:value={search} use:focusEl />
|
||||
</div>
|
||||
<div class="list">
|
||||
{#if loading}
|
||||
<p class="empty-msg">Loading…</p>
|
||||
{:else if results.length === 0}
|
||||
<p class="empty-msg">No results</p>
|
||||
{:else}
|
||||
{#each results as m (m.id)}
|
||||
<button class="list-row" onclick={() => onpin(m)}>
|
||||
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="row-thumb" />
|
||||
<div class="row-info">
|
||||
<span class="row-title">{m.title}</span>
|
||||
{#if m.source?.displayName}<span class="row-source">{m.source.displayName}</span>{/if}
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.62);
|
||||
z-index: var(--z-settings);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
animation: fadeIn 0.1s ease both;
|
||||
backdrop-filter: blur(4px);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
}
|
||||
.modal {
|
||||
width: min(460px, calc(100vw - 48px));
|
||||
max-height: 68vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-base);
|
||||
border-radius: var(--radius-xl);
|
||||
overflow: hidden;
|
||||
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.6);
|
||||
animation: scaleIn 0.14s ease both;
|
||||
}
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--sp-4) var(--sp-5);
|
||||
border-bottom: 1px solid var(--border-dim);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.modal-title {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--weight-medium);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.modal-close {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-faint);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: background var(--t-fast), color var(--t-fast);
|
||||
}
|
||||
.modal-close:hover { color: var(--text-muted); background: var(--bg-raised); }
|
||||
|
||||
.search-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
padding: var(--sp-3) var(--sp-4);
|
||||
border-bottom: 1px solid var(--border-dim);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.search-input {
|
||||
flex: 1;
|
||||
background: none;
|
||||
border: none;
|
||||
outline: none;
|
||||
color: var(--text-primary);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
.search-input::placeholder { color: var(--text-faint); }
|
||||
|
||||
.list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: var(--sp-2);
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.list::-webkit-scrollbar { display: none; }
|
||||
|
||||
.empty-msg {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-faint);
|
||||
padding: var(--sp-4) var(--sp-3);
|
||||
text-align: center;
|
||||
}
|
||||
.list-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
width: 100%;
|
||||
padding: 8px var(--sp-3);
|
||||
border-radius: var(--radius-md);
|
||||
border: none;
|
||||
background: none;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background var(--t-fast);
|
||||
}
|
||||
.list-row:hover { background: var(--bg-raised); }
|
||||
:global(.row-thumb) {
|
||||
height: 50px;
|
||||
width: 35px;
|
||||
aspect-ratio: 1 / 1.42;
|
||||
border-radius: var(--radius-sm);
|
||||
object-fit: cover;
|
||||
flex-shrink: 0;
|
||||
border: 1px solid var(--border-dim);
|
||||
background: var(--bg-raised);
|
||||
display: block;
|
||||
}
|
||||
.row-info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; min-width: 0; }
|
||||
.row-title {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.row-source {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-2xs);
|
||||
color: var(--text-faint);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
}
|
||||
|
||||
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
|
||||
@keyframes scaleIn { from { opacity: 0; transform: scale(0.97) } to { opacity: 1; transform: scale(1) } }
|
||||
</style>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user