[BETA] Initial Commit (Nix Support Only)
@@ -0,0 +1,35 @@
|
||||
# --- Build Artifacts ---
|
||||
node_modules/
|
||||
dist/
|
||||
dist-tauri/
|
||||
target/
|
||||
bin/
|
||||
out/
|
||||
|
||||
# --- Nix ---
|
||||
.direnv/
|
||||
result
|
||||
result-*
|
||||
|
||||
# --- Logs ---
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# --- IDEs & OS ---
|
||||
.vscode/
|
||||
.idea/
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.swp
|
||||
|
||||
# --- Tauri specific ---
|
||||
src-tauri/target/
|
||||
src-tauri/gen/
|
||||
@@ -0,0 +1,50 @@
|
||||
<div align="center">
|
||||
<img src="src/assets/Moku-Icon.svg" width="80" />
|
||||
<h1>Moku</h1>
|
||||
<p>A manga reader frontend for <a href="https://github.com/Suwayomi/Suwayomi-Server">Suwayomi-Server</a>, built with Tauri and React.</p>
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
- [Suwayomi-Server](https://github.com/Suwayomi/Suwayomi-Server) running on `http://127.0.0.1:4567`
|
||||
|
||||
## Installation
|
||||
|
||||
### Nix
|
||||
|
||||
```bash
|
||||
nix run github:Youwes09/moku
|
||||
```
|
||||
|
||||
Or add to your flake:
|
||||
|
||||
```nix
|
||||
inputs.moku.url = "github:Youwes09/moku";
|
||||
```
|
||||
|
||||
### From source
|
||||
|
||||
```bash
|
||||
git clone https://github.com/Youwes09/moku
|
||||
cd moku
|
||||
nix build
|
||||
./result/bin/moku
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
nix develop
|
||||
pnpm install
|
||||
pnpm tauri dev
|
||||
```
|
||||
|
||||
## Stack
|
||||
|
||||
- [Tauri v2](https://tauri.app) — app shell
|
||||
- [React](https://react.dev) + [TypeScript](https://www.typescriptlang.org) — UI
|
||||
- [Vite](https://vitejs.dev) — frontend build
|
||||
- [Zustand](https://zustand-demo.pmnd.rs) — state
|
||||
- [Crane](https://github.com/ipetkov/crane) — Nix Rust builds
|
||||
@@ -0,0 +1,98 @@
|
||||
{
|
||||
"nodes": {
|
||||
"crane": {
|
||||
"locked": {
|
||||
"lastModified": 1771438068,
|
||||
"narHash": "sha256-nGBbXvEZVe/egCPVPFcu89RFtd8Rf6J+4RFoVCFec0A=",
|
||||
"owner": "ipetkov",
|
||||
"repo": "crane",
|
||||
"rev": "b5090e53e9d68c523a4bb9ad42b4737ee6747597",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "ipetkov",
|
||||
"repo": "crane",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"flake-parts": {
|
||||
"inputs": {
|
||||
"nixpkgs-lib": "nixpkgs-lib"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1769996383,
|
||||
"narHash": "sha256-AnYjnFWgS49RlqX7LrC4uA+sCCDBj0Ry/WOJ5XWAsa0=",
|
||||
"owner": "hercules-ci",
|
||||
"repo": "flake-parts",
|
||||
"rev": "57928607ea566b5db3ad13af0e57e921e6b12381",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "hercules-ci",
|
||||
"repo": "flake-parts",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1771369470,
|
||||
"narHash": "sha256-0NBlEBKkN3lufyvFegY4TYv5mCNHbi5OmBDrzihbBMQ=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "0182a361324364ae3f436a63005877674cf45efb",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixos-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs-lib": {
|
||||
"locked": {
|
||||
"lastModified": 1769909678,
|
||||
"narHash": "sha256-cBEymOf4/o3FD5AZnzC3J9hLbiZ+QDT/KDuyHXVJOpM=",
|
||||
"owner": "nix-community",
|
||||
"repo": "nixpkgs.lib",
|
||||
"rev": "72716169fe93074c333e8d0173151350670b824c",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-community",
|
||||
"repo": "nixpkgs.lib",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"crane": "crane",
|
||||
"flake-parts": "flake-parts",
|
||||
"nixpkgs": "nixpkgs",
|
||||
"rust-overlay": "rust-overlay"
|
||||
}
|
||||
},
|
||||
"rust-overlay": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1771556776,
|
||||
"narHash": "sha256-zKprqMQDl3xVfhSSYvgru1IGXjFdxryWk+KqK0I20Xk=",
|
||||
"owner": "oxalica",
|
||||
"repo": "rust-overlay",
|
||||
"rev": "8b3f46b8a6d17ab46e533a5e3d5b1cc2ff228860",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "oxalica",
|
||||
"repo": "rust-overlay",
|
||||
"type": "github"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
{
|
||||
description = "Moku — manga reader frontend for Suwayomi";
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
flake-parts.url = "github:hercules-ci/flake-parts";
|
||||
crane.url = "github:ipetkov/crane";
|
||||
rust-overlay = {
|
||||
url = "github:oxalica/rust-overlay";
|
||||
inputs.nixpkgs.follows = "nixpkgs";
|
||||
};
|
||||
};
|
||||
|
||||
outputs =
|
||||
inputs@{ flake-parts, crane, rust-overlay, ... }:
|
||||
flake-parts.lib.mkFlake { inherit inputs; } {
|
||||
systems = [
|
||||
"x86_64-linux"
|
||||
"aarch64-linux"
|
||||
];
|
||||
|
||||
perSystem =
|
||||
{ system, pkgs, lib, ... }:
|
||||
let
|
||||
pkgs' = import inputs.nixpkgs {
|
||||
inherit system;
|
||||
overlays = [ rust-overlay.overlays.default ];
|
||||
};
|
||||
|
||||
rustToolchain = pkgs'.rust-bin.stable.latest.default.override {
|
||||
extensions = [
|
||||
"rust-src"
|
||||
"rust-analyzer"
|
||||
];
|
||||
};
|
||||
|
||||
craneLib = (crane.mkLib pkgs').overrideToolchain rustToolchain;
|
||||
|
||||
runtimeLibs = with pkgs; [
|
||||
webkitgtk_4_1
|
||||
gtk3
|
||||
glib
|
||||
cairo
|
||||
pango
|
||||
atk
|
||||
gdk-pixbuf
|
||||
libsoup_3
|
||||
openssl
|
||||
dbus
|
||||
libappindicator-gtk3
|
||||
gsettings-desktop-schemas
|
||||
];
|
||||
|
||||
# Frontend (Vite/TypeScript) built as a separate derivation.
|
||||
# Update `hash` whenever pnpm-lock.yaml changes:
|
||||
# nix build .#frontend 2>&1 | grep "got:"
|
||||
frontend = pkgs.stdenv.mkDerivation {
|
||||
pname = "moku-frontend";
|
||||
version = "0.1.0";
|
||||
src = lib.cleanSource ./.;
|
||||
|
||||
nativeBuildInputs = with pkgs; [
|
||||
nodejs_22
|
||||
pnpm
|
||||
pnpmConfigHook
|
||||
];
|
||||
|
||||
pnpmDeps = pkgs.fetchPnpmDeps {
|
||||
pname = "moku-frontend";
|
||||
version = "0.1.0";
|
||||
src = lib.cleanSource ./.;
|
||||
fetcherVersion = 1;
|
||||
hash = "sha256-2Hdzsjwbb+CKiRn/nGHwLeysKvpvEhd5C213YgWmOSU=";
|
||||
};
|
||||
|
||||
buildPhase = "pnpm build";
|
||||
installPhase = "cp -r dist $out";
|
||||
};
|
||||
|
||||
# tauri::generate_context!() embeds icons and reads tauri.conf.json +
|
||||
# capabilities at compile time — all must survive the source filter.
|
||||
cargoSrc = lib.cleanSourceWith {
|
||||
src = ./src-tauri;
|
||||
filter = path: type:
|
||||
(craneLib.filterCargoSources path type)
|
||||
|| (lib.hasInfix "/icons/" path)
|
||||
|| (lib.hasInfix "/capabilities/" path)
|
||||
|| (builtins.baseNameOf path == "tauri.conf.json");
|
||||
};
|
||||
|
||||
commonArgs = {
|
||||
src = cargoSrc;
|
||||
cargoToml = ./src-tauri/Cargo.toml;
|
||||
cargoLock = ./src-tauri/Cargo.lock;
|
||||
strictDeps = true;
|
||||
|
||||
buildInputs = runtimeLibs;
|
||||
|
||||
nativeBuildInputs = with pkgs; [
|
||||
pkg-config
|
||||
wrapGAppsHook3
|
||||
];
|
||||
|
||||
# Crane unpacks source to /build/source (src-tauri/).
|
||||
# tauri.conf.json has frontendDist = "../dist", so dist goes one
|
||||
# level up at /build/dist.
|
||||
preBuild = ''
|
||||
cp -r ${frontend} ../dist
|
||||
'';
|
||||
|
||||
WEBKIT_DISABLE_COMPOSITING_MODE = "1";
|
||||
};
|
||||
|
||||
cargoArtifacts = craneLib.buildDepsOnly commonArgs;
|
||||
|
||||
moku = craneLib.buildPackage (commonArgs // {
|
||||
inherit cargoArtifacts;
|
||||
|
||||
postInstall = ''
|
||||
wrapProgram $out/bin/moku \
|
||||
--prefix XDG_DATA_DIRS : "${lib.makeSearchPath "share/gsettings-schemas" [
|
||||
pkgs.gsettings-desktop-schemas
|
||||
pkgs.gtk3
|
||||
]}" \
|
||||
--prefix LD_LIBRARY_PATH : "${lib.makeLibraryPath runtimeLibs}" \
|
||||
--prefix PATH : "${lib.makeBinPath [ pkgs.suwayomi-server ]}"
|
||||
'';
|
||||
});
|
||||
|
||||
in
|
||||
{
|
||||
packages = {
|
||||
inherit moku frontend;
|
||||
default = moku;
|
||||
};
|
||||
|
||||
devShells.default = pkgs.mkShell {
|
||||
buildInputs = runtimeLibs;
|
||||
|
||||
nativeBuildInputs = with pkgs; [
|
||||
rustToolchain
|
||||
pkg-config
|
||||
wrapGAppsHook3
|
||||
nodejs_22
|
||||
pnpm
|
||||
suwayomi-server
|
||||
];
|
||||
|
||||
shellHook = ''
|
||||
export WEBKIT_DISABLE_COMPOSITING_MODE=1
|
||||
export PKG_CONFIG_PATH="${pkgs.openssl.dev}/lib/pkgconfig''${PKG_CONFIG_PATH:+:$PKG_CONFIG_PATH}"
|
||||
export XDG_DATA_DIRS="${pkgs.gsettings-desktop-schemas}/share/gsettings-schemas/${pkgs.gsettings-desktop-schemas.name}:${pkgs.gtk3}/share/gsettings-schemas/${pkgs.gtk3.name}''${XDG_DATA_DIRS:+:$XDG_DATA_DIRS}"
|
||||
|
||||
echo "Moku dev shell"
|
||||
echo " pnpm install && pnpm tauri dev"
|
||||
'';
|
||||
};
|
||||
|
||||
formatter = pkgs.nixfmt-rfc-style;
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Moku</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"name": "moku",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri",
|
||||
"tauri:dev": "tauri dev",
|
||||
"tauri:build": "tauri build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@phosphor-icons/react": "^2.1.10",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-progress": "^1.1.8",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@tauri-apps/api": "^2.0.0",
|
||||
"@tauri-apps/plugin-shell": "~2",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.575.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.26.0",
|
||||
"zustand": "^5.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.0.0",
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.4.40",
|
||||
"tailwindcss": "^3.4.7",
|
||||
"typescript": "^5.5.3",
|
||||
"vite": "^5.4.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
[package]
|
||||
name = "moku"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
name = "moku_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[[bin]]
|
||||
name = "moku"
|
||||
path = "src/main.rs"
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2.0", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2.0", features = [] }
|
||||
tauri-plugin-shell = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
||||
[profile.release]
|
||||
codegen-units = 1
|
||||
lto = true
|
||||
opt-level = "s"
|
||||
panic = "abort"
|
||||
strip = true
|
||||
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Allow launching tachidesk-server",
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"shell:allow-open",
|
||||
{
|
||||
"identifier": "shell:allow-spawn",
|
||||
"allow": [
|
||||
{
|
||||
"name": "tachidesk-server",
|
||||
"cmd": "tachidesk-server"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 669 B |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 6.6 KiB |
|
After Width: | Height: | Size: 7.2 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 577 B |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
</adaptive-icon>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 7.9 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 8.9 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 9.4 KiB |
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#fff</color>
|
||||
</resources>
|
||||
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 374 B |
|
After Width: | Height: | Size: 946 B |
|
After Width: | Height: | Size: 946 B |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 528 B |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 946 B |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 101 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 9.4 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 7.3 KiB |
|
After Width: | Height: | Size: 8.3 KiB |
@@ -0,0 +1,34 @@
|
||||
use std::sync::Mutex;
|
||||
use tauri::Manager;
|
||||
use tauri_plugin_shell::{ShellExt, process::CommandChild};
|
||||
|
||||
struct ServerState(Mutex<Option<CommandChild>>);
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.manage(ServerState(Mutex::new(None)))
|
||||
.setup(|app| {
|
||||
let shell = app.shell();
|
||||
let app_handle = app.handle().clone();
|
||||
|
||||
let status = shell.command("tachidesk-server").spawn();
|
||||
|
||||
match status {
|
||||
Ok((_rx, child)) => {
|
||||
println!("Tachidesk server process spawned successfully.");
|
||||
let state = app_handle.state::<ServerState>();
|
||||
let mut guard = state.0.lock().unwrap();
|
||||
*guard = Some(child);
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Failed to spawn Tachidesk server: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running moku");
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
moku_lib::run();
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Moku",
|
||||
"version": "0.1.0",
|
||||
"identifier": "dev.moku.app",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
"beforeBuildCommand": "pnpm build"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "Moku",
|
||||
"width": 1280,
|
||||
"height": 800,
|
||||
"minWidth": 800,
|
||||
"minHeight": 600,
|
||||
"resizable": true,
|
||||
"fullscreen": false,
|
||||
"decorations": false
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
]
|
||||
},
|
||||
"plugins": {
|
||||
"shell": {
|
||||
"open": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
.root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { useEffect } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import "./styles/global.css";
|
||||
import { useStore } from "./store";
|
||||
import Layout from "./components/layout/Layout";
|
||||
import Reader from "./components/pages/Reader";
|
||||
import Settings from "./components/settings/Settings";
|
||||
import TitleBar from "./components/layout/TitleBar";
|
||||
import s from "./App.module.css";
|
||||
|
||||
export default function App() {
|
||||
const activeChapter = useStore((s) => s.activeChapter);
|
||||
const settingsOpen = useStore((s) => s.settingsOpen);
|
||||
const settings = useStore((s) => s.settings);
|
||||
const setActiveDownloads = useStore((s) => s.setActiveDownloads);
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.style.zoom = `${settings.uiScale}%`;
|
||||
}, [settings.uiScale]);
|
||||
|
||||
useEffect(() => {
|
||||
const prevent = (e: MouseEvent) => e.preventDefault();
|
||||
document.addEventListener("contextmenu", prevent);
|
||||
return () => document.removeEventListener("contextmenu", prevent);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!settings.autoStartServer) return;
|
||||
invoke("spawn_server", { binary: settings.serverBinary }).catch((err) =>
|
||||
console.warn("Could not start server:", err)
|
||||
);
|
||||
return () => { invoke("kill_server").catch(() => {}); };
|
||||
}, [settings.autoStartServer, settings.serverBinary]);
|
||||
|
||||
// Global Tauri download-progress listener — no polling, always current
|
||||
useEffect(() => {
|
||||
type DlPayload = { chapterId: number; mangaId: number; progress: number }[];
|
||||
const unsub = listen<DlPayload>("download-progress", (e) => {
|
||||
setActiveDownloads(e.payload);
|
||||
});
|
||||
return () => { unsub.then((fn) => fn()); };
|
||||
}, [setActiveDownloads]);
|
||||
|
||||
return (
|
||||
<div className={s.root}>
|
||||
{!activeChapter && <TitleBar />}
|
||||
<div className={s.content}>
|
||||
{activeChapter ? <Reader /> : <Layout />}
|
||||
</div>
|
||||
{settingsOpen && <Settings />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?xml version="1.0" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
|
||||
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
|
||||
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
|
||||
width="500.000000pt" height="500.000000pt" viewBox="0 0 500.000000 500.000000"
|
||||
preserveAspectRatio="xMidYMid meet">
|
||||
<g transform="translate(0.000000,500.000000) scale(0.050000,-0.050000)"
|
||||
fill="#000000" stroke="none">
|
||||
<path d="M4908 6615 c-597 -629 -935 -1264 -934 -1755 0 -508 195 -778 790
|
||||
-1098 l194 -104 22 -114 c33 -164 76 -271 140 -347 80 -94 106 -62 77 94 -47
|
||||
255 43 443 294 613 797 539 714 1392 -250 2551 -230 278 -224 275 -333 160z
|
||||
m52 -945 c0 -472 -8 -850 -17 -850 -36 0 -693 703 -692 740 2 48 167 321 309
|
||||
509 175 233 359 451 380 451 13 0 20 -304 20 -850z m183 775 c120 -126 357
|
||||
-447 356 -482 0 -18 -47 -83 -105 -144 -57 -61 -151 -163 -208 -225 -151 -166
|
||||
-146 -180 -146 406 0 286 7 520 16 520 9 0 48 -34 87 -75z m541 -750 c86 -150
|
||||
196 -391 196 -430 0 -8 -25 -42 -55 -75 -31 -33 -149 -163 -264 -290 -339
|
||||
-374 -480 -520 -501 -520 -13 0 -20 167 -20 465 l1 465 151 170 c83 94 193
|
||||
217 244 275 122 138 137 135 248 -60z m-1098 -633 l374 -378 0 -462 c0 -254
|
||||
-5 -462 -11 -462 -6 0 -55 23 -110 50 l-99 51 0 208 c0 259 -11 288 -176 457
|
||||
-196 200 -188 199 -326 62 l-117 -116 -35 79 c-71 161 -39 569 64 816 42 100
|
||||
20 116 436 -305z m1349 23 c109 -390 -87 -848 -475 -1111 -207 -139 -305 -262
|
||||
-338 -420 -7 -31 -21 -56 -32 -56 -36 -1 -46 86 -48 405 l-2 313 123 137 c68
|
||||
75 214 236 325 357 454 493 421 466 447 375z m-1292 -785 c12 -29 15 -118 7
|
||||
-215 l-14 -166 -73 44 c-174 104 -403 341 -403 416 0 15 47 70 105 123 l104
|
||||
98 127 -125 c70 -69 136 -147 147 -175z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 5.6 KiB |
@@ -0,0 +1,55 @@
|
||||
.menu {
|
||||
position: fixed;
|
||||
z-index: 200;
|
||||
background: var(--bg-raised);
|
||||
border: 1px solid var(--border-base);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--sp-1);
|
||||
min-width: 180px;
|
||||
box-shadow:
|
||||
0 4px 16px rgba(0, 0, 0, 0.5),
|
||||
0 1px 4px rgba(0, 0, 0, 0.3);
|
||||
animation: scaleIn 0.1s ease both;
|
||||
transform-origin: top left;
|
||||
}
|
||||
|
||||
.item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
width: 100%;
|
||||
padding: 6px var(--sp-3);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background var(--t-fast), color var(--t-fast);
|
||||
border: none;
|
||||
background: none;
|
||||
}
|
||||
|
||||
.item:hover:not(:disabled) {
|
||||
background: var(--bg-overlay);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.itemDanger { color: var(--color-error); }
|
||||
.itemDanger:hover:not(:disabled) { background: var(--color-error-bg); color: var(--color-error); }
|
||||
|
||||
.itemDisabled { opacity: 0.35; cursor: default; }
|
||||
|
||||
.itemIcon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: inherit;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.itemLabel { flex: 1; }
|
||||
|
||||
.separator {
|
||||
height: 1px;
|
||||
background: var(--border-dim);
|
||||
margin: var(--sp-1) var(--sp-2);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { useEffect, useRef, useCallback } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import s from "./ContextMenu.module.css";
|
||||
|
||||
export interface ContextMenuItem {
|
||||
label: string;
|
||||
icon?: React.ReactNode;
|
||||
onClick: () => void;
|
||||
danger?: boolean;
|
||||
disabled?: boolean;
|
||||
separator?: never;
|
||||
}
|
||||
|
||||
export interface ContextMenuSeparator {
|
||||
separator: true;
|
||||
label?: never;
|
||||
icon?: never;
|
||||
onClick?: never;
|
||||
danger?: never;
|
||||
disabled?: never;
|
||||
}
|
||||
|
||||
export type ContextMenuEntry = ContextMenuItem | ContextMenuSeparator;
|
||||
|
||||
interface Props {
|
||||
x: number;
|
||||
y: number;
|
||||
items: ContextMenuEntry[];
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function ContextMenu({ x, y, items, onClose }: Props) {
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Close on outside click or Escape
|
||||
useEffect(() => {
|
||||
function onDown(e: MouseEvent) {
|
||||
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") onClose();
|
||||
}
|
||||
// Use capture so we intercept before other handlers
|
||||
document.addEventListener("mousedown", onDown, true);
|
||||
document.addEventListener("keydown", onKey, true);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", onDown, true);
|
||||
document.removeEventListener("keydown", onKey, true);
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
// Adjust position so menu doesn't clip outside viewport
|
||||
const style = useCallback(() => {
|
||||
const menuW = 200;
|
||||
const menuH = items.length * 32;
|
||||
const left = x + menuW > window.innerWidth ? x - menuW : x;
|
||||
const top = y + menuH > window.innerHeight ? y - menuH : y;
|
||||
return { left, top };
|
||||
}, [x, y, items.length]);
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
ref={menuRef}
|
||||
className={s.menu}
|
||||
style={style()}
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
>
|
||||
{items.map((item, i) => {
|
||||
if ("separator" in item && item.separator) {
|
||||
return <div key={i} className={s.separator} />;
|
||||
}
|
||||
const mi = item as ContextMenuItem;
|
||||
return (
|
||||
<button
|
||||
key={i}
|
||||
className={[s.item, mi.danger ? s.itemDanger : "", mi.disabled ? s.itemDisabled : ""].join(" ").trim()}
|
||||
onClick={() => { if (!mi.disabled) { mi.onClick(); onClose(); } }}
|
||||
disabled={mi.disabled}
|
||||
>
|
||||
{mi.icon && <span className={s.itemIcon}>{mi.icon}</span>}
|
||||
<span className={s.itemLabel}>{mi.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
.root {
|
||||
padding: var(--sp-6);
|
||||
overflow-y: auto;
|
||||
height: 100%;
|
||||
animation: fadeIn 0.14s ease both;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: var(--sp-5);
|
||||
}
|
||||
|
||||
.heading {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--weight-normal);
|
||||
color: var(--text-faint);
|
||||
letter-spacing: var(--tracking-wider);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.headerActions { display: flex; gap: var(--sp-2); }
|
||||
|
||||
.iconBtn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border-dim);
|
||||
color: var(--text-muted);
|
||||
transition: color var(--t-base), border-color var(--t-base), background var(--t-base);
|
||||
}
|
||||
|
||||
.iconBtn:hover { color: var(--text-secondary); border-color: var(--border-strong); background: var(--bg-raised); }
|
||||
.iconBtn:disabled { opacity: 0.3; cursor: default; }
|
||||
|
||||
.statusBar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
padding: var(--sp-3);
|
||||
background: var(--bg-raised);
|
||||
border: 1px solid var(--border-dim);
|
||||
border-radius: var(--radius-md);
|
||||
margin-bottom: var(--sp-4);
|
||||
}
|
||||
|
||||
.statusDot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-faint);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.statusDotActive {
|
||||
background: var(--accent);
|
||||
animation: pulse 1.6s ease infinite;
|
||||
}
|
||||
|
||||
.statusText {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-muted);
|
||||
flex: 1;
|
||||
letter-spacing: var(--tracking-wide);
|
||||
}
|
||||
|
||||
.statusCount {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-faint);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
}
|
||||
|
||||
.list { display: flex; flex-direction: column; gap: var(--sp-2); }
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
padding: var(--sp-3);
|
||||
background: var(--bg-raised);
|
||||
border: 1px solid var(--border-dim);
|
||||
border-radius: var(--radius-md);
|
||||
transition: border-color var(--t-fast);
|
||||
}
|
||||
|
||||
.rowActive { border-color: var(--accent-dim); }
|
||||
|
||||
/* Thumbnail */
|
||||
.thumb {
|
||||
width: 36px;
|
||||
height: 54px;
|
||||
border-radius: var(--radius-sm);
|
||||
overflow: hidden;
|
||||
background: var(--bg-overlay);
|
||||
flex-shrink: 0;
|
||||
border: 1px solid var(--border-dim);
|
||||
}
|
||||
|
||||
.thumbImg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
/* Info block */
|
||||
.info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mangaTitle {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--weight-medium);
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.chapterName {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.pagesLabel {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-2xs);
|
||||
color: var(--text-faint);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
}
|
||||
|
||||
.progressWrap {
|
||||
height: 2px;
|
||||
background: var(--border-base);
|
||||
border-radius: var(--radius-full);
|
||||
overflow: hidden;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.progressBar {
|
||||
height: 100%;
|
||||
background: var(--accent);
|
||||
border-radius: var(--radius-full);
|
||||
transition: width 0.4s ease;
|
||||
}
|
||||
|
||||
/* Right side */
|
||||
.rowRight {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: var(--sp-1);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.stateLabel {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-2xs);
|
||||
color: var(--text-faint);
|
||||
letter-spacing: var(--tracking-wider);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.removeBtn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-faint);
|
||||
transition: color var(--t-base), background var(--t-base);
|
||||
}
|
||||
|
||||
.removeBtn:hover { color: var(--color-error); background: var(--color-error-bg); }
|
||||
|
||||
.empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 160px;
|
||||
color: var(--text-faint);
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-xs);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Play, Pause, Trash, CircleNotch, X } from "@phosphor-icons/react";
|
||||
import { gql, thumbUrl } from "../../lib/client";
|
||||
import {
|
||||
GET_DOWNLOAD_STATUS, START_DOWNLOADER, STOP_DOWNLOADER,
|
||||
CLEAR_DOWNLOADER, DEQUEUE_DOWNLOAD,
|
||||
} from "../../lib/queries";
|
||||
import { useStore } from "../../store";
|
||||
import type { DownloadStatus } from "../../lib/types";
|
||||
import s from "./DownloadQueue.module.css";
|
||||
|
||||
export default function DownloadQueue() {
|
||||
const [status, setStatus] = useState<DownloadStatus | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const setActiveDownloads = useStore((s) => s.setActiveDownloads);
|
||||
|
||||
async function poll() {
|
||||
gql<{ downloadStatus: DownloadStatus }>(GET_DOWNLOAD_STATUS)
|
||||
.then((d) => {
|
||||
setStatus(d.downloadStatus);
|
||||
setActiveDownloads(
|
||||
d.downloadStatus.queue.map((item) => ({
|
||||
chapterId: item.chapter.id,
|
||||
mangaId: item.chapter.mangaId,
|
||||
progress: item.progress,
|
||||
}))
|
||||
);
|
||||
})
|
||||
.catch(console.error)
|
||||
.finally(() => setLoading(false));
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
poll();
|
||||
const id = setInterval(poll, 1500);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
async function start() { await gql(START_DOWNLOADER).catch(console.error); poll(); }
|
||||
async function stop() { await gql(STOP_DOWNLOADER).catch(console.error); poll(); }
|
||||
async function clear() { await gql(CLEAR_DOWNLOADER).catch(console.error); poll(); }
|
||||
async function dequeue(chapterId: number) {
|
||||
await gql(DEQUEUE_DOWNLOAD, { chapterId }).catch(console.error);
|
||||
poll();
|
||||
}
|
||||
|
||||
const queue = status?.queue ?? [];
|
||||
const isRunning = status?.state === "STARTED";
|
||||
|
||||
function pagesDownloaded(progress: number, pageCount: number): number {
|
||||
return Math.round(progress * pageCount);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={s.root}>
|
||||
<div className={s.header}>
|
||||
<h1 className={s.heading}>Downloads</h1>
|
||||
<div className={s.headerActions}>
|
||||
{isRunning ? (
|
||||
<button className={s.iconBtn} onClick={stop} title="Pause">
|
||||
<Pause size={14} weight="fill" />
|
||||
</button>
|
||||
) : (
|
||||
<button className={s.iconBtn} onClick={start} disabled={queue.length === 0} title="Resume">
|
||||
<Play size={14} weight="fill" />
|
||||
</button>
|
||||
)}
|
||||
<button className={s.iconBtn} onClick={clear} disabled={queue.length === 0} title="Clear queue">
|
||||
<Trash size={14} weight="regular" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={s.statusBar}>
|
||||
<div className={[s.statusDot, isRunning ? s.statusDotActive : ""].join(" ").trim()} />
|
||||
<span className={s.statusText}>{isRunning ? "Downloading" : "Paused"}</span>
|
||||
<span className={s.statusCount}>{queue.length} queued</span>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className={s.empty}>
|
||||
<CircleNotch size={16} weight="light" className="anim-spin" style={{ color: "var(--text-faint)" }} />
|
||||
</div>
|
||||
) : queue.length === 0 ? (
|
||||
<div className={s.empty}>Queue is empty.</div>
|
||||
) : (
|
||||
<div className={s.list}>
|
||||
{queue.map((item, i) => {
|
||||
const isActive = i === 0 && isRunning;
|
||||
const pages = item.chapter.pageCount ?? 0;
|
||||
const done = pagesDownloaded(item.progress, pages);
|
||||
const manga = item.chapter.manga;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.chapter.id}
|
||||
className={[s.row, isActive ? s.rowActive : ""].join(" ").trim()}
|
||||
>
|
||||
{manga?.thumbnailUrl && (
|
||||
<div className={s.thumb}>
|
||||
<img
|
||||
src={thumbUrl(manga.thumbnailUrl)}
|
||||
alt={manga.title}
|
||||
className={s.thumbImg}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={s.info}>
|
||||
{manga?.title && (
|
||||
<span className={s.mangaTitle}>{manga.title}</span>
|
||||
)}
|
||||
<span className={s.chapterName}>{item.chapter.name}</span>
|
||||
|
||||
{pages > 0 && (
|
||||
<span className={s.pagesLabel}>
|
||||
{isActive ? `${done} / ${pages} pages` : `${pages} pages`}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{isActive && (
|
||||
<div className={s.progressWrap}>
|
||||
<div
|
||||
className={s.progressBar}
|
||||
style={{ width: `${Math.round(item.progress * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={s.rowRight}>
|
||||
<span className={s.stateLabel}>{item.state}</span>
|
||||
{!isActive && (
|
||||
<button
|
||||
className={s.removeBtn}
|
||||
onClick={() => dequeue(item.chapter.id)}
|
||||
title="Remove from queue"
|
||||
>
|
||||
<X size={12} weight="light" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
.root {
|
||||
display: flex; flex-direction: column; height: 100%;
|
||||
overflow: hidden; animation: fadeIn 0.14s ease both;
|
||||
}
|
||||
.header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: var(--sp-5) var(--sp-6) var(--sp-3); flex-shrink: 0;
|
||||
}
|
||||
.heading {
|
||||
font-family: var(--font-ui); font-size: var(--text-xs); font-weight: var(--weight-normal);
|
||||
color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase;
|
||||
}
|
||||
.headerActions { display: flex; gap: var(--sp-1); }
|
||||
.iconBtn {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
width: 28px; height: 28px; border-radius: var(--radius-md);
|
||||
color: var(--text-muted); transition: color var(--t-base), background var(--t-base);
|
||||
}
|
||||
.iconBtn:hover:not(:disabled) { color: var(--text-primary); background: var(--bg-raised); }
|
||||
.iconBtn:disabled { opacity: 0.4; }
|
||||
|
||||
.externalRow {
|
||||
display: flex; gap: var(--sp-2); padding: 0 var(--sp-6) var(--sp-3); flex-shrink: 0;
|
||||
}
|
||||
.externalInput {
|
||||
flex: 1; background: var(--bg-raised); border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-md); padding: 6px var(--sp-3);
|
||||
color: var(--text-primary); font-size: var(--text-sm); outline: none;
|
||||
}
|
||||
.externalInput:focus { border-color: var(--border-focus); }
|
||||
.installBtn {
|
||||
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
|
||||
padding: 6px 14px; border-radius: var(--radius-md);
|
||||
background: var(--accent-muted); color: var(--accent-fg);
|
||||
border: 1px solid var(--accent-dim); cursor: pointer;
|
||||
}
|
||||
.installBtn:hover { filter: brightness(1.1); }
|
||||
|
||||
.controls {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 0 var(--sp-6) var(--sp-3); gap: var(--sp-3); flex-shrink: 0;
|
||||
}
|
||||
.tabs { display: flex; gap: 2px; }
|
||||
.tab {
|
||||
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
|
||||
padding: 4px 10px; border-radius: var(--radius-md); border: none;
|
||||
background: none; color: var(--text-muted); cursor: pointer;
|
||||
transition: background var(--t-base), color var(--t-base);
|
||||
}
|
||||
.tab:hover { background: var(--bg-raised); color: var(--text-secondary); }
|
||||
.tabActive { background: var(--accent-muted); color: var(--accent-fg); }
|
||||
.tabActive:hover { background: var(--accent-muted); color: var(--accent-fg); }
|
||||
|
||||
.searchWrap { position: relative; display: flex; align-items: center; }
|
||||
.searchIcon { position: absolute; left: 9px; color: var(--text-faint); pointer-events: none; }
|
||||
.search {
|
||||
background: var(--bg-raised); border: 1px solid var(--border-dim);
|
||||
border-radius: var(--radius-md); padding: 5px 10px 5px 26px;
|
||||
color: var(--text-primary); font-size: var(--text-sm); width: 160px; outline: none;
|
||||
transition: border-color var(--t-base);
|
||||
}
|
||||
.search::placeholder { color: var(--text-faint); }
|
||||
.search:focus { border-color: var(--border-strong); }
|
||||
|
||||
.list { flex: 1; overflow-y: auto; padding: 0 var(--sp-4) var(--sp-4); display: flex; flex-direction: column; gap: 1px; }
|
||||
|
||||
.group { display: flex; flex-direction: column; }
|
||||
|
||||
.row {
|
||||
display: flex; align-items: center; gap: var(--sp-3);
|
||||
padding: 8px var(--sp-3); border-radius: var(--radius-md);
|
||||
border: 1px solid transparent;
|
||||
transition: background var(--t-fast), border-color var(--t-fast);
|
||||
}
|
||||
.row:hover { background: var(--bg-raised); border-color: var(--border-dim); }
|
||||
|
||||
.icon {
|
||||
width: 32px; height: 32px; border-radius: var(--radius-md);
|
||||
object-fit: cover; flex-shrink: 0; background: var(--bg-raised);
|
||||
}
|
||||
.info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; min-width: 0; }
|
||||
.name {
|
||||
font-size: var(--text-base); font-weight: var(--weight-medium);
|
||||
color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.meta {
|
||||
display: flex; align-items: center; gap: var(--sp-2);
|
||||
font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
}
|
||||
|
||||
.langTag {
|
||||
background: var(--bg-overlay); border: 1px solid var(--border-dim);
|
||||
border-radius: var(--radius-sm); padding: 1px 5px;
|
||||
font-family: var(--font-ui); font-size: var(--text-2xs);
|
||||
color: var(--text-muted); letter-spacing: var(--tracking-wider);
|
||||
}
|
||||
.nsfwTag {
|
||||
background: transparent; border: 1px solid var(--color-error);
|
||||
border-radius: var(--radius-sm); padding: 1px 5px;
|
||||
font-family: var(--font-ui); font-size: var(--text-2xs);
|
||||
color: var(--color-error); letter-spacing: var(--tracking-wider);
|
||||
}
|
||||
.updateBadge {
|
||||
font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide);
|
||||
background: var(--accent-muted); color: var(--accent-fg);
|
||||
border: 1px solid var(--accent-dim); border-radius: var(--radius-sm);
|
||||
padding: 2px 6px; flex-shrink: 0;
|
||||
}
|
||||
.updateBadgeSmall {
|
||||
font-family: var(--font-ui); font-size: var(--text-2xs);
|
||||
color: var(--accent-fg); flex-shrink: 0;
|
||||
}
|
||||
|
||||
.rowActions { display: flex; gap: var(--sp-1); flex-shrink: 0; }
|
||||
.actionBtn {
|
||||
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
|
||||
padding: 4px 10px; border-radius: var(--radius-md);
|
||||
background: var(--accent-muted); color: var(--accent-fg);
|
||||
border: 1px solid var(--accent-dim); cursor: pointer; flex-shrink: 0;
|
||||
transition: filter var(--t-base);
|
||||
}
|
||||
.actionBtn:hover { filter: brightness(1.1); }
|
||||
.actionBtnDim {
|
||||
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
|
||||
padding: 4px 10px; border-radius: var(--radius-md);
|
||||
background: none; color: var(--text-faint);
|
||||
border: 1px solid var(--border-dim); cursor: pointer; flex-shrink: 0;
|
||||
transition: color var(--t-base), border-color var(--t-base);
|
||||
}
|
||||
.actionBtnDim:hover { color: var(--color-error); border-color: var(--color-error); }
|
||||
|
||||
.expandBtn {
|
||||
display: flex; align-items: center; gap: 3px;
|
||||
padding: 4px 6px; border-radius: var(--radius-sm);
|
||||
color: var(--text-faint); flex-shrink: 0;
|
||||
transition: color var(--t-base), background var(--t-base);
|
||||
}
|
||||
.expandBtn:hover { color: var(--text-muted); background: var(--bg-overlay); }
|
||||
.expandCount {
|
||||
font-family: var(--font-ui); font-size: var(--text-2xs);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
}
|
||||
|
||||
.variants {
|
||||
display: flex; flex-direction: column; gap: 1px;
|
||||
margin: 1px 0 2px calc(32px + var(--sp-3) + var(--sp-3));
|
||||
padding-left: var(--sp-3);
|
||||
border-left: 1px solid var(--border-dim);
|
||||
animation: fadeIn 0.1s ease both;
|
||||
}
|
||||
.variantRow {
|
||||
display: flex; align-items: center; gap: var(--sp-2);
|
||||
padding: 5px var(--sp-2); border-radius: var(--radius-md);
|
||||
transition: background var(--t-fast);
|
||||
}
|
||||
.variantRow:hover { background: var(--bg-raised); }
|
||||
.variantName {
|
||||
flex: 1; font-size: var(--text-sm); color: var(--text-muted);
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.variantVersion {
|
||||
font-family: var(--font-ui); font-size: var(--text-2xs);
|
||||
color: var(--text-faint); letter-spacing: var(--tracking-wide); flex-shrink: 0;
|
||||
}
|
||||
.variantActions { flex-shrink: 0; }
|
||||
|
||||
.empty {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
flex: 1; color: var(--text-faint);
|
||||
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
import { useEffect, useState, useMemo } from "react";
|
||||
import { MagnifyingGlass, ArrowsClockwise, Plus, CircleNotch, CaretRight, CaretDown } from "@phosphor-icons/react";
|
||||
import { gql, thumbUrl } from "../../lib/client";
|
||||
import {
|
||||
GET_EXTENSIONS, FETCH_EXTENSIONS, UPDATE_EXTENSION, INSTALL_EXTERNAL_EXTENSION,
|
||||
} from "../../lib/queries";
|
||||
import { useStore } from "../../store";
|
||||
import type { Extension } from "../../lib/types";
|
||||
import s from "./ExtensionList.module.css";
|
||||
|
||||
type Filter = "installed" | "available" | "updates" | "all";
|
||||
|
||||
// Strip language tag suffix e.g. "MangaDex (EN)" → "MangaDex"
|
||||
function baseName(name: string): string {
|
||||
return name.replace(/\s*\([A-Z0-9-]{2,10}\)\s*$/, "").trim();
|
||||
}
|
||||
|
||||
interface ExtGroup {
|
||||
base: string;
|
||||
primary: Extension;
|
||||
variants: Extension[]; // all variants excluding primary
|
||||
}
|
||||
|
||||
export default function ExtensionList() {
|
||||
const [extensions, setExtensions] = useState<Extension[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [filter, setFilter] = useState<Filter>("installed");
|
||||
const [search, setSearch] = useState("");
|
||||
const [working, setWorking] = useState<Set<string>>(new Set());
|
||||
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||
const [externalUrl, setExternalUrl] = useState("");
|
||||
const [showExternal, setShowExternal] = useState(false);
|
||||
const preferredLang = useStore((s) => s.settings.preferredExtensionLang);
|
||||
|
||||
async function load() {
|
||||
return gql<{ extensions: { nodes: Extension[] } }>(GET_EXTENSIONS)
|
||||
.then((d) => setExtensions(d.extensions.nodes))
|
||||
.catch(console.error);
|
||||
}
|
||||
|
||||
async function fetchFromRepo() {
|
||||
setRefreshing(true);
|
||||
return gql<{ fetchExtensions: { extensions: Extension[] } }>(FETCH_EXTENSIONS)
|
||||
.then((d) => setExtensions(d.fetchExtensions.extensions))
|
||||
.catch(console.error)
|
||||
.finally(() => setRefreshing(false));
|
||||
}
|
||||
|
||||
const mutate = async (fn: () => Promise<unknown>, pkgName: string) => {
|
||||
setWorking((p) => new Set(p).add(pkgName));
|
||||
await fn().catch(console.error);
|
||||
await load();
|
||||
setWorking((p) => { const n = new Set(p); n.delete(pkgName); return n; });
|
||||
};
|
||||
|
||||
async function installExternal() {
|
||||
if (!externalUrl.trim()) return;
|
||||
await gql(INSTALL_EXTERNAL_EXTENSION, { url: externalUrl.trim() }).catch(console.error);
|
||||
setExternalUrl("");
|
||||
setShowExternal(false);
|
||||
await load();
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchFromRepo().finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const filtered = extensions.filter((e) => {
|
||||
const q = search.toLowerCase();
|
||||
const matchSearch = e.name.toLowerCase().includes(q) || e.lang.toLowerCase().includes(q);
|
||||
const matchFilter =
|
||||
filter === "installed" ? e.isInstalled :
|
||||
filter === "available" ? !e.isInstalled :
|
||||
filter === "updates" ? e.hasUpdate : true;
|
||||
return matchSearch && matchFilter;
|
||||
});
|
||||
|
||||
// Group by base name. Primary is the preferred/en/first variant.
|
||||
// variants contains only the non-primary ones for the expanded list.
|
||||
const groups = useMemo<ExtGroup[]>(() => {
|
||||
const map = new Map<string, Extension[]>();
|
||||
for (const ext of filtered) {
|
||||
const key = baseName(ext.name);
|
||||
if (!map.has(key)) map.set(key, []);
|
||||
map.get(key)!.push(ext);
|
||||
}
|
||||
return Array.from(map.entries()).map(([base, all]) => {
|
||||
const primary =
|
||||
all.find((v) => v.lang === preferredLang) ??
|
||||
all.find((v) => v.lang === "en") ??
|
||||
all[0];
|
||||
const variants = all.filter((v) => v.pkgName !== primary.pkgName);
|
||||
return { base, primary, variants };
|
||||
});
|
||||
}, [filtered, preferredLang]);
|
||||
|
||||
const updateCount = extensions.filter((e) => e.hasUpdate).length;
|
||||
|
||||
const FILTERS: { id: Filter; label: string }[] = [
|
||||
{ id: "installed", label: "Installed" },
|
||||
{ id: "available", label: "Available" },
|
||||
{ id: "updates", label: updateCount > 0 ? `Updates (${updateCount})` : "Updates" },
|
||||
{ id: "all", label: "All" },
|
||||
];
|
||||
|
||||
function toggleExpand(base: string) {
|
||||
setExpanded((p) => {
|
||||
const n = new Set(p);
|
||||
n.has(base) ? n.delete(base) : n.add(base);
|
||||
return n;
|
||||
});
|
||||
}
|
||||
|
||||
function renderActions(ext: Extension) {
|
||||
if (working.has(ext.pkgName))
|
||||
return <CircleNotch size={14} weight="light" className="anim-spin" style={{ color: "var(--text-faint)" }} />;
|
||||
if (ext.hasUpdate) return (
|
||||
<div className={s.rowActions}>
|
||||
<button className={s.actionBtn} onClick={() => mutate(() => gql(UPDATE_EXTENSION, { id: ext.pkgName, update: true }), ext.pkgName)}>Update</button>
|
||||
<button className={s.actionBtnDim} onClick={() => mutate(() => gql(UPDATE_EXTENSION, { id: ext.pkgName, uninstall: true }), ext.pkgName)}>Remove</button>
|
||||
</div>
|
||||
);
|
||||
if (ext.isInstalled)
|
||||
return <button className={s.actionBtnDim} onClick={() => mutate(() => gql(UPDATE_EXTENSION, { id: ext.pkgName, uninstall: true }), ext.pkgName)}>Remove</button>;
|
||||
return <button className={s.actionBtn} onClick={() => mutate(() => gql(UPDATE_EXTENSION, { id: ext.pkgName, install: true }), ext.pkgName)}>Install</button>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={s.root}>
|
||||
<div className={s.header}>
|
||||
<h1 className={s.heading}>Extensions</h1>
|
||||
<div className={s.headerActions}>
|
||||
<button className={s.iconBtn} onClick={() => setShowExternal(!showExternal)} title="Install from URL">
|
||||
<Plus size={14} weight="light" />
|
||||
</button>
|
||||
<button className={s.iconBtn} onClick={fetchFromRepo} disabled={refreshing} title="Refresh repo">
|
||||
<ArrowsClockwise size={14} weight="light" className={refreshing ? "anim-spin" : ""} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showExternal && (
|
||||
<div className={s.externalRow}>
|
||||
<input className={s.externalInput} placeholder="APK URL"
|
||||
value={externalUrl} onChange={(e) => setExternalUrl(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && installExternal()} autoFocus />
|
||||
<button className={s.installBtn} onClick={installExternal}>Install</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={s.controls}>
|
||||
<div className={s.tabs}>
|
||||
{FILTERS.map((f) => (
|
||||
<button key={f.id} onClick={() => setFilter(f.id)}
|
||||
className={[s.tab, filter === f.id ? s.tabActive : ""].join(" ").trim()}>
|
||||
{f.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className={s.searchWrap}>
|
||||
<MagnifyingGlass size={12} className={s.searchIcon} weight="light" />
|
||||
<input className={s.search} placeholder="Search"
|
||||
value={search} onChange={(e) => setSearch(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className={s.empty}>
|
||||
<CircleNotch size={16} weight="light" className="anim-spin" style={{ color: "var(--text-faint)" }} />
|
||||
</div>
|
||||
) : groups.length === 0 ? (
|
||||
<div className={s.empty}>No extensions found.</div>
|
||||
) : (
|
||||
<div className={s.list}>
|
||||
{groups.map(({ base, primary, variants }) => {
|
||||
const isExpanded = expanded.has(base);
|
||||
const hasVariants = variants.length > 0;
|
||||
|
||||
return (
|
||||
<div key={base} className={s.group}>
|
||||
<div className={s.row}>
|
||||
<img src={thumbUrl(primary.iconUrl)} alt={primary.name} className={s.icon}
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
|
||||
<div className={s.info}>
|
||||
<span className={s.name}>{base}</span>
|
||||
<span className={s.meta}>
|
||||
<span className={s.langTag}>{primary.lang.toUpperCase()}</span>
|
||||
{" "}v{primary.versionName}
|
||||
</span>
|
||||
</div>
|
||||
{primary.hasUpdate && <span className={s.updateBadge}>Update</span>}
|
||||
{renderActions(primary)}
|
||||
{hasVariants && (
|
||||
<button className={s.expandBtn} onClick={() => toggleExpand(base)}
|
||||
title={`${variants.length + 1} languages`}>
|
||||
{isExpanded
|
||||
? <CaretDown size={12} weight="light" />
|
||||
: <CaretRight size={12} weight="light" />}
|
||||
<span className={s.expandCount}>{variants.length + 1}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isExpanded && hasVariants && (
|
||||
<div className={s.variants}>
|
||||
{variants.map((v) => (
|
||||
<div key={v.pkgName} className={s.variantRow}>
|
||||
<span className={s.langTag}>{v.lang.toUpperCase()}</span>
|
||||
<span className={s.variantName}>{v.name}</span>
|
||||
<span className={s.variantVersion}>v{v.versionName}</span>
|
||||
{v.hasUpdate && <span className={s.updateBadgeSmall}>↑</span>}
|
||||
<div className={s.variantActions}>{renderActions(v)}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
.root {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
background: var(--bg-base);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.main {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
background: var(--bg-surface);
|
||||
/* GPU layer for main content area */
|
||||
transform: translateZ(0);
|
||||
contain: layout style;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { useStore } from "../../store";
|
||||
import Sidebar from "./Sidebar";
|
||||
import Library from "../pages/Library";
|
||||
import SeriesDetail from "../pages/SeriesDetail";
|
||||
import History from "../pages/History";
|
||||
import Search from "../pages/Search";
|
||||
import SourceList from "../sources/SourceList";
|
||||
import SourceBrowse from "../sources/SourceBrowse";
|
||||
import DownloadQueue from "../downloads/DownloadQueue";
|
||||
import ExtensionList from "../extensions/ExtensionList";
|
||||
import s from "./Layout.module.css";
|
||||
|
||||
export default function Layout() {
|
||||
const navPage = useStore((s) => s.navPage);
|
||||
const activeManga = useStore((s) => s.activeManga);
|
||||
const activeSource = useStore((s) => s.activeSource);
|
||||
|
||||
function renderContent() {
|
||||
if (navPage === "library" && activeManga) return <SeriesDetail />;
|
||||
if (navPage === "sources" && activeSource) return <SourceBrowse />;
|
||||
switch (navPage) {
|
||||
case "library": return <Library />;
|
||||
case "search": return <Search />;
|
||||
case "history": return <History />;
|
||||
case "sources": return <SourceList />;
|
||||
case "downloads": return <DownloadQueue />;
|
||||
case "extensions": return <ExtensionList />;
|
||||
default: return <Library />;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={s.root}>
|
||||
<Sidebar />
|
||||
<main className={s.main}>{renderContent()}</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
.root {
|
||||
width: var(--sidebar-width);
|
||||
flex-shrink: 0;
|
||||
background: var(--bg-void);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: var(--sp-4) 0;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.logo {
|
||||
/* Logo set to 80px */
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
/* MARGIN REMOVED */
|
||||
margin-bottom: 0;
|
||||
|
||||
/* Allows the logo to overflow the sidebar width if the sidebar is smaller than 80px */
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.logoIcon {
|
||||
/* Icon set to 80px */
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
|
||||
/* Apply your UI accent green */
|
||||
background-color: var(--accent);
|
||||
|
||||
/* SVG Mask Logic using Moku-Icon.svg */
|
||||
mask-image: url("../../assets/Moku-Icon.svg");
|
||||
mask-repeat: no-repeat;
|
||||
mask-position: center;
|
||||
mask-size: contain;
|
||||
|
||||
-webkit-mask-image: url("../../assets/Moku-Icon.svg");
|
||||
-webkit-mask-repeat: no-repeat;
|
||||
-webkit-mask-position: center;
|
||||
-webkit-mask-size: contain;
|
||||
|
||||
/* Prominent glow for the large logo */
|
||||
filter: drop-shadow(0 0 12px rgba(107, 143, 107, 0.4));
|
||||
}
|
||||
|
||||
.nav {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--sp-1);
|
||||
width: 100%;
|
||||
padding: 0 var(--sp-2);
|
||||
}
|
||||
|
||||
.tab {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-faint);
|
||||
transition: color var(--t-base), background var(--t-base);
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
color: var(--text-muted);
|
||||
background: var(--bg-raised);
|
||||
}
|
||||
|
||||
.tabActive {
|
||||
color: var(--accent-fg);
|
||||
background: var(--accent-muted);
|
||||
}
|
||||
|
||||
.tabActive:hover {
|
||||
color: var(--accent-fg);
|
||||
background: var(--accent-muted);
|
||||
}
|
||||
|
||||
/* ── Bottom section ── */
|
||||
.bottom {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: var(--sp-3) var(--sp-2) 0;
|
||||
border-top: 1px solid var(--border-dim);
|
||||
margin-top: var(--sp-3);
|
||||
}
|
||||
|
||||
.settingsBtn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-faint);
|
||||
transition: color var(--t-base), background var(--t-base), transform var(--t-slow);
|
||||
}
|
||||
|
||||
.settingsBtn:hover {
|
||||
color: var(--text-muted);
|
||||
background: var(--bg-raised);
|
||||
transform: rotate(30deg);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import {
|
||||
Books, DownloadSimple, PuzzlePiece, Compass,
|
||||
GearSix, ClockCounterClockwise, MagnifyingGlass,
|
||||
} from "@phosphor-icons/react";
|
||||
import { useStore, type NavPage } from "../../store";
|
||||
import s from "./Sidebar.module.css";
|
||||
|
||||
const TABS: { id: NavPage; icon: React.ReactNode; label: string }[] = [
|
||||
{ id: "library", icon: <Books size={18} weight="light" />, label: "Library" },
|
||||
{ id: "search", icon: <MagnifyingGlass size={18} weight="light" />, label: "Search" },
|
||||
{ id: "history", icon: <ClockCounterClockwise size={18} weight="light" />, label: "History" },
|
||||
{ id: "sources", icon: <Compass size={18} weight="light" />, label: "Sources" },
|
||||
{ id: "downloads", icon: <DownloadSimple size={18} weight="light" />, label: "Downloads" },
|
||||
{ id: "extensions", icon: <PuzzlePiece size={18} weight="light" />, label: "Extensions" },
|
||||
];
|
||||
|
||||
export default function Sidebar() {
|
||||
const navPage = useStore((state) => state.navPage);
|
||||
const setNavPage = useStore((state) => state.setNavPage);
|
||||
const setActiveSource = useStore((state) => state.setActiveSource);
|
||||
const openSettings = useStore((state) => state.openSettings);
|
||||
|
||||
function navigate(id: NavPage) {
|
||||
setNavPage(id);
|
||||
if (id !== "sources") setActiveSource(null);
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className={s.root}>
|
||||
<div className={s.logo}>
|
||||
<div className={s.logoIcon} aria-label="Moku Logo" />
|
||||
</div>
|
||||
<nav className={s.nav}>
|
||||
{TABS.map((tab) => (
|
||||
<button key={tab.id} title={tab.label}
|
||||
onClick={() => navigate(tab.id)}
|
||||
className={[s.tab, navPage === tab.id ? s.tabActive : ""].join(" ")}>
|
||||
{tab.icon}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
<div className={s.bottom}>
|
||||
<button className={s.settingsBtn} onClick={openSettings} title="Settings">
|
||||
<GearSix size={18} weight="light" />
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
.bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 32px;
|
||||
padding: 0 var(--sp-3) 0 var(--sp-4);
|
||||
background: var(--bg-void);
|
||||
border-bottom: 1px solid var(--border-dim);
|
||||
flex-shrink: 0;
|
||||
user-select: none;
|
||||
/* Drag region covers the whole bar */
|
||||
-webkit-app-region: drag;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-2xs);
|
||||
color: var(--text-faint);
|
||||
letter-spacing: var(--tracking-wider);
|
||||
text-transform: uppercase;
|
||||
-webkit-app-region: drag;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
/* Controls must NOT be draggable */
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-faint);
|
||||
transition: color var(--t-base), background var(--t-base);
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
color: var(--text-muted);
|
||||
background: var(--bg-raised);
|
||||
}
|
||||
|
||||
.btnClose:hover {
|
||||
color: #fff;
|
||||
background: #c0392b;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import s from "./TitleBar.module.css";
|
||||
|
||||
const win = getCurrentWindow();
|
||||
|
||||
export default function TitleBar() {
|
||||
return (
|
||||
<div className={s.bar} data-tauri-drag-region>
|
||||
<span className={s.title} data-tauri-drag-region>Moku</span>
|
||||
<div className={s.controls}>
|
||||
<button
|
||||
className={s.btn}
|
||||
onClick={() => win.minimize()}
|
||||
title="Minimize"
|
||||
aria-label="Minimize"
|
||||
>
|
||||
<svg width="10" height="1" viewBox="0 0 10 1">
|
||||
<line x1="0" y1="0.5" x2="10" y2="0.5" stroke="currentColor" strokeWidth="1.5" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
className={s.btn}
|
||||
onClick={() => win.toggleMaximize()}
|
||||
title="Maximize"
|
||||
aria-label="Maximize"
|
||||
>
|
||||
<svg width="9" height="9" viewBox="0 0 9 9">
|
||||
<rect x="0.75" y="0.75" width="7.5" height="7.5" rx="1"
|
||||
fill="none" stroke="currentColor" strokeWidth="1.5" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
className={[s.btn, s.btnClose].join(" ")}
|
||||
onClick={() => win.close()}
|
||||
title="Close"
|
||||
aria-label="Close"
|
||||
>
|
||||
<svg width="10" height="10" viewBox="0 0 10 10">
|
||||
<line x1="1" y1="1" x2="9" y2="9" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||
<line x1="9" y1="1" x2="1" y2="9" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
.root {
|
||||
display: flex; flex-direction: column; height: 100%;
|
||||
overflow: hidden; animation: fadeIn 0.14s ease both;
|
||||
}
|
||||
.header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: var(--sp-5) var(--sp-6) var(--sp-3); flex-shrink: 0;
|
||||
border-bottom: 1px solid var(--border-dim);
|
||||
}
|
||||
.heading {
|
||||
font-family: var(--font-ui); font-size: var(--text-xs); font-weight: var(--weight-normal);
|
||||
color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase;
|
||||
}
|
||||
.headerRight { display: flex; align-items: center; gap: var(--sp-2); }
|
||||
.searchWrap { position: relative; display: flex; align-items: center; }
|
||||
.searchIcon { position: absolute; left: 9px; color: var(--text-faint); pointer-events: none; }
|
||||
.search {
|
||||
background: var(--bg-raised); border: 1px solid var(--border-dim);
|
||||
border-radius: var(--radius-md); padding: 5px 10px 5px 26px;
|
||||
color: var(--text-primary); font-size: var(--text-sm); width: 180px; outline: none;
|
||||
transition: border-color var(--t-base);
|
||||
}
|
||||
.search::placeholder { color: var(--text-faint); }
|
||||
.search:focus { border-color: var(--border-strong); }
|
||||
.clearBtn {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
width: 28px; height: 28px; border-radius: var(--radius-md);
|
||||
color: var(--text-faint); transition: color var(--t-base), background var(--t-base);
|
||||
}
|
||||
.clearBtn:hover { color: var(--color-error); background: var(--color-error-bg); }
|
||||
|
||||
.list { flex: 1; overflow-y: auto; padding: var(--sp-4) var(--sp-6); }
|
||||
|
||||
.group { margin-bottom: var(--sp-5); }
|
||||
.groupLabel {
|
||||
font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint);
|
||||
letter-spacing: var(--tracking-wider); text-transform: uppercase;
|
||||
margin-bottom: var(--sp-2); padding: 0 var(--sp-1);
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex; align-items: center; gap: var(--sp-3);
|
||||
width: 100%; padding: 8px var(--sp-2); border-radius: var(--radius-md);
|
||||
border: 1px solid transparent; background: none; text-align: left; cursor: pointer;
|
||||
transition: background var(--t-fast), border-color var(--t-fast);
|
||||
}
|
||||
.row:hover { background: var(--bg-raised); border-color: var(--border-dim); }
|
||||
.row:hover .playIcon { opacity: 1; }
|
||||
|
||||
.thumb {
|
||||
width: 36px; height: 52px; border-radius: var(--radius-sm);
|
||||
object-fit: cover; flex-shrink: 0; background: var(--bg-raised);
|
||||
border: 1px solid var(--border-dim);
|
||||
}
|
||||
.info { flex: 1; display: flex; flex-direction: column; gap: 3px; overflow: hidden; min-width: 0; }
|
||||
.mangaTitle {
|
||||
font-size: var(--text-base); font-weight: var(--weight-medium);
|
||||
color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.chapterName {
|
||||
font-size: var(--text-sm); color: var(--text-muted);
|
||||
display: flex; align-items: center; gap: var(--sp-2);
|
||||
}
|
||||
.pageBadge {
|
||||
font-family: var(--font-ui); font-size: var(--text-2xs);
|
||||
color: var(--text-faint); letter-spacing: var(--tracking-wide);
|
||||
}
|
||||
.time {
|
||||
font-family: var(--font-ui); font-size: var(--text-xs);
|
||||
color: var(--text-faint); letter-spacing: var(--tracking-wide);
|
||||
flex-shrink: 0; white-space: nowrap;
|
||||
}
|
||||
.playIcon {
|
||||
color: var(--text-faint); flex-shrink: 0;
|
||||
opacity: 0; transition: opacity var(--t-base);
|
||||
}
|
||||
|
||||
.empty {
|
||||
flex: 1; display: flex; flex-direction: column;
|
||||
align-items: center; justify-content: center; gap: var(--sp-2);
|
||||
}
|
||||
.emptyIcon { color: var(--text-faint); }
|
||||
.emptyText { font-size: var(--text-base); color: var(--text-muted); }
|
||||
.emptyHint { font-size: var(--text-sm); color: var(--text-faint); }
|
||||
@@ -0,0 +1,123 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { ClockCounterClockwise, Trash, MagnifyingGlass, Play } from "@phosphor-icons/react";
|
||||
import { thumbUrl } from "../../lib/client";
|
||||
import { useStore, type HistoryEntry } from "../../store";
|
||||
import s from "./History.module.css";
|
||||
|
||||
function timeAgo(ts: number): string {
|
||||
const diff = Date.now() - ts;
|
||||
const m = Math.floor(diff / 60000);
|
||||
if (m < 1) return "Just now";
|
||||
if (m < 60) return `${m}m ago`;
|
||||
const h = Math.floor(m / 60);
|
||||
if (h < 24) return `${h}h ago`;
|
||||
const d = Math.floor(h / 24);
|
||||
if (d < 7) return `${d}d ago`;
|
||||
return new Date(ts).toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
||||
}
|
||||
|
||||
// Group entries by day
|
||||
function groupByDay(entries: HistoryEntry[]): { label: string; items: HistoryEntry[] }[] {
|
||||
const groups = new Map<string, HistoryEntry[]>();
|
||||
for (const e of entries) {
|
||||
const d = new Date(e.readAt);
|
||||
const now = new Date();
|
||||
let label: string;
|
||||
if (d.toDateString() === now.toDateString()) label = "Today";
|
||||
else {
|
||||
const yesterday = new Date(now);
|
||||
yesterday.setDate(now.getDate() - 1);
|
||||
if (d.toDateString() === yesterday.toDateString()) label = "Yesterday";
|
||||
else label = d.toLocaleDateString("en-US", { weekday: "long", month: "long", day: "numeric" });
|
||||
}
|
||||
if (!groups.has(label)) groups.set(label, []);
|
||||
groups.get(label)!.push(e);
|
||||
}
|
||||
return Array.from(groups.entries()).map(([label, items]) => ({ label, items }));
|
||||
}
|
||||
|
||||
export default function History() {
|
||||
const history = useStore((s) => s.history);
|
||||
const clearHistory = useStore((s) => s.clearHistory);
|
||||
const setActiveManga = useStore((s) => s.setActiveManga);
|
||||
const setNavPage = useStore((s) => s.setNavPage);
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const filtered = useMemo(() =>
|
||||
search.trim()
|
||||
? history.filter((e) =>
|
||||
e.mangaTitle.toLowerCase().includes(search.toLowerCase()) ||
|
||||
e.chapterName.toLowerCase().includes(search.toLowerCase()))
|
||||
: history,
|
||||
[history, search]
|
||||
);
|
||||
|
||||
const groups = useMemo(() => groupByDay(filtered), [filtered]);
|
||||
|
||||
function resumeReading(entry: HistoryEntry) {
|
||||
// Navigate to manga detail — user can continue from there
|
||||
setActiveManga({
|
||||
id: entry.mangaId,
|
||||
title: entry.mangaTitle,
|
||||
thumbnailUrl: entry.thumbnailUrl,
|
||||
} as any);
|
||||
setNavPage("library");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={s.root}>
|
||||
<div className={s.header}>
|
||||
<h1 className={s.heading}>History</h1>
|
||||
<div className={s.headerRight}>
|
||||
<div className={s.searchWrap}>
|
||||
<MagnifyingGlass size={12} className={s.searchIcon} weight="light" />
|
||||
<input className={s.search} placeholder="Search history…"
|
||||
value={search} onChange={(e) => setSearch(e.target.value)} />
|
||||
</div>
|
||||
{history.length > 0 && (
|
||||
<button className={s.clearBtn} onClick={clearHistory} title="Clear all history">
|
||||
<Trash size={14} weight="light" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{history.length === 0 ? (
|
||||
<div className={s.empty}>
|
||||
<ClockCounterClockwise size={32} weight="light" className={s.emptyIcon} />
|
||||
<p className={s.emptyText}>No reading history yet.</p>
|
||||
<p className={s.emptyHint}>Chapters you read will appear here.</p>
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className={s.empty}>
|
||||
<p className={s.emptyText}>No results for "{search}"</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className={s.list}>
|
||||
{groups.map(({ label, items }) => (
|
||||
<div key={label} className={s.group}>
|
||||
<p className={s.groupLabel}>{label}</p>
|
||||
{items.map((entry) => (
|
||||
<button key={`${entry.chapterId}-${entry.readAt}`}
|
||||
className={s.row} onClick={() => resumeReading(entry)}>
|
||||
<img src={thumbUrl(entry.thumbnailUrl)} alt={entry.mangaTitle}
|
||||
className={s.thumb} />
|
||||
<div className={s.info}>
|
||||
<span className={s.mangaTitle}>{entry.mangaTitle}</span>
|
||||
<span className={s.chapterName}>{entry.chapterName}
|
||||
{entry.pageNumber > 1 && (
|
||||
<span className={s.pageBadge}>p.{entry.pageNumber}</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<span className={s.time}>{timeAgo(entry.readAt)}</span>
|
||||
<Play size={12} weight="fill" className={s.playIcon} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
.root {
|
||||
padding: var(--sp-6);
|
||||
overflow-y: auto;
|
||||
height: 100%;
|
||||
animation: fadeIn 0.14s ease both;
|
||||
/* GPU acceleration for smooth scrolling */
|
||||
will-change: scroll-position;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: var(--sp-5);
|
||||
gap: var(--sp-4);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.headerLeft {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-4);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.heading {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--weight-normal);
|
||||
color: var(--text-faint);
|
||||
letter-spacing: var(--tracking-wider);
|
||||
text-transform: uppercase;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Filter tabs */
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
background: var(--bg-raised);
|
||||
border: 1px solid var(--border-dim);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-2xs);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
text-transform: uppercase;
|
||||
padding: 4px 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--text-faint);
|
||||
cursor: pointer;
|
||||
transition: background var(--t-base), color var(--t-base);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tab:hover { color: var(--text-muted); }
|
||||
|
||||
.tabActive {
|
||||
background: var(--accent-muted);
|
||||
color: var(--accent-fg);
|
||||
border: 1px solid var(--accent-dim);
|
||||
}
|
||||
|
||||
.tabActive:hover { color: var(--accent-fg); }
|
||||
|
||||
.tabCount {
|
||||
font-size: var(--text-2xs);
|
||||
color: inherit;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* Search */
|
||||
.searchWrap {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.searchIcon {
|
||||
position: absolute;
|
||||
left: 10px;
|
||||
color: var(--text-faint);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.search {
|
||||
background: var(--bg-raised);
|
||||
border: 1px solid var(--border-dim);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 5px 10px 5px 28px;
|
||||
color: var(--text-primary);
|
||||
font-size: var(--text-sm);
|
||||
width: 180px;
|
||||
outline: none;
|
||||
transition: border-color var(--t-base);
|
||||
}
|
||||
|
||||
.search::placeholder { color: var(--text-faint); }
|
||||
.search:focus { border-color: var(--border-strong); }
|
||||
|
||||
/* Grid */
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
|
||||
gap: var(--sp-4);
|
||||
/* Contain stacking contexts for GPU layers */
|
||||
contain: layout style;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
/* Promote to own GPU layer on hover only */
|
||||
}
|
||||
|
||||
.card:hover .cover { filter: brightness(1.06); }
|
||||
.card:hover .title { color: var(--text-primary); }
|
||||
|
||||
.coverWrap {
|
||||
position: relative;
|
||||
aspect-ratio: 2 / 3;
|
||||
overflow: hidden;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-raised);
|
||||
border: 1px solid var(--border-dim);
|
||||
/* GPU-accelerated compositing */
|
||||
transform: translateZ(0);
|
||||
}
|
||||
|
||||
.cover {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: filter var(--t-base);
|
||||
/* Hint to compositor */
|
||||
will-change: filter;
|
||||
}
|
||||
|
||||
.downloadedBadge {
|
||||
position: absolute;
|
||||
bottom: var(--sp-1);
|
||||
right: var(--sp-1);
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 10px;
|
||||
font-weight: bold;
|
||||
background: var(--accent-dim);
|
||||
color: var(--accent-fg);
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--accent-muted);
|
||||
}
|
||||
|
||||
.title {
|
||||
margin-top: var(--sp-2);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
line-height: var(--leading-snug);
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
transition: color var(--t-base);
|
||||
}
|
||||
|
||||
/* Show more */
|
||||
.showMore {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: var(--sp-6) 0 var(--sp-4);
|
||||
}
|
||||
|
||||
.showMoreBtn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-xs);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
color: var(--text-muted);
|
||||
border: 1px solid var(--border-base);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 7px 20px;
|
||||
background: var(--bg-raised);
|
||||
cursor: pointer;
|
||||
transition: color var(--t-base), border-color var(--t-base), background var(--t-base);
|
||||
}
|
||||
|
||||
.showMoreBtn:hover {
|
||||
color: var(--text-primary);
|
||||
border-color: var(--border-strong);
|
||||
background: var(--bg-overlay);
|
||||
}
|
||||
|
||||
.showMoreCount {
|
||||
color: var(--text-faint);
|
||||
font-size: var(--text-2xs);
|
||||
}
|
||||
|
||||
/* Skeleton */
|
||||
.cardSkeleton { padding: 0; }
|
||||
|
||||
.coverSkeletonWrap {
|
||||
aspect-ratio: 2 / 3;
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.titleSkeleton {
|
||||
height: 12px;
|
||||
margin-top: var(--sp-2);
|
||||
width: 80%;
|
||||
}
|
||||
|
||||
.center {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 60%;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--text-sm);
|
||||
gap: var(--sp-2);
|
||||
text-align: center;
|
||||
line-height: var(--leading-base);
|
||||
}
|
||||
|
||||
.errorMsg { color: var(--color-error); font-size: var(--text-base); }
|
||||
.errorDetail { color: var(--text-faint); font-size: var(--text-sm); }
|
||||
/* ── Tag filter ── */
|
||||
.tagPanel {
|
||||
display: flex; flex-wrap: wrap; gap: var(--sp-1);
|
||||
padding: 0 var(--sp-6) var(--sp-3);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tagChip {
|
||||
font-family: var(--font-ui); font-size: var(--text-2xs);
|
||||
letter-spacing: var(--tracking-wide); padding: 3px 8px;
|
||||
border-radius: var(--radius-sm); border: 1px solid var(--border-dim);
|
||||
background: none; color: var(--text-faint); cursor: pointer;
|
||||
transition: color var(--t-base), border-color var(--t-base), background var(--t-base);
|
||||
}
|
||||
.tagChip:hover { color: var(--text-muted); border-color: var(--border-strong); }
|
||||
.tagChipActive {
|
||||
background: var(--accent-muted); border-color: var(--accent-dim);
|
||||
color: var(--accent-fg);
|
||||
}
|
||||
.tagChipActive:hover { background: var(--accent-muted); color: var(--accent-fg); }
|
||||
|
||||
.tagClear {
|
||||
display: flex; align-items: center; gap: 4px;
|
||||
font-family: var(--font-ui); font-size: var(--text-2xs);
|
||||
letter-spacing: var(--tracking-wide); padding: 3px 8px;
|
||||
border-radius: var(--radius-sm); border: 1px solid var(--color-error);
|
||||
background: none; color: var(--color-error); cursor: pointer;
|
||||
transition: background var(--t-base);
|
||||
}
|
||||
.tagClear:hover { background: var(--color-error-bg); }
|
||||
@@ -0,0 +1,230 @@
|
||||
import { useEffect, useState, useMemo, useCallback, memo } from "react";
|
||||
import { MagnifyingGlass, Books, DownloadSimple, X } from "@phosphor-icons/react";
|
||||
import { gql, thumbUrl } from "../../lib/client";
|
||||
import { GET_LIBRARY, GET_ALL_MANGA } from "../../lib/queries";
|
||||
import { useStore } from "../../store";
|
||||
import type { LibraryFilter } from "../../store";
|
||||
import type { Manga } from "../../lib/types";
|
||||
import s from "./Library.module.css";
|
||||
|
||||
const INITIAL_PAGE_SIZE = 48;
|
||||
const PAGE_INCREMENT = 48;
|
||||
|
||||
// Memoized card to prevent re-renders when siblings change
|
||||
const MangaCard = memo(function MangaCard({
|
||||
manga,
|
||||
onClick,
|
||||
cropCovers,
|
||||
}: {
|
||||
manga: Manga;
|
||||
onClick: () => void;
|
||||
cropCovers: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button className={s.card} onClick={onClick}>
|
||||
<div className={s.coverWrap}>
|
||||
<img
|
||||
src={thumbUrl(manga.thumbnailUrl)}
|
||||
alt={manga.title}
|
||||
className={s.cover}
|
||||
style={{ objectFit: cropCovers ? "cover" : "contain" }}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
{!!manga.downloadCount && (
|
||||
<span className={s.downloadedBadge}>{manga.downloadCount}</span>
|
||||
)}
|
||||
</div>
|
||||
<p className={s.title}>{manga.title}</p>
|
||||
</button>
|
||||
);
|
||||
});
|
||||
|
||||
export default function Library() {
|
||||
const [allManga, setAllManga] = useState<Manga[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [search, setSearch] = useState("");
|
||||
const [visibleCount, setVisibleCount] = useState(INITIAL_PAGE_SIZE);
|
||||
|
||||
const setActiveManga = useStore((state) => state.setActiveManga);
|
||||
const libraryFilter = useStore((state) => state.libraryFilter);
|
||||
const setLibraryFilter = useStore((state) => state.setLibraryFilter);
|
||||
const settings = useStore((state) => state.settings);
|
||||
const libraryTagFilter = useStore((state) => state.libraryTagFilter);
|
||||
const setLibraryTagFilter = useStore((state) => state.setLibraryTagFilter);
|
||||
|
||||
useEffect(() => {
|
||||
// Fetch all manga (for downloaded filter on non-library entries) and
|
||||
// library manga (for unreadCount/chapter progress). Merge: library wins.
|
||||
Promise.all([
|
||||
gql<{ mangas: { nodes: Manga[] } }>(GET_ALL_MANGA),
|
||||
gql<{ mangas: { nodes: Manga[] } }>(GET_LIBRARY),
|
||||
])
|
||||
.then(([all, lib]) => {
|
||||
const libMap = new Map(lib.mangas.nodes.map((m) => [m.id, m]));
|
||||
setAllManga(all.mangas.nodes.map((m) => libMap.get(m.id) ?? m));
|
||||
})
|
||||
.catch((e) => setError(e.message))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
// Reset visible count when filter/search changes
|
||||
useEffect(() => { setVisibleCount(INITIAL_PAGE_SIZE); }, [libraryFilter, search]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
let items = allManga;
|
||||
|
||||
// Apply filter tab
|
||||
if (libraryFilter === "library") {
|
||||
items = items.filter((m) => m.inLibrary);
|
||||
} else if (libraryFilter === "downloaded") {
|
||||
items = items.filter((m) => (m.downloadCount ?? 0) > 0);
|
||||
}
|
||||
|
||||
// Apply search
|
||||
if (search.trim()) {
|
||||
const q = search.toLowerCase();
|
||||
items = items.filter((m) => m.title.toLowerCase().includes(q));
|
||||
}
|
||||
|
||||
return items;
|
||||
}, [allManga, libraryFilter, search]);
|
||||
|
||||
const visible = filtered.slice(0, visibleCount);
|
||||
const hasMore = visibleCount < filtered.length;
|
||||
|
||||
const handleCardClick = useCallback(
|
||||
(m: Manga) => () => setActiveManga(m),
|
||||
[setActiveManga]
|
||||
);
|
||||
|
||||
// All genres present in current library
|
||||
const allTags = useMemo(() => {
|
||||
const tagSet = new Set<string>();
|
||||
allManga.filter((m) => m.inLibrary).forEach((m) => (m.genre ?? []).forEach((g) => tagSet.add(g)));
|
||||
return Array.from(tagSet).sort();
|
||||
}, [allManga]);
|
||||
|
||||
const counts = useMemo(() => ({
|
||||
all: allManga.length,
|
||||
library: allManga.filter((m) => m.inLibrary).length,
|
||||
downloaded: allManga.filter((m) => (m.downloadCount ?? 0) > 0).length,
|
||||
}), [allManga]);
|
||||
|
||||
if (error) return (
|
||||
<div className={s.center}>
|
||||
<p className={s.errorMsg}>Could not reach Suwayomi</p>
|
||||
<p className={s.errorDetail}>{error}</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={s.root}>
|
||||
<div className={s.header}>
|
||||
<div className={s.headerLeft}>
|
||||
<h1 className={s.heading}>Library</h1>
|
||||
<div className={s.tabs}>
|
||||
{(["library", "downloaded", "all"] as LibraryFilter[]).map((f) => (
|
||||
<button
|
||||
key={f}
|
||||
className={[s.tab, libraryFilter === f ? s.tabActive : ""].join(" ").trim()}
|
||||
onClick={() => setLibraryFilter(f)}
|
||||
>
|
||||
{f === "library" ? (
|
||||
<><Books size={11} weight="bold" /> Saved</>
|
||||
) : f === "downloaded" ? (
|
||||
<><DownloadSimple size={11} weight="bold" /> Downloaded</>
|
||||
) : (
|
||||
<>All</>
|
||||
)}
|
||||
<span className={s.tabCount}>{counts[f]}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className={s.searchWrap}>
|
||||
<MagnifyingGlass size={13} className={s.searchIcon} weight="light" />
|
||||
<input
|
||||
className={s.search}
|
||||
placeholder="Search"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Tag filter panel */}
|
||||
{allTags.length > 0 && (
|
||||
<div className={s.tagPanel}>
|
||||
{libraryTagFilter.length > 0 && (
|
||||
<button className={s.tagClear} onClick={() => setLibraryTagFilter([])}>
|
||||
<X size={11} weight="bold" />
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
{allTags.map((tag) => {
|
||||
const active = libraryTagFilter.includes(tag);
|
||||
return (
|
||||
<button key={tag}
|
||||
className={[s.tagChip, active ? s.tagChipActive : ""].join(" ")}
|
||||
onClick={() =>
|
||||
setLibraryTagFilter(
|
||||
active
|
||||
? libraryTagFilter.filter((t) => t !== tag)
|
||||
: [...libraryTagFilter, tag]
|
||||
)
|
||||
}>
|
||||
{tag}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className={s.grid}>
|
||||
{Array.from({ length: 12 }).map((_, i) => (
|
||||
<div key={i} className={s.cardSkeleton}>
|
||||
<div className={[s.coverSkeletonWrap, "skeleton"].join(" ")} />
|
||||
<div className={[s.titleSkeleton, "skeleton"].join(" ")} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className={s.center}>
|
||||
{libraryFilter === "library"
|
||||
? "No manga saved to library. Browse sources to add some."
|
||||
: libraryFilter === "downloaded"
|
||||
? "No downloaded manga."
|
||||
: "No manga found."}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className={s.grid}>
|
||||
{visible.map((m) => (
|
||||
<MangaCard
|
||||
key={m.id}
|
||||
manga={m}
|
||||
onClick={handleCardClick(m)}
|
||||
cropCovers={settings.libraryCropCovers}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{hasMore && (
|
||||
<div className={s.showMore}>
|
||||
<button
|
||||
className={s.showMoreBtn}
|
||||
onClick={() => setVisibleCount((c) => c + PAGE_INCREMENT)}
|
||||
>
|
||||
Show more
|
||||
<span className={s.showMoreCount}>{filtered.length - visibleCount} remaining</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
.root {
|
||||
position: fixed; inset: 0;
|
||||
background: #000;
|
||||
display: flex; flex-direction: column;
|
||||
z-index: var(--z-reader);
|
||||
transform: translateZ(0); will-change: transform;
|
||||
}
|
||||
|
||||
/* ── Topbar ── */
|
||||
.topbar {
|
||||
display: flex; align-items: center; gap: var(--sp-1);
|
||||
padding: 0 var(--sp-3); height: 40px;
|
||||
background: var(--bg-void); border-bottom: 1px solid var(--border-dim);
|
||||
flex-shrink: 0; overflow: hidden;
|
||||
}
|
||||
|
||||
.iconBtn {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
width: 28px; height: 28px; border-radius: var(--radius-sm);
|
||||
color: var(--text-muted); flex-shrink: 0;
|
||||
transition: color var(--t-base), background var(--t-base);
|
||||
}
|
||||
.iconBtn:hover:not(:disabled) { color: var(--text-primary); background: var(--bg-raised); }
|
||||
.iconBtn:disabled { opacity: 0.2; cursor: default; }
|
||||
|
||||
.chLabel {
|
||||
flex: 1; display: flex; align-items: center; gap: var(--sp-2);
|
||||
font-size: var(--text-sm); color: var(--text-muted);
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.chTitle { color: var(--text-secondary); font-weight: var(--weight-medium); }
|
||||
.chSep { color: var(--text-faint); }
|
||||
|
||||
.pageLabel {
|
||||
font-family: var(--font-ui); font-size: var(--text-xs);
|
||||
color: var(--text-muted); letter-spacing: var(--tracking-wide);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.topSep {
|
||||
width: 1px; height: 16px;
|
||||
background: var(--border-dim); flex-shrink: 0; margin: 0 var(--sp-1);
|
||||
}
|
||||
|
||||
.modeBtn {
|
||||
display: flex; align-items: center; gap: 4px;
|
||||
padding: 4px var(--sp-2); border-radius: var(--radius-sm);
|
||||
color: var(--text-muted); flex-shrink: 0;
|
||||
font-family: var(--font-ui); font-size: var(--text-2xs);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
transition: color var(--t-base), background var(--t-base);
|
||||
}
|
||||
.modeBtn:hover { color: var(--text-primary); background: var(--bg-raised); }
|
||||
.modeBtnActive { color: var(--accent-fg); background: var(--accent-muted); }
|
||||
.modeBtnActive:hover { color: var(--accent-fg); background: var(--accent-muted); }
|
||||
.modeBtnLabel { text-transform: capitalize; }
|
||||
|
||||
.zoomBtn {
|
||||
font-family: var(--font-ui); font-size: var(--text-2xs);
|
||||
letter-spacing: var(--tracking-wide); color: var(--text-faint);
|
||||
padding: 4px var(--sp-2); border-radius: var(--radius-sm);
|
||||
flex-shrink: 0; min-width: 36px; text-align: center;
|
||||
transition: color var(--t-base), background var(--t-base);
|
||||
}
|
||||
.zoomBtn:hover { color: var(--text-secondary); background: var(--bg-raised); }
|
||||
|
||||
/* ── Viewer ── */
|
||||
.viewer {
|
||||
flex: 1; overflow-y: auto; overflow-x: hidden;
|
||||
display: flex; flex-direction: column;
|
||||
align-items: center; justify-content: center;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.viewerStrip {
|
||||
justify-content: flex-start;
|
||||
padding: var(--sp-4) 0;
|
||||
}
|
||||
|
||||
/* ── Images ── */
|
||||
.img {
|
||||
display: block; user-select: none;
|
||||
image-rendering: auto;
|
||||
}
|
||||
.img.optimizeContrast { image-rendering: -webkit-optimize-contrast; }
|
||||
|
||||
/* Fit modes */
|
||||
.fitWidth { max-width: var(--max-page-width); width: 100%; height: auto; }
|
||||
.fitHeight { max-height: calc(100vh - 80px); width: auto; max-width: 100%; }
|
||||
.fitScreen { max-width: 100%; max-height: calc(100vh - 80px); object-fit: contain; }
|
||||
.fitOriginal { max-width: none; width: auto; height: auto; }
|
||||
|
||||
/* Longstrip */
|
||||
.stripGap { margin-bottom: 8px; }
|
||||
|
||||
/* ── Double page ── */
|
||||
.doubleWrap {
|
||||
display: flex; align-items: flex-start; justify-content: center;
|
||||
max-width: calc(var(--max-page-width) * 2);
|
||||
width: 100%;
|
||||
}
|
||||
.pageHalf { flex: 1; min-width: 0; object-fit: contain; }
|
||||
.gapLeft { margin-right: 2px; }
|
||||
.gapRight { margin-left: 2px; }
|
||||
|
||||
/* ── Bottom nav ── */
|
||||
.bottombar {
|
||||
display: flex; align-items: center; justify-content: center; gap: var(--sp-4);
|
||||
padding: var(--sp-3); border-top: 1px solid var(--border-dim);
|
||||
background: var(--bg-void); flex-shrink: 0;
|
||||
}
|
||||
|
||||
.navBtn {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
width: 34px; height: 34px; border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border-strong); color: var(--text-muted);
|
||||
transition: background var(--t-base), color var(--t-base);
|
||||
}
|
||||
.navBtn:hover:not(:disabled) { background: var(--bg-raised); color: var(--text-primary); }
|
||||
.navBtn:disabled { opacity: 0.25; cursor: default; }
|
||||
|
||||
/* ── States ── */
|
||||
.center {
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
position: fixed; inset: 0; background: #000;
|
||||
}
|
||||
.errorMsg { color: var(--color-error); font-size: var(--text-base); }
|
||||
|
||||
/* ── Download modal ── */
|
||||
.dlBackdrop {
|
||||
position: fixed; inset: 0;
|
||||
z-index: calc(var(--z-reader) + 10);
|
||||
display: flex; align-items: flex-start; justify-content: flex-end;
|
||||
padding: 48px var(--sp-4) 0;
|
||||
}
|
||||
|
||||
.dlModal {
|
||||
background: var(--bg-raised); border: 1px solid var(--border-base);
|
||||
border-radius: var(--radius-xl); padding: var(--sp-3);
|
||||
min-width: 210px; display: flex; flex-direction: column; gap: var(--sp-1);
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.6);
|
||||
animation: scaleIn 0.12s ease both; transform-origin: top right;
|
||||
}
|
||||
|
||||
.dlTitle {
|
||||
font-family: var(--font-ui); font-size: var(--text-2xs);
|
||||
color: var(--text-faint); letter-spacing: var(--tracking-wider);
|
||||
text-transform: uppercase; padding: 2px var(--sp-2) var(--sp-2);
|
||||
border-bottom: 1px solid var(--border-dim); margin-bottom: var(--sp-1);
|
||||
}
|
||||
|
||||
.dlOption {
|
||||
display: flex; flex-direction: column; align-items: flex-start; gap: 2px;
|
||||
width: 100%; padding: 7px var(--sp-3); border-radius: var(--radius-md);
|
||||
font-size: var(--text-sm); color: var(--text-secondary);
|
||||
background: none; border: none; cursor: pointer; text-align: left;
|
||||
transition: background var(--t-fast), color var(--t-fast);
|
||||
}
|
||||
.dlOption:hover:not(:disabled) { background: var(--bg-overlay); color: var(--text-primary); }
|
||||
.dlOption:disabled { opacity: 0.3; cursor: default; }
|
||||
|
||||
.dlSub { font-size: var(--text-xs); color: var(--text-faint); }
|
||||
|
||||
.dlRow { display: flex; align-items: center; gap: var(--sp-2); }
|
||||
|
||||
.dlInput {
|
||||
width: 48px; padding: 4px var(--sp-2);
|
||||
background: var(--bg-overlay); border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-sm); color: var(--text-secondary);
|
||||
font-family: var(--font-ui); font-size: var(--text-xs);
|
||||
text-align: center; outline: none;
|
||||
}
|
||||
.dlInput:focus { border-color: var(--border-focus); }
|
||||
@@ -0,0 +1,500 @@
|
||||
import { useEffect, useRef, useCallback, useState, useMemo } from "react";
|
||||
import {
|
||||
X, CaretLeft, CaretRight, ArrowLeft, ArrowRight,
|
||||
Square, Columns, Rows, Download, ArrowsLeftRight,
|
||||
ArrowsIn, ArrowsOut, ArrowsVertical, CircleNotch,
|
||||
} from "@phosphor-icons/react";
|
||||
import { gql, thumbUrl } from "../../lib/client";
|
||||
import {
|
||||
FETCH_CHAPTER_PAGES, MARK_CHAPTER_READ,
|
||||
ENQUEUE_DOWNLOAD, ENQUEUE_CHAPTERS_DOWNLOAD,
|
||||
} from "../../lib/queries";
|
||||
import { useStore, type FitMode } from "../../store";
|
||||
import { matchesKeybind } from "../../lib/keybinds";
|
||||
import s from "./Reader.module.css";
|
||||
|
||||
function preloadImage(url: string) {
|
||||
const img = new Image(); img.src = url;
|
||||
}
|
||||
|
||||
// Returns aspect ratio once image loads; wide (>1.2 w:h) = likely double spread
|
||||
function measureAspect(url: string): Promise<number> {
|
||||
return new Promise((res) => {
|
||||
const img = new Image();
|
||||
img.onload = () => res(img.naturalWidth / img.naturalHeight);
|
||||
img.onerror = () => res(0.67);
|
||||
img.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
// ── Download modal ────────────────────────────────────────────────────────────
|
||||
function DownloadModal({
|
||||
chapter,
|
||||
remaining,
|
||||
onClose,
|
||||
}: {
|
||||
chapter: { id: number; name: string };
|
||||
remaining: { id: number }[];
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [nextN, setNextN] = useState(5);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const run = async (fn: () => Promise<unknown>) => {
|
||||
setBusy(true);
|
||||
await fn().catch(console.error);
|
||||
setBusy(false);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={s.dlBackdrop} onClick={onClose}>
|
||||
<div className={s.dlModal} onClick={(e) => e.stopPropagation()}>
|
||||
<p className={s.dlTitle}>Download</p>
|
||||
<button className={s.dlOption} disabled={busy}
|
||||
onClick={() => run(() => gql(ENQUEUE_DOWNLOAD, { chapterId: chapter.id }))}>
|
||||
This chapter
|
||||
<span className={s.dlSub}>{chapter.name}</span>
|
||||
</button>
|
||||
<div className={s.dlRow}>
|
||||
<button className={s.dlOption} disabled={busy || !remaining.length}
|
||||
onClick={() => run(() => gql(ENQUEUE_CHAPTERS_DOWNLOAD, {
|
||||
chapterIds: remaining.slice(0, nextN).map((c) => c.id),
|
||||
}))}>
|
||||
Next chapters
|
||||
<span className={s.dlSub}>{Math.min(nextN, remaining.length)} queued</span>
|
||||
</button>
|
||||
<input type="number" className={s.dlInput} min={1}
|
||||
max={remaining.length || 1} value={nextN}
|
||||
onChange={(e) => setNextN(Math.max(1, Number(e.target.value)))}
|
||||
onClick={(e) => e.stopPropagation()} />
|
||||
</div>
|
||||
<button className={s.dlOption} disabled={busy || !remaining.length}
|
||||
onClick={() => run(() => gql(ENQUEUE_CHAPTERS_DOWNLOAD, {
|
||||
chapterIds: remaining.map((c) => c.id),
|
||||
}))}>
|
||||
All remaining
|
||||
<span className={s.dlSub}>{remaining.length} chapters</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Reader ────────────────────────────────────────────────────────────────────
|
||||
export default function Reader() {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const rafRef = useRef(0);
|
||||
const pageNumRef = useRef(1);
|
||||
const pageCache = useRef<Map<number, string[]>>(new Map());
|
||||
const aspectCache = useRef<Map<string, number>>(new Map());
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [dlOpen, setDlOpen] = useState(false);
|
||||
const [markedRead, setMarkedRead] = useState<Set<number>>(new Set());
|
||||
const [pageGroups, setPageGroups] = useState<number[][]>([]);
|
||||
|
||||
const {
|
||||
activeManga, activeChapter, activeChapterList,
|
||||
pageUrls, pageNumber, settings,
|
||||
setPageUrls, setPageNumber, closeReader, openReader, openSettings,
|
||||
updateSettings, addHistory,
|
||||
} = useStore();
|
||||
|
||||
const kb = settings.keybinds;
|
||||
const rtl = settings.readingDirection === "rtl";
|
||||
const fit = settings.fitMode ?? "width";
|
||||
const style = settings.pageStyle ?? "single";
|
||||
const maxW = settings.maxPageWidth ?? 900;
|
||||
|
||||
useEffect(() => { pageNumRef.current = pageNumber; }, [pageNumber]);
|
||||
|
||||
// ── Load pages ──────────────────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (!activeChapter) return;
|
||||
setLoading(true); setError(null); setPageGroups([]);
|
||||
const cached = pageCache.current.get(activeChapter.id);
|
||||
if (cached) { setPageUrls(cached); setLoading(false); return; }
|
||||
gql<{ fetchChapterPages: { pages: string[] } }>(FETCH_CHAPTER_PAGES, { chapterId: activeChapter.id })
|
||||
.then((d) => {
|
||||
const urls = d.fetchChapterPages.pages.map(thumbUrl);
|
||||
pageCache.current.set(activeChapter.id, urls);
|
||||
setPageUrls(urls);
|
||||
})
|
||||
.catch((e) => setError(e.message))
|
||||
.finally(() => setLoading(false));
|
||||
}, [activeChapter?.id]);
|
||||
|
||||
// ── Double-page grouping ─────────────────────────────────────────────────────
|
||||
// Rule: page 1 (cover) always solo. Wide pages (aspect>1.2) always solo.
|
||||
// Normal portrait pages pair with next portrait page.
|
||||
useEffect(() => {
|
||||
if (style !== "double" || !pageUrls.length) { setPageGroups([]); return; }
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
const aspects: number[] = [];
|
||||
for (const url of pageUrls) {
|
||||
if (aspectCache.current.has(url)) {
|
||||
aspects.push(aspectCache.current.get(url)!);
|
||||
} else {
|
||||
const a = await measureAspect(url);
|
||||
aspectCache.current.set(url, a);
|
||||
aspects.push(a);
|
||||
}
|
||||
}
|
||||
if (cancelled) return;
|
||||
const groups: number[][] = [];
|
||||
// Page 1 always solo (cover)
|
||||
groups.push([1]);
|
||||
let i = 2;
|
||||
while (i <= pageUrls.length) {
|
||||
const a = aspects[i - 1];
|
||||
if (a > 1.2 || i === pageUrls.length) {
|
||||
// Wide or last page — solo
|
||||
groups.push([i]); i++;
|
||||
} else {
|
||||
const next = aspects[i]; // aspects[i] = page i+1 (0-indexed)
|
||||
if (next !== undefined && next <= 1.2) {
|
||||
groups.push([i, i + 1]); i += 2;
|
||||
} else {
|
||||
groups.push([i]); i++;
|
||||
}
|
||||
}
|
||||
}
|
||||
setPageGroups(groups);
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [pageUrls, style, settings.offsetDoubleSpreads]);
|
||||
|
||||
const currentGroup = useMemo(() => {
|
||||
if (style !== "double" || !pageGroups.length) return null;
|
||||
return pageGroups.find((g) => g.includes(pageNumber)) ?? null;
|
||||
}, [pageGroups, pageNumber, style]);
|
||||
|
||||
// ── Preload ─────────────────────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
for (let i = 1; i <= (settings.preloadPages ?? 3); i++) {
|
||||
const url = pageUrls[pageNumber - 1 + i];
|
||||
if (url) preloadImage(url);
|
||||
}
|
||||
}, [pageNumber, pageUrls, settings.preloadPages]);
|
||||
|
||||
// ── Adjacent chapters ────────────────────────────────────────────────────────
|
||||
const adjacent = useMemo(() => {
|
||||
if (!activeChapter || !activeChapterList.length)
|
||||
return { prev: null, next: null, remaining: [] };
|
||||
const idx = activeChapterList.findIndex((c) => c.id === activeChapter.id);
|
||||
return {
|
||||
prev: idx > 0 ? activeChapterList[idx - 1] : null,
|
||||
next: idx < activeChapterList.length - 1 ? activeChapterList[idx + 1] : null,
|
||||
remaining: activeChapterList.slice(idx + 1),
|
||||
};
|
||||
}, [activeChapter, activeChapterList]);
|
||||
|
||||
useEffect(() => {
|
||||
const preload = (id: number) => {
|
||||
if (pageCache.current.has(id)) return;
|
||||
gql<{ fetchChapterPages: { pages: string[] } }>(FETCH_CHAPTER_PAGES, { chapterId: id })
|
||||
.then((d) => {
|
||||
const urls = d.fetchChapterPages.pages.map(thumbUrl);
|
||||
pageCache.current.set(id, urls);
|
||||
urls.slice(0, 2).forEach(preloadImage);
|
||||
}).catch(() => {});
|
||||
};
|
||||
if (adjacent.next) preload(adjacent.next.id);
|
||||
if (adjacent.prev) preload(adjacent.prev.id);
|
||||
}, [adjacent.next?.id, adjacent.prev?.id]);
|
||||
|
||||
const lastPage = pageUrls.length;
|
||||
|
||||
// ── Auto-mark read + history ─────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (!activeChapter || !lastPage) return;
|
||||
if (activeManga) {
|
||||
addHistory({
|
||||
mangaId: activeManga.id, mangaTitle: activeManga.title,
|
||||
thumbnailUrl: activeManga.thumbnailUrl, chapterId: activeChapter.id,
|
||||
chapterName: activeChapter.name, pageNumber, readAt: Date.now(),
|
||||
});
|
||||
}
|
||||
if (settings.autoMarkRead && pageNumber === lastPage && !markedRead.has(activeChapter.id)) {
|
||||
setMarkedRead((p) => new Set(p).add(activeChapter.id));
|
||||
gql(MARK_CHAPTER_READ, { id: activeChapter.id, isRead: true }).catch(console.error);
|
||||
}
|
||||
}, [pageNumber, lastPage, activeChapter?.id]);
|
||||
|
||||
// ── Navigation ──────────────────────────────────────────────────────────────
|
||||
const advanceGroup = useCallback((forward: boolean) => {
|
||||
if (!pageGroups.length) return;
|
||||
const gi = pageGroups.findIndex((g) => g.includes(pageNumber));
|
||||
if (forward) {
|
||||
if (gi < pageGroups.length - 1) setPageNumber(pageGroups[gi + 1][0]);
|
||||
else if (adjacent.next) openReader(adjacent.next, activeChapterList);
|
||||
else closeReader();
|
||||
} else {
|
||||
if (gi > 0) setPageNumber(pageGroups[gi - 1][0]);
|
||||
else if (adjacent.prev) openReader(adjacent.prev, activeChapterList);
|
||||
}
|
||||
}, [pageGroups, pageNumber, adjacent, activeChapterList]);
|
||||
|
||||
const goForward = useCallback(() => {
|
||||
if (style === "double" && pageGroups.length) { advanceGroup(true); return; }
|
||||
if (pageNumber < lastPage) setPageNumber(pageNumber + 1);
|
||||
else if (adjacent.next) openReader(adjacent.next, activeChapterList);
|
||||
else closeReader();
|
||||
}, [pageNumber, lastPage, adjacent, activeChapterList, style, pageGroups, advanceGroup]);
|
||||
|
||||
const goBack = useCallback(() => {
|
||||
if (style === "double" && pageGroups.length) { advanceGroup(false); return; }
|
||||
if (pageNumber > 1) setPageNumber(pageNumber - 1);
|
||||
else if (adjacent.prev) openReader(adjacent.prev, activeChapterList);
|
||||
}, [pageNumber, adjacent, activeChapterList, style, pageGroups, advanceGroup]);
|
||||
|
||||
const goNext = rtl ? goBack : goForward;
|
||||
const goPrev = rtl ? goForward : goBack;
|
||||
|
||||
function cycleStyle() {
|
||||
const cycle = ["single", "double", "longstrip"] as const;
|
||||
const next = cycle[(cycle.indexOf(style as any) + 1) % cycle.length];
|
||||
updateSettings({ pageStyle: next });
|
||||
}
|
||||
|
||||
function cycleFit() {
|
||||
const cycle: FitMode[] = ["width", "height", "screen", "original"];
|
||||
updateSettings({ fitMode: cycle[(cycle.indexOf(fit) + 1) % cycle.length] });
|
||||
}
|
||||
|
||||
// Ctrl+scroll → zoom maxPageWidth
|
||||
useEffect(() => {
|
||||
const onWheel = (e: WheelEvent) => {
|
||||
if (!e.ctrlKey) return;
|
||||
e.preventDefault();
|
||||
const delta = e.deltaY < 0 ? 50 : -50;
|
||||
updateSettings({ maxPageWidth: Math.min(2400, Math.max(200, maxW + delta)) });
|
||||
};
|
||||
window.addEventListener("wheel", onWheel, { passive: false });
|
||||
return () => window.removeEventListener("wheel", onWheel);
|
||||
}, [maxW]);
|
||||
|
||||
// Keybinds
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if ((e.target as HTMLElement).tagName === "INPUT") return;
|
||||
if (matchesKeybind(e, kb.pageRight)) { e.preventDefault(); goForward(); }
|
||||
else if (matchesKeybind(e, kb.pageLeft)) { e.preventDefault(); goBack(); }
|
||||
else if (matchesKeybind(e, kb.firstPage)) { e.preventDefault(); setPageNumber(1); }
|
||||
else if (matchesKeybind(e, kb.lastPage)) { e.preventDefault(); setPageNumber(lastPage); }
|
||||
else if (matchesKeybind(e, kb.chapterRight)) { e.preventDefault(); if (adjacent.next) openReader(adjacent.next, activeChapterList); }
|
||||
else if (matchesKeybind(e, kb.chapterLeft)) { e.preventDefault(); if (adjacent.prev) openReader(adjacent.prev, activeChapterList); }
|
||||
else if (matchesKeybind(e, kb.exitReader)) { e.preventDefault(); closeReader(); }
|
||||
else if (matchesKeybind(e, kb.togglePageStyle)) { e.preventDefault(); cycleStyle(); }
|
||||
else if (matchesKeybind(e, kb.toggleReadingDirection)){ e.preventDefault(); updateSettings({ readingDirection: rtl ? "ltr" : "rtl" }); }
|
||||
else if (matchesKeybind(e, kb.openSettings)) { e.preventDefault(); openSettings(); }
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [goForward, goBack, kb, style, rtl, lastPage, adjacent, activeChapterList]);
|
||||
|
||||
// Longstrip scroll — rAF throttled, no flushSync
|
||||
useEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el || style !== "longstrip") return;
|
||||
const onScroll = () => {
|
||||
cancelAnimationFrame(rafRef.current);
|
||||
rafRef.current = requestAnimationFrame(() => {
|
||||
if (!el) return;
|
||||
const midY = el.scrollTop + el.clientHeight * 0.5;
|
||||
let cumH = 0;
|
||||
const children = Array.from(el.children) as HTMLElement[];
|
||||
for (let i = 0; i < children.length; i++) {
|
||||
cumH += children[i].clientHeight;
|
||||
if (cumH >= midY) {
|
||||
const n = i + 1;
|
||||
if (n !== pageNumRef.current) setPageNumber(n);
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
el.addEventListener("scroll", onScroll, { passive: true });
|
||||
return () => { el.removeEventListener("scroll", onScroll); cancelAnimationFrame(rafRef.current); };
|
||||
}, [style]);
|
||||
|
||||
useEffect(() => {
|
||||
if (style !== "longstrip" && containerRef.current) containerRef.current.scrollTop = 0;
|
||||
}, [pageNumber, style]);
|
||||
|
||||
function handleTap(e: React.MouseEvent) {
|
||||
if (style === "longstrip") return;
|
||||
const x = e.clientX / window.innerWidth;
|
||||
if (!rtl) { if (x > 0.6) goForward(); else if (x < 0.4) goBack(); }
|
||||
else { if (x < 0.4) goForward(); else if (x > 0.6) goBack(); }
|
||||
}
|
||||
|
||||
// ── CSS vars ─────────────────────────────────────────────────────────────────
|
||||
const cssVars = { "--max-page-width": `${maxW}px` } as React.CSSProperties;
|
||||
|
||||
const imgCls = [
|
||||
s.img,
|
||||
fit === "width" && s.fitWidth,
|
||||
fit === "height" && s.fitHeight,
|
||||
fit === "screen" && s.fitScreen,
|
||||
fit === "original" && s.fitOriginal,
|
||||
settings.optimizeContrast && s.optimizeContrast,
|
||||
].filter(Boolean).join(" ");
|
||||
|
||||
// ── Double page render ────────────────────────────────────────────────────────
|
||||
function renderDouble() {
|
||||
if (!currentGroup) {
|
||||
return <img src={pageUrls[pageNumber - 1]} alt={`Page ${pageNumber}`} className={imgCls} decoding="async" />;
|
||||
}
|
||||
const ordered = rtl ? [...currentGroup].reverse() : currentGroup;
|
||||
const [left, right] = ordered;
|
||||
return (
|
||||
<div className={s.doubleWrap}>
|
||||
<img src={pageUrls[left - 1]} alt={`Page ${left}`}
|
||||
className={[imgCls, s.pageHalf, settings.pageGap ? s.gapLeft : ""].join(" ")} decoding="async" />
|
||||
{right && (
|
||||
<img src={pageUrls[right - 1]} alt={`Page ${right}`}
|
||||
className={[imgCls, s.pageHalf, settings.pageGap ? s.gapRight : ""].join(" ")} decoding="async" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Icons ────────────────────────────────────────────────────────────────────
|
||||
const fitIcon =
|
||||
fit === "width" ? <ArrowsLeftRight size={14} weight="light" /> :
|
||||
fit === "height" ? <ArrowsVertical size={14} weight="light" /> :
|
||||
fit === "screen" ? <ArrowsIn size={14} weight="light" /> :
|
||||
<ArrowsOut size={14} weight="light" />;
|
||||
|
||||
const fitLabel = { width: "Fit W", height: "Fit H", screen: "Fit Screen", original: "1:1" }[fit];
|
||||
|
||||
const styleIcon =
|
||||
style === "single" ? <Square size={14} weight="light" /> :
|
||||
style === "double" ? <Columns size={14} weight="light" /> :
|
||||
<Rows size={14} weight="light" />;
|
||||
|
||||
if (loading) return (
|
||||
<div className={s.center}>
|
||||
<CircleNotch size={20} weight="light" className="anim-spin" style={{ color: "var(--text-faint)" }} />
|
||||
</div>
|
||||
);
|
||||
|
||||
if (error) return (
|
||||
<div className={s.center}><p className={s.errorMsg}>{error}</p></div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={s.root}>
|
||||
{/* ── Topbar ── */}
|
||||
<div className={s.topbar}>
|
||||
<button className={s.iconBtn} onClick={closeReader} title="Close reader">
|
||||
<X size={15} weight="light" />
|
||||
</button>
|
||||
<button className={s.iconBtn} onClick={() => adjacent.prev && openReader(adjacent.prev, activeChapterList)}
|
||||
disabled={!adjacent.prev} title="Previous chapter">
|
||||
<CaretLeft size={14} weight="light" />
|
||||
</button>
|
||||
<span className={s.chLabel}>
|
||||
<span className={s.chTitle}>{activeManga?.title}</span>
|
||||
<span className={s.chSep}>/</span>
|
||||
<span>{activeChapter?.name}</span>
|
||||
</span>
|
||||
<span className={s.pageLabel}>{pageNumber} / {lastPage || "…"}</span>
|
||||
<button className={s.iconBtn} onClick={() => adjacent.next && openReader(adjacent.next, activeChapterList)}
|
||||
disabled={!adjacent.next} title="Next chapter">
|
||||
<CaretRight size={14} weight="light" />
|
||||
</button>
|
||||
|
||||
<div className={s.topSep} />
|
||||
|
||||
{/* Fit mode */}
|
||||
<button className={s.modeBtn} onClick={cycleFit} title={`Fit mode: ${fitLabel}\nCtrl+scroll to zoom`}>
|
||||
{fitIcon}
|
||||
<span className={s.modeBtnLabel}>{fitLabel}</span>
|
||||
</button>
|
||||
|
||||
{/* Zoom — click resets */}
|
||||
<button className={s.zoomBtn} onClick={() => updateSettings({ maxPageWidth: 900 })}
|
||||
title="Click to reset zoom (Ctrl+scroll to zoom)">
|
||||
{Math.round((maxW / 900) * 100)}%
|
||||
</button>
|
||||
|
||||
{/* RTL */}
|
||||
<button
|
||||
className={[s.modeBtn, rtl ? s.modeBtnActive : ""].join(" ")}
|
||||
onClick={() => updateSettings({ readingDirection: rtl ? "ltr" : "rtl" })}
|
||||
title={`Direction: ${rtl ? "RTL" : "LTR"}`}>
|
||||
<ArrowsLeftRight size={14} weight="light" />
|
||||
<span className={s.modeBtnLabel}>{rtl ? "RTL" : "LTR"}</span>
|
||||
</button>
|
||||
|
||||
{/* Page style */}
|
||||
<button className={s.modeBtn} onClick={cycleStyle} title={`Layout: ${style}`}>
|
||||
{styleIcon}
|
||||
<span className={s.modeBtnLabel}>{style}</span>
|
||||
</button>
|
||||
|
||||
{/* Page gap toggle — only meaningful in double/longstrip */}
|
||||
{style !== "single" && (
|
||||
<button
|
||||
className={[s.modeBtn, settings.pageGap ? s.modeBtnActive : ""].join(" ")}
|
||||
onClick={() => updateSettings({ pageGap: !settings.pageGap })}
|
||||
title="Toggle page gap">
|
||||
<span className={s.modeBtnLabel}>Gap</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Download */}
|
||||
<button className={s.modeBtn} onClick={() => setDlOpen(true)} title="Download options">
|
||||
<Download size={14} weight="light" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ── Viewer ── */}
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={[
|
||||
s.viewer,
|
||||
style === "longstrip" ? s.viewerStrip : "",
|
||||
].join(" ")}
|
||||
style={cssVars}
|
||||
onClick={handleTap}
|
||||
>
|
||||
{style === "longstrip" ? (
|
||||
pageUrls.map((url, i) => (
|
||||
<img key={i} src={url} alt={`Page ${i + 1}`}
|
||||
className={[imgCls, settings.pageGap ? s.stripGap : ""].join(" ")}
|
||||
loading={i < 3 ? "eager" : "lazy"} decoding="async" />
|
||||
))
|
||||
) : style === "double" ? (
|
||||
renderDouble()
|
||||
) : (
|
||||
<img key={pageNumber} src={pageUrls[pageNumber - 1]}
|
||||
alt={`Page ${pageNumber}`} className={imgCls} decoding="async" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Bottom nav ── */}
|
||||
<div className={s.bottombar}>
|
||||
<button className={s.navBtn} onClick={goPrev} disabled={pageNumber === 1 && !adjacent.prev}>
|
||||
<ArrowLeft size={13} weight="light" />
|
||||
</button>
|
||||
<button className={s.navBtn} onClick={goNext} disabled={pageNumber === lastPage && !adjacent.next}>
|
||||
<ArrowRight size={13} weight="light" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{dlOpen && activeChapter && (
|
||||
<DownloadModal
|
||||
chapter={activeChapter}
|
||||
remaining={adjacent.remaining}
|
||||
onClose={() => setDlOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
.root {
|
||||
display: flex; flex-direction: column; height: 100%;
|
||||
overflow: hidden; animation: fadeIn 0.14s ease both;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex; align-items: center; gap: var(--sp-4);
|
||||
padding: var(--sp-4) var(--sp-6); border-bottom: 1px solid var(--border-dim); flex-shrink: 0;
|
||||
}
|
||||
.heading {
|
||||
font-family: var(--font-ui); font-size: var(--text-xs);
|
||||
color: var(--text-faint); letter-spacing: var(--tracking-wider);
|
||||
text-transform: uppercase; flex-shrink: 0;
|
||||
}
|
||||
.searchBar {
|
||||
flex: 1; 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: 0 var(--sp-3) 0 var(--sp-2);
|
||||
transition: border-color var(--t-base);
|
||||
}
|
||||
.searchBar:focus-within { border-color: var(--border-strong); }
|
||||
.searchIcon { color: var(--text-faint); flex-shrink: 0; }
|
||||
.searchInput {
|
||||
flex: 1; background: none; border: none; outline: none;
|
||||
color: var(--text-primary); font-size: var(--text-sm); padding: 8px 0;
|
||||
}
|
||||
.searchInput::placeholder { color: var(--text-faint); }
|
||||
.searchBtn {
|
||||
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
|
||||
padding: 5px 12px; border-radius: var(--radius-md); flex-shrink: 0;
|
||||
background: var(--accent-muted); color: var(--accent-fg);
|
||||
border: 1px solid var(--accent-dim); cursor: pointer;
|
||||
transition: filter var(--t-base); display: flex; align-items: center; gap: var(--sp-2);
|
||||
}
|
||||
.searchBtn:hover:not(:disabled) { filter: brightness(1.1); }
|
||||
.searchBtn:disabled { opacity: 0.4; cursor: default; }
|
||||
|
||||
.results { flex: 1; overflow-y: auto; padding: var(--sp-5) var(--sp-6); display: flex; flex-direction: column; gap: var(--sp-6); }
|
||||
|
||||
.sourceSection { display: flex; flex-direction: column; gap: var(--sp-3); }
|
||||
|
||||
.sourceHeader {
|
||||
display: flex; align-items: center; gap: var(--sp-2);
|
||||
}
|
||||
.sourceIcon { width: 18px; height: 18px; border-radius: 4px; object-fit: cover; flex-shrink: 0; }
|
||||
.sourceName { font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-secondary); }
|
||||
.resultCount {
|
||||
font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint);
|
||||
letter-spacing: var(--tracking-wide); margin-left: auto;
|
||||
}
|
||||
.sourceError { font-size: var(--text-xs); color: var(--color-error); padding: 0 var(--sp-1); }
|
||||
|
||||
.sourceRow {
|
||||
display: flex; gap: var(--sp-3); overflow-x: auto;
|
||||
padding-bottom: var(--sp-2);
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
.card {
|
||||
flex-shrink: 0; width: 110px; background: none; border: none; padding: 0;
|
||||
cursor: pointer; text-align: left;
|
||||
}
|
||||
.card:hover .cover { filter: brightness(1.06); }
|
||||
.coverWrap {
|
||||
position: relative; aspect-ratio: 2/3; border-radius: var(--radius-md);
|
||||
overflow: hidden; background: var(--bg-raised); border: 1px solid var(--border-dim);
|
||||
}
|
||||
.cover { width: 100%; height: 100%; object-fit: cover; transition: filter var(--t-base); }
|
||||
.inLibBadge {
|
||||
position: absolute; bottom: var(--sp-1); left: var(--sp-1);
|
||||
font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide);
|
||||
text-transform: uppercase; background: var(--accent-muted); color: var(--accent-fg);
|
||||
border: 1px solid var(--accent-dim); padding: 2px 5px; border-radius: var(--radius-sm);
|
||||
}
|
||||
.cardTitle {
|
||||
margin-top: var(--sp-1); font-size: var(--text-xs); color: var(--text-muted);
|
||||
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
|
||||
line-height: var(--leading-snug);
|
||||
}
|
||||
|
||||
/* Skeletons */
|
||||
.skCard { flex-shrink: 0; width: 110px; }
|
||||
.skCover { aspect-ratio: 2/3; border-radius: var(--radius-md); }
|
||||
.skTitle { height: 11px; margin-top: var(--sp-1); width: 75%; }
|
||||
|
||||
.empty {
|
||||
flex: 1; display: flex; flex-direction: column;
|
||||
align-items: center; justify-content: center; gap: var(--sp-2);
|
||||
}
|
||||
.emptyIcon { color: var(--text-faint); }
|
||||
.emptyText { font-size: var(--text-base); color: var(--text-muted); }
|
||||
.emptyHint { font-size: var(--text-sm); color: var(--text-faint); }
|
||||
@@ -0,0 +1,168 @@
|
||||
import { useState, useRef, useCallback } from "react";
|
||||
import { MagnifyingGlass, CircleNotch } from "@phosphor-icons/react";
|
||||
import { gql, thumbUrl } from "../../lib/client";
|
||||
import { GET_SOURCES, FETCH_SOURCE_MANGA } from "../../lib/queries";
|
||||
import { useStore } from "../../store";
|
||||
import type { Manga, Source } from "../../lib/types";
|
||||
import s from "./Search.module.css";
|
||||
|
||||
interface SourceResult {
|
||||
source: Source;
|
||||
mangas: Manga[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export default function Search() {
|
||||
const [query, setQuery] = useState("");
|
||||
const [submitted, setSubmitted] = useState("");
|
||||
const [results, setResults] = useState<SourceResult[]>([]);
|
||||
const [sources, setSources] = useState<Source[]>([]);
|
||||
const [loadingSources, setLoadingSources] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const setActiveManga = useStore((s) => s.setActiveManga);
|
||||
const setNavPage = useStore((s) => s.setNavPage);
|
||||
|
||||
const loadSources = useCallback(async () => {
|
||||
if (sources.length) return sources;
|
||||
setLoadingSources(true);
|
||||
const data = await gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
|
||||
.finally(() => setLoadingSources(false));
|
||||
const nodes = data.sources.nodes.filter((s) => s.id !== "0");
|
||||
setSources(nodes);
|
||||
return nodes;
|
||||
}, [sources]);
|
||||
|
||||
async function runSearch() {
|
||||
const q = query.trim();
|
||||
if (!q) return;
|
||||
setSubmitted(q);
|
||||
|
||||
const srcs = await loadSources();
|
||||
// Initialise loading state for each source
|
||||
setResults(srcs.map((src) => ({ source: src, mangas: [], loading: true, error: null })));
|
||||
|
||||
// Fire all source queries in parallel, update each independently
|
||||
srcs.forEach((src) => {
|
||||
gql<{ fetchSourceManga: { mangas: Manga[] } }>(FETCH_SOURCE_MANGA, {
|
||||
source: src.id, type: "SEARCH", page: 1, query: q,
|
||||
})
|
||||
.then((d) => {
|
||||
setResults((prev) => prev.map((r) =>
|
||||
r.source.id === src.id
|
||||
? { ...r, mangas: d.fetchSourceManga.mangas, loading: false }
|
||||
: r
|
||||
));
|
||||
})
|
||||
.catch((e) => {
|
||||
setResults((prev) => prev.map((r) =>
|
||||
r.source.id === src.id
|
||||
? { ...r, loading: false, error: e.message }
|
||||
: r
|
||||
));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function openManga(m: Manga) {
|
||||
setActiveManga(m);
|
||||
setNavPage("library");
|
||||
}
|
||||
|
||||
const hasResults = results.some((r) => r.mangas.length > 0);
|
||||
const allDone = results.every((r) => !r.loading);
|
||||
|
||||
return (
|
||||
<div className={s.root}>
|
||||
{/* ── Search bar ── */}
|
||||
<div className={s.header}>
|
||||
<h1 className={s.heading}>Search</h1>
|
||||
<div className={s.searchBar}>
|
||||
<MagnifyingGlass size={14} className={s.searchIcon} weight="light" />
|
||||
<input ref={inputRef} className={s.searchInput}
|
||||
placeholder="Search across all sources…"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && runSearch()}
|
||||
autoFocus />
|
||||
<button className={s.searchBtn}
|
||||
onClick={runSearch}
|
||||
disabled={!query.trim() || loadingSources}>
|
||||
{loadingSources
|
||||
? <CircleNotch size={13} weight="light" className="anim-spin" />
|
||||
: "Search"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Empty state ── */}
|
||||
{!submitted && (
|
||||
<div className={s.empty}>
|
||||
<MagnifyingGlass size={36} weight="light" className={s.emptyIcon} />
|
||||
<p className={s.emptyText}>Search across all installed sources at once</p>
|
||||
<p className={s.emptyHint}>Results from each source appear as they load.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Results ── */}
|
||||
{submitted && (
|
||||
<div className={s.results}>
|
||||
{results.length === 0 && (
|
||||
<div className={s.empty}>
|
||||
<CircleNotch size={20} weight="light" className="anim-spin" style={{ color: "var(--text-faint)" }} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{results
|
||||
.filter((r) => r.mangas.length > 0 || r.loading || r.error)
|
||||
.map(({ source, mangas, loading, error }) => (
|
||||
<div key={source.id} className={s.sourceSection}>
|
||||
<div className={s.sourceHeader}>
|
||||
<img src={thumbUrl(source.iconUrl)} alt={source.displayName}
|
||||
className={s.sourceIcon}
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
|
||||
<span className={s.sourceName}>{source.displayName}</span>
|
||||
{loading && <CircleNotch size={12} weight="light" className="anim-spin" style={{ color: "var(--text-faint)" }} />}
|
||||
{!loading && mangas.length > 0 && (
|
||||
<span className={s.resultCount}>{mangas.length} results</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<p className={s.sourceError}>{error}</p>
|
||||
) : loading ? (
|
||||
<div className={s.sourceRow}>
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div key={i} className={s.skCard}>
|
||||
<div className={["skeleton", s.skCover].join(" ")} />
|
||||
<div className={["skeleton", s.skTitle].join(" ")} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : mangas.length > 0 ? (
|
||||
<div className={s.sourceRow}>
|
||||
{mangas.slice(0, 8).map((m) => (
|
||||
<button key={m.id} className={s.card} onClick={() => openManga(m)}>
|
||||
<div className={s.coverWrap}>
|
||||
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} className={s.cover} />
|
||||
{m.inLibrary && <span className={s.inLibBadge}>In Library</span>}
|
||||
</div>
|
||||
<p className={s.cardTitle}>{m.title}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{allDone && !hasResults && submitted && (
|
||||
<div className={s.empty}>
|
||||
<p className={s.emptyText}>No results for "{submitted}"</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,430 @@
|
||||
.root {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
animation: fadeIn 0.14s ease both;
|
||||
}
|
||||
|
||||
/* ── Sidebar ── */
|
||||
.sidebar {
|
||||
width: 200px;
|
||||
flex-shrink: 0;
|
||||
padding: var(--sp-5);
|
||||
border-right: 1px solid var(--border-dim);
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-4);
|
||||
background: var(--bg-base);
|
||||
}
|
||||
|
||||
.back {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
color: var(--text-muted);
|
||||
font-size: var(--text-xs);
|
||||
font-family: var(--font-ui);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
text-transform: uppercase;
|
||||
transition: color var(--t-base);
|
||||
}
|
||||
.back:hover { color: var(--text-secondary); }
|
||||
|
||||
.coverWrap {
|
||||
width: 100%;
|
||||
aspect-ratio: 2 / 3;
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
background: var(--bg-raised);
|
||||
border: 1px solid var(--border-dim);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.cover { width: 100%; height: 100%; object-fit: cover; }
|
||||
|
||||
.metaSkeleton { display: flex; flex-direction: column; gap: var(--sp-2); }
|
||||
.skLine { border-radius: var(--radius-sm); }
|
||||
|
||||
.meta { display: flex; flex-direction: column; gap: var(--sp-3); }
|
||||
|
||||
.title {
|
||||
font-size: var(--text-base);
|
||||
font-weight: var(--weight-medium);
|
||||
color: var(--text-primary);
|
||||
line-height: var(--leading-snug);
|
||||
letter-spacing: var(--tracking-tight);
|
||||
}
|
||||
|
||||
.byline {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-ui);
|
||||
}
|
||||
|
||||
.statusBadge {
|
||||
display: inline-block;
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-2xs);
|
||||
letter-spacing: var(--tracking-wider);
|
||||
text-transform: uppercase;
|
||||
padding: 2px 7px;
|
||||
border-radius: var(--radius-sm);
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.statusOngoing {
|
||||
background: var(--accent-muted);
|
||||
color: var(--accent-fg);
|
||||
border: 1px solid var(--accent-dim);
|
||||
}
|
||||
|
||||
.statusEnded {
|
||||
background: var(--bg-raised);
|
||||
color: var(--text-faint);
|
||||
border: 1px solid var(--border-dim);
|
||||
}
|
||||
|
||||
.genres { display: flex; flex-wrap: wrap; gap: var(--sp-1); }
|
||||
|
||||
.genre {
|
||||
font-size: var(--text-2xs);
|
||||
font-family: var(--font-ui);
|
||||
color: var(--text-faint);
|
||||
background: var(--bg-raised);
|
||||
border: 1px solid var(--border-dim);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 1px 6px;
|
||||
letter-spacing: var(--tracking-wide);
|
||||
}
|
||||
|
||||
.sourceLabel {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-2xs);
|
||||
color: var(--text-faint);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-muted);
|
||||
line-height: var(--leading-base);
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 8;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── Progress ── */
|
||||
.progressSection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-1);
|
||||
}
|
||||
|
||||
.progressHeader {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.progressLabel {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-2xs);
|
||||
color: var(--text-faint);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
}
|
||||
|
||||
.progressPct {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-2xs);
|
||||
color: var(--accent-fg);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
}
|
||||
|
||||
.progressTrack {
|
||||
height: 3px;
|
||||
background: var(--border-base);
|
||||
border-radius: var(--radius-full);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progressFill {
|
||||
height: 100%;
|
||||
background: var(--accent);
|
||||
border-radius: var(--radius-full);
|
||||
transition: width 0.4s ease;
|
||||
}
|
||||
|
||||
/* ── Actions ── */
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.libraryBtn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
font-size: var(--text-xs);
|
||||
font-family: var(--font-ui);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
padding: 5px 10px;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border-strong);
|
||||
color: var(--text-muted);
|
||||
background: var(--bg-raised);
|
||||
transition: border-color var(--t-base), color var(--t-base), background var(--t-base);
|
||||
flex: 1;
|
||||
}
|
||||
.libraryBtn:hover { border-color: var(--accent); color: var(--accent-fg); }
|
||||
.libraryBtn:disabled { opacity: 0.4; cursor: default; }
|
||||
.libraryBtnActive {
|
||||
background: var(--accent-muted);
|
||||
border-color: var(--accent-dim);
|
||||
color: var(--accent-fg);
|
||||
}
|
||||
|
||||
.externalLink {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border-dim);
|
||||
color: var(--text-faint);
|
||||
flex-shrink: 0;
|
||||
transition: color var(--t-base), border-color var(--t-base);
|
||||
}
|
||||
.externalLink:hover { color: var(--text-muted); border-color: var(--border-strong); }
|
||||
|
||||
/* ── Start/Continue reading button ── */
|
||||
.readBtn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--sp-2);
|
||||
width: 100%;
|
||||
padding: 8px var(--sp-3);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--accent-dim);
|
||||
border: 1px solid var(--accent);
|
||||
color: var(--accent-fg);
|
||||
font-size: var(--text-xs);
|
||||
font-family: var(--font-ui);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
cursor: pointer;
|
||||
transition: background var(--t-base), border-color var(--t-base);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.readBtn:hover { background: var(--accent-muted); border-color: var(--accent-bright); }
|
||||
|
||||
.chapterCount {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-2xs);
|
||||
color: var(--text-faint);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
text-transform: uppercase;
|
||||
margin-top: auto;
|
||||
padding-top: var(--sp-2);
|
||||
}
|
||||
|
||||
/* ── Chapter list ── */
|
||||
.listWrap {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.listHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--sp-3) var(--sp-4);
|
||||
border-bottom: 1px solid var(--border-dim);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sortBtn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-muted);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
padding: 4px 8px;
|
||||
border-radius: var(--radius-md);
|
||||
transition: background var(--t-base), color var(--t-base);
|
||||
}
|
||||
.sortBtn:hover { background: var(--bg-raised); color: var(--text-secondary); }
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.paginationBottom {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--sp-3);
|
||||
padding: var(--sp-3) var(--sp-4);
|
||||
border-top: 1px solid var(--border-dim);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.pageBtn {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-muted);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
padding: 3px 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border-dim);
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
transition: background var(--t-base), color var(--t-base), border-color var(--t-base);
|
||||
}
|
||||
.pageBtn:hover:not(:disabled) { background: var(--bg-raised); color: var(--text-secondary); border-color: var(--border-strong); }
|
||||
.pageBtn:disabled { opacity: 0.3; cursor: default; }
|
||||
|
||||
.pageNum {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-faint);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
min-width: 40px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: var(--sp-2) var(--sp-4);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.rowSkeleton {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-2);
|
||||
padding: 12px var(--sp-3);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-raised);
|
||||
margin-bottom: 1px;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
padding: 10px var(--sp-3);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
color: var(--text-primary);
|
||||
transition: background var(--t-fast);
|
||||
}
|
||||
.row:hover { background: var(--bg-raised); }
|
||||
.rowRead .chName { color: var(--text-faint); }
|
||||
|
||||
.chLeft {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.chName {
|
||||
font-size: var(--text-base);
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
transition: color var(--t-fast);
|
||||
}
|
||||
.row:hover .chName { color: var(--text-primary); }
|
||||
|
||||
.chMeta { display: flex; align-items: center; gap: var(--sp-3); }
|
||||
|
||||
.chMetaItem {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-2xs);
|
||||
color: var(--text-faint);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
}
|
||||
|
||||
.chRight {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
flex-shrink: 0;
|
||||
margin-left: var(--sp-3);
|
||||
}
|
||||
|
||||
.bookmarkIcon { color: var(--accent); }
|
||||
.readIcon { color: var(--text-faint); }
|
||||
.downloadedIcon { color: var(--accent-fg); }
|
||||
.enqueuingIcon { color: var(--text-faint); }
|
||||
|
||||
.dlBtn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-faint);
|
||||
transition: color var(--t-base), background var(--t-base);
|
||||
}
|
||||
.dlBtn:hover { color: var(--text-muted); background: var(--bg-overlay); }
|
||||
/* ── Download section ── */
|
||||
.downloadSection {
|
||||
position: relative; margin-top: var(--sp-2);
|
||||
}
|
||||
|
||||
.downloadToggle {
|
||||
display: flex; align-items: center; gap: var(--sp-2);
|
||||
width: 100%; padding: 7px var(--sp-3);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border-dim);
|
||||
background: none; color: var(--text-muted);
|
||||
font-size: var(--text-sm); cursor: pointer;
|
||||
transition: background var(--t-base), color var(--t-base), border-color var(--t-base);
|
||||
}
|
||||
.downloadToggle:hover { background: var(--bg-raised); color: var(--text-secondary); border-color: var(--border-strong); }
|
||||
|
||||
.downloadMenu {
|
||||
margin-top: var(--sp-1);
|
||||
background: var(--bg-raised); border: 1px solid var(--border-base);
|
||||
border-radius: var(--radius-lg); padding: var(--sp-1);
|
||||
display: flex; flex-direction: column; gap: 1px;
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.4);
|
||||
animation: fadeIn 0.1s ease both;
|
||||
}
|
||||
|
||||
.dlItem {
|
||||
display: flex; flex-direction: column; align-items: flex-start; gap: 2px;
|
||||
width: 100%; padding: 7px var(--sp-3); border-radius: var(--radius-md);
|
||||
font-size: var(--text-sm); color: var(--text-secondary);
|
||||
background: none; border: none; cursor: pointer; text-align: left;
|
||||
transition: background var(--t-fast), color var(--t-fast);
|
||||
}
|
||||
.dlItem:hover { background: var(--bg-overlay); color: var(--text-primary); }
|
||||
|
||||
.dlItemSub { font-size: var(--text-xs); color: var(--text-faint); }
|
||||
@@ -0,0 +1,468 @@
|
||||
import { useEffect, useState, useMemo, useCallback } from "react";
|
||||
import {
|
||||
ArrowLeft, BookmarkSimple, Download, CheckCircle,
|
||||
ArrowSquareOut, BookOpen, CircleNotch, Play,
|
||||
SortAscending, SortDescending,
|
||||
} from "@phosphor-icons/react";
|
||||
import { gql, thumbUrl } from "../../lib/client";
|
||||
import {
|
||||
GET_MANGA, GET_CHAPTERS, FETCH_CHAPTERS, ENQUEUE_DOWNLOAD,
|
||||
UPDATE_MANGA, MARK_CHAPTER_READ, MARK_CHAPTERS_READ, DELETE_DOWNLOADED_CHAPTERS,
|
||||
ENQUEUE_CHAPTERS_DOWNLOAD,
|
||||
} from "../../lib/queries";
|
||||
import { useStore } from "../../store";
|
||||
import ContextMenu, { type ContextMenuEntry } from "../context/ContextMenu";
|
||||
import type { Manga, Chapter } from "../../lib/types";
|
||||
import s from "./SeriesDetail.module.css";
|
||||
|
||||
function formatDate(ts: string | null | undefined): string {
|
||||
if (!ts) return "";
|
||||
const n = Number(ts);
|
||||
const d = new Date(n > 1e10 ? n : n * 1000);
|
||||
return d.toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" });
|
||||
}
|
||||
|
||||
interface CtxState {
|
||||
x: number;
|
||||
y: number;
|
||||
chapter: Chapter;
|
||||
indexInSorted: number;
|
||||
}
|
||||
|
||||
const CHAPTERS_PER_PAGE = 25;
|
||||
|
||||
export default function SeriesDetail() {
|
||||
const activeManga = useStore((state) => state.activeManga);
|
||||
const setActiveManga = useStore((state) => state.setActiveManga);
|
||||
const openReader = useStore((state) => state.openReader);
|
||||
const settings = useStore((state) => state.settings);
|
||||
const updateSettings = useStore((state) => state.updateSettings);
|
||||
|
||||
const [manga, setManga] = useState<Manga | null>(activeManga);
|
||||
const [chapters, setChapters] = useState<Chapter[]>([]);
|
||||
const [loadingManga, setLoadingManga] = useState(true);
|
||||
const [loadingChapters, setLoadingChapters] = useState(true);
|
||||
const [enqueueing, setEnqueueing] = useState<Set<number>>(new Set());
|
||||
const [dlOpen, setDlOpen] = useState(false);
|
||||
const [togglingLibrary, setTogglingLibrary] = useState(false);
|
||||
const [chapterPage, setChapterPage] = useState(1);
|
||||
const [ctx, setCtx] = useState<CtxState | null>(null);
|
||||
|
||||
const sortDir = settings.chapterSortDir;
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeManga) return;
|
||||
setLoadingManga(true);
|
||||
gql<{ manga: Manga }>(GET_MANGA, { id: activeManga.id })
|
||||
.then((data) => setManga(data.manga))
|
||||
.catch(console.error)
|
||||
.finally(() => setLoadingManga(false));
|
||||
}, [activeManga?.id]);
|
||||
|
||||
const loadChapters = useCallback((mangaId: number) => {
|
||||
return gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId })
|
||||
.then((data) => {
|
||||
// Always store in natural order (ascending sourceOrder), sort in render
|
||||
const sorted = [...data.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder);
|
||||
setChapters(sorted);
|
||||
return sorted;
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeManga) return;
|
||||
setLoadingChapters(true);
|
||||
setChapters([]);
|
||||
setChapterPage(1);
|
||||
|
||||
loadChapters(activeManga.id)
|
||||
.catch(console.error)
|
||||
.finally(() => setLoadingChapters(false));
|
||||
|
||||
// Fetch from source in background
|
||||
gql(FETCH_CHAPTERS, { mangaId: activeManga.id })
|
||||
.then(() => loadChapters(activeManga.id))
|
||||
.catch(console.error);
|
||||
}, [activeManga?.id]);
|
||||
|
||||
// Sorted chapters based on setting
|
||||
const sortedChapters = useMemo(() =>
|
||||
sortDir === "desc"
|
||||
? [...chapters].reverse()
|
||||
: [...chapters],
|
||||
[chapters, sortDir]
|
||||
);
|
||||
|
||||
const totalPages = Math.ceil(sortedChapters.length / CHAPTERS_PER_PAGE);
|
||||
const pageChapters = sortedChapters.slice(
|
||||
(chapterPage - 1) * CHAPTERS_PER_PAGE,
|
||||
chapterPage * CHAPTERS_PER_PAGE
|
||||
);
|
||||
|
||||
// Progress stats
|
||||
const readCount = chapters.filter((c) => c.isRead).length;
|
||||
const totalCount = chapters.length;
|
||||
const progressPct = totalCount > 0 ? (readCount / totalCount) * 100 : 0;
|
||||
|
||||
// Start / Continue reading logic
|
||||
const continueChapter = useMemo(() => {
|
||||
if (!chapters.length) return null;
|
||||
// Find first unread chapter (in ascending order)
|
||||
const asc = [...chapters].sort((a, b) => a.sourceOrder - b.sourceOrder);
|
||||
const inProgress = asc.find((c) => !c.isRead && (c.lastPageRead ?? 0) > 0);
|
||||
if (inProgress) return { chapter: inProgress, type: "continue" as const };
|
||||
const firstUnread = asc.find((c) => !c.isRead);
|
||||
if (firstUnread) return { chapter: firstUnread, type: "start" as const };
|
||||
return { chapter: asc[0], type: "reread" as const };
|
||||
}, [chapters]);
|
||||
|
||||
async function toggleLibrary() {
|
||||
if (!manga) return;
|
||||
setTogglingLibrary(true);
|
||||
const next = !manga.inLibrary;
|
||||
await gql(UPDATE_MANGA, { id: manga.id, inLibrary: next }).catch(console.error);
|
||||
setManga((prev) => prev ? { ...prev, inLibrary: next } : prev);
|
||||
setTogglingLibrary(false);
|
||||
}
|
||||
|
||||
async function enqueue(chapter: Chapter, e: React.MouseEvent) {
|
||||
e.stopPropagation();
|
||||
setEnqueueing((prev) => new Set(prev).add(chapter.id));
|
||||
await gql(ENQUEUE_DOWNLOAD, { chapterId: chapter.id }).catch(console.error);
|
||||
setEnqueueing((prev) => { const n = new Set(prev); n.delete(chapter.id); return n; });
|
||||
if (activeManga) loadChapters(activeManga.id);
|
||||
}
|
||||
|
||||
async function markRead(chapterId: number, isRead: boolean) {
|
||||
await gql(MARK_CHAPTER_READ, { id: chapterId, isRead }).catch(console.error);
|
||||
setChapters((prev) => prev.map((c) => c.id === chapterId ? { ...c, isRead } : c));
|
||||
}
|
||||
|
||||
async function markAllAboveRead(indexInSorted: number) {
|
||||
// "above" = all chapters that appear before this one in the current sort
|
||||
const targets = sortedChapters.slice(0, indexInSorted + 1);
|
||||
const ids = targets.filter((c) => !c.isRead).map((c) => c.id);
|
||||
if (!ids.length) return;
|
||||
await gql(MARK_CHAPTERS_READ, { ids, isRead: true }).catch(console.error);
|
||||
setChapters((prev) => prev.map((c) => ids.includes(c.id) ? { ...c, isRead: true } : c));
|
||||
}
|
||||
|
||||
async function deleteDownloaded(chapterId: number) {
|
||||
await gql(DELETE_DOWNLOADED_CHAPTERS, { ids: [chapterId] }).catch(console.error);
|
||||
setChapters((prev) => prev.map((c) => c.id === chapterId ? { ...c, isDownloaded: false } : c));
|
||||
}
|
||||
|
||||
async function enqueueMultiple(chapterIds: number[]) {
|
||||
await gql(ENQUEUE_CHAPTERS_DOWNLOAD, { chapterIds }).catch(console.error);
|
||||
if (activeManga) loadChapters(activeManga.id);
|
||||
}
|
||||
|
||||
function openContextMenu(e: React.MouseEvent, chapter: Chapter, indexInSorted: number) {
|
||||
e.preventDefault();
|
||||
setCtx({ x: e.clientX, y: e.clientY, chapter, indexInSorted });
|
||||
}
|
||||
|
||||
function buildCtxItems(ch: Chapter, indexInSorted: number): ContextMenuEntry[] {
|
||||
return [
|
||||
{
|
||||
label: ch.isRead ? "Mark as unread" : "Mark as read",
|
||||
onClick: () => markRead(ch.id, !ch.isRead),
|
||||
},
|
||||
{
|
||||
label: "Mark all above as read",
|
||||
onClick: () => markAllAboveRead(indexInSorted),
|
||||
disabled: indexInSorted === 0,
|
||||
},
|
||||
{ separator: true },
|
||||
{
|
||||
label: ch.isDownloaded ? "Delete download" : "Download",
|
||||
onClick: () => ch.isDownloaded
|
||||
? deleteDownloaded(ch.id)
|
||||
: gql(ENQUEUE_DOWNLOAD, { chapterId: ch.id }).catch(console.error),
|
||||
danger: ch.isDownloaded,
|
||||
},
|
||||
{ separator: true },
|
||||
{
|
||||
label: "Download all from here",
|
||||
onClick: () => {
|
||||
const fromHere = sortedChapters
|
||||
.slice(indexInSorted)
|
||||
.filter((c) => !c.isDownloaded)
|
||||
.map((c) => c.id);
|
||||
enqueueMultiple(fromHere);
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
if (!activeManga) return null;
|
||||
|
||||
const statusLabel = manga?.status
|
||||
? manga.status.charAt(0) + manga.status.slice(1).toLowerCase()
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className={s.root} onContextMenu={(e) => e.preventDefault()}>
|
||||
{/* ── Sidebar ── */}
|
||||
<div className={s.sidebar}>
|
||||
<button className={s.back} onClick={() => setActiveManga(null)}>
|
||||
<ArrowLeft size={13} weight="light" />
|
||||
<span>Library</span>
|
||||
</button>
|
||||
|
||||
<div className={s.coverWrap}>
|
||||
<img src={thumbUrl(activeManga.thumbnailUrl)} alt={activeManga.title} className={s.cover} />
|
||||
</div>
|
||||
|
||||
{loadingManga ? (
|
||||
<div className={s.metaSkeleton}>
|
||||
<div className={["skeleton", s.skLine].join(" ")} style={{ width: "90%", height: 14 }} />
|
||||
<div className={["skeleton", s.skLine].join(" ")} style={{ width: "60%", height: 11 }} />
|
||||
</div>
|
||||
) : (
|
||||
<div className={s.meta}>
|
||||
<p className={s.title}>{manga?.title}</p>
|
||||
|
||||
{(manga?.author || manga?.artist) && (
|
||||
<p className={s.byline}>
|
||||
{[manga.author, manga.artist]
|
||||
.filter(Boolean)
|
||||
.filter((v, i, a) => a.indexOf(v) === i)
|
||||
.join(" · ")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{statusLabel && (
|
||||
<span className={[s.statusBadge, manga?.status === "ONGOING" ? s.statusOngoing : s.statusEnded].join(" ").trim()}>
|
||||
{statusLabel}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{manga?.genre && manga.genre.length > 0 && (
|
||||
<div className={s.genres}>
|
||||
{manga.genre.map((g) => <span key={g} className={s.genre}>{g}</span>)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{manga?.source && <p className={s.sourceLabel}>{manga.source.displayName}</p>}
|
||||
{manga?.description && <p className={s.description}>{manga.description}</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Progress bar */}
|
||||
{totalCount > 0 && (
|
||||
<div className={s.progressSection}>
|
||||
<div className={s.progressHeader}>
|
||||
<span className={s.progressLabel}>{readCount} / {totalCount} read</span>
|
||||
<span className={s.progressPct}>{Math.round(progressPct)}%</span>
|
||||
</div>
|
||||
<div className={s.progressTrack}>
|
||||
<div className={s.progressFill} style={{ width: `${progressPct}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={s.actions}>
|
||||
<button
|
||||
className={[s.libraryBtn, manga?.inLibrary ? s.libraryBtnActive : ""].join(" ").trim()}
|
||||
onClick={toggleLibrary}
|
||||
disabled={togglingLibrary || loadingManga}
|
||||
>
|
||||
<BookmarkSimple size={13} weight={manga?.inLibrary ? "fill" : "light"} />
|
||||
{manga?.inLibrary ? "In Library" : "Add to Library"}
|
||||
</button>
|
||||
|
||||
{manga?.realUrl && (
|
||||
<a href={manga.realUrl} target="_blank" rel="noreferrer" className={s.externalLink}>
|
||||
<ArrowSquareOut size={13} weight="light" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Start / Continue reading button */}
|
||||
{continueChapter && (
|
||||
<button
|
||||
className={s.readBtn}
|
||||
onClick={() => openReader(continueChapter.chapter, sortedChapters)}
|
||||
>
|
||||
<Play size={12} weight="fill" />
|
||||
{continueChapter.type === "continue"
|
||||
? `Continue · Ch.${continueChapter.chapter.chapterNumber}${
|
||||
(continueChapter.chapter.lastPageRead ?? 0) > 0
|
||||
? ` p.${continueChapter.chapter.lastPageRead}`
|
||||
: ""
|
||||
}`
|
||||
: continueChapter.type === "reread"
|
||||
? "Read again"
|
||||
: "Start reading"
|
||||
}
|
||||
</button>
|
||||
)}
|
||||
|
||||
|
||||
{/* ── Download panel ── */}
|
||||
{chapters.length > 0 && (
|
||||
<div className={s.downloadSection}>
|
||||
<button className={s.downloadToggle} onClick={() => setDlOpen((p) => !p)}>
|
||||
<Download size={13} weight="light" />
|
||||
Download
|
||||
</button>
|
||||
{dlOpen && (
|
||||
<div className={s.downloadMenu}>
|
||||
{continueChapter && (
|
||||
<button className={s.dlItem}
|
||||
onClick={() => {
|
||||
const from = sortedChapters.indexOf(continueChapter.chapter);
|
||||
const ids = sortedChapters.slice(from).filter((c) => !c.isDownloaded).map((c) => c.id);
|
||||
enqueueMultiple(ids);
|
||||
setDlOpen(false);
|
||||
}}>
|
||||
<span>From current</span>
|
||||
<span className={s.dlItemSub}>Ch.{continueChapter.chapter.chapterNumber} onwards</span>
|
||||
</button>
|
||||
)}
|
||||
<button className={s.dlItem}
|
||||
onClick={() => {
|
||||
const ids = sortedChapters.filter((c) => !c.isRead && !c.isDownloaded).map((c) => c.id);
|
||||
enqueueMultiple(ids);
|
||||
setDlOpen(false);
|
||||
}}>
|
||||
<span>Unread chapters</span>
|
||||
<span className={s.dlItemSub}>{sortedChapters.filter((c) => !c.isRead && !c.isDownloaded).length} remaining</span>
|
||||
</button>
|
||||
<button className={s.dlItem}
|
||||
onClick={() => {
|
||||
const ids = sortedChapters.filter((c) => !c.isDownloaded).map((c) => c.id);
|
||||
enqueueMultiple(ids);
|
||||
setDlOpen(false);
|
||||
}}>
|
||||
<span>Download all</span>
|
||||
<span className={s.dlItemSub}>{sortedChapters.filter((c) => !c.isDownloaded).length} not downloaded</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className={s.chapterCount}>
|
||||
{totalCount} {totalCount === 1 ? "chapter" : "chapters"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* ── Chapter list ── */}
|
||||
<div className={s.listWrap}>
|
||||
{/* List header with sort + pagination */}
|
||||
<div className={s.listHeader}>
|
||||
<button
|
||||
className={s.sortBtn}
|
||||
onClick={() => {
|
||||
updateSettings({ chapterSortDir: sortDir === "desc" ? "asc" : "desc" });
|
||||
setChapterPage(1);
|
||||
}}
|
||||
title={sortDir === "desc" ? "Newest first" : "Oldest first"}
|
||||
>
|
||||
{sortDir === "desc"
|
||||
? <SortDescending size={14} weight="light" />
|
||||
: <SortAscending size={14} weight="light" />
|
||||
}
|
||||
<span>{sortDir === "desc" ? "Newest first" : "Oldest first"}</span>
|
||||
</button>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className={s.pagination}>
|
||||
<button
|
||||
className={s.pageBtn}
|
||||
onClick={() => setChapterPage((p) => Math.max(1, p - 1))}
|
||||
disabled={chapterPage === 1}
|
||||
>←</button>
|
||||
<span className={s.pageNum}>{chapterPage} / {totalPages}</span>
|
||||
<button
|
||||
className={s.pageBtn}
|
||||
onClick={() => setChapterPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={chapterPage === totalPages}
|
||||
>→</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={s.list}>
|
||||
{loadingChapters && chapters.length === 0 ? (
|
||||
Array.from({ length: 8 }).map((_, i) => (
|
||||
<div key={i} className={s.rowSkeleton}>
|
||||
<div className={["skeleton", s.skLine].join(" ")} style={{ width: "55%", height: 12 }} />
|
||||
<div className={["skeleton", s.skLine].join(" ")} style={{ width: "25%", height: 11 }} />
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
pageChapters.map((ch) => {
|
||||
const idxInSorted = sortedChapters.indexOf(ch);
|
||||
return (
|
||||
<button
|
||||
key={ch.id}
|
||||
className={[s.row, ch.isRead ? s.rowRead : ""].join(" ").trim()}
|
||||
onClick={() => openReader(ch, sortedChapters)}
|
||||
onContextMenu={(e) => openContextMenu(e, ch, idxInSorted)}
|
||||
>
|
||||
<div className={s.chLeft}>
|
||||
<span className={s.chName}>{ch.name}</span>
|
||||
<div className={s.chMeta}>
|
||||
{ch.scanlator && <span className={s.chMetaItem}>{ch.scanlator}</span>}
|
||||
{ch.uploadDate && <span className={s.chMetaItem}>{formatDate(ch.uploadDate)}</span>}
|
||||
{ch.lastPageRead != null && ch.lastPageRead > 0 && !ch.isRead && (
|
||||
<span className={s.chMetaItem}>p.{ch.lastPageRead}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={s.chRight}>
|
||||
{ch.isBookmarked && (
|
||||
<BookmarkSimple size={12} weight="fill" className={s.bookmarkIcon} />
|
||||
)}
|
||||
{ch.isRead ? (
|
||||
<CheckCircle size={14} weight="light" className={s.readIcon} />
|
||||
) : ch.isDownloaded ? (
|
||||
<BookOpen size={14} weight="light" className={s.downloadedIcon} />
|
||||
) : enqueueing.has(ch.id) ? (
|
||||
<CircleNotch size={14} weight="light" className={[s.enqueuingIcon, "anim-spin"].join(" ")} />
|
||||
) : (
|
||||
<button className={s.dlBtn} onClick={(e) => enqueue(ch, e)} title="Download">
|
||||
<Download size={13} weight="light" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bottom pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className={s.paginationBottom}>
|
||||
<button
|
||||
className={s.pageBtn}
|
||||
onClick={() => setChapterPage((p) => Math.max(1, p - 1))}
|
||||
disabled={chapterPage === 1}
|
||||
>← Prev</button>
|
||||
<span className={s.pageNum}>{chapterPage} / {totalPages}</span>
|
||||
<button
|
||||
className={s.pageBtn}
|
||||
onClick={() => setChapterPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={chapterPage === totalPages}
|
||||
>Next →</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Context menu */}
|
||||
{ctx && (
|
||||
<ContextMenu
|
||||
x={ctx.x}
|
||||
y={ctx.y}
|
||||
items={buildCtxItems(ctx.chapter, ctx.indexInSorted)}
|
||||
onClose={() => setCtx(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
/* ─── Backdrop ── */
|
||||
.backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.72);
|
||||
z-index: var(--z-settings);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
animation: fadeIn 0.12s ease both;
|
||||
backdrop-filter: blur(4px);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
/* ─── Modal shell ── */
|
||||
.modal {
|
||||
width: min(720px, calc(100vw - 48px));
|
||||
height: min(520px, calc(100vh - 80px));
|
||||
display: flex;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-base);
|
||||
border-radius: var(--radius-xl);
|
||||
overflow: hidden;
|
||||
animation: scaleIn 0.16s ease both;
|
||||
box-shadow: 0 0 0 1px var(--border-dim), 0 24px 64px rgba(0,0,0,0.6), 0 8px 24px rgba(0,0,0,0.4);
|
||||
}
|
||||
|
||||
/* ─── Sidebar ── */
|
||||
.sidebar {
|
||||
width: 152px;
|
||||
flex-shrink: 0;
|
||||
background: var(--bg-raised);
|
||||
border-right: 1px solid var(--border-dim);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: var(--sp-5) var(--sp-3);
|
||||
gap: var(--sp-1);
|
||||
}
|
||||
|
||||
.modalTitle {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-2xs);
|
||||
color: var(--text-faint);
|
||||
letter-spacing: var(--tracking-wider);
|
||||
text-transform: uppercase;
|
||||
padding: 0 var(--sp-2) var(--sp-3);
|
||||
}
|
||||
|
||||
.nav { display: flex; flex-direction: column; gap: 1px; }
|
||||
|
||||
.navItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
padding: 7px var(--sp-2);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-muted);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
transition: background var(--t-fast), color var(--t-fast);
|
||||
}
|
||||
.navItem:hover { background: var(--bg-overlay); color: var(--text-secondary); }
|
||||
.navActive { background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim); }
|
||||
.navActive:hover { background: var(--accent-muted); color: var(--accent-fg); }
|
||||
|
||||
/* ─── Content ── */
|
||||
.content { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
|
||||
|
||||
.contentHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--sp-5) var(--sp-6) var(--sp-4);
|
||||
border-bottom: 1px solid var(--border-dim);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.contentTitle {
|
||||
font-size: var(--text-md);
|
||||
font-weight: var(--weight-medium);
|
||||
color: var(--text-secondary);
|
||||
letter-spacing: var(--tracking-tight);
|
||||
}
|
||||
|
||||
.closeBtn {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
width: 26px; height: 26px; border-radius: var(--radius-sm);
|
||||
color: var(--text-faint);
|
||||
transition: color var(--t-base), background var(--t-base);
|
||||
}
|
||||
.closeBtn:hover { color: var(--text-muted); background: var(--bg-raised); }
|
||||
|
||||
.contentBody { flex: 1; overflow-y: auto; padding: var(--sp-5) var(--sp-6); }
|
||||
|
||||
/* ─── Panel / Section ── */
|
||||
.panel { display: flex; flex-direction: column; gap: var(--sp-6); }
|
||||
.section { display: flex; flex-direction: column; gap: 1px; }
|
||||
|
||||
.sectionTitle {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-2xs);
|
||||
color: var(--text-faint);
|
||||
letter-spacing: var(--tracking-wider);
|
||||
text-transform: uppercase;
|
||||
margin-bottom: var(--sp-2);
|
||||
}
|
||||
|
||||
/* ─── Toggle ── */
|
||||
.toggleRow {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
gap: var(--sp-4); padding: 10px var(--sp-3); border-radius: var(--radius-md);
|
||||
cursor: pointer; transition: background var(--t-fast);
|
||||
}
|
||||
.toggleRow:hover { background: var(--bg-raised); }
|
||||
|
||||
.toggleInfo { display: flex; flex-direction: column; gap: 2px; flex: 1; min-width: 0; }
|
||||
.toggleLabel { font-size: var(--text-sm); color: var(--text-secondary); line-height: var(--leading-tight); }
|
||||
.toggleDesc { font-size: var(--text-xs); color: var(--text-muted); line-height: var(--leading-snug); }
|
||||
|
||||
.toggle {
|
||||
position: relative; width: 34px; height: 18px; border-radius: var(--radius-full);
|
||||
background: var(--bg-subtle); border: 1px solid var(--border-strong); flex-shrink: 0;
|
||||
cursor: pointer; transition: background var(--t-base), border-color var(--t-base);
|
||||
}
|
||||
.toggleOn { background: var(--accent-dim); border-color: var(--accent); }
|
||||
.toggleThumb {
|
||||
position: absolute; top: 2px; left: 2px; width: 12px; height: 12px;
|
||||
border-radius: 50%; background: var(--text-faint);
|
||||
transition: transform var(--t-base), background var(--t-base);
|
||||
}
|
||||
.toggleOn .toggleThumb { transform: translateX(16px); background: var(--accent-fg); }
|
||||
|
||||
/* ─── Stepper ── */
|
||||
.stepRow {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
gap: var(--sp-4); padding: 10px var(--sp-3); border-radius: var(--radius-md);
|
||||
transition: background var(--t-fast);
|
||||
}
|
||||
.stepRow:hover { background: var(--bg-raised); }
|
||||
|
||||
.stepControls { display: flex; align-items: center; gap: var(--sp-2); flex-shrink: 0; }
|
||||
|
||||
.stepBtn {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
width: 24px; height: 24px; border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border-strong); font-size: var(--text-base);
|
||||
color: var(--text-muted); transition: background var(--t-base), color var(--t-base);
|
||||
line-height: 1;
|
||||
}
|
||||
.stepBtn:hover:not(:disabled) { background: var(--bg-overlay); color: var(--text-primary); }
|
||||
.stepBtn:disabled { opacity: 0.25; cursor: default; }
|
||||
|
||||
.stepVal {
|
||||
font-family: var(--font-ui); font-size: var(--text-sm); color: var(--text-secondary);
|
||||
min-width: 28px; text-align: center; letter-spacing: var(--tracking-wide);
|
||||
}
|
||||
|
||||
/* ─── Select ── */
|
||||
.select {
|
||||
background: var(--bg-raised); border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-md); padding: 5px 10px; color: var(--text-secondary);
|
||||
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
|
||||
outline: none; cursor: pointer; flex-shrink: 0; transition: border-color var(--t-base);
|
||||
appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' fill='none'%3E%3Cpath d='M0 0l5 6 5-6' fill='%23888'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 8px center;
|
||||
padding-right: 24px;
|
||||
}
|
||||
.select:focus { border-color: var(--border-focus); }
|
||||
.select option { background: var(--bg-raised); color: var(--text-secondary); }
|
||||
|
||||
/* ─── Scale ── */
|
||||
.scaleRow {
|
||||
display: flex; align-items: center; gap: var(--sp-3);
|
||||
padding: 10px var(--sp-3); border-radius: var(--radius-md);
|
||||
}
|
||||
.scaleSlider { flex: 1; }
|
||||
.scaleVal {
|
||||
font-family: var(--font-ui); font-size: var(--text-sm); color: var(--text-secondary);
|
||||
min-width: 36px; text-align: right; letter-spacing: var(--tracking-wide);
|
||||
}
|
||||
.scaleHint {
|
||||
display: flex; flex-wrap: wrap; gap: var(--sp-1);
|
||||
padding: 0 var(--sp-3) var(--sp-2);
|
||||
}
|
||||
.scalePreset {
|
||||
font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide);
|
||||
padding: 3px 8px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim);
|
||||
background: none; color: var(--text-faint); cursor: pointer;
|
||||
transition: color var(--t-base), border-color var(--t-base), background var(--t-base);
|
||||
}
|
||||
.scalePreset:hover { color: var(--text-muted); border-color: var(--border-strong); }
|
||||
.scalePresetActive {
|
||||
background: var(--accent-muted); border-color: var(--accent-dim); color: var(--accent-fg);
|
||||
}
|
||||
|
||||
/* ─── Text input ── */
|
||||
.textInput {
|
||||
background: var(--bg-raised); border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-md); padding: 5px 10px; color: var(--text-secondary);
|
||||
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
|
||||
outline: none; flex-shrink: 0; width: 180px;
|
||||
transition: border-color var(--t-base);
|
||||
}
|
||||
.textInput:focus { border-color: var(--border-focus); }
|
||||
|
||||
/* ─── Keybinds ── */
|
||||
.kbHeader { display: flex; align-items: center; justify-content: space-between; margin-bottom: var(--sp-2); }
|
||||
.kbHint { font-size: var(--text-xs); color: var(--text-faint); padding: 0 var(--sp-3) var(--sp-3); }
|
||||
.resetAllBtn {
|
||||
font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint);
|
||||
letter-spacing: var(--tracking-wide); padding: 3px 8px; border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border-dim); background: none; cursor: pointer;
|
||||
transition: color var(--t-base), border-color var(--t-base);
|
||||
}
|
||||
.resetAllBtn:hover { color: var(--color-error); border-color: var(--color-error); }
|
||||
|
||||
.kbList { display: flex; flex-direction: column; gap: 1px; }
|
||||
|
||||
.kbRow {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
gap: var(--sp-4); padding: 8px var(--sp-3); border-radius: var(--radius-md);
|
||||
transition: background var(--t-fast);
|
||||
}
|
||||
.kbRow:hover { background: var(--bg-raised); }
|
||||
|
||||
.kbLabel { font-size: var(--text-sm); color: var(--text-secondary); flex: 1; }
|
||||
.kbRight { display: flex; align-items: center; gap: var(--sp-2); flex-shrink: 0; }
|
||||
|
||||
.kbBind {
|
||||
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
|
||||
padding: 4px 12px; border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border-strong); background: var(--bg-overlay);
|
||||
color: var(--text-secondary); cursor: pointer; min-width: 100px; text-align: center;
|
||||
transition: border-color var(--t-base), background var(--t-base), color var(--t-base);
|
||||
}
|
||||
.kbBind:hover { border-color: var(--accent); color: var(--accent-fg); }
|
||||
.kbBindListening {
|
||||
border-color: var(--accent); background: var(--accent-muted); color: var(--accent-fg);
|
||||
animation: pulse 1s ease infinite;
|
||||
}
|
||||
|
||||
.kbReset {
|
||||
font-size: var(--text-base); color: var(--text-faint); width: 22px; height: 22px;
|
||||
border-radius: var(--radius-sm); border: 1px solid transparent; background: none;
|
||||
cursor: pointer; display: flex; align-items: center; justify-content: center;
|
||||
transition: color var(--t-base), border-color var(--t-base);
|
||||
}
|
||||
.kbReset:hover:not(:disabled) { color: var(--text-muted); border-color: var(--border-dim); }
|
||||
.kbReset:disabled { opacity: 0.2; cursor: default; }
|
||||
|
||||
/* ─── About ── */
|
||||
.aboutBlock {
|
||||
padding: var(--sp-3); background: var(--bg-raised); border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border-dim);
|
||||
}
|
||||
.aboutLine { font-size: var(--text-sm); color: var(--text-secondary); line-height: var(--leading-base); }
|
||||
.dangerBtn {
|
||||
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
|
||||
padding: 5px 12px; border-radius: var(--radius-md);
|
||||
background: none; border: 1px solid var(--color-error);
|
||||
color: var(--color-error); cursor: pointer; flex-shrink: 0;
|
||||
transition: background var(--t-base);
|
||||
}
|
||||
.dangerBtn:hover:not(:disabled) { background: var(--color-error-bg); }
|
||||
.dangerBtn:disabled { opacity: 0.3; cursor: default; }
|
||||
@@ -0,0 +1,435 @@
|
||||
import { useEffect, useRef, useState, useCallback } from "react";
|
||||
import { X, Book, Image, Sliders, Info, Keyboard, Gear } from "@phosphor-icons/react";
|
||||
import { useStore } from "../../store";
|
||||
import { KEYBIND_LABELS, DEFAULT_KEYBINDS, eventToKeybind, type Keybinds } from "../../lib/keybinds";
|
||||
import type { Settings, FitMode } from "../../store";
|
||||
import s from "./Settings.module.css";
|
||||
|
||||
type Tab = "general" | "reader" | "library" | "performance" | "keybinds" | "about";
|
||||
|
||||
const TABS: { id: Tab; label: string; icon: React.ReactNode }[] = [
|
||||
{ id: "general", label: "General", icon: <Gear size={14} weight="light" /> },
|
||||
{ id: "reader", label: "Reader", icon: <Book size={14} weight="light" /> },
|
||||
{ id: "library", label: "Library", icon: <Image size={14} weight="light" /> },
|
||||
{ id: "performance", label: "Performance", icon: <Sliders size={14} weight="light" /> },
|
||||
{ id: "keybinds", label: "Keybinds", icon: <Keyboard size={14} weight="light" /> },
|
||||
{ id: "about", label: "About", icon: <Info size={14} weight="light" /> },
|
||||
];
|
||||
|
||||
// ── Primitives ────────────────────────────────────────────────────────────────
|
||||
|
||||
function Toggle({ checked, onChange, label, description }: {
|
||||
checked: boolean; onChange: (v: boolean) => void; label: string; description?: string;
|
||||
}) {
|
||||
return (
|
||||
<label className={s.toggleRow}>
|
||||
<div className={s.toggleInfo}>
|
||||
<span className={s.toggleLabel}>{label}</span>
|
||||
{description && <span className={s.toggleDesc}>{description}</span>}
|
||||
</div>
|
||||
<button role="switch" aria-checked={checked}
|
||||
className={[s.toggle, checked ? s.toggleOn : ""].join(" ")}
|
||||
onClick={() => onChange(!checked)}>
|
||||
<span className={s.toggleThumb} />
|
||||
</button>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function Stepper({ value, onChange, min, max, step = 1, label, description }: {
|
||||
value: number; onChange: (v: number) => void;
|
||||
min: number; max: number; step?: number; label: string; description?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={s.stepRow}>
|
||||
<div className={s.toggleInfo}>
|
||||
<span className={s.toggleLabel}>{label}</span>
|
||||
{description && <span className={s.toggleDesc}>{description}</span>}
|
||||
</div>
|
||||
<div className={s.stepControls}>
|
||||
<button className={s.stepBtn} onClick={() => onChange(Math.max(min, value - step))} disabled={value <= min}>−</button>
|
||||
<span className={s.stepVal}>{value}</span>
|
||||
<button className={s.stepBtn} onClick={() => onChange(Math.min(max, value + step))} disabled={value >= max}>+</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectRow({ value, options, onChange, label, description }: {
|
||||
value: string;
|
||||
options: { value: string; label: string }[];
|
||||
onChange: (v: string) => void;
|
||||
label: string;
|
||||
description?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={s.stepRow}>
|
||||
<div className={s.toggleInfo}>
|
||||
<span className={s.toggleLabel}>{label}</span>
|
||||
{description && <span className={s.toggleDesc}>{description}</span>}
|
||||
</div>
|
||||
<select className={s.select} value={value} onChange={(e) => onChange(e.target.value)}>
|
||||
{options.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TextRow({ value, onChange, label, description, placeholder }: {
|
||||
value: string; onChange: (v: string) => void;
|
||||
label: string; description?: string; placeholder?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={s.stepRow}>
|
||||
<div className={s.toggleInfo}>
|
||||
<span className={s.toggleLabel}>{label}</span>
|
||||
{description && <span className={s.toggleDesc}>{description}</span>}
|
||||
</div>
|
||||
<input className={s.textInput} value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder} spellCheck={false} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Tabs ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
function GeneralTab({ settings, update }: { settings: Settings; update: (p: Partial<Settings>) => void }) {
|
||||
return (
|
||||
<div className={s.panel}>
|
||||
<div className={s.section}>
|
||||
<p className={s.sectionTitle}>Interface Scale</p>
|
||||
<div className={s.scaleRow}>
|
||||
<input type="range" min={70} max={150} step={5}
|
||||
value={settings.uiScale}
|
||||
onChange={(e) => update({ uiScale: Number(e.target.value) })}
|
||||
className={s.scaleSlider} />
|
||||
<span className={s.scaleVal}>{settings.uiScale}%</span>
|
||||
<button className={s.stepBtn}
|
||||
onClick={() => update({ uiScale: 100 })}
|
||||
disabled={settings.uiScale === 100} title="Reset">↺</button>
|
||||
</div>
|
||||
<p className={s.scaleHint}>
|
||||
{[70, 80, 90, 100, 110, 125, 150].map((v) => (
|
||||
<button key={v}
|
||||
className={[s.scalePreset, settings.uiScale === v ? s.scalePresetActive : ""].join(" ")}
|
||||
onClick={() => update({ uiScale: v })}>{v}%</button>
|
||||
))}
|
||||
</p>
|
||||
</div>
|
||||
<div className={s.section}>
|
||||
<p className={s.sectionTitle}>Server</p>
|
||||
<TextRow label="Server URL" description="Base URL of your Suwayomi instance"
|
||||
value={settings.serverUrl ?? "http://localhost:4567"}
|
||||
onChange={(v) => update({ serverUrl: v })}
|
||||
placeholder="http://localhost:4567" />
|
||||
<TextRow label="Server binary" description="Path or command to launch tachidesk-server"
|
||||
value={settings.serverBinary}
|
||||
onChange={(v) => update({ serverBinary: v })}
|
||||
placeholder="tachidesk-server" />
|
||||
<Toggle label="Auto-start server"
|
||||
description="Launch tachidesk-server when Moku opens"
|
||||
checked={settings.autoStartServer}
|
||||
onChange={(v) => update({ autoStartServer: v })} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReaderTab({ settings, update }: { settings: Settings; update: (p: Partial<Settings>) => void }) {
|
||||
return (
|
||||
<div className={s.panel}>
|
||||
<div className={s.section}>
|
||||
<p className={s.sectionTitle}>Page Layout</p>
|
||||
<SelectRow label="Default layout"
|
||||
description="How chapters open by default"
|
||||
value={settings.pageStyle}
|
||||
options={[
|
||||
{ value: "single", label: "Single page" },
|
||||
{ value: "double", label: "Double page" },
|
||||
{ value: "longstrip", label: "Long strip" },
|
||||
]}
|
||||
onChange={(v) => update({ pageStyle: v as Settings["pageStyle"] })} />
|
||||
<SelectRow label="Reading direction"
|
||||
description="Left-to-right for most manga, right-to-left for Japanese"
|
||||
value={settings.readingDirection}
|
||||
options={[
|
||||
{ value: "ltr", label: "Left to right" },
|
||||
{ value: "rtl", label: "Right to left" },
|
||||
]}
|
||||
onChange={(v) => update({ readingDirection: v as Settings["readingDirection"] })} />
|
||||
<Toggle label="Offset double spreads"
|
||||
description="Shift double-page groups so spreads align correctly"
|
||||
checked={settings.offsetDoubleSpreads}
|
||||
onChange={(v) => update({ offsetDoubleSpreads: v })} />
|
||||
<Toggle label="Page gap"
|
||||
description="Add spacing between pages in double and longstrip modes"
|
||||
checked={settings.pageGap}
|
||||
onChange={(v) => update({ pageGap: v })} />
|
||||
</div>
|
||||
|
||||
<div className={s.section}>
|
||||
<p className={s.sectionTitle}>Fit & Zoom</p>
|
||||
<SelectRow label="Default fit mode"
|
||||
description="How pages are sized to fit the screen"
|
||||
value={settings.fitMode ?? "width"}
|
||||
options={[
|
||||
{ value: "width", label: "Fit width" },
|
||||
{ value: "height", label: "Fit height" },
|
||||
{ value: "screen", label: "Fit screen" },
|
||||
{ value: "original", label: "Original (1:1)" },
|
||||
]}
|
||||
onChange={(v) => update({ fitMode: v as FitMode })} />
|
||||
<div className={s.stepRow}>
|
||||
<div className={s.toggleInfo}>
|
||||
<span className={s.toggleLabel}>Max page width</span>
|
||||
<span className={s.toggleDesc}>Pixel cap for fit-width mode. Ctrl+scroll in reader to adjust live.</span>
|
||||
</div>
|
||||
<div className={s.stepControls}>
|
||||
<button className={s.stepBtn} onClick={() => update({ maxPageWidth: Math.max(200, (settings.maxPageWidth ?? 900) - 100) })}>−</button>
|
||||
<span className={s.stepVal}>{settings.maxPageWidth ?? 900}px</span>
|
||||
<button className={s.stepBtn} onClick={() => update({ maxPageWidth: Math.min(2400, (settings.maxPageWidth ?? 900) + 100) })}>+</button>
|
||||
</div>
|
||||
</div>
|
||||
<Toggle label="Optimize contrast"
|
||||
description="Use webkit-optimize-contrast rendering (sharper on low-DPI)"
|
||||
checked={settings.optimizeContrast}
|
||||
onChange={(v) => update({ optimizeContrast: v })} />
|
||||
</div>
|
||||
|
||||
<div className={s.section}>
|
||||
<p className={s.sectionTitle}>Behaviour</p>
|
||||
<Toggle label="Auto-mark chapters read"
|
||||
description="Mark a chapter as read when you reach the last page"
|
||||
checked={settings.autoMarkRead}
|
||||
onChange={(v) => update({ autoMarkRead: v })} />
|
||||
<Stepper label="Pages to preload"
|
||||
description="Images loaded ahead of the current page"
|
||||
value={settings.preloadPages} min={0} max={10}
|
||||
onChange={(v) => update({ preloadPages: v })} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LibraryTab({ settings, update }: { settings: Settings; update: (p: Partial<Settings>) => void }) {
|
||||
const clearHistory = useStore((s) => s.clearHistory);
|
||||
const historyLen = useStore((s) => s.history.length);
|
||||
return (
|
||||
<div className={s.panel}>
|
||||
<div className={s.section}>
|
||||
<p className={s.sectionTitle}>Display</p>
|
||||
<Toggle label="Crop cover images"
|
||||
description="Fill grid cells — may crop cover edges"
|
||||
checked={settings.libraryCropCovers}
|
||||
onChange={(v) => update({ libraryCropCovers: v })} />
|
||||
<Toggle label="Show NSFW sources"
|
||||
description="Display adult content sources in the sources list"
|
||||
checked={settings.showNsfw}
|
||||
onChange={(v) => update({ showNsfw: v })} />
|
||||
<Stepper label="Initial cards to display"
|
||||
description="Cards shown before 'Show more' appears"
|
||||
value={settings.libraryPageSize} min={12} max={200} step={12}
|
||||
onChange={(v) => update({ libraryPageSize: v })} />
|
||||
</div>
|
||||
<div className={s.section}>
|
||||
<p className={s.sectionTitle}>Chapters</p>
|
||||
<SelectRow label="Default sort direction"
|
||||
value={settings.chapterSortDir}
|
||||
options={[
|
||||
{ value: "desc", label: "Newest first" },
|
||||
{ value: "asc", label: "Oldest first" },
|
||||
]}
|
||||
onChange={(v) => update({ chapterSortDir: v as Settings["chapterSortDir"] })} />
|
||||
<Stepper label="Chapters per page"
|
||||
description="Chapter list pagination size"
|
||||
value={settings.chapterPageSize} min={10} max={100} step={5}
|
||||
onChange={(v) => update({ chapterPageSize: v })} />
|
||||
</div>
|
||||
<div className={s.section}>
|
||||
<p className={s.sectionTitle}>Extensions</p>
|
||||
<SelectRow label="Preferred language"
|
||||
description="Language variant shown first when an extension has multiple"
|
||||
value={settings.preferredExtensionLang ?? "en"}
|
||||
options={[
|
||||
{ value: "en", label: "English" },
|
||||
{ value: "es", label: "Spanish" },
|
||||
{ value: "fr", label: "French" },
|
||||
{ value: "de", label: "German" },
|
||||
{ value: "pt-br", label: "Portuguese (BR)" },
|
||||
{ value: "it", label: "Italian" },
|
||||
{ value: "ru", label: "Russian" },
|
||||
{ value: "ar", label: "Arabic" },
|
||||
{ value: "tr", label: "Turkish" },
|
||||
{ value: "zh", label: "Chinese (Simplified)" },
|
||||
{ value: "zh-hant", label: "Chinese (Traditional)" },
|
||||
{ value: "ko", label: "Korean" },
|
||||
{ value: "ja", label: "Japanese" },
|
||||
{ value: "id", label: "Indonesian" },
|
||||
{ value: "vi", label: "Vietnamese" },
|
||||
{ value: "th", label: "Thai" },
|
||||
{ value: "pl", label: "Polish" },
|
||||
{ value: "nl", label: "Dutch" },
|
||||
]}
|
||||
onChange={(v) => update({ preferredExtensionLang: v })} />
|
||||
</div>
|
||||
<div className={s.section}>
|
||||
<p className={s.sectionTitle}>History</p>
|
||||
<div className={s.stepRow}>
|
||||
<div className={s.toggleInfo}>
|
||||
<span className={s.toggleLabel}>Reading history</span>
|
||||
<span className={s.toggleDesc}>{historyLen} entries stored</span>
|
||||
</div>
|
||||
<button className={s.dangerBtn} onClick={clearHistory} disabled={historyLen === 0}>
|
||||
Clear history
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PerformanceTab({ settings, update }: { settings: Settings; update: (p: Partial<Settings>) => void }) {
|
||||
return (
|
||||
<div className={s.panel}>
|
||||
<div className={s.section}>
|
||||
<p className={s.sectionTitle}>Rendering</p>
|
||||
<Toggle label="GPU acceleration"
|
||||
description="Promote reader and library to compositor layers (recommended)"
|
||||
checked={settings.gpuAcceleration}
|
||||
onChange={(v) => update({ gpuAcceleration: v })} />
|
||||
</div>
|
||||
<div className={s.section}>
|
||||
<p className={s.sectionTitle}>Interface</p>
|
||||
<Toggle label="Compact sidebar"
|
||||
description="Reduce sidebar icon spacing"
|
||||
checked={settings.compactSidebar}
|
||||
onChange={(v) => update({ compactSidebar: v })} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function KeybindsTab({ settings, update, reset }: {
|
||||
settings: Settings; update: (p: Partial<Settings>) => void; reset: () => void;
|
||||
}) {
|
||||
const [listening, setListening] = useState<keyof Keybinds | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!listening) return;
|
||||
function onKey(e: KeyboardEvent) {
|
||||
e.preventDefault(); e.stopPropagation();
|
||||
const bind = eventToKeybind(e);
|
||||
if (!bind) return;
|
||||
update({ keybinds: { ...settings.keybinds, [listening!]: bind } });
|
||||
setListening(null);
|
||||
}
|
||||
window.addEventListener("keydown", onKey, true);
|
||||
return () => window.removeEventListener("keydown", onKey, true);
|
||||
}, [listening, settings.keybinds]);
|
||||
|
||||
return (
|
||||
<div className={s.panel}>
|
||||
<div className={s.section}>
|
||||
<div className={s.kbHeader}>
|
||||
<p className={s.sectionTitle}>Keyboard shortcuts</p>
|
||||
<button className={s.resetAllBtn} onClick={reset}>Reset all</button>
|
||||
</div>
|
||||
<p className={s.kbHint}>Click a key to rebind, then press the new combination.</p>
|
||||
<div className={s.kbList}>
|
||||
{(Object.keys(KEYBIND_LABELS) as (keyof Keybinds)[]).map((key) => {
|
||||
const isListening = listening === key;
|
||||
const isDefault = settings.keybinds[key] === DEFAULT_KEYBINDS[key];
|
||||
return (
|
||||
<div key={key} className={s.kbRow}>
|
||||
<span className={s.kbLabel}>{KEYBIND_LABELS[key]}</span>
|
||||
<div className={s.kbRight}>
|
||||
<button
|
||||
className={[s.kbBind, isListening ? s.kbBindListening : ""].join(" ")}
|
||||
onClick={() => setListening(isListening ? null : key)}>
|
||||
{isListening ? "Press key…" : settings.keybinds[key]}
|
||||
</button>
|
||||
<button className={s.kbReset}
|
||||
onClick={() => update({ keybinds: { ...settings.keybinds, [key]: DEFAULT_KEYBINDS[key] } })}
|
||||
disabled={isDefault} title="Reset">↺</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AboutTab() {
|
||||
return (
|
||||
<div className={s.panel}>
|
||||
<div className={s.section}>
|
||||
<p className={s.sectionTitle}>Moku</p>
|
||||
<div className={s.aboutBlock}>
|
||||
<p className={s.aboutLine}>A manga reader frontend for Suwayomi / Tachidesk.</p>
|
||||
<p className={s.aboutLine} style={{ color: "var(--text-faint)", marginTop: "var(--sp-2)" }}>
|
||||
Built with Tauri + React. Connects to tachidesk-server.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Modal ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function SettingsModal() {
|
||||
const [tab, setTab] = useState<Tab>("general");
|
||||
const closeSettings = useStore((s) => s.closeSettings);
|
||||
const settings = useStore((s) => s.settings);
|
||||
const updateSettings = useStore((s) => s.updateSettings);
|
||||
const resetKeybinds = useStore((s) => s.resetKeybinds);
|
||||
const backdropRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleBackdrop = useCallback(
|
||||
(e: React.MouseEvent) => { if (e.target === backdropRef.current) closeSettings(); },
|
||||
[closeSettings]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") closeSettings(); };
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [closeSettings]);
|
||||
|
||||
return (
|
||||
<div className={s.backdrop} ref={backdropRef} onClick={handleBackdrop}>
|
||||
<div className={s.modal} role="dialog" aria-label="Settings">
|
||||
<div className={s.sidebar}>
|
||||
<p className={s.modalTitle}>Settings</p>
|
||||
<nav className={s.nav}>
|
||||
{TABS.map((t) => (
|
||||
<button key={t.id}
|
||||
className={[s.navItem, tab === t.id ? s.navActive : ""].join(" ")}
|
||||
onClick={() => setTab(t.id)}>
|
||||
{t.icon}
|
||||
<span>{t.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
<div className={s.content}>
|
||||
<div className={s.contentHeader}>
|
||||
<p className={s.contentTitle}>{TABS.find((t) => t.id === tab)?.label}</p>
|
||||
<button className={s.closeBtn} onClick={closeSettings}><X size={15} weight="light" /></button>
|
||||
</div>
|
||||
<div className={s.contentBody}>
|
||||
{tab === "general" && <GeneralTab settings={settings} update={updateSettings} />}
|
||||
{tab === "reader" && <ReaderTab settings={settings} update={updateSettings} />}
|
||||
{tab === "library" && <LibraryTab settings={settings} update={updateSettings} />}
|
||||
{tab === "performance" && <PerformanceTab settings={settings} update={updateSettings} />}
|
||||
{tab === "keybinds" && <KeybindsTab settings={settings} update={updateSettings} reset={resetKeybinds} />}
|
||||
{tab === "about" && <AboutTab />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
.root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
animation: fadeIn 0.14s ease both;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
padding: var(--sp-4) var(--sp-6);
|
||||
border-bottom: 1px solid var(--border-dim);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.back {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
color: var(--text-muted);
|
||||
font-size: var(--text-xs);
|
||||
font-family: var(--font-ui);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
text-transform: uppercase;
|
||||
transition: color var(--t-base);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.back:hover { color: var(--text-secondary); }
|
||||
|
||||
.sourceName {
|
||||
font-size: var(--text-base);
|
||||
font-weight: var(--weight-medium);
|
||||
color: var(--text-secondary);
|
||||
letter-spacing: var(--tracking-tight);
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--sp-3);
|
||||
padding: var(--sp-3) var(--sp-6);
|
||||
border-bottom: 1px solid var(--border-dim);
|
||||
flex-shrink: 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tabs { display: flex; gap: 2px; }
|
||||
|
||||
.tab {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-xs);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
padding: 4px 10px;
|
||||
border-radius: var(--radius-md);
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
transition: background var(--t-base), color var(--t-base);
|
||||
}
|
||||
|
||||
.tab:hover { background: var(--bg-raised); color: var(--text-secondary); }
|
||||
.tabActive { background: var(--accent-muted); color: var(--accent-fg); }
|
||||
.tabActive:hover { background: var(--accent-muted); color: var(--accent-fg); }
|
||||
|
||||
.searchWrap {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.searchIcon {
|
||||
position: absolute;
|
||||
left: 9px;
|
||||
color: var(--text-faint);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.search {
|
||||
background: var(--bg-raised);
|
||||
border: 1px solid var(--border-dim);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 5px 10px 5px 26px;
|
||||
color: var(--text-primary);
|
||||
font-size: var(--text-sm);
|
||||
width: 200px;
|
||||
outline: none;
|
||||
transition: border-color var(--t-base);
|
||||
}
|
||||
|
||||
.search::placeholder { color: var(--text-faint); }
|
||||
.search:focus { border-color: var(--border-strong); }
|
||||
|
||||
/* ─── Responsive grid ─────────────────────────────────────────────────────── */
|
||||
/*
|
||||
Adapts to screen width:
|
||||
- narrow (< ~640px): 2 columns
|
||||
- default (~640-900px): auto-fill ~120px → 4–6 cols
|
||||
- wide (> ~900px): more columns, stays readable
|
||||
*/
|
||||
.grid, .loadingGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(clamp(100px, 14vw, 140px), 1fr));
|
||||
gap: var(--sp-4);
|
||||
padding: var(--sp-5) var(--sp-6);
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
align-content: start;
|
||||
/* GPU for smooth scroll */
|
||||
will-change: scroll-position;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
contain: layout style;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.card:hover .cover { filter: brightness(1.06); }
|
||||
.card:hover .title { color: var(--text-primary); }
|
||||
|
||||
.coverWrap {
|
||||
position: relative;
|
||||
aspect-ratio: 2 / 3;
|
||||
overflow: hidden;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-raised);
|
||||
border: 1px solid var(--border-dim);
|
||||
transform: translateZ(0);
|
||||
}
|
||||
|
||||
.cover {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: filter var(--t-base);
|
||||
will-change: filter;
|
||||
}
|
||||
|
||||
.inLibraryBadge {
|
||||
position: absolute;
|
||||
bottom: var(--sp-1);
|
||||
left: var(--sp-1);
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-2xs);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
text-transform: uppercase;
|
||||
background: var(--accent-muted);
|
||||
color: var(--accent-fg);
|
||||
border: 1px solid var(--accent-dim);
|
||||
padding: 2px 5px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.title {
|
||||
margin-top: var(--sp-2);
|
||||
font-size: var(--text-sm);
|
||||
/* Use secondary not muted - readable against dark bg */
|
||||
color: var(--text-secondary);
|
||||
line-height: var(--leading-snug);
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
transition: color var(--t-base);
|
||||
}
|
||||
|
||||
/* Skeleton */
|
||||
.cardSkeleton { padding: 0; }
|
||||
|
||||
.coverSkeleton {
|
||||
aspect-ratio: 2 / 3;
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.titleSkeleton {
|
||||
height: 11px;
|
||||
margin-top: var(--sp-2);
|
||||
width: 75%;
|
||||
}
|
||||
|
||||
/* Pagination */
|
||||
.pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--sp-4);
|
||||
padding: var(--sp-4);
|
||||
border-top: 1px solid var(--border-dim);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.pageBtn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-xs);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
color: var(--text-muted);
|
||||
border: 1px solid var(--border-dim);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 5px 12px;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
transition: color var(--t-base), border-color var(--t-base), background var(--t-base);
|
||||
}
|
||||
|
||||
.pageBtn:hover:not(:disabled) {
|
||||
color: var(--text-primary);
|
||||
border-color: var(--border-strong);
|
||||
background: var(--bg-raised);
|
||||
}
|
||||
|
||||
.pageBtn:disabled { opacity: 0.3; cursor: default; }
|
||||
|
||||
.pageNum {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-muted);
|
||||
letter-spacing: var(--tracking-wider);
|
||||
min-width: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 1;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-xs);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
import { ArrowLeft, MagnifyingGlass, ArrowLeft as Prev, ArrowRight as Next } from "@phosphor-icons/react";
|
||||
import { gql, thumbUrl } from "../../lib/client";
|
||||
import { FETCH_SOURCE_MANGA } from "../../lib/queries";
|
||||
import { useStore } from "../../store";
|
||||
import type { Manga } from "../../lib/types";
|
||||
import s from "./SourceBrowse.module.css";
|
||||
|
||||
type BrowseType = "POPULAR" | "LATEST" | "SEARCH";
|
||||
|
||||
export default function SourceBrowse() {
|
||||
const activeSource = useStore((state) => state.activeSource);
|
||||
const setActiveSource = useStore((state) => state.setActiveSource);
|
||||
const setActiveManga = useStore((state) => state.setActiveManga);
|
||||
const setNavPage = useStore((state) => state.setNavPage);
|
||||
|
||||
const [mangas, setMangas] = useState<Manga[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [page, setPage] = useState(1);
|
||||
const [hasNextPage, setHasNextPage] = useState(false);
|
||||
const [browseType, setBrowseType] = useState<BrowseType>("POPULAR");
|
||||
const [search, setSearch] = useState("");
|
||||
const [searchInput, setSearchInput] = useState("");
|
||||
const searchRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
async function fetch(type: BrowseType, p: number, q: string) {
|
||||
if (!activeSource) return;
|
||||
setLoading(true);
|
||||
setMangas([]);
|
||||
gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>(
|
||||
FETCH_SOURCE_MANGA,
|
||||
{ source: activeSource.id, type, page: p, query: q || null }
|
||||
)
|
||||
.then((d) => {
|
||||
setMangas(d.fetchSourceManga.mangas);
|
||||
setHasNextPage(d.fetchSourceManga.hasNextPage);
|
||||
})
|
||||
.catch(console.error)
|
||||
.finally(() => setLoading(false));
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetch(browseType, page, search);
|
||||
}, [activeSource?.id, browseType, page, search]);
|
||||
|
||||
function submitSearch() {
|
||||
const q = searchInput.trim();
|
||||
setSearch(q);
|
||||
setBrowseType("SEARCH");
|
||||
setPage(1);
|
||||
}
|
||||
|
||||
function setMode(mode: BrowseType) {
|
||||
if (mode === browseType) return;
|
||||
setBrowseType(mode);
|
||||
setSearch("");
|
||||
setSearchInput("");
|
||||
setPage(1);
|
||||
}
|
||||
|
||||
function openManga(m: Manga) {
|
||||
setActiveManga(m);
|
||||
setNavPage("library");
|
||||
}
|
||||
|
||||
if (!activeSource) return null;
|
||||
|
||||
return (
|
||||
<div className={s.root}>
|
||||
<div className={s.header}>
|
||||
<button className={s.back} onClick={() => setActiveSource(null)}>
|
||||
<ArrowLeft size={13} weight="light" />
|
||||
<span>Sources</span>
|
||||
</button>
|
||||
<span className={s.sourceName}>{activeSource.displayName}</span>
|
||||
</div>
|
||||
|
||||
<div className={s.toolbar}>
|
||||
<div className={s.tabs}>
|
||||
{(["POPULAR", "LATEST"] as BrowseType[]).map((mode) => (
|
||||
<button
|
||||
key={mode}
|
||||
onClick={() => setMode(mode)}
|
||||
className={[s.tab, browseType === mode && search === "" ? s.tabActive : ""].join(" ").trim()}
|
||||
>
|
||||
{mode.charAt(0) + mode.slice(1).toLowerCase()}
|
||||
</button>
|
||||
))}
|
||||
{search && (
|
||||
<button className={[s.tab, s.tabActive].join(" ")}>
|
||||
Search
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={s.searchWrap}>
|
||||
<MagnifyingGlass size={12} className={s.searchIcon} weight="light" />
|
||||
<input
|
||||
ref={searchRef}
|
||||
className={s.search}
|
||||
placeholder="Search source…"
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && submitSearch()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className={s.loadingGrid}>
|
||||
{Array.from({ length: 18 }).map((_, i) => (
|
||||
<div key={i} className={s.cardSkeleton}>
|
||||
<div className={["skeleton", s.coverSkeleton].join(" ")} />
|
||||
<div className={["skeleton", s.titleSkeleton].join(" ")} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : mangas.length === 0 ? (
|
||||
<div className={s.empty}>No results.</div>
|
||||
) : (
|
||||
<div className={s.grid}>
|
||||
{mangas.map((m) => (
|
||||
<button key={m.id} className={s.card} onClick={() => openManga(m)}>
|
||||
<div className={s.coverWrap}>
|
||||
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} className={s.cover} />
|
||||
{m.inLibrary && <span className={s.inLibraryBadge}>In Library</span>}
|
||||
</div>
|
||||
<p className={s.title}>{m.title}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && (page > 1 || hasNextPage) && (
|
||||
<div className={s.pagination}>
|
||||
<button
|
||||
className={s.pageBtn}
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={page === 1}
|
||||
>
|
||||
<Prev size={13} weight="light" />
|
||||
Prev
|
||||
</button>
|
||||
<span className={s.pageNum}>{page}</span>
|
||||
<button
|
||||
className={s.pageBtn}
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
disabled={!hasNextPage}
|
||||
>
|
||||
Next
|
||||
<Next size={13} weight="light" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
.root {
|
||||
padding: var(--sp-6);
|
||||
overflow-y: auto;
|
||||
height: 100%;
|
||||
animation: fadeIn 0.14s ease both;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: var(--sp-5);
|
||||
}
|
||||
|
||||
.heading {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--weight-normal);
|
||||
color: var(--text-faint);
|
||||
letter-spacing: var(--tracking-wider);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.searchWrap {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.searchIcon {
|
||||
position: absolute;
|
||||
left: 9px;
|
||||
color: var(--text-faint);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.search {
|
||||
background: var(--bg-raised);
|
||||
border: 1px solid var(--border-dim);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 5px 10px 5px 26px;
|
||||
color: var(--text-primary);
|
||||
font-size: var(--text-sm);
|
||||
width: 180px;
|
||||
outline: none;
|
||||
transition: border-color var(--t-base);
|
||||
}
|
||||
|
||||
.search::placeholder { color: var(--text-faint); }
|
||||
.search:focus { border-color: var(--border-strong); }
|
||||
|
||||
.langRow {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--sp-1);
|
||||
margin-bottom: var(--sp-4);
|
||||
}
|
||||
|
||||
.langBtn {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-2xs);
|
||||
letter-spacing: var(--tracking-wider);
|
||||
padding: 3px 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border-dim);
|
||||
background: none;
|
||||
color: var(--text-faint);
|
||||
cursor: pointer;
|
||||
transition: color var(--t-base), border-color var(--t-base), background var(--t-base);
|
||||
}
|
||||
|
||||
.langBtn:hover { color: var(--text-muted); border-color: var(--border-strong); }
|
||||
|
||||
.langBtnActive {
|
||||
background: var(--accent-muted);
|
||||
border-color: var(--accent-dim);
|
||||
color: var(--accent-fg);
|
||||
}
|
||||
|
||||
.langBtnActive:hover {
|
||||
background: var(--accent-muted);
|
||||
color: var(--accent-fg);
|
||||
}
|
||||
|
||||
.list { display: flex; flex-direction: column; gap: 1px; }
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
padding: 9px var(--sp-3);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid transparent;
|
||||
background: none;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
cursor: pointer;
|
||||
transition: background var(--t-fast), border-color var(--t-fast);
|
||||
}
|
||||
|
||||
.row:hover { background: var(--bg-raised); border-color: var(--border-dim); }
|
||||
|
||||
.icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: var(--radius-md);
|
||||
object-fit: cover;
|
||||
flex-shrink: 0;
|
||||
background: var(--bg-raised);
|
||||
}
|
||||
|
||||
.info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; }
|
||||
|
||||
.name {
|
||||
font-size: var(--text-base);
|
||||
font-weight: var(--weight-medium);
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.meta {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-2xs);
|
||||
color: var(--text-faint);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
}
|
||||
|
||||
.arrow {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-faint);
|
||||
flex-shrink: 0;
|
||||
opacity: 0;
|
||||
transition: opacity var(--t-base);
|
||||
}
|
||||
|
||||
.row:hover .arrow { opacity: 1; }
|
||||
|
||||
.empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 160px;
|
||||
color: var(--text-faint);
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-xs);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { MagnifyingGlass, CircleNotch } from "@phosphor-icons/react";
|
||||
import { gql, thumbUrl } from "../../lib/client";
|
||||
import { GET_SOURCES } from "../../lib/queries";
|
||||
import { useStore } from "../../store";
|
||||
import type { Source } from "../../lib/types";
|
||||
import s from "./SourceList.module.css";
|
||||
|
||||
export default function SourceList() {
|
||||
const [sources, setSources] = useState<Source[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [lang, setLang] = useState("all");
|
||||
const [search, setSearch] = useState("");
|
||||
const setActiveSource = useStore((state) => state.setActiveSource);
|
||||
|
||||
useEffect(() => {
|
||||
gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
|
||||
.then((d) => setSources(d.sources.nodes))
|
||||
.catch(console.error)
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const langs = ["all", ...Array.from(new Set(sources.map((s) => s.lang))).sort()];
|
||||
|
||||
const filtered = sources.filter((src) => {
|
||||
if (src.id === "0") return false; // hide local source
|
||||
const matchLang = lang === "all" || src.lang === lang;
|
||||
const matchSearch =
|
||||
src.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
src.displayName.toLowerCase().includes(search.toLowerCase());
|
||||
return matchLang && matchSearch;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={s.root}>
|
||||
<div className={s.header}>
|
||||
<h1 className={s.heading}>Sources</h1>
|
||||
<div className={s.searchWrap}>
|
||||
<MagnifyingGlass size={12} className={s.searchIcon} weight="light" />
|
||||
<input
|
||||
className={s.search}
|
||||
placeholder="Search"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={s.langRow}>
|
||||
{langs.map((l) => (
|
||||
<button
|
||||
key={l}
|
||||
onClick={() => setLang(l)}
|
||||
className={[s.langBtn, lang === l ? s.langBtnActive : ""].join(" ").trim()}
|
||||
>
|
||||
{l === "all" ? "All" : l.toUpperCase()}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className={s.empty}>
|
||||
<CircleNotch size={16} weight="light" className="anim-spin" style={{ color: "var(--text-faint)" }} />
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className={s.empty}>No sources found.</div>
|
||||
) : (
|
||||
<div className={s.list}>
|
||||
{filtered.map((src) => (
|
||||
<button
|
||||
key={src.id}
|
||||
className={s.row}
|
||||
onClick={() => setActiveSource(src)}
|
||||
>
|
||||
<img
|
||||
src={thumbUrl(src.iconUrl)}
|
||||
alt={src.name}
|
||||
className={s.icon}
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
|
||||
/>
|
||||
<div className={s.info}>
|
||||
<span className={s.name}>{src.displayName}</span>
|
||||
<span className={s.meta}>{src.lang.toUpperCase()}{src.isNsfw ? " · NSFW" : ""}</span>
|
||||
</div>
|
||||
<span className={s.arrow}>→</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
const SUWAYOMI = "http://127.0.0.1:4567";
|
||||
const GQL = `${SUWAYOMI}/api/graphql`;
|
||||
|
||||
export function thumbUrl(path: string): string {
|
||||
return `${SUWAYOMI}${path}`;
|
||||
}
|
||||
|
||||
interface GQLResponse<T> {
|
||||
data: T;
|
||||
errors?: { message: string }[];
|
||||
}
|
||||
|
||||
// Retry with exponential backoff — Suwayomi may not be ready on first load
|
||||
async function fetchWithRetry(url: string, init: RequestInit, retries = 8, delayMs = 500): Promise<Response> {
|
||||
for (let i = 0; i < retries; i++) {
|
||||
try {
|
||||
const res = await fetch(url, init);
|
||||
return res;
|
||||
} catch (e) {
|
||||
if (i === retries - 1) throw e;
|
||||
await new Promise((r) => setTimeout(r, delayMs * Math.pow(1.5, i)));
|
||||
}
|
||||
}
|
||||
throw new Error("unreachable");
|
||||
}
|
||||
|
||||
export async function gql<T>(
|
||||
query: string,
|
||||
variables?: Record<string, unknown>
|
||||
): Promise<T> {
|
||||
const res = await fetchWithRetry(GQL, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ query, variables }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Suwayomi HTTP ${res.status}`);
|
||||
}
|
||||
|
||||
const json: GQLResponse<T> = await res.json();
|
||||
|
||||
if (json.errors?.length) {
|
||||
throw new Error(json.errors[0].message);
|
||||
}
|
||||
|
||||
return json.data;
|
||||
}
|
||||