mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 01:09:56 -05:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8c2917b698 | |||
| 3b8c8dea38 | |||
| 615fa1e92f | |||
| 248b046627 | |||
| 79e5548879 | |||
| ed4c11ca7e | |||
| 5dfbc80bbe | |||
| 8aa92e6b54 | |||
| b55dd16d0d | |||
| 7c6aeb8f4c |
@@ -88,10 +88,10 @@ jobs:
|
||||
- name: Download Suwayomi (Linux x64)
|
||||
run: |
|
||||
curl -fsSL \
|
||||
"https://github.com/Suwayomi/Suwayomi-Server-preview/releases/download/v2.1.2087/Suwayomi-Server-v2.1.2087-linux-x64.tar.gz" \
|
||||
"https://github.com/Suwayomi/Suwayomi-Server-preview/releases/download/v2.2.2196/Suwayomi-Server-v2.2.2196-linux-x64.tar.gz" \
|
||||
-o suwayomi-linux.tar.gz
|
||||
|
||||
echo "888bee202649ce7e3e3468a729c4084fb465f024b4033cab3f8ab98b0c66fe76 suwayomi-linux.tar.gz" | sha256sum -c -
|
||||
echo "e13d63ceb7e2b15e83d0a78281e8c1c04ac4a833caa73e5a2b68fbaf0cb20c1f suwayomi-linux.tar.gz" | sha256sum -c -
|
||||
|
||||
mkdir -p suwayomi-extracted
|
||||
tar -xzf suwayomi-linux.tar.gz -C suwayomi-extracted --strip-components=1
|
||||
|
||||
@@ -79,7 +79,7 @@ jobs:
|
||||
download_suwayomi() {
|
||||
local asset="$1" sha="$2" outdir="$3"
|
||||
curl -fsSL \
|
||||
"https://github.com/Suwayomi/Suwayomi-Server-preview/releases/download/v2.1.2087/${asset}" \
|
||||
"https://github.com/Suwayomi/Suwayomi-Server-preview/releases/download/v2.2.2196/${asset}" \
|
||||
-o "${outdir}.tar.gz"
|
||||
echo "${sha} ${outdir}.tar.gz" | shasum -a 256 -c -
|
||||
mkdir -p "${outdir}"
|
||||
@@ -87,13 +87,13 @@ jobs:
|
||||
}
|
||||
|
||||
download_suwayomi \
|
||||
"Suwayomi-Server-v2.1.2087-macOS-arm64.tar.gz" \
|
||||
"59f73a53a139d5d843e16cab4f3ac425a410add6bee0a60920fa26eb0a4b8a5c" \
|
||||
"Suwayomi-Server-v2.2.2196-macOS-arm64.tar.gz" \
|
||||
"9e3dbebc7475707e8d11c56a473385c00b09bde0103d013bc1cb3d06c89e5c43" \
|
||||
"suwayomi-arm64"
|
||||
|
||||
download_suwayomi \
|
||||
"Suwayomi-Server-v2.1.2087-macOS-x64.tar.gz" \
|
||||
"da7e664e4c2615a0b9eac09ee38fe979feee1d6c0b266e19dba1ceea8ae3795c" \
|
||||
"Suwayomi-Server-v2.2.2196-macOS-x64.tar.gz" \
|
||||
"eadee02060b780a5febfb8dada2f89c7bd7db5905cfd20d47eaca02fcde8c9c5" \
|
||||
"suwayomi-x64"
|
||||
|
||||
- name: Stage Suwayomi sidecars
|
||||
|
||||
@@ -79,9 +79,9 @@ jobs:
|
||||
shell: bash
|
||||
run: |
|
||||
curl -fsSL \
|
||||
"https://github.com/Suwayomi/Suwayomi-Server-preview/releases/download/v2.1.2087/Suwayomi-Server-v2.1.2087-windows-x64.zip" \
|
||||
"https://github.com/Suwayomi/Suwayomi-Server-preview/releases/download/v2.2.2196/Suwayomi-Server-v2.2.2196-windows-x64.zip" \
|
||||
-o suwayomi-windows.zip
|
||||
echo "65c3ec544190bc4e52f8ba05b49c87448421d9825aaaeb902cb4e34e69ff7207 suwayomi-windows.zip" | sha256sum -c -
|
||||
echo "457ca4a64a57e0d274a87203d25e962103bcb456ee30ada3ea47328a3093329d suwayomi-windows.zip" | sha256sum -c -
|
||||
unzip -q suwayomi-windows.zip -d suwayomi-raw
|
||||
|
||||
- name: Extract Suwayomi bundle
|
||||
|
||||
@@ -1,42 +1,173 @@
|
||||
# sv
|
||||
<div align="center">
|
||||
<img src="docs/banner.svg" width="100%" alt="Moku" />
|
||||
</div>
|
||||
|
||||
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
|
||||
<div align="center">
|
||||
|
||||
## Creating a project
|
||||
[](https://github.com/moku-project/Moku/releases/latest)
|
||||

|
||||
[](https://github.com/moku-project/Moku)
|
||||
[](https://discord.gg/x97hj8zR72)
|
||||
|
||||
If you're seeing this, you've probably already done this step. Congrats!
|
||||
</div>
|
||||
|
||||
```sh
|
||||
# create a new project
|
||||
npx sv create my-app
|
||||
<br/>
|
||||
|
||||
Moku is a fast, minimal manga reader frontend for [Suwayomi-Server](https://github.com/Suwayomi/Suwayomi-Server). It wraps Suwayomi's GraphQL API in a lightweight Tauri app — no Electron overhead.
|
||||
|
||||
---
|
||||
|
||||
## Screenshots
|
||||
|
||||
<div align="center">
|
||||
<img src="docs/screenshots/Moku-Home.png" width="100%" alt="Home" />
|
||||
</div>
|
||||
|
||||
<div align="center">
|
||||
<img src="docs/screenshots/Moku-Search.png" width="49%" alt="Search" />
|
||||
<img src="docs/screenshots/Moku-TagSearch.png" width="49%" alt="Tag Search" />
|
||||
<img src="docs/screenshots/Moku-Settings.png" width="49%" alt="Settings" />
|
||||
<img src="docs/screenshots/Moku-Preview.png" width="49%" alt="Preview" />
|
||||
<img src="docs/screenshots/Moku-Downloads.png" width="49%" alt="Downloads" />
|
||||
<img src="docs/screenshots/Moku-ReaderSettings.png" width="49%" alt="Reader Settings" />
|
||||
</div>
|
||||
|
||||
<div align="center">
|
||||
<a href="docs/screenshots">View all screenshots →</a>
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
- **Library management** — organize manga into folders, track unread counts, filter by genre
|
||||
- **Per-folder sorting & filtering** — each folder has its own independent sort (unread, 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 reading (accessible from Series Detail)
|
||||
- **Discord Rich Presence** — shows manga title, current chapter, and elapsed timer in your Discord status; configurable in Settings → General
|
||||
- **Auto-start server** — optionally launch Suwayomi in the background on startup
|
||||
- **Multiple themes** — Dark, Light, Midnight, Warm, High Contrast, and more
|
||||
- **Auto-updates** — in-app update checker with silent background notifications
|
||||
- **Improved NSFW filtering** — expanded tag parser gives the Hide NSFW setting better coverage across sources
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
<div align="center">
|
||||
|
||||

|
||||

|
||||

|
||||
|
||||
</div>
|
||||
|
||||
### Windows
|
||||
|
||||
**winget:**
|
||||
|
||||
```powershell
|
||||
winget install Moku.Moku
|
||||
```
|
||||
|
||||
To recreate this project with the same configuration:
|
||||
> Thanks to [@frozenKelp](https://github.com/frozenKelp) for setting up and maintaining the winget package through v0.9.0.
|
||||
|
||||
```sh
|
||||
# recreate this project
|
||||
pnpm dlx sv@0.15.3 create --template minimal --types ts --install pnpm .
|
||||
Or download the `.exe` installer from the [releases page](https://github.com/moku-project/Moku/releases/latest). Suwayomi-Server and a JRE are bundled.
|
||||
|
||||
### Linux (Flatpak, recommended)
|
||||
|
||||
Suwayomi-Server and a bundled JRE are included — no separate install needed.
|
||||
|
||||
```bash
|
||||
flatpak install io.github.moku_app.Moku
|
||||
```
|
||||
|
||||
## Developing
|
||||
Or download the latest `moku.flatpak` from the [releases page](https://github.com/moku-project/Moku/releases/latest) and install manually:
|
||||
|
||||
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
|
||||
|
||||
```sh
|
||||
npm run dev
|
||||
|
||||
# or start the server and open the app in a new browser tab
|
||||
npm run dev -- --open
|
||||
```bash
|
||||
flatpak install moku.flatpak
|
||||
```
|
||||
|
||||
## Building
|
||||
### Nix
|
||||
|
||||
To create a production version of your app:
|
||||
|
||||
```sh
|
||||
npm run build
|
||||
```bash
|
||||
nix run github:moku-project/Moku
|
||||
```
|
||||
|
||||
You can preview the production build with `npm run preview`.
|
||||
Add to your flake:
|
||||
|
||||
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
|
||||
```nix
|
||||
inputs.moku.url = "github:moku-project/Moku";
|
||||
```
|
||||
|
||||
### macOS
|
||||
|
||||
Download the `.dmg` from the [releases page](https://github.com/moku-project/Moku/releases/latest).
|
||||
|
||||
> **Note:** Builds are ad-hoc signed. On first launch you may need to run:
|
||||
> ```bash
|
||||
> xattr -rd com.apple.quarantine /Applications/Moku.app
|
||||
> ```
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
If you're not using the bundled Flatpak or Windows installer, [Suwayomi-Server](https://github.com/Suwayomi/Suwayomi-Server) must be running separately. By default Moku connects to `http://127.0.0.1:4567`.
|
||||
|
||||
You can point Moku at any Suwayomi instance — local or remote — via **Settings → General → Server URL**.
|
||||
|
||||
---
|
||||
|
||||
## Development
|
||||
|
||||
**Prerequisites:** [Rust](https://rustup.rs), [Node.js](https://nodejs.org), [pnpm](https://pnpm.io), and [Tauri v2 prerequisites](https://tauri.app/start/prerequisites/).
|
||||
|
||||
```bash
|
||||
git clone https://github.com/moku-project/Moku
|
||||
cd Moku
|
||||
pnpm install
|
||||
pnpm tauri:dev
|
||||
```
|
||||
|
||||
Or with Nix:
|
||||
|
||||
```bash
|
||||
nix develop
|
||||
pnpm install
|
||||
pnpm tauri:dev
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Stack
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| [Tauri v2](https://tauri.app) | Native app shell |
|
||||
| [Svelte 5](https://svelte.dev) + [SvelteKit 2](https://kit.svelte.dev) + [TypeScript](https://www.typescriptlang.org) | UI |
|
||||
| [Vite 8](https://vitejs.dev) | Frontend bundler |
|
||||
| [Nixpkgs stdenv](https://nixos.org/manual/nixpkgs/stable/) | Nix builds |
|
||||
|
||||
---
|
||||
|
||||
## Community
|
||||
|
||||
Questions, feedback, or just want to hang out — join the Discord.
|
||||
|
||||
[](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.
|
||||
Generated
+12
-12
@@ -5,11 +5,11 @@
|
||||
"nixpkgs-lib": "nixpkgs-lib"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1772408722,
|
||||
"narHash": "sha256-rHuJtdcOjK7rAHpHphUb1iCvgkU3GpfvicLMwwnfMT0=",
|
||||
"lastModified": 1778716662,
|
||||
"narHash": "sha256-m1Yf0wZ8j1OHjTc2UwHwyQRSnNeSgLJOd7q5Y45hzi4=",
|
||||
"owner": "hercules-ci",
|
||||
"repo": "flake-parts",
|
||||
"rev": "f20dc5d9b8027381c474144ecabc9034d6a839a3",
|
||||
"rev": "f7c1a2d347e4c52d5fb8d10cb4d94b5884e546fb",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -20,11 +20,11 @@
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1773821835,
|
||||
"narHash": "sha256-TJ3lSQtW0E2JrznGVm8hOQGVpXjJyXY2guAxku2O9A4=",
|
||||
"lastModified": 1780243769,
|
||||
"narHash": "sha256-x5UQuRsH3MqI0U9afaXSNqzTPSeZlRLvFAav2Ux1pNw=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "b40629efe5d6ec48dd1efba650c797ddbd39ace0",
|
||||
"rev": "331800de5053fcebacf6813adb5db9c9dca22a0c",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -36,11 +36,11 @@
|
||||
},
|
||||
"nixpkgs-lib": {
|
||||
"locked": {
|
||||
"lastModified": 1772328832,
|
||||
"narHash": "sha256-e+/T/pmEkLP6BHhYjx6GmwP5ivonQQn0bJdH9YrRB+Q=",
|
||||
"lastModified": 1777168982,
|
||||
"narHash": "sha256-GOkGPcboWE9BmGCRMLX3worL4EMnsnG8MyKmXNeYuhQ=",
|
||||
"owner": "nix-community",
|
||||
"repo": "nixpkgs.lib",
|
||||
"rev": "c185c7a5e5dd8f9add5b2f8ebeff00888b070742",
|
||||
"rev": "f5901329dade4a6ea039af1433fb087bd9c1fe14",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -63,11 +63,11 @@
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1773975983,
|
||||
"narHash": "sha256-zrRVwdfhDdohANqEhzY/ydeza6EXEi8AG6cyMRNYT9Q=",
|
||||
"lastModified": 1780543271,
|
||||
"narHash": "sha256-oPJ7eJN1sM37v92Rp/eyQL7/rUm0BOvXEBAoq/zN0cM=",
|
||||
"owner": "oxalica",
|
||||
"repo": "rust-overlay",
|
||||
"rev": "cc80954a95f6f356c303ed9f08d0b63ca86216ac",
|
||||
"rev": "c30ca201c5093540cf792f6982f81ba1aa0f3514",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
||||
@@ -220,8 +220,8 @@ modules:
|
||||
|
||||
sources:
|
||||
- type: file
|
||||
url: https://github.com/Suwayomi/Suwayomi-Server-preview/releases/download/v2.1.2087/Suwayomi-Server-v2.1.2087.jar
|
||||
sha256: f589a422674252394c13b289a9c8be691905bf583efb7f4d5f1501ae5e91e6b3
|
||||
url: https://github.com/Suwayomi/Suwayomi-Server-preview/releases/download/v2.2.2196/Suwayomi-Server-v2.2.2196.jar
|
||||
sha256: 8e7244c269456661a87705f746f0d87275770aa976bab7c6920e4d513e97c3f6
|
||||
dest-filename: Suwayomi-Server.jar
|
||||
|
||||
- name: moku
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
{ lib, stdenv, nodejs_22, pnpm, pnpmConfigHook, fetchPnpmDeps, version, src, versions }:
|
||||
|
||||
stdenv.mkDerivation {
|
||||
pname = "moku-frontend";
|
||||
inherit version src;
|
||||
|
||||
nativeBuildInputs = [ nodejs_22 pnpm pnpmConfigHook ];
|
||||
|
||||
pnpmDeps = fetchPnpmDeps {
|
||||
pname = "moku-frontend";
|
||||
inherit version src;
|
||||
fetcherVersion = 1;
|
||||
hash = versions.frontend.pnpmHash;
|
||||
};
|
||||
|
||||
buildPhase = ''
|
||||
export HOME=$(mktemp -d)
|
||||
pnpm build:static
|
||||
'';
|
||||
|
||||
installPhase = "cp -r dist $out";
|
||||
}
|
||||
+1
-1
@@ -29,7 +29,7 @@ pkgs.stdenv.mkDerivation {
|
||||
pnpmDeps = pkgs.fetchPnpmDeps {
|
||||
pname = "moku";
|
||||
inherit version src;
|
||||
fetcherVersion = 1;
|
||||
fetcherVersion = 3;
|
||||
hash = versions.frontend.pnpmHash;
|
||||
};
|
||||
|
||||
|
||||
+3
-3
@@ -2,12 +2,12 @@
|
||||
moku = "0.9.4";
|
||||
|
||||
suwayomi = {
|
||||
version = "2.1.2087";
|
||||
hash = "sha256-9YmkImdCUjlME7KJqci+aRkFv1g++39NXxUBrl6R5rM=";
|
||||
version = "2.2.2196";
|
||||
hash = "sha256-jnJEwmlFZmGodwX3RvDYcnV3Cql2urfGkg5NUT6Xw/Y=";
|
||||
};
|
||||
|
||||
frontend = {
|
||||
pnpmHash = "sha256-8bkwONUrr+U2OXYXvcsGytKhcImnehu+2bI/hmoFjJ4=";
|
||||
pnpmHash = "sha256-18twdFhprV9v9hzvqxuVDHD6Tm4zHNDJs7s6l/7ClBo=";
|
||||
distHash = "7db288b4b54277aa82b6ec5b21fc31a1e71f8246c50a74777500083b806c1fa5";
|
||||
distHashSri = "sha256-fbiiu0tCd6qCtu+SIfw+aR8Yj2bFCnR3dQAIO4BvwfM=";
|
||||
};
|
||||
|
||||
+9
-9
@@ -18,21 +18,21 @@
|
||||
"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.1.0",
|
||||
"svelte": "^5.55.2",
|
||||
"svelte-check": "^4.4.6",
|
||||
"typescript": "^6.0.2",
|
||||
"vite": "^8.0.7"
|
||||
"@sveltejs/kit": "^2.62.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^7.1.2",
|
||||
"@tauri-apps/cli": "^2.11.2",
|
||||
"svelte": "^5.56.1",
|
||||
"svelte-check": "^4.5.0",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^8.0.16"
|
||||
},
|
||||
"dependencies": {
|
||||
"@capacitor/app": "^8.1.0",
|
||||
"@capacitor/browser": "^8.0.3",
|
||||
"@capacitor/core": "^8.3.4",
|
||||
"@capacitor/core": "^8.4.0",
|
||||
"@capacitor/filesystem": "^8.1.2",
|
||||
"@capacitor/preferences": "^8.0.1",
|
||||
"@tauri-apps/api": "^2.0.0",
|
||||
"@tauri-apps/api": "^2.11.0",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.1",
|
||||
"@tauri-apps/plugin-fs": "^2.5.1",
|
||||
"@tauri-apps/plugin-http": "^2.5.9",
|
||||
|
||||
Generated
+350
-353
File diff suppressed because it is too large
Load Diff
Generated
+111
-111
@@ -78,9 +78,9 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
|
||||
|
||||
[[package]]
|
||||
name = "autocfg"
|
||||
version = "1.5.0"
|
||||
version = "1.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
|
||||
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
@@ -117,9 +117,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.11.1"
|
||||
version = "2.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
|
||||
checksum = "84d7ced0ae9557296835c32bf1b1e02b44c746701f898460fb000d7eaa84f00a"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
@@ -144,9 +144,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "brotli"
|
||||
version = "8.0.2"
|
||||
version = "8.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560"
|
||||
checksum = "8119e4516436f5708bbc474a9d395bf12f1b5395e93a92a56e647ac3388c8610"
|
||||
dependencies = [
|
||||
"alloc-no-stdlib",
|
||||
"alloc-stdlib",
|
||||
@@ -155,9 +155,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "brotli-decompressor"
|
||||
version = "5.0.0"
|
||||
version = "5.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03"
|
||||
checksum = "5962523e1b92ce1b5e793d9169b9943eece10d39f62550bc04bb605d75b94924"
|
||||
dependencies = [
|
||||
"alloc-no-stdlib",
|
||||
"alloc-stdlib",
|
||||
@@ -174,9 +174,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "bumpalo"
|
||||
version = "3.20.2"
|
||||
version = "3.20.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
|
||||
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
|
||||
|
||||
[[package]]
|
||||
name = "bytemuck"
|
||||
@@ -205,7 +205,7 @@ version = "0.18.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"bitflags 2.12.1",
|
||||
"cairo-sys-rs",
|
||||
"glib",
|
||||
"libc",
|
||||
@@ -268,9 +268,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.2.62"
|
||||
version = "1.2.63"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98"
|
||||
checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f"
|
||||
dependencies = [
|
||||
"find-msvc-tools",
|
||||
"shlex",
|
||||
@@ -290,7 +290,7 @@ checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
"fnv",
|
||||
"uuid 1.23.1",
|
||||
"uuid 1.23.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -398,7 +398,7 @@ version = "0.25.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"bitflags 2.12.1",
|
||||
"core-foundation 0.10.1",
|
||||
"core-graphics-types",
|
||||
"foreign-types 0.5.0",
|
||||
@@ -411,7 +411,7 @@ version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"bitflags 2.12.1",
|
||||
"core-foundation 0.10.1",
|
||||
"libc",
|
||||
]
|
||||
@@ -672,7 +672,7 @@ version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"bitflags 2.12.1",
|
||||
"block2",
|
||||
"libc",
|
||||
"objc2",
|
||||
@@ -680,9 +680,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "displaydoc"
|
||||
version = "0.2.5"
|
||||
version = "0.2.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0"
|
||||
checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -789,9 +789,9 @@ checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
|
||||
|
||||
[[package]]
|
||||
name = "either"
|
||||
version = "1.15.0"
|
||||
version = "1.16.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
|
||||
checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e"
|
||||
|
||||
[[package]]
|
||||
name = "embed-resource"
|
||||
@@ -1228,7 +1228,7 @@ version = "0.18.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"bitflags 2.12.1",
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-executor",
|
||||
@@ -1408,9 +1408,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "http"
|
||||
version = "1.4.0"
|
||||
version = "1.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a"
|
||||
checksum = "8be7462df143984c4598a256ef469b251d7d7f9e271135073e78fc535414f3d0"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"itoa",
|
||||
@@ -1447,9 +1447,9 @@ checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
|
||||
|
||||
[[package]]
|
||||
name = "hyper"
|
||||
version = "1.9.0"
|
||||
version = "1.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca"
|
||||
checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498"
|
||||
dependencies = [
|
||||
"atomic-waker",
|
||||
"bytes",
|
||||
@@ -1804,9 +1804,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "js-sys"
|
||||
version = "0.3.98"
|
||||
version = "0.3.99"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08"
|
||||
checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"futures-util",
|
||||
@@ -1842,7 +1842,7 @@ version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"bitflags 2.12.1",
|
||||
"serde",
|
||||
"unicode-segmentation",
|
||||
]
|
||||
@@ -1904,9 +1904,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "libredox"
|
||||
version = "0.1.16"
|
||||
version = "0.1.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c"
|
||||
checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
@@ -1940,9 +1940,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.29"
|
||||
version = "0.4.31"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
||||
checksum = "113b30b4cd05f7c06868fdb2854f66a7b9fece9a48425351cd532e810d74024f"
|
||||
|
||||
[[package]]
|
||||
name = "lru-slab"
|
||||
@@ -1963,9 +1963,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.0"
|
||||
version = "2.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
|
||||
checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8"
|
||||
|
||||
[[package]]
|
||||
name = "memoffset"
|
||||
@@ -1994,9 +1994,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "mio"
|
||||
version = "1.2.0"
|
||||
version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1"
|
||||
checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"wasi",
|
||||
@@ -2029,9 +2029,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "muda"
|
||||
version = "0.19.1"
|
||||
version = "0.19.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ae8844f63b5b118e334e205585b8c5c17b984121dbdb179d44aeb087ffad3cb"
|
||||
checksum = "47a2e3dff89cd322c66647942668faee0a2b1f88ea6cbb4d374b4a8d7e92528c"
|
||||
dependencies = [
|
||||
"crossbeam-channel",
|
||||
"dpi",
|
||||
@@ -2071,7 +2071,7 @@ version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"bitflags 2.12.1",
|
||||
"jni-sys 0.3.1",
|
||||
"log",
|
||||
"ndk-sys",
|
||||
@@ -2097,11 +2097,11 @@ checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
|
||||
|
||||
[[package]]
|
||||
name = "nix"
|
||||
version = "0.30.1"
|
||||
version = "0.31.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6"
|
||||
checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"bitflags 2.12.1",
|
||||
"cfg-if",
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
@@ -2118,9 +2118,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "num-conv"
|
||||
version = "0.2.1"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967"
|
||||
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
|
||||
|
||||
[[package]]
|
||||
name = "num-traits"
|
||||
@@ -2169,7 +2169,7 @@ version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"bitflags 2.12.1",
|
||||
"block2",
|
||||
"objc2",
|
||||
"objc2-core-foundation",
|
||||
@@ -2182,7 +2182,7 @@ version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"bitflags 2.12.1",
|
||||
"objc2",
|
||||
"objc2-foundation",
|
||||
]
|
||||
@@ -2203,7 +2203,7 @@ version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"bitflags 2.12.1",
|
||||
"dispatch2",
|
||||
"objc2",
|
||||
]
|
||||
@@ -2214,7 +2214,7 @@ version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"bitflags 2.12.1",
|
||||
"dispatch2",
|
||||
"objc2",
|
||||
"objc2-core-foundation",
|
||||
@@ -2247,7 +2247,7 @@ version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"bitflags 2.12.1",
|
||||
"objc2",
|
||||
"objc2-core-foundation",
|
||||
"objc2-core-graphics",
|
||||
@@ -2274,7 +2274,7 @@ version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"bitflags 2.12.1",
|
||||
"block2",
|
||||
"libc",
|
||||
"objc2",
|
||||
@@ -2297,7 +2297,7 @@ version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"bitflags 2.12.1",
|
||||
"objc2",
|
||||
"objc2-core-foundation",
|
||||
]
|
||||
@@ -2308,7 +2308,7 @@ version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"bitflags 2.12.1",
|
||||
"objc2",
|
||||
"objc2-core-foundation",
|
||||
"objc2-foundation",
|
||||
@@ -2320,7 +2320,7 @@ version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"bitflags 2.12.1",
|
||||
"block2",
|
||||
"objc2",
|
||||
"objc2-cloud-kit",
|
||||
@@ -2351,7 +2351,7 @@ version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"bitflags 2.12.1",
|
||||
"block2",
|
||||
"objc2",
|
||||
"objc2-app-kit",
|
||||
@@ -2383,7 +2383,7 @@ version = "0.10.80"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"bitflags 2.12.1",
|
||||
"cfg-if",
|
||||
"foreign-types 0.3.2",
|
||||
"libc",
|
||||
@@ -2428,9 +2428,9 @@ checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
|
||||
|
||||
[[package]]
|
||||
name = "os_info"
|
||||
version = "3.14.0"
|
||||
version = "3.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e4022a17595a00d6a369236fdae483f0de7f0a339960a53118b818238e132224"
|
||||
checksum = "9cf20a545b305cf1da722b236b5155c9bb35f1d5ceb28c048bd96ca842f41b5b"
|
||||
dependencies = [
|
||||
"android_system_properties",
|
||||
"log",
|
||||
@@ -2609,7 +2609,7 @@ version = "0.18.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"bitflags 2.12.1",
|
||||
"crc32fast",
|
||||
"fdeflate",
|
||||
"flate2",
|
||||
@@ -2682,7 +2682,7 @@ version = "3.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
|
||||
dependencies = [
|
||||
"toml_edit 0.25.11+spec-1.1.0",
|
||||
"toml_edit 0.25.12+spec-1.1.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2880,7 +2880,7 @@ version = "0.5.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"bitflags 2.12.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3004,9 +3004,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "reqwest"
|
||||
version = "0.13.3"
|
||||
version = "0.13.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "62e0021ea2c22aed41653bc7e1419abb2c97e038ff2c33d0e1309e49a97deec0"
|
||||
checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
@@ -3095,7 +3095,7 @@ version = "1.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"bitflags 2.12.1",
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
@@ -3179,7 +3179,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"url",
|
||||
"uuid 1.23.1",
|
||||
"uuid 1.23.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3230,7 +3230,7 @@ version = "3.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"bitflags 2.12.1",
|
||||
"core-foundation 0.10.1",
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
@@ -3253,7 +3253,7 @@ version = "0.36.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"bitflags 2.12.1",
|
||||
"cssparser",
|
||||
"derive_more",
|
||||
"log",
|
||||
@@ -3331,9 +3331,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.149"
|
||||
version = "1.0.150"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
|
||||
checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
@@ -3470,9 +3470,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "shlex"
|
||||
version = "1.3.0"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
|
||||
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
|
||||
|
||||
[[package]]
|
||||
name = "sigchld"
|
||||
@@ -3531,9 +3531,9 @@ checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
|
||||
|
||||
[[package]]
|
||||
name = "socket2"
|
||||
version = "0.6.3"
|
||||
version = "0.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
|
||||
checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
@@ -3724,7 +3724,7 @@ version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"bitflags 2.12.1",
|
||||
"core-foundation 0.9.4",
|
||||
"system-configuration-sys",
|
||||
]
|
||||
@@ -3754,11 +3754,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tao"
|
||||
version = "0.35.2"
|
||||
version = "0.35.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a33f7f9e486ade65fcf1e45c440f9236c904f5c1002cdc7fc6ae582777345ce4"
|
||||
checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"bitflags 2.12.1",
|
||||
"block2",
|
||||
"core-foundation 0.10.1",
|
||||
"core-graphics",
|
||||
@@ -3839,7 +3839,7 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
"plist",
|
||||
"raw-window-handle",
|
||||
"reqwest 0.13.3",
|
||||
"reqwest 0.13.4",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_repr",
|
||||
@@ -3904,7 +3904,7 @@ dependencies = [
|
||||
"thiserror 2.0.18",
|
||||
"time",
|
||||
"url",
|
||||
"uuid 1.23.1",
|
||||
"uuid 1.23.2",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
@@ -4171,7 +4171,7 @@ dependencies = [
|
||||
"toml 1.1.2+spec-1.1.0",
|
||||
"url",
|
||||
"urlpattern",
|
||||
"uuid 1.23.1",
|
||||
"uuid 1.23.2",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
@@ -4459,9 +4459,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "toml_edit"
|
||||
version = "0.25.11+spec-1.1.0"
|
||||
version = "0.25.12+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b"
|
||||
checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7"
|
||||
dependencies = [
|
||||
"indexmap 2.14.0",
|
||||
"toml_datetime 1.1.1+spec-1.1.0",
|
||||
@@ -4501,11 +4501,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tower-http"
|
||||
version = "0.6.10"
|
||||
version = "0.6.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "68d6fdd9f81c2819c9a8b0e0cd91660e7746a8e6ea2ba7c6b2b057985f6bcb51"
|
||||
checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"bitflags 2.12.1",
|
||||
"bytes",
|
||||
"futures-util",
|
||||
"http",
|
||||
@@ -4596,9 +4596,9 @@ checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c"
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.20.0"
|
||||
version = "1.20.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de"
|
||||
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
||||
|
||||
[[package]]
|
||||
name = "unic-char-property"
|
||||
@@ -4649,9 +4649,9 @@ checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-segmentation"
|
||||
version = "1.13.2"
|
||||
version = "1.13.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c"
|
||||
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-xid"
|
||||
@@ -4719,9 +4719,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "uuid"
|
||||
version = "1.23.1"
|
||||
version = "1.23.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76"
|
||||
checksum = "d258b83ceec21034727ecee8c382cfa6c3e133699b0742c64571814fb420c9f7"
|
||||
dependencies = [
|
||||
"getrandom 0.4.2",
|
||||
"js-sys",
|
||||
@@ -4812,9 +4812,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen"
|
||||
version = "0.2.121"
|
||||
version = "0.2.122"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790"
|
||||
checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"once_cell",
|
||||
@@ -4825,9 +4825,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-futures"
|
||||
version = "0.4.71"
|
||||
version = "0.4.72"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8"
|
||||
checksum = "9473dbd2991ae90b6291c3c32c30c6187ac49aa32f9905d1cce280ec1e110b0f"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
@@ -4835,9 +4835,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro"
|
||||
version = "0.2.121"
|
||||
version = "0.2.122"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578"
|
||||
checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"wasm-bindgen-macro-support",
|
||||
@@ -4845,9 +4845,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro-support"
|
||||
version = "0.2.121"
|
||||
version = "0.2.122"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2"
|
||||
checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"proc-macro2",
|
||||
@@ -4858,9 +4858,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-shared"
|
||||
version = "0.2.121"
|
||||
version = "0.2.122"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441"
|
||||
checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
@@ -4906,7 +4906,7 @@ version = "0.244.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"bitflags 2.12.1",
|
||||
"hashbrown 0.15.5",
|
||||
"indexmap 2.14.0",
|
||||
"semver",
|
||||
@@ -4914,9 +4914,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "web-sys"
|
||||
version = "0.3.98"
|
||||
version = "0.3.99"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa"
|
||||
checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
@@ -5759,7 +5759,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags 2.11.1",
|
||||
"bitflags 2.12.1",
|
||||
"indexmap 2.14.0",
|
||||
"log",
|
||||
"serde",
|
||||
@@ -5862,9 +5862,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "yoke"
|
||||
version = "0.8.2"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca"
|
||||
checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
|
||||
dependencies = [
|
||||
"stable_deref_trait",
|
||||
"yoke-derive",
|
||||
@@ -5885,18 +5885,18 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy"
|
||||
version = "0.8.48"
|
||||
version = "0.8.50"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9"
|
||||
checksum = "3b065d4f0e55f82fae73202e189638116a87c55ab6b8e6c2721e13dd9d854ad1"
|
||||
dependencies = [
|
||||
"zerocopy-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy-derive"
|
||||
version = "0.8.48"
|
||||
version = "0.8.50"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4"
|
||||
checksum = "0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
|
||||
@@ -43,6 +43,8 @@
|
||||
"discord-rpc:allow-disconnect",
|
||||
"discord-rpc:allow-set-activity",
|
||||
"discord-rpc:allow-clear-activity",
|
||||
"discord-rpc:allow-is-running"
|
||||
"discord-rpc:allow-is-running",
|
||||
"dialog:default",
|
||||
"dialog:allow-open"
|
||||
]
|
||||
}
|
||||
@@ -2,10 +2,58 @@ use serde::Serialize;
|
||||
use std::path::PathBuf;
|
||||
use sysinfo::Disks;
|
||||
use tauri::Emitter;
|
||||
use tauri_plugin_store::StoreExt;
|
||||
use walkdir::WalkDir;
|
||||
|
||||
use crate::server::resolve::suwayomi_data_dir;
|
||||
|
||||
// ── Key-value store (used by the frontend via platformService) ────────────────
|
||||
|
||||
#[tauri::command]
|
||||
pub fn load_store(app: tauri::AppHandle, key: String) -> Result<Option<String>, String> {
|
||||
let store = app
|
||||
.store(format!("{}.json", key))
|
||||
.map_err(|e| e.to_string())?;
|
||||
let value = store.get(&key);
|
||||
Ok(value.map(|v| v.to_string()))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn save_store(app: tauri::AppHandle, key: String, value: String) -> Result<(), String> {
|
||||
let store = app
|
||||
.store(format!("{}.json", key))
|
||||
.map_err(|e| e.to_string())?;
|
||||
let parsed: serde_json::Value =
|
||||
serde_json::from_str(&value).map_err(|e| e.to_string())?;
|
||||
store.set(key, parsed);
|
||||
store.save().map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
// ── Credential store (PIN-encrypted vault, auth tokens) ──────────────────────
|
||||
|
||||
#[tauri::command]
|
||||
pub fn store_credential(app: tauri::AppHandle, key: String, value: String) -> Result<(), String> {
|
||||
let store = app
|
||||
.store("credentials.json")
|
||||
.map_err(|e| e.to_string())?;
|
||||
if value.is_empty() {
|
||||
store.delete(&key);
|
||||
} else {
|
||||
store.set(&key, serde_json::Value::String(value));
|
||||
}
|
||||
store.save().map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_credential(app: tauri::AppHandle, key: String) -> Result<Option<String>, String> {
|
||||
let store = app
|
||||
.store("credentials.json")
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(store.get(&key).and_then(|v| v.as_str().map(|s| s.to_owned())))
|
||||
}
|
||||
|
||||
// ── Disk / downloads storage ─────────────────────────────────────────────────
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct StorageInfo {
|
||||
pub manga_bytes: u64,
|
||||
@@ -127,4 +175,4 @@ pub async fn migrate_downloads(
|
||||
|
||||
fs::remove_dir_all(&src_path).map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -108,6 +108,10 @@ pub fn run() {
|
||||
commands::backup::auto_backup_app_data,
|
||||
commands::backup::get_auto_backup_dir,
|
||||
commands::backup::read_store_files,
|
||||
commands::storage::load_store,
|
||||
commands::storage::save_store,
|
||||
commands::storage::store_credential,
|
||||
commands::storage::get_credential,
|
||||
commands::updater::list_releases,
|
||||
commands::updater::download_and_install_update,
|
||||
commands::biometric::windows_hello_authenticate,
|
||||
|
||||
+4
-8
@@ -17,21 +17,17 @@ interface SavedAuth {
|
||||
pass?: string
|
||||
}
|
||||
|
||||
async function resolveServerAdapter() {
|
||||
const { SuwayomiAdapter } = await import('$lib/server-adapters/suwayomi')
|
||||
return new SuwayomiAdapter()
|
||||
}
|
||||
|
||||
async function boot() {
|
||||
try {
|
||||
const platformAdapter = detectAdapter()
|
||||
initPlatformService(platformAdapter)
|
||||
|
||||
await platformAdapter.init()
|
||||
|
||||
const serverAdapter = await resolveServerAdapter()
|
||||
const { SuwayomiAdapter } = await import('$lib/server-adapters/suwayomi')
|
||||
const serverAdapter = new SuwayomiAdapter()
|
||||
initRequestManager(serverAdapter)
|
||||
|
||||
await platformAdapter.init()
|
||||
|
||||
appState.platform = platformAdapter.platform
|
||||
appState.version = await platformAdapter.getVersion()
|
||||
|
||||
|
||||
@@ -204,7 +204,7 @@
|
||||
{#each visibleItems as m, i (m.id)}
|
||||
<button class="card" onclick={() => setPreviewManga(m)} oncontextmenu={(e) => { e.stopPropagation(); openCtx(e, m); }}>
|
||||
<div class="cover-wrap">
|
||||
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="cover" priority={i < 12 ? 12 - i : 0} />
|
||||
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="cover" priority={i < 12 ? 12 - i : 0} id={m.id} />
|
||||
{#if m.inLibrary}<span class="in-library-badge">Saved</span>{/if}
|
||||
</div>
|
||||
<p class="card-title">{m.title}</p>
|
||||
|
||||
@@ -37,6 +37,8 @@
|
||||
let kw_inputEl: HTMLInputElement | null = $state(null);
|
||||
let kw_abortCtrl: AbortController | null = null;
|
||||
let kw_debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let kw_localQuery = $state(query);
|
||||
let kw_pending = $state(false);
|
||||
|
||||
interface SourceResult {
|
||||
source: Source;
|
||||
@@ -57,18 +59,23 @@
|
||||
if (!loadingSources && pendingPrefill && allSources.length) {
|
||||
const q = pendingPrefill;
|
||||
onPrefillConsumed();
|
||||
kw_localQuery = q;
|
||||
onQueryChange(q);
|
||||
kwDoSearch(q);
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const q = query;
|
||||
function kwHandleInput(value: string) {
|
||||
kw_localQuery = value;
|
||||
if (kw_debounceTimer) clearTimeout(kw_debounceTimer);
|
||||
if (!q.trim()) { kw_abortCtrl?.abort(); kw_results = []; return; }
|
||||
kw_debounceTimer = setTimeout(() => kwDoSearch(q), 350);
|
||||
return () => { if (kw_debounceTimer) clearTimeout(kw_debounceTimer); };
|
||||
});
|
||||
if (!value.trim()) { kw_abortCtrl?.abort(); kw_results = []; kw_pending = false; onQueryChange(""); return; }
|
||||
kw_pending = true;
|
||||
kw_debounceTimer = setTimeout(() => {
|
||||
kw_pending = false;
|
||||
onQueryChange(value);
|
||||
kwDoSearch(value);
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
function kwGetVisibleSources(): Source[] {
|
||||
let srcs = allSources;
|
||||
@@ -142,18 +149,17 @@
|
||||
</svg>
|
||||
<input
|
||||
bind:this={kw_inputEl}
|
||||
value={query}
|
||||
oninput={(e) => onQueryChange((e.target as HTMLInputElement).value)}
|
||||
value={kw_localQuery}
|
||||
oninput={(e) => kwHandleInput((e.target as HTMLInputElement).value)}
|
||||
class="searchInput"
|
||||
placeholder="Search across sources…"
|
||||
use:focusOnMount
|
||||
/>
|
||||
{#if kw_anyLoading}
|
||||
{#if kw_pending || kw_anyLoading}
|
||||
<svg width="13" height="13" viewBox="0 0 256 256" fill="currentColor" class="anim-spin" style="color:var(--text-faint);flex-shrink:0" aria-hidden="true">
|
||||
<path d="M232,128a104,104,0,0,1-208,0c0-41,23.81-78.36,60.66-95.27a8,8,0,0,1,6.68,14.54C60.15,61.59,40,93.27,40,128a88,88,0,0,0,176,0c0-34.73-20.15-66.41-51.34-80.73a8,8,0,0,1,6.68-14.54C208.19,49.64,232,87,232,128Z"/>
|
||||
</svg>
|
||||
{:else if query}
|
||||
<button class="clearBtn" title="Clear" onclick={() => { onQueryChange(""); kw_results = []; kw_inputEl?.focus(); }}>×</button>
|
||||
{:else if kw_localQuery}
|
||||
<button class="clearBtn" title="Clear" onclick={() => { kwHandleInput(""); kw_inputEl?.focus(); }}>×</button>
|
||||
{/if}
|
||||
{#if hasMultipleLangs}
|
||||
<button
|
||||
@@ -193,7 +199,7 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if !query.trim()}
|
||||
{#if !kw_localQuery.trim()}
|
||||
{#if popularLoading && popularResults.length === 0}
|
||||
<div class="searchGrid">
|
||||
{#each Array(24) as _, i (i)}<div class="skCard"><div class="skeleton skCover"></div></div>{/each}
|
||||
@@ -206,7 +212,7 @@
|
||||
{#each popularResults as m (m.id)}
|
||||
<button class="srchCard" onclick={() => onPreview(m)}>
|
||||
<div class="srchCoverWrap">
|
||||
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="cover" priority={m._priority} />
|
||||
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="cover" priority={m._priority} id={m.id} />
|
||||
<div class="srchGradient"></div>
|
||||
{#if m.inLibrary}<span class="inLibBadge">Saved</span>{/if}
|
||||
<div class="srchFooter">
|
||||
@@ -235,16 +241,20 @@
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
{:else if kw_pending}
|
||||
<div class="searchGrid">
|
||||
{#each Array(12) as _, i (i)}<div class="skCard"><div class="skeleton skCover"></div></div>{/each}
|
||||
</div>
|
||||
{:else}
|
||||
{#if kw_flatResults.length > 0}
|
||||
<div class="searchHeader">
|
||||
<span class="searchLabel">{kw_flatResults.length} result{kw_flatResults.length !== 1 ? "s" : ""}</span>
|
||||
<span class="searchLabel">{kw_flatResults.length} result{kw_flatResults.length !== 1 ? "s" : ""} for "{kw_localQuery.trim()}"</span>
|
||||
</div>
|
||||
<div class="searchGrid">
|
||||
{#each kw_flatResults as m (m.id)}
|
||||
<button class="srchCard" onclick={() => onPreview(m)}>
|
||||
<div class="srchCoverWrap">
|
||||
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="cover" priority={m._priority} />
|
||||
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="cover" priority={m._priority} id={m.id} />
|
||||
<div class="srchGradient"></div>
|
||||
{#if m.inLibrary}<span class="inLibBadge">Saved</span>{/if}
|
||||
<div class="srchFooter">
|
||||
@@ -264,16 +274,15 @@
|
||||
</div>
|
||||
{:else if kw_allDone && !kw_hasResults}
|
||||
<div class="empty">
|
||||
<p class="emptyText">No results for "{query.trim()}"</p>
|
||||
<svg width="36" height="36" viewBox="0 0 256 256" fill="currentColor" class="emptyIcon" aria-hidden="true">
|
||||
<path d="M229.66,218.34l-50.07-50.07a88,88,0,1,0-11.31,11.31l50.06,50.07a8,8,0,0,0,11.32-11.31ZM40,112a72,72,0,1,1,72,72A72.08,72.08,0,0,1,40,112Z"/>
|
||||
</svg>
|
||||
<p class="emptyText">No results for "{kw_localQuery.trim()}"</p>
|
||||
<p class="emptyHint">Try a different spelling or fewer words</p>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<script module>
|
||||
function focusOnMount(node: HTMLElement) { node.focus(); }
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.keywordBar { padding: var(--sp-3) var(--sp-4) var(--sp-2); flex-shrink: 0; display: flex; flex-direction: column; gap: var(--sp-2); }
|
||||
.searchBar { display: flex; align-items: center; gap: var(--sp-2); background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-lg); padding: var(--sp-2) var(--sp-3); transition: border-color var(--t-base); }
|
||||
|
||||
@@ -261,7 +261,7 @@
|
||||
{#each src_browseResults as m, i (m.id)}
|
||||
<button class="card" onclick={() => onPreview(m)}>
|
||||
<div class="coverWrap">
|
||||
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="cover" priority={i < 12 ? 12 - i : 0} />
|
||||
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="cover" priority={i < 12 ? 12 - i : 0} id={m.id} />
|
||||
{#if m.inLibrary}<span class="inLibBadge">Saved</span>{/if}
|
||||
</div>
|
||||
<p class="cardTitle">{m.title}</p>
|
||||
|
||||
@@ -360,7 +360,7 @@
|
||||
{#each tag_mergedResults as m, i (m.id)}
|
||||
<button class="card" onclick={() => onPreview(m)}>
|
||||
<div class="coverWrap">
|
||||
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="cover" priority={i < 12 ? 12 - i : 0} />
|
||||
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="cover" priority={i < 12 ? 12 - i : 0} id={m.id} />
|
||||
{#if m.inLibrary}<span class="inLibBadge">Saved</span>{/if}
|
||||
</div>
|
||||
<p class="cardTitle">{m.title}</p>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import logoUrl from '$lib/assets/moku-icon-splash.svg'
|
||||
import { appState } from '$lib/state/app.svelte'
|
||||
import logoUrl from '$lib/assets/moku-icon-splash.svg'
|
||||
import { appState } from '$lib/state/app.svelte'
|
||||
import { boot, submitLogin, bypassBoot } from '$lib/state/boot.svelte'
|
||||
|
||||
function handleBypass() {
|
||||
@@ -9,7 +9,7 @@
|
||||
</script>
|
||||
|
||||
{#if appState.status === 'auth'}
|
||||
<div class="overlay">
|
||||
<div class="overlay overlay--clear">
|
||||
<div class="card anim-scale-in">
|
||||
<img src={logoUrl} alt="Moku" class="logo" />
|
||||
<p class="title">moku</p>
|
||||
@@ -56,10 +56,14 @@
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.overlay { position:fixed; inset:0; z-index:10000; display:flex; align-items:center; justify-content:center; pointer-events:none; }
|
||||
.card { pointer-events:auto; width:min(280px, calc(100vw - 48px)); background:var(--bg-surface); border:1px solid var(--border-base); border-radius:var(--radius-xl); padding:var(--sp-6) var(--sp-5); display:flex; flex-direction:column; align-items:center; gap:var(--sp-3); box-shadow:0 32px 80px rgba(0,0,0,0.75); text-align:center; }
|
||||
.overlay { position:fixed; inset:0; z-index:10000; display:flex; align-items:center; justify-content:center; background:rgba(0,0,0,0.7); backdrop-filter:blur(6px); animation:overlayIn 0.28s cubic-bezier(0,0,0.2,1) both; }
|
||||
.overlay--clear { background:transparent; backdrop-filter:none; pointer-events:none; }
|
||||
.overlay--clear .card { pointer-events:auto; }
|
||||
|
||||
.card { width:min(280px, calc(100vw - 48px)); background:var(--bg-surface); border:1px solid var(--border-base); border-radius:var(--radius-xl); padding:var(--sp-6) var(--sp-5); display:flex; flex-direction:column; align-items:center; gap:var(--sp-3); box-shadow:0 32px 80px rgba(0,0,0,0.75); text-align:center; animation:cardIn 0.38s cubic-bezier(0.22,1,0.36,1) 0.06s both; }
|
||||
|
||||
.logo { width:56px; height:56px; border-radius:14px; display:block; position:relative; }
|
||||
|
||||
.logo { width:56px; height:56px; border-radius:14px; display:block; }
|
||||
.title { font-family:var(--font-ui); font-size:11px; font-weight:500; letter-spacing:0.26em; text-transform:uppercase; color:var(--text-secondary); margin:-6px 0 0; user-select:none; }
|
||||
.mode-badge { font-family:var(--font-ui); font-size:var(--text-2xs); letter-spacing:var(--tracking-wider); text-transform:uppercase; color:var(--accent-fg); background:var(--accent-muted); border:1px solid var(--accent-dim); border-radius:var(--radius-full); padding:2px 10px; }
|
||||
.host { font-family:var(--font-ui); font-size:var(--text-xs); color:var(--text-faint); letter-spacing:var(--tracking-wide); margin:-4px 0 0; }
|
||||
@@ -70,9 +74,14 @@
|
||||
.input:focus { border-color:var(--border-focus); box-shadow:0 0 0 2px color-mix(in srgb, var(--accent) 20%, transparent); }
|
||||
.input:disabled { opacity:0.5; }
|
||||
|
||||
.btn { width:100%; padding:9px; border-radius:var(--radius-md); background:var(--accent); border:1px solid var(--accent); color:var(--accent-fg); font-size:var(--text-sm); font-family:var(--font-ui); letter-spacing:var(--tracking-wide); cursor:pointer; transition:opacity var(--t-base); }
|
||||
.btn:hover:not(:disabled) { opacity:0.85; }
|
||||
.btn:disabled { opacity:0.35; cursor:default; }
|
||||
.btn--ghost { background:none; border-color:transparent; color:var(--text-faint); font-size:var(--text-xs); padding:4px; }
|
||||
.btn--ghost:hover:not(:disabled) { color:var(--text-muted); opacity:1; }
|
||||
.btn { width:100%; padding:9px; border-radius:var(--radius-md); background:var(--accent); border:1px solid var(--accent); color:var(--accent-fg); font-size:var(--text-sm); font-family:var(--font-ui); letter-spacing:var(--tracking-wide); cursor:pointer; transition:opacity var(--t-base); }
|
||||
.btn:hover:not(:disabled) { opacity:0.85; }
|
||||
.btn:disabled { opacity:0.35; cursor:default; }
|
||||
.btn--ghost { background:none; border-color:transparent; color:var(--text-faint); font-size:var(--text-xs); padding:4px; }
|
||||
.btn--ghost:hover:not(:disabled) { color:var(--text-muted); opacity:1; }
|
||||
|
||||
@keyframes overlayIn { from { opacity:0 } to { opacity:1 } }
|
||||
@keyframes cardIn { from { opacity:0; transform:translateY(28px) scale(0.97) } to { opacity:1; transform:translateY(0) scale(1) } }
|
||||
@keyframes anim-scale-in { from { opacity:0; transform:scale(0.96) } to { opacity:1; transform:scale(1) } }
|
||||
.anim-scale-in { animation:anim-scale-in 0.2s cubic-bezier(0,0,0.2,1) both; }
|
||||
</style>
|
||||
@@ -1,384 +1,355 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import { settingsState } from "$lib/state/settings.svelte";
|
||||
import logoUrl from "$lib/assets/moku-icon-splash.svg";
|
||||
import { onMount } from 'svelte'
|
||||
import logoUrl from '$lib/assets/moku-icon-splash.svg'
|
||||
import { platformService } from '$lib/platform-service'
|
||||
|
||||
const isTauri = platformService.platform === 'tauri'
|
||||
|
||||
interface Props {
|
||||
mode?: "loading" | "idle";
|
||||
ringFull?: boolean;
|
||||
failed?: boolean;
|
||||
notConfigured?: boolean;
|
||||
showCards?: boolean;
|
||||
showFps?: boolean;
|
||||
onReady?: () => void;
|
||||
onRetry?: () => void;
|
||||
onBypass?: () => void;
|
||||
onDismiss?: () => void;
|
||||
mode?: 'loading' | 'idle' | 'locked'
|
||||
ringFull?: boolean
|
||||
failed?: boolean
|
||||
notConfigured?: boolean
|
||||
showCards?: boolean
|
||||
showFps?: boolean
|
||||
pinLen?: number
|
||||
pinCorrect?: string
|
||||
onReady?: () => void
|
||||
onUnlock?: () => void
|
||||
onRetry?: () => void
|
||||
onBypass?: () => void
|
||||
onDismiss?: () => void
|
||||
}
|
||||
|
||||
let {
|
||||
mode = "loading", ringFull = false, failed = false,
|
||||
mode = 'loading', ringFull = false, failed = false,
|
||||
notConfigured = false, showCards = true, showFps = false,
|
||||
onReady, onRetry, onBypass, onDismiss,
|
||||
}: Props = $props();
|
||||
pinLen = 4, pinCorrect = '',
|
||||
onReady, onUnlock, onRetry, onBypass, onDismiss,
|
||||
}: Props = $props()
|
||||
|
||||
const serverAuthActive = $derived(
|
||||
settingsState.settings.serverAuthMode === "BASIC_AUTH" || settingsState.settings.serverAuthMode === "UI_LOGIN"
|
||||
);
|
||||
let fpsEl = $state<HTMLSpanElement | undefined>(undefined)
|
||||
let dots = $state('')
|
||||
let ringProg = $state(0.025)
|
||||
let exiting = $state(false)
|
||||
let exitLock = false
|
||||
|
||||
const lockEnabled = $derived(
|
||||
settingsState.settings.appLockEnabled &&
|
||||
(settingsState.settings.appLockPin?.length ?? 0) >= 4 &&
|
||||
(mode === "idle" || !serverAuthActive)
|
||||
);
|
||||
let pinEntry = $state('')
|
||||
let pinShake = $state(false)
|
||||
|
||||
let pinEntry = $state("");
|
||||
let pinShake = $state(false);
|
||||
let pinUnlocked = $state(false);
|
||||
let pinVisible = $state(false);
|
||||
let uiScale = $state(1);
|
||||
let fpsEl = $state<HTMLSpanElement | undefined>(undefined);
|
||||
const logoLoadingSize = 140
|
||||
const logoIdleSize = 128
|
||||
const ringR = 70
|
||||
const ringPad = 12
|
||||
const ringSize = (ringR + ringPad) * 2
|
||||
const ringC = ringR + ringPad
|
||||
const ringCirc = 2 * Math.PI * ringR
|
||||
const ringArc = $derived(ringCirc * Math.min(Math.max(ringProg, 0.025), 0.999))
|
||||
|
||||
const logoLoadingSize = 140;
|
||||
const logoIdleSize = 128;
|
||||
const logoLockSize = 96;
|
||||
const EXIT_MS = 320
|
||||
const PHASE1_TARGET = 0.85
|
||||
const PHASE1_MS = 3000
|
||||
const PHASE2_TARGET = 0.95
|
||||
const PHASE2_MS = 10000
|
||||
|
||||
const ringR = 70;
|
||||
const ringPad = 12;
|
||||
const ringSize = (ringR + ringPad) * 2;
|
||||
const ringC = ringR + ringPad;
|
||||
const ringCirc = 2 * Math.PI * ringR;
|
||||
const ringArc = $derived(ringCirc * Math.min(Math.max(ringProg, 0.025), 0.999));
|
||||
function triggerExit(cb?: () => void) {
|
||||
if (exitLock) return
|
||||
exitLock = true
|
||||
exiting = true
|
||||
setTimeout(() => cb?.(), EXIT_MS)
|
||||
}
|
||||
|
||||
let animFrame: number
|
||||
let animStart: number | null = null
|
||||
let animPhase = 1
|
||||
|
||||
function animateRing(ts: number) {
|
||||
if (exitLock) return
|
||||
if (animStart === null) animStart = ts
|
||||
const elapsed = ts - animStart
|
||||
if (animPhase === 1) {
|
||||
const t = Math.min(elapsed / PHASE1_MS, 1)
|
||||
ringProg = 0.025 + (1 - Math.pow(1 - t, 3)) * (PHASE1_TARGET - 0.025)
|
||||
if (t >= 1) { animPhase = 2; animStart = ts }
|
||||
} else {
|
||||
const t = Math.min(elapsed / PHASE2_MS, 1)
|
||||
ringProg = PHASE1_TARGET + (1 - Math.pow(1 - t, 4)) * (PHASE2_TARGET - PHASE1_TARGET)
|
||||
}
|
||||
animFrame = requestAnimationFrame(animateRing)
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (mode === 'loading' && !failed && !notConfigured && !ringFull) {
|
||||
if (!isTauri) return // no ring animation on web; probe outcome drives exit
|
||||
animStart = null
|
||||
animPhase = 1
|
||||
animFrame = requestAnimationFrame(animateRing)
|
||||
return () => cancelAnimationFrame(animFrame)
|
||||
}
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
if (!ringFull || mode === 'locked') { exitLock = false; exiting = false; return }
|
||||
cancelAnimationFrame(animFrame)
|
||||
ringProg = 1
|
||||
setTimeout(() => triggerExit(onReady), 650)
|
||||
})
|
||||
|
||||
function submitPin() {
|
||||
if (pinEntry === settingsState.settings.appLockPin) {
|
||||
pinUnlocked = true;
|
||||
pinEntry = "";
|
||||
if (mode === "idle") triggerExit(onDismiss);
|
||||
if (pinEntry === pinCorrect) {
|
||||
triggerExit(onUnlock)
|
||||
} else {
|
||||
pinShake = true;
|
||||
pinEntry = "";
|
||||
setTimeout(() => (pinShake = false), 500);
|
||||
pinShake = true
|
||||
pinEntry = ''
|
||||
setTimeout(() => (pinShake = false), 500)
|
||||
}
|
||||
}
|
||||
|
||||
function onPinKey(e: KeyboardEvent) {
|
||||
if (e.key === "Enter") { submitPin(); return; }
|
||||
if (e.key === "Backspace") { pinEntry = pinEntry.slice(0, -1); return; }
|
||||
if (mode !== 'locked' || exitLock) return
|
||||
if (e.key === 'Enter') { e.preventDefault(); submitPin(); return }
|
||||
if (e.key === 'Backspace') { e.preventDefault(); pinEntry = pinEntry.slice(0, -1); return }
|
||||
if (/^\d$/.test(e.key)) {
|
||||
pinEntry = (pinEntry + e.key).slice(0, 8);
|
||||
if (pinEntry.length >= (settingsState.settings.appLockPin?.length ?? 4)) submitPin();
|
||||
e.preventDefault()
|
||||
pinEntry = (pinEntry + e.key).slice(0, 8)
|
||||
if (pinEntry.length >= pinLen) submitPin()
|
||||
}
|
||||
}
|
||||
|
||||
const EXIT_MS = 320;
|
||||
const PHASE1_TARGET = 0.85;
|
||||
const PHASE1_MS = 3000;
|
||||
const PHASE2_TARGET = 0.95;
|
||||
const PHASE2_MS = 10000;
|
||||
|
||||
let dots = $state("");
|
||||
let ringProg = $state(0.025);
|
||||
let exiting = $state(false);
|
||||
let exitLock = false;
|
||||
|
||||
function triggerExit(cb?: () => void) {
|
||||
if (exitLock) return;
|
||||
exitLock = true;
|
||||
exiting = true;
|
||||
setTimeout(() => cb?.(), EXIT_MS);
|
||||
}
|
||||
|
||||
let animFrame: number;
|
||||
let animStart: number | null = null;
|
||||
let animPhase = 1;
|
||||
|
||||
function animateRing(ts: number) {
|
||||
if (exitLock) return;
|
||||
if (animStart === null) animStart = ts;
|
||||
const elapsed = ts - animStart;
|
||||
if (animPhase === 1) {
|
||||
const t = Math.min(elapsed / PHASE1_MS, 1);
|
||||
ringProg = 0.025 + (1 - Math.pow(1 - t, 3)) * (PHASE1_TARGET - 0.025);
|
||||
if (t >= 1) { animPhase = 2; animStart = ts; }
|
||||
} else {
|
||||
const t = Math.min(elapsed / PHASE2_MS, 1);
|
||||
ringProg = PHASE1_TARGET + (1 - Math.pow(1 - t, 4)) * (PHASE2_TARGET - PHASE1_TARGET);
|
||||
}
|
||||
animFrame = requestAnimationFrame(animateRing);
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (mode === "loading" && !failed && !notConfigured && !ringFull) {
|
||||
animStart = null;
|
||||
animPhase = 1;
|
||||
animFrame = requestAnimationFrame(animateRing);
|
||||
return () => cancelAnimationFrame(animFrame);
|
||||
}
|
||||
});
|
||||
if (mode !== 'locked') return
|
||||
pinEntry = ''
|
||||
window.addEventListener('keydown', onPinKey)
|
||||
return () => window.removeEventListener('keydown', onPinKey)
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
if (!ringFull) {
|
||||
exitLock = false;
|
||||
exiting = false;
|
||||
return;
|
||||
}
|
||||
|
||||
cancelAnimationFrame(animFrame);
|
||||
animFrame = 0;
|
||||
ringProg = 1;
|
||||
if (lockEnabled && !pinUnlocked) {
|
||||
setTimeout(() => (pinVisible = true), 400);
|
||||
} else {
|
||||
setTimeout(() => triggerExit(onReady), 650);
|
||||
}
|
||||
});
|
||||
onMount(() => {
|
||||
const iv = setInterval(() => { dots = dots.length >= 3 ? '' : dots + '.' }, 420)
|
||||
|
||||
$effect(() => {
|
||||
const needsPin =
|
||||
(mode === "idle" && lockEnabled) ||
|
||||
(mode === "loading" && lockEnabled && ringFull && !pinUnlocked);
|
||||
if (!needsPin) return;
|
||||
window.addEventListener("keydown", onPinKey);
|
||||
return () => window.removeEventListener("keydown", onPinKey);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (pinUnlocked && mode !== "idle") triggerExit(onReady);
|
||||
});
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
const win = getCurrentWindow();
|
||||
uiScale = await win.scaleFactor();
|
||||
} catch {
|
||||
uiScale = window.devicePixelRatio || 1;
|
||||
}
|
||||
|
||||
const dotsInterval = setInterval(() => {
|
||||
dots = dots.length >= 3 ? "" : dots + ".";
|
||||
}, 420);
|
||||
|
||||
if (mode === "idle" && onDismiss) {
|
||||
if (lockEnabled) return () => clearInterval(dotsInterval);
|
||||
const handler = () => triggerExit(onDismiss);
|
||||
if (mode === 'idle' && onDismiss) {
|
||||
const handler = () => triggerExit(onDismiss)
|
||||
const t = setTimeout(() => {
|
||||
window.addEventListener("keydown", handler, { once: true });
|
||||
window.addEventListener("mousedown", handler, { once: true });
|
||||
window.addEventListener("touchstart", handler, { once: true });
|
||||
}, 200);
|
||||
window.addEventListener('keydown', handler, { once: true })
|
||||
window.addEventListener('mousedown', handler, { once: true })
|
||||
window.addEventListener('touchstart', handler, { once: true })
|
||||
}, 200)
|
||||
return () => {
|
||||
clearTimeout(t);
|
||||
clearInterval(dotsInterval);
|
||||
window.removeEventListener("keydown", handler);
|
||||
window.removeEventListener("mousedown", handler);
|
||||
window.removeEventListener("touchstart", handler);
|
||||
};
|
||||
clearTimeout(t)
|
||||
clearInterval(iv)
|
||||
window.removeEventListener('keydown', handler)
|
||||
window.removeEventListener('mousedown', handler)
|
||||
window.removeEventListener('touchstart', handler)
|
||||
}
|
||||
}
|
||||
return () => clearInterval(dotsInterval);
|
||||
});
|
||||
return () => clearInterval(iv)
|
||||
})
|
||||
|
||||
interface CardDef { cx: number; w: number; h: number; lines: number; alpha: number; speed: number; cycleSec: number; phase: number; travel: number; yStart: number; angleStart: number; tilt: number; }
|
||||
interface CardTrig { cosA: number; sinA: number; tiltRad: number; }
|
||||
interface RenderState { cards: CardDef[]; trigs: CardTrig[]; stamps: HTMLCanvasElement[]; vignette: HTMLCanvasElement; CW: number; CH: number; scale: number; }
|
||||
interface CardDef { cx: number; w: number; h: number; lines: number; alpha: number; speed: number; cycleSec: number; phase: number; travel: number; yStart: number; angleStart: number; tilt: number }
|
||||
interface CardTrig { cosA: number; sinA: number; tiltRad: number }
|
||||
interface RenderState { cards: CardDef[]; trigs: CardTrig[]; stamps: HTMLCanvasElement[]; vignette: HTMLCanvasElement; CW: number; CH: number; scale: number }
|
||||
|
||||
const LAYER_CFG = [
|
||||
{ wMin: 26, wMax: 40, speedMin: 30, speedMax: 50, alpha: 0.22 },
|
||||
{ wMin: 38, wMax: 56, speedMin: 52, speedMax: 80, alpha: 0.35 },
|
||||
{ wMin: 54, wMax: 76, speedMin: 85, speedMax: 120, alpha: 0.50 },
|
||||
] as const;
|
||||
] as const
|
||||
|
||||
const BUF = 80, COLS = 14;
|
||||
const BUF = 80, COLS = 14
|
||||
|
||||
function hash(n: number): number {
|
||||
let x = Math.imul(n ^ (n >>> 16), 0x45d9f3b);
|
||||
x = Math.imul(x ^ (x >>> 16), 0x45d9f3b);
|
||||
return ((x ^ (x >>> 16)) >>> 0) / 0xffffffff;
|
||||
let x = Math.imul(n ^ (n >>> 16), 0x45d9f3b)
|
||||
x = Math.imul(x ^ (x >>> 16), 0x45d9f3b)
|
||||
return ((x ^ (x >>> 16)) >>> 0) / 0xffffffff
|
||||
}
|
||||
|
||||
function buildCards(vw: number, vh: number) {
|
||||
const cards: CardDef[] = [];
|
||||
const laneW = vw / COLS;
|
||||
const cards: CardDef[] = []
|
||||
const laneW = vw / COLS
|
||||
for (let layer = 0; layer < 3; layer++) {
|
||||
const cfg = LAYER_CFG[layer];
|
||||
const cfg = LAYER_CFG[layer]
|
||||
for (let col = 0; col < COLS; col++) {
|
||||
const seed = col * 31 + layer * 97 + 7;
|
||||
const w = cfg.wMin + hash(seed + 1) * (cfg.wMax - cfg.wMin);
|
||||
const h = w * 1.44;
|
||||
const speed = cfg.speedMin + hash(seed + 5) * (cfg.speedMax - cfg.speedMin);
|
||||
const travel = vh + h + BUF;
|
||||
const seed = col * 31 + layer * 97 + 7
|
||||
const w = cfg.wMin + hash(seed + 1) * (cfg.wMax - cfg.wMin)
|
||||
const h = w * 1.44
|
||||
const speed = cfg.speedMin + hash(seed + 5) * (cfg.speedMax - cfg.speedMin)
|
||||
const travel = vh + h + BUF
|
||||
cards.push({
|
||||
cx: (col + 0.5) * laneW + (hash(seed + 2) * 2 - 1) * Math.max(0, (laneW - w) / 2 - 2),
|
||||
w, h,
|
||||
lines: 1 + Math.floor(hash(seed + 7) * 3),
|
||||
alpha: cfg.alpha,
|
||||
lines: 1 + Math.floor(hash(seed + 7) * 3),
|
||||
alpha: cfg.alpha,
|
||||
speed,
|
||||
cycleSec: travel / speed,
|
||||
phase: ((col / COLS) + hash(seed + 6) * 0.6 + layer * 0.23) % 1,
|
||||
cycleSec: travel / speed,
|
||||
phase: ((col / COLS) + hash(seed + 6) * 0.6 + layer * 0.23) % 1,
|
||||
travel,
|
||||
yStart: vh + h / 2 + BUF / 2,
|
||||
yStart: vh + h / 2 + BUF / 2,
|
||||
angleStart: hash(seed + 3) * 50 - 25,
|
||||
tilt: (hash(seed + 4) * 2 - 1) * 18,
|
||||
});
|
||||
tilt: (hash(seed + 4) * 2 - 1) * 18,
|
||||
})
|
||||
}
|
||||
}
|
||||
const trigs: CardTrig[] = cards.map(c => ({
|
||||
cosA: Math.cos(c.angleStart * (Math.PI / 180)),
|
||||
sinA: Math.sin(c.angleStart * (Math.PI / 180)),
|
||||
tiltRad: c.tilt * (Math.PI / 180),
|
||||
}));
|
||||
return { cards, trigs };
|
||||
}))
|
||||
return { cards, trigs }
|
||||
}
|
||||
|
||||
function rrect(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x + r, y); ctx.lineTo(x + w - r, y); ctx.arcTo(x + w, y, x + w, y + r, r);
|
||||
ctx.lineTo(x + w, y + h - r); ctx.arcTo(x + w, y + h, x + w - r, y + h, r);
|
||||
ctx.lineTo(x + r, y + h); ctx.arcTo(x, y + h, x, y + h - r, r);
|
||||
ctx.lineTo(x, y + r); ctx.arcTo(x, y, x + r, y, r);
|
||||
ctx.closePath();
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(x + r, y); ctx.lineTo(x + w - r, y); ctx.arcTo(x + w, y, x + w, y + r, r)
|
||||
ctx.lineTo(x + w, y + h - r); ctx.arcTo(x + w, y + h, x + w - r, y + h, r)
|
||||
ctx.lineTo(x + r, y + h); ctx.arcTo(x, y + h, x, y + h - r, r)
|
||||
ctx.lineTo(x, y + r); ctx.arcTo(x, y, x + r, y, r)
|
||||
ctx.closePath()
|
||||
}
|
||||
|
||||
const STAMP_PAD = 6;
|
||||
const STAMP_PAD = 6
|
||||
|
||||
function buildStamp(c: CardDef, dpr: number): HTMLCanvasElement {
|
||||
const oc = document.createElement("canvas");
|
||||
oc.width = Math.round(Math.ceil(c.w + STAMP_PAD * 2) * dpr);
|
||||
oc.height = Math.round(Math.ceil(c.h + STAMP_PAD * 2) * dpr);
|
||||
const ctx = oc.getContext("2d")!;
|
||||
ctx.scale(dpr, dpr);
|
||||
const x0 = STAMP_PAD, y0 = STAMP_PAD;
|
||||
const coverH = c.w * 0.72 * 1.05;
|
||||
const lineY0 = y0 + 3 + coverH + 5;
|
||||
ctx.fillStyle = "rgba(0,0,0,0.5)"; rrect(ctx, x0 + 2, y0 + 2, c.w, c.h, 4); ctx.fill();
|
||||
ctx.fillStyle = "rgba(255,255,255,0.07)"; rrect(ctx, x0, y0, c.w, c.h, 4); ctx.fill();
|
||||
ctx.strokeStyle = "rgba(255,255,255,0.75)";
|
||||
ctx.lineWidth = 1.2; rrect(ctx, x0, y0, c.w, c.h, 4); ctx.stroke();
|
||||
ctx.fillStyle = "rgba(255,255,255,0.15)"; rrect(ctx, x0 + 3, y0 + 3, c.w - 6, coverH, 3); ctx.fill();
|
||||
ctx.fillStyle = "rgba(255,255,255,0.08)"; rrect(ctx, x0 + 3, y0 + 3, (c.w - 6) * 0.45, coverH, 3); ctx.fill();
|
||||
const oc = document.createElement('canvas')
|
||||
oc.width = Math.round(Math.ceil(c.w + STAMP_PAD * 2) * dpr)
|
||||
oc.height = Math.round(Math.ceil(c.h + STAMP_PAD * 2) * dpr)
|
||||
const ctx = oc.getContext('2d')!
|
||||
ctx.scale(dpr, dpr)
|
||||
const x0 = STAMP_PAD, y0 = STAMP_PAD
|
||||
const coverH = c.w * 0.72 * 1.05
|
||||
const lineY0 = y0 + 3 + coverH + 5
|
||||
ctx.fillStyle = 'rgba(0,0,0,0.5)'; rrect(ctx, x0 + 2, y0 + 2, c.w, c.h, 4); ctx.fill()
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.07)'; rrect(ctx, x0, y0, c.w, c.h, 4); ctx.fill()
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.75)'; ctx.lineWidth = 1.2; rrect(ctx, x0, y0, c.w, c.h, 4); ctx.stroke()
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.15)'; rrect(ctx, x0 + 3, y0 + 3, c.w - 6, coverH, 3); ctx.fill()
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.08)'; rrect(ctx, x0 + 3, y0 + 3, (c.w - 6) * 0.45, coverH, 3); ctx.fill()
|
||||
for (let li = 0; li < c.lines; li++) {
|
||||
ctx.fillStyle = li === 0 ? "rgba(255,255,255,0.35)" : "rgba(255,255,255,0.20)";
|
||||
ctx.fillRect(x0 + 4, lineY0 + li * 8, (c.w - 8) * (li === 0 ? 0.78 : 0.52), li === 0 ? 3 : 2);
|
||||
ctx.fillStyle = li === 0 ? 'rgba(255,255,255,0.35)' : 'rgba(255,255,255,0.20)'
|
||||
ctx.fillRect(x0 + 4, lineY0 + li * 8, (c.w - 8) * (li === 0 ? 0.78 : 0.52), li === 0 ? 3 : 2)
|
||||
}
|
||||
return oc;
|
||||
return oc
|
||||
}
|
||||
|
||||
function buildVignette(vw: number, vh: number, dpr: number): HTMLCanvasElement {
|
||||
const oc = document.createElement("canvas");
|
||||
oc.width = Math.round(vw * dpr);
|
||||
oc.height = Math.round(vh * dpr);
|
||||
const ctx = oc.getContext("2d")!;
|
||||
ctx.scale(dpr, dpr);
|
||||
const g = ctx.createRadialGradient(vw / 2, vh / 2, 0, vw / 2, vh / 2, Math.max(vw, vh) * 0.65);
|
||||
g.addColorStop(0, "rgba(0,0,0,0)");
|
||||
g.addColorStop(0.4, "rgba(0,0,0,0)");
|
||||
g.addColorStop(0.7, "rgba(0,0,0,0.25)");
|
||||
g.addColorStop(1, "rgba(0,0,0,0.65)");
|
||||
ctx.fillStyle = g;
|
||||
ctx.fillRect(0, 0, vw, vh);
|
||||
return oc;
|
||||
const oc = document.createElement('canvas')
|
||||
oc.width = Math.round(vw * dpr)
|
||||
oc.height = Math.round(vh * dpr)
|
||||
const ctx = oc.getContext('2d')!
|
||||
ctx.scale(dpr, dpr)
|
||||
const g = ctx.createRadialGradient(vw / 2, vh / 2, 0, vw / 2, vh / 2, Math.max(vw, vh) * 0.65)
|
||||
g.addColorStop(0, 'rgba(0,0,0,0)')
|
||||
g.addColorStop(0.4, 'rgba(0,0,0,0)')
|
||||
g.addColorStop(0.7, 'rgba(0,0,0,0.25)')
|
||||
g.addColorStop(1, 'rgba(0,0,0,0.65)')
|
||||
ctx.fillStyle = g
|
||||
ctx.fillRect(0, 0, vw, vh)
|
||||
return oc
|
||||
}
|
||||
|
||||
function drawFrame(
|
||||
ctx: CanvasRenderingContext2D, t: number, cw: number, ch: number, dpr: number,
|
||||
cards: CardDef[], trigs: CardTrig[], stamps: HTMLCanvasElement[], vignette: HTMLCanvasElement,
|
||||
) {
|
||||
ctx.clearRect(0, 0, cw, ch);
|
||||
ctx.clearRect(0, 0, cw, ch)
|
||||
for (let i = 0; i < cards.length; i++) {
|
||||
const c = cards[i];
|
||||
const p = ((t / c.cycleSec) + c.phase) % 1;
|
||||
const alpha = p < 0.07 ? (p / 0.07) * c.alpha : p > 0.86 ? ((1 - p) / 0.14) * c.alpha : c.alpha;
|
||||
if (alpha < 0.005) continue;
|
||||
const cy = c.yStart - p * c.travel;
|
||||
const tg = trigs[i];
|
||||
const delta = tg.tiltRad * p;
|
||||
const cos = tg.cosA * Math.cos(delta) - tg.sinA * Math.sin(delta);
|
||||
const sin = tg.sinA * Math.cos(delta) + tg.cosA * Math.sin(delta);
|
||||
ctx.globalAlpha = alpha;
|
||||
ctx.setTransform(cos * dpr, sin * dpr, -sin * dpr, cos * dpr, c.cx * dpr, cy * dpr);
|
||||
const sw = stamps[i].width / dpr, sh = stamps[i].height / dpr;
|
||||
ctx.drawImage(stamps[i], -sw / 2, -sh / 2, sw, sh);
|
||||
const c = cards[i]
|
||||
const p = ((t / c.cycleSec) + c.phase) % 1
|
||||
const alpha = p < 0.07 ? (p / 0.07) * c.alpha : p > 0.86 ? ((1 - p) / 0.14) * c.alpha : c.alpha
|
||||
if (alpha < 0.005) continue
|
||||
const cy = c.yStart - p * c.travel
|
||||
const tg = trigs[i]
|
||||
const delta = tg.tiltRad * p
|
||||
const cos = tg.cosA * Math.cos(delta) - tg.sinA * Math.sin(delta)
|
||||
const sin = tg.sinA * Math.cos(delta) + tg.cosA * Math.sin(delta)
|
||||
ctx.globalAlpha = alpha
|
||||
ctx.setTransform(cos * dpr, sin * dpr, -sin * dpr, cos * dpr, c.cx * dpr, cy * dpr)
|
||||
const sw = stamps[i].width / dpr, sh = stamps[i].height / dpr
|
||||
ctx.drawImage(stamps[i], -sw / 2, -sh / 2, sw, sh)
|
||||
}
|
||||
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.drawImage(vignette, 0, 0, cw, ch);
|
||||
ctx.setTransform(1, 0, 0, 1, 0, 0)
|
||||
ctx.globalAlpha = 1
|
||||
ctx.drawImage(vignette, 0, 0, cw, ch)
|
||||
}
|
||||
|
||||
let fps = 0, fpsFrames = 0, fpsLast = 0;
|
||||
let fps = 0, fpsFrames = 0, fpsLast = 0
|
||||
function tickFps(now: number) {
|
||||
fpsFrames++;
|
||||
fpsFrames++
|
||||
if (now - fpsLast >= 500) {
|
||||
fps = Math.round(fpsFrames / ((now - fpsLast) / 1000));
|
||||
fpsFrames = 0;
|
||||
fpsLast = now;
|
||||
if (fpsEl) fpsEl.textContent = `${fps} fps`;
|
||||
fps = Math.round(fpsFrames / ((now - fpsLast) / 1000))
|
||||
fpsFrames = 0
|
||||
fpsLast = now
|
||||
if (fpsEl) fpsEl.textContent = `${fps} fps`
|
||||
}
|
||||
}
|
||||
|
||||
function mountCanvas(el: HTMLCanvasElement) {
|
||||
const ctx = el.getContext("2d")!;
|
||||
let live: RenderState | null = null;
|
||||
let lastLogW = 0, lastLogH = 0, lastScale = 0, buildGen = 0;
|
||||
const ctx = el.getContext('2d')!
|
||||
let live: RenderState | null = null
|
||||
let lastLogW = 0, lastLogH = 0, lastScale = 0, buildGen = 0
|
||||
|
||||
async function syncSize() {
|
||||
const gen = ++buildGen;
|
||||
const scale = window.devicePixelRatio || 1;
|
||||
const logW = el.offsetWidth || el.parentElement?.offsetWidth || 800;
|
||||
const logH = el.offsetHeight || el.parentElement?.offsetHeight || 600;
|
||||
const phys = { width: Math.round(logW * scale), height: Math.round(logH * scale) };
|
||||
if (gen !== buildGen) return;
|
||||
if (logW === lastLogW && logH === lastLogH && scale === lastScale) return;
|
||||
lastLogW = logW; lastLogH = logH; lastScale = scale;
|
||||
const built = buildCards(logW, logH);
|
||||
const stamps = built.cards.map(c => buildStamp(c, scale));
|
||||
const vig = buildVignette(logW, logH, scale);
|
||||
el.width = phys.width; el.height = phys.height;
|
||||
live = { cards: built.cards, trigs: built.trigs, stamps, vignette: vig, CW: phys.width, CH: phys.height, scale };
|
||||
function applySize(logW: number, logH: number, scale: number) {
|
||||
const gen = ++buildGen
|
||||
if (logW <= 0 || logH <= 0) return
|
||||
if (logW === lastLogW && logH === lastLogH && scale === lastScale) return
|
||||
lastLogW = logW; lastLogH = logH; lastScale = scale
|
||||
const built = buildCards(logW, logH)
|
||||
const stamps = built.cards.map(c => buildStamp(c, scale))
|
||||
const vig = buildVignette(logW, logH, scale)
|
||||
el.width = Math.round(logW * scale)
|
||||
el.height = Math.round(logH * scale)
|
||||
if (gen === buildGen) live = { cards: built.cards, trigs: built.trigs, stamps, vignette: vig, CW: el.width, CH: el.height, scale }
|
||||
}
|
||||
|
||||
const ro = new ResizeObserver(() => syncSize());
|
||||
ro.observe(el);
|
||||
syncSize();
|
||||
let extraCleanup: (() => void) | undefined
|
||||
|
||||
let raf = 0, t0 = -1, paused = false;
|
||||
if (isTauri) {
|
||||
let tauriRo: ResizeObserver | undefined
|
||||
let tauriUnlisten: (() => void) | undefined
|
||||
import('@tauri-apps/api/window').then(({ getCurrentWindow }) => {
|
||||
const win = getCurrentWindow()
|
||||
const doSync = () => Promise.all([win.innerSize(), win.scaleFactor()])
|
||||
.then(([phys, scale]) => applySize(phys.width / scale, phys.height / scale, scale))
|
||||
doSync()
|
||||
tauriRo = new ResizeObserver(() => doSync())
|
||||
tauriRo.observe(el)
|
||||
win.onFocusChanged(() => doSync()).then(u => { tauriUnlisten = u })
|
||||
})
|
||||
extraCleanup = () => { tauriRo?.disconnect(); tauriUnlisten?.() }
|
||||
} else {
|
||||
const syncWeb = () => applySize(el.clientWidth, el.clientHeight, window.devicePixelRatio || 1)
|
||||
const ro = new ResizeObserver(() => syncWeb())
|
||||
ro.observe(el)
|
||||
requestAnimationFrame(() => syncWeb())
|
||||
extraCleanup = () => ro.disconnect()
|
||||
}
|
||||
|
||||
let raf = 0, t0 = -1, paused = false
|
||||
|
||||
function frame(now: number) {
|
||||
if (paused) { raf = 0; return; }
|
||||
raf = requestAnimationFrame(frame);
|
||||
if (!live) return;
|
||||
if (t0 < 0) t0 = now;
|
||||
if (showFps) tickFps(now);
|
||||
const { cards, trigs, stamps, vignette, CW, CH, scale } = live;
|
||||
drawFrame(ctx, (now - t0) / 1000, CW, CH, scale, cards, trigs, stamps, vignette);
|
||||
if (paused) { raf = 0; return }
|
||||
raf = requestAnimationFrame(frame)
|
||||
if (!live) return
|
||||
const { cards, trigs, stamps, vignette, CW, CH, scale } = live
|
||||
if (CW <= 0 || CH <= 0 || vignette.width <= 0 || vignette.height <= 0) return
|
||||
if (stamps.some(s => s.width <= 0 || s.height <= 0)) return
|
||||
if (t0 < 0) t0 = now
|
||||
if (showFps) tickFps(now)
|
||||
drawFrame(ctx, (now - t0) / 1000, CW, CH, scale, cards, trigs, stamps, vignette)
|
||||
}
|
||||
|
||||
function pause() { paused = true; t0 = -1; }
|
||||
function resume() { if (!paused) return; paused = false; raf = requestAnimationFrame(frame); }
|
||||
function pause() { paused = true; t0 = -1 }
|
||||
function resume() { if (!paused) return; paused = false; raf = requestAnimationFrame(frame) }
|
||||
function onVis() { document.hidden ? pause() : resume() }
|
||||
|
||||
function onVisibility() { document.hidden ? pause() : resume(); }
|
||||
|
||||
document.addEventListener("visibilitychange", onVisibility);
|
||||
|
||||
let unlistenFocus: Promise<() => void> | null = null;
|
||||
try {
|
||||
const win = getCurrentWindow();
|
||||
unlistenFocus = win.onFocusChanged(({ payload: focused }) => {
|
||||
focused ? resume() : pause();
|
||||
});
|
||||
} catch { }
|
||||
|
||||
raf = requestAnimationFrame(frame);
|
||||
document.addEventListener('visibilitychange', onVis)
|
||||
raf = requestAnimationFrame(frame)
|
||||
return () => {
|
||||
cancelAnimationFrame(raf);
|
||||
ro.disconnect();
|
||||
document.removeEventListener("visibilitychange", onVisibility);
|
||||
unlistenFocus?.then(f => f());
|
||||
};
|
||||
cancelAnimationFrame(raf)
|
||||
extraCleanup?.()
|
||||
document.removeEventListener('visibilitychange', onVis)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="splash" class:exiting style="cursor: {mode === 'idle' && !lockEnabled ? 'pointer' : 'default'}">
|
||||
<div class="splash" class:exiting style="cursor:{mode === 'idle' ? 'pointer' : 'default'}">
|
||||
{#if showCards}
|
||||
<canvas style="position:absolute;inset:0;pointer-events:none;width:100%;height:100%" use:mountCanvas></canvas>
|
||||
{#if showFps}
|
||||
@@ -386,26 +357,7 @@
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if mode === "idle" && lockEnabled}
|
||||
<div style="z-index:1;display:flex;flex-direction:column;align-items:center;gap:var(--sp-6)">
|
||||
<div style="position:relative;width:{logoLockSize}px;height:{logoLockSize}px">
|
||||
<div class="logo-glow"></div>
|
||||
<img src={logoUrl} alt="Moku" class="logo-breathe" style="width:{logoLockSize}px;height:{logoLockSize}px;border-radius:22px;display:block;position:relative" />
|
||||
</div>
|
||||
<div class="pin-card">
|
||||
<p class="pin-label">Enter PIN</p>
|
||||
<div class="pin-block">
|
||||
<div class="pin-dots" class:pin-shake={pinShake}>
|
||||
{#each Array(settingsState.settings.appLockPin?.length ?? 4) as _, i}
|
||||
<div class="pin-dot" class:pin-dot-filled={i < pinEntry.length}></div>
|
||||
{/each}
|
||||
</div>
|
||||
<button class="pin-submit-btn" onclick={submitPin} tabindex="-1" aria-label="Submit PIN">Unlock</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{:else if mode === "idle"}
|
||||
{#if mode === 'idle'}
|
||||
<div style="z-index:1;display:flex;flex-direction:column;align-items:center">
|
||||
<div style="position:relative;width:{logoIdleSize}px;height:{logoIdleSize}px;margin-bottom:32px">
|
||||
<div class="logo-glow"></div>
|
||||
@@ -414,60 +366,53 @@
|
||||
<p class="hint">press any key to continue</p>
|
||||
</div>
|
||||
|
||||
{:else if mode === 'locked'}
|
||||
<div class="pin-card" class:pin-card--leaving={exiting}>
|
||||
<div class="logo-wrap">
|
||||
<div class="logo-glow"></div>
|
||||
<img src={logoUrl} alt="Moku" class="logo-breathe" style="width:56px;height:56px;border-radius:14px;display:block;position:relative" />
|
||||
</div>
|
||||
<p class="pin-label">Enter PIN</p>
|
||||
<div class="pin-dots" class:pin-shake={pinShake}>
|
||||
{#each Array(pinLen) as _, i}
|
||||
<div class="pin-dot" class:filled={i < pinEntry.length}></div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{:else}
|
||||
<div style="position:relative;width:{ringSize}px;height:{ringSize}px;margin-bottom:20px;display:flex;align-items:center;justify-content:center">
|
||||
{#if !failed && !notConfigured}
|
||||
<svg width={ringSize} height={ringSize}
|
||||
class="loading-ring"
|
||||
class:ring-hide={lockEnabled && pinVisible}
|
||||
style="position:absolute;top:0;left:0;pointer-events:none">
|
||||
<svg width={ringSize} height={ringSize} class="loading-ring" style="position:absolute;top:0;left:0;pointer-events:none">
|
||||
<circle cx={ringC} cy={ringC} r={ringR} fill="none" stroke="var(--border-base)" stroke-width="2" />
|
||||
<circle cx={ringC} cy={ringC} r={ringR} fill="none" stroke="var(--accent)" stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-dasharray="{ringArc} {ringCirc}"
|
||||
transform="rotate(-90 {ringC} {ringC})"
|
||||
style="transition: stroke-dasharray 0.4s cubic-bezier(0.4,0,0.2,1)" />
|
||||
style="transition:stroke-dasharray 0.4s cubic-bezier(0.4,0,0.2,1)" />
|
||||
</svg>
|
||||
{/if}
|
||||
<img src={logoUrl} alt="Moku" style="width:{logoLoadingSize}px;height:{logoLoadingSize}px;border-radius:32px;display:block;position:relative" />
|
||||
</div>
|
||||
<div class="bottom-area" style="z-index:1">
|
||||
<div class="status-slot" class:status-slot-hide={lockEnabled && pinVisible}>
|
||||
{#if failed || notConfigured}
|
||||
<div class="error-box anim-fade-up">
|
||||
<p class="error-label">{failed ? "Could not reach server" : "Server not configured"}</p>
|
||||
<div class="error-actions">
|
||||
<button class="err-btn" onclick={() => onRetry?.()}>Retry</button>
|
||||
<button class="err-btn err-btn--primary" onclick={() => onBypass?.()}>Enter app</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="status-text">{ringFull ? "" : `Initializing server${dots}`}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if lockEnabled}
|
||||
<div class="pin-slot" class:pin-slot-visible={pinVisible}>
|
||||
<div class="pin-card">
|
||||
<p class="pin-label">Enter PIN</p>
|
||||
<div class="pin-block">
|
||||
<div class="pin-dots" class:pin-shake={pinShake}>
|
||||
{#each Array(settingsState.settings.appLockPin?.length ?? 4) as _, i}
|
||||
<div class="pin-dot" class:pin-dot-filled={i < pinEntry.length}></div>
|
||||
{/each}
|
||||
</div>
|
||||
<button class="pin-submit-btn" onclick={submitPin} tabindex="-1" aria-label="Submit PIN">Unlock</button>
|
||||
</div>
|
||||
{#if failed || notConfigured}
|
||||
<div class="error-box anim-fade-up">
|
||||
<p class="error-label">{failed ? 'Could not reach server' : 'Server not configured'}</p>
|
||||
<div class="error-actions">
|
||||
<button class="err-btn" onclick={() => onRetry?.()}>Retry</button>
|
||||
<button class="err-btn err-btn--primary" onclick={() => onBypass?.()}>Enter app</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="status-text">{ringFull ? '' : `Initializing server${dots}`}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.splash { position: fixed; inset: 0; z-index: 9999; background: var(--bg-base); overflow: hidden; display: flex; flex-direction: column; align-items: center; justify-content: center; animation: spIn 0.35s cubic-bezier(0,0,0.2,1) both; }
|
||||
.splash.exiting { animation: spOut 320ms cubic-bezier(0.4,0,1,1) both; }
|
||||
.splash { position:fixed; inset:0; z-index:9999; background:var(--bg-base); overflow:hidden; display:flex; flex-direction:column; align-items:center; justify-content:center; animation:spIn 0.35s cubic-bezier(0,0,0.2,1) both; }
|
||||
.exiting { animation:spOut 320ms cubic-bezier(0.4,0,1,1) both; }
|
||||
|
||||
@keyframes spIn { from { opacity:0; transform:scale(1.015) } to { opacity:1; transform:scale(1) } }
|
||||
@keyframes spOut { from { opacity:1; transform:scale(1) } to { opacity:0; transform:scale(0.96) } }
|
||||
@@ -475,33 +420,33 @@
|
||||
@keyframes hintFade { 0%,100% { opacity:0.35 } 50% { opacity:0.7 } }
|
||||
@keyframes pinShake { 0%,100% { transform:translateX(0) } 20%,60% { transform:translateX(-6px) } 40%,80% { transform:translateX(6px) } }
|
||||
|
||||
.logo-glow { position: absolute; inset: -20px; border-radius: 50%; background: radial-gradient(circle, rgba(255,255,255,0.06) 0%, transparent 70%); animation: logoBreathe 4s ease-in-out infinite; }
|
||||
.logo-breathe { animation: logoBreathe 4s ease-in-out infinite; }
|
||||
.hint { font-family: var(--font-ui); font-size: 10px; color: var(--text-faint); letter-spacing: 0.22em; text-transform: uppercase; margin: 0; user-select: none; animation: hintFade 3.5s ease-in-out infinite; }
|
||||
.logo-glow { position:absolute; inset:-20px; border-radius:50%; background:radial-gradient(circle, rgba(255,255,255,0.06) 0%, transparent 70%); animation:logoBreathe 4s ease-in-out infinite; }
|
||||
.logo-breathe { animation:logoBreathe 4s ease-in-out infinite; }
|
||||
.hint { font-family:var(--font-ui); font-size:10px; color:var(--text-faint); letter-spacing:0.22em; text-transform:uppercase; margin:0; user-select:none; animation:hintFade 3.5s ease-in-out infinite; }
|
||||
|
||||
.error-box { display: flex; flex-direction: column; align-items: center; gap: 12px; padding: 16px 20px; border-radius: var(--radius-lg); background: var(--bg-surface); border: 1px solid var(--border-base); min-width: 200px; text-align: center; }
|
||||
.error-label { font-family: var(--font-ui); font-size: 11px; font-weight: 500; color: var(--text-muted); letter-spacing: 0.06em; margin: 0; }
|
||||
.error-actions { display: flex; gap: 6px; }
|
||||
.err-btn { padding: 5px 14px; border-radius: var(--radius-md); border: 1px solid var(--border-base); background: transparent; color: var(--text-muted); cursor: pointer; font-family: var(--font-ui); font-size: 11px; letter-spacing: 0.04em; transition: border-color 0.15s, color 0.15s; }
|
||||
.err-btn:hover { border-color: var(--border-strong); color: var(--text-secondary); }
|
||||
.err-btn--primary { border-color: var(--accent-dim); color: var(--accent-fg); background: var(--accent-muted); }
|
||||
.err-btn--primary:hover { border-color: var(--accent); color: var(--accent-bright); }
|
||||
.logo-wrap { position:relative; width:72px; height:72px; display:flex; align-items:center; justify-content:center; }
|
||||
|
||||
.bottom-area { display: flex; align-items: center; justify-content: center; min-height: 48px; position: relative; }
|
||||
.status-slot { display: flex; align-items: center; justify-content: center; transition: opacity 0.35s ease; position: absolute; }
|
||||
.status-slot-hide { opacity: 0; pointer-events: none; }
|
||||
.status-text { font-family: var(--font-ui); font-size: 10px; color: var(--text-faint); letter-spacing: 0.12em; margin: 0; min-width: 160px; text-align: center; }
|
||||
.loading-ring { transition: opacity 0.5s ease; }
|
||||
.ring-hide { opacity: 0; }
|
||||
.pin-card { z-index:1; width:min(280px,calc(100vw - 48px)); background:var(--bg-surface); border:1px solid var(--border-base); border-radius:var(--radius-xl); padding:var(--sp-6) var(--sp-5); display:flex; flex-direction:column; align-items:center; gap:var(--sp-3); box-shadow:0 32px 80px rgba(0,0,0,0.75); animation:cardIn 0.38s cubic-bezier(0.22,1,0.36,1) 0.06s both; }
|
||||
.pin-card--leaving { animation:cardOut 0.28s cubic-bezier(0.4,0,1,1) both; }
|
||||
|
||||
.pin-slot { display: flex; flex-direction: column; align-items: center; gap: var(--sp-3); position: absolute; opacity: 0; transform: translateY(4px); transition: opacity 0.4s ease, transform 0.4s ease; pointer-events: none; }
|
||||
.pin-slot-visible { opacity: 1; transform: translateY(0); pointer-events: auto; }
|
||||
.pin-card { background: var(--bg-surface); border: 1px solid var(--border-base); border-radius: var(--radius-xl); padding: var(--sp-5) var(--sp-6); display: flex; flex-direction: column; align-items: center; gap: var(--sp-3); box-shadow: 0 24px 60px rgba(0,0,0,0.6); }
|
||||
.pin-label { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; margin: 0; }
|
||||
.pin-block { display: flex; flex-direction: column; align-items: center; gap: var(--sp-3); position: relative; }
|
||||
.pin-dots { display: flex; gap: 12px; align-items: center; }
|
||||
.pin-dot { width: 10px; height: 10px; border-radius: 50%; border: 1px solid var(--border-strong); background: transparent; transition: background 0.12s, border-color 0.12s; }
|
||||
.pin-dot-filled { background: var(--accent); border-color: var(--accent); }
|
||||
.pin-shake { animation: pinShake 0.42s ease; }
|
||||
.pin-submit-btn { opacity: 0; width: 1px; height: 1px; overflow: hidden; padding: 0; border: none; background: none; cursor: pointer; pointer-events: auto; position: absolute; }
|
||||
.pin-label { font-family:var(--font-ui); font-size:var(--text-xs); color:var(--text-faint); letter-spacing:var(--tracking-wider); text-transform:uppercase; margin:0; }
|
||||
.pin-dots { display:flex; gap:12px; align-items:center; }
|
||||
.pin-dot { width:10px; height:10px; border-radius:50%; border:1px solid var(--border-strong); background:transparent; transition:background 0.12s, border-color 0.12s; }
|
||||
.pin-dot.filled { background:var(--accent); border-color:var(--accent); }
|
||||
.pin-shake { animation:pinShake 0.42s ease; }
|
||||
|
||||
@keyframes cardIn { from { opacity:0; transform:translateY(28px) scale(0.97) } to { opacity:1; transform:translateY(0) scale(1) } }
|
||||
@keyframes cardOut { from { opacity:1; transform:translateY(0) scale(1) } to { opacity:0; transform:translateY(18px) scale(0.97) } }
|
||||
|
||||
.bottom-area { display:flex; align-items:center; justify-content:center; min-height:48px; }
|
||||
.status-text { font-family:var(--font-ui); font-size:10px; color:var(--text-faint); letter-spacing:0.12em; margin:0; min-width:160px; text-align:center; }
|
||||
.loading-ring { transition:opacity 0.5s ease; }
|
||||
|
||||
.error-box { display:flex; flex-direction:column; align-items:center; gap:12px; padding:16px 20px; border-radius:var(--radius-lg); background:var(--bg-surface); border:1px solid var(--border-base); min-width:200px; text-align:center; }
|
||||
.error-label { font-family:var(--font-ui); font-size:11px; font-weight:500; color:var(--text-muted); letter-spacing:0.06em; margin:0; }
|
||||
.error-actions { display:flex; gap:6px; }
|
||||
.err-btn { padding:5px 14px; border-radius:var(--radius-md); border:1px solid var(--border-base); background:transparent; color:var(--text-muted); cursor:pointer; font-family:var(--font-ui); font-size:11px; letter-spacing:0.04em; transition:border-color 0.15s, color 0.15s; }
|
||||
.err-btn:hover { border-color:var(--border-strong); color:var(--text-secondary); }
|
||||
.err-btn--primary { border-color:var(--accent-dim); color:var(--accent-fg); background:var(--accent-muted); }
|
||||
.err-btn--primary:hover { border-color:var(--accent); color:var(--accent-bright); }
|
||||
</style>
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { ArrowLeft, MagnifyingGlass, GearSix, Swap, Funnel, Check } from "phosphor-svelte";
|
||||
import { ArrowLeft, MagnifyingGlass, GearSix, Swap, Funnel, Check, CircleNotch } from "phosphor-svelte";
|
||||
import Thumbnail from "$lib/components/shared/manga/Thumbnail.svelte";
|
||||
import { resolvedCover } from "$lib/core/cover/coverResolver";
|
||||
import { getAdapter } from "$lib/request-manager";
|
||||
@@ -25,9 +25,22 @@
|
||||
|
||||
let { pkgName, extensionName, iconUrl, cols, cropCovers, statsAlways, anims, sources, onBack, onSettings }: Props = $props();
|
||||
|
||||
const isLocal = pkgName === '__local__';
|
||||
|
||||
// ── Library mode state ──────────────────────────────────────────────
|
||||
let groups: SourceLibrary[] = $state([]);
|
||||
let sourceNodes: SourceNode[] = $state([]);
|
||||
|
||||
// ── Local/browse mode state ──────────────────────────────────────────
|
||||
let localItems: any[] = $state([]);
|
||||
let localPage: number = $state(1);
|
||||
let localHasNext: boolean = $state(false);
|
||||
let localLoadingMore: boolean = $state(false);
|
||||
|
||||
// ── Shared state ─────────────────────────────────────────────────────
|
||||
let loading = $state(true);
|
||||
let search = $state("");
|
||||
let searchInput = $state("");
|
||||
|
||||
type ContentFilter = "unread" | "downloaded";
|
||||
let activeFilters = $state<Partial<Record<ContentFilter, boolean>>>({});
|
||||
@@ -37,35 +50,80 @@
|
||||
|
||||
let migrateTarget: { sourceId: string; sourceName: string; iconUrl: string; manga: LibraryManga[] } | null = $state(null);
|
||||
|
||||
const allManga = $derived(groups.flatMap(g => g.manga));
|
||||
// ── Derived filtered lists ────────────────────────────────────────────
|
||||
const allManga = $derived(isLocal ? localItems : groups.flatMap(g => g.manga));
|
||||
|
||||
const filtered = $derived((() => {
|
||||
let items = allManga;
|
||||
const q = search.trim().toLowerCase();
|
||||
if (q) items = items.filter(m => m.title.toLowerCase().includes(q));
|
||||
if (activeFilters.unread) items = items.filter(m => m.unreadCount > 0);
|
||||
if (activeFilters.downloaded) items = items.filter(m => m.downloadCount > 0);
|
||||
if (q && !isLocal) items = items.filter((m: any) => m.title.toLowerCase().includes(q));
|
||||
if (!isLocal) {
|
||||
if (activeFilters.unread) items = items.filter((m: any) => m.unreadCount > 0);
|
||||
if (activeFilters.downloaded) items = items.filter((m: any) => m.downloadCount > 0);
|
||||
}
|
||||
return items;
|
||||
})());
|
||||
|
||||
let sourceNodes: SourceNode[] = $state([]);
|
||||
|
||||
$effect(() => { load(); });
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
try {
|
||||
const [libData, srcData] = await Promise.all([
|
||||
getAdapter().getMangaList({}).then(r => ({ mangas: { nodes: r.items as any } })),
|
||||
getAdapter().getSources().then(nodes => ({ sources: { nodes } })),
|
||||
]);
|
||||
sourceNodes = srcData.sources.nodes;
|
||||
groups = libraryByExtension(libData.mangas.nodes, srcData.sources.nodes, pkgName);
|
||||
if (isLocal) {
|
||||
localPage = 1;
|
||||
localItems = [];
|
||||
const result = await getAdapter().browseSource('0', 1);
|
||||
localItems = result.items;
|
||||
localHasNext = result.hasNextPage;
|
||||
localPage = 1;
|
||||
} else {
|
||||
const [libData, srcData] = await Promise.all([
|
||||
getAdapter().getMangaList({}).then(r => ({ mangas: { nodes: r.items as any } })),
|
||||
getAdapter().getSources().then(nodes => ({ sources: { nodes } })),
|
||||
]);
|
||||
sourceNodes = srcData.sources.nodes;
|
||||
groups = libraryByExtension(libData.mangas.nodes, srcData.sources.nodes, pkgName);
|
||||
}
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMoreLocal() {
|
||||
if (localLoadingMore || !localHasNext) return;
|
||||
localLoadingMore = true;
|
||||
try {
|
||||
const next = localPage + 1;
|
||||
const result = await getAdapter().browseSource('0', next);
|
||||
localItems = [...localItems, ...result.items];
|
||||
localHasNext = result.hasNextPage;
|
||||
localPage = next;
|
||||
} finally {
|
||||
localLoadingMore = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function searchLocal() {
|
||||
const q = searchInput.trim();
|
||||
if (!q) { load(); return; }
|
||||
loading = true;
|
||||
try {
|
||||
const result = await getAdapter().searchSource('0', q, 1);
|
||||
localItems = result.items;
|
||||
localHasNext = result.hasNextPage;
|
||||
localPage = 1;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
search = q;
|
||||
}
|
||||
|
||||
function onSearchKeydown(e: KeyboardEvent) {
|
||||
if (!isLocal) return;
|
||||
if (e.key === 'Enter') searchLocal();
|
||||
if (e.key === 'Escape') { searchInput = ''; search = ''; load(); }
|
||||
}
|
||||
|
||||
function toggleFilter(f: ContentFilter) {
|
||||
activeFilters = { ...activeFilters, [f]: !activeFilters[f] };
|
||||
}
|
||||
@@ -108,58 +166,72 @@
|
||||
<Thumbnail src={iconUrl} alt={extensionName} class="header-icon" onerror={(e) => ((e.target as HTMLImageElement).style.display = "none")} />
|
||||
{/if}
|
||||
<div class="title-block">
|
||||
<span class="eyebrow">In Library</span>
|
||||
<span class="eyebrow">{isLocal ? 'Local Source' : 'In Library'}</span>
|
||||
<span class="title">{extensionName}</span>
|
||||
</div>
|
||||
{#if !loading}
|
||||
<span class="count-badge">{filtered.length}{filtered.length !== allManga.length ? ` / ${allManga.length}` : ""}</span>
|
||||
<span class="count-badge">
|
||||
{isLocal ? allManga.length + (localHasNext ? '+' : '') : `${filtered.length}${filtered.length !== allManga.length ? ` / ${allManga.length}` : ''}`}
|
||||
</span>
|
||||
{/if}
|
||||
<div class="header-right">
|
||||
<div class="search-wrap">
|
||||
<MagnifyingGlass size={12} class="search-icon" weight="light" />
|
||||
<input class="search" placeholder="Search" bind:value={search} autocomplete="off" />
|
||||
</div>
|
||||
|
||||
<div class="filter-wrap">
|
||||
<button
|
||||
class="filter-btn"
|
||||
class:filter-btn-active={hasActiveFilters}
|
||||
title="Filter"
|
||||
onclick={() => filterOpen = !filterOpen}
|
||||
>
|
||||
<Funnel size={13} weight={hasActiveFilters ? "fill" : "bold"} />
|
||||
</button>
|
||||
{#if filterOpen}
|
||||
<div class="filter-panel" role="menu">
|
||||
<div class="filter-panel-header">
|
||||
<span class="panel-heading">Filter</span>
|
||||
{#if hasActiveFilters}
|
||||
<button class="panel-clear-btn" onclick={clearFilters}>Clear all</button>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="panel-divider"></div>
|
||||
<p class="panel-label">Content</p>
|
||||
{#each CONTENT_FILTERS as [f, label]}
|
||||
<button
|
||||
class="panel-item"
|
||||
class:panel-item-active={activeFilters[f]}
|
||||
role="menuitem"
|
||||
onclick={() => toggleFilter(f)}
|
||||
>
|
||||
<span class="panel-check" class:panel-check-on={activeFilters[f]}>
|
||||
{#if activeFilters[f]}<Check size={9} weight="bold" />{/if}
|
||||
</span>
|
||||
{label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{#if isLocal}
|
||||
<input
|
||||
class="search"
|
||||
placeholder="Search…"
|
||||
bind:value={searchInput}
|
||||
autocomplete="off"
|
||||
onkeydown={onSearchKeydown}
|
||||
/>
|
||||
{:else}
|
||||
<input class="search" placeholder="Search" bind:value={search} autocomplete="off" />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if sources.length > 0}
|
||||
<button class="settings-btn" onclick={onSettings} title="Extension settings">
|
||||
<GearSix size={14} weight="bold" />
|
||||
</button>
|
||||
{#if !isLocal}
|
||||
<div class="filter-wrap">
|
||||
<button
|
||||
class="filter-btn"
|
||||
class:filter-btn-active={hasActiveFilters}
|
||||
title="Filter"
|
||||
onclick={() => filterOpen = !filterOpen}
|
||||
>
|
||||
<Funnel size={13} weight={hasActiveFilters ? "fill" : "bold"} />
|
||||
</button>
|
||||
{#if filterOpen}
|
||||
<div class="filter-panel" role="menu">
|
||||
<div class="filter-panel-header">
|
||||
<span class="panel-heading">Filter</span>
|
||||
{#if hasActiveFilters}
|
||||
<button class="panel-clear-btn" onclick={clearFilters}>Clear all</button>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="panel-divider"></div>
|
||||
<p class="panel-label">Content</p>
|
||||
{#each CONTENT_FILTERS as [f, label]}
|
||||
<button
|
||||
class="panel-item"
|
||||
class:panel-item-active={activeFilters[f]}
|
||||
role="menuitem"
|
||||
onclick={() => toggleFilter(f)}
|
||||
>
|
||||
<span class="panel-check" class:panel-check-on={activeFilters[f]}>
|
||||
{#if activeFilters[f]}<Check size={9} weight="bold" />{/if}
|
||||
</span>
|
||||
{label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if sources.length > 0}
|
||||
<button class="settings-btn" onclick={onSettings} title="Extension settings">
|
||||
<GearSix size={14} weight="bold" />
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -176,10 +248,14 @@
|
||||
</div>
|
||||
{:else if filtered.length === 0}
|
||||
<div class="empty">
|
||||
{allManga.length === 0 ? "Nothing from this extension is in your library." : "No matches."}
|
||||
{isLocal
|
||||
? 'No manga found in local source. Add manga folders to your local source directory.'
|
||||
: allManga.length === 0
|
||||
? 'Nothing from this extension is in your library.'
|
||||
: 'No matches.'}
|
||||
</div>
|
||||
{:else}
|
||||
{#if groups.length > 1}
|
||||
{#if !isLocal && groups.length > 1}
|
||||
<div class="source-groups">
|
||||
{#each groups as group}
|
||||
<div class="source-group-header">
|
||||
@@ -192,7 +268,7 @@
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if groups.length === 1}
|
||||
{:else if !isLocal && groups.length === 1}
|
||||
<div class="single-source-bar">
|
||||
<span class="source-group-name">{groups[0].displayName}</span>
|
||||
<button class="migrate-btn" onclick={() => openMigrate(groups[0])} title="Migrate this source">
|
||||
@@ -214,23 +290,38 @@
|
||||
style="object-fit:{cropCovers ? 'cover' : 'contain'}"
|
||||
draggable="false"
|
||||
/>
|
||||
<div class="card-info-overlay" class:anim={anims} class:instant={!anims} class:always={statsAlways}>
|
||||
<div class="overlay-badges">
|
||||
{#if isCompleted}
|
||||
<span class="badge badge-done">✓ Done</span>
|
||||
{:else if m.unreadCount}
|
||||
<span class="badge badge-unread">{m.unreadCount} new</span>
|
||||
{/if}
|
||||
{#if m.downloadCount}
|
||||
<span class="badge badge-dl">↓ {m.downloadCount}</span>
|
||||
{/if}
|
||||
{#if !isLocal}
|
||||
<div class="card-info-overlay" class:anim={anims} class:instant={!anims} class:always={statsAlways}>
|
||||
<div class="overlay-badges">
|
||||
{#if isCompleted}
|
||||
<span class="badge badge-done">✓ Done</span>
|
||||
{:else if m.unreadCount}
|
||||
<span class="badge badge-unread">{m.unreadCount} new</span>
|
||||
{/if}
|
||||
{#if m.downloadCount}
|
||||
<span class="badge badge-dl">↓ {m.downloadCount}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="card-title">{m.title}</p>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if isLocal && localHasNext}
|
||||
<div class="load-more">
|
||||
<button class="load-more-btn" onclick={loadMoreLocal} disabled={localLoadingMore}>
|
||||
{#if localLoadingMore}
|
||||
<CircleNotch size={13} weight="light" class="anim-spin" />
|
||||
Loading…
|
||||
{:else}
|
||||
Load more
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -330,7 +421,12 @@
|
||||
.cover-skeleton { aspect-ratio: 2/3; border-radius: var(--radius-md); }
|
||||
.title-skeleton { height: 12px; margin-top: var(--sp-2); width: 80%; border-radius: var(--radius-sm); }
|
||||
|
||||
.empty { display: flex; align-items: center; justify-content: center; height: 60%; color: var(--text-muted); font-size: var(--text-sm); }
|
||||
.empty { display: flex; align-items: center; justify-content: center; height: 60%; color: var(--text-muted); font-size: var(--text-sm); text-align: center; padding: 0 var(--sp-6); }
|
||||
|
||||
.load-more { display: flex; justify-content: center; padding: var(--sp-4) 0; }
|
||||
.load-more-btn { display: flex; align-items: center; gap: var(--sp-2); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 7px 20px; border-radius: var(--radius-md); background: var(--bg-raised); color: var(--text-muted); border: 1px solid var(--border-dim); cursor: pointer; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
|
||||
.load-more-btn:hover:not(:disabled) { color: var(--text-primary); border-color: var(--border-strong); }
|
||||
.load-more-btn:disabled { opacity: 0.5; cursor: default; }
|
||||
|
||||
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
|
||||
</style>
|
||||
@@ -28,7 +28,7 @@
|
||||
}
|
||||
|
||||
let extensions: Extension[] = $state([]);
|
||||
let localMangaCount = $state(0);
|
||||
let localMangaCount = $state<string>("0");
|
||||
let loading = $state(true);
|
||||
let refreshing = $state(false);
|
||||
let filter = $state<Filter>("installed");
|
||||
@@ -84,8 +84,10 @@
|
||||
}
|
||||
|
||||
async function loadLocalManga() {
|
||||
const d = await Promise.resolve(null);
|
||||
|
||||
try {
|
||||
const r = await getAdapter().browseSource('0', 1)
|
||||
localMangaCount = r.hasNextPage ? r.items.length + '+' : String(r.items.length)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function fetchFromRepo() {
|
||||
@@ -102,8 +104,7 @@
|
||||
async function loadRepos() {
|
||||
reposLoading = true;
|
||||
try {
|
||||
const d = await (getAdapter() as any).gql<{ settings: { extensionRepos: string[] } }>(`query GetSettings { settings { extensionRepos } }`);
|
||||
repos = d.settings.extensionRepos ?? [];
|
||||
repos = await getAdapter().getExtensionRepos();
|
||||
} catch (e) { console.error(e); }
|
||||
finally { reposLoading = false; }
|
||||
}
|
||||
@@ -111,11 +112,11 @@
|
||||
async function saveRepos(updated: string[], intent: "add" | "remove") {
|
||||
savingRepos = true;
|
||||
try {
|
||||
const d = await (getAdapter() as any).gql<{ setSettings: { settings: { extensionRepos: string[] } } }>(`mutation SetExtensionRepos($repos: [String!]!) { setSettings(input: { settings: { extensionRepos: $repos } }) { settings { extensionRepos } } }`, { repos: updated });
|
||||
repos = d.setSettings.settings.extensionRepos;
|
||||
const removed = repos.find(r => !updated.includes(r)) ?? "";
|
||||
repos = await getAdapter().setExtensionRepos(updated);
|
||||
addToast(intent === "add"
|
||||
? { kind: "success", title: "Repo added", body: updated[updated.length - 1] }
|
||||
: { kind: "info", title: "Repo removed", body: repos.find(r => !updated.includes(r)) ?? "" }
|
||||
: { kind: "info", title: "Repo removed", body: removed }
|
||||
);
|
||||
} catch (e: any) {
|
||||
repoError = e instanceof Error ? e.message : "Failed to save";
|
||||
@@ -136,13 +137,11 @@
|
||||
async function mutate(pkgName: string, op: "install" | "update" | "uninstall") {
|
||||
working = new Set(working).add(pkgName);
|
||||
const label = extensions.find((e) => e.pkgName === pkgName)?.name ?? pkgName;
|
||||
const gqlArgs = {
|
||||
install: { id: pkgName, install: true },
|
||||
update: { id: pkgName, update: true },
|
||||
uninstall: { id: pkgName, uninstall: true },
|
||||
}[op];
|
||||
try {
|
||||
await getAdapter()[{ install: 'installExtension', update: 'updateExtension', uninstall: 'uninstallExtension' }[op] as 'installExtension'](pkgName);
|
||||
const adapter = getAdapter();
|
||||
if (op === "install") await adapter.installExtension(pkgName);
|
||||
else if (op === "update") await adapter.updateExtension(pkgName);
|
||||
else await adapter.uninstallExtension(pkgName);
|
||||
await load();
|
||||
addToast({
|
||||
install: { kind: "download" as const, title: "Extension installed", body: label },
|
||||
@@ -338,7 +337,7 @@
|
||||
{:else}
|
||||
<div class="list">
|
||||
{#if showLocal}
|
||||
<div class="local-row">
|
||||
<div class="local-row" style="cursor:pointer" onclick={() => libraryTarget = { pkgName: '__local__', extensionName: 'Local Source', iconUrl: '' }}>
|
||||
<div class="local-icon"><HardDrives size={18} weight="bold" /></div>
|
||||
<div class="info">
|
||||
<span class="name">Local Source</span>
|
||||
|
||||
@@ -66,17 +66,7 @@
|
||||
editKey = null;
|
||||
listOpen = null;
|
||||
try {
|
||||
const d = await (getAdapter() as any).gql<{ source: { preferences: Preference[] } }>(
|
||||
`query GetSourceSettings($id: LongString!) { source(id: $id) { 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 }
|
||||
} } }`,
|
||||
{ id: String(src.id) },
|
||||
);
|
||||
prefs = d.source.preferences ?? [];
|
||||
prefs = (await getAdapter().getSourceSettings(src.id)) as Preference[];
|
||||
} catch (e: any) {
|
||||
addToast({ kind: "error", title: "Failed to load settings", body: e?.message ?? "" });
|
||||
} finally {
|
||||
@@ -86,24 +76,9 @@
|
||||
|
||||
async function save(position: number, changeType: string, value: unknown) {
|
||||
if (!activeSource) return;
|
||||
const pref = prefs[position];
|
||||
saving = pref.key;
|
||||
saving = prefs[position].key;
|
||||
try {
|
||||
await (getAdapter() as any).gql(
|
||||
`mutation UpdateSourcePreference($source: LongString!, $change: SourcePreferenceChangeInput!) { updateSourcePreference(input: { source: $source, change: $change }) { source { id } } }`,
|
||||
{ source: String(activeSource.id), change: { position, [changeType]: value } },
|
||||
);
|
||||
const d = await (getAdapter() as any).gql<{ source: { preferences: Preference[] } }>(
|
||||
`query GetSourceSettings($id: LongString!) { source(id: $id) { preferences {
|
||||
... on CheckBoxPreference { type: __typename CheckBoxTitle: title CheckBoxCurrentValue: currentValue key }
|
||||
... on SwitchPreference { type: __typename SwitchPreferenceTitle: title SwitchPreferenceCurrentValue: currentValue key }
|
||||
... on ListPreference { type: __typename ListPreferenceTitle: title ListPreferenceCurrentValue: currentValue entries entryValues key }
|
||||
... on EditTextPreference { type: __typename EditTextPreferenceTitle: title EditTextPreferenceCurrentValue: currentValue key }
|
||||
... on MultiSelectListPreference { type: __typename MultiSelectListPreferenceTitle: title MultiSelectListPreferenceCurrentValue: currentValue entries entryValues key }
|
||||
} } }`,
|
||||
{ id: String(activeSource.id) },
|
||||
);
|
||||
prefs = d.source.preferences ?? [];
|
||||
prefs = (await getAdapter().updateSourcePreference(activeSource.id, position, changeType, value)) as Preference[];
|
||||
} catch (e: any) {
|
||||
addToast({ kind: "error", title: "Failed to save", body: e?.message ?? "" });
|
||||
} finally {
|
||||
|
||||
@@ -9,6 +9,7 @@ export interface RecommendedManga {
|
||||
|
||||
const TOP_GENRES = 6
|
||||
const TARGET_PER_GENRE = 20
|
||||
const FALLBACK_GENRES = ['Action', 'Adventure', 'Fantasy', 'Romance', 'Comedy', 'Drama']
|
||||
|
||||
export function topGenres(history: ReadSession[], libraryManga: Manga[]): string[] {
|
||||
const byId = new Map(libraryManga.map(m => [m.id, m]))
|
||||
@@ -16,8 +17,8 @@ export function topGenres(history: ReadSession[], libraryManga: Manga[]): string
|
||||
|
||||
for (const session of history) {
|
||||
const manga = byId.get(session.mangaId)
|
||||
if (!manga?.genre?.length) continue
|
||||
for (const g of manga.genre) {
|
||||
if (!manga?.tags?.length) continue
|
||||
for (const g of manga.tags) {
|
||||
const key = g.toLowerCase()
|
||||
const existing = tally.get(key)
|
||||
if (existing) existing.count++
|
||||
@@ -25,10 +26,12 @@ export function topGenres(history: ReadSession[], libraryManga: Manga[]): string
|
||||
}
|
||||
}
|
||||
|
||||
return [...tally.values()]
|
||||
const derived = [...tally.values()]
|
||||
.sort((a, b) => b.count - a.count)
|
||||
.slice(0, TOP_GENRES)
|
||||
.map(e => e.original)
|
||||
|
||||
return derived.length > 0 ? derived : FALLBACK_GENRES
|
||||
}
|
||||
|
||||
export async function fetchRecommendations(
|
||||
@@ -36,20 +39,22 @@ export async function fetchRecommendations(
|
||||
libraryManga: Manga[],
|
||||
signal?: AbortSignal,
|
||||
): Promise<RecommendedManga[]> {
|
||||
if (!history.length || !libraryManga.length) return []
|
||||
|
||||
const genres = topGenres(history, libraryManga)
|
||||
if (!genres.length) return []
|
||||
|
||||
const adapter = getAdapter()
|
||||
const adapter = getAdapter() as any
|
||||
const globalSeen = new Set<number>(libraryManga.map(m => m.id))
|
||||
|
||||
const perGenre = await Promise.all(
|
||||
genres.map(async genre => {
|
||||
if (signal?.aborted) return []
|
||||
try {
|
||||
const { items } = await adapter.getMangaList({ tags: [genre], inLibrary: false })
|
||||
return items
|
||||
const { items } = await adapter.getMangasByGenre(
|
||||
{ genre: { like: `%${genre}%` } },
|
||||
TARGET_PER_GENRE,
|
||||
0,
|
||||
signal,
|
||||
)
|
||||
return items as Manga[]
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
@@ -57,19 +62,21 @@ export async function fetchRecommendations(
|
||||
)
|
||||
|
||||
const merged: Manga[] = []
|
||||
for (const items of perGenre) {
|
||||
outer: for (const items of perGenre) {
|
||||
for (const m of items) {
|
||||
if (signal?.aborted) break outer
|
||||
if (globalSeen.has(m.id)) continue
|
||||
globalSeen.add(m.id)
|
||||
merged.push(m)
|
||||
if (merged.length >= genres.length * TARGET_PER_GENRE) break
|
||||
}
|
||||
}
|
||||
|
||||
return merged.map(m => ({
|
||||
manga: m,
|
||||
matchedGenres: (m.genre ?? []).filter(g =>
|
||||
genres.some(tg => tg.toLowerCase() === g.toLowerCase())
|
||||
),
|
||||
}))
|
||||
return merged.map(m => {
|
||||
const mTagsLower = (m.tags ?? []).map(g => g.toLowerCase())
|
||||
const matched = genres.filter(g => mTagsLower.includes(g.toLowerCase()))
|
||||
return {
|
||||
manga: m,
|
||||
matchedGenres: matched.length > 0 ? matched : [genres[0]],
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -2,11 +2,10 @@
|
||||
import { getAdapter } from '$lib/request-manager'
|
||||
import { libraryState } from '$lib/state/library.svelte'
|
||||
import type { LibrarySortOption, LibraryContentFilter, LibraryStatusFilter } from '$lib/state/library.svelte'
|
||||
import { startLibraryUpdate } from '$lib/components/library/lib/libraryUpdater'
|
||||
import { addToast } from '$lib/state/notifications.svelte'
|
||||
import { updateSettings, settingsState } from '$lib/state/settings.svelte'
|
||||
import { readerState } from '$lib/state/reader.svelte'
|
||||
import { goto } from '$app/navigation'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import LibraryToolbar from '$lib/components/library/LibraryToolbar.svelte'
|
||||
import LibraryGrid from '$lib/components/library/LibraryGrid.svelte'
|
||||
import ContextMenu from '$lib/components/shared/ui/ContextMenu.svelte'
|
||||
@@ -16,6 +15,7 @@
|
||||
Books, Folder, FolderSimple, FolderSimplePlus,
|
||||
Trash, CheckSquare, ArrowSquareOut, ArrowsClockwise,
|
||||
} from 'phosphor-svelte'
|
||||
import { openMangaFolder, openDownloadsFolder } from '$lib/core/filesystem'
|
||||
|
||||
const SIDEBAR_W = 52
|
||||
const TITLEBAR_H = 36
|
||||
@@ -23,13 +23,17 @@
|
||||
const DT_TAB = 'application/x-moku-tab'
|
||||
const COMPLETED_NAME = 'Completed'
|
||||
|
||||
let cancelUpdate: (() => void) | null = null
|
||||
let statusPollTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let refreshDoneTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const UPDATE_STATUS_POLL_MS = 2_000
|
||||
|
||||
let ctx: { x: number; y: number; manga: Manga } | null = $state(null)
|
||||
let emptyCtx: { x: number; y: number } | null = $state(null)
|
||||
|
||||
let bulkWorking: boolean = $state(false)
|
||||
let bulkWorking: boolean = $state(false)
|
||||
let sortPanelOpen: boolean = $state(false)
|
||||
let filterPanelOpen: boolean = $state(false)
|
||||
let activeDragKind: 'tab' | null = $state(null)
|
||||
let dragInsertIdx = $state(-1)
|
||||
let dragTabId: string|null = $state(null)
|
||||
@@ -42,6 +46,9 @@
|
||||
$effect(() => { libraryState.syncFromSettings(settingsState.settings) })
|
||||
$effect(() => { libraryState.tab; libraryState.exitSelect() })
|
||||
$effect(() => { libraryState.guardTab() })
|
||||
$effect(() => {
|
||||
if (readerState.activeManga === null) loadLibrary()
|
||||
})
|
||||
|
||||
async function loadLibrary() {
|
||||
libraryState.loading = true
|
||||
@@ -115,26 +122,6 @@
|
||||
} catch (e) { console.error(e) }
|
||||
}
|
||||
|
||||
async function openMangaFolder(m: Manga) {
|
||||
let base: string | undefined
|
||||
try { base = await invoke<string>('get_default_downloads_path') } catch {}
|
||||
if (!base) { addToast({ kind: 'error', title: 'No downloads path set', body: 'Configure it in Settings → Storage' }); return }
|
||||
const sanitize = (s: string) => s.replace(/[\/\\?%*:|"<>]/g, '_')
|
||||
const source = (m as any).source?.displayName ?? (m as any).source?.name ?? ''
|
||||
const path = source
|
||||
? `${base}/mangas/${sanitize(source)}/${sanitize(m.title)}`
|
||||
: `${base}/mangas/${sanitize(m.title)}`
|
||||
try { await invoke('open_path', { path }) }
|
||||
catch (e: any) { addToast({ kind: 'error', title: 'Could not open folder', body: e?.toString?.() ?? path }) }
|
||||
}
|
||||
|
||||
async function openDownloadsFolder() {
|
||||
let path: string | undefined
|
||||
try { path = await invoke<string>('get_default_downloads_path') } catch {}
|
||||
if (!path) { addToast({ kind: 'error', title: 'No downloads path set', body: 'Configure it in Settings → Storage' }); return }
|
||||
try { await invoke('open_path', { path }) }
|
||||
catch (e: any) { addToast({ kind: 'error', title: 'Could not open folder', body: e?.toString?.() ?? path }) }
|
||||
}
|
||||
|
||||
async function refreshSingleManga(m: Manga) {
|
||||
if (libraryState.refreshingMangaId !== null) return
|
||||
@@ -217,33 +204,57 @@
|
||||
} finally { bulkWorking = false }
|
||||
}
|
||||
|
||||
function stopStatusPolling() {
|
||||
if (!statusPollTimer) return
|
||||
clearTimeout(statusPollTimer)
|
||||
statusPollTimer = null
|
||||
}
|
||||
|
||||
async function startRefresh() {
|
||||
if (libraryState.refreshing) return
|
||||
libraryState.refreshing = true
|
||||
libraryState.refreshProgress = { finished: 0, total: 0 }
|
||||
|
||||
cancelUpdate = startLibraryUpdate({
|
||||
onProgress(p) { libraryState.refreshProgress = p },
|
||||
async onDone({ newChapters, totalUpdated }) {
|
||||
cancelUpdate = null
|
||||
await loadLibrary()
|
||||
libraryState.refreshing = false
|
||||
libraryState.refreshDone = true
|
||||
if (refreshDoneTimer) clearTimeout(refreshDoneTimer)
|
||||
refreshDoneTimer = setTimeout(() => { libraryState.refreshDone = false }, 2500)
|
||||
if (newChapters > 0) {
|
||||
addToast({ kind: 'success', title: 'Library updated', body: `${newChapters} new chapter${newChapters !== 1 ? 's' : ''} across ${totalUpdated} series` })
|
||||
} else {
|
||||
addToast({ kind: 'info', title: 'Already up to date' })
|
||||
try {
|
||||
await getAdapter().checkForUpdates()
|
||||
} catch (e) {
|
||||
libraryState.refreshing = false
|
||||
addToast({ kind: 'error', title: 'Update failed', body: String(e) })
|
||||
return
|
||||
}
|
||||
|
||||
const tick = async () => {
|
||||
statusPollTimer = null
|
||||
try {
|
||||
const statusRes = await getAdapter().getLibraryUpdateStatus()
|
||||
const wasRunning = libraryState.refreshing
|
||||
|
||||
libraryState.refreshProgress = {
|
||||
finished: statusRes.finishedJobs ?? 0,
|
||||
total: statusRes.totalJobs ?? 0,
|
||||
}
|
||||
},
|
||||
onError() { libraryState.refreshing = false; cancelUpdate = null },
|
||||
})
|
||||
|
||||
if (statusRes.isRunning) {
|
||||
statusPollTimer = setTimeout(tick, UPDATE_STATUS_POLL_MS)
|
||||
} else if (wasRunning) {
|
||||
libraryState.refreshing = false
|
||||
libraryState.refreshDone = true
|
||||
if (refreshDoneTimer) clearTimeout(refreshDoneTimer)
|
||||
refreshDoneTimer = setTimeout(() => { libraryState.refreshDone = false }, 2500)
|
||||
await loadLibrary()
|
||||
addToast({ kind: 'info', title: 'Library updated' })
|
||||
}
|
||||
} catch {
|
||||
if (libraryState.refreshing) statusPollTimer = setTimeout(tick, UPDATE_STATUS_POLL_MS)
|
||||
}
|
||||
}
|
||||
|
||||
statusPollTimer = setTimeout(tick, UPDATE_STATUS_POLL_MS)
|
||||
}
|
||||
|
||||
async function cancelRefresh() {
|
||||
if (!libraryState.refreshing) return
|
||||
cancelUpdate?.(); cancelUpdate = null
|
||||
stopStatusPolling()
|
||||
try { await getAdapter().stopLibraryUpdate() } catch {}
|
||||
libraryState.refreshing = false
|
||||
libraryState.refreshProgress = { finished: 0, total: 0 }
|
||||
@@ -390,7 +401,7 @@
|
||||
visibleCategories={libraryState.visibleCategories}
|
||||
visibleTabIds={libraryState.visibleTabIds}
|
||||
counts={libraryState.counts}
|
||||
query={libraryState.filter.query}
|
||||
search={libraryState.filter.query}
|
||||
refreshing={libraryState.refreshing}
|
||||
refreshProgress={libraryState.refreshProgress}
|
||||
refreshDone={libraryState.refreshDone}
|
||||
@@ -399,13 +410,17 @@
|
||||
{dragInsertIdx}
|
||||
{dragTabId}
|
||||
{dragOverTabId}
|
||||
{sortPanelOpen}
|
||||
{filterPanelOpen}
|
||||
onTabChange={(t) => libraryState.tab = t}
|
||||
onQuery={(q) => libraryState.filter.query = q}
|
||||
onSearchChange={(q) => libraryState.filter.query = q}
|
||||
onSortChange={(mode) => libraryState.setTabSort(libraryState.tab, mode)}
|
||||
onSortDirToggle={() => libraryState.toggleTabSortDir(libraryState.tab)}
|
||||
onSortPanelToggle={() => sortPanelOpen = !sortPanelOpen}
|
||||
onStatusChange={(s) => libraryState.setTabStatus(libraryState.tab, s)}
|
||||
onFilterToggle={(f) => libraryState.toggleTabFilter(libraryState.tab, f)}
|
||||
onFiltersClear={() => libraryState.clearTabFilters(libraryState.tab)}
|
||||
onFilterPanelToggle={() => filterPanelOpen = !filterPanelOpen}
|
||||
onRefresh={startRefresh}
|
||||
onCancelRefresh={cancelRefresh}
|
||||
onRefreshCategory={refreshCategory}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { CheckSquare, Trash, Folder } from 'phosphor-svelte'
|
||||
import Thumbnail from '$lib/components/shared/manga/Thumbnail.svelte'
|
||||
import type { Manga, Category } from '$lib/types'
|
||||
|
||||
interface Props {
|
||||
@@ -24,15 +25,8 @@
|
||||
onCardClick, onCardContextMenu, onSelectAll, onExitSelect, onBulkRemove, onBulkMove,
|
||||
}: Props = $props()
|
||||
|
||||
const THUMB_BASE = 'http://127.0.0.1:4567'
|
||||
|
||||
let movePanelOpen = $state(false)
|
||||
|
||||
function coverUrl(m: Manga) {
|
||||
const url = m.thumbnailUrl ?? ''
|
||||
return url.startsWith('http') ? url : `${THUMB_BASE}${url}`
|
||||
}
|
||||
|
||||
function onDocDown(e: MouseEvent) {
|
||||
if (movePanelOpen && !(e.target as HTMLElement).closest('.move-wrap')) movePanelOpen = false
|
||||
}
|
||||
@@ -124,13 +118,7 @@
|
||||
oncontextmenu={(e) => onCardContextMenu(e, m)}
|
||||
>
|
||||
<div class="cover-wrap" class:completed={isCompleted}>
|
||||
<img
|
||||
class="cover"
|
||||
src={coverUrl(m)}
|
||||
alt={m.title}
|
||||
draggable="false"
|
||||
loading="lazy"
|
||||
/>
|
||||
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="cover" id={m.id} />
|
||||
<div class="overlay">
|
||||
<div class="badges">
|
||||
{#if isCompleted}
|
||||
@@ -247,7 +235,7 @@
|
||||
}
|
||||
.cover-wrap.completed { box-shadow: inset 0 -2px 0 0 var(--accent); }
|
||||
|
||||
.cover { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
:global(.cover) { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
|
||||
.overlay {
|
||||
position: absolute; bottom: 0; left: 0; right: 0; z-index: 2;
|
||||
|
||||
@@ -212,14 +212,14 @@
|
||||
</div>
|
||||
|
||||
<LibraryFilters
|
||||
{tabStatus}
|
||||
{tabFilters}
|
||||
{hasActiveFilters}
|
||||
{filterPanelOpen}
|
||||
status={tabStatus}
|
||||
filters={tabFilters}
|
||||
hasActive={hasActiveFilters}
|
||||
open={filterPanelOpen}
|
||||
onToggle={onFilterPanelToggle}
|
||||
{onStatusChange}
|
||||
{onFilterToggle}
|
||||
{onFiltersClear}
|
||||
{onFilterPanelToggle}
|
||||
onClear={onFiltersClear}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,125 +0,0 @@
|
||||
import { getAdapter } from '$lib/request-manager'
|
||||
|
||||
const POLL_INTERVAL_MS = 2000
|
||||
const POLL_INITIAL_MS = 500
|
||||
|
||||
export interface UpdateProgress {
|
||||
finished: number
|
||||
total: number
|
||||
skippedManga: number
|
||||
skippedCategories: number
|
||||
}
|
||||
|
||||
export interface UpdateResult {
|
||||
entries: UpdateEntry[]
|
||||
totalUpdated: number
|
||||
newChapters: number
|
||||
}
|
||||
|
||||
export interface UpdateEntry {
|
||||
mangaId: number
|
||||
mangaTitle: string
|
||||
thumbnailUrl: string
|
||||
newChapters: number
|
||||
checkedAt: number
|
||||
}
|
||||
|
||||
export interface LibraryUpdaterCallbacks {
|
||||
onProgress: (p: UpdateProgress) => void
|
||||
onDone: (r: UpdateResult) => void
|
||||
onError: (e?: unknown) => void
|
||||
}
|
||||
|
||||
function buildEntries(
|
||||
mangaUpdates: { status: string; manga: { id: number; title: string; thumbnailUrl: string; unreadCount: number } }[]
|
||||
): UpdateEntry[] {
|
||||
const byManga = new Map<number, UpdateEntry>()
|
||||
for (const u of mangaUpdates) {
|
||||
if (u.status !== 'UPDATED') continue
|
||||
const existing = byManga.get(u.manga.id)
|
||||
if (existing) {
|
||||
existing.newChapters++
|
||||
} else {
|
||||
byManga.set(u.manga.id, {
|
||||
mangaId: u.manga.id,
|
||||
mangaTitle: u.manga.title,
|
||||
thumbnailUrl: u.manga.thumbnailUrl,
|
||||
newChapters: 1,
|
||||
checkedAt: Date.now(),
|
||||
})
|
||||
}
|
||||
}
|
||||
return [...byManga.values()]
|
||||
}
|
||||
|
||||
export function startLibraryUpdate(callbacks: LibraryUpdaterCallbacks): () => void {
|
||||
let timer: ReturnType<typeof setTimeout> | null = null
|
||||
let cancelled = false
|
||||
|
||||
function cancel() {
|
||||
cancelled = true
|
||||
if (timer) { clearTimeout(timer); timer = null }
|
||||
}
|
||||
|
||||
async function run() {
|
||||
let jobsStarted = false
|
||||
|
||||
try {
|
||||
const status = await getAdapter().checkForUpdates()
|
||||
if (cancelled) return
|
||||
|
||||
const { jobsInfo } = status
|
||||
jobsStarted = jobsInfo.totalJobs > 0
|
||||
|
||||
callbacks.onProgress({
|
||||
finished: jobsInfo.finishedJobs,
|
||||
total: jobsInfo.totalJobs,
|
||||
skippedManga: jobsInfo.skippedMangasCount,
|
||||
skippedCategories: jobsInfo.skippedCategoriesCount,
|
||||
})
|
||||
|
||||
if (!jobsStarted || !jobsInfo.isRunning) {
|
||||
callbacks.onDone({ entries: [], totalUpdated: 0, newChapters: 0 })
|
||||
return
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[libraryUpdater] failed to start update', e)
|
||||
if (!cancelled) callbacks.onError(e)
|
||||
return
|
||||
}
|
||||
|
||||
function poll() {
|
||||
getAdapter().getLibraryUpdateStatus()
|
||||
.then(d => {
|
||||
if (cancelled) return
|
||||
const { jobsInfo, mangaUpdates } = d
|
||||
|
||||
if (jobsInfo.totalJobs > 0) jobsStarted = true
|
||||
callbacks.onProgress({
|
||||
finished: jobsInfo.finishedJobs,
|
||||
total: jobsInfo.totalJobs,
|
||||
skippedManga: jobsInfo.skippedMangasCount,
|
||||
skippedCategories: jobsInfo.skippedCategoriesCount,
|
||||
})
|
||||
|
||||
if (!jobsInfo.isRunning && jobsStarted) {
|
||||
const entries = buildEntries(mangaUpdates)
|
||||
const newChapters = entries.reduce((s, e) => s + e.newChapters, 0)
|
||||
callbacks.onDone({ entries, totalUpdated: entries.length, newChapters })
|
||||
return
|
||||
}
|
||||
|
||||
timer = setTimeout(poll, POLL_INTERVAL_MS)
|
||||
})
|
||||
.catch(e => {
|
||||
console.error('[libraryUpdater] poll error', e)
|
||||
if (!cancelled) callbacks.onError(e)
|
||||
})
|
||||
}
|
||||
|
||||
timer = setTimeout(poll, POLL_INITIAL_MS)
|
||||
}
|
||||
|
||||
run()
|
||||
return cancel
|
||||
}
|
||||
@@ -12,6 +12,7 @@
|
||||
import { clampZoom, captureZoomAnchor, restoreZoomAnchor } from "$lib/components/reader/lib/zoomHelpers";
|
||||
import { loadChapter, scheduleResumeDismiss } from "$lib/components/reader/lib/chapterLoader";
|
||||
import { historyState } from "$lib/state/history.svelte";
|
||||
import { getAdapter } from "$lib/request-manager";
|
||||
import type { ReaderSettings } from "$lib/state/reader.svelte";
|
||||
import ReaderControls from "$lib/components/reader/ReaderControls.svelte";
|
||||
import PageView from "$lib/components/reader/PageView.svelte";
|
||||
@@ -143,7 +144,8 @@
|
||||
let startAtLastPageRef = { current: false };
|
||||
let cleanupScroll: () => void = () => {};
|
||||
let stripChaptersRef = readerState.stripChapters;
|
||||
let tickTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let tickTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let progressTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
$effect(() => { stripChaptersRef = readerState.stripChapters; });
|
||||
|
||||
@@ -380,19 +382,7 @@
|
||||
const toQueue = list.slice(idx + 1, idx + 1 + prefs.downloadAhead)
|
||||
.filter(c => !c.downloaded && !c.read)
|
||||
.map(c => c.id);
|
||||
if (toQueue.length) {
|
||||
const DL = `mutation EnqueueDl($ids: [Int!]!) { enqueueChaptersDownloads(input: { ids: $ids }) { downloadStatus { queue { chapter { id } } } } }`;
|
||||
const base = settingsState.settings.serverUrl ?? "http://localhost:4567";
|
||||
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
||||
const mode = settingsState.settings.serverAuthMode ?? "NONE";
|
||||
if (mode === "BASIC_AUTH") {
|
||||
const u = settingsState.settings.serverAuthUser?.trim() ?? "";
|
||||
const p = settingsState.settings.serverAuthPass?.trim() ?? "";
|
||||
if (u && p) headers["Authorization"] = `Basic ${btoa(`${u}:${p}`)}`;
|
||||
}
|
||||
fetch(`${base}/api/graphql`, { method: "POST", headers, body: JSON.stringify({ query: DL, variables: { ids: toQueue } }) })
|
||||
.catch(console.error);
|
||||
}
|
||||
if (toQueue.length) getAdapter().enqueueDownloads(toQueue.map(String)).catch(console.error);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -525,6 +515,14 @@
|
||||
readerState.addBookmark({ mangaId, mangaTitle, thumbnailUrl: thumb, chapterId, chapterName, pageNumber: pageNum });
|
||||
}
|
||||
if (style !== "longstrip" && (settingsState.settings.autoMarkRead ?? true) && atLast) markChapterRead(chapterId, markedRead);
|
||||
|
||||
if (pageNum > 1 && !markedRead.has(chapterId)) {
|
||||
if (progressTimer) clearTimeout(progressTimer);
|
||||
progressTimer = setTimeout(() => {
|
||||
getAdapter().updateChaptersProgress([String(chapterId)], { lastPageRead: pageNum }).catch(console.error);
|
||||
progressTimer = null;
|
||||
}, 2_000);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -558,6 +556,7 @@
|
||||
abortCtrl.current?.abort();
|
||||
if (hideTimer) clearTimeout(hideTimer);
|
||||
if (roTimer) clearTimeout(roTimer);
|
||||
if (progressTimer) clearTimeout(progressTimer);
|
||||
window.removeEventListener("keydown", onKey);
|
||||
window.removeEventListener("mousemove", pageViewRef.onInspectMouseMove);
|
||||
window.removeEventListener("mouseup", pageViewRef.onInspectMouseUp);
|
||||
|
||||
@@ -3,10 +3,11 @@
|
||||
X, CaretLeft, CaretRight, CaretUp, CaretDown,
|
||||
MagnifyingGlassMinus, MagnifyingGlassPlus,
|
||||
Bookmark, MapPin, Download, Check, GearSix, Sliders,
|
||||
ArrowsOut, ArrowsIn,
|
||||
ArrowsOut, ArrowsIn, Minus,
|
||||
} from "phosphor-svelte";
|
||||
import { readerState, MARKER_COLORS, MARKER_COLOR_HEX, ZOOM_STEP, ZOOM_MIN, ZOOM_MAX } from "$lib/state/reader.svelte";
|
||||
import { settingsState } from "$lib/state/settings.svelte";
|
||||
import { getAdapter } from "$lib/request-manager";
|
||||
import { platformService } from "$lib/platform-service";
|
||||
import { fly } from "svelte/transition";
|
||||
import { cubicOut, cubicIn } from "svelte/easing";
|
||||
import type { Chapter } from "$lib/types";
|
||||
@@ -52,24 +53,6 @@
|
||||
|
||||
const queueable = $derived(adjacent.remaining.filter(c => !c.downloaded));
|
||||
|
||||
async function gqlMutation(query: string, variables: Record<string, unknown>): Promise<void> {
|
||||
const base = settingsState.settings.serverUrl ?? "http://localhost:4567";
|
||||
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
||||
const mode = settingsState.settings.serverAuthMode ?? "NONE";
|
||||
if (mode === "BASIC_AUTH") {
|
||||
const u = settingsState.settings.serverAuthUser?.trim() ?? "";
|
||||
const p = settingsState.settings.serverAuthPass?.trim() ?? "";
|
||||
if (u && p) headers["Authorization"] = `Basic ${btoa(`${u}:${p}`)}`;
|
||||
}
|
||||
const res = await fetch(`${base}/api/graphql`, { method: "POST", headers, body: JSON.stringify({ query, variables }) });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const json = await res.json();
|
||||
if (json.errors?.length) throw new Error(json.errors[0].message);
|
||||
}
|
||||
|
||||
const ENQUEUE_ONE = `mutation EnqueueOne($id: Int!) { fetchChapterPages(input: { chapterId: $id }) { chapter { id } } }`;
|
||||
const ENQUEUE_MANY = `mutation EnqueueMany($ids: [Int!]!) { enqueueChaptersDownloads(input: { ids: $ids }) { downloadStatus { queue { chapter { id } } } } }`;
|
||||
|
||||
async function runDl(fn: () => Promise<void>) {
|
||||
readerState.dlBusy = true;
|
||||
try { await fn(); } catch (e) { console.error(e); }
|
||||
@@ -77,6 +60,14 @@
|
||||
readerState.dlOpen = false;
|
||||
}
|
||||
|
||||
function enqueueOne(chapterId: number) {
|
||||
return getAdapter().enqueueDownload(String(chapterId));
|
||||
}
|
||||
|
||||
function enqueueMany(chapterIds: number[]) {
|
||||
return getAdapter().enqueueDownloads(chapterIds.map(String));
|
||||
}
|
||||
|
||||
const isVertical = $derived(barPosition === "left" || barPosition === "right");
|
||||
const popoverSide = $derived(
|
||||
barPosition === "left" ? "right" :
|
||||
@@ -96,9 +87,10 @@
|
||||
onRestoreZoomAnchor();
|
||||
}
|
||||
|
||||
const isTauri = platformService.platform === "tauri";
|
||||
|
||||
async function toggleFullscreen() {
|
||||
if (!document.fullscreenElement) await document.documentElement.requestFullscreen();
|
||||
else await document.exitFullscreen();
|
||||
await platformService.toggleFullscreen();
|
||||
}
|
||||
|
||||
function closeAllPopovers() {
|
||||
@@ -337,6 +329,16 @@
|
||||
<span>Fullscreen</span>
|
||||
{/if}
|
||||
</button>
|
||||
{#if isTauri}
|
||||
<button class="action-row" onclick={() => { readerState.actionsOpen = false; platformService.minimize(); }}>
|
||||
<Minus size={13} weight="regular" />
|
||||
<span>Minimize</span>
|
||||
</button>
|
||||
<button class="action-row action-row-danger" onclick={() => { readerState.actionsOpen = false; platformService.close(); }}>
|
||||
<X size={13} weight="regular" />
|
||||
<span>Close window</span>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -345,13 +347,13 @@
|
||||
<div class="popover dl-popover popover-{popoverSide}" role="presentation" onclick={(e) => e.stopPropagation()}>
|
||||
<p class="dl-title">Download</p>
|
||||
<button class="dl-option" disabled={readerState.dlBusy || !!chapter.downloaded}
|
||||
onclick={() => runDl(() => gqlMutation(ENQUEUE_ONE, { id: chapter.id }))}>
|
||||
onclick={() => runDl(() => enqueueOne(chapter.id))}>
|
||||
This chapter
|
||||
<span class="dl-sub">{chapter.downloaded ? "Already downloaded" : chapter.name}</span>
|
||||
</button>
|
||||
<div class="dl-row">
|
||||
<button class="dl-option" disabled={readerState.dlBusy || queueable.length === 0}
|
||||
onclick={() => runDl(() => gqlMutation(ENQUEUE_MANY, { ids: queueable.slice(0, readerState.nextN).map(c => c.id) }))}>
|
||||
onclick={() => runDl(() => enqueueMany(queueable.slice(0, readerState.nextN).map(c => c.id)))}>
|
||||
Next chapters
|
||||
<span class="dl-sub">{Math.min(readerState.nextN, queueable.length)} not yet downloaded</span>
|
||||
</button>
|
||||
@@ -362,7 +364,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<button class="dl-option" disabled={readerState.dlBusy || queueable.length === 0}
|
||||
onclick={() => runDl(() => gqlMutation(ENQUEUE_MANY, { ids: queueable.map(c => c.id) }))}>
|
||||
onclick={() => runDl(() => enqueueMany(queueable.map(c => c.id)))}>
|
||||
All remaining
|
||||
<span class="dl-sub">{queueable.length} not yet downloaded</span>
|
||||
</button>
|
||||
@@ -661,8 +663,10 @@
|
||||
transition: background var(--t-fast), color var(--t-fast);
|
||||
}
|
||||
.action-row:hover { background: var(--bg-overlay); color: var(--text-primary); }
|
||||
.action-row.action-row-danger:hover { background: color-mix(in srgb, #c0392b 15%, transparent); color: var(--color-error, #e57373); }
|
||||
.action-row svg, .action-row :global(svg) { flex-shrink: 0; color: var(--text-faint); }
|
||||
.action-row:hover svg, .action-row:hover :global(svg) { color: var(--text-muted); }
|
||||
.action-row-danger:hover svg, .action-row-danger:hover :global(svg) { color: var(--color-error, #e57373); }
|
||||
|
||||
.action-divider { height: 1px; background: var(--border-dim); margin: var(--sp-1) 0; }
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { readerState } from "$lib/state/reader.svelte";
|
||||
import { settingsState } from "$lib/state/settings.svelte";
|
||||
import { getAdapter } from "$lib/request-manager";
|
||||
import type { Chapter } from "$lib/types";
|
||||
|
||||
@@ -15,28 +14,6 @@
|
||||
|
||||
const { showResumeBanner, resumePage, resumeFading, adjacent, onDismissResume, barPosition }: Props = $props();
|
||||
|
||||
async function gqlMutation(query: string, variables: Record<string, unknown>): Promise<void> {
|
||||
const base = settingsState.settings.serverUrl ?? "http://localhost:4567";
|
||||
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
||||
const mode = settingsState.settings.serverAuthMode ?? "NONE";
|
||||
if (mode === "BASIC_AUTH") {
|
||||
const u = settingsState.settings.serverAuthUser?.trim() ?? "";
|
||||
const p = settingsState.settings.serverAuthPass?.trim() ?? "";
|
||||
if (u && p) headers["Authorization"] = `Basic ${btoa(`${u}:${p}`)}`;
|
||||
}
|
||||
const res = await fetch(`${base}/api/graphql`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({ query, variables }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const json = await res.json();
|
||||
if (json.errors?.length) throw new Error(json.errors[0].message);
|
||||
}
|
||||
|
||||
const ENQUEUE_ONE = `mutation EnqueueOne($id: Int!) { fetchChapterPages(input: { chapterId: $id }) { chapter { id } } }`;
|
||||
const ENQUEUE_MANY = `mutation EnqueueMany($ids: [Int!]!) { enqueueChaptersDownloads(input: { ids: $ids }) { downloadStatus { queue { chapter { id } } } } }`;
|
||||
|
||||
async function runDl(fn: () => Promise<void>) {
|
||||
readerState.dlBusy = true;
|
||||
try { await fn(); } catch (e) { console.error(e); }
|
||||
@@ -60,14 +37,14 @@
|
||||
<p class="dl-title">Download</p>
|
||||
|
||||
<button class="dl-option" disabled={readerState.dlBusy || !!chapter.downloaded}
|
||||
onclick={() => runDl(() => gqlMutation(ENQUEUE_ONE, { id: chapter.id }))}>
|
||||
onclick={() => runDl(() => getAdapter().enqueueDownload(String(chapter.id)))}>
|
||||
This chapter
|
||||
<span class="dl-sub">{chapter.downloaded ? "Already downloaded" : chapter.name}</span>
|
||||
</button>
|
||||
|
||||
<div class="dl-row">
|
||||
<button class="dl-option" disabled={readerState.dlBusy || queueable.length === 0}
|
||||
onclick={() => runDl(() => gqlMutation(ENQUEUE_MANY, { ids: queueable.slice(0, readerState.nextN).map(c => c.id) }))}>
|
||||
onclick={() => runDl(() => getAdapter().enqueueDownloads(queueable.slice(0, readerState.nextN).map(c => String(c.id))))}>
|
||||
Next chapters
|
||||
<span class="dl-sub">{Math.min(readerState.nextN, queueable.length)} not yet downloaded</span>
|
||||
</button>
|
||||
@@ -79,7 +56,7 @@
|
||||
</div>
|
||||
|
||||
<button class="dl-option" disabled={readerState.dlBusy || queueable.length === 0}
|
||||
onclick={() => runDl(() => gqlMutation(ENQUEUE_MANY, { ids: queueable.map(c => c.id) }))}>
|
||||
onclick={() => runDl(() => getAdapter().enqueueDownloads(queueable.map(c => String(c.id))))}>
|
||||
All remaining
|
||||
<span class="dl-sub">{queueable.length} not yet downloaded</span>
|
||||
</button>
|
||||
|
||||
@@ -93,7 +93,7 @@
|
||||
|
||||
<div class="cover-wrap">
|
||||
<button class="cover-btn" onclick={() => manga && setPreviewManga(manga)} title="Quick preview" disabled={!manga}>
|
||||
<Thumbnail src={resolvedCover(seriesState.activeManga?.id ?? manga?.id ?? 0, seriesState.activeManga?.thumbnailUrl ?? manga?.thumbnailUrl ?? "")} alt={seriesState.activeManga?.title ?? manga?.title ?? ""} class="cover" />
|
||||
<Thumbnail src={resolvedCover(seriesState.activeManga?.id ?? manga?.id ?? 0, seriesState.activeManga?.thumbnailUrl ?? manga?.thumbnailUrl ?? "")} alt={seriesState.activeManga?.title ?? manga?.title ?? ""} class="cover" id={seriesState.activeManga?.id ?? manga?.id} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -76,7 +76,7 @@
|
||||
let entries = 0, oldest: number | null = null, newest: number | null = null
|
||||
const foundKeys: string[] = []
|
||||
const checkKey = (k: string) => {
|
||||
const age = cache.ageOf(k)
|
||||
const age = cache?.ageOf?.(k)
|
||||
if (age !== undefined) {
|
||||
entries++
|
||||
foundKeys.push(k)
|
||||
@@ -85,7 +85,7 @@
|
||||
if (newest === null || ts > newest) newest = ts
|
||||
}
|
||||
}
|
||||
['library', 'sources', 'popular'].forEach(checkKey)
|
||||
['library', 'sources', 'popular'].forEach(checkKey);
|
||||
['Action','Romance','Fantasy','Comedy','Drama','Horror','Sci-Fi','Adventure','Thriller',
|
||||
'Isekai','Supernatural','Historical','Psychological','Sports','Mystery','Mecha',
|
||||
'Slice of Life','School Life','Martial Arts','Magic','Military'].forEach(g => checkKey(`genre:${g}`))
|
||||
@@ -104,7 +104,7 @@
|
||||
function triggerSplash() {
|
||||
splashTriggered = true
|
||||
setTimeout(() => splashTriggered = false, 200)
|
||||
;(window as any).__mokuShowSplash?.()
|
||||
appState.idleSplash = true
|
||||
}
|
||||
|
||||
async function testWindowsHello() {
|
||||
@@ -192,7 +192,7 @@
|
||||
<div class="s-dev-grid">
|
||||
<span class="s-dev-key">Filter</span> <span class="s-dev-val">{appState.libraryFilter}</span>
|
||||
<span class="s-dev-key">Folders</span> <span class="s-dev-val">{appState.categories.filter(c => c.id !== 0).map(c => c.name).join(', ') || 'none'}</span>
|
||||
<span class="s-dev-key">History</span> <span class="s-dev-val">{appState.history.length} entries</span>
|
||||
<span class="s-dev-key">History</span> <span class="s-dev-val">{appState.history?.length ?? 0} entries</span>
|
||||
<span class="s-dev-key">Cache</span> <span class="s-dev-val">{perfSnapshot?.cacheEntries ?? '—'} entries</span>
|
||||
<span class="s-dev-key">Toasts</span> <span class="s-dev-val">{appState.toasts.length} queued</span>
|
||||
<span class="s-dev-key">Version</span> <span class="s-dev-val">{appVersion} · {import.meta.env.MODE}</span>
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
import { settingsState, updateSettings } from '$lib/state/settings.svelte'
|
||||
import { platformService } from '$lib/platform-service'
|
||||
|
||||
const isTauri = platformService.platform === 'tauri'
|
||||
|
||||
interface Props {
|
||||
selectOpen: string | null
|
||||
closingSelect: string | null
|
||||
@@ -67,6 +69,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if isTauri}
|
||||
<label class="s-row">
|
||||
<div class="s-row-info"><span class="s-label">Auto-start server</span><span class="s-desc">Launch tachidesk-server when Moku opens</span></div>
|
||||
<button role="switch" aria-checked={settingsState.settings.autoStartServer} aria-label="Auto-start server"
|
||||
@@ -84,6 +87,7 @@
|
||||
<span class="s-toggle-thumb"></span>
|
||||
</button>
|
||||
</label>
|
||||
{/if}
|
||||
|
||||
{#if serverAdvancedOpen}
|
||||
<div class="srv-adv-panel">
|
||||
|
||||
@@ -83,8 +83,8 @@
|
||||
<div class="s-section">
|
||||
<div class="s-section-body">
|
||||
<div class="s-row">
|
||||
<div class="s-row-info"><span class="s-label">Reading history</span><span class="s-desc">{homeState.history.length} entries</span></div>
|
||||
<button class="s-btn s-btn-danger" onclick={clearHistory} disabled={homeState.history.length === 0}>Clear</button>
|
||||
<div class="s-row-info"><span class="s-label">Reading history</span><span class="s-desc">{homeState.history?.length ?? 0} entries</span></div>
|
||||
<button class="s-btn s-btn-danger" onclick={clearHistory} disabled={!homeState.history?.length}>Clear</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { settingsState, updateSettings } from '$lib/state/settings.svelte'
|
||||
import { requestManager } from '$lib/request-manager'
|
||||
import { authSession, loginUI } from '$lib/core/auth'
|
||||
import { retryBoot } from '$lib/state/boot.svelte'
|
||||
import { authSession, configureAuth } from '$lib/core/auth'
|
||||
|
||||
interface Props { selectOpen: string | null; toggleSelect: (id: string) => void }
|
||||
let { selectOpen, toggleSelect }: Props = $props()
|
||||
@@ -13,9 +14,15 @@
|
||||
let secSaved = $state<string | null>(null)
|
||||
let secLoaded = $state(false)
|
||||
|
||||
let authMode = $state(settingsState.settings.serverAuthMode ?? 'NONE')
|
||||
function normalizeForUI(mode: string | undefined): 'NONE' | 'BASIC_AUTH' | 'UI_LOGIN' {
|
||||
if (mode === 'BASIC_AUTH' || mode === 'UI_LOGIN') return mode
|
||||
return 'NONE'
|
||||
}
|
||||
|
||||
let authMode = $state(normalizeForUI(settingsState.settings.serverAuthMode))
|
||||
let authUsername = $state(settingsState.settings.serverAuthUser ?? '')
|
||||
let authPassword = $state('')
|
||||
let authDirty = $state(false)
|
||||
|
||||
let socksEnabled = $state(settingsState.settings.socksProxyEnabled ?? false)
|
||||
let socksHost = $state(settingsState.settings.socksProxyHost ?? '')
|
||||
@@ -31,9 +38,33 @@
|
||||
let flareTtl = $state(settingsState.settings.flareSolverrSessionTtl ?? 15)
|
||||
let flareFallback = $state(settingsState.settings.flareSolverrAsResponseFallback ?? false)
|
||||
|
||||
function normalizeAuthMode(mode: string): 'NONE' | 'BASIC_AUTH' | 'UI_LOGIN' {
|
||||
if (mode === 'BASIC_AUTH' || mode === 'UI_LOGIN' || mode === 'NONE') return mode
|
||||
return 'NONE'
|
||||
let lockEnabled = $state(settingsState.settings.appLockEnabled ?? false)
|
||||
let lockPin = $state(settingsState.settings.appLockEnabled ? (settingsState.settings.appLockPin ?? '') : '')
|
||||
let lockPinVis = $state(false)
|
||||
let lockError = $state<string | null>(null)
|
||||
let lockSaved = $state(false)
|
||||
|
||||
function onLockToggle() {
|
||||
lockEnabled = !lockEnabled
|
||||
lockError = null
|
||||
lockSaved = false
|
||||
if (!lockEnabled) {
|
||||
lockPin = ''
|
||||
updateSettings({ appLockEnabled: false, appLockPin: '' })
|
||||
}
|
||||
}
|
||||
|
||||
function onLockPinInput() {
|
||||
lockPin = lockPin.replace(/\D/g, '')
|
||||
lockError = null
|
||||
lockSaved = false
|
||||
}
|
||||
|
||||
function saveLockPin() {
|
||||
if (lockPin.length < 4) { lockError = 'PIN must be at least 4 digits'; return }
|
||||
updateSettings({ appLockEnabled: true, appLockPin: lockPin })
|
||||
lockSaved = true
|
||||
setTimeout(() => lockSaved = false, 2000)
|
||||
}
|
||||
|
||||
function showSaved(key: string) {
|
||||
@@ -42,17 +73,17 @@
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (!secLoaded) { secLoaded = true; authSession.clearTokens(); loadServerSecurity() }
|
||||
if (!secLoaded) { secLoaded = true; loadServerSecurity() }
|
||||
})
|
||||
|
||||
async function loadServerSecurity() {
|
||||
try {
|
||||
const s = await requestManager.extensions.getServerSecurity()
|
||||
const serverMode = normalizeAuthMode(s.authMode)
|
||||
if (serverMode !== 'UI_LOGIN') authSession.clearTokens()
|
||||
authMode = serverMode
|
||||
authUsername = s.authUsername || ''
|
||||
updateSettings({ serverAuthMode: serverMode, serverAuthUser: authUsername })
|
||||
if (!authDirty) {
|
||||
authMode = normalizeForUI(s.authMode)
|
||||
authUsername = s.authUsername || ''
|
||||
updateSettings({ serverAuthMode: authMode, serverAuthUser: authUsername })
|
||||
}
|
||||
socksEnabled = s.socksProxyEnabled; socksHost = s.socksProxyHost
|
||||
socksPort = s.socksProxyPort; socksVersion = s.socksProxyVersion
|
||||
socksUsername = s.socksProxyUsername
|
||||
@@ -66,37 +97,28 @@
|
||||
flareSolverrTimeout: flareTimeout, flareSolverrSessionName: flareSession,
|
||||
flareSolverrSessionTtl: flareTtl, flareSolverrAsResponseFallback: flareFallback,
|
||||
})
|
||||
} catch {}
|
||||
} catch (e: any) {
|
||||
console.warn('[SecuritySettings] loadServerSecurity failed:', e?.message ?? e)
|
||||
}
|
||||
}
|
||||
|
||||
async function saveAuth() {
|
||||
if (authMode === 'NONE') { await clearAuth(); return }
|
||||
if (!authUsername.trim() || !authPassword.trim()) { secError = 'Username and password are required'; return }
|
||||
secLoading = true; secError = null
|
||||
const prev = { mode: settingsState.settings.serverAuthMode, user: settingsState.settings.serverAuthUser, pass: settingsState.settings.serverAuthPass }
|
||||
try {
|
||||
const newUser = authUsername.trim()
|
||||
const newPass = authPassword.trim()
|
||||
authSession.clearTokens()
|
||||
if (authMode === 'UI_LOGIN') {
|
||||
await loginUI(newUser, newPass)
|
||||
updateSettings({ serverAuthMode: 'UI_LOGIN', serverAuthUser: newUser, serverAuthPass: '' })
|
||||
} else {
|
||||
updateSettings({ serverAuthMode: 'BASIC_AUTH', serverAuthUser: newUser, serverAuthPass: newPass })
|
||||
}
|
||||
await requestManager.extensions.setServerAuth({ authMode, authUsername: newUser, authPassword: newPass })
|
||||
authSession.clearTokens()
|
||||
updateSettings({ serverAuthMode: authMode as any, serverAuthUser: newUser, serverAuthPass: authMode === 'BASIC_AUTH' ? newPass : '' })
|
||||
configureAuth(settingsState.settings.serverUrl ?? '', authMode as any, newUser, authMode === 'BASIC_AUTH' ? newPass : undefined)
|
||||
authPassword = ''
|
||||
authDirty = false
|
||||
showSaved('auth')
|
||||
retryBoot(authMode as any, newUser, newPass)
|
||||
} catch (e: any) {
|
||||
const msg = e?.message ?? 'Failed to save authentication settings'
|
||||
const authMismatch = /unauthorized|unauthenticated|authentication|401/i.test(msg)
|
||||
if (!authMismatch) {
|
||||
authSession.clearTokens()
|
||||
updateSettings({ serverAuthMode: prev.mode, serverAuthUser: prev.user, serverAuthPass: prev.pass })
|
||||
}
|
||||
secError = authMismatch
|
||||
? 'Saved local auth settings, but the server rejected the update. Verify your new credentials with the current server configuration.'
|
||||
: msg
|
||||
secError = e?.message ?? 'Failed to save authentication settings'
|
||||
} finally { secLoading = false }
|
||||
}
|
||||
|
||||
@@ -105,9 +127,11 @@
|
||||
const prev = { mode: settingsState.settings.serverAuthMode, user: settingsState.settings.serverAuthUser, pass: settingsState.settings.serverAuthPass }
|
||||
try {
|
||||
await requestManager.extensions.setServerAuth({ authMode: 'NONE', authUsername: '', authPassword: '' })
|
||||
authSession.clearTokens()
|
||||
configureAuth(settingsState.settings.serverUrl ?? '', 'NONE')
|
||||
updateSettings({ serverAuthMode: 'NONE', serverAuthUser: '', serverAuthPass: '' })
|
||||
authMode = 'NONE'; authUsername = ''; authPassword = ''
|
||||
authSession.clearTokens(); showSaved('auth')
|
||||
authMode = 'NONE'; authUsername = ''; authPassword = ''; authDirty = false
|
||||
showSaved('auth')
|
||||
} catch (e: any) {
|
||||
updateSettings({ serverAuthMode: prev.mode, serverAuthUser: prev.user, serverAuthPass: prev.pass })
|
||||
secError = e?.message ?? 'Failed to disable authentication'
|
||||
@@ -157,6 +181,7 @@
|
||||
authMode = 'NONE'
|
||||
authUsername = ''
|
||||
authPassword = ''
|
||||
authDirty = false
|
||||
updateSettings({ serverAuthMode: 'NONE', serverAuthUser: '', serverAuthPass: '' })
|
||||
showSaved('auth')
|
||||
}
|
||||
@@ -185,23 +210,28 @@
|
||||
<div class="s-segment">
|
||||
{#each [{ value: 'NONE', label: 'None' }, { value: 'BASIC_AUTH', label: 'Basic' }, { value: 'UI_LOGIN', label: 'UI Login' }] as opt}
|
||||
<button class="s-segment-btn" class:active={authMode === opt.value}
|
||||
onclick={() => authMode = opt.value as any} disabled={secLoading}>{opt.label}</button>
|
||||
onclick={() => { authMode = opt.value as any; authPassword = ''; authDirty = true }} disabled={secLoading}>{opt.label}</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{#if authMode !== 'NONE'}
|
||||
<div class="s-row">
|
||||
<div class="s-row-info"><span class="s-label">Username</span></div>
|
||||
<input class="s-input" bind:value={authUsername} placeholder="Username" autocomplete="off" spellcheck="false" disabled={secLoading} />
|
||||
<input class="s-input" bind:value={authUsername} oninput={() => authDirty = true} placeholder="Username" autocomplete="off" spellcheck="false" disabled={secLoading} />
|
||||
</div>
|
||||
<div class="s-row">
|
||||
<div class="s-row-info"><span class="s-label">Password</span></div>
|
||||
<div class="s-field-wrap">
|
||||
<input class="s-input" type={showAuthPass ? 'text' : 'password'} bind:value={authPassword} placeholder="Password" autocomplete="off" spellcheck="false" disabled={secLoading} />
|
||||
<input class="s-input" type={showAuthPass ? 'text' : 'password'} bind:value={authPassword} oninput={() => authDirty = true} placeholder="Password" autocomplete="off" spellcheck="false" disabled={secLoading} />
|
||||
<button class="s-eye-btn" onclick={() => showAuthPass = !showAuthPass} tabindex="-1" aria-label={showAuthPass ? 'Hide password' : 'Show password'}>{@html showAuthPass ? EyeClose : EyeOpen}</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{#if authMode !== 'NONE' && settingsState.settings.serverAuthMode === authMode && !authPassword}
|
||||
<div class="s-row">
|
||||
<span class="s-desc" style="color: var(--text-muted)">Re-enter your password to update credentials.</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if settingsState.settings.serverAuthMode === 'BASIC_AUTH'}
|
||||
<div class="s-row">
|
||||
<span class="s-desc">Images are proxied through Tauri when Basic Auth is active, which reduces loading speed.</span>
|
||||
@@ -221,8 +251,16 @@
|
||||
</button>
|
||||
{/if}
|
||||
<button class="s-btn s-btn-accent" onclick={saveAuth}
|
||||
disabled={secLoading || ((authMode === 'BASIC_AUTH' || authMode === 'UI_LOGIN') && (!authUsername.trim() || !authPassword.trim()))}>
|
||||
{secLoading ? 'Saving…' : secSaved === 'auth' ? 'Saved ✓' : settingsState.settings.serverAuthMode === 'BASIC_AUTH' ? 'Update' : authMode === 'NONE' ? 'Save' : 'Enable'}
|
||||
disabled={secLoading || (authMode !== 'NONE' && (!authUsername.trim() || !authPassword.trim()))}>
|
||||
{#if secLoading}
|
||||
Saving…
|
||||
{:else if secSaved === 'auth'}
|
||||
Saved ✓
|
||||
{:else if authMode === 'NONE'}
|
||||
Save
|
||||
{:else}
|
||||
{authDirty ? 'Enable' : 'Save'}
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -283,6 +321,41 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="s-section">
|
||||
<p class="s-section-title">App Lock</p>
|
||||
<div class="s-section-body">
|
||||
<label class="s-row">
|
||||
<div class="s-row-info">
|
||||
<span class="s-label">Require PIN on launch</span>
|
||||
<span class="s-desc">Lock the app behind a numeric PIN when it opens</span>
|
||||
</div>
|
||||
<button role="switch" aria-checked={lockEnabled} aria-label="Enable app lock" class="s-toggle" class:on={lockEnabled}
|
||||
onclick={onLockToggle}><span class="s-toggle-thumb"></span></button>
|
||||
</label>
|
||||
{#if lockEnabled}
|
||||
{#if lockError}
|
||||
<div class="s-banner s-banner-error" style="margin: 0">{lockError}</div>
|
||||
{/if}
|
||||
<div class="s-row">
|
||||
<div class="s-row-info">
|
||||
<span class="s-label">PIN</span>
|
||||
<span class="s-desc">Minimum 4 digits</span>
|
||||
</div>
|
||||
<div class="s-pin-row">
|
||||
<div class="s-field-wrap">
|
||||
<input class="s-input" type={lockPinVis ? 'text' : 'password'} inputmode="numeric" pattern="\d*"
|
||||
bind:value={lockPin} oninput={onLockPinInput} placeholder="••••" autocomplete="off" spellcheck="false" maxlength="8" />
|
||||
<button class="s-eye-btn" onclick={() => lockPinVis = !lockPinVis} tabindex="-1" aria-label={lockPinVis ? 'Hide PIN' : 'Show PIN'}>{@html lockPinVis ? EyeClose : EyeOpen}</button>
|
||||
</div>
|
||||
<button class="s-btn s-btn-accent" onclick={saveLockPin} disabled={!lockPin}>
|
||||
{lockSaved ? 'Saved ✓' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="s-section">
|
||||
<p class="s-section-title">FlareSolverr</p>
|
||||
<div class="s-section-body">
|
||||
@@ -337,4 +410,5 @@
|
||||
.s-ghost-btn { display: inline-flex; align-items: center; gap: 5px; background: none; border: none; color: var(--text-faint); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); cursor: pointer; padding: 2px 0; transition: color 0.15s; }
|
||||
.s-ghost-btn:hover:not(:disabled) { color: var(--color-error); }
|
||||
.s-ghost-btn:disabled { opacity: 0.35; cursor: default; }
|
||||
.s-pin-row { display: flex; align-items: center; gap: 8px; }
|
||||
</style>
|
||||
@@ -12,6 +12,9 @@
|
||||
import { clearBlobCache } from '$lib/core/cache/imageCache'
|
||||
import { clearPageCache } from '$lib/request-manager'
|
||||
import { cache as queryCache } from '$lib/core/cache/queryCache'
|
||||
import { getAdapter } from '$lib/request-manager'
|
||||
import { requestManager } from '$lib/request-manager'
|
||||
import type { ValidateBackupResult, RestoreStatus } from '$lib/server-adapters/types'
|
||||
|
||||
const supportsFilesystem = platformService.isSupported('filesystem')
|
||||
|
||||
@@ -79,7 +82,7 @@
|
||||
await Promise.all([
|
||||
platformService.clearMokuCache(),
|
||||
platformService.clearSuwayomiCache(),
|
||||
gql(`mutation { clearCachedImages(input: { cachedPages: true, cachedThumbnails: true, downloadedThumbnails: false }) { cachedPages cachedThumbnails } }`),
|
||||
getAdapter().clearCachedImages({ cachedPages: true, cachedThumbnails: true, downloadedThumbnails: false }),
|
||||
])
|
||||
}
|
||||
|
||||
@@ -168,11 +171,7 @@
|
||||
if (!supportsFilesystem) return
|
||||
storageLoading = true; storageError = null
|
||||
try {
|
||||
const pathData = await gql<{ downloadsPath: string | null; localSourcePath: string | null }>(
|
||||
`{ downloadsPath localSourcePath }`
|
||||
)
|
||||
const dl = pathData.downloadsPath ?? ''
|
||||
const loc = pathData.localSourcePath ?? ''
|
||||
const { downloadsPath: dl, localSourcePath: loc } = await getAdapter().getDownloadsPath()
|
||||
downloadsPathInput = dl; localSourcePathInput = loc
|
||||
confirmedDownloadsPath = dl; confirmedLocalSourcePath = loc
|
||||
updateSettings({ serverDownloadsPath: dl, serverLocalSourcePath: loc })
|
||||
@@ -218,8 +217,8 @@
|
||||
if (dlErr || locErr) { pathsFieldError = { ...(dlErr ? { dl: dlErr } : {}), ...(locErr ? { loc: locErr } : {}) }; return }
|
||||
pathsSaving = true
|
||||
try {
|
||||
await gql(`mutation($path: String!) { setDownloadsPath(input: { location: $path }) { location } }`, { path: dl })
|
||||
if (loc) await gql(`mutation($path: String!) { setLocalSourcePath(input: { location: $path }) { location } }`, { path: loc })
|
||||
await getAdapter().setDownloadsPath(dl)
|
||||
if (loc) await getAdapter().setLocalSourcePath(loc)
|
||||
updateSettings({ serverDownloadsPath: dl, serverLocalSourcePath: loc })
|
||||
if (supportsFilesystem && !isExternalServer) {
|
||||
const oldDl = confirmedDownloadsPath || defaultDownloadsPath
|
||||
@@ -301,8 +300,7 @@
|
||||
async function createBackup() {
|
||||
backupLoading = true; backupError = null
|
||||
try {
|
||||
const data = await gql<{ createBackup: { url: string } }>(`mutation { createBackup { url } }`)
|
||||
const { url } = data.createBackup
|
||||
const { url } = await getAdapter().createBackup()
|
||||
const name = url.split('/').pop() ?? url
|
||||
backupList = [{ url, name }, ...backupList]
|
||||
await saveBackupList()
|
||||
@@ -313,7 +311,7 @@
|
||||
async function deleteBackup(url: string) {
|
||||
backupList = backupList.map(b => b.url === url ? { ...b, deleting: true } : b)
|
||||
try {
|
||||
await fetch(`${serverUrl()}${url}`, { method: 'DELETE', headers: buildAuthHeaders() })
|
||||
await getAdapter().deleteBackup(url)
|
||||
backupList = backupList.filter(b => b.url !== url)
|
||||
await saveBackupList()
|
||||
} catch (e: any) {
|
||||
@@ -324,9 +322,7 @@
|
||||
|
||||
async function downloadBackup(backup: BackupEntry) {
|
||||
try {
|
||||
const resp = await fetch(`${serverUrl()}${backup.url}`, { headers: buildAuthHeaders() })
|
||||
if (!resp.ok) throw new Error(`Server returned ${resp.status}`)
|
||||
const blob = await resp.blob()
|
||||
const blob = await getAdapter().downloadBackup(backup.url)
|
||||
if ('showSaveFilePicker' in window) {
|
||||
try {
|
||||
const handle = await (window as any).showSaveFilePicker({
|
||||
@@ -349,12 +345,11 @@
|
||||
|
||||
let restoreLoading = $state(false)
|
||||
let restoreError = $state<string | null>(null)
|
||||
let restoreJobId = $state<string | null>(null)
|
||||
let restoreStatus = $state<{ mangaProgress: number; state: string; totalManga: number } | null>(null)
|
||||
let restoreStatus = $state<RestoreStatus | null>(null)
|
||||
let restorePollInterval = $state<ReturnType<typeof setInterval> | null>(null)
|
||||
let validateLoading = $state(false)
|
||||
let validateError = $state<string | null>(null)
|
||||
let validateResult = $state<{ missingSources: { id: string; name: string }[]; missingTrackers: { name: string }[] } | null>(null)
|
||||
let validateResult = $state<ValidateBackupResult | null>(null)
|
||||
let restoreFile = $state<File | null>(null)
|
||||
|
||||
function stopRestorePoll() {
|
||||
@@ -363,62 +358,19 @@
|
||||
|
||||
async function pollRestoreStatus(id: string) {
|
||||
try {
|
||||
const data = await gql<{ restoreStatus: { mangaProgress: number; state: string; totalManga: number } }>(
|
||||
`query($id: String!) { restoreStatus(id: $id) { mangaProgress state totalManga } }`,
|
||||
{ id }
|
||||
)
|
||||
const status = data.restoreStatus
|
||||
const status = await getAdapter().pollRestoreStatus(id)
|
||||
restoreStatus = status
|
||||
if (status?.state === 'SUCCESS' || status?.state === 'FAILURE') stopRestorePoll()
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function buildBackupFormData(file: File, query: string, variables: Record<string, unknown>) {
|
||||
const form = new FormData()
|
||||
form.append('operations', JSON.stringify({ query, variables }))
|
||||
form.append('map', JSON.stringify({ '0': ['variables.backup'] }))
|
||||
form.append('0', file, file.name)
|
||||
return form
|
||||
}
|
||||
|
||||
function buildAuthHeaders(): Record<string, string> {
|
||||
const headers: Record<string, string> = { Accept: 'application/json' }
|
||||
const pass = settingsState.settings.serverAuthPass ?? '', user = settingsState.settings.serverAuthUser ?? ''
|
||||
if (settingsState.settings.serverAuthMode === 'BASIC_AUTH' && user && pass)
|
||||
headers['Authorization'] = 'Basic ' + btoa(`${user}:${pass}`)
|
||||
return headers
|
||||
}
|
||||
|
||||
function serverUrl(): string {
|
||||
return (settingsState.settings.serverUrl ?? 'http://localhost:4567').replace(/\/$/, '')
|
||||
}
|
||||
|
||||
async function gql<T = unknown>(query: string, variables?: Record<string, unknown>): Promise<T> {
|
||||
const res = await fetch(`${serverUrl()}/api/graphql`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...buildAuthHeaders() },
|
||||
body: JSON.stringify({ query, variables }),
|
||||
})
|
||||
const json = await res.json()
|
||||
if (json.errors?.length) throw new Error(json.errors[0].message)
|
||||
return json.data as T
|
||||
}
|
||||
|
||||
async function submitRestore() {
|
||||
if (!restoreFile) return
|
||||
restoreLoading = true; restoreError = null; restoreStatus = null; restoreJobId = null
|
||||
restoreLoading = true; restoreError = null; restoreStatus = null
|
||||
stopRestorePoll()
|
||||
try {
|
||||
const form = buildBackupFormData(
|
||||
restoreFile,
|
||||
`mutation RestoreBackup($backup: Upload!) { restoreBackup(input: { backup: $backup }) { id status { mangaProgress state totalManga } } }`,
|
||||
{ backup: null }
|
||||
)
|
||||
const resp = await fetch(`${serverUrl()}/api/graphql`, { method: 'POST', headers: buildAuthHeaders(), body: form })
|
||||
const json = await resp.json()
|
||||
if (json.errors?.length) throw new Error(json.errors[0].message)
|
||||
const result = json.data.restoreBackup
|
||||
restoreJobId = result.id; restoreStatus = result.status
|
||||
const result = await requestManager.meta.restoreBackup(restoreFile)
|
||||
restoreStatus = result.status
|
||||
if (result.status?.state !== 'SUCCESS' && result.status?.state !== 'FAILURE')
|
||||
restorePollInterval = setInterval(() => pollRestoreStatus(result.id), 1500)
|
||||
} catch (e: any) { restoreError = e?.message ?? 'Failed to start restore' }
|
||||
@@ -429,15 +381,7 @@
|
||||
if (!restoreFile) return
|
||||
validateLoading = true; validateError = null; validateResult = null
|
||||
try {
|
||||
const form = buildBackupFormData(
|
||||
restoreFile,
|
||||
`query ValidateBackup($backup: Upload!) { validateBackup(input: { backup: $backup }) { missingSources { id name } missingTrackers { name } } }`,
|
||||
{ backup: null }
|
||||
)
|
||||
const resp = await fetch(`${serverUrl()}/api/graphql`, { method: 'POST', headers: buildAuthHeaders(), body: form })
|
||||
const json = await resp.json()
|
||||
if (json.errors?.length) throw new Error(json.errors[0].message)
|
||||
validateResult = json.data.validateBackup
|
||||
validateResult = await requestManager.meta.validateBackup(restoreFile)
|
||||
} catch (e: any) { validateError = e?.message ?? 'Failed to validate backup' }
|
||||
finally { validateLoading = false }
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { settingsState, updateSettings } from "$lib/state/settings.svelte";
|
||||
import { toast } from "$lib/state/notifications.svelte";
|
||||
import { getAdapter } from "$lib/request-manager";
|
||||
import { platformService } from "$lib/platform-service";
|
||||
import { syncBackFromTracker } from "$lib/components/tracking/lib/trackingSync";
|
||||
import { trackingState } from "$lib/state/tracking.svelte";
|
||||
import type { Tracker, TrackRecord } from "$lib/types/index";
|
||||
@@ -41,7 +42,7 @@
|
||||
async function startOAuth(tracker: Tracker) {
|
||||
if (!tracker.authUrl) return;
|
||||
oauthTrackerId = tracker.id; oauthCallbackInput = "";
|
||||
window.open(tracker.authUrl, "_blank");
|
||||
await platformService.openExternal(tracker.authUrl);
|
||||
}
|
||||
|
||||
async function submitOAuth() {
|
||||
@@ -274,6 +275,7 @@
|
||||
|
||||
<style>
|
||||
.s-tracker-status-row { display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap; }
|
||||
.s-tracker-status-row .s-pill { border-radius: 4px; }
|
||||
.s-pill-warn { background: color-mix(in srgb, var(--color-warn, #c97c2b) 15%, transparent); color: var(--color-warn, #c97c2b); border-color: color-mix(in srgb, var(--color-warn, #c97c2b) 35%, transparent); }
|
||||
.s-banner-dismissible { cursor: pointer; max-height: 8rem; overflow-y: auto; }
|
||||
.s-banner-dismissible:hover { opacity: 0.85; }
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
let {
|
||||
src,
|
||||
id = undefined,
|
||||
alt = "",
|
||||
class: cls = "",
|
||||
loading = "lazy",
|
||||
@@ -13,6 +14,7 @@
|
||||
...rest
|
||||
}: {
|
||||
src: string | null | undefined;
|
||||
id?: string | number;
|
||||
alt?: string;
|
||||
class?: string;
|
||||
loading?: string;
|
||||
@@ -27,10 +29,14 @@
|
||||
return typeof url === "string" && url.trim() ? url.replace(/\/$/, "") : "http://127.0.0.1:4567";
|
||||
}
|
||||
|
||||
function withBust(url: string): string {
|
||||
return id != null ? `${url}${url.includes('?') ? '&' : '?'}id=${id}` : url;
|
||||
}
|
||||
|
||||
function plainThumbUrl(path: string | null | undefined): string {
|
||||
if (!path) return "";
|
||||
if (path.startsWith("http")) return path;
|
||||
return `${getServerUrl()}${path}`;
|
||||
const base = path.startsWith("http") ? path : `${getServerUrl()}${path}`;
|
||||
return withBust(base);
|
||||
}
|
||||
|
||||
const isAuth = $derived((settingsState.settings.serverAuthMode ?? "NONE") !== "NONE");
|
||||
@@ -45,15 +51,15 @@
|
||||
|
||||
if (!_isAuth || !_src) { blobUrl = ""; return; }
|
||||
|
||||
const id = ++reqId;
|
||||
const myId = ++reqId;
|
||||
const bareUrl = _src.startsWith("http") ? _src : `${getServerUrl()}${_src}`;
|
||||
getBlobUrl(bareUrl, _priority)
|
||||
.then(u => { if (id === reqId) blobUrl = u; })
|
||||
.catch(() => { if (id === reqId) blobUrl = ""; });
|
||||
getBlobUrl(withBust(bareUrl), _priority)
|
||||
.then(u => { if (myId === reqId) blobUrl = u; })
|
||||
.catch(() => { if (myId === reqId) blobUrl = ""; });
|
||||
});
|
||||
|
||||
const plainUrl = $derived(plainThumbUrl(src));
|
||||
const resolved = $derived(isAuth ? (blobUrl || plainUrl) || undefined : plainUrl || undefined);
|
||||
const plainUrl = $derived(plainThumbUrl(src));
|
||||
const resolved = $derived(isAuth ? (blobUrl || plainUrl) || undefined : plainUrl || undefined);
|
||||
</script>
|
||||
|
||||
<img src={resolved} {alt} class={cls} {loading} {decoding} {onerror} {...rest} />
|
||||
+59
-74
@@ -1,4 +1,5 @@
|
||||
const DEFAULT_URL = 'http://127.0.0.1:4567'
|
||||
const SKEW_MS = 60_000 * 2
|
||||
|
||||
interface AuthConfig {
|
||||
baseUrl: string
|
||||
@@ -10,74 +11,61 @@ interface AuthConfig {
|
||||
export interface UiAuthDebugStatus {
|
||||
mode: 'NONE' | 'BASIC_AUTH' | 'UI_LOGIN'
|
||||
hasSession: boolean
|
||||
hasRefreshToken: boolean
|
||||
accessExpiresAt: number | null
|
||||
refreshExpiresAt: number | null
|
||||
accessExpiresInMs: number | null
|
||||
refreshExpiresInMs: number | null
|
||||
shouldRefreshSoon: boolean
|
||||
refreshInFlight: boolean
|
||||
skewMs: number
|
||||
}
|
||||
|
||||
const SKEW_MS = 60_000 * 2
|
||||
|
||||
let config: AuthConfig = { baseUrl: DEFAULT_URL, mode: 'NONE' }
|
||||
|
||||
let accessToken: string | null = null
|
||||
let refreshToken: string | null = null
|
||||
let accessExpiresAt: number | null = null
|
||||
let refreshExpiresAt: number | null = null
|
||||
let refreshInFlight = false
|
||||
let accessToken: string | null = null
|
||||
let refreshToken: string | null = null
|
||||
let accessExpiresAt: number | null = null
|
||||
let refreshInFlight = false
|
||||
|
||||
function parseExpiry(token: string): number | null {
|
||||
try {
|
||||
const payload = JSON.parse(atob(token.split('.')[1]))
|
||||
return typeof payload.exp === 'number' ? payload.exp * 1000 : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
} catch { return null }
|
||||
}
|
||||
|
||||
export const authSession = {
|
||||
clearTokens() {
|
||||
accessToken = null
|
||||
refreshToken = null
|
||||
accessExpiresAt = null
|
||||
refreshExpiresAt = null
|
||||
accessToken = null
|
||||
refreshToken = null
|
||||
accessExpiresAt = null
|
||||
},
|
||||
}
|
||||
|
||||
export function getUIAccessToken(): string | null {
|
||||
return accessToken
|
||||
}
|
||||
export function getUIAccessToken(): string | null { return accessToken }
|
||||
|
||||
export function getUiAuthDebugStatus(): UiAuthDebugStatus {
|
||||
const now = Date.now()
|
||||
const accessExpiresInMs = accessExpiresAt !== null ? accessExpiresAt - now : null
|
||||
const refreshExpiresInMs = refreshExpiresAt !== null ? refreshExpiresAt - now : null
|
||||
const now = Date.now()
|
||||
const accessExpiresInMs = accessExpiresAt !== null ? accessExpiresAt - now : null
|
||||
return {
|
||||
mode: config.mode,
|
||||
hasSession: accessToken !== null,
|
||||
hasRefreshToken: refreshToken !== null,
|
||||
mode: config.mode,
|
||||
hasSession: accessToken !== null,
|
||||
accessExpiresAt,
|
||||
refreshExpiresAt,
|
||||
accessExpiresInMs,
|
||||
refreshExpiresInMs,
|
||||
shouldRefreshSoon: accessExpiresInMs !== null && accessExpiresInMs < SKEW_MS,
|
||||
shouldRefreshSoon: accessExpiresInMs !== null && accessExpiresInMs < SKEW_MS,
|
||||
refreshInFlight,
|
||||
skewMs: SKEW_MS,
|
||||
skewMs: SKEW_MS,
|
||||
}
|
||||
}
|
||||
|
||||
export function configureAuth(
|
||||
baseUrl: string,
|
||||
mode: 'NONE' | 'BASIC_AUTH' | 'UI_LOGIN',
|
||||
user?: string,
|
||||
pass?: string,
|
||||
mode: 'NONE' | 'BASIC_AUTH' | 'UI_LOGIN',
|
||||
user?: string,
|
||||
pass?: string,
|
||||
): void {
|
||||
config = { baseUrl: baseUrl.replace(/\/$/, ''), mode, user, pass }
|
||||
authSession.clearTokens()
|
||||
config = { baseUrl: baseUrl.replace(/\/$/, ''), mode, user, pass }
|
||||
accessToken = null
|
||||
refreshToken = null
|
||||
accessExpiresAt = null
|
||||
}
|
||||
|
||||
export function authHeaders(): Record<string, string> {
|
||||
@@ -90,16 +78,18 @@ export function authHeaders(): Record<string, string> {
|
||||
return {}
|
||||
}
|
||||
|
||||
async function gqlRaw(query: string, variables?: Record<string, unknown>): Promise<unknown> {
|
||||
async function gql<T>(query: string, variables?: Record<string, unknown>, bare = false): Promise<T> {
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
|
||||
if (!bare) Object.assign(headers, authHeaders())
|
||||
const res = await fetch(`${config.baseUrl}/api/graphql`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...authHeaders() },
|
||||
headers,
|
||||
body: JSON.stringify({ query, variables }),
|
||||
})
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
const json = await res.json()
|
||||
if (json.errors?.length) throw new Error(json.errors[0].message)
|
||||
return json.data
|
||||
return json.data as T
|
||||
}
|
||||
|
||||
export async function probeServer(): Promise<'ok' | 'auth_required' | 'unreachable'> {
|
||||
@@ -107,7 +97,7 @@ export async function probeServer(): Promise<'ok' | 'auth_required' | 'unreachab
|
||||
const res = await fetch(`${config.baseUrl}/api/graphql`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...authHeaders() },
|
||||
body: JSON.stringify({ query: '{ aboutServer { name } }' }),
|
||||
body: JSON.stringify({ query: '{ settings { authMode } }' }),
|
||||
})
|
||||
if (res.status === 401 || res.status === 403) return 'auth_required'
|
||||
if (!res.ok) return 'unreachable'
|
||||
@@ -116,17 +106,7 @@ export async function probeServer(): Promise<'ok' | 'auth_required' | 'unreachab
|
||||
/unauthorized|unauthenticated/i.test(e.message)
|
||||
)
|
||||
return isAuthError ? 'auth_required' : 'ok'
|
||||
} catch {
|
||||
return 'unreachable'
|
||||
}
|
||||
}
|
||||
|
||||
export async function loginBasic(user: string, pass: string): Promise<void> {
|
||||
config.user = user
|
||||
config.pass = pass
|
||||
config.mode = 'BASIC_AUTH'
|
||||
const probe = await probeServer()
|
||||
if (probe !== 'ok') throw new Error('Invalid credentials')
|
||||
} catch { return 'unreachable' }
|
||||
}
|
||||
|
||||
const LOGIN_MUTATION = `
|
||||
@@ -145,30 +125,29 @@ const REFRESH_MUTATION = `
|
||||
}
|
||||
`
|
||||
|
||||
export async function loginUI(user: string, pass: string): Promise<void> {
|
||||
const data = await gqlRaw(LOGIN_MUTATION, { username: user, password: pass }) as {
|
||||
login: { accessToken: string; refreshToken: string }
|
||||
export async function loginBasic(user: string, pass: string): Promise<void> {
|
||||
const prev = { user: config.user, pass: config.pass, mode: config.mode }
|
||||
config.user = user
|
||||
config.pass = pass
|
||||
config.mode = 'BASIC_AUTH'
|
||||
const probe = await probeServer()
|
||||
if (probe !== 'ok') {
|
||||
config.user = prev.user
|
||||
config.pass = prev.pass
|
||||
config.mode = prev.mode as typeof config.mode
|
||||
throw new Error('Invalid credentials')
|
||||
}
|
||||
accessToken = data.login.accessToken
|
||||
refreshToken = data.login.refreshToken
|
||||
accessExpiresAt = parseExpiry(accessToken)
|
||||
refreshExpiresAt = parseExpiry(refreshToken)
|
||||
config.mode = 'UI_LOGIN'
|
||||
config.user = user
|
||||
}
|
||||
|
||||
export async function refreshAccessToken(): Promise<boolean> {
|
||||
if (!refreshToken) return false
|
||||
try {
|
||||
const data = await gqlRaw(REFRESH_MUTATION, { refreshToken }) as {
|
||||
refreshToken: { accessToken: string }
|
||||
}
|
||||
accessToken = data.refreshToken.accessToken
|
||||
accessExpiresAt = parseExpiry(accessToken)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
export async function loginUI(user: string, pass: string): Promise<void> {
|
||||
const data = await gql<{ login: { accessToken: string; refreshToken: string } }>(
|
||||
LOGIN_MUTATION, { username: user, password: pass }, true
|
||||
)
|
||||
accessToken = data.login.accessToken
|
||||
refreshToken = data.login.refreshToken
|
||||
accessExpiresAt = parseExpiry(accessToken)
|
||||
config.mode = 'UI_LOGIN'
|
||||
config.user = user
|
||||
}
|
||||
|
||||
export async function refreshUiAccessToken(force = false): Promise<string | null> {
|
||||
@@ -179,9 +158,15 @@ export async function refreshUiAccessToken(force = false): Promise<string | null
|
||||
if (refreshInFlight) return accessToken
|
||||
refreshInFlight = true
|
||||
try {
|
||||
const ok = await refreshAccessToken()
|
||||
return ok ? accessToken : null
|
||||
const data = await gql<{ refreshToken: { accessToken: string } }>(
|
||||
REFRESH_MUTATION, { refreshToken }
|
||||
)
|
||||
accessToken = data.refreshToken.accessToken
|
||||
accessExpiresAt = parseExpiry(accessToken)
|
||||
return accessToken
|
||||
} catch {
|
||||
return null
|
||||
} finally {
|
||||
refreshInFlight = false
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
+8
-10
@@ -1,6 +1,6 @@
|
||||
import { fetch as tauriFetch } from "@tauri-apps/plugin-http";
|
||||
import { settingsState } from "$lib/state/settings.svelte";
|
||||
import { getUIAccessToken } from "$lib/core/auth";
|
||||
import { platformService } from "$lib/platform-service";
|
||||
import { settingsState } from "$lib/state/settings.svelte";
|
||||
import { getUIAccessToken } from "$lib/core/auth";
|
||||
|
||||
const cache = new Map<string, string>();
|
||||
const inflight = new Map<string, Promise<string>>();
|
||||
@@ -19,14 +19,14 @@ interface QueueEntry {
|
||||
const queue: QueueEntry[] = [];
|
||||
|
||||
async function getAuthHeaders(): Promise<Record<string, string>> {
|
||||
const mode = settingsState.serverAuthMode ?? "NONE";
|
||||
const mode = settingsState.settings.serverAuthMode ?? "NONE";
|
||||
if (mode === "UI_LOGIN") {
|
||||
const token = await getUIAccessToken();
|
||||
const token = getUIAccessToken();
|
||||
return token ? { Authorization: `Bearer ${token}` } : {};
|
||||
}
|
||||
if (mode === "BASIC_AUTH") {
|
||||
const user = settingsState.serverAuthUser?.trim() ?? "";
|
||||
const pass = settingsState.serverAuthPass?.trim() ?? "";
|
||||
const user = settingsState.settings.serverAuthUser?.trim() ?? "";
|
||||
const pass = settingsState.settings.serverAuthPass?.trim() ?? "";
|
||||
return user && pass ? { Authorization: `Basic ${btoa(`${user}:${pass}`)}` } : {};
|
||||
}
|
||||
return {};
|
||||
@@ -34,9 +34,7 @@ async function getAuthHeaders(): Promise<Record<string, string>> {
|
||||
|
||||
async function doFetch(url: string): Promise<string> {
|
||||
const headers = await getAuthHeaders();
|
||||
const res = await tauriFetch(url, { method: "GET", headers });
|
||||
if (!res.ok) throw new Error(`${res.status}`);
|
||||
const blob = await res.blob();
|
||||
const blob = await platformService.fetchImage(url, headers);
|
||||
if (clearing) throw new DOMException("Cancelled", "AbortError");
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
cache.set(url, blobUrl);
|
||||
|
||||
+33
-24
@@ -1,16 +1,14 @@
|
||||
import { platformService } from '$lib/platform-service'
|
||||
import { seriesState } from '$lib/state/series.svelte'
|
||||
import { settingsState } from '$lib/state/settings.svelte'
|
||||
import { addToast } from '$lib/state/notifications.svelte'
|
||||
import type { Manga } from '$lib/types'
|
||||
|
||||
function sanitizeTitle(title: string): string {
|
||||
return title.replace(/[\\/:*?"<>|]/g, '').replace(/\s+/g, ' ').trim()
|
||||
function sanitize(s: string): string {
|
||||
return s.replace(/[\\/:*?"<>|]/g, '').replace(/\s+/g, ' ').trim()
|
||||
}
|
||||
|
||||
async function getDownloadsRoot(): Promise<string> {
|
||||
let root = (seriesState.settings as any).downloadsPath?.trim() ?? ''
|
||||
if (!root) root = await platformService.getDefaultDownloadsPath().catch(() => '')
|
||||
return root
|
||||
function getDownloadsRoot(): string {
|
||||
return settingsState.settings?.serverDownloadsPath?.trim() ?? ''
|
||||
}
|
||||
|
||||
function join(root: string, ...parts: string[]): string {
|
||||
@@ -18,31 +16,42 @@ function join(root: string, ...parts: string[]): string {
|
||||
return [root.replace(/[/\\]$/, ''), ...parts].join(sep)
|
||||
}
|
||||
|
||||
export async function openMangaFolder(manga: Manga): Promise<void> {
|
||||
function checkSupported(): boolean {
|
||||
if (!platformService.isSupported('filesystem')) {
|
||||
addToast({ kind: 'info', title: 'Desktop only', body: 'Opening folders requires the desktop app.' })
|
||||
return
|
||||
return false
|
||||
}
|
||||
const root = await getDownloadsRoot()
|
||||
if (!root) return
|
||||
await platformService.openPath(join(root, 'mangas', sanitizeTitle(manga.title))).catch(console.error)
|
||||
return true
|
||||
}
|
||||
|
||||
export async function openCustomFolder(path: string): Promise<void> {
|
||||
if (!platformService.isSupported('filesystem')) {
|
||||
addToast({ kind: 'info', title: 'Desktop only', body: 'Opening folders requires the desktop app.' })
|
||||
return
|
||||
function checkRoot(root: string): boolean {
|
||||
if (!root) {
|
||||
addToast({ kind: 'error', title: 'No downloads path set', body: 'Configure it in Settings → Storage' })
|
||||
return false
|
||||
}
|
||||
if (!path?.trim()) return
|
||||
return true
|
||||
}
|
||||
|
||||
export async function openMangaFolder(manga: Manga): Promise<void> {
|
||||
if (!checkSupported()) return
|
||||
const root = getDownloadsRoot()
|
||||
if (!checkRoot(root)) return
|
||||
const source = (manga as any).source?.displayName ?? (manga as any).source?.name ?? ''
|
||||
const path = source
|
||||
? join(root, 'mangas', sanitize(source), sanitize(manga.title))
|
||||
: join(root, 'mangas', sanitize(manga.title))
|
||||
await platformService.openPath(path).catch(console.error)
|
||||
}
|
||||
|
||||
export async function openDownloadsFolder(): Promise<void> {
|
||||
if (!platformService.isSupported('filesystem')) {
|
||||
addToast({ kind: 'info', title: 'Desktop only', body: 'Opening folders requires the desktop app.' })
|
||||
return
|
||||
}
|
||||
const root = await getDownloadsRoot()
|
||||
if (!root) return
|
||||
await platformService.openPath(join(root, 'mangas')).catch(console.error)
|
||||
if (!checkSupported()) return
|
||||
const root = getDownloadsRoot()
|
||||
if (!checkRoot(root)) return
|
||||
await platformService.openPath(root).catch(console.error)
|
||||
}
|
||||
|
||||
export async function openCustomFolder(path: string): Promise<void> {
|
||||
if (!checkSupported()) return
|
||||
if (!path?.trim()) return
|
||||
await platformService.openPath(path).catch(console.error)
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { getCurrentWindow } from '@tauri-apps/api
|
||||
import { listen } from '@tauri-apps/api/event'
|
||||
import { open } from '@tauri-apps/plugin-dialog'
|
||||
import { readFile, writeFile } from '@tauri-apps/plugin-fs'
|
||||
import { fetch as tauriFetch } from '@tauri-apps/plugin-http'
|
||||
import { open as openUrl } from '@tauri-apps/plugin-shell'
|
||||
import { getVersion } from '@tauri-apps/api/app'
|
||||
import { connect, disconnect, setActivity, clearActivity } from 'tauri-plugin-discord-rpc-api'
|
||||
@@ -40,7 +41,11 @@ export class TauriAdapter implements PlatformAdapter {
|
||||
|
||||
async loadStore(key: string): Promise<unknown> {
|
||||
try {
|
||||
return await invoke<unknown>('load_store', { key })
|
||||
const raw = await invoke<unknown>('load_store', { key })
|
||||
if (typeof raw === 'string') {
|
||||
try { return JSON.parse(raw) } catch { return null }
|
||||
}
|
||||
return raw
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
@@ -112,6 +117,12 @@ export class TauriAdapter implements PlatformAdapter {
|
||||
return invoke('get_auto_backup_dir')
|
||||
}
|
||||
|
||||
async fetchImage(url: string, headers: Record<string, string>): Promise<Blob> {
|
||||
const res = await tauriFetch(url, { method: 'GET', headers })
|
||||
if (!res.ok) throw new Error(`${res.status}`)
|
||||
return res.blob()
|
||||
}
|
||||
|
||||
async launchServer(config: ServerLaunchConfig): Promise<void> {
|
||||
await invoke('spawn_server', {
|
||||
binary: config.binary ?? '',
|
||||
|
||||
@@ -93,6 +93,8 @@ export interface PlatformAdapter {
|
||||
migrateDownloads(src: string, dst: string): Promise<void>
|
||||
getAutoBackupDir(): Promise<string>
|
||||
|
||||
fetchImage(url: string, headers: Record<string, string>): Promise<Blob>
|
||||
|
||||
launchServer(config: ServerLaunchConfig): Promise<void>
|
||||
stopServer(): Promise<void>
|
||||
getServerStatus(): Promise<'running' | 'stopped' | 'error'>
|
||||
|
||||
@@ -61,6 +61,12 @@ export class WebAdapter implements PlatformAdapter {
|
||||
async migrateDownloads(_src: string, _dst: string): Promise<void> {}
|
||||
async getAutoBackupDir(): Promise<string> { return '' }
|
||||
|
||||
async fetchImage(url: string, headers: Record<string, string>): Promise<Blob> {
|
||||
const res = await fetch(url, { method: 'GET', headers })
|
||||
if (!res.ok) throw new Error(`${res.status}`)
|
||||
return res.blob()
|
||||
}
|
||||
|
||||
async launchServer(_config: ServerLaunchConfig): Promise<void> {}
|
||||
async stopServer(): Promise<void> {}
|
||||
async getServerStatus(): Promise<'running' | 'stopped' | 'error'> { return 'stopped' }
|
||||
|
||||
@@ -42,6 +42,8 @@ export const platformService = {
|
||||
migrateDownloads:(src: string, dst: string) => get().migrateDownloads(src, dst),
|
||||
getAutoBackupDir:() => get().getAutoBackupDir(),
|
||||
|
||||
fetchImage: (url: string, headers: Record<string, string>) => get().fetchImage(url, headers),
|
||||
|
||||
launchServer: (c: ServerLaunchConfig) => get().launchServer(c),
|
||||
stopServer: () => get().stopServer(),
|
||||
getServerStatus: () => get().getServerStatus(),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { getAdapter } from "$lib/request-manager";
|
||||
import type { ValidateBackupResult, RestoreStatus } from "$lib/server-adapters/types";
|
||||
|
||||
export async function getAboutServer() {
|
||||
return getAdapter().getAboutServer();
|
||||
@@ -6,4 +7,12 @@ export async function getAboutServer() {
|
||||
|
||||
export async function getAboutWebUI() {
|
||||
return getAdapter().getAboutWebUI();
|
||||
}
|
||||
|
||||
export async function restoreBackup(file: File): Promise<{ id: string; status: RestoreStatus }> {
|
||||
return getAdapter().restoreBackup(file);
|
||||
}
|
||||
|
||||
export async function validateBackup(file: File): Promise<ValidateBackupResult> {
|
||||
return getAdapter().validateBackup(file);
|
||||
}
|
||||
@@ -79,15 +79,6 @@ export const CLEAR_DOWNLOADER = `
|
||||
}
|
||||
`
|
||||
|
||||
export const FETCH_SOURCE_MANGA = `
|
||||
mutation FetchSourceManga($source: LongString!, $type: FetchSourceMangaType!, $page: Int!, $query: String, $filters: [FilterChangeInput!]) {
|
||||
fetchSourceManga(input: { source: $source, type: $type, page: $page, query: $query, filters: $filters }) {
|
||||
mangas { id title thumbnailUrl inLibrary }
|
||||
hasNextPage
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const SET_DOWNLOADS_PATH = `
|
||||
mutation SetDownloadsPath($path: String!) {
|
||||
setSettings(input: { settings: { downloadsPath: $path } }) {
|
||||
|
||||
@@ -16,6 +16,8 @@ import type {
|
||||
TrackRecordPatch,
|
||||
AboutServer,
|
||||
AboutWebUI,
|
||||
RestoreStatus,
|
||||
ValidateBackupResult,
|
||||
} from '$lib/server-adapters/types'
|
||||
import type { DownloadStatus } from '$lib/types/api'
|
||||
import type { Manga, Chapter, Extension, Source, Tracker, TrackRecord, Category } from '$lib/types'
|
||||
@@ -23,12 +25,14 @@ import {
|
||||
GET_LIBRARY,
|
||||
GET_MANGA,
|
||||
GET_CATEGORIES,
|
||||
GET_DOWNLOADS_PATH,
|
||||
FETCH_MANGA,
|
||||
UPDATE_MANGA,
|
||||
UPDATE_MANGAS,
|
||||
UPDATE_MANGA_CATEGORIES,
|
||||
UPDATE_MANGAS_CATEGORIES,
|
||||
CREATE_CATEGORY,
|
||||
UPDATE_CATEGORY,
|
||||
DELETE_CATEGORY,
|
||||
UPDATE_CATEGORY_ORDER,
|
||||
UPDATE_CATEGORY_MANGA,
|
||||
@@ -37,9 +41,11 @@ import {
|
||||
UPDATE_STOP,
|
||||
SET_MANGA_META,
|
||||
DELETE_MANGA_META,
|
||||
CREATE_BACKUP,
|
||||
FETCH_SOURCE_MANGA,
|
||||
LIBRARY_UPDATE_STATUS,
|
||||
MANGAS_BY_GENRE,
|
||||
POLL_RESTORE_STATUS,
|
||||
} from './manga'
|
||||
import {
|
||||
GET_CHAPTERS,
|
||||
@@ -64,16 +70,26 @@ import {
|
||||
START_DOWNLOADER,
|
||||
STOP_DOWNLOADER,
|
||||
CLEAR_DOWNLOADER,
|
||||
SET_DOWNLOADS_PATH,
|
||||
SET_LOCAL_SOURCE_PATH,
|
||||
} from './downloads'
|
||||
import {
|
||||
GET_EXTENSIONS,
|
||||
GET_SOURCES,
|
||||
GET_SOURCE_SETTINGS,
|
||||
GET_SETTINGS,
|
||||
GET_SERVER_SECURITY,
|
||||
FETCH_EXTENSIONS,
|
||||
UPDATE_EXTENSION,
|
||||
UPDATE_EXTENSIONS,
|
||||
INSTALL_EXTERNAL_EXTENSION,
|
||||
UPDATE_SOURCE_PREFERENCE,
|
||||
SET_SOURCE_META,
|
||||
DELETE_SOURCE_META,
|
||||
SET_EXTENSION_REPOS,
|
||||
SET_SERVER_AUTH,
|
||||
CLEAR_CACHED_IMAGES,
|
||||
RESET_SETTINGS,
|
||||
} from './extensions'
|
||||
import {
|
||||
GET_TRACKERS,
|
||||
@@ -85,11 +101,22 @@ import {
|
||||
UNLINK_TRACK,
|
||||
TRACK_PROGRESS,
|
||||
UPDATE_TRACK,
|
||||
LOGIN_TRACKER_CREDENTIALS,
|
||||
LOGOUT_TRACKER,
|
||||
LOGIN_TRACKER_OAUTH,
|
||||
} from './tracking'
|
||||
import {
|
||||
GET_ABOUT_SERVER,
|
||||
GET_ABOUT_WEBUI,
|
||||
CHECK_FOR_SERVER_UPDATES,
|
||||
GET_META,
|
||||
GET_METAS,
|
||||
SET_SOCKS_PROXY,
|
||||
SET_FLARE_SOLVERR,
|
||||
RESTORE_BACKUP,
|
||||
VALIDATE_BACKUP,
|
||||
} from './meta'
|
||||
import { authHeaders } from '$lib/core/auth'
|
||||
import {
|
||||
type GQLResponse,
|
||||
mapManga,
|
||||
@@ -100,50 +127,6 @@ import {
|
||||
} from './types'
|
||||
import { initPageCache, clearPageCache as _clearPageCache } from './pageCache'
|
||||
|
||||
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 }
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const SET_FLARE_SOLVERR = `
|
||||
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 }
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
type RawQueueItem = Record<string, unknown>
|
||||
|
||||
function mapDownloadStatus(raw: { state: string; queue: RawQueueItem[] }): DownloadStatus {
|
||||
@@ -160,14 +143,9 @@ function mapDownloadStatus(raw: { state: string; queue: RawQueueItem[] }): Downl
|
||||
|
||||
export class SuwayomiAdapter implements ServerAdapter {
|
||||
private baseUrl = 'http://127.0.0.1:4567'
|
||||
private authHeader: string | null = null
|
||||
|
||||
async connect(config: ServerConfig): Promise<void> {
|
||||
this.baseUrl = config.baseUrl.replace(/\/$/, '')
|
||||
if (config.credentials) {
|
||||
const { username, password } = config.credentials
|
||||
this.authHeader = 'Basic ' + btoa(`${username}:${password}`)
|
||||
}
|
||||
initPageCache(this.gql.bind(this), this.getServerUrl.bind(this))
|
||||
}
|
||||
|
||||
@@ -178,9 +156,9 @@ export class SuwayomiAdapter implements ServerAdapter {
|
||||
async getStatus(): Promise<ServerStatus> {
|
||||
try {
|
||||
const res = await fetch(`${this.baseUrl}/api/graphql`, {
|
||||
method: 'POST',
|
||||
headers: this.headers(),
|
||||
body: JSON.stringify({ query: '{ aboutServer { name } }' }),
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...authHeaders() },
|
||||
body: JSON.stringify({ query: '{ aboutServer { name } }' }),
|
||||
})
|
||||
return res.ok ? 'connected' : 'error'
|
||||
} catch {
|
||||
@@ -189,9 +167,7 @@ export class SuwayomiAdapter implements ServerAdapter {
|
||||
}
|
||||
|
||||
private headers(): Record<string, string> {
|
||||
const h: Record<string, string> = { 'Content-Type': 'application/json' }
|
||||
if (this.authHeader) h['Authorization'] = this.authHeader
|
||||
return h
|
||||
return { 'Content-Type': 'application/json', ...authHeaders() }
|
||||
}
|
||||
|
||||
private async gql<T>(
|
||||
@@ -200,9 +176,9 @@ export class SuwayomiAdapter implements ServerAdapter {
|
||||
signal?: AbortSignal,
|
||||
): Promise<T> {
|
||||
const res = await fetch(`${this.baseUrl}/api/graphql`, {
|
||||
method: 'POST',
|
||||
method: 'POST',
|
||||
headers: this.headers(),
|
||||
body: JSON.stringify({ query, variables }),
|
||||
body: JSON.stringify({ query, variables }),
|
||||
signal,
|
||||
})
|
||||
if (!res.ok) throw new Error(`Suwayomi HTTP ${res.status}`)
|
||||
@@ -243,6 +219,26 @@ export class SuwayomiAdapter implements ServerAdapter {
|
||||
return { items, hasNextPage: false }
|
||||
}
|
||||
|
||||
async getMangasByGenre(
|
||||
filter: Record<string, unknown>,
|
||||
first: number,
|
||||
offset: number,
|
||||
signal?: AbortSignal,
|
||||
): Promise<{ items: Manga[]; hasNextPage: boolean; totalCount: number }> {
|
||||
const data = await this.gql<{
|
||||
mangas: {
|
||||
nodes: Record<string, unknown>[]
|
||||
pageInfo: { hasNextPage: boolean }
|
||||
totalCount: number
|
||||
}
|
||||
}>(MANGAS_BY_GENRE, { filter, first, offset }, signal)
|
||||
return {
|
||||
items: data.mangas.nodes.map(mapManga),
|
||||
hasNextPage: data.mangas.pageInfo.hasNextPage,
|
||||
totalCount: data.mangas.totalCount,
|
||||
}
|
||||
}
|
||||
|
||||
async searchManga(query: string, sourceId?: string): Promise<Manga[]> {
|
||||
if (!sourceId) return []
|
||||
const data = await this.gql<{ fetchSourceManga: { mangas: Record<string, unknown>[] } }>(
|
||||
@@ -303,9 +299,7 @@ export class SuwayomiAdapter implements ServerAdapter {
|
||||
}
|
||||
|
||||
async getRecentlyUpdated(): Promise<Chapter[]> {
|
||||
const data = await this.gql<{ chapters: { nodes: Record<string, unknown>[] } }>(
|
||||
GET_RECENTLY_UPDATED
|
||||
)
|
||||
const data = await this.gql<{ chapters: { nodes: Record<string, unknown>[] } }>(GET_RECENTLY_UPDATED)
|
||||
return data.chapters.nodes.map(mapChapter)
|
||||
}
|
||||
|
||||
@@ -380,9 +374,7 @@ export class SuwayomiAdapter implements ServerAdapter {
|
||||
reorderChapterDownload: { downloadStatus: { state: string; queue: RawQueueItem[] } }
|
||||
}>(REORDER_DOWNLOAD, { chapterId: Number(chapterId), to })
|
||||
return mapDownloadStatus(data.reorderChapterDownload.downloadStatus)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
} catch { return null }
|
||||
}
|
||||
|
||||
async clearDownloads(): Promise<void> {
|
||||
@@ -433,6 +425,18 @@ export class SuwayomiAdapter implements ServerAdapter {
|
||||
await this.gql(INSTALL_EXTERNAL_EXTENSION, { url })
|
||||
}
|
||||
|
||||
async getExtensionRepos(): Promise<string[]> {
|
||||
const data = await this.gql<{ settings: { extensionRepos: string[] } }>(GET_SETTINGS)
|
||||
return data.settings.extensionRepos ?? []
|
||||
}
|
||||
|
||||
async setExtensionRepos(repos: string[]): Promise<string[]> {
|
||||
const data = await this.gql<{ setSettings: { settings: { extensionRepos: string[] } } }>(
|
||||
SET_EXTENSION_REPOS, { repos }
|
||||
)
|
||||
return data.setSettings.settings.extensionRepos
|
||||
}
|
||||
|
||||
async getSources(): Promise<Source[]> {
|
||||
const data = await this.gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
|
||||
return data.sources.nodes
|
||||
@@ -441,13 +445,36 @@ export class SuwayomiAdapter implements ServerAdapter {
|
||||
async browseSource(sourceId: string, page: number): Promise<PaginatedResult<Manga>> {
|
||||
const data = await this.gql<{
|
||||
fetchSourceManga: { mangas: Record<string, unknown>[]; hasNextPage: boolean }
|
||||
}>(FETCH_SOURCE_MANGA, { source: sourceId, type: 'LATEST', page })
|
||||
}>(FETCH_SOURCE_MANGA, { source: sourceId, type: sourceId === '0' ? 'POPULAR' : 'LATEST', page })
|
||||
return {
|
||||
items: data.fetchSourceManga.mangas.map(mapManga),
|
||||
hasNextPage: data.fetchSourceManga.hasNextPage,
|
||||
}
|
||||
}
|
||||
|
||||
async getSourceSettings(sourceId: string): Promise<unknown[]> {
|
||||
const data = await this.gql<{ source: { preferences: unknown[] } }>(
|
||||
GET_SOURCE_SETTINGS, { id: sourceId }
|
||||
)
|
||||
return data.source.preferences ?? []
|
||||
}
|
||||
|
||||
async updateSourcePreference(
|
||||
sourceId: string,
|
||||
position: number,
|
||||
changeType: string,
|
||||
value: unknown,
|
||||
): Promise<unknown[]> {
|
||||
await this.gql(UPDATE_SOURCE_PREFERENCE, {
|
||||
source: sourceId,
|
||||
change: { position, [changeType]: value },
|
||||
})
|
||||
const data = await this.gql<{ source: { preferences: unknown[] } }>(
|
||||
GET_SOURCE_SETTINGS, { id: sourceId }
|
||||
)
|
||||
return data.source.preferences ?? []
|
||||
}
|
||||
|
||||
async getCategories(): Promise<Category[]> {
|
||||
const data = await this.gql<{ categories: { nodes: Record<string, unknown>[] } }>(GET_CATEGORIES)
|
||||
return data.categories.nodes.map(mapCategory)
|
||||
@@ -533,6 +560,18 @@ export class SuwayomiAdapter implements ServerAdapter {
|
||||
await this.gql(TRACK_PROGRESS, { mangaId: Number(mangaId) })
|
||||
}
|
||||
|
||||
async loginTrackerOAuth(trackerId: string, callbackUrl: string): Promise<void> {
|
||||
await this.gql(LOGIN_TRACKER_OAUTH, { trackerId: Number(trackerId), callbackUrl })
|
||||
}
|
||||
|
||||
async loginTrackerCredentials(trackerId: string, username: string, password: string): Promise<void> {
|
||||
await this.gql(LOGIN_TRACKER_CREDENTIALS, { trackerId: Number(trackerId), username, password })
|
||||
}
|
||||
|
||||
async logoutTracker(trackerId: string): Promise<void> {
|
||||
await this.gql(LOGOUT_TRACKER, { trackerId: Number(trackerId) })
|
||||
}
|
||||
|
||||
async getServerSecurity(): Promise<ServerSecurity> {
|
||||
const data = await this.gql<{ settings: ServerSecurity }>(GET_SERVER_SECURITY)
|
||||
return data.settings
|
||||
@@ -569,26 +608,59 @@ export class SuwayomiAdapter implements ServerAdapter {
|
||||
}
|
||||
}
|
||||
|
||||
async getMangasByGenre(
|
||||
filter: Record<string, unknown>,
|
||||
first: number,
|
||||
offset: number,
|
||||
signal?: AbortSignal,
|
||||
): Promise<{ items: Manga[]; hasNextPage: boolean; totalCount: number }> {
|
||||
const data = await this.gql<{
|
||||
mangas: {
|
||||
nodes: Record<string, unknown>[]
|
||||
pageInfo: { hasNextPage: boolean }
|
||||
totalCount: number
|
||||
}
|
||||
}>(MANGAS_BY_GENRE, { filter, first, offset }, signal)
|
||||
async getDownloadsPath(): Promise<{ downloadsPath: string; localSourcePath: string }> {
|
||||
const data = await this.gql<{ settings: { downloadsPath: string | null; localSourcePath: string | null } }>(
|
||||
GET_DOWNLOADS_PATH
|
||||
)
|
||||
return {
|
||||
items: data.mangas.nodes.map(mapManga),
|
||||
hasNextPage: data.mangas.pageInfo.hasNextPage,
|
||||
totalCount: data.mangas.totalCount,
|
||||
downloadsPath: data.settings.downloadsPath ?? '',
|
||||
localSourcePath: data.settings.localSourcePath ?? '',
|
||||
}
|
||||
}
|
||||
|
||||
async setDownloadsPath(path: string): Promise<void> {
|
||||
await this.gql(SET_DOWNLOADS_PATH, { path })
|
||||
}
|
||||
|
||||
async setLocalSourcePath(path: string): Promise<void> {
|
||||
await this.gql(SET_LOCAL_SOURCE_PATH, { path })
|
||||
}
|
||||
|
||||
async createBackup(): Promise<{ url: string }> {
|
||||
const data = await this.gql<{ createBackup: { url: string } }>(CREATE_BACKUP)
|
||||
return data.createBackup
|
||||
}
|
||||
|
||||
private multipartGql<T>(query: string, file: File): Promise<T> {
|
||||
const form = new FormData()
|
||||
form.append('operations', JSON.stringify({ query, variables: { backup: null } }))
|
||||
form.append('map', JSON.stringify({ '0': ['variables.backup'] }))
|
||||
form.append('0', file, file.name)
|
||||
const headers: Record<string, string> = { Accept: 'application/json', ...authHeaders() }
|
||||
return fetch(`${this.baseUrl}/api/graphql`, { method: 'POST', headers, body: form })
|
||||
.then(r => { if (!r.ok) throw new Error(`Suwayomi HTTP ${r.status}`); return r.json() })
|
||||
.then((json: GQLResponse<T>) => { if (json.errors?.length) throw new Error(json.errors[0].message); return json.data })
|
||||
}
|
||||
|
||||
async restoreBackup(file: File): Promise<{ id: string; status: RestoreStatus }> {
|
||||
const data = await this.multipartGql<{ restoreBackup: { id: string; status: RestoreStatus } }>(RESTORE_BACKUP, file)
|
||||
return data.restoreBackup
|
||||
}
|
||||
|
||||
async validateBackup(file: File): Promise<ValidateBackupResult> {
|
||||
const data = await this.multipartGql<{ validateBackup: ValidateBackupResult }>(VALIDATE_BACKUP, file)
|
||||
return data.validateBackup
|
||||
}
|
||||
|
||||
async pollRestoreStatus(id: string): Promise<RestoreStatus> {
|
||||
const data = await this.gql<{ restoreStatus: RestoreStatus }>(POLL_RESTORE_STATUS, { id })
|
||||
return data.restoreStatus
|
||||
}
|
||||
|
||||
async clearCachedImages(opts: { cachedPages: boolean; cachedThumbnails: boolean; downloadedThumbnails: boolean }): Promise<void> {
|
||||
await this.gql(CLEAR_CACHED_IMAGES, opts)
|
||||
}
|
||||
|
||||
async checkForUpdates(mangaIds?: string[]): Promise<UpdateResult[]> {
|
||||
if (mangaIds?.length) {
|
||||
const results: UpdateResult[] = []
|
||||
|
||||
@@ -59,7 +59,7 @@ export const LIBRARY_UPDATE_STATUS = `
|
||||
|
||||
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) {
|
||||
mangas(filter: $filter, first: $first, offset: $offset) {
|
||||
nodes {
|
||||
id title thumbnailUrl inLibrary genre status
|
||||
source { id displayName }
|
||||
@@ -76,6 +76,12 @@ export const GET_DOWNLOADS_PATH = `
|
||||
}
|
||||
`
|
||||
|
||||
export const POLL_RESTORE_STATUS = `
|
||||
query PollRestoreStatus($id: String!) {
|
||||
restoreStatus(id: $id) { mangaProgress state totalManga }
|
||||
}
|
||||
`
|
||||
|
||||
export const FETCH_MANGA = `
|
||||
mutation FetchManga($id: Int!) {
|
||||
fetchManga(input: { id: $id }) {
|
||||
@@ -214,6 +220,15 @@ export const RESTORE_BACKUP = `
|
||||
}
|
||||
`
|
||||
|
||||
export const VALIDATE_BACKUP = `
|
||||
query ValidateBackup($backup: Upload!) {
|
||||
validateBackup(input: { backup: $backup }) {
|
||||
missingSources { id name }
|
||||
missingTrackers { name }
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const FETCH_SOURCE_MANGA = `
|
||||
mutation FetchSourceManga($source: LongString!, $type: FetchSourceMangaType!, $page: Int!, $query: String, $filters: [FilterChangeInput!]) {
|
||||
fetchSourceManga(input: { source: $source, type: $type, page: $page, query: $query, filters: $filters }) {
|
||||
|
||||
@@ -36,4 +36,70 @@ export const GET_METAS = `
|
||||
nodes { key value }
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
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 }
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const RESTORE_BACKUP = `
|
||||
mutation RestoreBackup($backup: Upload!) {
|
||||
restoreBackup(input: { backup: $backup }) {
|
||||
id status { mangaProgress state totalManga }
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const VALIDATE_BACKUP = `
|
||||
query ValidateBackup($backup: Upload!) {
|
||||
validateBackup(input: { backup: $backup }) {
|
||||
missingSources { id name } missingTrackers { name }
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const CREATE_BACKUP = `
|
||||
mutation CreateBackup {
|
||||
createBackup(input: {}) { url }
|
||||
}
|
||||
`
|
||||
|
||||
export const SET_FLARE_SOLVERR = `
|
||||
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 }
|
||||
}
|
||||
}
|
||||
`
|
||||
@@ -114,6 +114,14 @@ export const LOGIN_TRACKER_CREDENTIALS = `
|
||||
}
|
||||
`
|
||||
|
||||
export const LOGIN_TRACKER_OAUTH = `
|
||||
mutation LoginTrackerOAuth($trackerId: Int!, $callbackUrl: String!) {
|
||||
loginTrackerOAuth(input: { trackerId: $trackerId, callbackUrl: $callbackUrl }) {
|
||||
isLoggedIn
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const LOGOUT_TRACKER = `
|
||||
mutation LogoutTracker($trackerId: Int!) {
|
||||
logoutTracker(input: { trackerId: $trackerId }) {
|
||||
|
||||
@@ -1,209 +1,126 @@
|
||||
import type { DownloadStatus } from '$lib/types/api'
|
||||
import type { Manga, Chapter, Extension, Source, Tracker, TrackRecord, Category } from '$lib/types'
|
||||
export type PlatformFeature =
|
||||
| 'server-management'
|
||||
| 'biometric-auth'
|
||||
| 'native-window'
|
||||
| 'filesystem'
|
||||
| 'app-updates'
|
||||
| 'discord-rpc'
|
||||
|
||||
export interface ServerConfig {
|
||||
baseUrl: string
|
||||
credentials?: { username: string; password: string }
|
||||
export type Platform = 'tauri' | 'capacitor' | 'web'
|
||||
|
||||
export interface ServerLaunchConfig {
|
||||
binary?: string
|
||||
binaryArgs?: string
|
||||
webUiEnabled?: boolean
|
||||
}
|
||||
|
||||
export type ServerStatus = 'connected' | 'disconnected' | 'error'
|
||||
|
||||
export interface MangaFilters {
|
||||
inLibrary?: boolean
|
||||
status?: MangaStatus
|
||||
tags?: string[]
|
||||
unread?: boolean
|
||||
sourceId?: string
|
||||
export interface DiscordAssets {
|
||||
largeImage?: string
|
||||
largeText?: string
|
||||
smallImage?: string
|
||||
smallText?: string
|
||||
}
|
||||
|
||||
export type MangaStatus =
|
||||
| 'ONGOING'
|
||||
| 'COMPLETED'
|
||||
| 'LICENSED'
|
||||
| 'PUBLISHING_FINISHED'
|
||||
| 'CANCELLED'
|
||||
| 'ON_HIATUS'
|
||||
|
||||
export interface PaginatedResult<T> {
|
||||
items: T[]
|
||||
hasNextPage: boolean
|
||||
total?: number
|
||||
export interface DiscordButton {
|
||||
label: string
|
||||
url: string
|
||||
}
|
||||
|
||||
export interface MangaMeta {
|
||||
customTitle?: string
|
||||
customCover?: string
|
||||
notes?: string
|
||||
[key: string]: unknown
|
||||
export interface DiscordPresence {
|
||||
state?: string
|
||||
details?: string
|
||||
assets?: DiscordAssets
|
||||
buttons?: DiscordButton[]
|
||||
timestamps?: { start?: number; end?: number }
|
||||
}
|
||||
|
||||
export interface Page {
|
||||
index: number
|
||||
url: string
|
||||
imageData?: string
|
||||
export interface AppUpdateInfo {
|
||||
version: string
|
||||
url: string
|
||||
notes: string
|
||||
}
|
||||
|
||||
export interface AboutServer {
|
||||
name: string
|
||||
version: string
|
||||
buildType: string
|
||||
buildTime: number
|
||||
github: string
|
||||
discord: string
|
||||
export interface StorageInfo {
|
||||
manga_bytes: number
|
||||
total_bytes: number
|
||||
free_bytes: number
|
||||
path: string
|
||||
}
|
||||
|
||||
export interface AboutWebUI {
|
||||
channel: string
|
||||
tag: string
|
||||
updateTimestamp: number
|
||||
export interface MigrateProgress {
|
||||
done: number
|
||||
total: number
|
||||
current: string
|
||||
}
|
||||
|
||||
export interface DownloadItem {
|
||||
chapterId: string
|
||||
mangaId: string
|
||||
chapterName: string
|
||||
mangaTitle: string
|
||||
thumbnailUrl?: string
|
||||
progress: number
|
||||
state: 'queued' | 'downloading' | 'finished' | 'error'
|
||||
export interface UpdateProgress {
|
||||
downloaded: number
|
||||
total: number | null
|
||||
}
|
||||
|
||||
export interface UpdateResult {
|
||||
mangaId: string
|
||||
newChapters: number
|
||||
export interface ReleaseInfo {
|
||||
tag_name: string
|
||||
name: string
|
||||
body: string
|
||||
published_at: string
|
||||
html_url: string
|
||||
}
|
||||
|
||||
export interface LibraryUpdateProgress {
|
||||
isRunning: boolean
|
||||
finishedJobs: number
|
||||
totalJobs: number
|
||||
}
|
||||
export interface PlatformAdapter {
|
||||
readonly platform: Platform
|
||||
|
||||
export interface ServerSecurity {
|
||||
authMode: string
|
||||
authUsername: string
|
||||
socksProxyEnabled: boolean
|
||||
socksProxyHost: string
|
||||
socksProxyPort: string
|
||||
socksProxyVersion: number
|
||||
socksProxyUsername: string
|
||||
flareSolverrEnabled: boolean
|
||||
flareSolverrUrl: string
|
||||
flareSolverrTimeout: number
|
||||
flareSolverrSessionName: string
|
||||
flareSolverrSessionTtl: number
|
||||
flareSolverrAsResponseFallback: boolean
|
||||
}
|
||||
init(): Promise<void>
|
||||
destroy(): Promise<void>
|
||||
isSupported(feature: PlatformFeature): boolean
|
||||
|
||||
export interface SetServerAuthInput {
|
||||
authMode: string
|
||||
authUsername: string
|
||||
authPassword: string
|
||||
}
|
||||
getAppDir(): Promise<string>
|
||||
|
||||
export interface SetSocksProxyInput {
|
||||
socksProxyEnabled: boolean
|
||||
socksProxyHost: string
|
||||
socksProxyPort: string
|
||||
socksProxyVersion: number
|
||||
socksProxyUsername: string
|
||||
socksProxyPassword: string
|
||||
}
|
||||
loadStore(key: string): Promise<unknown>
|
||||
saveStore(key: string, value: unknown): Promise<void>
|
||||
|
||||
export interface SetFlareSolverrInput {
|
||||
flareSolverrEnabled: boolean
|
||||
flareSolverrUrl: string
|
||||
flareSolverrTimeout: number
|
||||
flareSolverrSessionName: string
|
||||
flareSolverrSessionTtl: number
|
||||
flareSolverrAsResponseFallback: boolean
|
||||
}
|
||||
storeCredential(key: string, value: string): Promise<void>
|
||||
getCredential(key: string): Promise<string | null>
|
||||
authenticateBiometric(): Promise<boolean>
|
||||
|
||||
export interface TrackRecordPatch {
|
||||
status?: number
|
||||
score?: number
|
||||
lastChapterRead?: number
|
||||
startDate?: string
|
||||
finishDate?: string
|
||||
private?: boolean
|
||||
}
|
||||
readFile(path: string): Promise<Uint8Array>
|
||||
writeFile(path: string, data: Uint8Array): Promise<void>
|
||||
pickFolder(): Promise<string | null>
|
||||
checkPathExists(path: string): Promise<boolean>
|
||||
createDirectory(path: string): Promise<void>
|
||||
openPath(path: string): Promise<void>
|
||||
getDefaultDownloadsPath(): Promise<string>
|
||||
getStorageInfo(downloadsPath: string): Promise<StorageInfo>
|
||||
migrateDownloads(src: string, dst: string): Promise<void>
|
||||
getAutoBackupDir(): Promise<string>
|
||||
|
||||
export interface ServerAdapter {
|
||||
connect(config: ServerConfig): Promise<void>
|
||||
getStatus(): Promise<ServerStatus>
|
||||
getServerUrl(): string
|
||||
fetchImage(url: string, headers: Record<string, string>): Promise<Blob>
|
||||
|
||||
getManga(id: string, signal?: AbortSignal): Promise<Manga>
|
||||
getMangaList(filters: MangaFilters): Promise<PaginatedResult<Manga>>
|
||||
searchManga(query: string, sourceId?: string): Promise<Manga[]>
|
||||
fetchManga(id: string): Promise<Manga>
|
||||
addToLibrary(mangaId: string): Promise<void>
|
||||
removeFromLibrary(mangaId: string): Promise<void>
|
||||
updateMangas(ids: string[], patch: { inLibrary?: boolean }): Promise<void>
|
||||
updateMangaMeta(id: string, meta: Partial<MangaMeta>): Promise<void>
|
||||
deleteMangaMeta(id: string, key: string): Promise<void>
|
||||
launchServer(config: ServerLaunchConfig): Promise<void>
|
||||
stopServer(): Promise<void>
|
||||
getServerStatus(): Promise<'running' | 'stopped' | 'error'>
|
||||
|
||||
getChapters(mangaId: string): Promise<Chapter[]>
|
||||
getChapter(id: string): Promise<Chapter>
|
||||
getChapterPages(id: string, signal?: AbortSignal): Promise<Page[]>
|
||||
fetchChapters(mangaId: string): Promise<Chapter[]>
|
||||
getRecentlyUpdated(): Promise<Chapter[]>
|
||||
markChapterRead(id: string, read: boolean): Promise<void>
|
||||
markChaptersRead(ids: string[], read: boolean): Promise<void>
|
||||
updateChaptersProgress(ids: string[], patch: { isRead?: boolean; isBookmarked?: boolean; lastPageRead?: number }): Promise<void>
|
||||
deleteDownloadedChapters(ids: string[]): Promise<void>
|
||||
setChapterMeta(chapterId: string, key: string, value: string): Promise<void>
|
||||
deleteChapterMeta(chapterId: string, key: string): Promise<void>
|
||||
setTitle(title: string): Promise<void>
|
||||
minimize(): Promise<void>
|
||||
maximize(): Promise<void>
|
||||
close(): Promise<void>
|
||||
toggleFullscreen(): Promise<void>
|
||||
|
||||
getAboutServer(): Promise<AboutServer>
|
||||
getAboutWebUI(): Promise<AboutWebUI>
|
||||
setDiscordPresence(presence: DiscordPresence): Promise<void>
|
||||
clearDiscordPresence(): Promise<void>
|
||||
|
||||
getDownloads(): Promise<DownloadItem[]>
|
||||
getDownloadStatus(): Promise<DownloadStatus>
|
||||
enqueueDownload(chapterId: string): Promise<void>
|
||||
enqueueDownloads(chapterIds: string[]): Promise<void>
|
||||
dequeueDownload(chapterId: string): Promise<void>
|
||||
dequeueDownloads(chapterIds: string[]): Promise<void>
|
||||
reorderDownload(chapterId: string, to: number): Promise<DownloadStatus | null>
|
||||
clearDownloads(): Promise<void>
|
||||
startDownloader(): Promise<DownloadStatus | null>
|
||||
stopDownloader(): Promise<DownloadStatus | null>
|
||||
getVersion(): Promise<string>
|
||||
openExternal(url: string): Promise<void>
|
||||
checkForAppUpdate(): Promise<AppUpdateInfo | null>
|
||||
installAppUpdate(tag: string): Promise<void>
|
||||
restartApp(): Promise<void>
|
||||
exitApp(): Promise<void>
|
||||
listReleases(): Promise<ReleaseInfo[]>
|
||||
|
||||
getExtensions(): Promise<Extension[]>
|
||||
installExtension(id: string): Promise<void>
|
||||
uninstallExtension(id: string): Promise<void>
|
||||
updateExtension(id: string): Promise<void>
|
||||
updateExtensions(ids: string[]): Promise<void>
|
||||
installExternalExtension(url: string): Promise<void>
|
||||
clearMokuCache(): Promise<void>
|
||||
clearSuwayomiCache(): Promise<void>
|
||||
resetSuwayomiData(): Promise<void>
|
||||
|
||||
getSources(): Promise<Source[]>
|
||||
browseSource(sourceId: string, page: number): Promise<PaginatedResult<Manga>>
|
||||
|
||||
getCategories(): Promise<Category[]>
|
||||
createCategory(name: string): Promise<Category>
|
||||
deleteCategory(id: number): Promise<void>
|
||||
updateCategoryOrder(id: number, position: number): Promise<Category[]>
|
||||
updateMangaCategories(mangaId: string, addTo: number[], removeFrom: number[]): Promise<void>
|
||||
updateMangasCategories(mangaIds: string[], addTo: number[], removeFrom: number[]): Promise<void>
|
||||
updateCategoryManga(categoryId: number): Promise<void>
|
||||
|
||||
getTrackers(): Promise<Tracker[]>
|
||||
getAllTrackerRecords(): Promise<unknown[]>
|
||||
getMangaTrackRecords(mangaId: string): Promise<unknown[]>
|
||||
searchTracker(trackerId: string, query: string): Promise<unknown[]>
|
||||
linkTracker(mangaId: string, trackerId: string, remoteId: string): Promise<void>
|
||||
unlinkTracker(recordId: string): Promise<void>
|
||||
updateTrackRecord(recordId: string, patch: TrackRecordPatch): Promise<TrackRecord>
|
||||
fetchTrackRecord(recordId: string): Promise<TrackRecord>
|
||||
syncTracking(mangaId: string): Promise<void>
|
||||
|
||||
getServerSecurity(): Promise<ServerSecurity>
|
||||
setServerAuth(input: SetServerAuthInput): Promise<void>
|
||||
setSocksProxy(input: SetSocksProxyInput): Promise<void>
|
||||
setFlareSolverr(input: SetFlareSolverrInput): Promise<void>
|
||||
|
||||
checkForUpdates(mangaIds?: string[]): Promise<UpdateResult[]>
|
||||
stopLibraryUpdate(): Promise<void>
|
||||
getLibraryUpdateStatus(): Promise<LibraryUpdateProgress>
|
||||
clearPageCache(chapterId?: number): void
|
||||
onUpdateProgress(cb: (p: UpdateProgress) => void): Promise<() => void>
|
||||
onUpdateLaunching(cb: () => void): Promise<() => void>
|
||||
onMigrateProgress(cb: (p: MigrateProgress) => void): Promise<() => void>
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
import type { Platform } from '$lib/platform-adapters/types'
|
||||
|
||||
export type AppStatus = 'booting' | 'not-configured' | 'auth' | 'ready' | 'error'
|
||||
export type AppStatus = 'booting' | 'not-configured' | 'auth' | 'locked' | 'ready' | 'error'
|
||||
|
||||
class AppStore {
|
||||
settingsOpen: boolean = $state(false)
|
||||
navPage: string = $state('')
|
||||
scrollPositions: Map<string, number> = $state(new Map())
|
||||
settingsOpen: boolean = $state(false)
|
||||
navPage: string = $state('')
|
||||
scrollPositions: Map<string, number> = $state(new Map())
|
||||
|
||||
setSettingsOpen(next: boolean) { this.settingsOpen = next }
|
||||
setNavPage(next: string) { this.navPage = next }
|
||||
@@ -35,6 +35,7 @@ export const appState = $state({
|
||||
history: [] as unknown[],
|
||||
toasts: [] as unknown[],
|
||||
appDir: '',
|
||||
idleSplash: false,
|
||||
})
|
||||
|
||||
export function setSettingsOpen(next: boolean) { app.setSettingsOpen(next) }
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { detectAdapter } from '$lib/platform-adapters'
|
||||
import { initPlatformService } from '$lib/platform-service'
|
||||
import { platformService } from '$lib/platform-service'
|
||||
import { probeServer, loginBasic, loginUI } from '$lib/core/auth'
|
||||
import { probeServer, loginBasic, loginUI, configureAuth } from '$lib/core/auth'
|
||||
import { appState } from '$lib/state/app.svelte'
|
||||
import { settingsState } from '$lib/state/settings.svelte'
|
||||
|
||||
const MAX_ATTEMPTS = 40
|
||||
const BG_MAX_ATTEMPTS = 120
|
||||
@@ -31,13 +32,21 @@ export async function initPlatform(): Promise<void> {
|
||||
appState.appDir = await platformService.getAppDir()
|
||||
}
|
||||
|
||||
function pinLockEnabled(): boolean {
|
||||
return (
|
||||
settingsState.settings.appLockEnabled === true &&
|
||||
typeof settingsState.settings.appLockPin === 'string' &&
|
||||
settingsState.settings.appLockPin.length >= 4
|
||||
)
|
||||
}
|
||||
|
||||
function handleProbeSuccess(gen: number) {
|
||||
if (gen !== probeGeneration) return
|
||||
boot.failed = false
|
||||
boot.skipped = false
|
||||
boot.serverProbeOk = true
|
||||
boot.failed = false
|
||||
boot.skipped = false
|
||||
boot.serverProbeOk = true
|
||||
appState.authenticated = true
|
||||
appState.status = 'ready'
|
||||
appState.status = pinLockEnabled() ? 'locked' : 'ready'
|
||||
}
|
||||
|
||||
function handleAuthRequired(
|
||||
@@ -47,7 +56,8 @@ function handleAuthRequired(
|
||||
pass: string,
|
||||
) {
|
||||
if (gen !== probeGeneration) return
|
||||
boot.failed = false
|
||||
boot.failed = false
|
||||
appState.authMode = authMode
|
||||
|
||||
if (authMode === 'BASIC_AUTH' && user && pass) {
|
||||
loginBasic(user, pass)
|
||||
@@ -66,19 +76,31 @@ function handleAuthRequired(
|
||||
appState.status = 'auth'
|
||||
}
|
||||
|
||||
export function startProbe(
|
||||
export async function startProbe(
|
||||
authMode: 'NONE' | 'BASIC_AUTH' | 'UI_LOGIN' = 'NONE',
|
||||
user = '',
|
||||
pass = '',
|
||||
initialDelay = 100,
|
||||
) {
|
||||
): Promise<void> {
|
||||
const gen = ++probeGeneration
|
||||
boot.failed = false
|
||||
boot.loginRequired = false
|
||||
boot.skipped = false
|
||||
boot.serverProbeOk = false
|
||||
appState.status = 'booting'
|
||||
let tries = 0
|
||||
appState.authMode = authMode
|
||||
|
||||
const baseUrl = settingsState.settings.serverUrl ?? 'http://127.0.0.1:4567'
|
||||
configureAuth(baseUrl, authMode, user || undefined, pass || undefined)
|
||||
|
||||
if (appState.platform === 'web') {
|
||||
boot.failed = true
|
||||
appState.status = 'error'
|
||||
startBackgroundProbe(gen, authMode, user, pass)
|
||||
return
|
||||
}
|
||||
|
||||
let tries = 0
|
||||
|
||||
async function probe() {
|
||||
if (gen !== probeGeneration) return
|
||||
@@ -144,7 +166,7 @@ export async function submitLogin(): Promise<void> {
|
||||
boot.loginError = null
|
||||
boot.serverProbeOk = true
|
||||
appState.authenticated = true
|
||||
appState.status = 'ready'
|
||||
appState.status = pinLockEnabled() ? 'locked' : 'ready'
|
||||
} catch (e: unknown) {
|
||||
boot.loginError = e instanceof Error ? e.message : 'Login failed'
|
||||
} finally {
|
||||
|
||||
@@ -149,7 +149,7 @@ class LibraryState {
|
||||
|
||||
const f = this.tabFilters[tab] ?? {};
|
||||
if (f.unread) items = items.filter(m => (m.unreadCount ?? 0) > 0);
|
||||
if (f.started) items = items.filter(m => (m.unreadCount ?? 0) > 0 && (m.chapters?.totalCount ?? 0) > (m.unreadCount ?? 0));
|
||||
if (f.started) items = items.filter(m => (m.unreadCount ?? 0) > 0 && (m.totalChapters ?? 0) > (m.unreadCount ?? 0));
|
||||
if (f.downloaded) items = items.filter(m => (m.downloadCount ?? 0) > 0);
|
||||
if (f.bookmarked) items = items.filter(m => (m.bookmarkCount ?? 0) > 0);
|
||||
|
||||
|
||||
@@ -145,7 +145,7 @@ class ReaderState {
|
||||
addBookmark(entry: Omit<BookmarkEntry, "savedAt">) {
|
||||
this.bookmarks = [
|
||||
{ ...entry, savedAt: Date.now() },
|
||||
...this.bookmarks.filter(b => b.chapterId !== entry.chapterId),
|
||||
...this.bookmarks.filter(b => b.mangaId !== entry.mangaId),
|
||||
].slice(0, 200);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,12 +2,16 @@ import type { Settings } from '$lib/types/settings'
|
||||
import { DEFAULT_SETTINGS } from '$lib/types/settings'
|
||||
import { saveSettings } from '$lib/core/persistence/persist'
|
||||
|
||||
export const settingsState = $state({ settings: { ...DEFAULT_SETTINGS } as Settings })
|
||||
export const settingsState = $state({
|
||||
settings: { ...DEFAULT_SETTINGS } as Settings,
|
||||
loaded: false,
|
||||
})
|
||||
|
||||
export async function loadSettingsIntoState(raw: unknown) {
|
||||
if (raw && typeof raw === 'object') {
|
||||
Object.assign(settingsState.settings, raw)
|
||||
}
|
||||
settingsState.loaded = true
|
||||
if (typeof document !== 'undefined') {
|
||||
document.documentElement.style.zoom = String(settingsState.settings.uiZoom ?? 1.0)
|
||||
}
|
||||
@@ -16,7 +20,6 @@ export async function loadSettingsIntoState(raw: unknown) {
|
||||
export function updateSettings(patch: Partial<Settings>) {
|
||||
Object.assign(settingsState.settings, patch)
|
||||
void saveSettings({ storeVersion: 2, settings: settingsState.settings })
|
||||
|
||||
if (typeof document !== 'undefined' && patch.uiZoom !== undefined) {
|
||||
document.documentElement.style.zoom = String(patch.uiZoom)
|
||||
}
|
||||
@@ -24,5 +27,6 @@ export function updateSettings(patch: Partial<Settings>) {
|
||||
|
||||
export function resetSettings() {
|
||||
settingsState.settings = { ...DEFAULT_SETTINGS }
|
||||
settingsState.loaded = true
|
||||
void saveSettings({ storeVersion: 2, settings: settingsState.settings })
|
||||
}
|
||||
+105
-53
@@ -1,22 +1,23 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte'
|
||||
import { page } from '$app/stores'
|
||||
import { appState, app } from '$lib/state/app.svelte'
|
||||
import { notifications } from '$lib/state/notifications.svelte'
|
||||
import { settingsState, loadSettingsIntoState, updateSettings } from '$lib/state/settings.svelte'
|
||||
import { applyTheme, mountSystemThemeSync } from '$lib/core/theme'
|
||||
import { platformService } from '$lib/platform-service'
|
||||
import * as discord from '$lib/core/discord'
|
||||
import SplashScreen from '$lib/components/chrome/SplashScreen.svelte'
|
||||
import AuthGate from '$lib/components/chrome/AuthGate.svelte'
|
||||
import Sidebar from '$lib/components/chrome/Sidebar.svelte'
|
||||
import TitleBar from '$lib/components/chrome/TitleBar.svelte'
|
||||
import Toaster from '$lib/components/chrome/Toaster.svelte'
|
||||
import Settings from '$lib/components/settings/Settings.svelte'
|
||||
import ThemeEditor from '$lib/components/settings/ThemeEditor.svelte'
|
||||
import { downloadStore } from '$lib/state/downloads.svelte'
|
||||
import { seriesState } from '$lib/state/series.svelte'
|
||||
import MangaPreview from '$lib/components/shared/manga/MangaPreview.svelte'
|
||||
import { onMount } from 'svelte'
|
||||
import { page } from '$app/stores'
|
||||
import { appState, app } from '$lib/state/app.svelte'
|
||||
import { boot } from '$lib/state/boot.svelte'
|
||||
import { notifications } from '$lib/state/notifications.svelte'
|
||||
import { settingsState, loadSettingsIntoState, updateSettings } from '$lib/state/settings.svelte'
|
||||
import { applyTheme, mountSystemThemeSync } from '$lib/core/theme'
|
||||
import { platformService } from '$lib/platform-service'
|
||||
import * as discord from '$lib/core/discord'
|
||||
import SplashScreen from '$lib/components/chrome/SplashScreen.svelte'
|
||||
import AuthGate from '$lib/components/chrome/AuthGate.svelte'
|
||||
import Sidebar from '$lib/components/chrome/Sidebar.svelte'
|
||||
import TitleBar from '$lib/components/chrome/TitleBar.svelte'
|
||||
import Toaster from '$lib/components/chrome/Toaster.svelte'
|
||||
import Settings from '$lib/components/settings/Settings.svelte'
|
||||
import ThemeEditor from '$lib/components/settings/ThemeEditor.svelte'
|
||||
import { downloadStore } from '$lib/state/downloads.svelte'
|
||||
import { seriesState } from '$lib/state/series.svelte'
|
||||
import MangaPreview from '$lib/components/shared/manga/MangaPreview.svelte'
|
||||
import '../app.css'
|
||||
|
||||
let { children } = $props()
|
||||
@@ -31,42 +32,81 @@
|
||||
if (polling) pollTimer = setTimeout(pollLoop, POLL_MS)
|
||||
}
|
||||
|
||||
const isTauri = typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window
|
||||
const ringFull = $derived(appState.status === 'ready' || appState.status === 'auth')
|
||||
const isTauri = typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window
|
||||
|
||||
let splashVisible = $state(true)
|
||||
let bypassed = $state(false)
|
||||
let themeEditorOpen = $state(false)
|
||||
let themeEditorId = $state<string | null>(null)
|
||||
let _splashDismissed = $state(false)
|
||||
let bypassed = $state(false)
|
||||
let themeEditorOpen = $state(false)
|
||||
let themeEditorId = $state<string | null>(null)
|
||||
|
||||
const splashVisible = $derived(
|
||||
!_splashDismissed ||
|
||||
appState.status === 'booting' ||
|
||||
appState.status === 'locked' ||
|
||||
appState.status === 'error' ||
|
||||
appState.status === 'auth'
|
||||
)
|
||||
|
||||
const ringFull = $derived(appState.status === 'ready')
|
||||
|
||||
const showApp = $derived(
|
||||
appState.status === 'ready' ||
|
||||
appState.status === 'auth' ||
|
||||
bypassed
|
||||
!splashVisible && (
|
||||
appState.status === 'ready' ||
|
||||
bypassed
|
||||
)
|
||||
)
|
||||
|
||||
function onSplashReady() { _splashDismissed = true }
|
||||
function onSplashUnlock() { appState.status = 'ready'; _splashDismissed = true }
|
||||
function onSplashBypass() { bypassed = true; _splashDismissed = true }
|
||||
|
||||
const isReaderRoute = $derived($page.url.pathname.startsWith('/reader'))
|
||||
const readerContainerized = $derived(settingsState.settings.readerContainerized ?? false)
|
||||
const strippedLayout = $derived(isReaderRoute && !readerContainerized)
|
||||
|
||||
onMount(async () => {
|
||||
if (isTauri && settingsState.settings.autoStartServer) {
|
||||
const { startProbe } = await import('$lib/state/boot.svelte')
|
||||
const { detectAdapter } = await import('$lib/platform-adapters')
|
||||
const { initPlatformService } = await import('$lib/platform-service')
|
||||
const { loadSettings } = await import('$lib/core/persistence/persist')
|
||||
const { startProbe } = await import('$lib/state/boot.svelte')
|
||||
|
||||
const adapter = detectAdapter()
|
||||
initPlatformService(adapter)
|
||||
await adapter.init()
|
||||
appState.platform = adapter.platform
|
||||
appState.version = await platformService.getVersion().catch(() => '')
|
||||
appState.appDir = await platformService.getAppDir().catch(() => '')
|
||||
|
||||
const persisted = await loadSettings()
|
||||
const raw = persisted?.settings ?? persisted ?? null
|
||||
await loadSettingsIntoState(raw)
|
||||
|
||||
const s = (raw ?? {}) as Record<string, unknown>
|
||||
appState.serverUrl = (s.serverUrl as string) ?? ''
|
||||
appState.authMode = (s.serverAuthMode as 'NONE' | 'BASIC_AUTH' | 'UI_LOGIN') ?? 'NONE'
|
||||
appState.authUser = (s.serverAuthUser as string) ?? ''
|
||||
appState.authPass = (s.serverAuthPass as string) ?? ''
|
||||
|
||||
applyTheme(
|
||||
settingsState.settings.theme ?? 'dark',
|
||||
settingsState.settings.customThemes ?? [],
|
||||
)
|
||||
|
||||
if (isTauri && settingsState.settings.autoStartServer) {
|
||||
platformService.launchServer({
|
||||
binary: settingsState.settings.serverBinary,
|
||||
binaryArgs: settingsState.settings.serverBinaryArgs,
|
||||
webUiEnabled: settingsState.settings.suwayomiWebUI,
|
||||
}).catch(() => {})
|
||||
|
||||
startProbe(
|
||||
appState.authMode ?? 'NONE',
|
||||
appState.authUser ?? '',
|
||||
appState.authPass ?? '',
|
||||
2000,
|
||||
)
|
||||
}
|
||||
|
||||
startProbe(
|
||||
appState.authMode ?? 'NONE',
|
||||
appState.authUser ?? '',
|
||||
appState.authPass ?? '',
|
||||
isTauri && settingsState.settings.autoStartServer ? 2000 : 100,
|
||||
)
|
||||
|
||||
if (settingsState.settings.discordRpc) {
|
||||
await discord.initRpc()
|
||||
await discord.setIdle()
|
||||
@@ -75,11 +115,6 @@
|
||||
polling = true
|
||||
pollLoop()
|
||||
|
||||
applyTheme(
|
||||
settingsState.settings.theme ?? 'dark',
|
||||
settingsState.settings.customThemes ?? []
|
||||
)
|
||||
|
||||
return () => {
|
||||
polling = false
|
||||
if (pollTimer !== null) { clearTimeout(pollTimer); pollTimer = null }
|
||||
@@ -93,20 +128,29 @@
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
const theme = settingsState.settings.theme ?? 'dark'
|
||||
const customThemes = settingsState.settings.customThemes ?? []
|
||||
applyTheme(theme, customThemes)
|
||||
applyTheme(settingsState.settings.theme ?? 'dark', settingsState.settings.customThemes ?? [])
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
const enabled = settingsState.settings.systemThemeSync ?? false
|
||||
const darkTheme = settingsState.settings.systemThemeDark ?? 'dark'
|
||||
const lightTheme = settingsState.settings.systemThemeLight ?? 'light'
|
||||
mountSystemThemeSync(enabled, darkTheme, lightTheme, (id) => updateSettings({ theme: id }))
|
||||
mountSystemThemeSync(
|
||||
settingsState.settings.systemThemeSync ?? false,
|
||||
settingsState.settings.systemThemeDark ?? 'dark',
|
||||
settingsState.settings.systemThemeLight ?? 'light',
|
||||
(id) => updateSettings({ theme: id }),
|
||||
)
|
||||
})
|
||||
|
||||
function onSplashReady() { splashVisible = false }
|
||||
function onSplashBypass() { bypassed = true; splashVisible = false }
|
||||
$effect(() => {
|
||||
if (appState.status === 'booting') _splashDismissed = false
|
||||
})
|
||||
|
||||
function onIdleDismiss() { appState.idleSplash = false }
|
||||
|
||||
function onSplashRetry() {
|
||||
import('$lib/state/boot.svelte').then(({ retryBoot }) => {
|
||||
retryBoot(appState.authMode ?? 'NONE', appState.authUser ?? '', appState.authPass ?? '')
|
||||
})
|
||||
}
|
||||
|
||||
function openThemeEditor(id?: string | null) {
|
||||
themeEditorId = id ?? null
|
||||
@@ -116,15 +160,23 @@
|
||||
|
||||
{#if splashVisible}
|
||||
<SplashScreen
|
||||
mode="loading"
|
||||
mode={appState.status === 'locked' ? 'locked' : 'loading'}
|
||||
{ringFull}
|
||||
failed={appState.status === 'error'}
|
||||
notConfigured={boot.notConfigured}
|
||||
pinLen={settingsState.settings.appLockPin?.length ?? 0}
|
||||
pinCorrect={settingsState.settings.appLockPin ?? ''}
|
||||
onReady={onSplashReady}
|
||||
onUnlock={onSplashUnlock}
|
||||
onBypass={onSplashBypass}
|
||||
onRetry={() => window.location.reload()}
|
||||
onRetry={onSplashRetry}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if appState.idleSplash}
|
||||
<SplashScreen mode="idle" onDismiss={onIdleDismiss} />
|
||||
{/if}
|
||||
|
||||
{#if showApp}
|
||||
{#if strippedLayout}
|
||||
{@render children()}
|
||||
@@ -215,4 +267,4 @@
|
||||
contain: layout style;
|
||||
min-width: 0;
|
||||
}
|
||||
</style>x
|
||||
</style>
|
||||
@@ -1,2 +1,2 @@
|
||||
export const ssr = false
|
||||
export const ssr = false
|
||||
export const prerender = false
|
||||
Reference in New Issue
Block a user