mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 09:19:56 -05:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7d3d76fa6d | |||
| fec0e5d3f6 | |||
| f866d4d0e9 | |||
| ac1c0520c5 | |||
| fff6bde8ad | |||
| c07fc90fc8 | |||
| 523fb40538 | |||
| fb82abaf21 | |||
| 0a4108218d | |||
| 7b61f85833 | |||
| cd2d79f80c | |||
| edf2af8618 |
@@ -1,10 +1,10 @@
|
|||||||
pkgname=moku
|
pkgname=moku
|
||||||
pkgver=0.1.0
|
pkgver=0.2.0
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc="Native Linux manga reader frontend for Suwayomi-Server"
|
pkgdesc="Native Linux manga reader frontend for Suwayomi-Server"
|
||||||
arch=('x86_64')
|
arch=('x86_64')
|
||||||
url="https://github.com/shozikan/Moku"
|
url="https://github.com/Youwes09/Moku"
|
||||||
license=('MIT')
|
license=('Apache 2.0')
|
||||||
depends=(
|
depends=(
|
||||||
'webkit2gtk-4.1'
|
'webkit2gtk-4.1'
|
||||||
'gtk3'
|
'gtk3'
|
||||||
@@ -18,15 +18,13 @@ makedepends=(
|
|||||||
'pnpm'
|
'pnpm'
|
||||||
)
|
)
|
||||||
source=(
|
source=(
|
||||||
"$pkgname-$pkgver.tar.gz::https://github.com/shozikan/Moku/archive/refs/tags/v$pkgver.tar.gz"
|
"$pkgname-$pkgver.tar.gz::https://github.com/Youwes09/Moku/archive/refs/tags/v$pkgver.tar.gz"
|
||||||
"suwayomi-server.jar::https://github.com/Suwayomi/Suwayomi-Server/releases/download/v2.1.1867/suwayomi-server-v2.1.1867.jar"
|
"suwayomi-server.jar::https://github.com/Suwayomi/Suwayomi-Server/releases/download/v2.1.1867/suwayomi-server-v2.1.1867.jar"
|
||||||
"jdk.tar.gz::https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21.0.3%2B9/OpenJDK21U-jre_x64_linux_hotspot_21.0.3_9.tar.gz"
|
"jdk.tar.gz::https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21.0.3%2B9/OpenJDK21U-jre_x64_linux_hotspot_21.0.3_9.tar.gz"
|
||||||
)
|
)
|
||||||
sha256sums=(
|
sha256sums=('dfd110ae4f11711ce979020ae65b08ab2d0bd51ecc1ba877ba1780ba037357a4'
|
||||||
'0019dfc4b32d63c1392aa264aed2253c1e0c2fb09216f8e2cc269bbfb8bb49b5'
|
'51e307c2581e4e1a002991ab3e3a77503c8b074c42695987a984a7382d0ac5af'
|
||||||
'51e307c2581e4e1a002991ab3e3a77503c8b074c42695987a984a7382d0ac5af'
|
'f1af100c4afca2035f446967323230150cfe5872b5a664d98c86963e5c066e0d')
|
||||||
'f1af100c4afca2035f446967323230150cfe5872b5a664d98c86963e5c066e0d'
|
|
||||||
)
|
|
||||||
|
|
||||||
prepare() {
|
prepare() {
|
||||||
cd "Moku-$pkgver"
|
cd "Moku-$pkgver"
|
||||||
|
|||||||
@@ -1,22 +1,89 @@
|
|||||||
Todo:
|
Todo:
|
||||||
1. Check all Keybind Toggles
|
3. Explore Manga Upscaler & Other Image Processing
|
||||||
2. Update ReadME with Comprehensive Feature List
|
4. Font Weird on Flatpak, Investigate and Fix
|
||||||
3. Explore Manga Upscaler
|
5. Investigate "egl:failed to create dri2 screen"
|
||||||
4. Add Zoom-Slider for Zoom in Manga Reader
|
|
||||||
|
|
||||||
|
|
||||||
Bugs:
|
Bugs:
|
||||||
2. Fix Auto-scroll between chapters & state when moving chapters (Implemented but not Fluidic)
|
|
||||||
3. Patch Chapters to Grid View
|
-
|
||||||
5. Fix Keybind Toggles
|
- Opening Explore first time loads nothing, going to different page then going back loads everything relatively instantly? (Explore Page Bug)
|
||||||
|
- Add Scroll Wheel Compatibility to Explore, etc (Explore Page Bug)
|
||||||
|
- Add Back after Search & Clear on Search
|
||||||
|
- Add as Package in Nix Flake & Check Later
|
||||||
|
- GenreDrill & GenreFilter pages do not populate completely.
|
||||||
|
- Fix Initial Async Loading (Shows Suwayomi Not Loaded Till Refresh)
|
||||||
|
- Fix Explore Polling into 115 Sources (It currently includes languages) also Super Laggy
|
||||||
|
- Allow to move back from a window and return to previous state, not completely reset (Whole UI Issue)
|
||||||
|
16. Contrast Adjustment Option in Settings for Users (UI FOCUSED)
|
||||||
|
|
||||||
|
|
||||||
|
- Fix Mangafire Main Dispatcher Issue
|
||||||
|
|
||||||
|
|
||||||
|
- Fix Zoom in Reader changing Pages (Zoom Out Threshold in Reader causes Break)
|
||||||
|
- Add Download Location Change, Moku-Migration, Flatpak & Normal Installation Directory Checks
|
||||||
|
|
||||||
|
- Clean up Migrate Model to be more initutive
|
||||||
|
|
||||||
Features:
|
Features:
|
||||||
1. Frecency based Manga Suggestions
|
- Add PDF Textbook Support
|
||||||
2. Proper Explore Tab
|
- Major revision to disable entire manga-subsection and use as
|
||||||
|
solely as a reader/document launcher.
|
||||||
|
- Multiple Tag Filters + Mor Tags, Types, Etc
|
||||||
|
- Consider what to do empty space in sidebar (Maybe Daily Reading Time Brainstorm)
|
||||||
|
- Properly Kill Tachidesk-Server
|
||||||
|
- Migration Features
|
||||||
|
- Multi-Page Long Screenshot
|
||||||
|
-
|
||||||
|
|
||||||
|
|
||||||
Big Revisions:
|
Big Revisions:
|
||||||
|
0. Expand into fully-fledged reader, with modular manga support
|
||||||
1. Anime & Novel Support
|
1. Anime & Novel Support
|
||||||
|
2. Tracker Support
|
||||||
|
3. Cloudflare Bypass Enable Support
|
||||||
|
4. macOS Support (feasible)
|
||||||
|
|
||||||
Test:
|
|
||||||
1. URL & Extension Additions
|
|
||||||
|
Testing:
|
||||||
|
6. Fix laggy renderer on single page (same cache as longscroll) & set default longstrip
|
||||||
|
5. Lock reader on valid chapters to avoid bugs, etc.
|
||||||
|
1. Cache issue when opening manga series detail first time (Loading screen addition) & Async Load
|
||||||
|
- Fix Download Cards (Series Detail Download UI) & Fix Download Range Expand
|
||||||
|
- Fix Grid View for Large Amounts of Chapter (Check Initial D) (Series Detail)
|
||||||
|
20. Expand History (Total Time Read, etc)
|
||||||
|
12. Delete all Downloads should also cancel all download queues
|
||||||
|
13. Cancel Download along with Queue & Download Timeout Feature
|
||||||
|
|
||||||
|
|
||||||
|
Completed:
|
||||||
|
8. Fix Polling on Download Manager (Instantanous Response)
|
||||||
|
19. Debounce Time on Reader to improve lag (Toggle Setting)
|
||||||
|
10. Download Manager Pause and Cancel All Not Working + Download Lag on Series Detail Side
|
||||||
|
17. Change Library Text change to "No manga saved to library, browse sources to add some."
|
||||||
|
9. Fix CSS issue on Sidebar (Weird Green Overlay on Button)
|
||||||
|
7. Fix Scaling (100 = 125% and so forth)
|
||||||
|
2. Expand Criteria on Series Detail (Tags, Summaries) Make more Compact
|
||||||
|
14. Right-Click should have (Remove Library & Delete All) + Make New Folder (Context Menu)
|
||||||
|
15. Explorer Right-Click New Context Menu with Add to Library, Add to Folder, etc
|
||||||
|
11. Reader & UI needs download and other Notifications
|
||||||
|
- Fix Mark all Above as Read to Mark all Below as Read (Should be visual based) also add Unread Option, Sidebar Category for mark all above as read and mark all below as unread. (Series Detail)
|
||||||
|
- Add Refresh Details on Series Details.
|
||||||
|
- Patch GenreDrill & Integrate into Explore Folder
|
||||||
|
18. Disable NSFW Extensions option in settings
|
||||||
|
- Filtering by Genre (Accessed by Clicking tags on Manga)
|
||||||
|
- Remove Series Detail Mark Read & Unread
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
Important Commands:
|
||||||
|
cd ~/Projects/Manga/Moku
|
||||||
|
pnpm build
|
||||||
|
tar -czf packaging/frontend-dist.tar.gz -C dist .
|
||||||
|
sha256sum packaging/frontend-dist.tar.gz | awk '{print $1}'
|
||||||
|
|
||||||
|
1. nix-shell -p "python311.withPackages(ps: [ ps.aiohttp ps.tomlkit ])" --run "python3 packaging/flatpak-cargo-generator.py src-tauri/Cargo.lock -o packaging/cargo-sources.json"
|
||||||
|
2. nix shell nixpkgs#appstream nixpkgs#flatpak-builder --command flatpak-builder --repo=repo --force-clean build-dir dev.moku.app.yml
|
||||||
|
3. flatpak build-bundle repo moku.flatpak dev.moku.app
|
||||||
@@ -118,7 +118,6 @@
|
|||||||
preBuild = ''
|
preBuild = ''
|
||||||
cp -r ${frontend} ../dist
|
cp -r ${frontend} ../dist
|
||||||
'';
|
'';
|
||||||
WEBKIT_DISABLE_COMPOSITING_MODE = "1";
|
|
||||||
};
|
};
|
||||||
|
|
||||||
cargoArtifacts = craneLib.buildDepsOnly commonArgs;
|
cargoArtifacts = craneLib.buildDepsOnly commonArgs;
|
||||||
@@ -133,7 +132,9 @@
|
|||||||
pkgs.gtk3
|
pkgs.gtk3
|
||||||
]}" \
|
]}" \
|
||||||
--prefix LD_LIBRARY_PATH : "${lib.makeLibraryPath runtimeLibs}" \
|
--prefix LD_LIBRARY_PATH : "${lib.makeLibraryPath runtimeLibs}" \
|
||||||
--prefix PATH : "${lib.makeBinPath [ pkgs.suwayomi-server ]}"
|
--prefix PATH : "${lib.makeBinPath [ pkgs.suwayomi-server ]}" \
|
||||||
|
--set GDK_BACKEND wayland \
|
||||||
|
--set WEBKIT_FORCE_SANDBOX 0
|
||||||
'';
|
'';
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -157,7 +158,6 @@
|
|||||||
xdg-utils
|
xdg-utils
|
||||||
];
|
];
|
||||||
shellHook = ''
|
shellHook = ''
|
||||||
export WEBKIT_DISABLE_COMPOSITING_MODE=1
|
|
||||||
export APPIMAGE_EXTRACT_AND_RUN=1
|
export APPIMAGE_EXTRACT_AND_RUN=1
|
||||||
export NO_STRIP=true
|
export NO_STRIP=true
|
||||||
export PKG_CONFIG_PATH="${pkgs.openssl.dev}/lib/pkgconfig''${PKG_CONFIG_PATH:+:$PKG_CONFIG_PATH}"
|
export PKG_CONFIG_PATH="${pkgs.openssl.dev}/lib/pkgconfig''${PKG_CONFIG_PATH:+:$PKG_CONFIG_PATH}"
|
||||||
|
|||||||
+68
-25
@@ -2,7 +2,7 @@ use std::path::PathBuf;
|
|||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
use nix::sys::statvfs::statvfs;
|
use nix::sys::statvfs::statvfs;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use tauri::Manager;
|
use tauri::{Manager, WindowEvent};
|
||||||
use tauri_plugin_shell::{ShellExt, process::CommandChild};
|
use tauri_plugin_shell::{ShellExt, process::CommandChild};
|
||||||
use walkdir::WalkDir;
|
use walkdir::WalkDir;
|
||||||
|
|
||||||
@@ -51,12 +51,9 @@ fn get_storage_info(downloads_path: String) -> Result<StorageInfo, String> {
|
|||||||
};
|
};
|
||||||
let vfs = statvfs(&stat_path).map_err(|e| e.to_string())?;
|
let vfs = statvfs(&stat_path).map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
// f_frsize is the fundamental block size used for block counts.
|
|
||||||
// f_bsize (block_size()) is just the preferred I/O size and must not be
|
|
||||||
// used with blocks()/blocks_free() — that gives wildly wrong numbers.
|
|
||||||
let frsize = vfs.fragment_size() as u64;
|
let frsize = vfs.fragment_size() as u64;
|
||||||
let total_bytes = vfs.blocks() * frsize;
|
let total_bytes = vfs.blocks() * frsize;
|
||||||
let free_bytes = vfs.blocks_available() * frsize;
|
let free_bytes = vfs.blocks_available() * frsize;
|
||||||
|
|
||||||
Ok(StorageInfo {
|
Ok(StorageInfo {
|
||||||
manga_bytes,
|
manga_bytes,
|
||||||
@@ -66,31 +63,77 @@ fn get_storage_info(downloads_path: String) -> Result<StorageInfo, String> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns the true OS-level scale factor for the main window.
|
||||||
|
/// This reads directly from the underlying winit window handle, bypassing
|
||||||
|
/// whatever value WebKitGTK happens to report to JS via window.devicePixelRatio.
|
||||||
|
/// This is the only reliable way to get the correct DPR in all launch
|
||||||
|
/// environments — tauri dev, nix run, flatpak, etc.
|
||||||
|
#[tauri::command]
|
||||||
|
fn get_scale_factor(window: tauri::Window) -> f64 {
|
||||||
|
window.scale_factor().unwrap_or(1.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn kill_tachidesk(app: &tauri::AppHandle) {
|
||||||
|
let state = app.state::<ServerState>();
|
||||||
|
let mut guard = state.0.lock().unwrap();
|
||||||
|
if let Some(child) = guard.take() {
|
||||||
|
let _ = child.kill();
|
||||||
|
println!("Killed tracked server child.");
|
||||||
|
}
|
||||||
|
let _ = std::process::Command::new("pkill")
|
||||||
|
.arg("-f")
|
||||||
|
.arg("tachidesk")
|
||||||
|
.status();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn spawn_server(binary: String, app: tauri::AppHandle) -> Result<(), String> {
|
||||||
|
let state = app.state::<ServerState>();
|
||||||
|
{
|
||||||
|
let guard = state.0.lock().unwrap();
|
||||||
|
if guard.is_some() {
|
||||||
|
println!("Server already running, skipping spawn.");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let shell = app.shell();
|
||||||
|
match shell.command(&binary).spawn() {
|
||||||
|
Ok((_rx, child)) => {
|
||||||
|
println!("Spawned server: {}", binary);
|
||||||
|
let mut guard = state.0.lock().unwrap();
|
||||||
|
*guard = Some(child);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("Failed to spawn {}: {}", binary, e);
|
||||||
|
Err(e.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn kill_server(app: tauri::AppHandle) -> Result<(), String> {
|
||||||
|
kill_tachidesk(&app);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
.plugin(tauri_plugin_shell::init())
|
.plugin(tauri_plugin_shell::init())
|
||||||
.manage(ServerState(Mutex::new(None)))
|
.manage(ServerState(Mutex::new(None)))
|
||||||
.invoke_handler(tauri::generate_handler![get_storage_info])
|
.invoke_handler(tauri::generate_handler![
|
||||||
.setup(|app| {
|
get_storage_info,
|
||||||
let shell = app.shell();
|
spawn_server,
|
||||||
let app_handle = app.handle().clone();
|
kill_server,
|
||||||
|
get_scale_factor,
|
||||||
let status = shell.command("tachidesk-server").spawn();
|
])
|
||||||
|
.setup(|_app| Ok(()))
|
||||||
match status {
|
.on_window_event(|window, event| {
|
||||||
Ok((_rx, child)) => {
|
if let WindowEvent::Destroyed = event {
|
||||||
println!("Tachidesk server process spawned successfully.");
|
kill_tachidesk(window.app_handle());
|
||||||
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!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running moku");
|
.expect("error while running moku");
|
||||||
|
|||||||
+156
-17
@@ -1,54 +1,193 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import { listen } from "@tauri-apps/api/event";
|
import { listen } from "@tauri-apps/api/event";
|
||||||
|
import { gql } from "./lib/client";
|
||||||
|
import { GET_DOWNLOAD_STATUS } from "./lib/queries";
|
||||||
import "./styles/global.css";
|
import "./styles/global.css";
|
||||||
import { useStore } from "./store";
|
import { useStore } from "./store";
|
||||||
import Layout from "./components/layout/Layout";
|
import Layout from "./components/layout/Layout";
|
||||||
import Reader from "./components/pages/Reader";
|
import Reader from "./components/pages/Reader";
|
||||||
import Settings from "./components/settings/Settings";
|
import Settings from "./components/settings/Settings";
|
||||||
|
import MangaPreview from "./components/explore/MangaPreview";
|
||||||
import TitleBar from "./components/layout/TitleBar";
|
import TitleBar from "./components/layout/TitleBar";
|
||||||
|
import Toaster from "./components/layout/Toaster";
|
||||||
|
import SplashScreen, { EXIT_MS as SPLASH_EXIT_MS } from "./components/layout/SplashScreen";
|
||||||
|
import type { DownloadStatus, DownloadQueueItem } from "./lib/types";
|
||||||
import s from "./App.module.css";
|
import s from "./App.module.css";
|
||||||
|
|
||||||
|
const MAX_ATTEMPTS = 30;
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const activeChapter = useStore((s) => s.activeChapter);
|
const activeChapter = useStore((s) => s.activeChapter);
|
||||||
const settingsOpen = useStore((s) => s.settingsOpen);
|
const settingsOpen = useStore((s) => s.settingsOpen);
|
||||||
const settings = useStore((s) => s.settings);
|
const settings = useStore((s) => s.settings);
|
||||||
const setActiveDownloads = useStore((s) => s.setActiveDownloads);
|
const setActiveDownloads = useStore((s) => s.setActiveDownloads);
|
||||||
|
const addToast = useStore((s) => s.addToast);
|
||||||
|
|
||||||
|
// serverProbeOk = server responded, but we wait for ring to finish before showing UI
|
||||||
|
const [serverProbeOk, setServerProbeOk] = useState(!settings.autoStartServer);
|
||||||
|
// appReady = ring filled + transition done, show main UI
|
||||||
|
const [appReady, setAppReady] = useState(!settings.autoStartServer);
|
||||||
|
const [failed, setFailed] = useState(false);
|
||||||
|
const [retryKey, setRetryKey] = useState(0);
|
||||||
|
const [idle, setIdle] = useState(false);
|
||||||
|
// dev tools: force show splash
|
||||||
|
const [devSplash, setDevSplash] = useState(false);
|
||||||
|
|
||||||
|
const prevQueueRef = useRef<DownloadQueueItem[]>([]);
|
||||||
|
const idleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
// expose devSplash trigger via window for settings
|
||||||
|
useEffect(() => {
|
||||||
|
(window as any).__mokuShowSplash = () => setDevSplash(true);
|
||||||
|
return () => { delete (window as any).__mokuShowSplash; };
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.documentElement.style.zoom = `${settings.uiScale}%`;
|
if (!appReady) return;
|
||||||
|
function resetIdle() {
|
||||||
|
setIdle(false);
|
||||||
|
if (idleTimerRef.current) clearTimeout(idleTimerRef.current);
|
||||||
|
const idleTimeoutMs = (settings.idleTimeoutMin ?? 5) * 60 * 1000;
|
||||||
|
if (idleTimeoutMs === 0) return;
|
||||||
|
idleTimerRef.current = setTimeout(() => setIdle(true), idleTimeoutMs);
|
||||||
|
}
|
||||||
|
const events = ["mousemove","mousedown","keydown","touchstart","wheel"];
|
||||||
|
events.forEach(e => window.addEventListener(e, resetIdle, { passive:true }));
|
||||||
|
resetIdle();
|
||||||
|
return () => {
|
||||||
|
events.forEach(e => window.removeEventListener(e, resetIdle));
|
||||||
|
if (idleTimerRef.current) clearTimeout(idleTimerRef.current);
|
||||||
|
};
|
||||||
|
}, [appReady, settings.idleTimeoutMin]);
|
||||||
|
|
||||||
|
function detectCompletions(prev: DownloadQueueItem[], next: DownloadQueueItem[]) {
|
||||||
|
for (const item of prev) {
|
||||||
|
if (item.state !== "DOWNLOADING") continue;
|
||||||
|
if (!next.some(q => q.chapter.id === item.chapter.id)) {
|
||||||
|
const manga = item.chapter.manga;
|
||||||
|
addToast({ kind:"success", title:"Chapter downloaded",
|
||||||
|
body: manga ? `${manga.title} — ${item.chapter.name}` : item.chapter.name,
|
||||||
|
duration: 4000 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyQueue(next: DownloadQueueItem[]) {
|
||||||
|
detectCompletions(prevQueueRef.current, next);
|
||||||
|
prevQueueRef.current = next;
|
||||||
|
setActiveDownloads(next.map(item => ({
|
||||||
|
chapterId: item.chapter.id, mangaId: item.chapter.mangaId, progress: item.progress,
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.documentElement.style.zoom = `${settings.uiScale * 1.5}%`;
|
||||||
}, [settings.uiScale]);
|
}, [settings.uiScale]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const prevent = (e: MouseEvent) => e.preventDefault();
|
const theme = settings.theme ?? "dark";
|
||||||
document.addEventListener("contextmenu", prevent);
|
document.documentElement.setAttribute("data-theme", theme);
|
||||||
return () => document.removeEventListener("contextmenu", prevent);
|
}, [settings.theme]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const p = (e: MouseEvent) => e.preventDefault();
|
||||||
|
document.addEventListener("contextmenu", p);
|
||||||
|
return () => document.removeEventListener("contextmenu", p);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!settings.autoStartServer) return;
|
if (!settings.autoStartServer) return;
|
||||||
invoke("spawn_server", { binary: settings.serverBinary }).catch((err) =>
|
invoke("spawn_server", { binary: settings.serverBinary }).catch(err =>
|
||||||
console.warn("Could not start server:", err)
|
console.warn("Could not start server:", err));
|
||||||
);
|
|
||||||
return () => { invoke("kill_server").catch(() => {}); };
|
return () => { invoke("kill_server").catch(() => {}); };
|
||||||
}, [settings.autoStartServer, settings.serverBinary]);
|
}, [settings.autoStartServer, settings.serverBinary]);
|
||||||
|
|
||||||
// Global Tauri download-progress listener — no polling, always current
|
// Poll until server responds
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
type DlPayload = { chapterId: number; mangaId: number; progress: number }[];
|
if (serverProbeOk) return;
|
||||||
const unsub = listen<DlPayload>("download-progress", (e) => {
|
let cancelled = false, tries = 0;
|
||||||
setActiveDownloads(e.payload);
|
async function probe() {
|
||||||
});
|
if (cancelled) return;
|
||||||
return () => { unsub.then((fn) => fn()); };
|
tries++;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${settings.serverUrl}/api/graphql`, {
|
||||||
|
method:"POST", headers:{"Content-Type":"application/json"},
|
||||||
|
body: JSON.stringify({ query:"{ __typename }" }),
|
||||||
|
signal: AbortSignal.timeout(2000),
|
||||||
|
});
|
||||||
|
if (res.ok && !cancelled) { setServerProbeOk(true); return; }
|
||||||
|
} catch {}
|
||||||
|
if (tries >= MAX_ATTEMPTS && !cancelled) { setFailed(true); return; }
|
||||||
|
if (!cancelled) setTimeout(probe, 800);
|
||||||
|
}
|
||||||
|
const t = setTimeout(probe, 800);
|
||||||
|
return () => { cancelled = true; clearTimeout(t); };
|
||||||
|
}, [serverProbeOk, settings.serverUrl, retryKey]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!appReady) return;
|
||||||
|
function poll() {
|
||||||
|
gql<{ downloadStatus: DownloadStatus }>(GET_DOWNLOAD_STATUS)
|
||||||
|
.then(d => applyQueue(d.downloadStatus.queue)).catch(console.error);
|
||||||
|
}
|
||||||
|
poll();
|
||||||
|
const id = setInterval(poll, 2000);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
}, [appReady]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
type P = { chapterId:number; mangaId:number; progress:number }[];
|
||||||
|
const unsub = listen<P>("download-progress", e => setActiveDownloads(e.payload));
|
||||||
|
return () => { unsub.then(fn => fn()); };
|
||||||
}, [setActiveDownloads]);
|
}, [setActiveDownloads]);
|
||||||
|
|
||||||
|
// Dev splash overlay — shows idle mode so you can dismiss with any interaction
|
||||||
|
if (devSplash) {
|
||||||
|
return (
|
||||||
|
<SplashScreen
|
||||||
|
mode="idle"
|
||||||
|
showFps
|
||||||
|
showCards={settings.splashCards ?? true}
|
||||||
|
onDismiss={() => { setTimeout(() => setDevSplash(false), SPLASH_EXIT_MS + 20); }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Loading splash — shown until ring fills + transition completes
|
||||||
|
if (!appReady) {
|
||||||
|
return (
|
||||||
|
<SplashScreen
|
||||||
|
mode="loading"
|
||||||
|
ringFull={serverProbeOk}
|
||||||
|
failed={failed}
|
||||||
|
showCards={settings.splashCards ?? true}
|
||||||
|
onReady={() => setAppReady(true)}
|
||||||
|
onRetry={() => {
|
||||||
|
setFailed(false);
|
||||||
|
setServerProbeOk(false);
|
||||||
|
setRetryKey(k => k+1);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={s.root}>
|
<div className={s.root}>
|
||||||
{!activeChapter && <TitleBar />}
|
{idle && !activeChapter && (
|
||||||
|
<SplashScreen
|
||||||
|
mode="idle"
|
||||||
|
showCards={settings.splashCards ?? true}
|
||||||
|
onDismiss={() => { setTimeout(() => setIdle(false), SPLASH_EXIT_MS + 20); }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{!activeChapter && <TitleBar/>}
|
||||||
<div className={s.content}>
|
<div className={s.content}>
|
||||||
{activeChapter ? <Reader /> : <Layout />}
|
{activeChapter ? <Reader/> : <Layout/>}
|
||||||
</div>
|
</div>
|
||||||
{settingsOpen && <Settings />}
|
{settingsOpen && <Settings/>}
|
||||||
|
<MangaPreview/>
|
||||||
|
<Toaster/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -34,9 +34,19 @@
|
|||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
transition: color var(--t-base), border-color var(--t-base), background var(--t-base);
|
transition: color var(--t-base), border-color var(--t-base), background var(--t-base);
|
||||||
}
|
}
|
||||||
|
.iconBtn:hover:not(:disabled) { color: var(--text-secondary); border-color: var(--border-strong); background: var(--bg-raised); }
|
||||||
.iconBtn:hover { color: var(--text-secondary); border-color: var(--border-strong); background: var(--bg-raised); }
|
|
||||||
.iconBtn:disabled { opacity: 0.3; cursor: default; }
|
.iconBtn:disabled { opacity: 0.3; cursor: default; }
|
||||||
|
/* Loading state — accent tint so it's visually distinct */
|
||||||
|
.iconBtnLoading {
|
||||||
|
border-color: var(--accent-dim);
|
||||||
|
color: var(--accent-fg);
|
||||||
|
background: var(--accent-muted);
|
||||||
|
}
|
||||||
|
.iconBtnLoading:hover:not(:disabled) {
|
||||||
|
border-color: var(--accent-dim);
|
||||||
|
color: var(--accent-fg);
|
||||||
|
background: var(--accent-muted);
|
||||||
|
}
|
||||||
|
|
||||||
.statusBar {
|
.statusBar {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -55,6 +65,7 @@
|
|||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: var(--text-faint);
|
background: var(--text-faint);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
transition: background var(--t-base);
|
||||||
}
|
}
|
||||||
|
|
||||||
.statusDotActive {
|
.statusDotActive {
|
||||||
@@ -68,6 +79,7 @@
|
|||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
flex: 1;
|
flex: 1;
|
||||||
letter-spacing: var(--tracking-wide);
|
letter-spacing: var(--tracking-wide);
|
||||||
|
transition: color var(--t-base);
|
||||||
}
|
}
|
||||||
|
|
||||||
.statusCount {
|
.statusCount {
|
||||||
@@ -87,11 +99,14 @@
|
|||||||
background: var(--bg-raised);
|
background: var(--bg-raised);
|
||||||
border: 1px solid var(--border-dim);
|
border: 1px solid var(--border-dim);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
transition: border-color var(--t-fast);
|
transition: border-color var(--t-fast), opacity var(--t-base);
|
||||||
}
|
}
|
||||||
|
|
||||||
.rowActive { border-color: var(--accent-dim); }
|
.rowActive { border-color: var(--accent-dim); }
|
||||||
|
|
||||||
|
/* Fade out rows being removed */
|
||||||
|
.rowRemoving { opacity: 0.4; pointer-events: none; }
|
||||||
|
|
||||||
/* Thumbnail */
|
/* Thumbnail */
|
||||||
.thumb {
|
.thumb {
|
||||||
width: 36px;
|
width: 36px;
|
||||||
@@ -185,8 +200,8 @@
|
|||||||
color: var(--text-faint);
|
color: var(--text-faint);
|
||||||
transition: color var(--t-base), background var(--t-base);
|
transition: color var(--t-base), background var(--t-base);
|
||||||
}
|
}
|
||||||
|
.removeBtn:hover:not(:disabled) { color: var(--color-error); background: var(--color-error-bg); }
|
||||||
.removeBtn:hover { color: var(--color-error); background: var(--color-error-bg); }
|
.removeBtn:disabled { opacity: 0.5; cursor: default; }
|
||||||
|
|
||||||
.empty {
|
.empty {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState, useCallback } from "react";
|
||||||
import { Play, Pause, Trash, CircleNotch, X } from "@phosphor-icons/react";
|
import { Play, Pause, Trash, CircleNotch, X } from "@phosphor-icons/react";
|
||||||
import { gql, thumbUrl } from "../../lib/client";
|
import { gql, thumbUrl } from "../../lib/client";
|
||||||
import {
|
import {
|
||||||
@@ -10,41 +10,100 @@ import type { DownloadStatus } from "../../lib/types";
|
|||||||
import s from "./DownloadQueue.module.css";
|
import s from "./DownloadQueue.module.css";
|
||||||
|
|
||||||
export default function DownloadQueue() {
|
export default function DownloadQueue() {
|
||||||
const [status, setStatus] = useState<DownloadStatus | null>(null);
|
const [status, setStatus] = useState<DownloadStatus | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const setActiveDownloads = useStore((s) => s.setActiveDownloads);
|
const [togglingPlay, setTogglingPlay] = useState(false);
|
||||||
|
const [clearing, setClearing] = useState(false);
|
||||||
|
const [dequeueing, setDequeueing] = useState<Set<number>>(new Set());
|
||||||
|
const setActiveDownloads = useStore((s) => s.setActiveDownloads);
|
||||||
|
|
||||||
|
// Apply status to local state + global store.
|
||||||
|
// Completion toasting is handled globally in App.tsx — no duplication here.
|
||||||
|
const applyStatus = useCallback((ds: DownloadStatus) => {
|
||||||
|
setStatus(ds);
|
||||||
|
setActiveDownloads(
|
||||||
|
ds.queue.map((item) => ({
|
||||||
|
chapterId: item.chapter.id,
|
||||||
|
mangaId: item.chapter.mangaId,
|
||||||
|
progress: item.progress,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}, [setActiveDownloads]);
|
||||||
|
|
||||||
async function poll() {
|
async function poll() {
|
||||||
gql<{ downloadStatus: DownloadStatus }>(GET_DOWNLOAD_STATUS)
|
gql<{ downloadStatus: DownloadStatus }>(GET_DOWNLOAD_STATUS)
|
||||||
.then((d) => {
|
.then((d) => applyStatus(d.downloadStatus))
|
||||||
setStatus(d.downloadStatus);
|
|
||||||
setActiveDownloads(
|
|
||||||
d.downloadStatus.queue.map((item) => ({
|
|
||||||
chapterId: item.chapter.id,
|
|
||||||
mangaId: item.chapter.mangaId,
|
|
||||||
progress: item.progress,
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.catch(console.error)
|
.catch(console.error)
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
poll();
|
poll();
|
||||||
const id = setInterval(poll, 1500);
|
const id = setInterval(poll, 2000);
|
||||||
return () => clearInterval(id);
|
return () => clearInterval(id);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
async function start() { await gql(START_DOWNLOADER).catch(console.error); poll(); }
|
// ── Actions ─────────────────────────────────────────────────────────────────
|
||||||
async function stop() { await gql(STOP_DOWNLOADER).catch(console.error); poll(); }
|
|
||||||
async function clear() { await gql(CLEAR_DOWNLOADER).catch(console.error); poll(); }
|
async function togglePlay() {
|
||||||
async function dequeue(chapterId: number) {
|
if (togglingPlay) return;
|
||||||
await gql(DEQUEUE_DOWNLOAD, { chapterId }).catch(console.error);
|
setTogglingPlay(true);
|
||||||
poll();
|
const wasRunning = status?.state === "STARTED";
|
||||||
|
setStatus((prev) => prev ? { ...prev, state: wasRunning ? "STOPPED" : "STARTED" } : prev);
|
||||||
|
try {
|
||||||
|
if (wasRunning) {
|
||||||
|
const d = await gql<{ stopDownloader: { downloadStatus: DownloadStatus } }>(STOP_DOWNLOADER);
|
||||||
|
applyStatus(d.stopDownloader.downloadStatus);
|
||||||
|
} else {
|
||||||
|
const d = await gql<{ startDownloader: { downloadStatus: DownloadStatus } }>(START_DOWNLOADER);
|
||||||
|
applyStatus(d.startDownloader.downloadStatus);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
poll();
|
||||||
|
} finally {
|
||||||
|
setTogglingPlay(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const queue = status?.queue ?? [];
|
async function clear() {
|
||||||
|
if (clearing) return;
|
||||||
|
setClearing(true);
|
||||||
|
setStatus((prev) => prev ? { ...prev, queue: [] } : prev);
|
||||||
|
setActiveDownloads([]);
|
||||||
|
try {
|
||||||
|
const d = await gql<{ clearDownloader: { downloadStatus: DownloadStatus } }>(CLEAR_DOWNLOADER);
|
||||||
|
applyStatus(d.clearDownloader.downloadStatus);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
poll();
|
||||||
|
} finally {
|
||||||
|
setClearing(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dequeue(chapterId: number) {
|
||||||
|
if (dequeueing.has(chapterId)) return;
|
||||||
|
setDequeueing((prev) => new Set(prev).add(chapterId));
|
||||||
|
setStatus((prev) =>
|
||||||
|
prev ? { ...prev, queue: prev.queue.filter((i) => i.chapter.id !== chapterId) } : prev
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
await gql(DEQUEUE_DOWNLOAD, { chapterId });
|
||||||
|
poll();
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
poll();
|
||||||
|
} finally {
|
||||||
|
setDequeueing((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.delete(chapterId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const queue = status?.queue ?? [];
|
||||||
const isRunning = status?.state === "STARTED";
|
const isRunning = status?.state === "STARTED";
|
||||||
|
|
||||||
function pagesDownloaded(progress: number, pageCount: number): number {
|
function pagesDownloaded(progress: number, pageCount: number): number {
|
||||||
@@ -56,24 +115,43 @@ export default function DownloadQueue() {
|
|||||||
<div className={s.header}>
|
<div className={s.header}>
|
||||||
<h1 className={s.heading}>Downloads</h1>
|
<h1 className={s.heading}>Downloads</h1>
|
||||||
<div className={s.headerActions}>
|
<div className={s.headerActions}>
|
||||||
{isRunning ? (
|
<button
|
||||||
<button className={s.iconBtn} onClick={stop} title="Pause">
|
className={[s.iconBtn, togglingPlay ? s.iconBtnLoading : ""].join(" ").trim()}
|
||||||
|
onClick={togglePlay}
|
||||||
|
disabled={togglingPlay || (queue.length === 0 && !isRunning)}
|
||||||
|
title={isRunning ? "Pause" : "Resume"}
|
||||||
|
>
|
||||||
|
{togglingPlay ? (
|
||||||
|
<CircleNotch size={14} weight="light" className="anim-spin" />
|
||||||
|
) : isRunning ? (
|
||||||
<Pause size={14} weight="fill" />
|
<Pause size={14} weight="fill" />
|
||||||
</button>
|
) : (
|
||||||
) : (
|
|
||||||
<button className={s.iconBtn} onClick={start} disabled={queue.length === 0} title="Resume">
|
|
||||||
<Play size={14} weight="fill" />
|
<Play size={14} weight="fill" />
|
||||||
</button>
|
)}
|
||||||
)}
|
</button>
|
||||||
<button className={s.iconBtn} onClick={clear} disabled={queue.length === 0} title="Clear queue">
|
|
||||||
<Trash size={14} weight="regular" />
|
<button
|
||||||
|
className={[s.iconBtn, clearing ? s.iconBtnLoading : ""].join(" ").trim()}
|
||||||
|
onClick={clear}
|
||||||
|
disabled={clearing || queue.length === 0}
|
||||||
|
title="Clear queue"
|
||||||
|
>
|
||||||
|
{clearing ? (
|
||||||
|
<CircleNotch size={14} weight="light" className="anim-spin" />
|
||||||
|
) : (
|
||||||
|
<Trash size={14} weight="regular" />
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={s.statusBar}>
|
<div className={s.statusBar}>
|
||||||
<div className={[s.statusDot, isRunning ? s.statusDotActive : ""].join(" ").trim()} />
|
<div className={[s.statusDot, isRunning ? s.statusDotActive : ""].join(" ").trim()} />
|
||||||
<span className={s.statusText}>{isRunning ? "Downloading" : "Paused"}</span>
|
<span className={s.statusText}>
|
||||||
|
{togglingPlay
|
||||||
|
? (isRunning ? "Pausing…" : "Starting…")
|
||||||
|
: isRunning ? "Downloading" : "Paused"}
|
||||||
|
</span>
|
||||||
<span className={s.statusCount}>{queue.length} queued</span>
|
<span className={s.statusCount}>{queue.length} queued</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -86,15 +164,16 @@ export default function DownloadQueue() {
|
|||||||
) : (
|
) : (
|
||||||
<div className={s.list}>
|
<div className={s.list}>
|
||||||
{queue.map((item, i) => {
|
{queue.map((item, i) => {
|
||||||
const isActive = i === 0 && isRunning;
|
const isActive = i === 0 && isRunning;
|
||||||
const pages = item.chapter.pageCount ?? 0;
|
const pages = item.chapter.pageCount ?? 0;
|
||||||
const done = pagesDownloaded(item.progress, pages);
|
const done = pagesDownloaded(item.progress, pages);
|
||||||
const manga = item.chapter.manga;
|
const manga = item.chapter.manga;
|
||||||
|
const isRemoving = dequeueing.has(item.chapter.id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={item.chapter.id}
|
key={item.chapter.id}
|
||||||
className={[s.row, isActive ? s.rowActive : ""].join(" ").trim()}
|
className={[s.row, isActive ? s.rowActive : "", isRemoving ? s.rowRemoving : ""].join(" ").trim()}
|
||||||
>
|
>
|
||||||
{manga?.thumbnailUrl && (
|
{manga?.thumbnailUrl && (
|
||||||
<div className={s.thumb}>
|
<div className={s.thumb}>
|
||||||
@@ -109,17 +188,13 @@ export default function DownloadQueue() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className={s.info}>
|
<div className={s.info}>
|
||||||
{manga?.title && (
|
{manga?.title && <span className={s.mangaTitle}>{manga.title}</span>}
|
||||||
<span className={s.mangaTitle}>{manga.title}</span>
|
|
||||||
)}
|
|
||||||
<span className={s.chapterName}>{item.chapter.name}</span>
|
<span className={s.chapterName}>{item.chapter.name}</span>
|
||||||
|
|
||||||
{pages > 0 && (
|
{pages > 0 && (
|
||||||
<span className={s.pagesLabel}>
|
<span className={s.pagesLabel}>
|
||||||
{isActive ? `${done} / ${pages} pages` : `${pages} pages`}
|
{isActive ? `${done} / ${pages} pages` : `${pages} pages`}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isActive && (
|
{isActive && (
|
||||||
<div className={s.progressWrap}>
|
<div className={s.progressWrap}>
|
||||||
<div
|
<div
|
||||||
@@ -136,9 +211,12 @@ export default function DownloadQueue() {
|
|||||||
<button
|
<button
|
||||||
className={s.removeBtn}
|
className={s.removeBtn}
|
||||||
onClick={() => dequeue(item.chapter.id)}
|
onClick={() => dequeue(item.chapter.id)}
|
||||||
|
disabled={isRemoving}
|
||||||
title="Remove from queue"
|
title="Remove from queue"
|
||||||
>
|
>
|
||||||
<X size={12} weight="light" />
|
{isRemoving
|
||||||
|
? <CircleNotch size={11} weight="light" className="anim-spin" />
|
||||||
|
: <X size={12} weight="light" />}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -385,3 +385,57 @@
|
|||||||
font-size: var(--text-xs);
|
font-size: var(--text-xs);
|
||||||
letter-spacing: var(--tracking-wide);
|
letter-spacing: var(--tracking-wide);
|
||||||
}
|
}
|
||||||
|
/* ── Explore More end-cap card ───────────────────────────────────────────── */
|
||||||
|
.exploreMoreCard {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 110px;
|
||||||
|
aspect-ratio: 2 / 3;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
border: 1px dashed var(--border-strong);
|
||||||
|
background: var(--bg-raised);
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: border-color var(--t-base), background var(--t-base);
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.exploreMoreCard:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: var(--accent-muted);
|
||||||
|
}
|
||||||
|
.exploreMoreCard:hover .exploreMoreIcon { color: var(--accent-fg); }
|
||||||
|
.exploreMoreCard:hover .exploreMoreLabel { color: var(--accent-fg); }
|
||||||
|
|
||||||
|
.exploreMoreInner {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
padding: var(--sp-3);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exploreMoreIcon {
|
||||||
|
color: var(--text-faint);
|
||||||
|
transition: color var(--t-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.exploreMoreLabel {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-2xs);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-faint);
|
||||||
|
transition: color var(--t-base);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exploreMoreGenre {
|
||||||
|
font-size: var(--text-2xs);
|
||||||
|
color: var(--text-faint);
|
||||||
|
opacity: 0.6;
|
||||||
|
text-align: center;
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
}
|
||||||
@@ -0,0 +1,535 @@
|
|||||||
|
import { useEffect, useState, useMemo, useRef, memo } from "react";
|
||||||
|
import { ArrowRight, Compass, List, BookOpen, Star, Fire, BookmarkSimple, FolderSimplePlus, Folder } from "@phosphor-icons/react";
|
||||||
|
import GenreDrillPage from "./GenreDrillPage";
|
||||||
|
import { gql, thumbUrl } from "../../lib/client";
|
||||||
|
import { UPDATE_MANGA } from "../../lib/queries";
|
||||||
|
import { cache, CACHE_KEYS, getTopSources } from "../../lib/cache";
|
||||||
|
import { dedupeSources, dedupeMangaByTitle } from "../../lib/sourceUtils";
|
||||||
|
import ContextMenu, { type ContextMenuEntry } from "../context/ContextMenu";
|
||||||
|
import { GET_ALL_MANGA, GET_LIBRARY, GET_SOURCES, FETCH_SOURCE_MANGA } from "../../lib/queries";
|
||||||
|
import { useStore } from "../../store";
|
||||||
|
import type { Manga, Source } from "../../lib/types";
|
||||||
|
import SourceList from "../sources/SourceList";
|
||||||
|
import SourceBrowse from "../sources/SourceBrowse";
|
||||||
|
import s from "./Explore.module.css";
|
||||||
|
|
||||||
|
// ── Frecency score ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function frecencyScore(readAt: number, count: number): number {
|
||||||
|
const hoursSince = (Date.now() - readAt) / 3_600_000;
|
||||||
|
return count / Math.log(hoursSince + 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Ghost / Skeleton ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function GhostCard() { return <div className={s.ghostCard} aria-hidden />; }
|
||||||
|
const GHOST_COUNT = 3;
|
||||||
|
const ROW_CAP = 25;
|
||||||
|
|
||||||
|
// Hijack vertical wheel delta → horizontal scroll on .row divs
|
||||||
|
function handleRowWheel(e: React.WheelEvent<HTMLDivElement>) {
|
||||||
|
if (Math.abs(e.deltaY) <= Math.abs(e.deltaX)) return;
|
||||||
|
const el = e.currentTarget;
|
||||||
|
const canScrollLeft = el.scrollLeft > 0;
|
||||||
|
const canScrollRight = el.scrollLeft < el.scrollWidth - el.clientWidth - 1;
|
||||||
|
if (!canScrollLeft && !canScrollRight) return;
|
||||||
|
e.stopPropagation();
|
||||||
|
el.scrollLeft += e.deltaY;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SkeletonRow({ count = 8 }: { count?: number }) {
|
||||||
|
return (
|
||||||
|
<div className={s.skeletonRow}>
|
||||||
|
{Array.from({ length: count }).map((_, i) => (
|
||||||
|
<div key={i} className={s.cardSkeleton}>
|
||||||
|
<div className={["skeleton", s.coverSkeleton].join(" ")} />
|
||||||
|
<div className={["skeleton", s.titleSkeleton].join(" ")} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Cover image with fade-in ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const CoverImg = memo(function CoverImg({ src, alt, className }: { src: string; alt: string; className?: string }) {
|
||||||
|
const [loaded, setLoaded] = useState(false);
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={src} alt={alt} className={className}
|
||||||
|
loading="lazy" decoding="async"
|
||||||
|
onLoad={() => setLoaded(true)}
|
||||||
|
style={{ opacity: loaded ? 1 : 0, transition: loaded ? "opacity 0.15s ease" : "none" }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Mini card ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const MiniCard = memo(function MiniCard({
|
||||||
|
manga, onClick, onContextMenu, subtitle, progress,
|
||||||
|
}: {
|
||||||
|
manga: Manga;
|
||||||
|
onClick: () => void;
|
||||||
|
onContextMenu?: (e: React.MouseEvent) => void;
|
||||||
|
subtitle?: string;
|
||||||
|
progress?: number;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button className={s.card} onClick={onClick} onContextMenu={onContextMenu}>
|
||||||
|
<div className={s.coverWrap}>
|
||||||
|
<CoverImg src={thumbUrl(manga.thumbnailUrl)} alt={manga.title} className={s.cover} />
|
||||||
|
{manga.inLibrary && <span className={s.inLibraryBadge}>Saved</span>}
|
||||||
|
{progress !== undefined && progress > 0 && (
|
||||||
|
<div className={s.progressBar}>
|
||||||
|
<div className={s.progressFill} style={{ width: `${progress * 100}%` }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className={s.title}>{manga.title}</p>
|
||||||
|
{subtitle && <p className={s.subtitle}>{subtitle}</p>}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Explore More end-cap ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const ExploreMoreCard = memo(function ExploreMoreCard({
|
||||||
|
genre, onClick,
|
||||||
|
}: { genre: string; onClick: () => void }) {
|
||||||
|
return (
|
||||||
|
<button className={s.exploreMoreCard} onClick={onClick} title={`See all ${genre} manga`}>
|
||||||
|
<div className={s.exploreMoreInner}>
|
||||||
|
<ArrowRight size={20} weight="light" className={s.exploreMoreIcon} />
|
||||||
|
<span className={s.exploreMoreLabel}>Explore more</span>
|
||||||
|
<span className={s.exploreMoreGenre}>{genre}</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Section ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function Section({
|
||||||
|
title, icon, onSeeAll, loading, children,
|
||||||
|
}: {
|
||||||
|
title: string; icon?: React.ReactNode; onSeeAll?: () => void;
|
||||||
|
loading?: boolean; children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className={s.section}>
|
||||||
|
<div className={s.sectionHeader}>
|
||||||
|
<span className={s.sectionTitle}>
|
||||||
|
<span className={s.sectionTitleIcon}>{icon}{title}</span>
|
||||||
|
</span>
|
||||||
|
{onSeeAll && (
|
||||||
|
<button className={s.seeAll} onClick={onSeeAll}>
|
||||||
|
See all <ArrowRight size={11} weight="light" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{loading ? <SkeletonRow /> : children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main component ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type ExploreMode = "explore" | "sources";
|
||||||
|
|
||||||
|
export default function Explore() {
|
||||||
|
const [mode, setMode] = useState<ExploreMode>("explore");
|
||||||
|
const activeSource = useStore((s) => s.activeSource);
|
||||||
|
const genreFilter = useStore((s) => s.genreFilter);
|
||||||
|
|
||||||
|
if (activeSource) return <SourceBrowse />;
|
||||||
|
if (genreFilter) return <GenreDrillPage />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={s.root}>
|
||||||
|
<div className={s.header}>
|
||||||
|
<div className={s.headerLeft}>
|
||||||
|
<h1 className={s.heading}>Explore</h1>
|
||||||
|
<div className={s.tabs}>
|
||||||
|
<button
|
||||||
|
className={[s.tab, mode === "explore" ? s.tabActive : ""].join(" ").trim()}
|
||||||
|
onClick={() => setMode("explore")}
|
||||||
|
>
|
||||||
|
<Compass size={11} weight="bold" /> Explore
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={[s.tab, mode === "sources" ? s.tabActive : ""].join(" ").trim()}
|
||||||
|
onClick={() => setMode("sources")}
|
||||||
|
>
|
||||||
|
<List size={11} weight="bold" /> Sources
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Keep ExploreFeed always mounted so data survives tab switches */}
|
||||||
|
<div style={{ display: mode === "explore" ? "contents" : "none" }}><ExploreFeed /></div>
|
||||||
|
{mode === "sources" && <SourceList />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Explore feed ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const FOUNDATIONAL_GENRES = ["Action", "Romance", "Fantasy", "Adventure", "Comedy", "Drama"];
|
||||||
|
|
||||||
|
function ExploreFeed() {
|
||||||
|
const [allManga, setAllManga] = useState<Manga[]>([]);
|
||||||
|
const [loadingLib, setLoadingLib] = useState(true);
|
||||||
|
const [popularManga, setPopularManga] = useState<Manga[]>([]);
|
||||||
|
const [loadingPopular, setLoadingPopular] = useState(true);
|
||||||
|
const [genreResults, setGenreResults] = useState<Map<string, Manga[]>>(new Map());
|
||||||
|
const [loadingGenres, setLoadingGenres] = useState(false);
|
||||||
|
const [sources, setSources] = useState<Source[]>([]);
|
||||||
|
const [loadError, setLoadError] = useState(false);
|
||||||
|
const [retryCount, setRetryCount] = useState(0);
|
||||||
|
const abortRef = useRef<AbortController | null>(null);
|
||||||
|
const fetchedGenresRef = useRef<string>("");
|
||||||
|
|
||||||
|
const history = useStore((s) => s.history);
|
||||||
|
const settings = useStore((s) => s.settings);
|
||||||
|
const setPreviewManga = useStore((s) => s.setPreviewManga);
|
||||||
|
const setGenreFilter = useStore((s) => s.setGenreFilter);
|
||||||
|
const folders = useStore((s) => s.settings.folders);
|
||||||
|
const addFolder = useStore((s) => s.addFolder);
|
||||||
|
const assignMangaToFolder = useStore((s) => s.assignMangaToFolder);
|
||||||
|
const [ctx, setCtx] = useState<{ x: number; y: number; manga: Manga } | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => { abortRef.current?.abort(); };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function openCtx(e: React.MouseEvent, m: Manga) {
|
||||||
|
e.preventDefault(); e.stopPropagation();
|
||||||
|
setCtx({ x: e.clientX, y: e.clientY, manga: m });
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCtxItems(m: Manga): ContextMenuEntry[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: m.inLibrary ? "In Library" : "Add to library",
|
||||||
|
icon: <BookmarkSimple size={13} weight={m.inLibrary ? "fill" : "light"} />,
|
||||||
|
disabled: m.inLibrary,
|
||||||
|
onClick: () => gql(UPDATE_MANGA, { id: m.id, inLibrary: true })
|
||||||
|
.then(() => { cache.clear(CACHE_KEYS.LIBRARY); })
|
||||||
|
.catch(console.error),
|
||||||
|
},
|
||||||
|
...(folders.length > 0 ? [
|
||||||
|
{ separator: true } as ContextMenuEntry,
|
||||||
|
...folders.map((f): ContextMenuEntry => ({
|
||||||
|
label: f.mangaIds.includes(m.id) ? `✓ ${f.name}` : f.name,
|
||||||
|
icon: <Folder size={13} weight={f.mangaIds.includes(m.id) ? "fill" : "light"} />,
|
||||||
|
onClick: () => assignMangaToFolder(f.id, m.id),
|
||||||
|
})),
|
||||||
|
] : []),
|
||||||
|
{ separator: true },
|
||||||
|
{
|
||||||
|
label: "New folder & add",
|
||||||
|
icon: <FolderSimplePlus size={13} weight="light" />,
|
||||||
|
onClick: () => {
|
||||||
|
const name = prompt("Folder name:");
|
||||||
|
if (name?.trim()) { const id = addFolder(name.trim()); assignMangaToFolder(id, m.id); }
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Library + sources load (retries when suwayomi wasn't ready) ─────────────
|
||||||
|
useEffect(() => {
|
||||||
|
// If we already have data, no need to re-fetch (cache hit path)
|
||||||
|
const alreadyLoaded = allManga.length > 0 && sources.length > 0;
|
||||||
|
if (alreadyLoaded) return;
|
||||||
|
|
||||||
|
setLoadingLib(true);
|
||||||
|
setLoadingPopular(true);
|
||||||
|
setLoadError(false);
|
||||||
|
|
||||||
|
const preferredLang = settings.preferredExtensionLang || "en";
|
||||||
|
|
||||||
|
// Clear stale failed cache entries so we actually retry
|
||||||
|
if (retryCount > 0) {
|
||||||
|
cache.clear(CACHE_KEYS.LIBRARY);
|
||||||
|
cache.clear(CACHE_KEYS.SOURCES);
|
||||||
|
fetchedGenresRef.current = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Library — fire immediately, independent of sources
|
||||||
|
cache.get(CACHE_KEYS.LIBRARY, () =>
|
||||||
|
Promise.all([
|
||||||
|
gql<{ mangas: { nodes: Manga[] } }>(GET_ALL_MANGA),
|
||||||
|
gql<{ mangas: { nodes: Manga[] } }>(GET_LIBRARY),
|
||||||
|
]).then(([all, lib]) => {
|
||||||
|
const libMap = new Map(lib.mangas.nodes.map((m) => [m.id, m]));
|
||||||
|
return all.mangas.nodes.map((m) => libMap.get(m.id) ?? m);
|
||||||
|
})
|
||||||
|
).then(setAllManga)
|
||||||
|
.catch((e) => { console.error(e); setLoadError(true); })
|
||||||
|
.finally(() => setLoadingLib(false));
|
||||||
|
|
||||||
|
// Sources — then kick off popular AND genres simultaneously
|
||||||
|
cache.get(CACHE_KEYS.SOURCES, () =>
|
||||||
|
gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
|
||||||
|
.then((d) => dedupeSources(d.sources.nodes, preferredLang))
|
||||||
|
).then((allSources) => {
|
||||||
|
if (allSources.length === 0) { setLoadingPopular(false); setLoadError(true); return; }
|
||||||
|
|
||||||
|
// Cap to 2 sources for the explore feed — halves the network calls
|
||||||
|
const topSources = getTopSources(allSources).slice(0, 2);
|
||||||
|
setSources(allSources);
|
||||||
|
|
||||||
|
// ── Popular — don't block genres ──────────────────────────────────
|
||||||
|
cache.get(CACHE_KEYS.POPULAR, () =>
|
||||||
|
Promise.allSettled(
|
||||||
|
topSources.map((src) =>
|
||||||
|
gql<{ fetchSourceManga: { mangas: Manga[] } }>(FETCH_SOURCE_MANGA, {
|
||||||
|
source: src.id, type: "POPULAR", page: 1, query: null,
|
||||||
|
}).then((d) => d.fetchSourceManga.mangas)
|
||||||
|
)
|
||||||
|
).then((results) => {
|
||||||
|
const merged: Manga[] = [];
|
||||||
|
for (const r of results)
|
||||||
|
if (r.status === "fulfilled") merged.push(...r.value);
|
||||||
|
return dedupeMangaByTitle(merged).slice(0, 30);
|
||||||
|
})
|
||||||
|
).then(setPopularManga).catch(console.error).finally(() => setLoadingPopular(false));
|
||||||
|
|
||||||
|
// ── Genres — start immediately alongside popular using foundational
|
||||||
|
// genres as a starting point; personalized genres replace these once
|
||||||
|
// library loads. Results stream in as each genre resolves.
|
||||||
|
const genresToFetch = FOUNDATIONAL_GENRES.slice(0, 3);
|
||||||
|
const genreKey = genresToFetch.join(",");
|
||||||
|
if (fetchedGenresRef.current === genreKey) return;
|
||||||
|
fetchedGenresRef.current = genreKey;
|
||||||
|
|
||||||
|
setLoadingGenres(true);
|
||||||
|
abortRef.current?.abort();
|
||||||
|
const ctrl = new AbortController();
|
||||||
|
abortRef.current = ctrl;
|
||||||
|
|
||||||
|
const streamingMap = new Map<string, Manga[]>();
|
||||||
|
Promise.allSettled(
|
||||||
|
genresToFetch.map((genre) =>
|
||||||
|
cache.get(CACHE_KEYS.GENRE(genre), () =>
|
||||||
|
Promise.allSettled(
|
||||||
|
topSources.map((src) =>
|
||||||
|
gql<{ fetchSourceManga: { mangas: Manga[] } }>(FETCH_SOURCE_MANGA, {
|
||||||
|
source: src.id, type: "SEARCH", page: 1, query: genre,
|
||||||
|
}, ctrl.signal).then((d) => d.fetchSourceManga.mangas)
|
||||||
|
)
|
||||||
|
).then((results) => {
|
||||||
|
const merged: Manga[] = [];
|
||||||
|
for (const r of results)
|
||||||
|
if (r.status === "fulfilled") merged.push(...r.value);
|
||||||
|
return dedupeMangaByTitle(merged).slice(0, 24);
|
||||||
|
})
|
||||||
|
).then((mangas) => {
|
||||||
|
if (ctrl.signal.aborted) return;
|
||||||
|
// Stream: each genre paints immediately as it resolves
|
||||||
|
streamingMap.set(genre, mangas);
|
||||||
|
setGenreResults(new Map(streamingMap));
|
||||||
|
})
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.catch((e) => { if (e?.name !== "AbortError") console.error(e); })
|
||||||
|
.finally(() => { if (!ctrl.signal.aborted) setLoadingGenres(false); });
|
||||||
|
})
|
||||||
|
.catch((e) => { console.error(e); setLoadError(true); setLoadingPopular(false); });
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [retryCount]);
|
||||||
|
|
||||||
|
// ── Frecency genres (derived from history + library) ──────────────────────
|
||||||
|
const frecencyGenres = useMemo(() => {
|
||||||
|
const mangaScores = new Map<number, number>();
|
||||||
|
const mangaReadAt = new Map<number, number>();
|
||||||
|
for (const entry of history) {
|
||||||
|
mangaScores.set(entry.mangaId, (mangaScores.get(entry.mangaId) ?? 0) + 1);
|
||||||
|
if (entry.readAt > (mangaReadAt.get(entry.mangaId) ?? 0))
|
||||||
|
mangaReadAt.set(entry.mangaId, entry.readAt);
|
||||||
|
}
|
||||||
|
const genreWeights = new Map<string, number>();
|
||||||
|
const mangaMap = new Map(allManga.map((m) => [m.id, m]));
|
||||||
|
for (const [mangaId, count] of mangaScores.entries()) {
|
||||||
|
const score = frecencyScore(mangaReadAt.get(mangaId) ?? 0, count);
|
||||||
|
for (const genre of mangaMap.get(mangaId)?.genre ?? [])
|
||||||
|
genreWeights.set(genre, (genreWeights.get(genre) ?? 0) + score);
|
||||||
|
}
|
||||||
|
if (genreWeights.size === 0)
|
||||||
|
allManga.filter((m) => m.inLibrary).forEach((m) =>
|
||||||
|
(m.genre ?? []).forEach((g) => genreWeights.set(g, (genreWeights.get(g) ?? 0) + 1)));
|
||||||
|
if (genreWeights.size === 0) return FOUNDATIONAL_GENRES.slice(0, 3);
|
||||||
|
return Array.from(genreWeights.entries())
|
||||||
|
.sort((a, b) => b[1] - a[1])
|
||||||
|
.slice(0, 3)
|
||||||
|
.map(([g]) => g);
|
||||||
|
}, [allManga, history]);
|
||||||
|
|
||||||
|
// ── Re-fetch only when personalized genres differ from what's cached ───────
|
||||||
|
useEffect(() => {
|
||||||
|
if (frecencyGenres.length === 0 || sources.length === 0) return;
|
||||||
|
|
||||||
|
const genreKey = frecencyGenres.join(",");
|
||||||
|
if (fetchedGenresRef.current === genreKey) return; // already fetched, cache hit
|
||||||
|
fetchedGenresRef.current = genreKey;
|
||||||
|
|
||||||
|
setLoadingGenres(true);
|
||||||
|
abortRef.current?.abort();
|
||||||
|
const ctrl = new AbortController();
|
||||||
|
abortRef.current = ctrl;
|
||||||
|
|
||||||
|
const topSources = getTopSources(sources).slice(0, 2);
|
||||||
|
const streamingMap = new Map<string, Manga[]>();
|
||||||
|
|
||||||
|
Promise.allSettled(
|
||||||
|
frecencyGenres.map((genre) =>
|
||||||
|
cache.get(CACHE_KEYS.GENRE(genre), () =>
|
||||||
|
Promise.allSettled(
|
||||||
|
topSources.map((src) =>
|
||||||
|
gql<{ fetchSourceManga: { mangas: Manga[] } }>(FETCH_SOURCE_MANGA, {
|
||||||
|
source: src.id, type: "SEARCH", page: 1, query: genre,
|
||||||
|
}, ctrl.signal).then((d) => d.fetchSourceManga.mangas)
|
||||||
|
)
|
||||||
|
).then((results) => {
|
||||||
|
const merged: Manga[] = [];
|
||||||
|
for (const r of results)
|
||||||
|
if (r.status === "fulfilled") merged.push(...r.value);
|
||||||
|
return dedupeMangaByTitle(merged).slice(0, 24);
|
||||||
|
})
|
||||||
|
).then((mangas) => {
|
||||||
|
if (ctrl.signal.aborted) return;
|
||||||
|
streamingMap.set(genre, mangas);
|
||||||
|
setGenreResults(new Map(streamingMap));
|
||||||
|
})
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.catch((e) => { if (e?.name !== "AbortError") console.error(e); })
|
||||||
|
.finally(() => { if (!ctrl.signal.aborted) setLoadingGenres(false); });
|
||||||
|
}, [frecencyGenres, sources]);
|
||||||
|
|
||||||
|
function openManga(m: Manga) { setPreviewManga(m); }
|
||||||
|
|
||||||
|
// ── Continue reading ──────────────────────────────────────────────────────
|
||||||
|
const continueReading = useMemo(() => {
|
||||||
|
const mangaMap = new Map(allManga.map((m) => [m.id, m]));
|
||||||
|
const seen = new Set<number>();
|
||||||
|
const result: { manga: Manga; chapterName: string; progress: number }[] = [];
|
||||||
|
for (const entry of history) {
|
||||||
|
if (seen.has(entry.mangaId)) continue;
|
||||||
|
seen.add(entry.mangaId);
|
||||||
|
const manga = mangaMap.get(entry.mangaId);
|
||||||
|
if (!manga) continue;
|
||||||
|
result.push({ manga, chapterName: entry.chapterName, progress: entry.pageNumber > 0 ? Math.min(entry.pageNumber / 20, 1) : 0 });
|
||||||
|
if (result.length >= 12) break;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}, [history, allManga]);
|
||||||
|
|
||||||
|
// ── Recommended ───────────────────────────────────────────────────────────
|
||||||
|
const recommended = useMemo(() => {
|
||||||
|
if (allManga.length === 0 || frecencyGenres.length === 0) return [];
|
||||||
|
const continueIds = new Set(continueReading.map((r) => r.manga.id));
|
||||||
|
return allManga
|
||||||
|
.filter((m) => m.inLibrary && !continueIds.has(m.id) &&
|
||||||
|
frecencyGenres.some((g) => (m.genre ?? []).includes(g)))
|
||||||
|
.slice(0, 20);
|
||||||
|
}, [allManga, frecencyGenres, continueReading]);
|
||||||
|
|
||||||
|
const genresLoading = loadingGenres;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={s.body}>
|
||||||
|
|
||||||
|
{(continueReading.length > 0 || loadingLib) && (
|
||||||
|
<Section title="Continue Reading" icon={<BookOpen size={11} weight="bold" />} loading={loadingLib}>
|
||||||
|
<div className={s.row} onWheel={handleRowWheel}>
|
||||||
|
{continueReading.slice(0, ROW_CAP).map(({ manga, chapterName, progress }) => (
|
||||||
|
<MiniCard key={manga.id} manga={manga} onClick={() => openManga(manga)}
|
||||||
|
onContextMenu={(e) => openCtx(e, manga)} subtitle={chapterName} progress={progress} />
|
||||||
|
))}
|
||||||
|
{Array.from({ length: GHOST_COUNT }).map((_, i) => <GhostCard key={`ghost-cr-${i}`} />)}
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(recommended.length > 0 || loadingLib) && (
|
||||||
|
<Section title="Recommended for You" icon={<Star size={11} weight="bold" />} loading={loadingLib}>
|
||||||
|
<div className={s.row} onWheel={handleRowWheel}>
|
||||||
|
{recommended.slice(0, ROW_CAP).map((m) => (
|
||||||
|
<MiniCard key={m.id} manga={m} onClick={() => openManga(m)} onContextMenu={(e) => openCtx(e, m)} />
|
||||||
|
))}
|
||||||
|
{Array.from({ length: GHOST_COUNT }).map((_, i) => <GhostCard key={`ghost-rec-${i}`} />)}
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(popularManga.length > 0 || loadingPopular) && (
|
||||||
|
<Section
|
||||||
|
title={sources.length === 1 ? `Popular on ${sources[0].displayName}` : sources.length > 1 ? `Popular across ${sources.length} sources` : "Popular"}
|
||||||
|
icon={<Fire size={11} weight="bold" />}
|
||||||
|
loading={loadingPopular}
|
||||||
|
>
|
||||||
|
{sources.length === 0 ? (
|
||||||
|
<div className={s.noSource}>No sources installed. Add extensions first.</div>
|
||||||
|
) : (
|
||||||
|
<div className={s.row} onWheel={handleRowWheel}>
|
||||||
|
{popularManga.slice(0, ROW_CAP).map((m) => (
|
||||||
|
<MiniCard key={m.id} manga={m} onClick={() => openManga(m)} onContextMenu={(e) => openCtx(e, m)} />
|
||||||
|
))}
|
||||||
|
{Array.from({ length: GHOST_COUNT }).map((_, i) => <GhostCard key={`ghost-pop-${i}`} />)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{frecencyGenres.map((genre) => {
|
||||||
|
const items = genreResults.get(genre) ?? [];
|
||||||
|
const isLoading = genresLoading && items.length === 0;
|
||||||
|
if (!isLoading && items.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<Section key={genre} title={genre} onSeeAll={() => setGenreFilter(genre)} loading={isLoading}>
|
||||||
|
<div className={s.row} onWheel={handleRowWheel}>
|
||||||
|
{items.slice(0, ROW_CAP).map((m) => (
|
||||||
|
<MiniCard key={m.id} manga={m} onClick={() => openManga(m)} onContextMenu={(e) => openCtx(e, m)} />
|
||||||
|
))}
|
||||||
|
{items.length >= ROW_CAP && (
|
||||||
|
<ExploreMoreCard genre={genre} onClick={() => setGenreFilter(genre)} />
|
||||||
|
)}
|
||||||
|
{Array.from({ length: GHOST_COUNT }).map((_, i) => <GhostCard key={`ghost-${genre}-${i}`} />)}
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{!loadingLib && !loadingPopular && !loadingGenres &&
|
||||||
|
continueReading.length === 0 && recommended.length === 0 &&
|
||||||
|
popularManga.length === 0 && frecencyGenres.every((g) => !genreResults.get(g)?.length) && (
|
||||||
|
<div className={s.empty}>
|
||||||
|
{loadError ? (
|
||||||
|
<>
|
||||||
|
<span>Could not reach Suwayomi</span>
|
||||||
|
<span className={s.emptyHint}>Make sure the server is running, then try again.</span>
|
||||||
|
<button
|
||||||
|
style={{ marginTop: "var(--sp-3)", padding: "6px 16px", borderRadius: "var(--radius-md)", border: "1px solid var(--border-dim)", background: "var(--bg-raised)", color: "var(--text-muted)", cursor: "pointer", fontFamily: "var(--font-ui)", fontSize: "var(--text-xs)", letterSpacing: "var(--tracking-wide)" }}
|
||||||
|
onClick={() => { setLoadingLib(true); setLoadingPopular(true); setRetryCount((c) => c + 1); }}
|
||||||
|
>
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span>Nothing to explore yet</span>
|
||||||
|
<span className={s.emptyHint}>Add manga to your library or install sources to get started.</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{ctx && (
|
||||||
|
<ContextMenu x={ctx.x} y={ctx.y} items={buildCtxItems(ctx.manga)} onClose={() => setCtx(null)} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
.root {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
animation: fadeIn 0.14s ease both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-3);
|
||||||
|
padding: var(--sp-4) var(--sp-6);
|
||||||
|
border-bottom: 1px solid var(--border-dim);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
text-transform: uppercase;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
transition: color var(--t-base);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.back:hover { color: var(--text-secondary); }
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: var(--text-base);
|
||||||
|
font-weight: var(--weight-medium);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
letter-spacing: var(--tracking-tight);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loadingHint {
|
||||||
|
margin-left: auto;
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-2xs);
|
||||||
|
color: var(--text-faint);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Grid fills entire remaining height, no show-more needed */
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(clamp(100px, 13vw, 140px), 1fr));
|
||||||
|
gap: var(--sp-4);
|
||||||
|
padding: var(--sp-5) var(--sp-6) var(--sp-6);
|
||||||
|
overflow-y: auto;
|
||||||
|
flex: 1;
|
||||||
|
align-content: start;
|
||||||
|
/* Smooth GPU-accelerated scrolling */
|
||||||
|
will-change: scroll-position;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
contain: layout style;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.card:hover .cover { filter: brightness(1.06); }
|
||||||
|
.card:hover .cardTitle { color: var(--text-primary); }
|
||||||
|
|
||||||
|
.coverWrap {
|
||||||
|
position: relative;
|
||||||
|
aspect-ratio: 2 / 3;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
/* Solid bg shown while image fades in — matches skeleton color */
|
||||||
|
background: var(--bg-raised);
|
||||||
|
border: 1px solid var(--border-dim);
|
||||||
|
transform: translateZ(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
transition: filter var(--t-base);
|
||||||
|
will-change: filter;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inLibraryBadge {
|
||||||
|
position: absolute;
|
||||||
|
bottom: var(--sp-1);
|
||||||
|
left: var(--sp-1);
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-2xs);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
text-transform: uppercase;
|
||||||
|
background: var(--accent-muted);
|
||||||
|
color: var(--accent-fg);
|
||||||
|
border: 1px solid var(--accent-dim);
|
||||||
|
padding: 2px 5px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardTitle {
|
||||||
|
margin-top: var(--sp-2);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: var(--leading-snug);
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: color var(--t-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Skeletons */
|
||||||
|
.cardSkeleton { padding: 0; }
|
||||||
|
.coverSkeleton { aspect-ratio: 2 / 3; border-radius: var(--radius-md); }
|
||||||
|
.titleSkeleton { height: 11px; margin-top: var(--sp-2); width: 75%; }
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex: 1;
|
||||||
|
color: var(--text-faint);
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
}
|
||||||
|
|
||||||
|
.resultCount {
|
||||||
|
margin-left: auto;
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-2xs);
|
||||||
|
color: var(--text-faint);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Show more — spans full grid width */
|
||||||
|
.showMoreCell {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: var(--sp-2) 0 var(--sp-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.showMoreBtn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
padding: 7px 20px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--bg-raised);
|
||||||
|
color: var(--text-muted);
|
||||||
|
border: 1px solid var(--border-dim);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color var(--t-base), border-color var(--t-base);
|
||||||
|
}
|
||||||
|
.showMoreBtn:hover:not(:disabled) {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
border-color: var(--border-strong);
|
||||||
|
}
|
||||||
|
.showMoreBtn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
@@ -0,0 +1,306 @@
|
|||||||
|
import { useEffect, useState, useMemo, useRef, memo, useCallback } from "react";
|
||||||
|
import { ArrowLeft, BookmarkSimple, FolderSimplePlus, Folder, CircleNotch } from "@phosphor-icons/react";
|
||||||
|
import { gql, thumbUrl } from "../../lib/client";
|
||||||
|
import { GET_ALL_MANGA, GET_LIBRARY, GET_SOURCES, FETCH_SOURCE_MANGA, UPDATE_MANGA } from "../../lib/queries";
|
||||||
|
import { cache, CACHE_KEYS } from "../../lib/cache";
|
||||||
|
import { dedupeSources, dedupeMangaById } from "../../lib/sourceUtils";
|
||||||
|
import { useStore } from "../../store";
|
||||||
|
import ContextMenu, { type ContextMenuEntry } from "../context/ContextMenu";
|
||||||
|
import type { Manga, Source } from "../../lib/types";
|
||||||
|
import s from "./GenreDrillPage.module.css";
|
||||||
|
|
||||||
|
// ── Constants ──────────────────────────────────────────────────────────────────
|
||||||
|
const PAGE_SIZE = 50; // how many items to show at once
|
||||||
|
const INITIAL_PAGES = 3; // source API pages to fetch upfront per source
|
||||||
|
const MAX_SOURCES = 12; // max sources to query concurrently
|
||||||
|
const CONCURRENCY = 4; // parallel source fetches
|
||||||
|
|
||||||
|
async function runConcurrent<T>(
|
||||||
|
items: T[],
|
||||||
|
fn: (item: T) => Promise<void>,
|
||||||
|
signal: AbortSignal,
|
||||||
|
): Promise<void> {
|
||||||
|
let i = 0;
|
||||||
|
async function worker() {
|
||||||
|
while (i < items.length) {
|
||||||
|
if (signal.aborted) return;
|
||||||
|
const item = items[i++];
|
||||||
|
await fn(item).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, items.length) }, worker));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── CoverImg ──────────────────────────────────────────────────────────────────
|
||||||
|
const CoverImg = memo(function CoverImg({ src, alt, className }: { src: string; alt: string; className?: string }) {
|
||||||
|
const [loaded, setLoaded] = useState(false);
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={src} alt={alt} className={className}
|
||||||
|
loading="lazy" decoding="async"
|
||||||
|
onLoad={() => setLoaded(true)}
|
||||||
|
style={{ opacity: loaded ? 1 : 0, transition: loaded ? "opacity 0.15s ease" : "none" }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── GenreDrillPage ────────────────────────────────────────────────────────────
|
||||||
|
export default function GenreDrillPage() {
|
||||||
|
const genre = useStore((st) => st.genreFilter);
|
||||||
|
const setGenreFilter = useStore((st) => st.setGenreFilter);
|
||||||
|
const setPreviewManga = useStore((st) => st.setPreviewManga);
|
||||||
|
const settings = useStore((st) => st.settings);
|
||||||
|
const folders = useStore((st) => st.settings.folders);
|
||||||
|
const addFolder = useStore((st) => st.addFolder);
|
||||||
|
const assignMangaToFolder = useStore((st) => st.assignMangaToFolder);
|
||||||
|
|
||||||
|
const [libraryManga, setLibraryManga] = useState<Manga[]>([]);
|
||||||
|
const [sourceManga, setSourceManga] = useState<Manga[]>([]);
|
||||||
|
const [loadingInitial, setLoadingInitial] = useState(true);
|
||||||
|
const [loadingMore, setLoadingMore] = useState(false);
|
||||||
|
const [visibleCount, setVisibleCount] = useState(PAGE_SIZE);
|
||||||
|
const [ctx, setCtx] = useState<{ x: number; y: number; manga: Manga } | null>(null);
|
||||||
|
|
||||||
|
// Per-source next-page tracker; -1 means exhausted
|
||||||
|
const nextPageRef = useRef<Map<string, number>>(new Map());
|
||||||
|
const sourcesRef = useRef<Source[]>([]);
|
||||||
|
const abortRef = useRef<AbortController | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!genre) return;
|
||||||
|
|
||||||
|
abortRef.current?.abort();
|
||||||
|
const ctrl = new AbortController();
|
||||||
|
abortRef.current = ctrl;
|
||||||
|
|
||||||
|
setLoadingInitial(true);
|
||||||
|
setSourceManga([]);
|
||||||
|
setLibraryManga([]);
|
||||||
|
setVisibleCount(PAGE_SIZE);
|
||||||
|
nextPageRef.current = new Map();
|
||||||
|
|
||||||
|
const preferredLang = settings.preferredExtensionLang || "en";
|
||||||
|
|
||||||
|
// ── Library (fire-and-forget, doesn't block skeleton removal) ─────────
|
||||||
|
cache.get(CACHE_KEYS.LIBRARY, () =>
|
||||||
|
Promise.all([
|
||||||
|
gql<{ mangas: { nodes: Manga[] } }>(GET_ALL_MANGA),
|
||||||
|
gql<{ mangas: { nodes: Manga[] } }>(GET_LIBRARY),
|
||||||
|
]).then(([all, lib]) => {
|
||||||
|
const libMap = new Map(lib.mangas.nodes.map((m) => [m.id, m]));
|
||||||
|
return all.mangas.nodes.map((m) => libMap.get(m.id) ?? m);
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.then((manga) => { if (!ctrl.signal.aborted) setLibraryManga(manga); })
|
||||||
|
.catch((e) => { if (e?.name !== "AbortError") console.error(e); });
|
||||||
|
|
||||||
|
// ── Sources: stream results in as each source responds ────────────────
|
||||||
|
cache.get(CACHE_KEYS.SOURCES, () =>
|
||||||
|
gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
|
||||||
|
.then((d) => dedupeSources(d.sources.nodes.filter((s) => s.id !== "0"), preferredLang))
|
||||||
|
).then(async (allSources) => {
|
||||||
|
const sources = allSources.slice(0, MAX_SOURCES);
|
||||||
|
sourcesRef.current = sources;
|
||||||
|
// Start all sources at -1 (unknown/exhausted); the fetch loop will set the correct next page
|
||||||
|
for (const src of sources) nextPageRef.current.set(src.id, -1);
|
||||||
|
|
||||||
|
await runConcurrent(sources, async (src) => {
|
||||||
|
if (ctrl.signal.aborted) return;
|
||||||
|
const pageItems: Manga[] = [];
|
||||||
|
for (let page = 1; page <= INITIAL_PAGES; page++) {
|
||||||
|
if (ctrl.signal.aborted) return;
|
||||||
|
try {
|
||||||
|
const d = await gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>(
|
||||||
|
FETCH_SOURCE_MANGA,
|
||||||
|
{ source: src.id, type: "SEARCH", page, query: genre },
|
||||||
|
ctrl.signal,
|
||||||
|
);
|
||||||
|
pageItems.push(...d.fetchSourceManga.mangas);
|
||||||
|
if (!d.fetchSourceManga.hasNextPage) {
|
||||||
|
nextPageRef.current.set(src.id, -1);
|
||||||
|
break;
|
||||||
|
} else if (page === INITIAL_PAGES) {
|
||||||
|
// Has more pages beyond what we fetched upfront — mark for "load more"
|
||||||
|
nextPageRef.current.set(src.id, INITIAL_PAGES + 1);
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e?.name === "AbortError") return;
|
||||||
|
nextPageRef.current.set(src.id, -1);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!ctrl.signal.aborted && pageItems.length > 0) {
|
||||||
|
// Dedupe by ID only — title dedup across sources is too aggressive and collapses
|
||||||
|
// legitimate different-source results that share a common title (e.g. "Action" genre)
|
||||||
|
setSourceManga((prev) => dedupeMangaById([...prev, ...pageItems]));
|
||||||
|
// Drop the skeleton as soon as we have anything
|
||||||
|
setLoadingInitial(false);
|
||||||
|
}
|
||||||
|
}, ctrl.signal);
|
||||||
|
|
||||||
|
if (!ctrl.signal.aborted) setLoadingInitial(false);
|
||||||
|
}).catch((e) => {
|
||||||
|
if (e?.name !== "AbortError") console.error(e);
|
||||||
|
if (!ctrl.signal.aborted) setLoadingInitial(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => { ctrl.abort(); };
|
||||||
|
}, [genre]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
// ── Derived merged list ────────────────────────────────────────────────────
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
const libMatches = libraryManga.filter((m) => (m.genre ?? []).includes(genre));
|
||||||
|
const libIds = new Set(libMatches.map((m) => m.id));
|
||||||
|
const srcAll = sourceManga.filter((m) => !libIds.has(m.id));
|
||||||
|
return dedupeMangaById([...libMatches, ...srcAll]);
|
||||||
|
}, [libraryManga, sourceManga, genre]);
|
||||||
|
|
||||||
|
// ── Load more ──────────────────────────────────────────────────────────────
|
||||||
|
const hasMoreVisible = visibleCount < filtered.length;
|
||||||
|
const hasMoreNetwork = sourcesRef.current.some((src) => (nextPageRef.current.get(src.id) ?? -1) > 0);
|
||||||
|
const hasMore = hasMoreVisible || hasMoreNetwork;
|
||||||
|
|
||||||
|
const loadMore = useCallback(async () => {
|
||||||
|
if (loadingMore) return;
|
||||||
|
|
||||||
|
// If there are buffered results, just reveal the next page
|
||||||
|
if (hasMoreVisible) {
|
||||||
|
setVisibleCount((v) => v + PAGE_SIZE);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch next pages from network
|
||||||
|
const sources = sourcesRef.current.filter(
|
||||||
|
(src) => (nextPageRef.current.get(src.id) ?? -1) > 0
|
||||||
|
);
|
||||||
|
if (!sources.length) return;
|
||||||
|
|
||||||
|
setLoadingMore(true);
|
||||||
|
abortRef.current?.abort();
|
||||||
|
const ctrl = new AbortController();
|
||||||
|
abortRef.current = ctrl;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await runConcurrent(sources, async (src) => {
|
||||||
|
const page = nextPageRef.current.get(src.id)!;
|
||||||
|
if (ctrl.signal.aborted) return;
|
||||||
|
try {
|
||||||
|
const d = await gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>(
|
||||||
|
FETCH_SOURCE_MANGA,
|
||||||
|
{ source: src.id, type: "SEARCH", page, query: genre },
|
||||||
|
ctrl.signal,
|
||||||
|
);
|
||||||
|
nextPageRef.current.set(src.id, d.fetchSourceManga.hasNextPage ? page + 1 : -1);
|
||||||
|
if (!ctrl.signal.aborted && d.fetchSourceManga.mangas.length > 0)
|
||||||
|
setSourceManga((prev) => dedupeMangaById([...prev, ...d.fetchSourceManga.mangas]));
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e?.name !== "AbortError") nextPageRef.current.set(src.id, -1);
|
||||||
|
}
|
||||||
|
}, ctrl.signal);
|
||||||
|
} finally {
|
||||||
|
if (!ctrl.signal.aborted) {
|
||||||
|
setVisibleCount((v) => v + PAGE_SIZE);
|
||||||
|
setLoadingMore(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [loadingMore, hasMoreVisible, genre]);
|
||||||
|
|
||||||
|
// ── Context menu ──────────────────────────────────────────────────────────
|
||||||
|
function openCtx(e: React.MouseEvent, m: Manga) {
|
||||||
|
e.preventDefault(); e.stopPropagation();
|
||||||
|
setCtx({ x: e.clientX, y: e.clientY, manga: m });
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCtxItems(m: Manga): ContextMenuEntry[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: m.inLibrary ? "In Library" : "Add to library",
|
||||||
|
icon: <BookmarkSimple size={13} weight={m.inLibrary ? "fill" : "light"} />,
|
||||||
|
disabled: m.inLibrary,
|
||||||
|
onClick: () => gql(UPDATE_MANGA, { id: m.id, inLibrary: true })
|
||||||
|
.then(() => {
|
||||||
|
setSourceManga((prev) => prev.map((x) => x.id === m.id ? { ...x, inLibrary: true } : x));
|
||||||
|
cache.clear(CACHE_KEYS.LIBRARY);
|
||||||
|
})
|
||||||
|
.catch(console.error),
|
||||||
|
},
|
||||||
|
...(folders.length > 0 ? [
|
||||||
|
{ separator: true } as ContextMenuEntry,
|
||||||
|
...folders.map((f): ContextMenuEntry => ({
|
||||||
|
label: f.mangaIds.includes(m.id) ? `✓ ${f.name}` : f.name,
|
||||||
|
icon: <Folder size={13} weight={f.mangaIds.includes(m.id) ? "fill" : "light"} />,
|
||||||
|
onClick: () => assignMangaToFolder(f.id, m.id),
|
||||||
|
})),
|
||||||
|
] : []),
|
||||||
|
{ separator: true },
|
||||||
|
{
|
||||||
|
label: "New folder & add",
|
||||||
|
icon: <FolderSimplePlus size={13} weight="light" />,
|
||||||
|
onClick: () => {
|
||||||
|
const name = prompt("Folder name:");
|
||||||
|
if (name?.trim()) { const id = addFolder(name.trim()); assignMangaToFolder(id, m.id); }
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
const visibleItems = filtered.slice(0, visibleCount);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={s.root}>
|
||||||
|
<div className={s.header}>
|
||||||
|
<button className={s.back} onClick={() => setGenreFilter("")}>
|
||||||
|
<ArrowLeft size={13} weight="light" />
|
||||||
|
<span>Back</span>
|
||||||
|
</button>
|
||||||
|
<span className={s.title}>{genre}</span>
|
||||||
|
{loadingInitial && filtered.length === 0 ? null : (
|
||||||
|
<span className={s.resultCount}>
|
||||||
|
{visibleItems.length}{filtered.length > visibleCount ? "+" : ""} of {filtered.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{!loadingInitial && hasMoreNetwork && (
|
||||||
|
<span className={s.loadingHint}>More loading…</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loadingInitial && filtered.length === 0 ? (
|
||||||
|
<div className={s.grid}>
|
||||||
|
{Array.from({ length: 50 }).map((_, i) => (
|
||||||
|
<div key={i} className={s.cardSkeleton}>
|
||||||
|
<div className={["skeleton", s.coverSkeleton].join(" ")} />
|
||||||
|
<div className={["skeleton", s.titleSkeleton].join(" ")} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : filtered.length === 0 ? (
|
||||||
|
<div className={s.empty}>No manga found for "{genre}".</div>
|
||||||
|
) : (
|
||||||
|
<div className={s.grid}>
|
||||||
|
{visibleItems.map((m) => (
|
||||||
|
<button key={m.id} className={s.card} onClick={() => setPreviewManga(m)} onContextMenu={(e) => openCtx(e, m)}>
|
||||||
|
<div className={s.coverWrap}>
|
||||||
|
<CoverImg src={thumbUrl(m.thumbnailUrl)} alt={m.title} className={s.cover} />
|
||||||
|
{m.inLibrary && <span className={s.inLibraryBadge}>Saved</span>}
|
||||||
|
</div>
|
||||||
|
<p className={s.cardTitle}>{m.title}</p>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{hasMore && (
|
||||||
|
<div className={s.showMoreCell}>
|
||||||
|
<button className={s.showMoreBtn} onClick={loadMore} disabled={loadingMore}>
|
||||||
|
{loadingMore
|
||||||
|
? <><CircleNotch size={13} weight="light" className="anim-spin" style={{ display:"inline-block" }} /> Loading…</>
|
||||||
|
: `Show more`}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{ctx && (
|
||||||
|
<ContextMenu x={ctx.x} y={ctx.y} items={buildCtxItems(ctx.manga)} onClose={() => setCtx(null)} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,395 @@
|
|||||||
|
/* ── Animations ──────────────────────────────────────────────────────────── */
|
||||||
|
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
|
||||||
|
@keyframes scaleIn { from { opacity: 0; transform: scale(0.97) } to { opacity: 1; transform: scale(1) } }
|
||||||
|
@keyframes pulse { 0%,100% { opacity: 0.4 } 50% { opacity: 0.8 } }
|
||||||
|
|
||||||
|
/* ── Backdrop ────────────────────────────────────────────────────────────── */
|
||||||
|
.backdrop {
|
||||||
|
position: fixed; inset: 0;
|
||||||
|
background: rgba(0,0,0,0.72);
|
||||||
|
z-index: var(--z-settings);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
animation: fadeIn 0.12s ease both;
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
-webkit-backdrop-filter: blur(4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Modal shell ─────────────────────────────────────────────────────────── */
|
||||||
|
.modal {
|
||||||
|
width: min(800px, calc(100vw - 48px));
|
||||||
|
height: min(560px, calc(100vh - 80px));
|
||||||
|
display: flex;
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border: 1px solid var(--border-base);
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
overflow: hidden;
|
||||||
|
animation: scaleIn 0.16s ease both;
|
||||||
|
box-shadow: 0 0 0 1px var(--border-dim), 0 24px 64px rgba(0,0,0,0.6), 0 8px 24px rgba(0,0,0,0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Cover column ────────────────────────────────────────────────────────── */
|
||||||
|
.coverCol {
|
||||||
|
width: 190px; flex-shrink: 0;
|
||||||
|
background: var(--bg-raised);
|
||||||
|
border-right: 1px solid var(--border-dim);
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
padding: var(--sp-5) var(--sp-4) var(--sp-4);
|
||||||
|
gap: var(--sp-3);
|
||||||
|
overflow-y: auto; overflow-x: hidden;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
.coverCol::-webkit-scrollbar { display: none; }
|
||||||
|
|
||||||
|
.coverWrap {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover {
|
||||||
|
width: 100%; aspect-ratio: 2 / 3; object-fit: cover;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
border: 1px solid var(--border-dim);
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.coverSpinner {
|
||||||
|
position: absolute; inset: 0;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
background: rgba(0,0,0,0.35);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
color: var(--text-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.coverActions {
|
||||||
|
display: flex; flex-direction: column; gap: var(--sp-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Cover action buttons ────────────────────────────────────────────────── */
|
||||||
|
.actionBtn {
|
||||||
|
display: flex; align-items: center; justify-content: center; gap: var(--sp-2);
|
||||||
|
width: 100%; padding: 7px var(--sp-3);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-family: var(--font-ui); font-size: var(--text-xs);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
background: none; color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color var(--t-base), border-color var(--t-base), background var(--t-base);
|
||||||
|
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.actionBtn:hover:not(:disabled) { color: var(--accent-fg); border-color: var(--accent); background: var(--accent-muted); }
|
||||||
|
.actionBtn:disabled { opacity: 0.4; cursor: default; }
|
||||||
|
|
||||||
|
.actionBtnActive {
|
||||||
|
background: var(--accent-muted);
|
||||||
|
border-color: var(--accent-dim);
|
||||||
|
color: var(--accent-fg);
|
||||||
|
}
|
||||||
|
.actionBtnActive:hover:not(:disabled) { filter: brightness(1.1); }
|
||||||
|
|
||||||
|
.actionBtnFolder { color: var(--text-secondary); border-color: var(--border-strong); }
|
||||||
|
|
||||||
|
.actionBtnLabel {
|
||||||
|
flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Folder picker ───────────────────────────────────────────────────────── */
|
||||||
|
.folderWrap { position: relative; width: 100%; }
|
||||||
|
|
||||||
|
.folderMenu {
|
||||||
|
position: absolute;
|
||||||
|
bottom: calc(100% + 4px); left: 0; right: 0;
|
||||||
|
background: var(--bg-raised);
|
||||||
|
border: 1px solid var(--border-base);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: var(--sp-1);
|
||||||
|
display: flex; flex-direction: column; gap: 1px;
|
||||||
|
box-shadow: 0 8px 24px rgba(0,0,0,0.5);
|
||||||
|
z-index: 10;
|
||||||
|
animation: scaleIn 0.1s ease both;
|
||||||
|
transform-origin: bottom center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.folderEmpty {
|
||||||
|
font-family: var(--font-ui); font-size: var(--text-xs);
|
||||||
|
color: var(--text-faint); padding: var(--sp-2) var(--sp-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.folderItem {
|
||||||
|
display: flex; align-items: center; gap: var(--sp-2);
|
||||||
|
padding: 6px var(--sp-3); border-radius: var(--radius-sm);
|
||||||
|
font-family: var(--font-ui); font-size: var(--text-xs);
|
||||||
|
color: var(--text-muted);
|
||||||
|
background: none; border: none; cursor: pointer; text-align: left;
|
||||||
|
transition: background var(--t-fast), color var(--t-fast);
|
||||||
|
}
|
||||||
|
.folderItem:hover { background: var(--bg-overlay); color: var(--text-primary); }
|
||||||
|
.folderItemOn { color: var(--accent-fg); }
|
||||||
|
|
||||||
|
.folderDivider { height: 1px; background: var(--border-dim); margin: var(--sp-1) 0; }
|
||||||
|
|
||||||
|
.folderCreateRow {
|
||||||
|
display: flex; gap: var(--sp-1); padding: var(--sp-1);
|
||||||
|
}
|
||||||
|
.folderInput {
|
||||||
|
flex: 1; background: var(--bg-overlay);
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
border-radius: var(--radius-sm); padding: 4px 8px;
|
||||||
|
color: var(--text-secondary); font-family: var(--font-ui); font-size: var(--text-xs);
|
||||||
|
outline: none; min-width: 0;
|
||||||
|
}
|
||||||
|
.folderInput:focus { border-color: var(--border-focus); }
|
||||||
|
|
||||||
|
.folderOkBtn {
|
||||||
|
font-family: var(--font-ui); font-size: var(--text-xs);
|
||||||
|
padding: 4px 8px; border-radius: var(--radius-sm);
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
background: none; color: var(--text-muted); cursor: pointer; flex-shrink: 0;
|
||||||
|
transition: color var(--t-base);
|
||||||
|
}
|
||||||
|
.folderOkBtn:disabled { opacity: 0.4; cursor: default; }
|
||||||
|
.folderOkBtn:not(:disabled):hover { color: var(--accent-fg); border-color: var(--accent); }
|
||||||
|
|
||||||
|
.folderNewBtn {
|
||||||
|
padding: 6px var(--sp-3); border-radius: var(--radius-sm);
|
||||||
|
font-family: var(--font-ui); font-size: var(--text-xs);
|
||||||
|
color: var(--text-faint); background: none; border: none;
|
||||||
|
cursor: pointer; text-align: left; width: 100%;
|
||||||
|
transition: color var(--t-fast);
|
||||||
|
}
|
||||||
|
.folderNewBtn:hover { color: var(--accent-fg); }
|
||||||
|
|
||||||
|
/* ── Content column ──────────────────────────────────────────────────────── */
|
||||||
|
.content {
|
||||||
|
flex: 1; display: flex; flex-direction: column; overflow: hidden; min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Header ──────────────────────────────────────────────────────────────── */
|
||||||
|
.contentHeader {
|
||||||
|
display: flex; align-items: flex-start; justify-content: space-between;
|
||||||
|
gap: var(--sp-4); padding: var(--sp-5) var(--sp-6) var(--sp-4);
|
||||||
|
border-bottom: 1px solid var(--border-dim); flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.titleBlock {
|
||||||
|
flex: 1; min-width: 0; display: flex; flex-direction: column; gap: var(--sp-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: var(--text-lg); font-weight: var(--weight-medium);
|
||||||
|
color: var(--text-primary); letter-spacing: var(--tracking-tight);
|
||||||
|
line-height: var(--leading-tight);
|
||||||
|
}
|
||||||
|
|
||||||
|
.byline {
|
||||||
|
font-size: var(--text-sm); color: var(--text-muted); line-height: var(--leading-snug);
|
||||||
|
}
|
||||||
|
|
||||||
|
.skByline {
|
||||||
|
height: 14px; width: 55%;
|
||||||
|
background: var(--bg-overlay); border-radius: var(--radius-sm);
|
||||||
|
animation: pulse 1.4s ease infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.closeBtn {
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
width: 28px; height: 28px; border-radius: var(--radius-sm);
|
||||||
|
color: var(--text-faint); border: none; background: none;
|
||||||
|
cursor: pointer; flex-shrink: 0;
|
||||||
|
transition: color var(--t-base), background var(--t-base);
|
||||||
|
}
|
||||||
|
.closeBtn:hover { color: var(--text-muted); background: var(--bg-raised); }
|
||||||
|
|
||||||
|
/* ── Scrollable body ─────────────────────────────────────────────────────── */
|
||||||
|
.contentBody {
|
||||||
|
flex: 1; overflow-y: auto;
|
||||||
|
padding: var(--sp-5) var(--sp-6);
|
||||||
|
display: flex; flex-direction: column; gap: var(--sp-4);
|
||||||
|
scrollbar-width: thin;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Error banner ────────────────────────────────────────────────────────── */
|
||||||
|
.errorBanner {
|
||||||
|
font-family: var(--font-ui); font-size: var(--text-xs);
|
||||||
|
color: var(--color-warn, #f59e0b);
|
||||||
|
background: color-mix(in srgb, var(--color-warn, #f59e0b) 10%, transparent);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--color-warn, #f59e0b) 25%, transparent);
|
||||||
|
border-radius: var(--radius-sm); padding: 6px var(--sp-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Skeleton rows ───────────────────────────────────────────────────────── */
|
||||||
|
.skRow {
|
||||||
|
display: flex; gap: var(--sp-2); align-items: center;
|
||||||
|
}
|
||||||
|
.skBadge {
|
||||||
|
height: 20px; width: 54px;
|
||||||
|
background: var(--bg-overlay); border-radius: var(--radius-sm);
|
||||||
|
animation: pulse 1.4s ease infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skDesc {
|
||||||
|
display: flex; flex-direction: column; gap: 8px; padding: var(--sp-2) 0;
|
||||||
|
}
|
||||||
|
.skLine {
|
||||||
|
height: 13px; background: var(--bg-overlay);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
animation: pulse 1.4s ease infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Badges ──────────────────────────────────────────────────────────────── */
|
||||||
|
.badges { display: flex; flex-wrap: wrap; gap: var(--sp-2); }
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
font-family: var(--font-ui); font-size: var(--text-2xs);
|
||||||
|
letter-spacing: var(--tracking-wide); text-transform: uppercase;
|
||||||
|
padding: 3px 8px; border-radius: var(--radius-sm);
|
||||||
|
border: 1px solid var(--border-dim);
|
||||||
|
background: var(--bg-raised); color: var(--text-faint);
|
||||||
|
}
|
||||||
|
.badgeGreen {
|
||||||
|
background: color-mix(in srgb, #22c55e 12%, transparent);
|
||||||
|
border-color: color-mix(in srgb, #22c55e 30%, transparent);
|
||||||
|
color: #22c55e;
|
||||||
|
}
|
||||||
|
.badgeDim { /* default */ }
|
||||||
|
.badgeAccent {
|
||||||
|
background: var(--accent-muted); border-color: var(--accent-dim); color: var(--accent-fg);
|
||||||
|
}
|
||||||
|
.badgeUnread {
|
||||||
|
background: color-mix(in srgb, #f59e0b 12%, transparent);
|
||||||
|
border-color: color-mix(in srgb, #f59e0b 30%, transparent);
|
||||||
|
color: #f59e0b;
|
||||||
|
}
|
||||||
|
.badgeNsfw {
|
||||||
|
background: color-mix(in srgb, #ef4444 12%, transparent);
|
||||||
|
border-color: color-mix(in srgb, #ef4444 30%, transparent);
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Chapter box — clearly separated from description ────────────────────── */
|
||||||
|
.chapterBox {
|
||||||
|
display: flex; flex-direction: column; gap: var(--sp-3);
|
||||||
|
padding: var(--sp-4);
|
||||||
|
background: var(--bg-raised);
|
||||||
|
border: 1px solid var(--border-dim);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chapterLoading {
|
||||||
|
display: flex; align-items: center; gap: var(--sp-2);
|
||||||
|
}
|
||||||
|
.chapterLoadingLabel {
|
||||||
|
font-family: var(--font-ui); font-size: var(--text-xs);
|
||||||
|
color: var(--text-faint); letter-spacing: var(--tracking-wide);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chapterMeta {
|
||||||
|
display: flex; align-items: center; justify-content: space-between; gap: var(--sp-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chapterLabel {
|
||||||
|
font-family: var(--font-ui); font-size: var(--text-xs);
|
||||||
|
color: var(--text-muted); letter-spacing: var(--tracking-wide);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dlAllBtn {
|
||||||
|
display: flex; align-items: center; gap: var(--sp-1);
|
||||||
|
font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide);
|
||||||
|
padding: 3px 10px; border-radius: var(--radius-sm);
|
||||||
|
border: 1px solid var(--border-dim); background: none; color: var(--text-faint);
|
||||||
|
cursor: pointer; flex-shrink: 0;
|
||||||
|
transition: color var(--t-base), border-color var(--t-base);
|
||||||
|
}
|
||||||
|
.dlAllBtn:hover:not(:disabled) { color: var(--text-muted); border-color: var(--border-strong); }
|
||||||
|
.dlAllBtn:disabled { opacity: 0.5; cursor: default; }
|
||||||
|
|
||||||
|
.progressTrack {
|
||||||
|
height: 3px; background: var(--bg-overlay);
|
||||||
|
border-radius: var(--radius-full); overflow: hidden;
|
||||||
|
}
|
||||||
|
.progressFill {
|
||||||
|
height: 100%; background: var(--accent);
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.readBtn {
|
||||||
|
display: flex; align-items: center; gap: var(--sp-2);
|
||||||
|
padding: 8px var(--sp-4);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
|
||||||
|
background: var(--accent-muted); border: 1px solid var(--accent-dim); color: var(--accent-fg);
|
||||||
|
cursor: pointer; align-self: flex-start;
|
||||||
|
transition: filter var(--t-base);
|
||||||
|
}
|
||||||
|
.readBtn:hover { filter: brightness(1.1); }
|
||||||
|
|
||||||
|
/* ── Description block ───────────────────────────────────────────────────── */
|
||||||
|
.descBlock {
|
||||||
|
display: flex; flex-direction: column; gap: var(--sp-2);
|
||||||
|
border-top: 1px solid var(--border-dim); padding-top: var(--sp-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.desc {
|
||||||
|
font-size: var(--text-sm); color: var(--text-muted);
|
||||||
|
line-height: var(--leading-base);
|
||||||
|
display: -webkit-box; -webkit-line-clamp: 5; -webkit-box-orient: vertical; overflow: hidden;
|
||||||
|
}
|
||||||
|
.descOpen {
|
||||||
|
display: block; -webkit-line-clamp: unset; overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.descToggle {
|
||||||
|
display: flex; align-items: center; gap: var(--sp-1);
|
||||||
|
font-family: var(--font-ui); font-size: var(--text-xs);
|
||||||
|
color: var(--text-faint); background: none; border: none;
|
||||||
|
cursor: pointer; padding: 0; align-self: flex-start;
|
||||||
|
transition: color var(--t-base);
|
||||||
|
}
|
||||||
|
.descToggle:hover { color: var(--accent-fg); }
|
||||||
|
|
||||||
|
/* ── Genre tags ──────────────────────────────────────────────────────────── */
|
||||||
|
.genres { display: flex; flex-wrap: wrap; gap: var(--sp-1); }
|
||||||
|
|
||||||
|
.genreTag {
|
||||||
|
font-family: var(--font-ui); font-size: var(--text-2xs);
|
||||||
|
letter-spacing: var(--tracking-wide); padding: 3px 8px;
|
||||||
|
border-radius: var(--radius-sm); border: 1px solid var(--border-dim);
|
||||||
|
background: var(--bg-raised); color: var(--text-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.genreTagClickable {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color var(--t-base), border-color var(--t-base), background var(--t-base);
|
||||||
|
}
|
||||||
|
.genreTagClickable:hover {
|
||||||
|
color: var(--accent-fg);
|
||||||
|
border-color: var(--accent-dim);
|
||||||
|
background: var(--accent-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Metadata table ──────────────────────────────────────────────────────── */
|
||||||
|
.metaTable {
|
||||||
|
display: flex; flex-direction: column; gap: 1px;
|
||||||
|
border-top: 1px solid var(--border-dim); padding-top: var(--sp-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metaRow {
|
||||||
|
display: flex; align-items: baseline; gap: var(--sp-3); padding: 5px 0;
|
||||||
|
}
|
||||||
|
.metaKey {
|
||||||
|
font-family: var(--font-ui); font-size: var(--text-xs);
|
||||||
|
color: var(--text-faint); letter-spacing: var(--tracking-wide);
|
||||||
|
text-transform: uppercase; min-width: 56px; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.metaVal {
|
||||||
|
font-size: var(--text-sm); color: var(--text-secondary);
|
||||||
|
line-height: var(--leading-snug);
|
||||||
|
}
|
||||||
|
.metaLink {
|
||||||
|
display: inline-flex; align-items: center; gap: 4px;
|
||||||
|
font-size: var(--text-sm); color: var(--accent-fg);
|
||||||
|
text-decoration: none; transition: opacity var(--t-base);
|
||||||
|
}
|
||||||
|
.metaLink:hover { opacity: 0.75; }
|
||||||
@@ -0,0 +1,569 @@
|
|||||||
|
import { useEffect, useRef, useState, useCallback } from "react";
|
||||||
|
import {
|
||||||
|
X, BookmarkSimple, ArrowSquareOut, Play,
|
||||||
|
CircleNotch, Books, CaretDown, FolderSimplePlus, Folder,
|
||||||
|
} from "@phosphor-icons/react";
|
||||||
|
import { gql, thumbUrl } from "../../lib/client";
|
||||||
|
import {
|
||||||
|
GET_MANGA, GET_CHAPTERS, FETCH_MANGA, FETCH_CHAPTERS, UPDATE_MANGA, ENQUEUE_CHAPTERS_DOWNLOAD,
|
||||||
|
} from "../../lib/queries";
|
||||||
|
import { cache, CACHE_KEYS } from "../../lib/cache";
|
||||||
|
import { useStore } from "../../store";
|
||||||
|
import type { Manga, Chapter } from "../../lib/types";
|
||||||
|
import s from "./MangaPreview.module.css";
|
||||||
|
|
||||||
|
export default function MangaPreview() {
|
||||||
|
const previewManga = useStore((st) => st.previewManga);
|
||||||
|
const setPreviewManga = useStore((st) => st.setPreviewManga);
|
||||||
|
const setActiveManga = useStore((st) => st.setActiveManga);
|
||||||
|
const setNavPage = useStore((st) => st.setNavPage);
|
||||||
|
const setGenreFilter = useStore((st) => st.setGenreFilter);
|
||||||
|
const openReader = useStore((st) => st.openReader);
|
||||||
|
const addToast = useStore((st) => st.addToast);
|
||||||
|
const folders = useStore((st) => st.settings.folders);
|
||||||
|
const addFolder = useStore((st) => st.addFolder);
|
||||||
|
const assignMangaToFolder = useStore((st) => st.assignMangaToFolder);
|
||||||
|
const removeMangaFromFolder = useStore((st) => st.removeMangaFromFolder);
|
||||||
|
|
||||||
|
const [manga, setManga] = useState<Manga | null>(null);
|
||||||
|
const [chapters, setChapters] = useState<Chapter[]>([]);
|
||||||
|
const [loadingDetail, setLoadingDetail] = useState(false);
|
||||||
|
const [loadingChapters, setLoadingChapters] = useState(false);
|
||||||
|
const [togglingLib, setTogglingLib] = useState(false);
|
||||||
|
const [descExpanded, setDescExpanded] = useState(false);
|
||||||
|
const [folderOpen, setFolderOpen] = useState(false);
|
||||||
|
const [newFolderName, setNewFolderName] = useState("");
|
||||||
|
const [creatingFolder, setCreatingFolder] = useState(false);
|
||||||
|
const [queueingAll, setQueueingAll] = useState(false);
|
||||||
|
const [fetchError, setFetchError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const backdropRef = useRef<HTMLDivElement>(null);
|
||||||
|
const detailAbort = useRef<AbortController | null>(null);
|
||||||
|
const chapterAbort = useRef<AbortController | null>(null);
|
||||||
|
const folderRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const close = useCallback(() => {
|
||||||
|
detailAbort.current?.abort();
|
||||||
|
chapterAbort.current?.abort();
|
||||||
|
setPreviewManga(null);
|
||||||
|
setManga(null);
|
||||||
|
setChapters([]);
|
||||||
|
setDescExpanded(false);
|
||||||
|
setFolderOpen(false);
|
||||||
|
setCreatingFolder(false);
|
||||||
|
setNewFolderName("");
|
||||||
|
setFetchError(null);
|
||||||
|
}, [setPreviewManga]);
|
||||||
|
|
||||||
|
// ── Fetch detail + chapters on open ──────────────────────────────────────
|
||||||
|
useEffect(() => {
|
||||||
|
if (!previewManga) return;
|
||||||
|
|
||||||
|
// Abort any in-flight requests from previous manga
|
||||||
|
detailAbort.current?.abort();
|
||||||
|
chapterAbort.current?.abort();
|
||||||
|
|
||||||
|
const dCtrl = new AbortController();
|
||||||
|
const cCtrl = new AbortController();
|
||||||
|
detailAbort.current = dCtrl;
|
||||||
|
chapterAbort.current = cCtrl;
|
||||||
|
|
||||||
|
setManga(null);
|
||||||
|
setChapters([]);
|
||||||
|
setDescExpanded(false);
|
||||||
|
setFetchError(null);
|
||||||
|
setLoadingDetail(true);
|
||||||
|
setLoadingChapters(true);
|
||||||
|
|
||||||
|
const id = previewManga.id;
|
||||||
|
|
||||||
|
// ── Detail fetch strategy ─────────────────────────────────────────────
|
||||||
|
// For source/explore manga we must call FETCH_MANGA (mutation that
|
||||||
|
// hits the source and syncs to the local DB). GET_MANGA only works for
|
||||||
|
// manga already in the local DB with full metadata.
|
||||||
|
//
|
||||||
|
// Fast path: if we already cached a full record, use it directly.
|
||||||
|
// Slow path: always try FETCH_MANGA first — it never fails for valid IDs
|
||||||
|
// and returns the richest data. Fall back to GET_MANGA if it errors.
|
||||||
|
//
|
||||||
|
(async (): Promise<Manga> => {
|
||||||
|
const cacheKey = CACHE_KEYS.MANGA(id);
|
||||||
|
|
||||||
|
// Already have a cached rich record — no network needed
|
||||||
|
if (cache.has(cacheKey)) {
|
||||||
|
return cache.get(cacheKey, () =>
|
||||||
|
Promise.resolve(previewManga as Manga)
|
||||||
|
) as Promise<Manga>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try FETCH_MANGA first — works for all manga regardless of whether
|
||||||
|
// they are in the local DB yet (it fetches from source and syncs).
|
||||||
|
try {
|
||||||
|
const d = await gql<{ fetchManga: { manga: Manga } }>(
|
||||||
|
FETCH_MANGA, { id }, dCtrl.signal
|
||||||
|
);
|
||||||
|
return d.fetchManga.manga;
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e?.name === "AbortError") throw e;
|
||||||
|
// FETCH_MANGA failed (e.g. source offline) — fall back to local DB
|
||||||
|
const local = await gql<{ manga: Manga }>(
|
||||||
|
GET_MANGA, { id }, dCtrl.signal
|
||||||
|
).then((d) => d.manga);
|
||||||
|
if (local) return local;
|
||||||
|
throw new Error("Could not load manga details");
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
.then((fullManga) => {
|
||||||
|
if (dCtrl.signal.aborted) return;
|
||||||
|
// Cache the rich record so re-opening is instant
|
||||||
|
if (!cache.has(CACHE_KEYS.MANGA(id))) {
|
||||||
|
cache.get(CACHE_KEYS.MANGA(id), () => Promise.resolve(fullManga));
|
||||||
|
}
|
||||||
|
setManga(fullManga);
|
||||||
|
setLoadingDetail(false);
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
if (e?.name === "AbortError") return;
|
||||||
|
console.error("MangaPreview detail fetch:", e);
|
||||||
|
// Show whatever sparse data we have from previewManga
|
||||||
|
setManga(previewManga as Manga);
|
||||||
|
setFetchError("Could not load full details — showing cached data");
|
||||||
|
setLoadingDetail(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Chapter fetch — local DB first, fall back to source fetch ────────
|
||||||
|
gql<{ chapters: { nodes: Chapter[] } }>(
|
||||||
|
GET_CHAPTERS, { mangaId: id }, cCtrl.signal
|
||||||
|
)
|
||||||
|
.then(async (d) => {
|
||||||
|
if (cCtrl.signal.aborted) return;
|
||||||
|
let nodes = [...d.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder);
|
||||||
|
// If no local chapters yet (explore/source manga), fetch from source
|
||||||
|
if (nodes.length === 0) {
|
||||||
|
try {
|
||||||
|
const fetched = await gql<{ fetchChapters: { chapters: Chapter[] } }>(
|
||||||
|
FETCH_CHAPTERS, { mangaId: id }, cCtrl.signal
|
||||||
|
);
|
||||||
|
if (!cCtrl.signal.aborted)
|
||||||
|
nodes = [...fetched.fetchChapters.chapters].sort((a, b) => a.sourceOrder - b.sourceOrder);
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e?.name === "AbortError") return;
|
||||||
|
// Leave nodes empty — not a fatal error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!cCtrl.signal.aborted) setChapters(nodes);
|
||||||
|
})
|
||||||
|
.catch((e) => { if (e?.name !== "AbortError") console.error(e); })
|
||||||
|
.finally(() => { if (!cCtrl.signal.aborted) setLoadingChapters(false); });
|
||||||
|
|
||||||
|
return () => { dCtrl.abort(); cCtrl.abort(); };
|
||||||
|
}, [previewManga?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
// ── Keyboard close ────────────────────────────────────────────────────────
|
||||||
|
useEffect(() => {
|
||||||
|
if (!previewManga) return;
|
||||||
|
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") close(); };
|
||||||
|
window.addEventListener("keydown", onKey);
|
||||||
|
return () => window.removeEventListener("keydown", onKey);
|
||||||
|
}, [previewManga, close]);
|
||||||
|
|
||||||
|
// ── Folder outside click ──────────────────────────────────────────────────
|
||||||
|
useEffect(() => {
|
||||||
|
if (!folderOpen) return;
|
||||||
|
const handler = (e: MouseEvent) => {
|
||||||
|
if (folderRef.current && !folderRef.current.contains(e.target as Node)) {
|
||||||
|
setFolderOpen(false); setCreatingFolder(false); setNewFolderName("");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener("mousedown", handler);
|
||||||
|
return () => document.removeEventListener("mousedown", handler);
|
||||||
|
}, [folderOpen]);
|
||||||
|
|
||||||
|
if (!previewManga) return null;
|
||||||
|
|
||||||
|
// Always show title/cover from previewManga immediately; upgrade to fetched manga when ready
|
||||||
|
const displayManga = manga ?? previewManga;
|
||||||
|
const totalCount = chapters.length;
|
||||||
|
const readCount = chapters.filter((c) => c.isRead).length;
|
||||||
|
const unreadCount = totalCount - readCount;
|
||||||
|
const downloadedCount = chapters.filter((c) => c.isDownloaded).length;
|
||||||
|
const bookmarkCount = chapters.filter((c) => c.isBookmarked).length;
|
||||||
|
const inLibrary = manga?.inLibrary ?? previewManga.inLibrary ?? false;
|
||||||
|
|
||||||
|
// Scanlators — deduplicated, non-empty
|
||||||
|
const scanlators = [...new Set(
|
||||||
|
chapters.map((c) => c.scanlator).filter((sc): sc is string => !!sc?.trim())
|
||||||
|
)];
|
||||||
|
|
||||||
|
// Publication date range from chapter upload dates
|
||||||
|
const uploadDates = chapters
|
||||||
|
.map((c) => c.uploadDate ? new Date(c.uploadDate).getTime() : null)
|
||||||
|
.filter((d): d is number => d !== null && !isNaN(d));
|
||||||
|
const firstUpload = uploadDates.length ? new Date(Math.min(...uploadDates)) : null;
|
||||||
|
const lastUpload = uploadDates.length ? new Date(Math.max(...uploadDates)) : null;
|
||||||
|
|
||||||
|
function formatDate(d: Date) {
|
||||||
|
return d.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusLabel = displayManga.status
|
||||||
|
? displayManga.status.charAt(0) + displayManga.status.slice(1).toLowerCase()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const continueChapter = (() => {
|
||||||
|
if (!chapters.length) return null;
|
||||||
|
const asc = [...chapters];
|
||||||
|
const inProgress = asc.find((c) => !c.isRead && (c.lastPageRead ?? 0) > 0);
|
||||||
|
if (inProgress) return { ch: inProgress, label: `Continue · Ch.${inProgress.chapterNumber}` };
|
||||||
|
const firstUnread = asc.find((c) => !c.isRead);
|
||||||
|
if (firstUnread) return { ch: firstUnread, label: `Start · Ch.${firstUnread.chapterNumber}` };
|
||||||
|
return { ch: asc[0], label: "Read again" };
|
||||||
|
})();
|
||||||
|
|
||||||
|
async function toggleLibrary() {
|
||||||
|
if (!manga) return;
|
||||||
|
setTogglingLib(true);
|
||||||
|
const next = !manga.inLibrary;
|
||||||
|
await gql(UPDATE_MANGA, { id: manga.id, inLibrary: next }).catch(console.error);
|
||||||
|
const updated = { ...manga, inLibrary: next };
|
||||||
|
setManga(updated);
|
||||||
|
// Update cache so subsequent opens reflect new state
|
||||||
|
cache.clear(CACHE_KEYS.MANGA(manga.id));
|
||||||
|
cache.get(CACHE_KEYS.MANGA(manga.id), () => Promise.resolve(updated));
|
||||||
|
cache.clear(CACHE_KEYS.LIBRARY);
|
||||||
|
setTogglingLib(false);
|
||||||
|
addToast({ kind: "success", title: next ? "Added to library" : "Removed from library" });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadAll() {
|
||||||
|
const ids = chapters.filter((c) => !c.isDownloaded && !c.isRead).map((c) => c.id);
|
||||||
|
if (!ids.length) return;
|
||||||
|
setQueueingAll(true);
|
||||||
|
await gql(ENQUEUE_CHAPTERS_DOWNLOAD, { chapterIds: ids }).catch(console.error);
|
||||||
|
addToast({ kind: "download", title: "Downloading", body: `${ids.length} chapters queued` });
|
||||||
|
setQueueingAll(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openSeriesDetail() {
|
||||||
|
setActiveManga(displayManga);
|
||||||
|
setNavPage("library");
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFolderCreate() {
|
||||||
|
const name = newFolderName.trim();
|
||||||
|
if (!name || !previewManga) return;
|
||||||
|
const newId = addFolder(name);
|
||||||
|
assignMangaToFolder(newId, previewManga.id);
|
||||||
|
setNewFolderName("");
|
||||||
|
setCreatingFolder(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
const assignedFolders = folders.filter((f) => f.mangaIds.includes(previewManga.id));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={s.backdrop}
|
||||||
|
ref={backdropRef}
|
||||||
|
onClick={(e) => { if (e.target === backdropRef.current) close(); }}
|
||||||
|
>
|
||||||
|
<div className={s.modal} role="dialog" aria-label="Manga preview">
|
||||||
|
|
||||||
|
{/* ── Cover column ── */}
|
||||||
|
<div className={s.coverCol}>
|
||||||
|
<div className={s.coverWrap}>
|
||||||
|
<img
|
||||||
|
src={thumbUrl(previewManga.thumbnailUrl)}
|
||||||
|
alt={displayManga.title}
|
||||||
|
className={s.cover}
|
||||||
|
/>
|
||||||
|
{loadingDetail && (
|
||||||
|
<div className={s.coverSpinner}>
|
||||||
|
<CircleNotch size={18} weight="light" className="anim-spin" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={s.coverActions}>
|
||||||
|
<button
|
||||||
|
className={[s.actionBtn, inLibrary ? s.actionBtnActive : ""].join(" ")}
|
||||||
|
onClick={toggleLibrary}
|
||||||
|
disabled={togglingLib || loadingDetail}
|
||||||
|
>
|
||||||
|
<BookmarkSimple size={13} weight={inLibrary ? "fill" : "light"} />
|
||||||
|
{togglingLib ? "…" : inLibrary ? "In Library" : "Add to Library"}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button className={s.actionBtn} onClick={openSeriesDetail}>
|
||||||
|
<Books size={13} weight="light" />
|
||||||
|
Series Detail
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Folder picker */}
|
||||||
|
<div className={s.folderWrap} ref={folderRef}>
|
||||||
|
<button
|
||||||
|
className={[s.actionBtn, assignedFolders.length > 0 ? s.actionBtnFolder : ""].join(" ")}
|
||||||
|
onClick={() => setFolderOpen((p) => !p)}
|
||||||
|
>
|
||||||
|
<FolderSimplePlus size={13} weight={assignedFolders.length > 0 ? "fill" : "light"} />
|
||||||
|
<span className={s.actionBtnLabel}>
|
||||||
|
{assignedFolders.length > 0 ? assignedFolders.map((f) => f.name).join(", ") : "Add to folder"}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{folderOpen && (
|
||||||
|
<div className={s.folderMenu}>
|
||||||
|
{folders.length === 0 && !creatingFolder && (
|
||||||
|
<p className={s.folderEmpty}>No folders yet</p>
|
||||||
|
)}
|
||||||
|
{folders.map((f) => {
|
||||||
|
const isIn = f.mangaIds.includes(previewManga.id);
|
||||||
|
return (
|
||||||
|
<button key={f.id}
|
||||||
|
className={[s.folderItem, isIn ? s.folderItemOn : ""].join(" ")}
|
||||||
|
onClick={() => isIn
|
||||||
|
? removeMangaFromFolder(f.id, previewManga.id)
|
||||||
|
: assignMangaToFolder(f.id, previewManga.id)}
|
||||||
|
>
|
||||||
|
<Folder size={12} weight={isIn ? "fill" : "light"} />
|
||||||
|
{isIn ? "✓ " : ""}{f.name}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<div className={s.folderDivider} />
|
||||||
|
{creatingFolder ? (
|
||||||
|
<div className={s.folderCreateRow}>
|
||||||
|
<input autoFocus className={s.folderInput} placeholder="Folder name…"
|
||||||
|
value={newFolderName} onChange={(e) => setNewFolderName(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") handleFolderCreate();
|
||||||
|
if (e.key === "Escape") { setCreatingFolder(false); setNewFolderName(""); }
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button className={s.folderOkBtn} onClick={handleFolderCreate} disabled={!newFolderName.trim()}>Add</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button className={s.folderNewBtn} onClick={() => setCreatingFolder(true)}>+ New folder</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Content column ── */}
|
||||||
|
<div className={s.content}>
|
||||||
|
|
||||||
|
{/* Header — title visible immediately from previewManga */}
|
||||||
|
<div className={s.contentHeader}>
|
||||||
|
<div className={s.titleBlock}>
|
||||||
|
<h2 className={s.title}>{displayManga.title}</h2>
|
||||||
|
{loadingDetail
|
||||||
|
? <div className={s.skByline} />
|
||||||
|
: (displayManga.author || displayManga.artist)
|
||||||
|
? <p className={s.byline}>
|
||||||
|
{[displayManga.author, displayManga.artist]
|
||||||
|
.filter(Boolean).filter((v, i, a) => a.indexOf(v) === i).join(" · ")}
|
||||||
|
</p>
|
||||||
|
: null}
|
||||||
|
</div>
|
||||||
|
<button className={s.closeBtn} onClick={close}><X size={15} weight="light" /></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Scrollable body */}
|
||||||
|
<div className={s.contentBody}>
|
||||||
|
|
||||||
|
{/* Error banner */}
|
||||||
|
{fetchError && (
|
||||||
|
<div className={s.errorBanner}>{fetchError}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Badges ── */}
|
||||||
|
{loadingDetail ? (
|
||||||
|
<div className={s.skRow}>
|
||||||
|
<div className={s.skBadge} />
|
||||||
|
<div className={s.skBadge} style={{ width: 72 }} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className={s.badges}>
|
||||||
|
{statusLabel && (
|
||||||
|
<span className={[s.badge,
|
||||||
|
displayManga.status === "ONGOING" ? s.badgeGreen : s.badgeDim
|
||||||
|
].join(" ")}>{statusLabel}</span>
|
||||||
|
)}
|
||||||
|
{displayManga.source && (
|
||||||
|
<span className={[s.badge, (displayManga.source as any).isNsfw ? s.badgeNsfw : ""].join(" ").trim()}>
|
||||||
|
{displayManga.source.displayName}{(displayManga.source as any).isNsfw ? " · 18+" : ""}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{inLibrary && <span className={[s.badge, s.badgeAccent].join(" ")}>In Library</span>}
|
||||||
|
{!loadingChapters && unreadCount > 0 && (
|
||||||
|
<span className={[s.badge, s.badgeUnread].join(" ")}>{unreadCount} unread</span>
|
||||||
|
)}
|
||||||
|
{!loadingChapters && bookmarkCount > 0 && (
|
||||||
|
<span className={s.badge}>{bookmarkCount} bookmarked</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Chapter section — visually separated box ── */}
|
||||||
|
<div className={s.chapterBox}>
|
||||||
|
{loadingChapters ? (
|
||||||
|
<div className={s.chapterLoading}>
|
||||||
|
<CircleNotch size={13} weight="light" className="anim-spin" style={{ color: "var(--text-faint)" }} />
|
||||||
|
<span className={s.chapterLoadingLabel}>Loading chapters…</span>
|
||||||
|
</div>
|
||||||
|
) : totalCount > 0 ? (
|
||||||
|
<>
|
||||||
|
<div className={s.chapterMeta}>
|
||||||
|
<span className={s.chapterLabel}>
|
||||||
|
{totalCount} {totalCount === 1 ? "chapter" : "chapters"}
|
||||||
|
{readCount > 0 && ` · ${readCount} read`}
|
||||||
|
{unreadCount > 0 && readCount > 0 && ` · ${unreadCount} left`}
|
||||||
|
{downloadedCount > 0 && ` · ${downloadedCount} dl`}
|
||||||
|
</span>
|
||||||
|
{unreadCount > 0 && (
|
||||||
|
<button className={s.dlAllBtn} onClick={downloadAll} disabled={queueingAll}>
|
||||||
|
{queueingAll && <CircleNotch size={11} weight="light" className="anim-spin" />}
|
||||||
|
{queueingAll ? "Queuing…" : "Download unread"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{readCount > 0 && (
|
||||||
|
<div className={s.progressTrack}>
|
||||||
|
<div className={s.progressFill} style={{ width: `${(readCount / totalCount) * 100}%` }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{continueChapter && (
|
||||||
|
<button className={s.readBtn}
|
||||||
|
onClick={() => { openReader(continueChapter.ch, chapters); close(); }}
|
||||||
|
>
|
||||||
|
<Play size={12} weight="fill" />
|
||||||
|
{continueChapter.label}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : !loadingDetail ? (
|
||||||
|
<span className={s.chapterLabel} style={{ color: "var(--text-faint)" }}>
|
||||||
|
No chapters in local library
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Description — clearly separated from chapter block ── */}
|
||||||
|
{loadingDetail ? (
|
||||||
|
<div className={s.skDesc}>
|
||||||
|
<div className={s.skLine} style={{ width: "100%" }} />
|
||||||
|
<div className={s.skLine} style={{ width: "88%" }} />
|
||||||
|
<div className={s.skLine} style={{ width: "70%" }} />
|
||||||
|
</div>
|
||||||
|
) : displayManga.description ? (
|
||||||
|
<div className={s.descBlock}>
|
||||||
|
<p className={[s.desc, descExpanded ? s.descOpen : ""].join(" ")}>
|
||||||
|
{displayManga.description}
|
||||||
|
</p>
|
||||||
|
{displayManga.description.length > 220 && (
|
||||||
|
<button className={s.descToggle} onClick={() => setDescExpanded((p) => !p)}>
|
||||||
|
{descExpanded ? "Show less" : "Show more"}
|
||||||
|
<CaretDown size={10} weight="light" style={{
|
||||||
|
transform: descExpanded ? "rotate(180deg)" : "none",
|
||||||
|
transition: "transform 0.15s ease",
|
||||||
|
}} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* ── Genre tags ── */}
|
||||||
|
{!loadingDetail && displayManga.genre && displayManga.genre.length > 0 && (
|
||||||
|
<div className={s.genres}>
|
||||||
|
{displayManga.genre.map((g) => (
|
||||||
|
<button
|
||||||
|
key={g}
|
||||||
|
className={[s.genreTag, s.genreTagClickable].join(" ")}
|
||||||
|
title={`Browse "${g}"`}
|
||||||
|
onClick={() => {
|
||||||
|
setGenreFilter(g);
|
||||||
|
setNavPage("explore");
|
||||||
|
close();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{g}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Metadata table ── */}
|
||||||
|
{!loadingDetail && (
|
||||||
|
<div className={s.metaTable}>
|
||||||
|
{displayManga.author && (
|
||||||
|
<div className={s.metaRow}>
|
||||||
|
<span className={s.metaKey}>Author</span>
|
||||||
|
<span className={s.metaVal}>{displayManga.author}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{displayManga.artist && displayManga.artist !== displayManga.author && (
|
||||||
|
<div className={s.metaRow}>
|
||||||
|
<span className={s.metaKey}>Artist</span>
|
||||||
|
<span className={s.metaVal}>{displayManga.artist}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{statusLabel && (
|
||||||
|
<div className={s.metaRow}>
|
||||||
|
<span className={s.metaKey}>Status</span>
|
||||||
|
<span className={s.metaVal}>{statusLabel}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{displayManga.source && (
|
||||||
|
<div className={s.metaRow}>
|
||||||
|
<span className={s.metaKey}>Source</span>
|
||||||
|
<span className={s.metaVal}>{displayManga.source.displayName}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!loadingChapters && scanlators.length > 0 && (
|
||||||
|
<div className={s.metaRow}>
|
||||||
|
<span className={s.metaKey}>{scanlators.length === 1 ? "Scanlator" : "Scanlators"}</span>
|
||||||
|
<span className={s.metaVal}>{scanlators.join(", ")}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!loadingChapters && firstUpload && lastUpload && (
|
||||||
|
<div className={s.metaRow}>
|
||||||
|
<span className={s.metaKey}>Published</span>
|
||||||
|
<span className={s.metaVal}>
|
||||||
|
{firstUpload.getTime() === lastUpload.getTime()
|
||||||
|
? formatDate(firstUpload)
|
||||||
|
: `${formatDate(firstUpload)} – ${formatDate(lastUpload)}`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!loadingChapters && downloadedCount > 0 && (
|
||||||
|
<div className={s.metaRow}>
|
||||||
|
<span className={s.metaKey}>Downloaded</span>
|
||||||
|
<span className={s.metaVal}>{downloadedCount} / {totalCount} chapters</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!loadingChapters && bookmarkCount > 0 && (
|
||||||
|
<div className={s.metaRow}>
|
||||||
|
<span className={s.metaKey}>Bookmarks</span>
|
||||||
|
<span className={s.metaVal}>{bookmarkCount} chapter{bookmarkCount !== 1 ? "s" : ""}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{displayManga.realUrl && (
|
||||||
|
<div className={s.metaRow}>
|
||||||
|
<span className={s.metaKey}>Link</span>
|
||||||
|
<a href={displayManga.realUrl} target="_blank" rel="noreferrer" className={s.metaLink}>
|
||||||
|
Open <ArrowSquareOut size={11} weight="light" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ import Library from "../pages/Library";
|
|||||||
import SeriesDetail from "../pages/SeriesDetail";
|
import SeriesDetail from "../pages/SeriesDetail";
|
||||||
import History from "../pages/History";
|
import History from "../pages/History";
|
||||||
import Search from "../pages/Search";
|
import Search from "../pages/Search";
|
||||||
import Explore from "../sources/Explore";
|
import Explore from "../explore/Explore";
|
||||||
import DownloadQueue from "../downloads/DownloadQueue";
|
import DownloadQueue from "../downloads/DownloadQueue";
|
||||||
import ExtensionList from "../extensions/ExtensionList";
|
import ExtensionList from "../extensions/ExtensionList";
|
||||||
import s from "./Layout.module.css";
|
import s from "./Layout.module.css";
|
||||||
@@ -14,7 +14,7 @@ export default function Layout() {
|
|||||||
const activeManga = useStore((s) => s.activeManga);
|
const activeManga = useStore((s) => s.activeManga);
|
||||||
|
|
||||||
function renderContent() {
|
function renderContent() {
|
||||||
if (navPage === "library" && activeManga) return <SeriesDetail />;
|
if (activeManga) return <SeriesDetail />;
|
||||||
switch (navPage) {
|
switch (navPage) {
|
||||||
case "library": return <Library />;
|
case "library": return <Library />;
|
||||||
case "search": return <Search />;
|
case "search": return <Search />;
|
||||||
|
|||||||
@@ -17,15 +17,21 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
margin-bottom: var(--sp-3);
|
margin-bottom: var(--sp-3);
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
|
/* Explicit reset — prevents browser from injecting a default button background */
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
|
outline: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
transition: opacity var(--t-base), transform var(--t-base);
|
transition: opacity var(--t-base), transform var(--t-base);
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
}
|
}
|
||||||
.logo:hover { opacity: 0.8; transform: scale(0.96); }
|
.logo:hover { opacity: 0.8; transform: scale(0.96); }
|
||||||
.logo:active { transform: scale(0.92); }
|
.logo:active { transform: scale(0.92); }
|
||||||
|
/* Kill the focus ring that can render as a coloured glow on some GTK themes */
|
||||||
|
.logo:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||||
|
|
||||||
.logoIcon {
|
.logoIcon {
|
||||||
width: 80px;
|
width: 80px;
|
||||||
@@ -58,10 +64,21 @@
|
|||||||
display: flex; align-items: center; justify-content: center;
|
display: flex; align-items: center; justify-content: center;
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
color: var(--text-faint);
|
color: var(--text-faint);
|
||||||
|
/* Explicit resets — the green overlay was browser default button styles bleeding through */
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
transition: color var(--t-base), background var(--t-base);
|
transition: color var(--t-base), background var(--t-base);
|
||||||
}
|
}
|
||||||
.tab:hover { color: var(--text-muted); background: var(--bg-raised); }
|
.tab:hover { color: var(--text-muted); background: var(--bg-raised); }
|
||||||
|
.tab:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; border-radius: var(--radius-md); }
|
||||||
|
|
||||||
.tabActive { color: var(--accent-fg); background: var(--accent-muted); }
|
.tabActive { color: var(--accent-fg); background: var(--accent-muted); }
|
||||||
|
/* Prevent hover state from overriding active colour */
|
||||||
.tabActive:hover { color: var(--accent-fg); background: var(--accent-muted); }
|
.tabActive:hover { color: var(--accent-fg); background: var(--accent-muted); }
|
||||||
|
|
||||||
.bottom {
|
.bottom {
|
||||||
@@ -76,6 +93,15 @@
|
|||||||
display: flex; align-items: center; justify-content: center;
|
display: flex; align-items: center; justify-content: center;
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
color: var(--text-faint);
|
color: var(--text-faint);
|
||||||
|
/* Same explicit resets */
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
transition: color var(--t-base), background var(--t-base), transform var(--t-slow);
|
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); }
|
.settingsBtn:hover { color: var(--text-muted); background: var(--bg-raised); transform: rotate(30deg); }
|
||||||
|
.settingsBtn:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
|
||||||
@@ -20,10 +20,13 @@ export default function Sidebar() {
|
|||||||
const setActiveSource = useStore((state) => state.setActiveSource);
|
const setActiveSource = useStore((state) => state.setActiveSource);
|
||||||
const setActiveManga = useStore((state) => state.setActiveManga);
|
const setActiveManga = useStore((state) => state.setActiveManga);
|
||||||
const setLibraryFilter = useStore((state) => state.setLibraryFilter);
|
const setLibraryFilter = useStore((state) => state.setLibraryFilter);
|
||||||
|
const setGenreFilter = useStore((state) => state.setGenreFilter);
|
||||||
const openSettings = useStore((state) => state.openSettings);
|
const openSettings = useStore((state) => state.openSettings);
|
||||||
|
|
||||||
function navigate(id: NavPage) {
|
function navigate(id: NavPage) {
|
||||||
setNavPage(id);
|
setNavPage(id);
|
||||||
|
setActiveManga(null);
|
||||||
|
setGenreFilter("");
|
||||||
if (id !== "explore") setActiveSource(null);
|
if (id !== "explore") setActiveSource(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,518 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import logoUrl from "../../assets/moku-icon.svg";
|
||||||
|
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||||
|
|
||||||
|
export type SplashMode = "loading" | "idle";
|
||||||
|
export const EXIT_MS = 320;
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
mode: SplashMode;
|
||||||
|
ringFull?: boolean;
|
||||||
|
failed?: boolean;
|
||||||
|
showCards?: boolean;
|
||||||
|
showFps?: boolean;
|
||||||
|
onReady?: () => void;
|
||||||
|
onRetry?: () => void;
|
||||||
|
onDismiss?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Hash ──────────────────────────────────────────────────────────────────────
|
||||||
|
function hash(n: number): number {
|
||||||
|
let x = Math.imul(n ^ (n >>> 16), 0x45d9f3b);
|
||||||
|
x = Math.imul(x ^ (x >>> 16), 0x45d9f3b);
|
||||||
|
return ((x ^ (x >>> 16)) >>> 0) / 0xffffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Card definition ───────────────────────────────────────────────────────────
|
||||||
|
interface CardDef {
|
||||||
|
layer: 0 | 1 | 2;
|
||||||
|
cx: number;
|
||||||
|
w: number;
|
||||||
|
h: number;
|
||||||
|
lines: number;
|
||||||
|
alpha: number;
|
||||||
|
speed: number;
|
||||||
|
cycleSec: number;
|
||||||
|
phase: number;
|
||||||
|
travel: number;
|
||||||
|
yStart: number;
|
||||||
|
angleStart: number;
|
||||||
|
tilt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CardTrig { cosA: number; sinA: number; tiltRad: number; }
|
||||||
|
|
||||||
|
const LAYER_CFG = [
|
||||||
|
{ wMin: 26, wMax: 40, speedMin: 30, speedMax: 50, alpha: 0.22 },
|
||||||
|
{ wMin: 38, wMax: 56, speedMin: 52, speedMax: 80, alpha: 0.35 },
|
||||||
|
{ wMin: 54, wMax: 76, speedMin: 85, speedMax: 120, alpha: 0.50 },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const BUF = 80;
|
||||||
|
const COLS = 14;
|
||||||
|
|
||||||
|
function buildCards(vw: number, vh: number): { cards: CardDef[]; trigs: CardTrig[] } {
|
||||||
|
const cards: CardDef[] = [];
|
||||||
|
const laneW = vw / COLS;
|
||||||
|
for (let layer = 0; layer < 3; layer++) {
|
||||||
|
const cfg = LAYER_CFG[layer];
|
||||||
|
for (let col = 0; col < COLS; col++) {
|
||||||
|
const seed = col * 31 + layer * 97 + 7;
|
||||||
|
const w = cfg.wMin + hash(seed + 1) * (cfg.wMax - cfg.wMin);
|
||||||
|
const h = w * 1.44;
|
||||||
|
const maxNudge = (laneW - w) / 2 - 2;
|
||||||
|
const cx = (col + 0.5) * laneW + (hash(seed + 2) * 2 - 1) * Math.max(0, maxNudge);
|
||||||
|
const speed = cfg.speedMin + hash(seed + 5) * (cfg.speedMax - cfg.speedMin);
|
||||||
|
const travel = vh + h + BUF;
|
||||||
|
cards.push({
|
||||||
|
layer: layer as 0 | 1 | 2,
|
||||||
|
cx, w, h,
|
||||||
|
lines: 1 + Math.floor(hash(seed + 7) * 3),
|
||||||
|
alpha: cfg.alpha,
|
||||||
|
speed,
|
||||||
|
cycleSec: travel / speed,
|
||||||
|
phase: ((col / COLS) + hash(seed + 6) * 0.6 + layer * 0.23) % 1,
|
||||||
|
travel,
|
||||||
|
yStart: vh + h / 2 + BUF / 2,
|
||||||
|
angleStart: hash(seed + 3) * 50 - 25,
|
||||||
|
tilt: (hash(seed + 4) * 2 - 1) * 18,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const trigs: CardTrig[] = cards.map(c => ({
|
||||||
|
cosA: Math.cos(c.angleStart * (Math.PI / 180)),
|
||||||
|
sinA: Math.sin(c.angleStart * (Math.PI / 180)),
|
||||||
|
tiltRad: c.tilt * (Math.PI / 180),
|
||||||
|
}));
|
||||||
|
return { cards, trigs };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Rounded rect ──────────────────────────────────────────────────────────────
|
||||||
|
function rrect(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x + r, y);
|
||||||
|
ctx.lineTo(x + w - r, y); ctx.arcTo(x + w, y, x + w, y + r, r);
|
||||||
|
ctx.lineTo(x + w, y + h - r); ctx.arcTo(x + w, y + h, x + w - r, y + h, r);
|
||||||
|
ctx.lineTo(x + r, y + h); ctx.arcTo(x, y + h, x, y + h - r, r);
|
||||||
|
ctx.lineTo(x, y + r); ctx.arcTo(x, y, x + r, y, r);
|
||||||
|
ctx.closePath();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Stamp builder ─────────────────────────────────────────────────────────────
|
||||||
|
const STAMP_PAD = 6;
|
||||||
|
|
||||||
|
function buildStamp(c: CardDef, dpr: number): HTMLCanvasElement {
|
||||||
|
const logW = Math.ceil(c.w + STAMP_PAD * 2);
|
||||||
|
const logH = Math.ceil(c.h + STAMP_PAD * 2);
|
||||||
|
const oc = document.createElement("canvas");
|
||||||
|
oc.width = Math.round(logW * dpr);
|
||||||
|
oc.height = Math.round(logH * dpr);
|
||||||
|
const ctx = oc.getContext("2d")!;
|
||||||
|
ctx.scale(dpr, dpr);
|
||||||
|
|
||||||
|
const x0 = STAMP_PAD;
|
||||||
|
const y0 = STAMP_PAD;
|
||||||
|
const coverH = (c.w * 0.72) * 1.05;
|
||||||
|
const lineY0 = y0 + 3 + coverH + 5;
|
||||||
|
|
||||||
|
ctx.fillStyle = "rgba(0,0,0,0.5)";
|
||||||
|
rrect(ctx, x0 + 2, y0 + 2, c.w, c.h, 4); ctx.fill();
|
||||||
|
|
||||||
|
ctx.fillStyle = "rgba(255,255,255,0.07)";
|
||||||
|
rrect(ctx, x0, y0, c.w, c.h, 4); ctx.fill();
|
||||||
|
|
||||||
|
ctx.strokeStyle = "rgba(255,255,255,0.75)";
|
||||||
|
ctx.lineWidth = 1.2;
|
||||||
|
rrect(ctx, x0, y0, c.w, c.h, 4); ctx.stroke();
|
||||||
|
|
||||||
|
ctx.fillStyle = "rgba(255,255,255,0.15)";
|
||||||
|
rrect(ctx, x0 + 3, y0 + 3, c.w - 6, coverH, 3); ctx.fill();
|
||||||
|
|
||||||
|
ctx.fillStyle = "rgba(255,255,255,0.08)";
|
||||||
|
rrect(ctx, x0 + 3, y0 + 3, (c.w - 6) * 0.45, coverH, 3); ctx.fill();
|
||||||
|
|
||||||
|
for (let li = 0; li < c.lines; li++) {
|
||||||
|
ctx.fillStyle = li === 0 ? "rgba(255,255,255,0.35)" : "rgba(255,255,255,0.20)";
|
||||||
|
ctx.fillRect(x0 + 4, lineY0 + li * 8, (c.w - 8) * (li === 0 ? 0.78 : 0.52), li === 0 ? 3 : 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
return oc;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Vignette builder ──────────────────────────────────────────────────────────
|
||||||
|
function buildVignette(vw: number, vh: number, dpr: number): HTMLCanvasElement {
|
||||||
|
const oc = document.createElement("canvas");
|
||||||
|
oc.width = Math.round(vw * dpr);
|
||||||
|
oc.height = Math.round(vh * dpr);
|
||||||
|
const ctx = oc.getContext("2d")!;
|
||||||
|
ctx.scale(dpr, dpr);
|
||||||
|
const g = ctx.createRadialGradient(vw / 2, vh / 2, 0, vw / 2, vh / 2, Math.max(vw, vh) * 0.65);
|
||||||
|
g.addColorStop(0.15, "rgba(0,0,0,0)");
|
||||||
|
g.addColorStop(1, "rgba(0,0,0,0.82)");
|
||||||
|
ctx.fillStyle = g;
|
||||||
|
ctx.fillRect(0, 0, vw, vh);
|
||||||
|
return oc;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Draw frame ────────────────────────────────────────────────────────────────
|
||||||
|
function drawFrame(
|
||||||
|
ctx: CanvasRenderingContext2D,
|
||||||
|
t: number,
|
||||||
|
cw: number,
|
||||||
|
ch: number,
|
||||||
|
dpr: number,
|
||||||
|
cards: CardDef[],
|
||||||
|
trigs: CardTrig[],
|
||||||
|
stamps: HTMLCanvasElement[],
|
||||||
|
vignette: HTMLCanvasElement,
|
||||||
|
) {
|
||||||
|
ctx.clearRect(0, 0, cw, ch);
|
||||||
|
|
||||||
|
for (let i = 0; i < cards.length; i++) {
|
||||||
|
const c = cards[i];
|
||||||
|
const p = ((t / c.cycleSec) + c.phase) % 1;
|
||||||
|
|
||||||
|
const alpha = p < 0.07
|
||||||
|
? (p / 0.07) * c.alpha
|
||||||
|
: p > 0.86
|
||||||
|
? ((1 - p) / 0.14) * c.alpha
|
||||||
|
: c.alpha;
|
||||||
|
|
||||||
|
if (alpha < 0.005) continue;
|
||||||
|
|
||||||
|
const cy = c.yStart - p * c.travel;
|
||||||
|
const tg = trigs[i];
|
||||||
|
const delta = tg.tiltRad * p;
|
||||||
|
const cosDelta = Math.cos(delta);
|
||||||
|
const sinDelta = Math.sin(delta);
|
||||||
|
const cos = tg.cosA * cosDelta - tg.sinA * sinDelta;
|
||||||
|
const sin = tg.sinA * cosDelta + tg.cosA * sinDelta;
|
||||||
|
|
||||||
|
ctx.globalAlpha = alpha;
|
||||||
|
ctx.setTransform(
|
||||||
|
cos * dpr, sin * dpr,
|
||||||
|
-sin * dpr, cos * dpr,
|
||||||
|
c.cx * dpr, cy * dpr,
|
||||||
|
);
|
||||||
|
// Draw stamp at its natural logical size.
|
||||||
|
// The stamp was baked at (logical * dpr) physical pixels.
|
||||||
|
// setTransform already applied dpr scaling, so drawing at logical size
|
||||||
|
// means the stamp maps 1:1 to physical pixels — zero resampling, zero blur.
|
||||||
|
const sw = stamps[i].width / dpr;
|
||||||
|
const sh = stamps[i].height / dpr;
|
||||||
|
ctx.drawImage(stamps[i], -sw / 2, -sh / 2, sw, sh);
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
||||||
|
ctx.globalAlpha = 1;
|
||||||
|
ctx.drawImage(vignette, 0, 0, cw, ch);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Ring ──────────────────────────────────────────────────────────────────────
|
||||||
|
function Ring({ progress }: { progress: number }) {
|
||||||
|
const r = 44, sw = 2, pad = 8;
|
||||||
|
const size = (r + pad) * 2, c = r + pad;
|
||||||
|
const circ = 2 * Math.PI * r;
|
||||||
|
const arc = circ * Math.min(Math.max(progress, 0.025), 0.999);
|
||||||
|
return (
|
||||||
|
<svg width={size} height={size} style={{
|
||||||
|
position: "absolute", pointerEvents: "none",
|
||||||
|
top: -((size - 80) / 2), left: -((size - 80) / 2),
|
||||||
|
}}>
|
||||||
|
<circle cx={c} cy={c} r={r} fill="none" stroke="rgba(255,255,255,0.08)" strokeWidth={sw} />
|
||||||
|
<circle cx={c} cy={c} r={r} fill="none" stroke="#4ade80" strokeWidth={sw}
|
||||||
|
strokeLinecap="round" strokeDasharray={`${arc} ${circ}`}
|
||||||
|
transform={`rotate(-90 ${c} ${c})`}
|
||||||
|
style={{ transition: "stroke-dasharray 0.55s cubic-bezier(0.4,0,0.2,1)" }} />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── FPS counter ───────────────────────────────────────────────────────────────
|
||||||
|
function FpsCounter() {
|
||||||
|
const divRef = useRef<HTMLDivElement>(null);
|
||||||
|
const times = useRef<number[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let raf = 0;
|
||||||
|
function tick(now: number) {
|
||||||
|
const arr = times.current;
|
||||||
|
arr.push(now);
|
||||||
|
if (arr.length > 60) arr.shift();
|
||||||
|
if (arr.length > 1 && divRef.current) {
|
||||||
|
const fps = Math.round((arr.length - 1) / ((arr[arr.length - 1] - arr[0]) / 1000));
|
||||||
|
divRef.current.textContent = `${fps} fps`;
|
||||||
|
divRef.current.style.color = fps >= 55 ? "#4ade80" : fps >= 30 ? "#facc15" : "#f87171";
|
||||||
|
}
|
||||||
|
raf = requestAnimationFrame(tick);
|
||||||
|
}
|
||||||
|
raf = requestAnimationFrame(tick);
|
||||||
|
return () => cancelAnimationFrame(raf);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={divRef} style={{
|
||||||
|
position: "fixed", top: 10, right: 14, zIndex: 10001,
|
||||||
|
fontFamily: "var(--font-mono, 'Courier New', monospace)",
|
||||||
|
fontSize: 11, fontWeight: 600, letterSpacing: "0.08em",
|
||||||
|
color: "#4ade80",
|
||||||
|
background: "rgba(0,0,0,0.55)",
|
||||||
|
border: "1px solid rgba(255,255,255,0.12)",
|
||||||
|
borderRadius: 4, padding: "2px 7px",
|
||||||
|
userSelect: "none", pointerEvents: "none",
|
||||||
|
}}>-- fps</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ── CardCanvas ────────────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Strategy: best of both worlds.
|
||||||
|
//
|
||||||
|
// LAYOUT → logical pixels (window.innerWidth/Height or Tauri innerSize/scale)
|
||||||
|
// Cards fill the actual window shape correctly at any size.
|
||||||
|
//
|
||||||
|
// QUALITY → physical pixels (Tauri innerSize + scaleFactor)
|
||||||
|
// Canvas buffer = physical pixels, stamps baked at the true OS DPR.
|
||||||
|
// No WebKitGTK lies, no late compositor hints, always pixel-perfect.
|
||||||
|
//
|
||||||
|
// On every resize both are re-derived together so fullscreen, half-split,
|
||||||
|
// monitor switch — all produce crisp, correctly-proportioned cards.
|
||||||
|
//
|
||||||
|
function CardCanvas({ showFps }: { showFps: boolean }) {
|
||||||
|
const ref = useRef<HTMLCanvasElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = ref.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
|
||||||
|
const ctx = canvas.getContext("2d", { alpha: true, willReadFrequently: false });
|
||||||
|
if (!ctx) return;
|
||||||
|
ctx.imageSmoothingEnabled = true;
|
||||||
|
ctx.imageSmoothingQuality = "high";
|
||||||
|
|
||||||
|
const win = getCurrentWindow();
|
||||||
|
|
||||||
|
// ── Live render state ────────────────────────────────────────────────────
|
||||||
|
// The frame loop only ever reads from `live`. syncSize builds a complete
|
||||||
|
// replacement object off-thread then swaps it in one atomic assignment —
|
||||||
|
// no frame ever sees a half-rebuilt state.
|
||||||
|
interface RenderState {
|
||||||
|
cards: ReturnType<typeof buildCards>["cards"];
|
||||||
|
trigs: ReturnType<typeof buildCards>["trigs"];
|
||||||
|
stamps: HTMLCanvasElement[];
|
||||||
|
vignette: HTMLCanvasElement;
|
||||||
|
CW: number; CH: number; scale: number;
|
||||||
|
}
|
||||||
|
let live: RenderState | null = null;
|
||||||
|
|
||||||
|
// Track what we last built so we skip no-op resize events.
|
||||||
|
let lastLogW = 0, lastLogH = 0, lastScale = 0;
|
||||||
|
// Debounce: if a new resize arrives while one is in-flight, we only
|
||||||
|
// want the most recent result. A simple generation counter handles this.
|
||||||
|
let buildGen = 0;
|
||||||
|
|
||||||
|
async function syncSize() {
|
||||||
|
const gen = ++buildGen;
|
||||||
|
|
||||||
|
const [phys, scale] = await Promise.all([
|
||||||
|
win.innerSize(),
|
||||||
|
win.scaleFactor(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Another resize fired while we were awaiting — our result is stale.
|
||||||
|
if (gen !== buildGen) return;
|
||||||
|
|
||||||
|
const physW = phys.width;
|
||||||
|
const physH = phys.height;
|
||||||
|
const logW = physW / scale;
|
||||||
|
const logH = physH / scale;
|
||||||
|
|
||||||
|
if (logW === lastLogW && logH === lastLogH && scale === lastScale) return;
|
||||||
|
lastLogW = logW; lastLogH = logH; lastScale = scale;
|
||||||
|
|
||||||
|
// Build everything into a local staging object — nothing visible changes yet.
|
||||||
|
const built = buildCards(logW, logH);
|
||||||
|
const stamps = built.cards.map(c => buildStamp(c, scale));
|
||||||
|
const vig = buildVignette(logW, logH, scale);
|
||||||
|
|
||||||
|
// One atomic swap — the frame loop immediately sees the complete new state.
|
||||||
|
// Canvas dimensions are updated here too so they're always in sync with
|
||||||
|
// the render state that uses them.
|
||||||
|
canvas!.width = physW;
|
||||||
|
canvas!.height = physH;
|
||||||
|
live = {
|
||||||
|
cards: built.cards, trigs: built.trigs,
|
||||||
|
stamps, vignette: vig,
|
||||||
|
CW: physW, CH: physH, scale,
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[SplashScreen] syncSize: logical ${Math.round(logW)}×${Math.round(logH)}`,
|
||||||
|
`physical ${physW}×${physH} @${scale.toFixed(3)}×`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ro = new ResizeObserver(() => syncSize());
|
||||||
|
ro.observe(canvas);
|
||||||
|
syncSize();
|
||||||
|
|
||||||
|
let raf = 0, t0 = -1;
|
||||||
|
function frame(now: number) {
|
||||||
|
raf = requestAnimationFrame(frame);
|
||||||
|
if (!live) return;
|
||||||
|
if (t0 < 0) t0 = now;
|
||||||
|
const { cards, trigs, stamps, vignette, CW, CH, scale } = live;
|
||||||
|
drawFrame(ctx!, (now - t0) / 1000, CW, CH, scale, cards, trigs, stamps, vignette);
|
||||||
|
}
|
||||||
|
raf = requestAnimationFrame(frame);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelAnimationFrame(raf);
|
||||||
|
ro.disconnect();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<canvas ref={ref} style={{
|
||||||
|
position: "absolute", inset: 0, pointerEvents: "none",
|
||||||
|
width: "100%", height: "100%",
|
||||||
|
}} />
|
||||||
|
{showFps && <FpsCounter />}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Static CSS ────────────────────────────────────────────────────────────────
|
||||||
|
const STATIC_CSS = `
|
||||||
|
@keyframes spIn { from{opacity:0;transform:scale(1.015)} to{opacity:1;transform:scale(1)} }
|
||||||
|
@keyframes spOut { from{opacity:1;transform:scale(1)} to{opacity:0;transform:scale(0.96)} }
|
||||||
|
@keyframes logoBreathe {
|
||||||
|
0%,100%{transform:scale(1);filter:drop-shadow(0 0 0px rgba(255,255,255,0))}
|
||||||
|
50% {transform:scale(1.04);filter:drop-shadow(0 0 18px rgba(255,255,255,0.12))}
|
||||||
|
}
|
||||||
|
@keyframes hintFade { 0%,100%{opacity:0.35} 50%{opacity:0.7} }
|
||||||
|
`;
|
||||||
|
|
||||||
|
// ── Main ──────────────────────────────────────────────────────────────────────
|
||||||
|
export default function SplashScreen({
|
||||||
|
mode, ringFull = false, failed = false,
|
||||||
|
showCards = true, showFps = false,
|
||||||
|
onReady, onRetry, onDismiss,
|
||||||
|
}: Props) {
|
||||||
|
const [dots, setDots] = useState("");
|
||||||
|
const [ringProg, setRingProg] = useState(0.025);
|
||||||
|
const [exiting, setExiting] = useState(false);
|
||||||
|
const exitLock = useRef(false);
|
||||||
|
|
||||||
|
function triggerExit(cb?: () => void) {
|
||||||
|
if (exitLock.current) return;
|
||||||
|
exitLock.current = true;
|
||||||
|
setExiting(true);
|
||||||
|
setTimeout(() => cb?.(), EXIT_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!ringFull) return;
|
||||||
|
setRingProg(1);
|
||||||
|
const t = setTimeout(() => triggerExit(onReady), 650);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}, [ringFull]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const id = setInterval(() => setDots(d => d.length >= 3 ? "" : d + "."), 420);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (mode !== "idle" || !onDismiss) return;
|
||||||
|
function handler() { triggerExit(onDismiss); }
|
||||||
|
window.addEventListener("keydown", handler, { once: true });
|
||||||
|
window.addEventListener("mousedown", handler, { once: true });
|
||||||
|
window.addEventListener("touchstart", handler, { once: true });
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("keydown", handler);
|
||||||
|
window.removeEventListener("mousedown", handler);
|
||||||
|
window.removeEventListener("touchstart", handler);
|
||||||
|
};
|
||||||
|
}, [mode, onDismiss]);
|
||||||
|
|
||||||
|
const isIdle = mode === "idle";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
position: "fixed", inset: 0, zIndex: 9999,
|
||||||
|
background: "var(--bg-base)", overflow: "hidden",
|
||||||
|
display: "flex", flexDirection: "column",
|
||||||
|
alignItems: "center", justifyContent: "center",
|
||||||
|
cursor: isIdle ? "pointer" : "default",
|
||||||
|
animation: exiting
|
||||||
|
? `spOut ${EXIT_MS}ms cubic-bezier(0.4,0,1,1) both`
|
||||||
|
: "spIn 0.35s cubic-bezier(0,0,0.2,1) both",
|
||||||
|
}}>
|
||||||
|
<style>{STATIC_CSS}</style>
|
||||||
|
|
||||||
|
{showCards && <CardCanvas showFps={showFps} />}
|
||||||
|
|
||||||
|
{isIdle ? (
|
||||||
|
<div style={{ zIndex: 1, display: "flex", flexDirection: "column", alignItems: "center" }}>
|
||||||
|
<div style={{ position: "relative", width: 128, height: 128, marginBottom: 32 }}>
|
||||||
|
<div style={{
|
||||||
|
position: "absolute", inset: -20, borderRadius: "50%",
|
||||||
|
background: "radial-gradient(circle, rgba(255,255,255,0.06) 0%, transparent 70%)",
|
||||||
|
animation: "logoBreathe 4s ease-in-out infinite",
|
||||||
|
}} />
|
||||||
|
<img src={logoUrl} alt="Moku" style={{
|
||||||
|
width: 128, height: 128, borderRadius: 28,
|
||||||
|
display: "block", position: "relative",
|
||||||
|
animation: "logoBreathe 4s ease-in-out infinite",
|
||||||
|
}} />
|
||||||
|
</div>
|
||||||
|
<p style={{
|
||||||
|
fontFamily: "var(--font-ui)", fontSize: 10, color: "var(--text-faint)",
|
||||||
|
letterSpacing: "0.22em", textTransform: "uppercase",
|
||||||
|
margin: 0, userSelect: "none",
|
||||||
|
animation: "hintFade 3.5s ease-in-out infinite",
|
||||||
|
}}>press any key to continue</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div style={{ position: "relative", width: 80, height: 80, marginBottom: 20, zIndex: 1 }}>
|
||||||
|
{!failed && <Ring progress={ringProg} />}
|
||||||
|
<img src={logoUrl} alt="Moku"
|
||||||
|
style={{ width: 80, height: 80, borderRadius: 18, display: "block" }} />
|
||||||
|
</div>
|
||||||
|
<p style={{
|
||||||
|
fontFamily: "var(--font-ui)", fontSize: 11, fontWeight: 500,
|
||||||
|
letterSpacing: "0.26em", textTransform: "uppercase",
|
||||||
|
color: "var(--text-secondary)", margin: "0 0 8px",
|
||||||
|
zIndex: 1, userSelect: "none",
|
||||||
|
}}>moku</p>
|
||||||
|
<div style={{ zIndex: 1, display: "flex", flexDirection: "column", alignItems: "center", gap: 8 }}>
|
||||||
|
{failed ? (
|
||||||
|
<>
|
||||||
|
<p style={{ fontFamily: "var(--font-ui)", fontSize: 11, color: "var(--color-error)", letterSpacing: "0.1em", margin: 0 }}>
|
||||||
|
Could not reach Suwayomi
|
||||||
|
</p>
|
||||||
|
<p style={{ fontFamily: "var(--font-ui)", fontSize: 10, color: "var(--text-faint)", letterSpacing: "0.05em", margin: 0, textAlign: "center", maxWidth: 240, lineHeight: "1.6" }}>
|
||||||
|
Make sure tachidesk-server is on your PATH
|
||||||
|
</p>
|
||||||
|
<button onClick={onRetry} style={{
|
||||||
|
marginTop: 4, padding: "5px 16px", borderRadius: "var(--radius-md)",
|
||||||
|
border: "1px solid var(--border-dim)", background: "var(--bg-raised)",
|
||||||
|
color: "var(--text-muted)", cursor: "pointer",
|
||||||
|
fontFamily: "var(--font-ui)", fontSize: 11, letterSpacing: "0.08em",
|
||||||
|
}}>Retry</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p style={{ fontFamily: "var(--font-ui)", fontSize: 10, color: "var(--text-faint)", letterSpacing: "0.12em", margin: 0, minWidth: 160, textAlign: "center" }}>
|
||||||
|
{ringFull ? "Ready" : `Initializing server${dots}`}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
.toaster {
|
||||||
|
position: fixed;
|
||||||
|
bottom: var(--sp-5);
|
||||||
|
right: var(--sp-5);
|
||||||
|
z-index: 9999;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
pointer-events: none;
|
||||||
|
max-width: 320px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
padding: var(--sp-2) var(--sp-3);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
border: 1px solid var(--border-base);
|
||||||
|
background: var(--bg-raised);
|
||||||
|
box-shadow: 0 4px 24px rgba(0,0,0,0.45), 0 0 0 1px rgba(0,0,0,0.08);
|
||||||
|
pointer-events: all;
|
||||||
|
animation: toastIn 0.18s cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||||
|
min-width: 220px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes toastIn {
|
||||||
|
from { opacity: 0; transform: translateX(24px) scale(0.96); }
|
||||||
|
to { opacity: 1; transform: translateX(0) scale(1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Kind variants */
|
||||||
|
.toast_success { border-color: var(--accent-dim); }
|
||||||
|
.toast_success .toastIcon { color: var(--accent-fg); }
|
||||||
|
|
||||||
|
.toast_error { border-color: var(--color-error); }
|
||||||
|
.toast_error .toastIcon { color: var(--color-error); }
|
||||||
|
|
||||||
|
.toast_download .toastIcon { color: var(--accent-fg); }
|
||||||
|
.toast_info .toastIcon { color: var(--text-muted); }
|
||||||
|
|
||||||
|
.toastIcon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-top: 2px;
|
||||||
|
color: var(--text-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toastBody {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toastTitle {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-weight: var(--weight-medium);
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toastSub {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--text-faint);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toastClose {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--text-faint);
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-top: 1px;
|
||||||
|
transition: color var(--t-base), background var(--t-base);
|
||||||
|
}
|
||||||
|
.toastClose:hover { color: var(--text-muted); background: var(--bg-overlay); }
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
import { CheckCircle, X, WarningCircle, Info, DownloadSimple } from "@phosphor-icons/react";
|
||||||
|
import { useStore } from "../../store";
|
||||||
|
import s from "./Toaster.module.css";
|
||||||
|
|
||||||
|
export type ToastKind = "success" | "error" | "info" | "download";
|
||||||
|
|
||||||
|
export interface Toast {
|
||||||
|
id: string;
|
||||||
|
kind: ToastKind;
|
||||||
|
title: string;
|
||||||
|
body?: string;
|
||||||
|
duration?: number; // ms, 0 = persistent
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── icons per kind ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function ToastIcon({ kind }: { kind: ToastKind }) {
|
||||||
|
const size = 15;
|
||||||
|
const w = "light" as const;
|
||||||
|
if (kind === "success") return <CheckCircle size={size} weight={w} />;
|
||||||
|
if (kind === "error") return <WarningCircle size={size} weight={w} />;
|
||||||
|
if (kind === "download") return <DownloadSimple size={size} weight={w} />;
|
||||||
|
return <Info size={size} weight={w} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── individual toast ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function ToastItem({ toast }: { toast: Toast }) {
|
||||||
|
const dismissToast = useStore((s) => s.dismissToast);
|
||||||
|
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
const duration = toast.duration ?? 3500;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (duration === 0) return;
|
||||||
|
timerRef.current = setTimeout(() => dismissToast(toast.id), duration);
|
||||||
|
return () => { if (timerRef.current) clearTimeout(timerRef.current); };
|
||||||
|
}, [toast.id, duration]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={[s.toast, s[`toast_${toast.kind}`]].join(" ")} role="alert">
|
||||||
|
<span className={s.toastIcon}><ToastIcon kind={toast.kind} /></span>
|
||||||
|
<div className={s.toastBody}>
|
||||||
|
<p className={s.toastTitle}>{toast.title}</p>
|
||||||
|
{toast.body && <p className={s.toastSub}>{toast.body}</p>}
|
||||||
|
</div>
|
||||||
|
<button className={s.toastClose} onClick={() => dismissToast(toast.id)} title="Dismiss">
|
||||||
|
<X size={12} weight="light" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── toaster container ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export default function Toaster() {
|
||||||
|
const toasts = useStore((s) => s.toasts);
|
||||||
|
|
||||||
|
if (!toasts.length) return null;
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div className={s.toaster} aria-live="polite">
|
||||||
|
{toasts.map((t) => <ToastItem key={t.id} toast={t} />)}
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -38,6 +38,44 @@
|
|||||||
}
|
}
|
||||||
.clearBtn:hover { color: var(--color-error); background: var(--color-error-bg); }
|
.clearBtn:hover { color: var(--color-error); background: var(--color-error-bg); }
|
||||||
|
|
||||||
|
.statsBar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-3);
|
||||||
|
padding: var(--sp-2) var(--sp-6);
|
||||||
|
border-bottom: 1px solid var(--border-dim);
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: var(--bg-raised);
|
||||||
|
}
|
||||||
|
|
||||||
|
.statItem {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statVal {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: var(--weight-medium);
|
||||||
|
color: var(--accent-fg);
|
||||||
|
letter-spacing: var(--tracking-tight);
|
||||||
|
}
|
||||||
|
|
||||||
|
.statLabel {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-2xs);
|
||||||
|
color: var(--text-faint);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
}
|
||||||
|
|
||||||
|
.statDivider {
|
||||||
|
width: 1px;
|
||||||
|
height: 12px;
|
||||||
|
background: var(--border-dim);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.list { flex: 1; overflow-y: auto; padding: var(--sp-4) var(--sp-6); }
|
.list { flex: 1; overflow-y: auto; padding: var(--sp-4) var(--sp-6); }
|
||||||
|
|
||||||
.group { margin-bottom: var(--sp-5); }
|
.group { margin-bottom: var(--sp-5); }
|
||||||
|
|||||||
@@ -28,10 +28,18 @@ function dayLabel(ts: number): string {
|
|||||||
return d.toLocaleDateString("en-US", { weekday: "long", month: "long", day: "numeric" });
|
return d.toLocaleDateString("en-US", { weekday: "long", month: "long", day: "numeric" });
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Session grouping ──────────────────────────────────────────────────────────
|
// Estimate reading time: ~8 seconds per page, counted from chapter entries
|
||||||
// Consecutive entries for the same manga within SESSION_GAP_MS are collapsed
|
// Each unique chapter read ≈ pageCount pages (fallback 30 if unknown)
|
||||||
// into one session card showing the chapter range read.
|
function formatReadTime(minutes: number): string {
|
||||||
|
if (minutes < 1) return "< 1 min";
|
||||||
|
if (minutes < 60) return `${minutes} min`;
|
||||||
|
const h = Math.floor(minutes / 60);
|
||||||
|
const m = minutes % 60;
|
||||||
|
if (m === 0) return `${h}h`;
|
||||||
|
return `${h}h ${m}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Session grouping ──────────────────────────────────────────────────────────
|
||||||
const SESSION_GAP_MS = 30 * 60 * 1000; // 30 min
|
const SESSION_GAP_MS = 30 * 60 * 1000; // 30 min
|
||||||
|
|
||||||
export interface ReadingSession {
|
export interface ReadingSession {
|
||||||
@@ -97,7 +105,8 @@ export default function History() {
|
|||||||
const history = useStore((s) => s.history);
|
const history = useStore((s) => s.history);
|
||||||
const clearHistory = useStore((s) => s.clearHistory);
|
const clearHistory = useStore((s) => s.clearHistory);
|
||||||
const setActiveManga = useStore((s) => s.setActiveManga);
|
const setActiveManga = useStore((s) => s.setActiveManga);
|
||||||
const setNavPage = useStore((s) => s.setNavPage);
|
const openReader = useStore((s) => s.openReader);
|
||||||
|
const activeChapterList = useStore((s) => s.activeChapterList);
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
@@ -111,9 +120,28 @@ export default function History() {
|
|||||||
const sessions = useMemo(() => buildSessions(filtered), [filtered]);
|
const sessions = useMemo(() => buildSessions(filtered), [filtered]);
|
||||||
const groups = useMemo(() => groupSessionsByDay(sessions), [sessions]);
|
const groups = useMemo(() => groupSessionsByDay(sessions), [sessions]);
|
||||||
|
|
||||||
|
// ── Stats ─────────────────────────────────────────────────────────────────
|
||||||
|
const stats = useMemo(() => {
|
||||||
|
if (!history.length) return null;
|
||||||
|
// Unique chapters read
|
||||||
|
const uniqueChapters = new Set(history.map((e) => e.chapterId)).size;
|
||||||
|
// Unique manga read
|
||||||
|
const uniqueManga = new Set(history.map((e) => e.mangaId)).size;
|
||||||
|
// Estimated read time: average ~45 pages/chapter at ~6s/page = ~4.5 min/chapter
|
||||||
|
const estimatedMinutes = Math.round(uniqueChapters * 4.5);
|
||||||
|
return { uniqueChapters, uniqueManga, estimatedMinutes };
|
||||||
|
}, [history]);
|
||||||
|
|
||||||
function resumeReading(session: ReadingSession) {
|
function resumeReading(session: ReadingSession) {
|
||||||
setActiveManga({ id: session.mangaId, title: session.mangaTitle, thumbnailUrl: session.thumbnailUrl } as any);
|
// If the chapter list is available in store (user already visited this manga),
|
||||||
setNavPage("library");
|
// open the reader directly for a snappier experience
|
||||||
|
const chapterInList = activeChapterList.find((c) => c.id === session.latestChapterId);
|
||||||
|
if (chapterInList && activeChapterList.length > 0) {
|
||||||
|
openReader(chapterInList, activeChapterList);
|
||||||
|
} else {
|
||||||
|
// Fall back to opening SeriesDetail — it will show the continue button
|
||||||
|
setActiveManga({ id: session.mangaId, title: session.mangaTitle, thumbnailUrl: session.thumbnailUrl } as any);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -137,6 +165,25 @@ export default function History() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{stats && (
|
||||||
|
<div className={s.statsBar}>
|
||||||
|
<span className={s.statItem}>
|
||||||
|
<span className={s.statVal}>{stats.uniqueChapters}</span>
|
||||||
|
<span className={s.statLabel}>chapters read</span>
|
||||||
|
</span>
|
||||||
|
<span className={s.statDivider} />
|
||||||
|
<span className={s.statItem}>
|
||||||
|
<span className={s.statVal}>{stats.uniqueManga}</span>
|
||||||
|
<span className={s.statLabel}>series</span>
|
||||||
|
</span>
|
||||||
|
<span className={s.statDivider} />
|
||||||
|
<span className={s.statItem}>
|
||||||
|
<span className={s.statVal}>{formatReadTime(stats.estimatedMinutes)}</span>
|
||||||
|
<span className={s.statLabel}>est. read time</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{history.length === 0 ? (
|
{history.length === 0 ? (
|
||||||
<div className={s.empty}>
|
<div className={s.empty}>
|
||||||
<ClockCounterClockwise size={32} weight="light" className={s.emptyIcon} />
|
<ClockCounterClockwise size={32} weight="light" className={s.emptyIcon} />
|
||||||
|
|||||||
@@ -1,23 +1,32 @@
|
|||||||
import { useEffect, useState, useMemo, useCallback, memo, useRef } from "react";
|
import { useEffect, useState, useMemo, useCallback, memo, useRef } from "react";
|
||||||
import { MagnifyingGlass, Books, DownloadSimple, X, Folder, Trash, BookOpen, BookmarkSimple } from "@phosphor-icons/react";
|
import { MagnifyingGlass, Books, DownloadSimple, X, Folder, FolderSimplePlus, Trash, BookOpen, BookmarkSimple } from "@phosphor-icons/react";
|
||||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||||
import { gql, thumbUrl } from "../../lib/client";
|
import { gql, thumbUrl } from "../../lib/client";
|
||||||
import { GET_LIBRARY, UPDATE_MANGA, GET_CHAPTERS, DELETE_DOWNLOADED_CHAPTERS } from "../../lib/queries";
|
import { GET_LIBRARY, UPDATE_MANGA, GET_CHAPTERS, DELETE_DOWNLOADED_CHAPTERS, DEQUEUE_DOWNLOAD } from "../../lib/queries";
|
||||||
|
import { cache, CACHE_KEYS } from "../../lib/cache";
|
||||||
import { useStore } from "../../store";
|
import { useStore } from "../../store";
|
||||||
import type { Manga, Chapter } from "../../lib/types";
|
import type { Manga, Chapter } from "../../lib/types";
|
||||||
import ContextMenu, { type ContextMenuEntry } from "../context/ContextMenu";
|
import ContextMenu, { type ContextMenuEntry } from "../context/ContextMenu";
|
||||||
import s from "./Library.module.css";
|
import s from "./Library.module.css";
|
||||||
|
|
||||||
// Keep in sync with CSS grid: minmax(130px, 1fr) + var(--sp-4)=16px gap
|
|
||||||
const CARD_MIN_W = 130;
|
const CARD_MIN_W = 130;
|
||||||
const CARD_GAP = 16;
|
const CARD_GAP = 16;
|
||||||
const ROW_HEIGHT = 260; // ~195px cover + ~40px title + 16px gap + buffer
|
const ROW_HEIGHT = 260;
|
||||||
|
|
||||||
|
function FadeImg({ src, alt, className, objectFit }: { src: string; alt: string; className?: string; objectFit?: string }) {
|
||||||
|
const [loaded, setLoaded] = useState(false);
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={src} alt={alt} className={className}
|
||||||
|
loading="lazy" decoding="async"
|
||||||
|
style={{ objectFit: (objectFit ?? "cover") as any, opacity: loaded ? 1 : 0, transition: loaded ? "opacity 0.15s ease" : "none" }}
|
||||||
|
onLoad={() => setLoaded(true)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const MangaCard = memo(function MangaCard({
|
const MangaCard = memo(function MangaCard({
|
||||||
manga,
|
manga, onClick, onContextMenu, cropCovers,
|
||||||
onClick,
|
|
||||||
onContextMenu,
|
|
||||||
cropCovers,
|
|
||||||
}: {
|
}: {
|
||||||
manga: Manga;
|
manga: Manga;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
@@ -27,13 +36,11 @@ const MangaCard = memo(function MangaCard({
|
|||||||
return (
|
return (
|
||||||
<button className={s.card} onClick={onClick} onContextMenu={onContextMenu}>
|
<button className={s.card} onClick={onClick} onContextMenu={onContextMenu}>
|
||||||
<div className={s.coverWrap}>
|
<div className={s.coverWrap}>
|
||||||
<img
|
<FadeImg
|
||||||
src={thumbUrl(manga.thumbnailUrl)}
|
src={thumbUrl(manga.thumbnailUrl)}
|
||||||
alt={manga.title}
|
alt={manga.title}
|
||||||
className={s.cover}
|
className={s.cover}
|
||||||
style={{ objectFit: cropCovers ? "cover" : "contain" }}
|
objectFit={cropCovers ? "cover" : "contain"}
|
||||||
loading="lazy"
|
|
||||||
decoding="async"
|
|
||||||
/>
|
/>
|
||||||
{!!manga.downloadCount && (
|
{!!manga.downloadCount && (
|
||||||
<span className={s.downloadedBadge}>{manga.downloadCount}</span>
|
<span className={s.downloadedBadge}>{manga.downloadCount}</span>
|
||||||
@@ -44,13 +51,21 @@ const MangaCard = memo(function MangaCard({
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function fetchLibrary() {
|
||||||
|
return cache.get(CACHE_KEYS.LIBRARY, () =>
|
||||||
|
gql<{ mangas: { nodes: Manga[] } }>(GET_LIBRARY).then((lib) => lib.mangas.nodes)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function Library() {
|
export default function Library() {
|
||||||
const [allManga, setAllManga] = useState<Manga[]>([]);
|
const [allManga, setAllManga] = useState<Manga[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [search, setSearch] = useState("");
|
const [retryCount, setRetryCount] = useState(0);
|
||||||
const [ctx, setCtx] = useState<{ x: number; y: number; manga: Manga } | null>(null);
|
const [search, setSearch] = useState("");
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const [ctx, setCtx] = useState<{ x: number; y: number; manga: Manga } | null>(null);
|
||||||
|
const [emptyCtx, setEmptyCtx] = useState<{ x: number; y: number } | null>(null);
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const setActiveManga = useStore((state) => state.setActiveManga);
|
const setActiveManga = useStore((state) => state.setActiveManga);
|
||||||
const libraryFilter = useStore((state) => state.libraryFilter);
|
const libraryFilter = useStore((state) => state.libraryFilter);
|
||||||
@@ -58,16 +73,39 @@ export default function Library() {
|
|||||||
const settings = useStore((state) => state.settings);
|
const settings = useStore((state) => state.settings);
|
||||||
const libraryTagFilter = useStore((state) => state.libraryTagFilter);
|
const libraryTagFilter = useStore((state) => state.libraryTagFilter);
|
||||||
const setLibraryTagFilter = useStore((state) => state.setLibraryTagFilter);
|
const setLibraryTagFilter = useStore((state) => state.setLibraryTagFilter);
|
||||||
const folders = useStore((state) => state.settings.folders);
|
const setGenreFilter = useStore((state) => state.setGenreFilter);
|
||||||
|
const folders = useStore((state) => state.settings.folders);
|
||||||
|
const addFolder = useStore((state) => state.addFolder);
|
||||||
|
const assignMangaToFolder = useStore((state) => state.assignMangaToFolder);
|
||||||
|
const removeMangaFromFolder = useStore((state) => state.removeMangaFromFolder);
|
||||||
|
|
||||||
useEffect(() => {
|
const loadData = useCallback((showLoading = false) => {
|
||||||
gql<{ mangas: { nodes: Manga[] } }>(GET_LIBRARY)
|
if (showLoading) setLoading(true);
|
||||||
.then((lib) => setAllManga(lib.mangas.nodes))
|
// Clear a previously failed cache entry so we actually retry the network call
|
||||||
|
if (!cache.has(CACHE_KEYS.LIBRARY)) {
|
||||||
|
// cache miss — fresh fetch, nothing to clear
|
||||||
|
}
|
||||||
|
fetchLibrary()
|
||||||
|
.then((nodes) => { setAllManga(nodes); setError(null); })
|
||||||
.catch((e) => setError(e.message))
|
.catch((e) => setError(e.message))
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Reset scroll when filter/search changes
|
// Initial load — delayed on first mount so the server has time to start.
|
||||||
|
// retryCount bumps force a re-run; manual retries clear the cache first.
|
||||||
|
useEffect(() => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
if (retryCount > 0) cache.clear(CACHE_KEYS.LIBRARY);
|
||||||
|
loadData(false);
|
||||||
|
|
||||||
|
// Re-fetch when library cache is invalidated by other pages
|
||||||
|
const unsub = cache.subscribe(CACHE_KEYS.LIBRARY, () => loadData(false));
|
||||||
|
return unsub;
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [retryCount]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
scrollRef.current?.scrollTo({ top: 0 });
|
scrollRef.current?.scrollTo({ top: 0 });
|
||||||
}, [libraryFilter, search]);
|
}, [libraryFilter, search]);
|
||||||
@@ -99,8 +137,6 @@ export default function Library() {
|
|||||||
}, [allManga, libraryFilter, search, libraryTagFilter, folders, isBuiltinFilter]);
|
}, [allManga, libraryFilter, search, libraryTagFilter, folders, isBuiltinFilter]);
|
||||||
|
|
||||||
// ── Virtualizer setup ──────────────────────────────────────────────────────
|
// ── Virtualizer setup ──────────────────────────────────────────────────────
|
||||||
// We need to know columns to chunk filtered into rows.
|
|
||||||
// Use a ResizeObserver on the scroll container to get real width.
|
|
||||||
const [containerWidth, setContainerWidth] = useState(800);
|
const [containerWidth, setContainerWidth] = useState(800);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -136,15 +172,19 @@ export default function Library() {
|
|||||||
|
|
||||||
async function removeFromLibrary(manga: Manga) {
|
async function removeFromLibrary(manga: Manga) {
|
||||||
await gql(UPDATE_MANGA, { id: manga.id, inLibrary: false }).catch(console.error);
|
await gql(UPDATE_MANGA, { id: manga.id, inLibrary: false }).catch(console.error);
|
||||||
|
// Optimistic update first, then invalidate cache
|
||||||
setAllManga((prev) => prev.map((m) => m.id === manga.id ? { ...m, inLibrary: false } : m));
|
setAllManga((prev) => prev.map((m) => m.id === manga.id ? { ...m, inLibrary: false } : m));
|
||||||
|
cache.clear(CACHE_KEYS.LIBRARY);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteAllDownloads(manga: Manga) {
|
async function deleteAllDownloads(manga: Manga) {
|
||||||
try {
|
try {
|
||||||
const data = await gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId: manga.id });
|
const data = await gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId: manga.id });
|
||||||
const ids = data.chapters.nodes.filter((c) => c.isDownloaded).map((c) => c.id);
|
const downloadedChapters = data.chapters.nodes.filter((c) => c.isDownloaded);
|
||||||
|
const ids = downloadedChapters.map((c) => c.id);
|
||||||
if (!ids.length) return;
|
if (!ids.length) return;
|
||||||
await gql(DELETE_DOWNLOADED_CHAPTERS, { ids });
|
await gql(DELETE_DOWNLOADED_CHAPTERS, { ids });
|
||||||
|
await Promise.allSettled(ids.map((id) => gql(DEQUEUE_DOWNLOAD, { chapterId: id })));
|
||||||
setAllManga((prev) => prev.map((m) => m.id === manga.id ? { ...m, downloadCount: 0 } : m));
|
setAllManga((prev) => prev.map((m) => m.id === manga.id ? { ...m, downloadCount: 0 } : m));
|
||||||
} catch (e) { console.error(e); }
|
} catch (e) { console.error(e); }
|
||||||
}
|
}
|
||||||
@@ -157,6 +197,15 @@ export default function Library() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildCtxItems(m: Manga): ContextMenuEntry[] {
|
function buildCtxItems(m: Manga): ContextMenuEntry[] {
|
||||||
|
const mangaFolderEntries: ContextMenuEntry[] = folders.map((f) => {
|
||||||
|
const inFolder = f.mangaIds.includes(m.id);
|
||||||
|
return {
|
||||||
|
label: inFolder ? `✓ ${f.name}` : f.name,
|
||||||
|
icon: <Folder size={13} weight={inFolder ? "fill" : "light"} />,
|
||||||
|
onClick: () => inFolder ? removeMangaFromFolder(f.id, m.id) : assignMangaToFolder(f.id, m.id),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
label: "Open",
|
label: "Open",
|
||||||
@@ -171,7 +220,10 @@ export default function Library() {
|
|||||||
onClick: () => m.inLibrary
|
onClick: () => m.inLibrary
|
||||||
? removeFromLibrary(m)
|
? removeFromLibrary(m)
|
||||||
: gql(UPDATE_MANGA, { id: m.id, inLibrary: true })
|
: gql(UPDATE_MANGA, { id: m.id, inLibrary: true })
|
||||||
.then(() => setAllManga((prev) => prev.map((x) => x.id === m.id ? { ...x, inLibrary: true } : x)))
|
.then(() => {
|
||||||
|
setAllManga((prev) => prev.map((x) => x.id === m.id ? { ...x, inLibrary: true } : x));
|
||||||
|
cache.clear(CACHE_KEYS.LIBRARY);
|
||||||
|
})
|
||||||
.catch(console.error),
|
.catch(console.error),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -181,6 +233,35 @@ export default function Library() {
|
|||||||
disabled: !(m.downloadCount && m.downloadCount > 0),
|
disabled: !(m.downloadCount && m.downloadCount > 0),
|
||||||
onClick: () => deleteAllDownloads(m),
|
onClick: () => deleteAllDownloads(m),
|
||||||
},
|
},
|
||||||
|
...(folders.length > 0 ? [
|
||||||
|
{ separator: true } as ContextMenuEntry,
|
||||||
|
...mangaFolderEntries,
|
||||||
|
] : []),
|
||||||
|
{ separator: true },
|
||||||
|
{
|
||||||
|
label: "New folder",
|
||||||
|
icon: <FolderSimplePlus size={13} weight="light" />,
|
||||||
|
onClick: () => {
|
||||||
|
const name = prompt("Folder name:");
|
||||||
|
if (name?.trim()) {
|
||||||
|
const id = addFolder(name.trim());
|
||||||
|
assignMangaToFolder(id, m.id);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildEmptyCtxItems(): ContextMenuEntry[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: "New folder",
|
||||||
|
icon: <FolderSimplePlus size={13} weight="light" />,
|
||||||
|
onClick: () => {
|
||||||
|
const name = prompt("Folder name:");
|
||||||
|
if (name?.trim()) addFolder(name.trim());
|
||||||
|
},
|
||||||
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -203,12 +284,26 @@ export default function Library() {
|
|||||||
if (error) return (
|
if (error) return (
|
||||||
<div className={s.center}>
|
<div className={s.center}>
|
||||||
<p className={s.errorMsg}>Could not reach Suwayomi</p>
|
<p className={s.errorMsg}>Could not reach Suwayomi</p>
|
||||||
<p className={s.errorDetail}>{error}</p>
|
<p className={s.errorDetail}>Make sure the server is running, then retry.</p>
|
||||||
|
<button
|
||||||
|
style={{ marginTop: "var(--sp-3)", padding: "6px 16px", borderRadius: "var(--radius-md)", border: "1px solid var(--border-dim)", background: "var(--bg-raised)", color: "var(--text-muted)", cursor: "pointer", fontFamily: "var(--font-ui)", fontSize: "var(--text-xs)", letterSpacing: "var(--tracking-wide)" }}
|
||||||
|
onClick={() => setRetryCount((c) => c + 1)}
|
||||||
|
>
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={s.root} ref={scrollRef}>
|
<div
|
||||||
|
className={s.root}
|
||||||
|
ref={scrollRef}
|
||||||
|
onContextMenu={(e) => {
|
||||||
|
if ((e.target as HTMLElement).closest("button")) return;
|
||||||
|
e.preventDefault();
|
||||||
|
setEmptyCtx({ x: e.clientX, y: e.clientY });
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div className={s.header}>
|
<div className={s.header}>
|
||||||
<div className={s.headerLeft}>
|
<div className={s.headerLeft}>
|
||||||
<h1 className={s.heading}>Library</h1>
|
<h1 className={s.heading}>Library</h1>
|
||||||
@@ -263,9 +358,7 @@ export default function Library() {
|
|||||||
return (
|
return (
|
||||||
<button key={tag}
|
<button key={tag}
|
||||||
className={[s.tagChip, active ? s.tagChipActive : ""].join(" ")}
|
className={[s.tagChip, active ? s.tagChipActive : ""].join(" ")}
|
||||||
onClick={() => setLibraryTagFilter(
|
onClick={() => setGenreFilter(tag)}>
|
||||||
active ? libraryTagFilter.filter((t) => t !== tag) : [...libraryTagFilter, tag]
|
|
||||||
)}>
|
|
||||||
{tag}
|
{tag}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
@@ -285,7 +378,7 @@ export default function Library() {
|
|||||||
) : filtered.length === 0 ? (
|
) : filtered.length === 0 ? (
|
||||||
<div className={s.center}>
|
<div className={s.center}>
|
||||||
{libraryFilter === "library"
|
{libraryFilter === "library"
|
||||||
? "No manga saved to library. Browse sources to add some."
|
? "No manga saved to library, browse sources to add some."
|
||||||
: libraryFilter === "downloaded"
|
: libraryFilter === "downloaded"
|
||||||
? "No downloaded manga."
|
? "No downloaded manga."
|
||||||
: !isBuiltinFilter
|
: !isBuiltinFilter
|
||||||
@@ -293,13 +386,7 @@ export default function Library() {
|
|||||||
: "No manga found."}
|
: "No manga found."}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
/* Virtual scroll container */
|
<div style={{ height: virtualizer.getTotalSize(), position: "relative" }}>
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
height: virtualizer.getTotalSize(),
|
|
||||||
position: "relative",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{virtualizer.getVirtualItems().map((virtualRow) => {
|
{virtualizer.getVirtualItems().map((virtualRow) => {
|
||||||
const rowManga = rows[virtualRow.index];
|
const rowManga = rows[virtualRow.index];
|
||||||
return (
|
return (
|
||||||
@@ -323,7 +410,6 @@ export default function Library() {
|
|||||||
cropCovers={settings.libraryCropCovers}
|
cropCovers={settings.libraryCropCovers}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{/* Ghost cards on last row to fill grid */}
|
|
||||||
{virtualRow.index === rows.length - 1 &&
|
{virtualRow.index === rows.length - 1 &&
|
||||||
Array.from({ length: cols - rowManga.length }).map((_, i) => (
|
Array.from({ length: cols - rowManga.length }).map((_, i) => (
|
||||||
<div key={`ghost-${i}`} className={s.ghostCard} aria-hidden />
|
<div key={`ghost-${i}`} className={s.ghostCard} aria-hidden />
|
||||||
@@ -335,12 +421,10 @@ export default function Library() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{ctx && (
|
{ctx && (
|
||||||
<ContextMenu
|
<ContextMenu x={ctx.x} y={ctx.y} items={buildCtxItems(ctx.manga)} onClose={() => setCtx(null)} />
|
||||||
x={ctx.x}
|
)}
|
||||||
y={ctx.y}
|
{emptyCtx && (
|
||||||
items={buildCtxItems(ctx.manga)}
|
<ContextMenu x={emptyCtx.x} y={emptyCtx.y} items={buildEmptyCtxItems()} onClose={() => setEmptyCtx(null)} />
|
||||||
onClose={() => setCtx(null)}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -476,3 +476,153 @@
|
|||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
border: 1px solid rgba(var(--color-error-rgb, 180, 60, 60), 0.2);
|
border: 1px solid rgba(var(--color-error-rgb, 180, 60, 60), 0.2);
|
||||||
}
|
}
|
||||||
|
/* ── Source context pill (step 2 header) ── */
|
||||||
|
.searchContext {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
padding: var(--sp-2) var(--sp-3);
|
||||||
|
background: var(--bg-raised);
|
||||||
|
border: 1px solid var(--border-dim);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.searchContextIcon {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
object-fit: cover;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.searchContextName {
|
||||||
|
flex: 1;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.searchContextChange {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-2xs);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
color: var(--accent-fg);
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: opacity var(--t-base);
|
||||||
|
}
|
||||||
|
.searchContextChange:hover { opacity: 0.75; }
|
||||||
|
|
||||||
|
/* ── Result row: updated layout with similarity ── */
|
||||||
|
.resultInfo {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resultMeta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bestMatchBadge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 3px;
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: 9px;
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--accent-fg);
|
||||||
|
background: var(--accent-muted);
|
||||||
|
border: 1px solid var(--accent-dim);
|
||||||
|
padding: 1px 5px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.simBar {
|
||||||
|
width: 48px;
|
||||||
|
height: 3px;
|
||||||
|
background: var(--bg-overlay);
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
overflow: hidden;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.simFill {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
background: var(--accent);
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
transition: width 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.simLabel {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-2xs);
|
||||||
|
color: var(--text-faint);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Confirm step additions ── */
|
||||||
|
.confirmDivider {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirmTag {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-2xs);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
text-transform: uppercase;
|
||||||
|
padding: 2px 7px;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
background: var(--bg-raised);
|
||||||
|
border: 1px solid var(--border-dim);
|
||||||
|
color: var(--text-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirmTagNew {
|
||||||
|
background: var(--accent-muted);
|
||||||
|
border-color: var(--accent-dim);
|
||||||
|
color: var(--accent-fg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.statGood { color: var(--color-success) !important; }
|
||||||
|
.statWarn { color: #d97706 !important; }
|
||||||
|
.statBad { color: var(--color-error) !important; }
|
||||||
|
|
||||||
|
.chapterDiff {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-2xs);
|
||||||
|
color: #d97706;
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
margin-left: var(--sp-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.warnBox {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
padding: var(--sp-2) var(--sp-3);
|
||||||
|
background: rgba(217, 119, 6, 0.08);
|
||||||
|
border: 1px solid rgba(217, 119, 6, 0.25);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: #d97706;
|
||||||
|
line-height: var(--leading-snug);
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect, useCallback } from "react";
|
||||||
import { X, MagnifyingGlass, CircleNotch, ArrowRight, Check, Warning } from "@phosphor-icons/react";
|
import { X, MagnifyingGlass, CircleNotch, ArrowRight, Check, Warning, Sparkle } from "@phosphor-icons/react";
|
||||||
import { gql, thumbUrl } from "../../lib/client";
|
import { gql, thumbUrl } from "../../lib/client";
|
||||||
import { GET_SOURCES, FETCH_SOURCE_MANGA, FETCH_CHAPTERS, UPDATE_MANGA, UPDATE_CHAPTERS_PROGRESS } from "../../lib/queries";
|
import { GET_SOURCES, FETCH_SOURCE_MANGA, FETCH_CHAPTERS, UPDATE_MANGA, UPDATE_CHAPTERS_PROGRESS } from "../../lib/queries";
|
||||||
import type { Manga, Source, Chapter } from "../../lib/types";
|
import type { Manga, Source, Chapter } from "../../lib/types";
|
||||||
@@ -18,20 +18,33 @@ interface Match {
|
|||||||
manga: Manga;
|
manga: Manga;
|
||||||
chapters: Chapter[];
|
chapters: Chapter[];
|
||||||
readCount: number;
|
readCount: number;
|
||||||
|
similarity: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simple title similarity: normalise → word overlap / Jaccard
|
||||||
|
function titleSimilarity(a: string, b: string): number {
|
||||||
|
const norm = (s: string) =>
|
||||||
|
s.toLowerCase().replace(/[^a-z0-9\s]/g, "").split(/\s+/).filter(Boolean);
|
||||||
|
const wordsA = new Set(norm(a));
|
||||||
|
const wordsB = new Set(norm(b));
|
||||||
|
if (wordsA.size === 0 || wordsB.size === 0) return 0;
|
||||||
|
const intersection = [...wordsA].filter((w) => wordsB.has(w)).length;
|
||||||
|
const union = new Set([...wordsA, ...wordsB]).size;
|
||||||
|
return intersection / union;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function MigrateModal({ manga, currentChapters, onClose, onMigrated }: Props) {
|
export default function MigrateModal({ manga, currentChapters, onClose, onMigrated }: Props) {
|
||||||
const [step, setStep] = useState<Step>("source");
|
const [step, setStep] = useState<Step>("source");
|
||||||
const [sources, setSources] = useState<Source[]>([]);
|
const [sources, setSources] = useState<Source[]>([]);
|
||||||
const [loadingSources, setLoadingSources] = useState(true);
|
const [loadingSources, setLoadingSources] = useState(true);
|
||||||
const [selectedSource, setSelectedSource] = useState<Source | null>(null);
|
const [selectedSource, setSelectedSource] = useState<Source | null>(null);
|
||||||
const [query, setQuery] = useState(manga.title);
|
const [query, setQuery] = useState(manga.title);
|
||||||
const [results, setResults] = useState<Manga[]>([]);
|
const [results, setResults] = useState<{ manga: Manga; similarity: number }[]>([]);
|
||||||
const [searching, setSearching] = useState(false);
|
const [searching, setSearching] = useState(false);
|
||||||
const [selectedMatch, setSelectedMatch] = useState<Match | null>(null);
|
const [selectedMatch, setSelectedMatch] = useState<Match | null>(null);
|
||||||
const [loadingMatch, setLoadingMatch] = useState(false);
|
const [loadingMatchId, setLoadingMatchId] = useState<number | null>(null);
|
||||||
const [migrating, setMigrating] = useState(false);
|
const [migrating, setMigrating] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
|
gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
|
||||||
@@ -40,25 +53,38 @@ export default function MigrateModal({ manga, currentChapters, onClose, onMigrat
|
|||||||
.finally(() => setLoadingSources(false));
|
.finally(() => setLoadingSources(false));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
async function searchSource() {
|
const searchSource = useCallback(async (src: Source, q: string) => {
|
||||||
if (!selectedSource || !query.trim()) return;
|
if (!src || !q.trim()) return;
|
||||||
setSearching(true);
|
setSearching(true);
|
||||||
setResults([]);
|
setResults([]);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const d = await gql<{ fetchSourceManga: { mangas: Manga[] } }>(FETCH_SOURCE_MANGA, {
|
const d = await gql<{ fetchSourceManga: { mangas: Manga[] } }>(FETCH_SOURCE_MANGA, {
|
||||||
source: selectedSource.id, type: "SEARCH", page: 1, query: query.trim(),
|
source: src.id, type: "SEARCH", page: 1, query: q.trim(),
|
||||||
});
|
});
|
||||||
setResults(d.fetchSourceManga.mangas);
|
const scored = d.fetchSourceManga.mangas.map((m) => ({
|
||||||
|
manga: m,
|
||||||
|
similarity: titleSimilarity(manga.title, m.title),
|
||||||
|
}));
|
||||||
|
// Sort by similarity desc so best matches float to top
|
||||||
|
scored.sort((a, b) => b.similarity - a.similarity);
|
||||||
|
setResults(scored);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setError(e.message);
|
setError(e.message);
|
||||||
} finally {
|
} finally {
|
||||||
setSearching(false);
|
setSearching(false);
|
||||||
}
|
}
|
||||||
|
}, [manga.title]);
|
||||||
|
|
||||||
|
function pickSource(src: Source) {
|
||||||
|
setSelectedSource(src);
|
||||||
|
setStep("search");
|
||||||
|
// Auto-search immediately with original title
|
||||||
|
searchSource(src, query);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function selectMatch(m: Manga) {
|
async function selectMatch(m: Manga, similarity: number) {
|
||||||
setLoadingMatch(true);
|
setLoadingMatchId(m.id);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const d = await gql<{ fetchChapters: { chapters: Chapter[] } }>(FETCH_CHAPTERS, { mangaId: m.id });
|
const d = await gql<{ fetchChapters: { chapters: Chapter[] } }>(FETCH_CHAPTERS, { mangaId: m.id });
|
||||||
@@ -67,12 +93,12 @@ export default function MigrateModal({ manga, currentChapters, onClose, onMigrat
|
|||||||
const old = currentChapters.find((o) => Math.abs(o.chapterNumber - c.chapterNumber) < 0.01);
|
const old = currentChapters.find((o) => Math.abs(o.chapterNumber - c.chapterNumber) < 0.01);
|
||||||
return old?.isRead;
|
return old?.isRead;
|
||||||
}).length;
|
}).length;
|
||||||
setSelectedMatch({ manga: m, chapters, readCount });
|
setSelectedMatch({ manga: m, chapters, readCount, similarity });
|
||||||
setStep("confirm");
|
setStep("confirm");
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setError(e.message);
|
setError(e.message);
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingMatch(false);
|
setLoadingMatchId(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,8 +108,6 @@ export default function MigrateModal({ manga, currentChapters, onClose, onMigrat
|
|||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const { manga: newManga, chapters: newChapters } = selectedMatch;
|
const { manga: newManga, chapters: newChapters } = selectedMatch;
|
||||||
|
|
||||||
// Build read/bookmark/progress maps from old chapters keyed by chapterNumber
|
|
||||||
const oldByNum = new Map(currentChapters.map((c) => [Math.round(c.chapterNumber * 100), c]));
|
const oldByNum = new Map(currentChapters.map((c) => [Math.round(c.chapterNumber * 100), c]));
|
||||||
|
|
||||||
const toMarkRead: number[] = [];
|
const toMarkRead: number[] = [];
|
||||||
@@ -96,25 +120,17 @@ export default function MigrateModal({ manga, currentChapters, onClose, onMigrat
|
|||||||
if (!old) continue;
|
if (!old) continue;
|
||||||
if (old.isRead) toMarkRead.push(nc.id);
|
if (old.isRead) toMarkRead.push(nc.id);
|
||||||
if (old.isBookmarked) toMarkBookmarked.push(nc.id);
|
if (old.isBookmarked) toMarkBookmarked.push(nc.id);
|
||||||
if ((old.lastPageRead ?? 0) > 0 && !old.isRead) {
|
if ((old.lastPageRead ?? 0) > 0 && !old.isRead)
|
||||||
progressUpdates.push({ id: nc.id, lastPageRead: old.lastPageRead! });
|
progressUpdates.push({ id: nc.id, lastPageRead: old.lastPageRead! });
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Migrate read state
|
if (toMarkRead.length)
|
||||||
if (toMarkRead.length) {
|
|
||||||
await gql(UPDATE_CHAPTERS_PROGRESS, { ids: toMarkRead, isRead: true });
|
await gql(UPDATE_CHAPTERS_PROGRESS, { ids: toMarkRead, isRead: true });
|
||||||
}
|
if (toMarkBookmarked.length)
|
||||||
// Migrate bookmarks
|
|
||||||
if (toMarkBookmarked.length) {
|
|
||||||
await gql(UPDATE_CHAPTERS_PROGRESS, { ids: toMarkBookmarked, isBookmarked: true });
|
await gql(UPDATE_CHAPTERS_PROGRESS, { ids: toMarkBookmarked, isBookmarked: true });
|
||||||
}
|
for (const { id, lastPageRead } of progressUpdates)
|
||||||
// Migrate in-progress pages one by one (different lastPageRead per chapter)
|
|
||||||
for (const { id, lastPageRead } of progressUpdates) {
|
|
||||||
await gql(UPDATE_CHAPTERS_PROGRESS, { ids: [id], lastPageRead });
|
await gql(UPDATE_CHAPTERS_PROGRESS, { ids: [id], lastPageRead });
|
||||||
}
|
|
||||||
|
|
||||||
// Add new to library, remove old
|
|
||||||
await gql(UPDATE_MANGA, { id: newManga.id, inLibrary: true });
|
await gql(UPDATE_MANGA, { id: newManga.id, inLibrary: true });
|
||||||
await gql(UPDATE_MANGA, { id: manga.id, inLibrary: false });
|
await gql(UPDATE_MANGA, { id: manga.id, inLibrary: false });
|
||||||
|
|
||||||
@@ -125,33 +141,48 @@ export default function MigrateModal({ manga, currentChapters, onClose, onMigrat
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const readCount = currentChapters.filter((c) => c.isRead).length;
|
const readCount = currentChapters.filter((c) => c.isRead).length;
|
||||||
const totalCount = currentChapters.length;
|
const totalCount = currentChapters.length;
|
||||||
|
|
||||||
|
const chapterDiff = selectedMatch
|
||||||
|
? selectedMatch.chapters.length - totalCount
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
const STEPS: Step[] = ["source", "search", "confirm"];
|
||||||
|
const stepIdx = STEPS.indexOf(step);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={s.overlay} onClick={(e) => e.target === e.currentTarget && onClose()}>
|
<div className={s.overlay} onClick={(e) => e.target === e.currentTarget && onClose()}>
|
||||||
<div className={s.modal}>
|
<div className={s.modal}>
|
||||||
|
|
||||||
|
{/* ── Header ── */}
|
||||||
<div className={s.modalHeader}>
|
<div className={s.modalHeader}>
|
||||||
<div className={s.modalTitle}>
|
<div className={s.modalTitle}>
|
||||||
<span className={s.modalTitleLabel}>Migrate source</span>
|
<span className={s.modalTitleLabel}>Migrate source</span>
|
||||||
<span className={s.modalTitleManga}>{manga.title}</span>
|
<span className={s.modalTitleManga}>{manga.title}</span>
|
||||||
</div>
|
</div>
|
||||||
<button className={s.closeBtn} onClick={onClose}>
|
<button className={s.closeBtn} onClick={onClose}><X size={14} weight="light" /></button>
|
||||||
<X size={14} weight="light" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Step indicators ── */}
|
{/* ── Step indicators ── */}
|
||||||
<div className={s.steps}>
|
<div className={s.steps}>
|
||||||
{(["source", "search", "confirm"] as Step[]).map((st, i) => (
|
{STEPS.map((st, i) => (
|
||||||
<div key={st} className={[s.step, step === st ? s.stepActive : "", i < ["source","search","confirm"].indexOf(step) ? s.stepDone : ""].join(" ").trim()}>
|
<div key={st}
|
||||||
<span className={s.stepDot}>{i < ["source","search","confirm"].indexOf(step) ? <Check size={9} weight="bold" /> : i + 1}</span>
|
className={[s.step, step === st ? s.stepActive : "", i < stepIdx ? s.stepDone : ""].join(" ").trim()}>
|
||||||
<span className={s.stepLabel}>{st.charAt(0).toUpperCase() + st.slice(1)}</span>
|
<span className={s.stepDot}>
|
||||||
|
{i < stepIdx ? <Check size={9} weight="bold" /> : i + 1}
|
||||||
|
</span>
|
||||||
|
<span className={s.stepLabel}>
|
||||||
|
{st === "source" ? "Pick source"
|
||||||
|
: st === "search" ? (selectedSource ? selectedSource.displayName : "Search")
|
||||||
|
: "Confirm"}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={s.body}>
|
<div className={s.body}>
|
||||||
|
|
||||||
{/* ── Step 1: Pick source ── */}
|
{/* ── Step 1: Pick source ── */}
|
||||||
{step === "source" && (
|
{step === "source" && (
|
||||||
<div className={s.sourceList}>
|
<div className={s.sourceList}>
|
||||||
@@ -163,11 +194,9 @@ export default function MigrateModal({ manga, currentChapters, onClose, onMigrat
|
|||||||
<div className={s.centered}><span className={s.hint}>No other sources installed.</span></div>
|
<div className={s.centered}><span className={s.hint}>No other sources installed.</span></div>
|
||||||
) : (
|
) : (
|
||||||
sources.map((src) => (
|
sources.map((src) => (
|
||||||
<button
|
<button key={src.id}
|
||||||
key={src.id}
|
|
||||||
className={[s.sourceRow, selectedSource?.id === src.id ? s.sourceRowActive : ""].join(" ").trim()}
|
className={[s.sourceRow, selectedSource?.id === src.id ? s.sourceRowActive : ""].join(" ").trim()}
|
||||||
onClick={() => { setSelectedSource(src); setStep("search"); searchSource(); }}
|
onClick={() => pickSource(src)}>
|
||||||
>
|
|
||||||
<img src={thumbUrl(src.iconUrl)} alt={src.name} className={s.sourceIcon}
|
<img src={thumbUrl(src.iconUrl)} alt={src.name} className={s.sourceIcon}
|
||||||
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
|
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
|
||||||
<div className={s.sourceInfo}>
|
<div className={s.sourceInfo}>
|
||||||
@@ -184,22 +213,34 @@ export default function MigrateModal({ manga, currentChapters, onClose, onMigrat
|
|||||||
{/* ── Step 2: Search & pick match ── */}
|
{/* ── Step 2: Search & pick match ── */}
|
||||||
{step === "search" && (
|
{step === "search" && (
|
||||||
<div className={s.searchStep}>
|
<div className={s.searchStep}>
|
||||||
|
|
||||||
|
{/* Source context pill */}
|
||||||
|
{selectedSource && (
|
||||||
|
<div className={s.searchContext}>
|
||||||
|
<img src={thumbUrl(selectedSource.iconUrl)} alt="" className={s.searchContextIcon}
|
||||||
|
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
|
||||||
|
<span className={s.searchContextName}>{selectedSource.displayName}</span>
|
||||||
|
<button className={s.searchContextChange} onClick={() => { setStep("source"); setResults([]); }}>
|
||||||
|
Change
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className={s.searchRow}>
|
<div className={s.searchRow}>
|
||||||
<div className={s.searchBar}>
|
<div className={s.searchBar}>
|
||||||
<MagnifyingGlass size={13} weight="light" className={s.searchIcon} />
|
<MagnifyingGlass size={13} weight="light" className={s.searchIcon} />
|
||||||
<input
|
<input className={s.searchInput} value={query}
|
||||||
className={s.searchInput}
|
|
||||||
value={query}
|
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
onKeyDown={(e) => e.key === "Enter" && searchSource()}
|
onKeyDown={(e) => e.key === "Enter" && selectedSource && searchSource(selectedSource, query)}
|
||||||
autoFocus
|
placeholder="Search title…"
|
||||||
/>
|
autoFocus />
|
||||||
</div>
|
</div>
|
||||||
<button className={s.searchBtn} onClick={searchSource} disabled={searching}>
|
<button className={s.searchBtn}
|
||||||
{searching ? <CircleNotch size={13} weight="light" className="anim-spin" /> : "Search"}
|
onClick={() => selectedSource && searchSource(selectedSource, query)}
|
||||||
</button>
|
disabled={searching || !selectedSource}>
|
||||||
<button className={s.backBtn} onClick={() => { setStep("source"); setResults([]); }}>
|
{searching
|
||||||
Back
|
? <CircleNotch size={13} weight="light" className="anim-spin" />
|
||||||
|
: <><MagnifyingGlass size={12} weight="bold" /> Search</>}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -211,25 +252,40 @@ export default function MigrateModal({ manga, currentChapters, onClose, onMigrat
|
|||||||
<div className={["skeleton", s.skCover].join(" ")} />
|
<div className={["skeleton", s.skCover].join(" ")} />
|
||||||
<div className={s.skMeta}>
|
<div className={s.skMeta}>
|
||||||
<div className={["skeleton", s.skTitle].join(" ")} />
|
<div className={["skeleton", s.skTitle].join(" ")} />
|
||||||
|
<div className={["skeleton", s.skTitle].join(" ")} style={{ width: "40%" }} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{!searching && results.map((m) => (
|
{!searching && results.map(({ manga: m, similarity }, idx) => (
|
||||||
<button
|
<button key={m.id} className={s.resultRow}
|
||||||
key={m.id}
|
onClick={() => selectMatch(m, similarity)}
|
||||||
className={s.resultRow}
|
disabled={loadingMatchId !== null}>
|
||||||
onClick={() => selectMatch(m)}
|
|
||||||
disabled={loadingMatch}
|
|
||||||
>
|
|
||||||
<div className={s.resultCoverWrap}>
|
<div className={s.resultCoverWrap}>
|
||||||
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} className={s.resultCover} />
|
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} className={s.resultCover} />
|
||||||
</div>
|
</div>
|
||||||
<span className={s.resultTitle}>{m.title}</span>
|
<div className={s.resultInfo}>
|
||||||
{loadingMatch && <CircleNotch size={13} weight="light" className="anim-spin" style={{ color: "var(--text-faint)", marginLeft: "auto" }} />}
|
<span className={s.resultTitle}>{m.title}</span>
|
||||||
|
<div className={s.resultMeta}>
|
||||||
|
{idx === 0 && similarity > 0.5 && (
|
||||||
|
<span className={s.bestMatchBadge}>
|
||||||
|
<Sparkle size={9} weight="fill" /> Best match
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className={s.simBar}>
|
||||||
|
<span className={s.simFill} style={{ width: `${Math.round(similarity * 100)}%` }} />
|
||||||
|
</span>
|
||||||
|
<span className={s.simLabel}>{Math.round(similarity * 100)}% match</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{loadingMatchId === m.id
|
||||||
|
? <CircleNotch size={13} weight="light" className="anim-spin" style={{ color: "var(--text-faint)", flexShrink: 0 }} />
|
||||||
|
: <ArrowRight size={13} weight="light" style={{ color: "var(--text-faint)", flexShrink: 0, opacity: 0.5 }} />}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
{!searching && results.length === 0 && query && (
|
{!searching && results.length === 0 && !error && (
|
||||||
<div className={s.centered}><span className={s.hint}>No results.</span></div>
|
<div className={s.centered}>
|
||||||
|
<span className={s.hint}>{query ? "No results — try a different title." : "Enter a title to search."}</span>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -245,9 +301,12 @@ export default function MigrateModal({ manga, currentChapters, onClose, onMigrat
|
|||||||
</div>
|
</div>
|
||||||
<p className={s.confirmTitle}>{manga.title}</p>
|
<p className={s.confirmTitle}>{manga.title}</p>
|
||||||
<p className={s.confirmSource}>{manga.source?.displayName ?? "Unknown"}</p>
|
<p className={s.confirmSource}>{manga.source?.displayName ?? "Unknown"}</p>
|
||||||
|
<span className={s.confirmTag}>Current</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ArrowRight size={20} weight="light" className={s.confirmArrow} />
|
<div className={s.confirmDivider}>
|
||||||
|
<ArrowRight size={16} weight="light" className={s.confirmArrow} />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className={s.confirmManga}>
|
<div className={s.confirmManga}>
|
||||||
<div className={s.confirmCoverWrap}>
|
<div className={s.confirmCoverWrap}>
|
||||||
@@ -255,24 +314,39 @@ export default function MigrateModal({ manga, currentChapters, onClose, onMigrat
|
|||||||
</div>
|
</div>
|
||||||
<p className={s.confirmTitle}>{selectedMatch.manga.title}</p>
|
<p className={s.confirmTitle}>{selectedMatch.manga.title}</p>
|
||||||
<p className={s.confirmSource}>{selectedSource?.displayName ?? "Unknown"}</p>
|
<p className={s.confirmSource}>{selectedSource?.displayName ?? "Unknown"}</p>
|
||||||
|
<span className={[s.confirmTag, s.confirmTagNew].join(" ")}>New</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={s.confirmStats}>
|
<div className={s.confirmStats}>
|
||||||
|
<div className={s.statRow}>
|
||||||
|
<span className={s.statLabel}>Title match</span>
|
||||||
|
<span className={[s.statVal, selectedMatch.similarity > 0.7 ? s.statGood : selectedMatch.similarity > 0.4 ? s.statWarn : s.statBad].join(" ")}>
|
||||||
|
{Math.round(selectedMatch.similarity * 100)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<div className={s.statRow}>
|
<div className={s.statRow}>
|
||||||
<span className={s.statLabel}>Chapters on new source</span>
|
<span className={s.statLabel}>Chapters on new source</span>
|
||||||
<span className={s.statVal}>{selectedMatch.chapters.length}</span>
|
<span className={[s.statVal, chapterDiff < -5 ? s.statWarn : ""].join(" ").trim()}>
|
||||||
|
{selectedMatch.chapters.length}
|
||||||
|
{chapterDiff !== 0 && (
|
||||||
|
<span className={s.chapterDiff}>{chapterDiff > 0 ? `+${chapterDiff}` : chapterDiff} vs current</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className={s.statRow}>
|
<div className={s.statRow}>
|
||||||
<span className={s.statLabel}>Read progress to migrate</span>
|
<span className={s.statLabel}>Read progress to carry over</span>
|
||||||
<span className={s.statVal}>{readCount} / {totalCount} chapters</span>
|
<span className={s.statVal}>{selectedMatch.readCount} / {readCount} chapters</span>
|
||||||
</div>
|
|
||||||
<div className={s.statRow}>
|
|
||||||
<span className={s.statLabel}>Matched chapters</span>
|
|
||||||
<span className={s.statVal}>{selectedMatch.readCount} will carry over</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{chapterDiff < -5 && (
|
||||||
|
<div className={s.warnBox}>
|
||||||
|
<Warning size={13} weight="light" />
|
||||||
|
New source has {Math.abs(chapterDiff)} fewer chapters — some content may be missing.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<p className={s.confirmNote}>
|
<p className={s.confirmNote}>
|
||||||
The current entry will be removed from your library. Downloads are not transferred.
|
The current entry will be removed from your library. Downloads are not transferred.
|
||||||
</p>
|
</p>
|
||||||
@@ -286,7 +360,7 @@ export default function MigrateModal({ manga, currentChapters, onClose, onMigrat
|
|||||||
<button className={s.migrateBtn} onClick={migrate} disabled={migrating}>
|
<button className={s.migrateBtn} onClick={migrate} disabled={migrating}>
|
||||||
{migrating
|
{migrating
|
||||||
? <><CircleNotch size={13} weight="light" className="anim-spin" /> Migrating…</>
|
? <><CircleNotch size={13} weight="light" className="anim-spin" /> Migrating…</>
|
||||||
: "Migrate"}
|
: <><Check size={13} weight="bold" /> Migrate</>}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+115
-46
@@ -77,53 +77,86 @@ function DownloadModal({
|
|||||||
remaining,
|
remaining,
|
||||||
onClose,
|
onClose,
|
||||||
}: {
|
}: {
|
||||||
chapter: { id: number; name: string };
|
chapter: { id: number; name: string; isDownloaded?: boolean };
|
||||||
remaining: { id: number }[];
|
remaining: { id: number; isDownloaded?: boolean }[];
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}) {
|
}) {
|
||||||
|
const addToast = useStore((s) => s.addToast);
|
||||||
const [nextN, setNextN] = useState(5);
|
const [nextN, setNextN] = useState(5);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
const run = async (fn: () => Promise<unknown>) => {
|
// Only offer chapters that aren't already downloaded
|
||||||
|
const queueable = remaining.filter((c) => !c.isDownloaded);
|
||||||
|
|
||||||
|
const run = async (fn: () => Promise<unknown>, toastBody: string) => {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
await fn().catch(console.error);
|
try {
|
||||||
|
await fn();
|
||||||
|
addToast({ kind: "download", title: "Download queued", body: toastBody });
|
||||||
|
} catch (e) {
|
||||||
|
addToast({ kind: "error", title: "Queue failed", body: e instanceof Error ? e.message : String(e) });
|
||||||
|
}
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const thisAlreadyDl = !!chapter.isDownloaded;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={s.dlBackdrop} onClick={onClose}>
|
<div className={s.dlBackdrop} onClick={onClose}>
|
||||||
<div className={s.dlModal} onClick={(e) => e.stopPropagation()}>
|
<div className={s.dlModal} onClick={(e) => e.stopPropagation()}>
|
||||||
<p className={s.dlTitle}>Download</p>
|
<p className={s.dlTitle}>Download</p>
|
||||||
<button className={s.dlOption} disabled={busy}
|
<button
|
||||||
onClick={() => run(() => gql(ENQUEUE_DOWNLOAD, { chapterId: chapter.id }))}>
|
className={s.dlOption}
|
||||||
|
disabled={busy || thisAlreadyDl}
|
||||||
|
onClick={() => run(
|
||||||
|
() => gql(ENQUEUE_DOWNLOAD, { chapterId: chapter.id }),
|
||||||
|
thisAlreadyDl ? "" : chapter.name,
|
||||||
|
)}
|
||||||
|
>
|
||||||
This chapter
|
This chapter
|
||||||
<span className={s.dlSub}>{chapter.name}</span>
|
<span className={s.dlSub}>
|
||||||
|
{thisAlreadyDl ? "Already downloaded" : chapter.name}
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<div className={s.dlRow}>
|
<div className={s.dlRow}>
|
||||||
<button className={s.dlOption} disabled={busy || !remaining.length}
|
<button
|
||||||
onClick={() => run(() => gql(ENQUEUE_CHAPTERS_DOWNLOAD, {
|
className={s.dlOption}
|
||||||
chapterIds: remaining.slice(0, nextN).map((c) => c.id),
|
disabled={busy || queueable.length === 0}
|
||||||
}))}>
|
onClick={() => run(
|
||||||
|
() => gql(ENQUEUE_CHAPTERS_DOWNLOAD, {
|
||||||
|
chapterIds: queueable.slice(0, nextN).map((c) => c.id),
|
||||||
|
}),
|
||||||
|
`${Math.min(nextN, queueable.length)} chapters queued`,
|
||||||
|
)}
|
||||||
|
>
|
||||||
Next chapters
|
Next chapters
|
||||||
<span className={s.dlSub}>{Math.min(nextN, remaining.length)} queued</span>
|
<span className={s.dlSub}>{Math.min(nextN, queueable.length)} not yet downloaded</span>
|
||||||
</button>
|
</button>
|
||||||
<div className={s.dlStepper} onClick={(e) => e.stopPropagation()}>
|
<div className={s.dlStepper} onClick={(e) => e.stopPropagation()}>
|
||||||
<button className={s.dlStepBtn}
|
<button
|
||||||
|
className={s.dlStepBtn}
|
||||||
onClick={() => setNextN((n) => Math.max(1, n - 1))}
|
onClick={() => setNextN((n) => Math.max(1, n - 1))}
|
||||||
disabled={nextN <= 1}>−</button>
|
disabled={nextN <= 1}
|
||||||
|
>−</button>
|
||||||
<span className={s.dlStepVal}>{nextN}</span>
|
<span className={s.dlStepVal}>{nextN}</span>
|
||||||
<button className={s.dlStepBtn}
|
<button
|
||||||
onClick={() => setNextN((n) => Math.min(remaining.length || 1, n + 1))}
|
className={s.dlStepBtn}
|
||||||
disabled={nextN >= remaining.length}>+</button>
|
onClick={() => setNextN((n) => Math.min(queueable.length || 1, n + 1))}
|
||||||
|
disabled={nextN >= queueable.length}
|
||||||
|
>+</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button className={s.dlOption} disabled={busy || !remaining.length}
|
<button
|
||||||
onClick={() => run(() => gql(ENQUEUE_CHAPTERS_DOWNLOAD, {
|
className={s.dlOption}
|
||||||
chapterIds: remaining.map((c) => c.id),
|
disabled={busy || queueable.length === 0}
|
||||||
}))}>
|
onClick={() => run(
|
||||||
|
() => gql(ENQUEUE_CHAPTERS_DOWNLOAD, { chapterIds: queueable.map((c) => c.id) }),
|
||||||
|
`${queueable.length} chapter${queueable.length !== 1 ? "s" : ""} queued`,
|
||||||
|
)}
|
||||||
|
>
|
||||||
All remaining
|
All remaining
|
||||||
<span className={s.dlSub}>{remaining.length} chapters</span>
|
<span className={s.dlSub}>{queueable.length} not yet downloaded</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -223,9 +256,14 @@ export default function Reader() {
|
|||||||
* currently reading (for topbar display) without triggering a full reload.
|
* currently reading (for topbar display) without triggering a full reload.
|
||||||
*/
|
*/
|
||||||
const [visibleChapterId, setVisibleChapterId] = useState<number | null>(null);
|
const [visibleChapterId, setVisibleChapterId] = useState<number | null>(null);
|
||||||
|
// Ref mirror so the scroll handler always reads the latest value without
|
||||||
|
// closing over a stale state snapshot from a previous effect render.
|
||||||
|
const visibleChapterIdRef = useRef<number | null>(null);
|
||||||
|
|
||||||
// Keep the ref mirror in sync so the scroll handler always sees current strip state
|
// Keep the ref mirror in sync so the scroll handler always sees current strip state
|
||||||
useEffect(() => { stripChaptersRef.current = stripChapters; }, [stripChapters]);
|
useEffect(() => { stripChaptersRef.current = stripChapters; }, [stripChapters]);
|
||||||
|
// Keep visibleChapterId ref in sync
|
||||||
|
useEffect(() => { visibleChapterIdRef.current = visibleChapterId; }, [visibleChapterId]);
|
||||||
|
|
||||||
// Restore scroll position synchronously after a head-trim, before the browser paints
|
// Restore scroll position synchronously after a head-trim, before the browser paints
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
@@ -329,8 +367,11 @@ export default function Reader() {
|
|||||||
// Discard result if the user has already navigated to a different chapter
|
// Discard result if the user has already navigated to a different chapter
|
||||||
if (loadingChapterRef.current !== targetId) return;
|
if (loadingChapterRef.current !== targetId) return;
|
||||||
|
|
||||||
// Decode the first page before committing so no previous chapter flashes
|
// Decode the first page before committing so no previous chapter flashes.
|
||||||
await decodeImage(urls[0]);
|
// In longstrip mode skip the blocking decode — images stream in naturally.
|
||||||
|
if (style !== "longstrip") {
|
||||||
|
await decodeImage(urls[0]);
|
||||||
|
}
|
||||||
|
|
||||||
if (loadingChapterRef.current !== targetId) return;
|
if (loadingChapterRef.current !== targetId) return;
|
||||||
|
|
||||||
@@ -348,10 +389,14 @@ export default function Reader() {
|
|||||||
setStripChapters([]);
|
setStripChapters([]);
|
||||||
setVisibleChapterId(null);
|
setVisibleChapterId(null);
|
||||||
}
|
}
|
||||||
|
// Only clear loading after state is fully committed — no flash frames
|
||||||
|
setLoading(false);
|
||||||
})
|
})
|
||||||
.catch((e) => setError(e instanceof Error ? e.message : String(e)))
|
.catch((e) => {
|
||||||
.finally(() => {
|
if (loadingChapterRef.current === targetId) {
|
||||||
if (loadingChapterRef.current === targetId) setLoading(false);
|
setError(e instanceof Error ? e.message : String(e));
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}, [activeChapter?.id]);
|
}, [activeChapter?.id]);
|
||||||
|
|
||||||
@@ -507,6 +552,7 @@ export default function Reader() {
|
|||||||
}, [pageGroups, pageNumber, adjacent, activeChapterList]);
|
}, [pageGroups, pageNumber, adjacent, activeChapterList]);
|
||||||
|
|
||||||
const goForward = useCallback(() => {
|
const goForward = useCallback(() => {
|
||||||
|
if (loading || !pageUrls.length) return;
|
||||||
if (style === "double" && pageGroups.length) { advanceGroup(true); return; }
|
if (style === "double" && pageGroups.length) { advanceGroup(true); return; }
|
||||||
if (pageNumber < lastPage) {
|
if (pageNumber < lastPage) {
|
||||||
const nextUrl = pageUrls[pageNumber]; // pageNumber is 1-based, so index is pageNumber
|
const nextUrl = pageUrls[pageNumber]; // pageNumber is 1-based, so index is pageNumber
|
||||||
@@ -521,9 +567,10 @@ export default function Reader() {
|
|||||||
} else {
|
} else {
|
||||||
closeReader();
|
closeReader();
|
||||||
}
|
}
|
||||||
}, [pageNumber, lastPage, pageUrls, adjacent, activeChapterList, style, pageGroups, advanceGroup]);
|
}, [loading, pageNumber, lastPage, pageUrls, adjacent, activeChapterList, style, pageGroups, advanceGroup]);
|
||||||
|
|
||||||
const goBack = useCallback(() => {
|
const goBack = useCallback(() => {
|
||||||
|
if (loading || !pageUrls.length) return;
|
||||||
if (style === "double" && pageGroups.length) { advanceGroup(false); return; }
|
if (style === "double" && pageGroups.length) { advanceGroup(false); return; }
|
||||||
if (pageNumber > 1) {
|
if (pageNumber > 1) {
|
||||||
const prevUrl = pageUrls[pageNumber - 2]; // 0-based index of previous page
|
const prevUrl = pageUrls[pageNumber - 2]; // 0-based index of previous page
|
||||||
@@ -535,7 +582,7 @@ export default function Reader() {
|
|||||||
} else if (adjacent.prev) {
|
} else if (adjacent.prev) {
|
||||||
openReader(adjacent.prev, activeChapterList);
|
openReader(adjacent.prev, activeChapterList);
|
||||||
}
|
}
|
||||||
}, [pageNumber, pageUrls, adjacent, activeChapterList, style, pageGroups, advanceGroup]);
|
}, [loading, pageNumber, pageUrls, adjacent, activeChapterList, style, pageGroups, advanceGroup]);
|
||||||
|
|
||||||
const goNext = rtl ? goBack : goForward;
|
const goNext = rtl ? goBack : goForward;
|
||||||
const goPrev = rtl ? goForward : goBack;
|
const goPrev = rtl ? goForward : goBack;
|
||||||
@@ -600,8 +647,8 @@ export default function Reader() {
|
|||||||
else if (matchesKeybind(e, kb.pageLeft)) { e.preventDefault(); goBack(); }
|
else if (matchesKeybind(e, kb.pageLeft)) { e.preventDefault(); goBack(); }
|
||||||
else if (matchesKeybind(e, kb.firstPage)) { e.preventDefault(); setPageNumber(1); }
|
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.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.chapterRight)) { e.preventDefault(); if (!loading && 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.chapterLeft)) { e.preventDefault(); if (!loading && adjacent.prev) openReader(adjacent.prev, activeChapterList); }
|
||||||
else if (matchesKeybind(e, kb.togglePageStyle)) { e.preventDefault(); cycleStyle(); }
|
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.toggleReadingDirection)) { e.preventDefault(); updateSettings({ readingDirection: rtl ? "ltr" : "rtl" }); }
|
||||||
else if (matchesKeybind(e, kb.toggleFullscreen)) { e.preventDefault(); toggleFullscreen().catch(console.error); }
|
else if (matchesKeybind(e, kb.toggleFullscreen)) { e.preventDefault(); toggleFullscreen().catch(console.error); }
|
||||||
@@ -609,7 +656,7 @@ export default function Reader() {
|
|||||||
};
|
};
|
||||||
window.addEventListener("keydown", onKey);
|
window.addEventListener("keydown", onKey);
|
||||||
return () => window.removeEventListener("keydown", onKey);
|
return () => window.removeEventListener("keydown", onKey);
|
||||||
}, [goForward, goBack, kb, style, rtl, lastPage, adjacent, activeChapterList, zoomOpen, dlOpen, maxW]);
|
}, [goForward, goBack, kb, style, rtl, lastPage, adjacent, activeChapterList, zoomOpen, dlOpen, maxW, loading]);
|
||||||
|
|
||||||
// ── Longstrip scroll tracker ─────────────────────────────────────────────────
|
// ── Longstrip scroll tracker ─────────────────────────────────────────────────
|
||||||
// Tracks current page number. In autoNext mode, appends the next chapter's
|
// Tracks current page number. In autoNext mode, appends the next chapter's
|
||||||
@@ -639,33 +686,54 @@ export default function Reader() {
|
|||||||
|
|
||||||
// ── Infinite append ──────────────────────────────────────────────────
|
// ── Infinite append ──────────────────────────────────────────────────
|
||||||
if (!autoNext) {
|
if (!autoNext) {
|
||||||
const atBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 80;
|
// Only navigate when the strip genuinely overflows the viewport.
|
||||||
|
// If pages are short/zoomed-out, scrollHeight === clientHeight and
|
||||||
|
// atBottom would always be true, causing unwanted chapter switches.
|
||||||
|
const isScrollable = el.scrollHeight > el.clientHeight + 4;
|
||||||
|
const atBottom = isScrollable && el.scrollTop + el.clientHeight >= el.scrollHeight - 80;
|
||||||
if (atBottom && adjacent.next) openReader(adjacent.next, activeChapterList);
|
if (atBottom && adjacent.next) openReader(adjacent.next, activeChapterList);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const strip = stripChaptersRef.current;
|
const strip = stripChaptersRef.current;
|
||||||
|
|
||||||
// Silently update visibleChapterId as we scroll into each chunk
|
// Silently update visibleChapterId as we scroll into each chunk.
|
||||||
|
// Use the ref so we always compare against the current value, not a
|
||||||
|
// stale closure snapshot from when the effect was last set up.
|
||||||
for (const chunk of strip) {
|
for (const chunk of strip) {
|
||||||
const chunkEnd = chunk.startGlobalIdx + chunk.urls.length;
|
const chunkEnd = chunk.startGlobalIdx + chunk.urls.length;
|
||||||
if (n - 1 >= chunk.startGlobalIdx && n - 1 < chunkEnd) {
|
if (n - 1 >= chunk.startGlobalIdx && n - 1 < chunkEnd) {
|
||||||
if (chunk.chapterId !== visibleChapterId) {
|
if (chunk.chapterId !== visibleChapterIdRef.current) {
|
||||||
setVisibleChapterId(chunk.chapterId);
|
// Mark the chapter we just *left* as read before updating the ref.
|
||||||
if (settings.autoMarkRead) {
|
if (settings.autoMarkRead) {
|
||||||
const prevChunk = strip[strip.indexOf(chunk) - 1];
|
const chunkIdx = strip.indexOf(chunk);
|
||||||
if (prevChunk) {
|
const prevChunk = chunkIdx > 0 ? strip[chunkIdx - 1] : null;
|
||||||
if (!markedReadRef.current.has(prevChunk.chapterId)) {
|
if (prevChunk && !markedReadRef.current.has(prevChunk.chapterId)) {
|
||||||
markedReadRef.current.add(prevChunk.chapterId);
|
markedReadRef.current.add(prevChunk.chapterId);
|
||||||
gql(MARK_CHAPTER_READ, { id: prevChunk.chapterId, isRead: true }).catch(console.error);
|
gql(MARK_CHAPTER_READ, { id: prevChunk.chapterId, isRead: true }).catch(console.error);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
visibleChapterIdRef.current = chunk.chapterId;
|
||||||
|
setVisibleChapterId(chunk.chapterId);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// When the user reaches the very bottom of the full strip, mark the
|
||||||
|
// last chapter as read (it never triggers the "crossed into next chunk" path).
|
||||||
|
if (settings.autoMarkRead) {
|
||||||
|
const isScrollable = el.scrollHeight > el.clientHeight + 4;
|
||||||
|
const atVeryBottom = isScrollable && el.scrollTop + el.clientHeight >= el.scrollHeight - 40;
|
||||||
|
if (atVeryBottom) {
|
||||||
|
const lastChunk = strip[strip.length - 1];
|
||||||
|
if (lastChunk && !markedReadRef.current.has(lastChunk.chapterId)) {
|
||||||
|
markedReadRef.current.add(lastChunk.chapterId);
|
||||||
|
gql(MARK_CHAPTER_READ, { id: lastChunk.chapterId, isRead: true }).catch(console.error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Append next chapter when within 300px of the bottom
|
// Append next chapter when within 300px of the bottom
|
||||||
const nearBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 300;
|
const nearBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 300;
|
||||||
if (!nearBottom) return;
|
if (!nearBottom) return;
|
||||||
@@ -709,7 +777,7 @@ export default function Reader() {
|
|||||||
el.removeEventListener("scroll", onScroll);
|
el.removeEventListener("scroll", onScroll);
|
||||||
cancelAnimationFrame(rafRef.current);
|
cancelAnimationFrame(rafRef.current);
|
||||||
};
|
};
|
||||||
}, [style, autoNext, activeChapterList, activeChapter?.id, adjacent.next, fetchPages, visibleChapterId]);
|
}, [style, autoNext, activeChapterList, activeChapter?.id, adjacent.next, fetchPages]);
|
||||||
|
|
||||||
// Reset scroll position when switching chapters in non-longstrip modes
|
// Reset scroll position when switching chapters in non-longstrip modes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -900,6 +968,7 @@ export default function Reader() {
|
|||||||
style={cssVars}
|
style={cssVars}
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
onClick={handleTap}
|
onClick={handleTap}
|
||||||
|
onWheel={(e) => { if (e.ctrlKey) e.preventDefault(); }}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === " " && style === "longstrip") {
|
if (e.key === " " && style === "longstrip") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -935,11 +1004,11 @@ export default function Reader() {
|
|||||||
) : (
|
) : (
|
||||||
pageReady && (
|
pageReady && (
|
||||||
<img
|
<img
|
||||||
key={pageNumber}
|
|
||||||
src={pageUrls[pageNumber - 1]}
|
src={pageUrls[pageNumber - 1]}
|
||||||
alt={`Page ${pageNumber}`}
|
alt={`Page ${pageNumber}`}
|
||||||
className={imgCls}
|
className={imgCls}
|
||||||
decoding="async"
|
decoding="async"
|
||||||
|
style={{ transition: "opacity 0.1s ease" }}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
@@ -947,10 +1016,10 @@ export default function Reader() {
|
|||||||
|
|
||||||
{/* ── Bottom nav ── */}
|
{/* ── Bottom nav ── */}
|
||||||
<div className={[s.bottombar, uiVisible ? "" : s.uiHidden].join(" ")}>
|
<div className={[s.bottombar, uiVisible ? "" : s.uiHidden].join(" ")}>
|
||||||
<button className={s.navBtn} onClick={goPrev} disabled={pageNumber === 1 && !adjacent.prev}>
|
<button className={s.navBtn} onClick={goPrev} disabled={loading || (pageNumber === 1 && !adjacent.prev)}>
|
||||||
<ArrowLeft size={13} weight="light" />
|
<ArrowLeft size={13} weight="light" />
|
||||||
</button>
|
</button>
|
||||||
<button className={s.navBtn} onClick={goNext} disabled={pageNumber === lastPage && !adjacent.next}>
|
<button className={s.navBtn} onClick={goNext} disabled={loading || (pageNumber === lastPage && !adjacent.next)}>
|
||||||
<ArrowRight size={13} weight="light" />
|
<ArrowRight size={13} weight="light" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,30 +1,79 @@
|
|||||||
|
/* ── Root ────────────────────────────────────────────────────────────────── */
|
||||||
.root {
|
.root {
|
||||||
display: flex; flex-direction: column; height: 100%;
|
display: flex; flex-direction: column; height: 100%;
|
||||||
overflow: hidden; animation: fadeIn 0.14s ease both;
|
overflow: hidden; animation: fadeIn 0.14s ease both;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Header ──────────────────────────────────────────────────────────────── */
|
||||||
.header {
|
.header {
|
||||||
display: flex; align-items: center; gap: var(--sp-4);
|
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;
|
padding: var(--sp-3) var(--sp-6) var(--sp-3) var(--sp-6);
|
||||||
|
border-bottom: 1px solid var(--border-dim); flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.heading {
|
.heading {
|
||||||
font-family: var(--font-ui); font-size: var(--text-xs);
|
font-family: var(--font-ui); font-size: var(--text-xs);
|
||||||
color: var(--text-faint); letter-spacing: var(--tracking-wider);
|
color: var(--text-faint); letter-spacing: var(--tracking-wider);
|
||||||
text-transform: uppercase; flex-shrink: 0;
|
text-transform: uppercase; flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Tab bar ─────────────────────────────────────────────────────────────── */
|
||||||
|
.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); }
|
||||||
|
|
||||||
|
/* ── Keyword tab bar area ────────────────────────────────────────────────── */
|
||||||
|
.keywordBar {
|
||||||
|
flex-shrink: 0; display: flex; flex-direction: column;
|
||||||
|
border-bottom: 1px solid var(--border-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Shared search bar ───────────────────────────────────────────────────── */
|
||||||
.searchBar {
|
.searchBar {
|
||||||
flex: 1; display: flex; align-items: center; gap: var(--sp-2);
|
display: flex; align-items: center; gap: var(--sp-2);
|
||||||
background: var(--bg-raised); border: 1px solid var(--border-dim);
|
background: var(--bg-raised); border: 1px solid var(--border-dim);
|
||||||
border-radius: var(--radius-lg); padding: 0 var(--sp-3) 0 var(--sp-2);
|
border-radius: var(--radius-lg); padding: 0 var(--sp-3) 0 var(--sp-2);
|
||||||
transition: border-color var(--t-base);
|
transition: border-color var(--t-base);
|
||||||
|
margin: var(--sp-3) var(--sp-6);
|
||||||
}
|
}
|
||||||
.searchBar:focus-within { border-color: var(--border-strong); }
|
.searchBar:focus-within { border-color: var(--border-strong); }
|
||||||
|
|
||||||
.searchIcon { color: var(--text-faint); flex-shrink: 0; }
|
.searchIcon { color: var(--text-faint); flex-shrink: 0; }
|
||||||
|
|
||||||
.searchInput {
|
.searchInput {
|
||||||
flex: 1; background: none; border: none; outline: none;
|
flex: 1; background: none; border: none; outline: none;
|
||||||
color: var(--text-primary); font-size: var(--text-sm); padding: 8px 0;
|
color: var(--text-primary); font-size: var(--text-sm); padding: 8px 0;
|
||||||
}
|
}
|
||||||
.searchInput::placeholder { color: var(--text-faint); }
|
.searchInput::placeholder { color: var(--text-faint); }
|
||||||
|
|
||||||
|
.advancedBtn {
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
width: 26px; height: 26px; border-radius: var(--radius-sm);
|
||||||
|
background: none; border: 1px solid transparent;
|
||||||
|
color: var(--text-faint); cursor: pointer; flex-shrink: 0;
|
||||||
|
transition: color var(--t-base), border-color var(--t-base), background var(--t-base);
|
||||||
|
}
|
||||||
|
.advancedBtn:hover { color: var(--text-muted); border-color: var(--border-dim); }
|
||||||
|
.advancedBtnActive {
|
||||||
|
background: var(--accent-muted); border-color: var(--accent-dim); color: var(--accent-fg);
|
||||||
|
}
|
||||||
|
|
||||||
.searchBtn {
|
.searchBtn {
|
||||||
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
|
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;
|
padding: 5px 12px; border-radius: var(--radius-md); flex-shrink: 0;
|
||||||
@@ -35,74 +84,108 @@
|
|||||||
.searchBtn:hover:not(:disabled) { filter: brightness(1.1); }
|
.searchBtn:hover:not(:disabled) { filter: brightness(1.1); }
|
||||||
.searchBtn:disabled { opacity: 0.4; cursor: default; }
|
.searchBtn:disabled { opacity: 0.4; cursor: default; }
|
||||||
|
|
||||||
.langBar {
|
.clearSearchBtn {
|
||||||
display: flex;
|
display: flex; align-items: center; justify-content: center;
|
||||||
align-items: center;
|
width: 20px; height: 20px; border-radius: 50%;
|
||||||
flex-wrap: wrap;
|
font-size: 15px; line-height: 1;
|
||||||
gap: var(--sp-1);
|
color: var(--text-faint); background: var(--bg-overlay); border: none;
|
||||||
padding: var(--sp-2) var(--sp-6);
|
cursor: pointer; flex-shrink: 0; transition: color var(--t-base);
|
||||||
border-bottom: 1px solid var(--border-dim);
|
}
|
||||||
flex-shrink: 0;
|
.clearSearchBtn:hover { color: var(--text-muted); }
|
||||||
|
|
||||||
|
/* ── Advanced filter panel ───────────────────────────────────────────────── */
|
||||||
|
.advancedPanel {
|
||||||
|
display: flex; flex-direction: column; gap: var(--sp-3);
|
||||||
|
padding: 0 var(--sp-6) var(--sp-4);
|
||||||
|
animation: fadeIn 0.1s ease both;
|
||||||
}
|
}
|
||||||
|
|
||||||
.langBtn {
|
.advancedHeader { display: flex; align-items: center; justify-content: space-between; }
|
||||||
font-family: var(--font-ui);
|
.advancedTitle {
|
||||||
font-size: var(--text-2xs);
|
font-family: var(--font-ui); font-size: var(--text-2xs);
|
||||||
letter-spacing: var(--tracking-wider);
|
color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase;
|
||||||
padding: 3px 8px;
|
}
|
||||||
border-radius: var(--radius-sm);
|
.advancedActions { display: flex; gap: var(--sp-3); }
|
||||||
border: 1px solid var(--border-dim);
|
.advancedLink {
|
||||||
background: none;
|
font-family: var(--font-ui); font-size: var(--text-2xs);
|
||||||
color: var(--text-faint);
|
letter-spacing: var(--tracking-wide); color: var(--accent-fg);
|
||||||
cursor: pointer;
|
background: none; border: none; cursor: pointer; padding: 0;
|
||||||
|
transition: opacity var(--t-base);
|
||||||
|
}
|
||||||
|
.advancedLink:hover { opacity: 0.7; }
|
||||||
|
|
||||||
|
.langGrid { display: flex; flex-wrap: wrap; gap: var(--sp-1); }
|
||||||
|
.langFilterRow { display: flex; flex-wrap: wrap; gap: var(--sp-1); padding: var(--sp-3) var(--sp-3) 0; }
|
||||||
|
|
||||||
|
.langChip {
|
||||||
|
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);
|
transition: color var(--t-base), border-color var(--t-base), background var(--t-base);
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.langBtn:hover { color: var(--text-muted); border-color: var(--border-strong); }
|
.langChip:hover { color: var(--text-muted); border-color: var(--border-strong); }
|
||||||
.langBtnActive {
|
.langChipActive {
|
||||||
background: var(--accent-muted);
|
background: var(--accent-muted); border-color: var(--accent-dim); color: var(--accent-fg);
|
||||||
border-color: var(--accent-dim);
|
|
||||||
color: var(--accent-fg);
|
|
||||||
}
|
|
||||||
.langBtnActive:hover { background: var(--accent-muted); color: var(--accent-fg); }
|
|
||||||
|
|
||||||
.sourceCount {
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-2xs);
|
|
||||||
color: var(--text-faint);
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
margin-left: auto;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.results { flex: 1; overflow-y: auto; padding: var(--sp-5) var(--sp-6); display: flex; flex-direction: column; gap: var(--sp-6); }
|
.advancedDivider { height: 1px; background: var(--border-dim); margin: 0 calc(-1 * var(--sp-6)); }
|
||||||
|
.advancedCheck {
|
||||||
|
display: flex; align-items: center; gap: var(--sp-2);
|
||||||
|
font-family: var(--font-ui); font-size: var(--text-xs);
|
||||||
|
color: var(--text-muted); cursor: pointer; user-select: none;
|
||||||
|
}
|
||||||
|
.checkbox { accent-color: var(--accent-fg); width: 13px; height: 13px; cursor: pointer; }
|
||||||
|
.advancedFooter {
|
||||||
|
font-family: var(--font-ui); font-size: var(--text-2xs);
|
||||||
|
color: var(--text-faint); letter-spacing: var(--tracking-wide);
|
||||||
|
}
|
||||||
|
.advancedFooter strong { color: var(--text-muted); font-weight: var(--weight-medium); }
|
||||||
|
|
||||||
|
/* ── Keyword results list ────────────────────────────────────────────────── */
|
||||||
|
.results {
|
||||||
|
flex: 1; overflow-y: auto; padding: var(--sp-5) var(--sp-6);
|
||||||
|
display: flex; flex-direction: column; gap: var(--sp-6);
|
||||||
|
scrollbar-width: thin;
|
||||||
|
}
|
||||||
|
|
||||||
.sourceSection { display: flex; flex-direction: column; gap: var(--sp-3); }
|
.sourceSection { display: flex; flex-direction: column; gap: var(--sp-3); }
|
||||||
|
|
||||||
.sourceHeader {
|
.sourceHeader { display: flex; align-items: center; gap: var(--sp-2); }
|
||||||
display: flex; align-items: center; gap: var(--sp-2);
|
|
||||||
}
|
|
||||||
.sourceIcon { width: 18px; height: 18px; border-radius: 4px; object-fit: cover; flex-shrink: 0; }
|
.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); }
|
.sourceName { font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-secondary); }
|
||||||
.resultCount {
|
.sourceLang {
|
||||||
font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint);
|
font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint);
|
||||||
letter-spacing: var(--tracking-wide); margin-left: auto;
|
letter-spacing: var(--tracking-wider); padding: 1px 5px;
|
||||||
|
border: 1px solid var(--border-dim); border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
.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); }
|
.sourceError { font-size: var(--text-xs); color: var(--color-error); padding: 0 var(--sp-1); }
|
||||||
|
|
||||||
.sourceRow {
|
.sourceRow {
|
||||||
display: flex; gap: var(--sp-3); overflow-x: auto;
|
display: flex; gap: var(--sp-3); overflow-x: auto;
|
||||||
padding-bottom: var(--sp-2);
|
padding-bottom: var(--sp-2); scrollbar-width: thin;
|
||||||
scrollbar-width: thin;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Shared manga card ───────────────────────────────────────────────────── */
|
||||||
.card {
|
.card {
|
||||||
flex-shrink: 0; width: 110px; background: none; border: none; padding: 0;
|
flex-shrink: 0; width: 110px;
|
||||||
|
background: none; border: none; padding: 0;
|
||||||
cursor: pointer; text-align: left;
|
cursor: pointer; text-align: left;
|
||||||
}
|
}
|
||||||
.card:hover .cover { filter: brightness(1.06); }
|
.card:hover .cover { filter: brightness(1.06); }
|
||||||
|
|
||||||
.coverWrap {
|
.coverWrap {
|
||||||
position: relative; aspect-ratio: 2/3; border-radius: var(--radius-md);
|
position: relative; aspect-ratio: 2/3;
|
||||||
overflow: hidden; background: var(--bg-raised); border: 1px solid var(--border-dim);
|
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); }
|
.cover { width: 100%; height: 100%; object-fit: cover; transition: filter var(--t-base); }
|
||||||
|
|
||||||
.inLibBadge {
|
.inLibBadge {
|
||||||
position: absolute; bottom: var(--sp-1); left: var(--sp-1);
|
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);
|
font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide);
|
||||||
@@ -115,14 +198,145 @@
|
|||||||
line-height: var(--leading-snug);
|
line-height: var(--leading-snug);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Skeleton cards ──────────────────────────────────────────────────────── */
|
||||||
.skCard { flex-shrink: 0; width: 110px; }
|
.skCard { flex-shrink: 0; width: 110px; }
|
||||||
.skCover { aspect-ratio: 2/3; border-radius: var(--radius-md); }
|
.skCover { aspect-ratio: 2/3; border-radius: var(--radius-md); width: 100%; }
|
||||||
.skTitle { height: 11px; margin-top: var(--sp-1); width: 75%; }
|
.skTitle { height: 11px; margin-top: var(--sp-1); width: 75%; border-radius: 3px; }
|
||||||
|
|
||||||
|
/* ── Empty state ─────────────────────────────────────────────────────────── */
|
||||||
.empty {
|
.empty {
|
||||||
flex: 1; display: flex; flex-direction: column;
|
flex: 1; display: flex; flex-direction: column;
|
||||||
align-items: center; justify-content: center; gap: var(--sp-2);
|
align-items: center; justify-content: center; gap: var(--sp-2);
|
||||||
|
padding: var(--sp-6);
|
||||||
}
|
}
|
||||||
.emptyIcon { color: var(--text-faint); }
|
.emptyIcon { color: var(--text-faint); }
|
||||||
.emptyText { font-size: var(--text-base); color: var(--text-muted); }
|
.emptyText { font-size: var(--text-base); color: var(--text-muted); }
|
||||||
.emptyHint { font-size: var(--text-sm); color: var(--text-faint); }
|
.emptyHint { font-size: var(--text-sm); color: var(--text-faint); text-align: center; max-width: 280px; }
|
||||||
|
|
||||||
|
.advancedLinkStandalone {
|
||||||
|
display: flex; align-items: center; gap: var(--sp-1);
|
||||||
|
font-family: var(--font-ui); font-size: var(--text-xs);
|
||||||
|
color: var(--text-faint); background: none; border: none;
|
||||||
|
cursor: pointer; padding: 0; margin-top: var(--sp-1);
|
||||||
|
transition: color var(--t-base);
|
||||||
|
}
|
||||||
|
.advancedLinkStandalone:hover { color: var(--accent-fg); }
|
||||||
|
|
||||||
|
/* ── Split layout (tag + source tabs) ───────────────────────────────────── */
|
||||||
|
.splitRoot { flex: 1; display: flex; overflow: hidden; }
|
||||||
|
|
||||||
|
.splitSidebar {
|
||||||
|
width: 192px; flex-shrink: 0;
|
||||||
|
border-right: 1px solid var(--border-dim);
|
||||||
|
display: flex; flex-direction: column; overflow: hidden;
|
||||||
|
background: var(--bg-raised);
|
||||||
|
}
|
||||||
|
|
||||||
|
.splitSearchWrap {
|
||||||
|
display: flex; align-items: center; gap: var(--sp-2);
|
||||||
|
padding: var(--sp-3);
|
||||||
|
border-bottom: 1px solid var(--border-dim); flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.splitSearchIcon { color: var(--text-faint); flex-shrink: 0; }
|
||||||
|
.splitSearchInput {
|
||||||
|
flex: 1; background: none; border: none; outline: none;
|
||||||
|
color: var(--text-primary); font-size: var(--text-xs);
|
||||||
|
font-family: var(--font-ui); letter-spacing: var(--tracking-wide);
|
||||||
|
}
|
||||||
|
.splitSearchInput::placeholder { color: var(--text-faint); }
|
||||||
|
|
||||||
|
.splitList {
|
||||||
|
flex: 1; overflow-y: auto; padding: var(--sp-2);
|
||||||
|
display: flex; flex-direction: column; gap: 1px; scrollbar-width: thin;
|
||||||
|
}
|
||||||
|
|
||||||
|
.splitItem {
|
||||||
|
display: flex; align-items: center; width: 100%;
|
||||||
|
padding: 7px var(--sp-2); border-radius: var(--radius-md);
|
||||||
|
border: none; background: none;
|
||||||
|
color: var(--text-muted); font-size: var(--text-sm);
|
||||||
|
cursor: pointer; text-align: left;
|
||||||
|
transition: background var(--t-fast), color var(--t-fast);
|
||||||
|
}
|
||||||
|
.splitItem:hover { background: var(--bg-overlay); color: var(--text-secondary); }
|
||||||
|
.splitItemActive { background: var(--accent-muted); color: var(--accent-fg); }
|
||||||
|
.splitItemActive:hover { background: var(--accent-muted); color: var(--accent-fg); }
|
||||||
|
|
||||||
|
.splitItemSource { gap: var(--sp-2); }
|
||||||
|
.splitItemLabel { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.splitSourceIcon { width: 16px; height: 16px; border-radius: 3px; object-fit: cover; flex-shrink: 0; }
|
||||||
|
|
||||||
|
.splitEmpty {
|
||||||
|
font-family: var(--font-ui); font-size: var(--text-xs);
|
||||||
|
color: var(--text-faint); padding: var(--sp-3) var(--sp-2);
|
||||||
|
}
|
||||||
|
.splitLoading {
|
||||||
|
flex: 1; display: flex; align-items: center; justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Split right content ─────────────────────────────────────────────────── */
|
||||||
|
.splitContent { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
|
||||||
|
|
||||||
|
.splitContentHeader {
|
||||||
|
display: flex; align-items: center; gap: var(--sp-3);
|
||||||
|
padding: var(--sp-3) var(--sp-5);
|
||||||
|
border-bottom: 1px solid var(--border-dim); flex-shrink: 0;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
min-height: 52px;
|
||||||
|
}
|
||||||
|
.splitContentTitle {
|
||||||
|
font-size: var(--text-base); font-weight: var(--weight-medium);
|
||||||
|
color: var(--text-secondary); letter-spacing: var(--tracking-tight);
|
||||||
|
}
|
||||||
|
.splitResultCount {
|
||||||
|
font-family: var(--font-ui); font-size: var(--text-2xs);
|
||||||
|
color: var(--text-faint); letter-spacing: var(--tracking-wide);
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
.splitSourceTitle {
|
||||||
|
display: flex; align-items: center; gap: var(--sp-2); flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.sourceBrowseBar {
|
||||||
|
display: flex; align-items: center; gap: var(--sp-2); flex: 1; min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Grid (tag + source results) ─────────────────────────────────────────── */
|
||||||
|
.tagGrid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(clamp(90px, 9vw, 120px), 1fr));
|
||||||
|
gap: var(--sp-3);
|
||||||
|
padding: var(--sp-4) var(--sp-5);
|
||||||
|
overflow-y: auto; flex: 1;
|
||||||
|
align-content: start;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
}
|
||||||
|
/* In the grid, cards stretch to fill the column */
|
||||||
|
.tagGrid .card { width: auto; }
|
||||||
|
.tagGrid .skCard { width: auto; }
|
||||||
|
.tagGrid .skCover { width: 100%; }
|
||||||
|
|
||||||
|
/* ── Show more (tag grid & genre drill) ──────────────────────────────────── */
|
||||||
|
.showMoreCell {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: var(--sp-2) 0 var(--sp-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.showMoreBtn {
|
||||||
|
display: flex; align-items: center; gap: var(--sp-2);
|
||||||
|
font-family: var(--font-ui); font-size: var(--text-xs);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
padding: 7px 20px; border-radius: var(--radius-md);
|
||||||
|
background: var(--bg-raised); color: var(--text-muted);
|
||||||
|
border: 1px solid var(--border-dim); cursor: pointer;
|
||||||
|
transition: color var(--t-base), border-color var(--t-base);
|
||||||
|
}
|
||||||
|
.showMoreBtn:hover:not(:disabled) { color: var(--text-secondary); border-color: var(--border-strong); }
|
||||||
|
.showMoreBtn:disabled { opacity: 0.5; cursor: default; }
|
||||||
|
.nsfwBadge {
|
||||||
|
font-family: var(--font-ui); font-size: var(--text-2xs);
|
||||||
|
letter-spacing: var(--tracking-wide); padding: 1px 5px;
|
||||||
|
border-radius: var(--radius-sm); border: 1px solid var(--border-dim);
|
||||||
|
color: var(--text-faint); flex-shrink: 0;
|
||||||
|
}
|
||||||
+694
-101
@@ -1,11 +1,19 @@
|
|||||||
import { useState, useRef, useCallback, useEffect } from "react";
|
import { useState, useRef, useCallback, useEffect, memo, useMemo } from "react";
|
||||||
import { MagnifyingGlass, CircleNotch } from "@phosphor-icons/react";
|
import {
|
||||||
|
MagnifyingGlass, CircleNotch, SlidersHorizontal, Hash, List,
|
||||||
|
} from "@phosphor-icons/react";
|
||||||
import { gql, thumbUrl } from "../../lib/client";
|
import { gql, thumbUrl } from "../../lib/client";
|
||||||
import { GET_SOURCES, FETCH_SOURCE_MANGA } from "../../lib/queries";
|
import { GET_SOURCES, FETCH_SOURCE_MANGA } from "../../lib/queries";
|
||||||
|
import { cache, CACHE_KEYS } from "../../lib/cache";
|
||||||
|
import { dedupeSources, dedupeMangaById } from "../../lib/sourceUtils";
|
||||||
import { useStore } from "../../store";
|
import { useStore } from "../../store";
|
||||||
import type { Manga, Source } from "../../lib/types";
|
import type { Manga, Source } from "../../lib/types";
|
||||||
import s from "./Search.module.css";
|
import s from "./Search.module.css";
|
||||||
|
|
||||||
|
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type SearchTab = "keyword" | "tag" | "source";
|
||||||
|
|
||||||
interface SourceResult {
|
interface SourceResult {
|
||||||
source: Source;
|
source: Source;
|
||||||
mangas: Manga[];
|
mangas: Manga[];
|
||||||
@@ -13,15 +21,30 @@ interface SourceResult {
|
|||||||
error: string | null;
|
error: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CONCURRENCY = 3;
|
// ── Constants ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const CONCURRENCY = 4;
|
||||||
|
const RESULTS_PER_SOURCE = 8;
|
||||||
|
|
||||||
|
const COMMON_GENRES = [
|
||||||
|
"Action","Adventure","Comedy","Drama","Fantasy","Romance",
|
||||||
|
"Sci-Fi","Slice of Life","Horror","Mystery","Thriller","Sports",
|
||||||
|
"Supernatural","Mecha","Historical","Psychological","School Life",
|
||||||
|
"Shounen","Seinen","Josei","Shoujo","Isekai","Martial Arts",
|
||||||
|
"Magic","Music","Cooking","Medical","Military","Harem","Ecchi",
|
||||||
|
];
|
||||||
|
|
||||||
|
// ── Concurrent fetch helper ───────────────────────────────────────────────────
|
||||||
|
|
||||||
async function runConcurrent<T>(
|
async function runConcurrent<T>(
|
||||||
items: T[],
|
items: T[],
|
||||||
fn: (item: T) => Promise<void>
|
fn: (item: T) => Promise<void>,
|
||||||
|
signal: AbortSignal,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
let i = 0;
|
let i = 0;
|
||||||
async function worker() {
|
async function worker() {
|
||||||
while (i < items.length) {
|
while (i < items.length) {
|
||||||
|
if (signal.aborted) return;
|
||||||
const item = items[i++];
|
const item = items[i++];
|
||||||
await fn(item).catch(() => {});
|
await fn(item).catch(() => {});
|
||||||
}
|
}
|
||||||
@@ -29,84 +52,280 @@ async function runConcurrent<T>(
|
|||||||
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, items.length) }, worker));
|
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, items.length) }, worker));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Shared card ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const CoverImg = memo(function CoverImg({
|
||||||
|
src, alt, className,
|
||||||
|
}: { src: string; alt: string; className?: string }) {
|
||||||
|
const [loaded, setLoaded] = useState(false);
|
||||||
|
return (
|
||||||
|
<img src={src} alt={alt} className={className}
|
||||||
|
loading="lazy" decoding="async" onLoad={() => setLoaded(true)}
|
||||||
|
style={{ opacity: loaded ? 1 : 0, transition: loaded ? "opacity 0.15s ease" : "none" }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
function MangaCard({ manga, onClick }: { manga: Manga; onClick: () => void }) {
|
||||||
|
return (
|
||||||
|
<button className={s.card} onClick={onClick}>
|
||||||
|
<div className={s.coverWrap}>
|
||||||
|
<CoverImg src={thumbUrl(manga.thumbnailUrl)} alt={manga.title} className={s.cover} />
|
||||||
|
{manga.inLibrary && <span className={s.inLibBadge}>Saved</span>}
|
||||||
|
</div>
|
||||||
|
<p className={s.cardTitle}>{manga.title}</p>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function GridSkeleton({ count = 18 }: { count?: number }) {
|
||||||
|
return (
|
||||||
|
<div className={s.tagGrid}>
|
||||||
|
{Array.from({ length: count }).map((_, i) => (
|
||||||
|
<div key={i} className={s.skCard} style={{ width: "auto" }}>
|
||||||
|
<div className={["skeleton", s.skCover].join(" ")} style={{ aspectRatio: "2/3", width: "100%" }} />
|
||||||
|
<div className={["skeleton", s.skTitle].join(" ")} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RowSkeleton({ count = 4 }: { count?: number }) {
|
||||||
|
return (
|
||||||
|
<div className={s.sourceRow}>
|
||||||
|
{Array.from({ length: count }).map((_, i) => (
|
||||||
|
<div key={i} className={s.skCard}>
|
||||||
|
<div className={["skeleton", s.skCover].join(" ")} />
|
||||||
|
<div className={["skeleton", s.skTitle].join(" ")} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Root ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export default function Search() {
|
export default function Search() {
|
||||||
const [query, setQuery] = useState("");
|
const [tab, setTab] = useState<SearchTab>("keyword");
|
||||||
const [submitted, setSubmitted] = useState("");
|
|
||||||
const [results, setResults] = useState<SourceResult[]>([]);
|
const preferredLang = useStore((st) => st.settings.preferredExtensionLang);
|
||||||
const [allSources, setAllSources] = useState<Source[]>([]);
|
const searchPrefill = useStore((st) => st.searchPrefill ?? "");
|
||||||
|
const setSearchPrefill = useStore((st) => st.setSearchPrefill);
|
||||||
|
const setPreviewManga = useStore((st) => st.setPreviewManga);
|
||||||
|
|
||||||
|
const [allSources, setAllSources] = useState<Source[]>([]);
|
||||||
const [loadingSources, setLoadingSources] = useState(false);
|
const [loadingSources, setLoadingSources] = useState(false);
|
||||||
const [activeLang, setActiveLang] = useState<string>("preferred");
|
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
const setActiveManga = useStore((st) => st.setActiveManga);
|
const pendingPrefill = useRef<string>("");
|
||||||
const setNavPage = useStore((st) => st.setNavPage);
|
|
||||||
const preferredLang = useStore((st) => st.settings.preferredExtensionLang);
|
|
||||||
|
|
||||||
|
// Consume searchPrefill → route to keyword tab
|
||||||
|
useEffect(() => {
|
||||||
|
if (!searchPrefill) return;
|
||||||
|
pendingPrefill.current = searchPrefill;
|
||||||
|
setTab("keyword");
|
||||||
|
setSearchPrefill("");
|
||||||
|
}, [searchPrefill, setSearchPrefill]);
|
||||||
|
|
||||||
|
// Load sources once, shared across all tabs
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLoadingSources(true);
|
setLoadingSources(true);
|
||||||
gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
|
cache.get(CACHE_KEYS.SOURCES, () =>
|
||||||
.then((d) => setAllSources(d.sources.nodes.filter((s) => s.id !== "0")))
|
gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
|
||||||
|
.then((d) => d.sources.nodes.filter((src) => src.id !== "0"))
|
||||||
|
)
|
||||||
|
.then(setAllSources)
|
||||||
.catch(console.error)
|
.catch(console.error)
|
||||||
.finally(() => setLoadingSources(false));
|
.finally(() => setLoadingSources(false));
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const langs = ["preferred", ...Array.from(new Set(allSources.map((s) => s.lang))).sort(), "all"];
|
const availableLangs = useMemo(() =>
|
||||||
|
Array.from(new Set<string>(allSources.map((s) => s.lang))).sort(), [allSources]);
|
||||||
const visibleSources = allSources.filter((src) => {
|
const hasMultipleLangs = availableLangs.length > 1;
|
||||||
if (activeLang === "all") return true;
|
|
||||||
if (activeLang === "preferred") return src.lang === preferredLang;
|
|
||||||
return src.lang === activeLang;
|
|
||||||
});
|
|
||||||
|
|
||||||
const runSearch = useCallback(async () => {
|
|
||||||
const q = query.trim();
|
|
||||||
if (!q || !visibleSources.length) return;
|
|
||||||
setSubmitted(q);
|
|
||||||
|
|
||||||
setResults(visibleSources.map((src) => ({ source: src, mangas: [], loading: true, error: null })));
|
|
||||||
|
|
||||||
await runConcurrent(visibleSources, async (src) => {
|
|
||||||
try {
|
|
||||||
const d = await gql<{ fetchSourceManga: { mangas: Manga[] } }>(FETCH_SOURCE_MANGA, {
|
|
||||||
source: src.id, type: "SEARCH", page: 1, query: q,
|
|
||||||
});
|
|
||||||
setResults((prev) => prev.map((r) =>
|
|
||||||
r.source.id === src.id ? { ...r, mangas: d.fetchSourceManga.mangas, loading: false } : r
|
|
||||||
));
|
|
||||||
} catch (e: any) {
|
|
||||||
setResults((prev) => prev.map((r) =>
|
|
||||||
r.source.id === src.id ? { ...r, loading: false, error: e.message } : r
|
|
||||||
));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, [query, visibleSources]);
|
|
||||||
|
|
||||||
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 (
|
return (
|
||||||
<div className={s.root}>
|
<div className={s.root}>
|
||||||
<div className={s.header}>
|
<div className={s.header}>
|
||||||
<h1 className={s.heading}>Search</h1>
|
<h1 className={s.heading}>Search</h1>
|
||||||
|
<div className={s.tabs}>
|
||||||
|
<button className={[s.tab, tab === "keyword" ? s.tabActive : ""].join(" ")} onClick={() => setTab("keyword")}>
|
||||||
|
<MagnifyingGlass size={11} weight="bold" /> Keyword
|
||||||
|
</button>
|
||||||
|
<button className={[s.tab, tab === "tag" ? s.tabActive : ""].join(" ")} onClick={() => setTab("tag")}>
|
||||||
|
<Hash size={11} weight="bold" /> Tags
|
||||||
|
</button>
|
||||||
|
<button className={[s.tab, tab === "source" ? s.tabActive : ""].join(" ")} onClick={() => setTab("source")}>
|
||||||
|
<List size={11} weight="bold" /> Sources
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tab === "keyword" && (
|
||||||
|
<KeywordTab
|
||||||
|
allSources={allSources}
|
||||||
|
loadingSources={loadingSources}
|
||||||
|
availableLangs={availableLangs}
|
||||||
|
hasMultipleLangs={hasMultipleLangs}
|
||||||
|
preferredLang={preferredLang}
|
||||||
|
pendingPrefill={pendingPrefill}
|
||||||
|
onMangaClick={setPreviewManga}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{tab === "tag" && (
|
||||||
|
<TagTab
|
||||||
|
allSources={allSources}
|
||||||
|
loadingSources={loadingSources}
|
||||||
|
preferredLang={preferredLang}
|
||||||
|
onMangaClick={setPreviewManga}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{tab === "source" && (
|
||||||
|
<SourceTab
|
||||||
|
allSources={allSources}
|
||||||
|
loadingSources={loadingSources}
|
||||||
|
availableLangs={availableLangs}
|
||||||
|
hasMultipleLangs={hasMultipleLangs}
|
||||||
|
onMangaClick={setPreviewManga}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Keyword tab ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function KeywordTab({
|
||||||
|
allSources, loadingSources, availableLangs, hasMultipleLangs,
|
||||||
|
preferredLang, pendingPrefill, onMangaClick,
|
||||||
|
}: {
|
||||||
|
allSources: Source[];
|
||||||
|
loadingSources: boolean;
|
||||||
|
availableLangs: string[];
|
||||||
|
hasMultipleLangs: boolean;
|
||||||
|
preferredLang: string;
|
||||||
|
pendingPrefill: React.MutableRefObject<string>;
|
||||||
|
onMangaClick: (m: Manga) => void;
|
||||||
|
}) {
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [submitted, setSubmitted] = useState("");
|
||||||
|
const [results, setResults] = useState<SourceResult[]>([]);
|
||||||
|
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||||
|
const [selectedLangs, setSelectedLangs] = useState<Set<string>>(new Set());
|
||||||
|
const [includeNsfw, setIncludeNsfw] = useState(false);
|
||||||
|
|
||||||
|
const abortRef = useRef<AbortController | null>(null);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const allSourcesRef = useRef<Source[]>([]);
|
||||||
|
const selectedLangsRef = useRef<Set<string>>(new Set());
|
||||||
|
|
||||||
|
useEffect(() => { allSourcesRef.current = allSources; }, [allSources]);
|
||||||
|
useEffect(() => { selectedLangsRef.current = selectedLangs; }, [selectedLangs]);
|
||||||
|
|
||||||
|
// Set default lang selection once sources load
|
||||||
|
useEffect(() => {
|
||||||
|
if (!allSources.length) return;
|
||||||
|
const available = new Set(allSources.map((s) => s.lang));
|
||||||
|
setSelectedLangs(available.has(preferredLang)
|
||||||
|
? new Set([preferredLang])
|
||||||
|
: new Set(availableLangs.slice(0, 1))
|
||||||
|
);
|
||||||
|
}, [allSources]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
// Consume prefill once sources are ready
|
||||||
|
useEffect(() => {
|
||||||
|
if (loadingSources || !pendingPrefill.current || submitted) return;
|
||||||
|
if (!allSourcesRef.current.length) return;
|
||||||
|
const q = pendingPrefill.current;
|
||||||
|
pendingPrefill.current = "";
|
||||||
|
setQuery(q);
|
||||||
|
doSearch(q);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [loadingSources]);
|
||||||
|
|
||||||
|
useEffect(() => () => { abortRef.current?.abort(); }, []);
|
||||||
|
|
||||||
|
const getVisibleSources = useCallback((): Source[] => {
|
||||||
|
let filtered = allSourcesRef.current;
|
||||||
|
if (selectedLangsRef.current.size > 0)
|
||||||
|
filtered = filtered.filter((s) => selectedLangsRef.current.has(s.lang));
|
||||||
|
if (!includeNsfw)
|
||||||
|
filtered = filtered.filter((s) => !s.isNsfw);
|
||||||
|
return filtered;
|
||||||
|
}, [includeNsfw]);
|
||||||
|
|
||||||
|
const doSearch = useCallback(async (q: string) => {
|
||||||
|
const trimmed = q.trim();
|
||||||
|
if (!trimmed) return;
|
||||||
|
const visible = getVisibleSources();
|
||||||
|
if (!visible.length) return;
|
||||||
|
|
||||||
|
abortRef.current?.abort();
|
||||||
|
const ctrl = new AbortController();
|
||||||
|
abortRef.current = ctrl;
|
||||||
|
|
||||||
|
setSubmitted(trimmed);
|
||||||
|
setResults(visible.map((src) => ({ source: src, mangas: [], loading: true, error: null })));
|
||||||
|
|
||||||
|
await runConcurrent(visible, async (src) => {
|
||||||
|
if (ctrl.signal.aborted) return;
|
||||||
|
try {
|
||||||
|
const d = await gql<{ fetchSourceManga: { mangas: Manga[] } }>(
|
||||||
|
FETCH_SOURCE_MANGA,
|
||||||
|
{ source: src.id, type: "SEARCH", page: 1, query: trimmed },
|
||||||
|
ctrl.signal,
|
||||||
|
);
|
||||||
|
if (ctrl.signal.aborted) return;
|
||||||
|
setResults((prev) => prev.map((r) =>
|
||||||
|
r.source.id === src.id ? { ...r, mangas: d.fetchSourceManga.mangas, loading: false } : r
|
||||||
|
));
|
||||||
|
} catch (e: any) {
|
||||||
|
if (ctrl.signal.aborted || e?.name === "AbortError") return;
|
||||||
|
setResults((prev) => prev.map((r) =>
|
||||||
|
r.source.id === src.id ? { ...r, loading: false, error: e.message } : r
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}, ctrl.signal);
|
||||||
|
}, [getVisibleSources]);
|
||||||
|
|
||||||
|
function toggleLang(lang: string) {
|
||||||
|
setSelectedLangs((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(lang)) { if (next.size === 1) return prev; next.delete(lang); }
|
||||||
|
else next.add(lang);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const visibleCount = getVisibleSources().length;
|
||||||
|
const hasResults = results.some((r) => r.mangas.length > 0);
|
||||||
|
const allDone = results.every((r) => !r.loading);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className={s.keywordBar}>
|
||||||
<div className={s.searchBar}>
|
<div className={s.searchBar}>
|
||||||
<MagnifyingGlass size={14} className={s.searchIcon} weight="light" />
|
<MagnifyingGlass size={14} className={s.searchIcon} weight="light" />
|
||||||
<input
|
<input
|
||||||
ref={inputRef}
|
ref={inputRef} autoFocus
|
||||||
className={s.searchInput}
|
className={s.searchInput}
|
||||||
placeholder="Search across sources…"
|
placeholder="Search across sources…"
|
||||||
value={query}
|
value={query}
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
onKeyDown={(e) => e.key === "Enter" && runSearch()}
|
onKeyDown={(e) => e.key === "Enter" && doSearch(query)}
|
||||||
autoFocus
|
|
||||||
/>
|
/>
|
||||||
|
{hasMultipleLangs && (
|
||||||
|
<button
|
||||||
|
className={[s.advancedBtn, showAdvanced ? s.advancedBtnActive : ""].join(" ")}
|
||||||
|
onClick={() => setShowAdvanced((v) => !v)}
|
||||||
|
title="Language & filter options"
|
||||||
|
>
|
||||||
|
<SlidersHorizontal size={13} weight="light" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
className={s.searchBtn}
|
className={s.searchBtn}
|
||||||
onClick={runSearch}
|
onClick={() => doSearch(query)}
|
||||||
disabled={!query.trim() || loadingSources}
|
disabled={!query.trim() || loadingSources}
|
||||||
>
|
>
|
||||||
{loadingSources
|
{loadingSources
|
||||||
@@ -114,20 +333,36 @@ export default function Search() {
|
|||||||
: "Search"}
|
: "Search"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={s.langBar}>
|
{hasMultipleLangs && showAdvanced && (
|
||||||
{langs.map((l) => (
|
<div className={s.advancedPanel}>
|
||||||
<button
|
<div className={s.advancedHeader}>
|
||||||
key={l}
|
<span className={s.advancedTitle}>Languages</span>
|
||||||
onClick={() => setActiveLang(l)}
|
<div className={s.advancedActions}>
|
||||||
className={[s.langBtn, activeLang === l ? s.langBtnActive : ""].join(" ").trim()}
|
<button className={s.advancedLink} onClick={() => setSelectedLangs(new Set(availableLangs))}>All</button>
|
||||||
>
|
<button className={s.advancedLink} onClick={() => setSelectedLangs(new Set([preferredLang]))}>Reset</button>
|
||||||
{l === "preferred" ? `${preferredLang.toUpperCase()} (default)` : l === "all" ? "All" : l.toUpperCase()}
|
</div>
|
||||||
</button>
|
</div>
|
||||||
))}
|
<div className={s.langGrid}>
|
||||||
{visibleSources.length > 0 && (
|
{availableLangs.map((lang) => (
|
||||||
<span className={s.sourceCount}>{visibleSources.length} sources</span>
|
<button key={lang}
|
||||||
|
className={[s.langChip, selectedLangs.has(lang) ? s.langChipActive : ""].join(" ")}
|
||||||
|
onClick={() => toggleLang(lang)}
|
||||||
|
>
|
||||||
|
{lang === preferredLang ? `${lang.toUpperCase()} ★` : lang.toUpperCase()}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className={s.advancedDivider} />
|
||||||
|
<label className={s.advancedCheck}>
|
||||||
|
<input type="checkbox" checked={includeNsfw}
|
||||||
|
onChange={(e) => setIncludeNsfw(e.target.checked)} className={s.checkbox} />
|
||||||
|
Include NSFW sources
|
||||||
|
</label>
|
||||||
|
<div className={s.advancedFooter}>
|
||||||
|
Searching <strong>{visibleCount}</strong> source{visibleCount !== 1 ? "s" : ""}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -136,8 +371,15 @@ export default function Search() {
|
|||||||
<MagnifyingGlass size={36} weight="light" className={s.emptyIcon} />
|
<MagnifyingGlass size={36} weight="light" className={s.emptyIcon} />
|
||||||
<p className={s.emptyText}>Search across sources</p>
|
<p className={s.emptyText}>Search across sources</p>
|
||||||
<p className={s.emptyHint}>
|
<p className={s.emptyHint}>
|
||||||
Searching {visibleSources.length} {activeLang === "preferred" ? `${preferredLang.toUpperCase()}` : activeLang === "all" ? "" : activeLang.toUpperCase()} source{visibleSources.length !== 1 ? "s" : ""}.
|
{hasMultipleLangs
|
||||||
|
? `${visibleCount} source${visibleCount !== 1 ? "s" : ""} · ${selectedLangs.size} language${selectedLangs.size !== 1 ? "s" : ""}`
|
||||||
|
: `${visibleCount} source${visibleCount !== 1 ? "s" : ""}`}
|
||||||
</p>
|
</p>
|
||||||
|
{hasMultipleLangs && !showAdvanced && (
|
||||||
|
<button className={s.advancedLinkStandalone} onClick={() => setShowAdvanced(true)}>
|
||||||
|
<SlidersHorizontal size={12} weight="light" /> Adjust language filters
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -148,59 +390,410 @@ export default function Search() {
|
|||||||
<CircleNotch size={20} weight="light" className="anim-spin" style={{ color: "var(--text-faint)" }} />
|
<CircleNotch size={20} weight="light" className="anim-spin" style={{ color: "var(--text-faint)" }} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{results
|
{results
|
||||||
.filter((r) => r.mangas.length > 0 || r.loading || r.error)
|
.filter((r) => r.mangas.length > 0 || r.loading || r.error)
|
||||||
.map(({ source, mangas, loading, error }) => (
|
.map(({ source, mangas, loading, error }) => (
|
||||||
<div key={source.id} className={s.sourceSection}>
|
<div key={source.id} className={s.sourceSection}>
|
||||||
<div className={s.sourceHeader}>
|
<div className={s.sourceHeader}>
|
||||||
<img
|
<img src={thumbUrl(source.iconUrl)} alt={source.displayName} className={s.sourceIcon}
|
||||||
src={thumbUrl(source.iconUrl)}
|
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
|
||||||
alt={source.displayName}
|
|
||||||
className={s.sourceIcon}
|
|
||||||
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
|
|
||||||
/>
|
|
||||||
<span className={s.sourceName}>{source.displayName}</span>
|
<span className={s.sourceName}>{source.displayName}</span>
|
||||||
{loading && <CircleNotch size={12} weight="light" className="anim-spin" style={{ color: "var(--text-faint)" }} />}
|
{hasMultipleLangs && <span className={s.sourceLang}>{source.lang.toUpperCase()}</span>}
|
||||||
{!loading && mangas.length > 0 && (
|
{loading && <CircleNotch size={12} weight="light" className="anim-spin" style={{ color: "var(--text-faint)", marginLeft: "auto" }} />}
|
||||||
<span className={s.resultCount}>{mangas.length} results</span>
|
{!loading && mangas.length > 0 && <span className={s.resultCount}>{mangas.length} results</span>}
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error ? (
|
{error ? (
|
||||||
<p className={s.sourceError}>{error}</p>
|
<p className={s.sourceError}>{error}</p>
|
||||||
) : loading ? (
|
) : loading ? (
|
||||||
<div className={s.sourceRow}>
|
<RowSkeleton />
|
||||||
{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 ? (
|
) : mangas.length > 0 ? (
|
||||||
<div className={s.sourceRow}>
|
<div className={s.sourceRow}>
|
||||||
{mangas.slice(0, 8).map((m) => (
|
{mangas.slice(0, RESULTS_PER_SOURCE).map((m) => (
|
||||||
<button key={m.id} className={s.card} onClick={() => openManga(m)}>
|
<MangaCard key={m.id} manga={m} onClick={() => onMangaClick(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>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
{allDone && !hasResults && (
|
||||||
{allDone && !hasResults && submitted && (
|
|
||||||
<div className={s.empty}>
|
<div className={s.empty}>
|
||||||
<p className={s.emptyText}>No results for "{submitted}"</p>
|
<p className={s.emptyText}>No results for "{submitted}"</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tag tab ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const TAG_PAGE_SIZE = 50; // items shown per "page"
|
||||||
|
const TAG_FETCH_PAGES = 3; // source pages to fetch per source on initial load
|
||||||
|
const TAG_MAX_SOURCES = 12; // max sources to query
|
||||||
|
|
||||||
|
function TagTab({
|
||||||
|
preferredLang, onMangaClick,
|
||||||
|
}: {
|
||||||
|
allSources: Source[];
|
||||||
|
loadingSources: boolean;
|
||||||
|
preferredLang: string;
|
||||||
|
onMangaClick: (m: Manga) => void;
|
||||||
|
}) {
|
||||||
|
const [activeTag, setActiveTag] = useState<string | null>(null);
|
||||||
|
const [tagResults, setTagResults] = useState<Manga[]>([]);
|
||||||
|
const [loadingTag, setLoadingTag] = useState(false);
|
||||||
|
const [loadingMore, setLoadingMore] = useState(false);
|
||||||
|
const [visibleCount, setVisibleCount] = useState(TAG_PAGE_SIZE);
|
||||||
|
const [tagFilter, setTagFilter] = useState("");
|
||||||
|
// Track next page to fetch per source for "load more from network"
|
||||||
|
const nextPageRef = useRef<Map<string, number>>(new Map());
|
||||||
|
const sourcesRef = useRef<Source[]>([]);
|
||||||
|
const abortRef = useRef<AbortController | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => () => { abortRef.current?.abort(); }, []);
|
||||||
|
|
||||||
|
async function drillTag(tag: string) {
|
||||||
|
if (tag === activeTag && !loadingTag) return;
|
||||||
|
setActiveTag(tag);
|
||||||
|
setTagResults([]);
|
||||||
|
setLoadingTag(true);
|
||||||
|
setVisibleCount(TAG_PAGE_SIZE);
|
||||||
|
nextPageRef.current = new Map();
|
||||||
|
|
||||||
|
abortRef.current?.abort();
|
||||||
|
const ctrl = new AbortController();
|
||||||
|
abortRef.current = ctrl;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const sources = await cache.get(CACHE_KEYS.SOURCES, () =>
|
||||||
|
gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
|
||||||
|
.then((d) => d.sources.nodes.filter((s) => s.id !== "0"))
|
||||||
|
);
|
||||||
|
const deduped = dedupeSources(sources, preferredLang).slice(0, TAG_MAX_SOURCES);
|
||||||
|
sourcesRef.current = deduped;
|
||||||
|
|
||||||
|
// Start all at -1; the fetch loop sets the real next page if hasNextPage is true
|
||||||
|
for (const src of deduped) {
|
||||||
|
nextPageRef.current.set(src.id, -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stream results in: fetch each source's pages concurrently, update state as each settles
|
||||||
|
await runConcurrent(deduped, async (src) => {
|
||||||
|
if (ctrl.signal.aborted) return;
|
||||||
|
const pageResults: Manga[] = [];
|
||||||
|
// Fetch TAG_FETCH_PAGES pages in series per source
|
||||||
|
for (let page = 1; page <= TAG_FETCH_PAGES; page++) {
|
||||||
|
if (ctrl.signal.aborted) return;
|
||||||
|
try {
|
||||||
|
const d = await gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>(
|
||||||
|
FETCH_SOURCE_MANGA,
|
||||||
|
{ source: src.id, type: "SEARCH", page, query: tag },
|
||||||
|
ctrl.signal,
|
||||||
|
);
|
||||||
|
pageResults.push(...d.fetchSourceManga.mangas);
|
||||||
|
if (!d.fetchSourceManga.hasNextPage) {
|
||||||
|
nextPageRef.current.set(src.id, -1); // no more pages
|
||||||
|
break;
|
||||||
|
} else if (page === TAG_FETCH_PAGES) {
|
||||||
|
// Still has more pages beyond what we fetched upfront
|
||||||
|
nextPageRef.current.set(src.id, TAG_FETCH_PAGES + 1);
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e?.name === "AbortError") return;
|
||||||
|
break; // source error — move on
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!ctrl.signal.aborted && pageResults.length > 0) {
|
||||||
|
setTagResults((prev) => dedupeMangaById([...prev, ...pageResults]));
|
||||||
|
}
|
||||||
|
}, ctrl.signal);
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e?.name !== "AbortError") console.error(e);
|
||||||
|
} finally {
|
||||||
|
if (!ctrl.signal.aborted) setLoadingTag(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMore() {
|
||||||
|
if (!activeTag || loadingMore) return;
|
||||||
|
|
||||||
|
// First check if we have more buffered results to show
|
||||||
|
if (visibleCount < tagResults.length) {
|
||||||
|
setVisibleCount((v) => v + TAG_PAGE_SIZE);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise fetch next pages from sources
|
||||||
|
const sourcesToFetch = sourcesRef.current.filter(
|
||||||
|
(src) => (nextPageRef.current.get(src.id) ?? -1) > 0
|
||||||
|
);
|
||||||
|
if (sourcesToFetch.length === 0) return;
|
||||||
|
|
||||||
|
setLoadingMore(true);
|
||||||
|
abortRef.current?.abort();
|
||||||
|
const ctrl = new AbortController();
|
||||||
|
abortRef.current = ctrl;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await runConcurrent(sourcesToFetch, async (src) => {
|
||||||
|
const page = nextPageRef.current.get(src.id)!;
|
||||||
|
if (ctrl.signal.aborted) return;
|
||||||
|
try {
|
||||||
|
const d = await gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>(
|
||||||
|
FETCH_SOURCE_MANGA,
|
||||||
|
{ source: src.id, type: "SEARCH", page, query: activeTag },
|
||||||
|
ctrl.signal,
|
||||||
|
);
|
||||||
|
nextPageRef.current.set(src.id, d.fetchSourceManga.hasNextPage ? page + 1 : -1);
|
||||||
|
if (!ctrl.signal.aborted && d.fetchSourceManga.mangas.length > 0) {
|
||||||
|
setTagResults((prev) => dedupeMangaById([...prev, ...d.fetchSourceManga.mangas]));
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e?.name !== "AbortError") nextPageRef.current.set(src.id, -1);
|
||||||
|
}
|
||||||
|
}, ctrl.signal);
|
||||||
|
} finally {
|
||||||
|
if (!ctrl.signal.aborted) {
|
||||||
|
setVisibleCount((v) => v + TAG_PAGE_SIZE);
|
||||||
|
setLoadingMore(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredGenres = useMemo(() => {
|
||||||
|
const q = tagFilter.trim().toLowerCase();
|
||||||
|
return q ? COMMON_GENRES.filter((g) => g.toLowerCase().includes(q)) : COMMON_GENRES;
|
||||||
|
}, [tagFilter]);
|
||||||
|
|
||||||
|
const visibleResults = tagResults.slice(0, visibleCount);
|
||||||
|
const hasMore = visibleCount < tagResults.length ||
|
||||||
|
sourcesRef.current.some((src) => (nextPageRef.current.get(src.id) ?? -1) > 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={s.splitRoot}>
|
||||||
|
<div className={s.splitSidebar}>
|
||||||
|
<div className={s.splitSearchWrap}>
|
||||||
|
<MagnifyingGlass size={12} className={s.splitSearchIcon} weight="light" />
|
||||||
|
<input
|
||||||
|
className={s.splitSearchInput}
|
||||||
|
placeholder="Filter tags…"
|
||||||
|
value={tagFilter}
|
||||||
|
onChange={(e) => setTagFilter(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={s.splitList}>
|
||||||
|
{filteredGenres.map((tag) => (
|
||||||
|
<button
|
||||||
|
key={tag}
|
||||||
|
className={[s.splitItem, activeTag === tag ? s.splitItemActive : ""].join(" ")}
|
||||||
|
onClick={() => drillTag(tag)}
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{filteredGenres.length === 0 && <p className={s.splitEmpty}>No matching tags</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={s.splitContent}>
|
||||||
|
{!activeTag ? (
|
||||||
|
<div className={s.empty}>
|
||||||
|
<Hash size={32} weight="light" className={s.emptyIcon} />
|
||||||
|
<p className={s.emptyText}>Browse by tag</p>
|
||||||
|
<p className={s.emptyHint}>Select a genre tag to see matching manga across your sources.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className={s.splitContentHeader}>
|
||||||
|
<span className={s.splitContentTitle}>{activeTag}</span>
|
||||||
|
{loadingTag
|
||||||
|
? <CircleNotch size={13} weight="light" className="anim-spin" style={{ color: "var(--text-faint)" }} />
|
||||||
|
: <span className={s.splitResultCount}>
|
||||||
|
{visibleResults.length}{tagResults.length > visibleCount ? `+` : ""} of {tagResults.length} results
|
||||||
|
</span>}
|
||||||
|
</div>
|
||||||
|
{loadingTag ? (
|
||||||
|
<GridSkeleton count={50} />
|
||||||
|
) : tagResults.length > 0 ? (
|
||||||
|
<div className={s.tagGrid}>
|
||||||
|
{visibleResults.map((m) => (
|
||||||
|
<MangaCard key={m.id} manga={m} onClick={() => onMangaClick(m)} />
|
||||||
|
))}
|
||||||
|
{hasMore && (
|
||||||
|
<div className={s.showMoreCell}>
|
||||||
|
<button className={s.showMoreBtn} onClick={loadMore} disabled={loadingMore}>
|
||||||
|
{loadingMore
|
||||||
|
? <><CircleNotch size={13} weight="light" className="anim-spin" /> Loading…</>
|
||||||
|
: "Show more"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className={s.empty}>
|
||||||
|
<p className={s.emptyText}>No results for "{activeTag}"</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Source tab ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function SourceTab({
|
||||||
|
allSources, loadingSources, availableLangs, hasMultipleLangs, onMangaClick,
|
||||||
|
}: {
|
||||||
|
allSources: Source[];
|
||||||
|
loadingSources: boolean;
|
||||||
|
availableLangs: string[];
|
||||||
|
hasMultipleLangs: boolean;
|
||||||
|
onMangaClick: (m: Manga) => void;
|
||||||
|
}) {
|
||||||
|
const [selectedLang, setSelectedLang] = useState<string>("all");
|
||||||
|
const [activeSource, setActiveSource] = useState<Source | null>(null);
|
||||||
|
const [browseResults, setBrowseResults] = useState<Manga[]>([]);
|
||||||
|
const [loadingBrowse, setLoadingBrowse] = useState(false);
|
||||||
|
const [browseQuery, setBrowseQuery] = useState("");
|
||||||
|
const [submitted, setSubmitted] = useState("");
|
||||||
|
const abortRef = useRef<AbortController | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => () => { abortRef.current?.abort(); }, []);
|
||||||
|
|
||||||
|
const visibleSources = useMemo(() =>
|
||||||
|
selectedLang === "all" ? allSources : allSources.filter((s) => s.lang === selectedLang),
|
||||||
|
[allSources, selectedLang]
|
||||||
|
);
|
||||||
|
|
||||||
|
async function fetchBrowse(src: Source, type: "POPULAR" | "SEARCH", q?: string) {
|
||||||
|
abortRef.current?.abort();
|
||||||
|
const ctrl = new AbortController();
|
||||||
|
abortRef.current = ctrl;
|
||||||
|
setLoadingBrowse(true);
|
||||||
|
setBrowseResults([]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const d = await gql<{ fetchSourceManga: { mangas: Manga[] } }>(
|
||||||
|
FETCH_SOURCE_MANGA,
|
||||||
|
{ source: src.id, type, page: 1, query: q ?? null },
|
||||||
|
ctrl.signal,
|
||||||
|
);
|
||||||
|
if (!ctrl.signal.aborted) setBrowseResults(d.fetchSourceManga.mangas);
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e?.name !== "AbortError") console.error(e);
|
||||||
|
} finally {
|
||||||
|
if (!ctrl.signal.aborted) setLoadingBrowse(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectSource(src: Source) {
|
||||||
|
setActiveSource(src);
|
||||||
|
setBrowseQuery("");
|
||||||
|
setSubmitted("");
|
||||||
|
fetchBrowse(src, "POPULAR");
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSearch() {
|
||||||
|
if (!activeSource || !browseQuery.trim()) return;
|
||||||
|
setSubmitted(browseQuery.trim());
|
||||||
|
fetchBrowse(activeSource, "SEARCH", browseQuery.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSearch() {
|
||||||
|
setBrowseQuery("");
|
||||||
|
setSubmitted("");
|
||||||
|
if (activeSource) fetchBrowse(activeSource, "POPULAR");
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={s.splitRoot}>
|
||||||
|
<div className={s.splitSidebar}>
|
||||||
|
{hasMultipleLangs && (
|
||||||
|
<div className={s.langFilterRow}>
|
||||||
|
{["all", ...availableLangs].map((lang) => (
|
||||||
|
<button key={lang}
|
||||||
|
className={[s.langChip, selectedLang === lang ? s.langChipActive : ""].join(" ")}
|
||||||
|
onClick={() => setSelectedLang(lang)}
|
||||||
|
>
|
||||||
|
{lang === "all" ? "All" : lang.toUpperCase()}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{loadingSources ? (
|
||||||
|
<div className={s.splitLoading}>
|
||||||
|
<CircleNotch size={16} weight="light" className="anim-spin" style={{ color: "var(--text-faint)" }} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className={s.splitList}>
|
||||||
|
{visibleSources.map((src) => (
|
||||||
|
<button key={src.id}
|
||||||
|
className={[s.splitItem, s.splitItemSource, activeSource?.id === src.id ? s.splitItemActive : ""].join(" ")}
|
||||||
|
onClick={() => selectSource(src)}
|
||||||
|
>
|
||||||
|
<img src={thumbUrl(src.iconUrl)} alt="" className={s.splitSourceIcon}
|
||||||
|
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
|
||||||
|
<span className={s.splitItemLabel}>{src.displayName}</span>
|
||||||
|
{src.isNsfw && <span className={s.nsfwBadge}>18+</span>}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{visibleSources.length === 0 && <p className={s.splitEmpty}>No sources for this language</p>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={s.splitContent}>
|
||||||
|
{!activeSource ? (
|
||||||
|
<div className={s.empty}>
|
||||||
|
<List size={32} weight="light" className={s.emptyIcon} />
|
||||||
|
<p className={s.emptyText}>Browse a source</p>
|
||||||
|
<p className={s.emptyHint}>Select a source to see its popular titles, or search within it.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className={s.splitContentHeader}>
|
||||||
|
<div className={s.splitSourceTitle}>
|
||||||
|
<img src={thumbUrl(activeSource.iconUrl)} alt="" className={s.splitSourceIcon}
|
||||||
|
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
|
||||||
|
<span className={s.splitContentTitle}>{activeSource.displayName}</span>
|
||||||
|
{loadingBrowse && <CircleNotch size={13} weight="light" className="anim-spin" style={{ color: "var(--text-faint)" }} />}
|
||||||
|
{!loadingBrowse && browseResults.length > 0 && <span className={s.splitResultCount}>{browseResults.length} results</span>}
|
||||||
|
</div>
|
||||||
|
<div className={s.sourceBrowseBar}>
|
||||||
|
<div className={s.searchBar} style={{ flex: 1 }}>
|
||||||
|
<MagnifyingGlass size={12} className={s.searchIcon} weight="light" />
|
||||||
|
<input
|
||||||
|
className={s.searchInput}
|
||||||
|
placeholder={`Search ${activeSource.displayName}…`}
|
||||||
|
value={browseQuery}
|
||||||
|
onChange={(e) => setBrowseQuery(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
|
||||||
|
/>
|
||||||
|
{submitted && (
|
||||||
|
<button className={s.clearSearchBtn} onClick={clearSearch} title="Clear search">×</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button className={s.searchBtn} onClick={handleSearch} disabled={!browseQuery.trim() || loadingBrowse}>
|
||||||
|
Search
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loadingBrowse ? <GridSkeleton /> : browseResults.length > 0 ? (
|
||||||
|
<div className={s.tagGrid}>
|
||||||
|
{browseResults.map((m) => <MangaCard key={m.id} manga={m} onClick={() => onMangaClick(m)} />)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className={s.empty}>
|
||||||
|
<p className={s.emptyText}>{submitted ? `No results for "${submitted}"` : "No results"}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -98,6 +98,16 @@
|
|||||||
letter-spacing: var(--tracking-wide);
|
letter-spacing: var(--tracking-wide);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.genreClickable {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color var(--t-base), border-color var(--t-base), background var(--t-base);
|
||||||
|
}
|
||||||
|
.genreClickable:hover {
|
||||||
|
color: var(--accent-fg);
|
||||||
|
border-color: var(--accent-dim);
|
||||||
|
background: var(--accent-muted);
|
||||||
|
}
|
||||||
|
|
||||||
.sourceLabel {
|
.sourceLabel {
|
||||||
font-family: var(--font-ui);
|
font-family: var(--font-ui);
|
||||||
font-size: var(--text-2xs);
|
font-size: var(--text-2xs);
|
||||||
@@ -111,11 +121,52 @@
|
|||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
line-height: var(--leading-base);
|
line-height: var(--leading-base);
|
||||||
display: -webkit-box;
|
display: -webkit-box;
|
||||||
-webkit-line-clamp: 8;
|
-webkit-line-clamp: 3;
|
||||||
-webkit-box-orient: vertical;
|
-webkit-box-orient: vertical;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.descriptionExpanded {
|
||||||
|
-webkit-line-clamp: unset;
|
||||||
|
display: block;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.descriptionWrap {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.descToggle {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-2xs);
|
||||||
|
color: var(--accent-fg);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
opacity: 0.7;
|
||||||
|
transition: opacity var(--t-base);
|
||||||
|
}
|
||||||
|
.descToggle:hover { opacity: 1; }
|
||||||
|
|
||||||
|
.genreToggle {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-2xs);
|
||||||
|
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);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color var(--t-base), border-color var(--t-base);
|
||||||
|
}
|
||||||
|
.genreToggle:hover { color: var(--accent-fg); border-color: var(--accent-dim); }
|
||||||
|
|
||||||
/* ── Progress ── */
|
/* ── Progress ── */
|
||||||
.progressSection {
|
.progressSection {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -230,10 +281,39 @@
|
|||||||
color: var(--text-faint);
|
color: var(--text-faint);
|
||||||
letter-spacing: var(--tracking-wide);
|
letter-spacing: var(--tracking-wide);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
margin-top: auto;
|
|
||||||
padding-top: var(--sp-2);
|
padding-top: var(--sp-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Sidebar mark-all quick actions ── */
|
||||||
|
.markAllRow {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.markAllBtn {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 5px var(--sp-2);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
border: 1px solid var(--border-dim);
|
||||||
|
background: none;
|
||||||
|
color: var(--text-faint);
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-2xs);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color var(--t-base), border-color var(--t-base), background var(--t-base);
|
||||||
|
}
|
||||||
|
.markAllBtn:hover:not(:disabled) {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
border-color: var(--border-strong);
|
||||||
|
background: var(--bg-raised);
|
||||||
|
}
|
||||||
|
.markAllBtn:disabled { opacity: 0.3; cursor: default; }
|
||||||
|
|
||||||
/* ── Chapter list ── */
|
/* ── Chapter list ── */
|
||||||
.listWrap {
|
.listWrap {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -644,6 +724,18 @@
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* In-progress progress fill bar (width set inline) */
|
||||||
|
.gridCellProgress {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
height: 2px;
|
||||||
|
background: var(--accent);
|
||||||
|
border-radius: 0 0 var(--radius-sm) var(--radius-sm);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
/* In-progress — accent highlight on bottom edge */
|
/* In-progress — accent highlight on bottom edge */
|
||||||
.gridCellInProgress {
|
.gridCellInProgress {
|
||||||
border-color: var(--accent-dim);
|
border-color: var(--accent-dim);
|
||||||
@@ -853,18 +945,23 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--sp-2);
|
gap: var(--sp-2);
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin-top: var(--sp-2);
|
padding: 6px var(--sp-2);
|
||||||
padding: 7px var(--sp-3);
|
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
|
font-family: var(--font-ui);
|
||||||
font-size: var(--text-xs);
|
font-size: var(--text-xs);
|
||||||
color: var(--color-error);
|
letter-spacing: var(--tracking-wide);
|
||||||
|
color: var(--text-faint);
|
||||||
background: none;
|
background: none;
|
||||||
border: 1px solid var(--color-error);
|
border: 1px solid var(--border-dim);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
transition: background var(--t-base);
|
transition: color var(--t-base), border-color var(--t-base), background var(--t-base);
|
||||||
|
}
|
||||||
|
.deleteAllBtn:hover:not(:disabled) {
|
||||||
|
color: var(--color-error);
|
||||||
|
border-color: color-mix(in srgb, var(--color-error) 40%, transparent);
|
||||||
|
background: var(--color-error-bg, color-mix(in srgb, var(--color-error) 8%, transparent));
|
||||||
}
|
}
|
||||||
.deleteAllBtn:hover:not(:disabled) { background: var(--color-error-bg); }
|
|
||||||
.deleteAllBtn:disabled { opacity: 0.4; cursor: default; }
|
.deleteAllBtn:disabled { opacity: 0.4; cursor: default; }
|
||||||
|
|
||||||
/* ── Danger item in dl dropdown ─────────────────────────────────────── */
|
/* ── Danger item in dl dropdown ─────────────────────────────────────── */
|
||||||
@@ -874,3 +971,100 @@
|
|||||||
.dlItemDanger:hover:not(:disabled) {
|
.dlItemDanger:hover:not(:disabled) {
|
||||||
background: var(--color-error-bg) !important;
|
background: var(--color-error-bg) !important;
|
||||||
}
|
}
|
||||||
|
/* ── Download dropdown extended: Next-N quick buttons + range picker ─────── */
|
||||||
|
.dlSectionLabel {
|
||||||
|
padding: 6px var(--sp-3) 2px;
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-2xs);
|
||||||
|
color: var(--text-faint);
|
||||||
|
letter-spacing: var(--tracking-wider);
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dlNextRow {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 2px var(--sp-2) var(--sp-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dlNextBtn {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 6px 4px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
border: 1px solid var(--border-dim);
|
||||||
|
background: var(--bg-overlay);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background var(--t-fast), border-color var(--t-fast), color var(--t-fast);
|
||||||
|
}
|
||||||
|
.dlNextBtn:hover:not(:disabled) {
|
||||||
|
background: var(--accent-muted);
|
||||||
|
border-color: var(--accent-dim);
|
||||||
|
color: var(--accent-fg);
|
||||||
|
}
|
||||||
|
.dlNextBtn:disabled { opacity: 0.3; cursor: default; }
|
||||||
|
|
||||||
|
.dlNextSub {
|
||||||
|
font-size: var(--text-2xs);
|
||||||
|
color: var(--text-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dlDivider {
|
||||||
|
height: 1px;
|
||||||
|
background: var(--border-dim);
|
||||||
|
margin: var(--sp-1) var(--sp-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dlRangeRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 2px var(--sp-2) var(--sp-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dlRangeInput {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 4px 8px;
|
||||||
|
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);
|
||||||
|
outline: none;
|
||||||
|
text-align: center;
|
||||||
|
transition: border-color var(--t-base);
|
||||||
|
}
|
||||||
|
.dlRangeInput:focus { border-color: var(--border-focus); }
|
||||||
|
.dlRangeInput::placeholder { color: var(--text-faint); }
|
||||||
|
|
||||||
|
.dlRangeSep {
|
||||||
|
color: var(--text-faint);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dlRangeGo {
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: 1px solid var(--accent-dim);
|
||||||
|
background: var(--accent-muted);
|
||||||
|
color: var(--accent-fg);
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: background var(--t-base);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.dlRangeGo:hover:not(:disabled) { background: var(--accent-dim); }
|
||||||
|
.dlRangeGo:disabled { opacity: 0.3; cursor: default; }
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useState, useMemo, useCallback, useRef } from "react";
|
import { useEffect, useState, useMemo, useCallback, useRef } from "react";
|
||||||
import {
|
import {
|
||||||
ArrowLeft, BookmarkSimple, Download, CheckCircle, Circle,
|
ArrowLeft, BookmarkSimple, Download, CheckCircle, Circle,
|
||||||
ArrowSquareOut, BookOpen, CircleNotch, Play,
|
ArrowSquareOut, CircleNotch, Play,
|
||||||
SortAscending, SortDescending, CaretDown, ArrowsClockwise,
|
SortAscending, SortDescending, CaretDown, ArrowsClockwise,
|
||||||
List, SquaresFour, FolderSimplePlus, X, Trash, DownloadSimple,
|
List, SquaresFour, FolderSimplePlus, X, Trash, DownloadSimple,
|
||||||
} from "@phosphor-icons/react";
|
} from "@phosphor-icons/react";
|
||||||
@@ -11,12 +11,15 @@ import {
|
|||||||
UPDATE_MANGA, MARK_CHAPTER_READ, MARK_CHAPTERS_READ, DELETE_DOWNLOADED_CHAPTERS,
|
UPDATE_MANGA, MARK_CHAPTER_READ, MARK_CHAPTERS_READ, DELETE_DOWNLOADED_CHAPTERS,
|
||||||
ENQUEUE_CHAPTERS_DOWNLOAD,
|
ENQUEUE_CHAPTERS_DOWNLOAD,
|
||||||
} from "../../lib/queries";
|
} from "../../lib/queries";
|
||||||
|
import { cache, CACHE_KEYS, recordSourceAccess } from "../../lib/cache";
|
||||||
import { useStore } from "../../store";
|
import { useStore } from "../../store";
|
||||||
import ContextMenu, { type ContextMenuEntry } from "../context/ContextMenu";
|
import ContextMenu, { type ContextMenuEntry } from "../context/ContextMenu";
|
||||||
import MigrateModal from "./MigrateModal";
|
import MigrateModal from "./MigrateModal";
|
||||||
import type { Manga, Chapter } from "../../lib/types";
|
import type { Manga, Chapter } from "../../lib/types";
|
||||||
import s from "./SeriesDetail.module.css";
|
import s from "./SeriesDetail.module.css";
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function formatDate(ts: string | null | undefined): string {
|
function formatDate(ts: string | null | undefined): string {
|
||||||
if (!ts) return "";
|
if (!ts) return "";
|
||||||
const n = Number(ts);
|
const n = Number(ts);
|
||||||
@@ -33,18 +36,174 @@ interface CtxState {
|
|||||||
|
|
||||||
const CHAPTERS_PER_PAGE = 25;
|
const CHAPTERS_PER_PAGE = 25;
|
||||||
|
|
||||||
// ── Folder picker (icon button for list header) ───────────────────────────────
|
// How long before we consider a manga detail / chapter list stale and silently re-fetch.
|
||||||
function FolderPicker({ mangaId }: { mangaId: number }) {
|
// This prevents hammering the server when rapidly opening/closing while still keeping
|
||||||
const [open, setOpen] = useState(false);
|
// data fresh enough for normal use.
|
||||||
|
const MANGA_CACHE_TTL_MS = 5 * 60 * 1000; // 5 min — detail rarely changes mid-session
|
||||||
|
const CHAPTER_CACHE_TTL_MS = 2 * 60 * 1000; // 2 min — chapters update more often
|
||||||
|
|
||||||
|
// ── TTL-aware memory stores (cleared on page refresh, not persisted) ──────────
|
||||||
|
// These supplement the session `cache` with timestamp tracking so we know when
|
||||||
|
// to silently re-validate in the background.
|
||||||
|
const mangaDetailStore = new Map<number, { data: Manga; fetchedAt: number }>();
|
||||||
|
const chapterStore = new Map<number, { data: Chapter[]; fetchedAt: number }>();
|
||||||
|
|
||||||
|
// ── Download dropdown ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface DownloadDropdownProps {
|
||||||
|
sortedChapters: Chapter[];
|
||||||
|
continueChapter: { chapter: Chapter; type: string } | null;
|
||||||
|
downloadedCount: number;
|
||||||
|
deletingAll: boolean;
|
||||||
|
onEnqueue: (ids: number[]) => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DownloadDropdown({
|
||||||
|
sortedChapters, continueChapter, downloadedCount, deletingAll,
|
||||||
|
onEnqueue, onDelete, onClose,
|
||||||
|
}: DownloadDropdownProps) {
|
||||||
|
const [rangeFrom, setRangeFrom] = useState("");
|
||||||
|
const [rangeTo, setRangeTo] = useState("");
|
||||||
|
const [showRange, setShowRange] = useState(false);
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (e: MouseEvent) => {
|
||||||
|
if (ref.current && !ref.current.contains(e.target as Node)) onClose();
|
||||||
|
};
|
||||||
|
document.addEventListener("mousedown", handler, true);
|
||||||
|
return () => document.removeEventListener("mousedown", handler, true);
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
const continueIdx = continueChapter
|
||||||
|
? sortedChapters.indexOf(continueChapter.chapter)
|
||||||
|
: -1;
|
||||||
|
|
||||||
|
function enqueueNext(n: number) {
|
||||||
|
if (continueIdx < 0) return;
|
||||||
|
const ids = sortedChapters
|
||||||
|
.slice(continueIdx, continueIdx + n)
|
||||||
|
.filter((c) => !c.isDownloaded)
|
||||||
|
.map((c) => c.id);
|
||||||
|
onEnqueue(ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
function enqueueRange() {
|
||||||
|
const from = parseFloat(rangeFrom);
|
||||||
|
const to = parseFloat(rangeTo);
|
||||||
|
if (isNaN(from) || isNaN(to)) return;
|
||||||
|
const lo = Math.min(from, to), hi = Math.max(from, to);
|
||||||
|
const ids = sortedChapters
|
||||||
|
.filter((c) => c.chapterNumber >= lo && c.chapterNumber <= hi && !c.isDownloaded)
|
||||||
|
.map((c) => c.id);
|
||||||
|
if (ids.length) onEnqueue(ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
const unreadNotDl = sortedChapters.filter((c) => !c.isRead && !c.isDownloaded);
|
||||||
|
const allNotDl = sortedChapters.filter((c) => !c.isDownloaded);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={s.dlDropdown} ref={ref}>
|
||||||
|
{continueChapter && continueIdx >= 0 && (
|
||||||
|
<>
|
||||||
|
<p className={s.dlSectionLabel}>From Ch.{continueChapter.chapter.chapterNumber}</p>
|
||||||
|
<div className={s.dlNextRow}>
|
||||||
|
{[5, 10, 25].map((n) => {
|
||||||
|
const avail = sortedChapters
|
||||||
|
.slice(continueIdx, continueIdx + n)
|
||||||
|
.filter((c) => !c.isDownloaded).length;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={n}
|
||||||
|
className={s.dlNextBtn}
|
||||||
|
disabled={avail === 0}
|
||||||
|
onClick={() => enqueueNext(n)}
|
||||||
|
>
|
||||||
|
<span>Next {n}</span>
|
||||||
|
<span className={s.dlNextSub}>{avail} new</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className={s.dlDivider} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button className={s.dlItem} onClick={() => setShowRange((p) => !p)}>
|
||||||
|
<span>Custom range…</span>
|
||||||
|
<span className={s.dlItemSub}>Enter chapter numbers</span>
|
||||||
|
</button>
|
||||||
|
{showRange && (
|
||||||
|
<div className={s.dlRangeRow}>
|
||||||
|
<input
|
||||||
|
className={s.dlRangeInput}
|
||||||
|
placeholder="From"
|
||||||
|
value={rangeFrom}
|
||||||
|
onChange={(e) => setRangeFrom(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && enqueueRange()}
|
||||||
|
/>
|
||||||
|
<span className={s.dlRangeSep}>–</span>
|
||||||
|
<input
|
||||||
|
className={s.dlRangeInput}
|
||||||
|
placeholder="To"
|
||||||
|
value={rangeTo}
|
||||||
|
onChange={(e) => setRangeTo(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && enqueueRange()}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className={s.dlRangeGo}
|
||||||
|
disabled={!rangeFrom.trim() || !rangeTo.trim()}
|
||||||
|
onClick={enqueueRange}
|
||||||
|
>
|
||||||
|
Queue
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={s.dlDivider} />
|
||||||
|
|
||||||
|
<button className={s.dlItem} onClick={() => onEnqueue(unreadNotDl.map((c) => c.id))}>
|
||||||
|
<span>Unread chapters</span>
|
||||||
|
<span className={s.dlItemSub}>{unreadNotDl.length} remaining</span>
|
||||||
|
</button>
|
||||||
|
<button className={s.dlItem} onClick={() => onEnqueue(allNotDl.map((c) => c.id))}>
|
||||||
|
<span>Download all</span>
|
||||||
|
<span className={s.dlItemSub}>{allNotDl.length} not yet downloaded</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{downloadedCount > 0 && (
|
||||||
|
<>
|
||||||
|
<div className={s.dlDivider} />
|
||||||
|
<button
|
||||||
|
className={[s.dlItem, s.dlItemDanger].join(" ")}
|
||||||
|
onClick={onDelete}
|
||||||
|
disabled={deletingAll}
|
||||||
|
>
|
||||||
|
<span>{deletingAll ? "Deleting…" : "Delete all downloads"}</span>
|
||||||
|
<span className={s.dlItemSub}>{downloadedCount} downloaded</span>
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Folder picker ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function FolderPicker({ mangaId }: { mangaId: number }) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [newName, setNewName] = useState("");
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const folders = useStore((st) => st.settings.folders);
|
const folders = useStore((st) => st.settings.folders);
|
||||||
const assignMangaToFolder = useStore((st) => st.assignMangaToFolder);
|
const assignMangaToFolder = useStore((st) => st.assignMangaToFolder);
|
||||||
const removeMangaFromFolder = useStore((st) => st.removeMangaFromFolder);
|
const removeMangaFromFolder = useStore((st) => st.removeMangaFromFolder);
|
||||||
const addFolder = useStore((st) => st.addFolder);
|
const addFolder = useStore((st) => st.addFolder);
|
||||||
const [newName, setNewName] = useState("");
|
|
||||||
const [creating, setCreating] = useState(false);
|
|
||||||
|
|
||||||
const assigned = folders.filter((f) => f.mangaIds.includes(mangaId));
|
const assigned = folders.filter((f) => f.mangaIds.includes(mangaId));
|
||||||
const hasAssigned = assigned.length > 0;
|
const hasAssigned = assigned.length > 0;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -134,84 +293,192 @@ function FolderPicker({ mangaId }: { mangaId: number }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Main component ────────────────────────────────────────────────────────────
|
// ── Main component ────────────────────────────────────────────────────────────
|
||||||
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);
|
export default function SeriesDetail() {
|
||||||
const [chapters, setChapters] = useState<Chapter[]>([]);
|
const activeManga = useStore((state) => state.activeManga);
|
||||||
const [loadingManga, setLoadingManga] = useState(true);
|
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 addToast = useStore((state) => state.addToast);
|
||||||
|
const setGenreFilter = useStore((state) => state.setGenreFilter);
|
||||||
|
const setNavPage = useStore((state) => state.setNavPage);
|
||||||
|
|
||||||
|
const [manga, setManga] = useState<Manga | null>(null);
|
||||||
|
const [chapters, setChapters] = useState<Chapter[]>([]);
|
||||||
|
const [loadingManga, setLoadingManga] = useState(false);
|
||||||
const [loadingChapters, setLoadingChapters] = useState(true);
|
const [loadingChapters, setLoadingChapters] = useState(true);
|
||||||
const [enqueueing, setEnqueueing] = useState<Set<number>>(new Set());
|
const [enqueueing, setEnqueueing] = useState<Set<number>>(new Set());
|
||||||
const [dlOpen, setDlOpen] = useState(false);
|
const [dlOpen, setDlOpen] = useState(false);
|
||||||
const [detailsOpen, setDetailsOpen] = useState(false);
|
const [detailsOpen, setDetailsOpen] = useState(false);
|
||||||
const [migrateOpen, setMigrateOpen] = useState(false);
|
const [migrateOpen, setMigrateOpen] = useState(false);
|
||||||
const [togglingLibrary, setTogglingLibrary] = useState(false);
|
const [togglingLibrary, setTogglingLibrary] = useState(false);
|
||||||
const [chapterPage, setChapterPage] = useState(1);
|
const [chapterPage, setChapterPage] = useState(1);
|
||||||
const [ctx, setCtx] = useState<CtxState | null>(null);
|
const [ctx, setCtx] = useState<CtxState | null>(null);
|
||||||
const [jumpOpen, setJumpOpen] = useState(false);
|
const [jumpOpen, setJumpOpen] = useState(false);
|
||||||
const [jumpInput, setJumpInput] = useState("");
|
const [jumpInput, setJumpInput] = useState("");
|
||||||
const [viewMode, setViewMode] = useState<"list" | "grid">("list");
|
const [viewMode, setViewMode] = useState<"list" | "grid">("list");
|
||||||
const [deletingAll, setDeletingAll] = useState(false);
|
const [deletingAll, setDeletingAll] = useState(false);
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
const [descExpanded, setDescExpanded] = useState(false);
|
||||||
|
const [genresExpanded, setGenresExpanded] = useState(false);
|
||||||
|
|
||||||
|
// Track the abort controllers for in-flight requests so we can cancel on unmount/change
|
||||||
|
// Manga detail and chapters each get their own controller so they don't clobber each other
|
||||||
|
const mangaAbortRef = useRef<AbortController | null>(null);
|
||||||
|
const chapterAbortRef = useRef<AbortController | null>(null);
|
||||||
|
// Track the manga ID we're currently loading to discard stale results
|
||||||
|
const loadingForRef = useRef<number | null>(null);
|
||||||
|
|
||||||
const sortDir = settings.chapterSortDir;
|
const sortDir = settings.chapterSortDir;
|
||||||
|
|
||||||
|
// ── Manga detail: serve from TTL cache, silently re-validate if stale ──────
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!activeManga) return;
|
if (!activeManga) return;
|
||||||
|
|
||||||
|
const mangaId = activeManga.id;
|
||||||
|
|
||||||
|
// Cancel any in-flight manga detail request from a previous manga
|
||||||
|
mangaAbortRef.current?.abort();
|
||||||
|
const ctrl = new AbortController();
|
||||||
|
mangaAbortRef.current = ctrl;
|
||||||
|
loadingForRef.current = mangaId;
|
||||||
|
|
||||||
|
const cached = mangaDetailStore.get(mangaId);
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
if (cached) {
|
||||||
|
// Serve from memory immediately — no loading state, no flash
|
||||||
|
setManga(cached.data);
|
||||||
|
setLoadingManga(false);
|
||||||
|
|
||||||
|
// If cache is fresh enough, skip the network entirely
|
||||||
|
if (now - cached.fetchedAt < MANGA_CACHE_TTL_MS) return;
|
||||||
|
|
||||||
|
// Stale: re-validate silently in the background (no spinner)
|
||||||
|
gql<{ manga: Manga }>(GET_MANGA, { id: mangaId }, ctrl.signal)
|
||||||
|
.then((data) => {
|
||||||
|
if (ctrl.signal.aborted || loadingForRef.current !== mangaId) return;
|
||||||
|
mangaDetailStore.set(mangaId, { data: data.manga, fetchedAt: Date.now() });
|
||||||
|
setManga(data.manga);
|
||||||
|
if (data.manga.source?.id) recordSourceAccess(data.manga.source.id);
|
||||||
|
})
|
||||||
|
.catch((e) => { if (e?.name !== "AbortError") console.error(e); });
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nothing cached — show skeleton and fetch
|
||||||
setLoadingManga(true);
|
setLoadingManga(true);
|
||||||
gql<{ manga: Manga }>(GET_MANGA, { id: activeManga.id })
|
gql<{ manga: Manga }>(GET_MANGA, { id: mangaId }, ctrl.signal)
|
||||||
.then((data) => setManga(data.manga))
|
.then((data) => {
|
||||||
.catch(console.error)
|
if (ctrl.signal.aborted || loadingForRef.current !== mangaId) return;
|
||||||
.finally(() => setLoadingManga(false));
|
mangaDetailStore.set(mangaId, { data: data.manga, fetchedAt: Date.now() });
|
||||||
|
setManga(data.manga);
|
||||||
|
if (data.manga.source?.id) recordSourceAccess(data.manga.source.id);
|
||||||
|
})
|
||||||
|
.catch((e) => { if (e?.name !== "AbortError") console.error(e); })
|
||||||
|
.finally(() => {
|
||||||
|
if (!ctrl.signal.aborted && loadingForRef.current === mangaId) setLoadingManga(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => { ctrl.abort(); mangaAbortRef.current = null; };
|
||||||
}, [activeManga?.id]);
|
}, [activeManga?.id]);
|
||||||
|
|
||||||
const loadChapters = useCallback((mangaId: number) => {
|
// ── Chapter loading: cache-first, background refresh only when stale ────────
|
||||||
return gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId })
|
const applyChapters = useCallback((nodes: Chapter[]) => {
|
||||||
.then((data) => {
|
const sorted = [...nodes].sort((a, b) => a.sourceOrder - b.sourceOrder);
|
||||||
const sorted = [...data.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder);
|
setChapters(sorted);
|
||||||
setChapters(sorted);
|
return sorted;
|
||||||
return sorted;
|
|
||||||
});
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!activeManga) return;
|
if (!activeManga) return;
|
||||||
setLoadingChapters(true);
|
|
||||||
setChapters([]);
|
const mangaId = activeManga.id;
|
||||||
setChapterPage(1);
|
setChapterPage(1);
|
||||||
|
|
||||||
loadChapters(activeManga.id)
|
// Cancel any previous in-flight chapter requests
|
||||||
.catch(console.error)
|
chapterAbortRef.current?.abort();
|
||||||
.finally(() => setLoadingChapters(false));
|
const ctrl = new AbortController();
|
||||||
|
chapterAbortRef.current = ctrl;
|
||||||
|
loadingForRef.current = mangaId;
|
||||||
|
|
||||||
gql(FETCH_CHAPTERS, { mangaId: activeManga.id })
|
const cached = chapterStore.get(mangaId);
|
||||||
.then(() => loadChapters(activeManga.id))
|
const now = Date.now();
|
||||||
.catch(console.error);
|
|
||||||
}, [activeManga?.id]);
|
if (cached) {
|
||||||
|
// Show cached data instantly
|
||||||
|
applyChapters(cached.data);
|
||||||
|
setLoadingChapters(false);
|
||||||
|
|
||||||
|
// Fresh enough — don't touch the network at all
|
||||||
|
if (now - cached.fetchedAt < CHAPTER_CACHE_TTL_MS) return;
|
||||||
|
|
||||||
|
// Stale — silently re-validate: fetch from source then re-read local DB
|
||||||
|
// We don't clear the chapter list while this happens (no flicker)
|
||||||
|
gql(FETCH_CHAPTERS, { mangaId }, ctrl.signal)
|
||||||
|
.then(() => gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId }, ctrl.signal))
|
||||||
|
.then((data) => {
|
||||||
|
if (ctrl.signal.aborted || loadingForRef.current !== mangaId) return;
|
||||||
|
chapterStore.set(mangaId, { data: data.chapters.nodes, fetchedAt: Date.now() });
|
||||||
|
applyChapters(data.chapters.nodes);
|
||||||
|
})
|
||||||
|
.catch((e) => { if (e?.name !== "AbortError") console.error(e); });
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nothing cached — show skeleton, load local DB first (fast), then source
|
||||||
|
setChapters([]);
|
||||||
|
setLoadingChapters(true);
|
||||||
|
|
||||||
|
gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId }, ctrl.signal)
|
||||||
|
.then((data) => {
|
||||||
|
if (ctrl.signal.aborted || loadingForRef.current !== mangaId) return;
|
||||||
|
// Show local DB result immediately so the user isn't staring at a spinner
|
||||||
|
applyChapters(data.chapters.nodes);
|
||||||
|
setLoadingChapters(false);
|
||||||
|
|
||||||
|
// Now silently fetch from the source to pick up any new chapters
|
||||||
|
return gql(FETCH_CHAPTERS, { mangaId }, ctrl.signal)
|
||||||
|
.then(() => gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId }, ctrl.signal))
|
||||||
|
.then((fresh) => {
|
||||||
|
if (ctrl.signal.aborted || loadingForRef.current !== mangaId) return;
|
||||||
|
chapterStore.set(mangaId, { data: fresh.chapters.nodes, fetchedAt: Date.now() });
|
||||||
|
applyChapters(fresh.chapters.nodes);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
if (ctrl.signal.aborted || e?.name === "AbortError") return;
|
||||||
|
console.error(e);
|
||||||
|
setLoadingChapters(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => { ctrl.abort(); chapterAbortRef.current = null; };
|
||||||
|
}, [activeManga?.id, applyChapters]);
|
||||||
|
|
||||||
|
// ── Derived state ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const sortedChapters = useMemo(() =>
|
const sortedChapters = useMemo(() =>
|
||||||
sortDir === "desc" ? [...chapters].reverse() : [...chapters],
|
sortDir === "desc" ? [...chapters].reverse() : [...chapters],
|
||||||
[chapters, sortDir]
|
[chapters, sortDir]
|
||||||
);
|
);
|
||||||
|
|
||||||
const totalPages = Math.ceil(sortedChapters.length / CHAPTERS_PER_PAGE);
|
const totalPages = Math.ceil(sortedChapters.length / CHAPTERS_PER_PAGE);
|
||||||
const pageChapters = sortedChapters.slice(
|
const pageChapters = sortedChapters.slice(
|
||||||
(chapterPage - 1) * CHAPTERS_PER_PAGE,
|
(chapterPage - 1) * CHAPTERS_PER_PAGE,
|
||||||
chapterPage * CHAPTERS_PER_PAGE
|
chapterPage * CHAPTERS_PER_PAGE
|
||||||
);
|
);
|
||||||
|
const readCount = chapters.filter((c) => c.isRead).length;
|
||||||
const readCount = chapters.filter((c) => c.isRead).length;
|
const totalCount = chapters.length;
|
||||||
const totalCount = chapters.length;
|
const progressPct = totalCount > 0 ? (readCount / totalCount) * 100 : 0;
|
||||||
const progressPct = totalCount > 0 ? (readCount / totalCount) * 100 : 0;
|
|
||||||
const downloadedCount = chapters.filter((c) => c.isDownloaded).length;
|
const downloadedCount = chapters.filter((c) => c.isDownloaded).length;
|
||||||
|
|
||||||
const continueChapter = useMemo(() => {
|
const continueChapter = useMemo(() => {
|
||||||
if (!chapters.length) return null;
|
if (!chapters.length) return null;
|
||||||
const asc = [...chapters].sort((a, b) => a.sourceOrder - b.sourceOrder);
|
const asc = [...chapters].sort((a, b) => a.sourceOrder - b.sourceOrder);
|
||||||
const anyRead = asc.some((c) => c.isRead);
|
const anyRead = asc.some((c) => c.isRead);
|
||||||
const inProgress = asc.find((c) => !c.isRead && (c.lastPageRead ?? 0) > 0);
|
const inProgress = asc.find((c) => !c.isRead && (c.lastPageRead ?? 0) > 0);
|
||||||
if (inProgress) return { chapter: inProgress, type: "continue" as const };
|
if (inProgress) return { chapter: inProgress, type: "continue" as const };
|
||||||
const firstUnread = asc.find((c) => !c.isRead);
|
const firstUnread = asc.find((c) => !c.isRead);
|
||||||
@@ -219,39 +486,88 @@ export default function SeriesDetail() {
|
|||||||
return { chapter: asc[0], type: "reread" as const };
|
return { chapter: asc[0], type: "reread" as const };
|
||||||
}, [chapters]);
|
}, [chapters]);
|
||||||
|
|
||||||
|
// ── Actions ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async function toggleLibrary() {
|
async function toggleLibrary() {
|
||||||
if (!manga) return;
|
if (!manga) return;
|
||||||
setTogglingLibrary(true);
|
setTogglingLibrary(true);
|
||||||
const next = !manga.inLibrary;
|
const next = !manga.inLibrary;
|
||||||
await gql(UPDATE_MANGA, { id: manga.id, inLibrary: next }).catch(console.error);
|
await gql(UPDATE_MANGA, { id: manga.id, inLibrary: next }).catch(console.error);
|
||||||
setManga((prev) => prev ? { ...prev, inLibrary: next } : prev);
|
const updated = { ...manga, inLibrary: next };
|
||||||
|
setManga(updated);
|
||||||
|
// Update the detail cache so re-open reflects the new state
|
||||||
|
if (mangaDetailStore.has(manga.id)) {
|
||||||
|
const entry = mangaDetailStore.get(manga.id)!;
|
||||||
|
mangaDetailStore.set(manga.id, { ...entry, data: updated });
|
||||||
|
}
|
||||||
|
cache.clear(CACHE_KEYS.LIBRARY);
|
||||||
setTogglingLibrary(false);
|
setTogglingLibrary(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const reloadChapters = useCallback((mangaId: number, signal?: AbortSignal) => {
|
||||||
|
return gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId }, signal)
|
||||||
|
.then((data) => {
|
||||||
|
chapterStore.set(mangaId, { data: data.chapters.nodes, fetchedAt: Date.now() });
|
||||||
|
applyChapters(data.chapters.nodes);
|
||||||
|
});
|
||||||
|
}, [applyChapters]);
|
||||||
|
|
||||||
async function enqueue(chapter: Chapter, e: React.MouseEvent) {
|
async function enqueue(chapter: Chapter, e: React.MouseEvent) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setEnqueueing((prev) => new Set(prev).add(chapter.id));
|
setEnqueueing((prev) => new Set(prev).add(chapter.id));
|
||||||
await gql(ENQUEUE_DOWNLOAD, { chapterId: chapter.id }).catch(console.error);
|
await gql(ENQUEUE_DOWNLOAD, { chapterId: chapter.id }).catch(console.error);
|
||||||
|
addToast({ kind: "download", title: "Download queued", body: chapter.name });
|
||||||
setEnqueueing((prev) => { const n = new Set(prev); n.delete(chapter.id); return n; });
|
setEnqueueing((prev) => { const n = new Set(prev); n.delete(chapter.id); return n; });
|
||||||
if (activeManga) loadChapters(activeManga.id);
|
if (activeManga) reloadChapters(activeManga.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function enqueueMultiple(chapterIds: number[]) {
|
||||||
|
if (!chapterIds.length) return;
|
||||||
|
await gql(ENQUEUE_CHAPTERS_DOWNLOAD, { chapterIds }).catch(console.error);
|
||||||
|
addToast({
|
||||||
|
kind: "download",
|
||||||
|
title: "Download queued",
|
||||||
|
body: `${chapterIds.length} chapter${chapterIds.length !== 1 ? "s" : ""} added to queue`,
|
||||||
|
});
|
||||||
|
if (activeManga) reloadChapters(activeManga.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function markRead(chapterId: number, isRead: boolean) {
|
async function markRead(chapterId: number, isRead: boolean) {
|
||||||
await gql(MARK_CHAPTER_READ, { id: chapterId, isRead }).catch(console.error);
|
await gql(MARK_CHAPTER_READ, { id: chapterId, isRead }).catch(console.error);
|
||||||
setChapters((prev) => prev.map((c) => c.id === chapterId ? { ...c, isRead } : c));
|
setChapters((prev) => {
|
||||||
|
const updated = prev.map((c) => c.id === chapterId ? { ...c, isRead } : c);
|
||||||
|
if (activeManga) chapterStore.set(activeManga.id, { data: updated, fetchedAt: Date.now() });
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function markAllAboveRead(indexInSorted: number) {
|
async function markBulk(ids: number[], isRead: boolean) {
|
||||||
const targets = sortedChapters.slice(0, indexInSorted + 1);
|
|
||||||
const ids = targets.filter((c) => !c.isRead).map((c) => c.id);
|
|
||||||
if (!ids.length) return;
|
if (!ids.length) return;
|
||||||
await gql(MARK_CHAPTERS_READ, { ids, isRead: true }).catch(console.error);
|
await gql(MARK_CHAPTERS_READ, { ids, isRead }).catch(console.error);
|
||||||
setChapters((prev) => prev.map((c) => ids.includes(c.id) ? { ...c, isRead: true } : c));
|
setChapters((prev) => {
|
||||||
|
const idSet = new Set(ids);
|
||||||
|
const updated = prev.map((c) => idSet.has(c.id) ? { ...c, isRead } : c);
|
||||||
|
if (activeManga) chapterStore.set(activeManga.id, { data: updated, fetchedAt: Date.now() });
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const markAllAboveRead = (i: number) =>
|
||||||
|
markBulk(sortedChapters.slice(0, i + 1).filter((c) => !c.isRead).map((c) => c.id), true);
|
||||||
|
const markAllBelowRead = (i: number) =>
|
||||||
|
markBulk(sortedChapters.slice(i).filter((c) => !c.isRead).map((c) => c.id), true);
|
||||||
|
const markAllAboveUnread = (i: number) =>
|
||||||
|
markBulk(sortedChapters.slice(0, i + 1).filter((c) => c.isRead).map((c) => c.id), false);
|
||||||
|
const markAllBelowUnread = (i: number) =>
|
||||||
|
markBulk(sortedChapters.slice(i).filter((c) => c.isRead).map((c) => c.id), false);
|
||||||
|
|
||||||
async function deleteDownloaded(chapterId: number) {
|
async function deleteDownloaded(chapterId: number) {
|
||||||
await gql(DELETE_DOWNLOADED_CHAPTERS, { ids: [chapterId] }).catch(console.error);
|
await gql(DELETE_DOWNLOADED_CHAPTERS, { ids: [chapterId] }).catch(console.error);
|
||||||
setChapters((prev) => prev.map((c) => c.id === chapterId ? { ...c, isDownloaded: false } : c));
|
setChapters((prev) => {
|
||||||
|
const updated = prev.map((c) => c.id === chapterId ? { ...c, isDownloaded: false } : c);
|
||||||
|
if (activeManga) chapterStore.set(activeManga.id, { data: updated, fetchedAt: Date.now() });
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteAllDownloads() {
|
async function deleteAllDownloads() {
|
||||||
@@ -259,13 +575,24 @@ export default function SeriesDetail() {
|
|||||||
if (!ids.length) return;
|
if (!ids.length) return;
|
||||||
setDeletingAll(true);
|
setDeletingAll(true);
|
||||||
await gql(DELETE_DOWNLOADED_CHAPTERS, { ids }).catch(console.error);
|
await gql(DELETE_DOWNLOADED_CHAPTERS, { ids }).catch(console.error);
|
||||||
setChapters((prev) => prev.map((c) => ({ ...c, isDownloaded: false })));
|
setChapters((prev) => {
|
||||||
|
const updated = prev.map((c) => ({ ...c, isDownloaded: false }));
|
||||||
|
if (activeManga) chapterStore.set(activeManga.id, { data: updated, fetchedAt: Date.now() });
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
setDeletingAll(false);
|
setDeletingAll(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function enqueueMultiple(chapterIds: number[]) {
|
async function refreshChapters() {
|
||||||
await gql(ENQUEUE_CHAPTERS_DOWNLOAD, { chapterIds }).catch(console.error);
|
if (!activeManga || refreshing) return;
|
||||||
if (activeManga) loadChapters(activeManga.id);
|
setRefreshing(true);
|
||||||
|
// Force-invalidate the chapter cache for this manga so we get a fresh fetch
|
||||||
|
chapterStore.delete(activeManga.id);
|
||||||
|
await gql(FETCH_CHAPTERS, { mangaId: activeManga.id })
|
||||||
|
.then(() => reloadChapters(activeManga.id))
|
||||||
|
.then(() => addToast({ kind: "success", title: "Chapters refreshed" }))
|
||||||
|
.catch((e) => addToast({ kind: "error", title: "Refresh failed", body: e?.message ?? String(e) }))
|
||||||
|
.finally(() => setRefreshing(false));
|
||||||
}
|
}
|
||||||
|
|
||||||
function openContextMenu(e: React.MouseEvent, chapter: Chapter, indexInSorted: number) {
|
function openContextMenu(e: React.MouseEvent, chapter: Chapter, indexInSorted: number) {
|
||||||
@@ -274,59 +601,99 @@ export default function SeriesDetail() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildCtxItems(ch: Chapter, indexInSorted: number): ContextMenuEntry[] {
|
function buildCtxItems(ch: Chapter, indexInSorted: number): ContextMenuEntry[] {
|
||||||
|
const aboveItems = sortedChapters.slice(0, indexInSorted + 1);
|
||||||
|
const belowItems = sortedChapters.slice(indexInSorted);
|
||||||
|
const unreadAbove = aboveItems.filter((c) => !c.isRead).length;
|
||||||
|
const unreadBelow = belowItems.filter((c) => !c.isRead).length;
|
||||||
|
const readAbove = aboveItems.filter((c) => c.isRead).length;
|
||||||
|
const readBelow = belowItems.filter((c) => c.isRead).length;
|
||||||
|
const lastIdx = sortedChapters.length - 1;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
label: ch.isRead ? "Mark as unread" : "Mark as read",
|
label: ch.isRead ? "Mark as unread" : "Mark as read",
|
||||||
icon: ch.isRead
|
icon: ch.isRead ? <Circle size={13} weight="light" /> : <CheckCircle size={13} weight="light" />,
|
||||||
? <Circle size={13} weight="light" />
|
|
||||||
: <CheckCircle size={13} weight="light" />,
|
|
||||||
onClick: () => markRead(ch.id, !ch.isRead),
|
onClick: () => markRead(ch.id, !ch.isRead),
|
||||||
},
|
},
|
||||||
|
{ separator: true },
|
||||||
{
|
{
|
||||||
label: "Mark all above as read",
|
label: "Mark above as read",
|
||||||
icon: <CheckCircle size={13} weight="duotone" />,
|
icon: <CheckCircle size={13} weight="duotone" />,
|
||||||
onClick: () => markAllAboveRead(indexInSorted),
|
onClick: () => markAllAboveRead(indexInSorted),
|
||||||
disabled: indexInSorted === 0,
|
disabled: indexInSorted === 0 || unreadAbove === 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Mark above as unread",
|
||||||
|
icon: <Circle size={13} weight="duotone" />,
|
||||||
|
onClick: () => markAllAboveUnread(indexInSorted),
|
||||||
|
disabled: indexInSorted === 0 || readAbove === 0,
|
||||||
|
},
|
||||||
|
{ separator: true },
|
||||||
|
{
|
||||||
|
label: "Mark below as read",
|
||||||
|
icon: <CheckCircle size={13} weight="duotone" />,
|
||||||
|
onClick: () => markAllBelowRead(indexInSorted),
|
||||||
|
disabled: indexInSorted === lastIdx || unreadBelow === 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Mark below as unread",
|
||||||
|
icon: <Circle size={13} weight="duotone" />,
|
||||||
|
onClick: () => markAllBelowUnread(indexInSorted),
|
||||||
|
disabled: indexInSorted === lastIdx || readBelow === 0,
|
||||||
},
|
},
|
||||||
{ separator: true },
|
{ separator: true },
|
||||||
{
|
{
|
||||||
label: ch.isDownloaded ? "Delete download" : "Download",
|
label: ch.isDownloaded ? "Delete download" : "Download",
|
||||||
icon: ch.isDownloaded
|
icon: ch.isDownloaded ? <Trash size={13} weight="light" /> : <Download size={13} weight="light" />,
|
||||||
? <Trash size={13} weight="light" />
|
|
||||||
: <Download size={13} weight="light" />,
|
|
||||||
onClick: () => ch.isDownloaded
|
onClick: () => ch.isDownloaded
|
||||||
? deleteDownloaded(ch.id)
|
? deleteDownloaded(ch.id)
|
||||||
: gql(ENQUEUE_DOWNLOAD, { chapterId: ch.id }).catch(console.error),
|
: gql(ENQUEUE_DOWNLOAD, { chapterId: ch.id }).catch(console.error),
|
||||||
danger: ch.isDownloaded,
|
danger: ch.isDownloaded,
|
||||||
},
|
},
|
||||||
{ separator: true },
|
{ separator: true },
|
||||||
|
{
|
||||||
|
label: "Download next 5 from here",
|
||||||
|
icon: <DownloadSimple size={13} weight="light" />,
|
||||||
|
onClick: () => {
|
||||||
|
const ids = sortedChapters
|
||||||
|
.slice(indexInSorted, indexInSorted + 5)
|
||||||
|
.filter((c) => !c.isDownloaded)
|
||||||
|
.map((c) => c.id);
|
||||||
|
enqueueMultiple(ids);
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: "Download all from here",
|
label: "Download all from here",
|
||||||
icon: <DownloadSimple size={13} weight="light" />,
|
icon: <DownloadSimple size={13} weight="light" />,
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
const fromHere = sortedChapters
|
const ids = sortedChapters
|
||||||
.slice(indexInSorted)
|
.slice(indexInSorted)
|
||||||
.filter((c) => !c.isDownloaded)
|
.filter((c) => !c.isDownloaded)
|
||||||
.map((c) => c.id);
|
.map((c) => c.id);
|
||||||
enqueueMultiple(fromHere);
|
enqueueMultiple(ids);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Early exit ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
if (!activeManga) return null;
|
if (!activeManga) return null;
|
||||||
|
|
||||||
const statusLabel = manga?.status
|
const statusLabel = manga?.status
|
||||||
? manga.status.charAt(0) + manga.status.slice(1).toLowerCase()
|
? manga.status.charAt(0) + manga.status.slice(1).toLowerCase()
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
// ── Render ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={s.root} onContextMenu={(e) => e.preventDefault()}>
|
<div className={s.root} onContextMenu={(e) => e.preventDefault()}>
|
||||||
|
|
||||||
{/* ── Sidebar ── */}
|
{/* ── Sidebar ── */}
|
||||||
<div className={s.sidebar}>
|
<div className={s.sidebar}>
|
||||||
<button className={s.back} onClick={() => setActiveManga(null)}>
|
<button className={s.back} onClick={() => setActiveManga(null)}>
|
||||||
<ArrowLeft size={13} weight="light" />
|
<ArrowLeft size={13} weight="light" />
|
||||||
<span>Library</span>
|
<span>Back</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className={s.coverWrap}>
|
<div className={s.coverWrap}>
|
||||||
@@ -352,22 +719,54 @@ export default function SeriesDetail() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{statusLabel && (
|
{statusLabel && (
|
||||||
<span className={[s.statusBadge, manga?.status === "ONGOING" ? s.statusOngoing : s.statusEnded].join(" ").trim()}>
|
<span className={[
|
||||||
|
s.statusBadge,
|
||||||
|
manga?.status === "ONGOING" ? s.statusOngoing : s.statusEnded,
|
||||||
|
].join(" ").trim()}>
|
||||||
{statusLabel}
|
{statusLabel}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{manga?.genre && manga.genre.length > 0 && (
|
{manga?.genre && manga.genre.length > 0 && (
|
||||||
<div className={s.genres}>
|
<div className={s.genres}>
|
||||||
{manga.genre.map((g) => <span key={g} className={s.genre}>{g}</span>)}
|
{(genresExpanded ? manga.genre : manga.genre.slice(0, 5)).map((g) => (
|
||||||
|
<button
|
||||||
|
key={g}
|
||||||
|
className={[s.genre, s.genreClickable].join(" ")}
|
||||||
|
title={`Filter library by "${g}"`}
|
||||||
|
onClick={() => {
|
||||||
|
setGenreFilter(g);
|
||||||
|
setNavPage("explore");
|
||||||
|
setActiveManga(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{g}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{manga.genre.length > 5 && (
|
||||||
|
<button className={s.genreToggle} onClick={() => setGenresExpanded((p) => !p)}>
|
||||||
|
{genresExpanded ? "less" : `+${manga.genre.length - 5}`}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{manga?.description && <p className={s.description}>{manga.description}</p>}
|
{manga?.description && (
|
||||||
|
<div className={s.descriptionWrap}>
|
||||||
|
<p className={[s.description, descExpanded ? s.descriptionExpanded : ""].join(" ")}>
|
||||||
|
{manga.description}
|
||||||
|
</p>
|
||||||
|
{manga.description.length > 120 && (
|
||||||
|
<button className={s.descToggle} onClick={() => setDescExpanded((p) => !p)}>
|
||||||
|
{descExpanded ? "Less" : "More"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Progress bar */}
|
{/* Progress */}
|
||||||
{totalCount > 0 && (
|
{totalCount > 0 && (
|
||||||
<div className={s.progressSection}>
|
<div className={s.progressSection}>
|
||||||
<div className={s.progressHeader}>
|
<div className={s.progressHeader}>
|
||||||
@@ -397,8 +796,6 @@ export default function SeriesDetail() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Folder picker moved to chapter list header */}
|
|
||||||
|
|
||||||
{continueChapter && (
|
{continueChapter && (
|
||||||
<button
|
<button
|
||||||
className={s.readBtn}
|
className={s.readBtn}
|
||||||
@@ -420,14 +817,23 @@ export default function SeriesDetail() {
|
|||||||
|
|
||||||
<p className={s.chapterCount}>
|
<p className={s.chapterCount}>
|
||||||
{totalCount} {totalCount === 1 ? "chapter" : "chapters"}
|
{totalCount} {totalCount === 1 ? "chapter" : "chapters"}
|
||||||
|
{readCount > 0 && ` · ${readCount} read`}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* ── Details (collapsible) ── */}
|
{/* Source info — collapsible details */}
|
||||||
{!loadingManga && manga?.source && (
|
{!loadingManga && manga?.source && (
|
||||||
<div className={s.detailsSection}>
|
<div className={s.detailsSection}>
|
||||||
<button className={s.detailsToggle} onClick={() => setDetailsOpen((p) => !p)}>
|
<button className={s.detailsToggle} onClick={() => setDetailsOpen((p) => !p)}>
|
||||||
<span>Details</span>
|
<span>Details</span>
|
||||||
<CaretDown size={11} weight="light" className={detailsOpen ? s.caretOpen : s.caretClosed} />
|
<CaretDown
|
||||||
|
size={11}
|
||||||
|
weight="light"
|
||||||
|
style={{
|
||||||
|
transform: detailsOpen ? "rotate(180deg)" : "rotate(0deg)",
|
||||||
|
transition: "transform 0.15s ease",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</button>
|
</button>
|
||||||
{detailsOpen && (
|
{detailsOpen && (
|
||||||
<div className={s.detailsBody}>
|
<div className={s.detailsBody}>
|
||||||
@@ -435,20 +841,36 @@ export default function SeriesDetail() {
|
|||||||
<span className={s.detailKey}>Source</span>
|
<span className={s.detailKey}>Source</span>
|
||||||
<span className={s.detailVal}>{manga.source.displayName}</span>
|
<span className={s.detailVal}>{manga.source.displayName}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className={s.detailRow}>
|
{manga.status && (
|
||||||
<span className={s.detailKey}>Language</span>
|
<div className={s.detailRow}>
|
||||||
<span className={s.detailVal}>{manga.source.name.match(/\(([^)]+)\)$/)?.[1] ?? "—"}</span>
|
<span className={s.detailKey}>Status</span>
|
||||||
</div>
|
<span className={s.detailVal}>
|
||||||
<div className={s.detailRow}>
|
{manga.status.charAt(0) + manga.status.slice(1).toLowerCase()}
|
||||||
<span className={s.detailKey}>Source ID</span>
|
</span>
|
||||||
<span className={[s.detailVal, s.detailMono].join(" ")}>{manga.source.id}</span>
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
{manga.author && (
|
||||||
|
<div className={s.detailRow}>
|
||||||
|
<span className={s.detailKey}>Author</span>
|
||||||
|
<span className={s.detailVal}>{manga.author}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{manga.artist && manga.artist !== manga.author && (
|
||||||
|
<div className={s.detailRow}>
|
||||||
|
<span className={s.detailKey}>Artist</span>
|
||||||
|
<span className={s.detailVal}>{manga.artist}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{totalCount > 0 && (
|
||||||
|
<div className={s.detailRow}>
|
||||||
|
<span className={s.detailKey}>Progress</span>
|
||||||
|
<span className={s.detailVal}>{readCount} / {totalCount} read</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<button className={s.migrateBtn} onClick={() => setMigrateOpen(true)}>
|
<button className={s.migrateBtn} onClick={() => setMigrateOpen(true)}>
|
||||||
<ArrowsClockwise size={12} weight="light" />
|
<ArrowsClockwise size={12} weight="light" />
|
||||||
Switch source
|
Switch source
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Delete all downloads */}
|
|
||||||
{downloadedCount > 0 && (
|
{downloadedCount > 0 && (
|
||||||
<button
|
<button
|
||||||
className={s.deleteAllBtn}
|
className={s.deleteAllBtn}
|
||||||
@@ -456,13 +878,20 @@ export default function SeriesDetail() {
|
|||||||
disabled={deletingAll}
|
disabled={deletingAll}
|
||||||
>
|
>
|
||||||
<Trash size={12} weight="light" />
|
<Trash size={12} weight="light" />
|
||||||
{deletingAll ? "Deleting…" : `Delete all downloads (${downloadedCount})`}
|
{deletingAll ? "Deleting…" : `Delete downloads (${downloadedCount})`}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{manga && !manga.source && (
|
||||||
|
<button className={s.migrateBtn} onClick={() => setMigrateOpen(true)}>
|
||||||
|
<ArrowsClockwise size={12} weight="light" />
|
||||||
|
Switch source
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Chapter list ── */}
|
{/* ── Chapter list ── */}
|
||||||
@@ -479,8 +908,7 @@ export default function SeriesDetail() {
|
|||||||
>
|
>
|
||||||
{sortDir === "desc"
|
{sortDir === "desc"
|
||||||
? <SortDescending size={14} weight="light" />
|
? <SortDescending size={14} weight="light" />
|
||||||
: <SortAscending size={14} weight="light" />
|
: <SortAscending size={14} weight="light" />}
|
||||||
}
|
|
||||||
<span>{sortDir === "desc" ? "Newest first" : "Oldest first"}</span>
|
<span>{sortDir === "desc" ? "Newest first" : "Oldest first"}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -497,7 +925,14 @@ export default function SeriesDetail() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={s.listHeaderRight}>
|
<div className={s.listHeaderRight}>
|
||||||
{/* Folder picker */}
|
<button
|
||||||
|
className={s.viewToggleBtn}
|
||||||
|
onClick={refreshChapters}
|
||||||
|
disabled={refreshing}
|
||||||
|
title="Refresh chapters from source"
|
||||||
|
>
|
||||||
|
<ArrowsClockwise size={14} weight="light" className={refreshing ? "anim-spin" : ""} />
|
||||||
|
</button>
|
||||||
{activeManga && <FolderPicker mangaId={activeManga.id} />}
|
{activeManga && <FolderPicker mangaId={activeManga.id} />}
|
||||||
|
|
||||||
{/* Jump to chapter */}
|
{/* Jump to chapter */}
|
||||||
@@ -544,50 +979,15 @@ export default function SeriesDetail() {
|
|||||||
<Download size={13} weight="light" />
|
<Download size={13} weight="light" />
|
||||||
</button>
|
</button>
|
||||||
{dlOpen && (
|
{dlOpen && (
|
||||||
<div className={s.dlDropdown}>
|
<DownloadDropdown
|
||||||
{continueChapter && (
|
sortedChapters={sortedChapters}
|
||||||
<button className={s.dlItem}
|
continueChapter={continueChapter}
|
||||||
onClick={() => {
|
downloadedCount={downloadedCount}
|
||||||
const from = sortedChapters.indexOf(continueChapter.chapter);
|
deletingAll={deletingAll}
|
||||||
const ids = sortedChapters.slice(from).filter((c) => !c.isDownloaded).map((c) => c.id);
|
onEnqueue={(ids) => { enqueueMultiple(ids); setDlOpen(false); }}
|
||||||
enqueueMultiple(ids);
|
onDelete={() => { deleteAllDownloads(); setDlOpen(false); }}
|
||||||
setDlOpen(false);
|
onClose={() => 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>
|
|
||||||
{downloadedCount > 0 && (
|
|
||||||
<>
|
|
||||||
<div style={{ height: 1, background: "var(--border-dim)", margin: "var(--sp-1) var(--sp-2)" }} />
|
|
||||||
<button className={[s.dlItem, s.dlItemDanger].join(" ")}
|
|
||||||
onClick={() => { deleteAllDownloads(); setDlOpen(false); }}
|
|
||||||
disabled={deletingAll}
|
|
||||||
>
|
|
||||||
<span>{deletingAll ? "Deleting…" : "Delete all downloads"}</span>
|
|
||||||
<span className={s.dlItemSub}>{downloadedCount} downloaded</span>
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -627,8 +1027,7 @@ export default function SeriesDetail() {
|
|||||||
))
|
))
|
||||||
)
|
)
|
||||||
) : viewMode === "grid" ? (
|
) : viewMode === "grid" ? (
|
||||||
sortedChapters.map((ch) => {
|
sortedChapters.map((ch, idxInSorted) => {
|
||||||
const idxInSorted = sortedChapters.indexOf(ch);
|
|
||||||
const inProgress = !ch.isRead && (ch.lastPageRead ?? 0) > 0;
|
const inProgress = !ch.isRead && (ch.lastPageRead ?? 0) > 0;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -663,10 +1062,13 @@ export default function SeriesDetail() {
|
|||||||
pageChapters.map((ch) => {
|
pageChapters.map((ch) => {
|
||||||
const idxInSorted = sortedChapters.indexOf(ch);
|
const idxInSorted = sortedChapters.indexOf(ch);
|
||||||
return (
|
return (
|
||||||
<button
|
<div
|
||||||
key={ch.id}
|
key={ch.id}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
className={[s.row, ch.isRead ? s.rowRead : ""].join(" ").trim()}
|
className={[s.row, ch.isRead ? s.rowRead : ""].join(" ").trim()}
|
||||||
onClick={() => openReader(ch, sortedChapters)}
|
onClick={() => openReader(ch, sortedChapters)}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && openReader(ch, sortedChapters)}
|
||||||
onContextMenu={(e) => openContextMenu(e, ch, idxInSorted)}
|
onContextMenu={(e) => openContextMenu(e, ch, idxInSorted)}
|
||||||
>
|
>
|
||||||
<div className={s.chLeft}>
|
<div className={s.chLeft}>
|
||||||
@@ -684,19 +1086,30 @@ export default function SeriesDetail() {
|
|||||||
{ch.isBookmarked && (
|
{ch.isBookmarked && (
|
||||||
<BookmarkSimple size={12} weight="fill" className={s.bookmarkIcon} />
|
<BookmarkSimple size={12} weight="fill" className={s.bookmarkIcon} />
|
||||||
)}
|
)}
|
||||||
{ch.isRead ? (
|
{ch.isRead && (
|
||||||
<CheckCircle size={14} weight="light" className={s.readIcon} />
|
<CheckCircle size={14} weight="light" className={s.readIcon} />
|
||||||
) : ch.isDownloaded ? (
|
)}
|
||||||
<BookOpen size={14} weight="light" className={s.downloadedIcon} />
|
{ch.isDownloaded ? (
|
||||||
|
<button
|
||||||
|
className={s.dlBtn}
|
||||||
|
onClick={(e) => { e.stopPropagation(); deleteDownloaded(ch.id); }}
|
||||||
|
title="Delete download"
|
||||||
|
>
|
||||||
|
<Trash size={13} weight="light" />
|
||||||
|
</button>
|
||||||
) : enqueueing.has(ch.id) ? (
|
) : enqueueing.has(ch.id) ? (
|
||||||
<CircleNotch size={14} weight="light" className={[s.enqueuingIcon, "anim-spin"].join(" ")} />
|
<CircleNotch size={14} weight="light" className={[s.enqueuingIcon, "anim-spin"].join(" ")} />
|
||||||
) : (
|
) : (
|
||||||
<button className={s.dlBtn} onClick={(e) => enqueue(ch, e)} title="Download">
|
<button
|
||||||
|
className={s.dlBtn}
|
||||||
|
onClick={(e) => enqueue(ch, e)}
|
||||||
|
title="Download"
|
||||||
|
>
|
||||||
<Download size={13} weight="light" />
|
<Download size={13} weight="light" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</div>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -459,3 +459,103 @@
|
|||||||
background: var(--accent-muted);
|
background: var(--accent-muted);
|
||||||
color: var(--accent-fg);
|
color: var(--accent-fg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ─── Theme picker ── */
|
||||||
|
.themeGrid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: var(--sp-2);
|
||||||
|
padding: var(--sp-2) var(--sp-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.themeCard {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
padding: var(--sp-2);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
border: 1px solid var(--border-dim);
|
||||||
|
background: var(--bg-raised);
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
transition: border-color var(--t-base), background var(--t-base);
|
||||||
|
}
|
||||||
|
.themeCard:hover { border-color: var(--border-strong); background: var(--bg-overlay); }
|
||||||
|
.themeCardActive {
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: var(--accent-muted);
|
||||||
|
}
|
||||||
|
.themeCardActive:hover { border-color: var(--accent); }
|
||||||
|
|
||||||
|
.themePreview {
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid rgba(0,0,0,0.15);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.themePreviewBg {
|
||||||
|
width: 100%; height: 100%;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.themePreviewSidebar {
|
||||||
|
width: 22%;
|
||||||
|
height: 100%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.themePreviewContent {
|
||||||
|
flex: 1;
|
||||||
|
padding: 10% 12%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8%;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.themePreviewAccent {
|
||||||
|
height: 14%;
|
||||||
|
border-radius: 2px;
|
||||||
|
width: 55%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.themePreviewText {
|
||||||
|
height: 9%;
|
||||||
|
border-radius: 2px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.themeCardInfo {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.themeCardLabel {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: var(--weight-medium);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: var(--leading-tight);
|
||||||
|
}
|
||||||
|
|
||||||
|
.themeCardDesc {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-2xs);
|
||||||
|
color: var(--text-faint);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
line-height: var(--leading-snug);
|
||||||
|
}
|
||||||
|
|
||||||
|
.themeCardCheck {
|
||||||
|
position: absolute;
|
||||||
|
top: var(--sp-1);
|
||||||
|
right: var(--sp-2);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--accent-fg);
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
}
|
||||||
@@ -1,25 +1,27 @@
|
|||||||
import { useEffect, useRef, useState, useCallback } from "react";
|
import { useEffect, useRef, useState, useCallback } from "react";
|
||||||
import { X, Book, Image, Sliders, Info, Keyboard, Gear, HardDrives, FolderSimple, Plus, Pencil, Trash } from "@phosphor-icons/react";
|
import { X, Book, Image, Sliders, Info, Keyboard, Gear, HardDrives, FolderSimple, Plus, Pencil, Trash, Wrench, PaintBrush } from "@phosphor-icons/react";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import { gql } from "../../lib/client";
|
import { gql } from "../../lib/client";
|
||||||
import { GET_DOWNLOADS_PATH } from "../../lib/queries";
|
import { GET_DOWNLOADS_PATH } from "../../lib/queries";
|
||||||
import { useStore } from "../../store";
|
import { useStore } from "../../store";
|
||||||
import type { Folder } from "../../store";
|
import type { Folder } from "../../store";
|
||||||
import { KEYBIND_LABELS, DEFAULT_KEYBINDS, eventToKeybind, type Keybinds } from "../../lib/keybinds";
|
import { KEYBIND_LABELS, DEFAULT_KEYBINDS, eventToKeybind, type Keybinds } from "../../lib/keybinds";
|
||||||
import type { Settings, FitMode } from "../../store";
|
import type { Settings, FitMode, Theme } from "../../store";
|
||||||
import s from "./Settings.module.css";
|
import s from "./Settings.module.css";
|
||||||
|
|
||||||
type Tab = "general" | "reader" | "library" | "performance" | "keybinds" | "storage" | "folders" | "about";
|
type Tab = "general" | "appearance" | "reader" | "library" | "performance" | "keybinds" | "storage" | "folders" | "about" | "devtools";
|
||||||
|
|
||||||
const TABS: { id: Tab; label: string; icon: React.ReactNode }[] = [
|
const TABS: { id: Tab; label: string; icon: React.ReactNode }[] = [
|
||||||
{ id: "general", label: "General", icon: <Gear size={14} weight="light" /> },
|
{ id: "general", label: "General", icon: <Gear size={14} weight="light" /> },
|
||||||
{ id: "reader", label: "Reader", icon: <Book size={14} weight="light" /> },
|
{ id: "appearance", label: "Appearance", icon: <PaintBrush size={14} weight="light" /> },
|
||||||
{ id: "library", label: "Library", icon: <Image size={14} weight="light" /> },
|
{ id: "reader", label: "Reader", icon: <Book size={14} weight="light" /> },
|
||||||
{ id: "performance", label: "Performance", icon: <Sliders size={14} weight="light" /> },
|
{ id: "library", label: "Library", icon: <Image size={14} weight="light" /> },
|
||||||
{ id: "keybinds", label: "Keybinds", icon: <Keyboard size={14} weight="light" /> },
|
{ id: "performance",label: "Performance",icon: <Sliders size={14} weight="light" /> },
|
||||||
{ id: "storage", label: "Storage", icon: <HardDrives size={14} weight="light" /> },
|
{ id: "keybinds", label: "Keybinds", icon: <Keyboard size={14} weight="light" /> },
|
||||||
{ id: "folders", label: "Folders", icon: <FolderSimple size={14} weight="light" /> },
|
{ id: "storage", label: "Storage", icon: <HardDrives size={14} weight="light" /> },
|
||||||
{ id: "about", label: "About", icon: <Info size={14} weight="light" /> },
|
{ id: "folders", label: "Folders", icon: <FolderSimple size={14} weight="light" /> },
|
||||||
|
{ id: "about", label: "About", icon: <Info size={14} weight="light" /> },
|
||||||
|
{ id: "devtools", label: "Dev Tools", icon: <Wrench size={14} weight="light" /> },
|
||||||
];
|
];
|
||||||
|
|
||||||
// ── Primitives ────────────────────────────────────────────────────────────────
|
// ── Primitives ────────────────────────────────────────────────────────────────
|
||||||
@@ -134,6 +136,7 @@ function TextRow({ value, onChange, label, description, placeholder }: {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// ── Tabs ──────────────────────────────────────────────────────────────────────
|
// ── Tabs ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function GeneralTab({ settings, update }: { settings: Settings; update: (p: Partial<Settings>) => void }) {
|
function GeneralTab({ settings, update }: { settings: Settings; update: (p: Partial<Settings>) => void }) {
|
||||||
@@ -174,6 +177,24 @@ function GeneralTab({ settings, update }: { settings: Settings; update: (p: Part
|
|||||||
checked={settings.autoStartServer}
|
checked={settings.autoStartServer}
|
||||||
onChange={(v) => update({ autoStartServer: v })} />
|
onChange={(v) => update({ autoStartServer: v })} />
|
||||||
</div>
|
</div>
|
||||||
|
<div className={s.section}>
|
||||||
|
<p className={s.sectionTitle}>Inactivity</p>
|
||||||
|
<SelectRow
|
||||||
|
label="Idle screen timeout"
|
||||||
|
description="Show the Moku idle splash after this much inactivity. Set to Never to disable."
|
||||||
|
value={String(settings.idleTimeoutMin ?? 5)}
|
||||||
|
options={[
|
||||||
|
{ value: "0", label: "Never" },
|
||||||
|
{ value: "1", label: "1 minute" },
|
||||||
|
{ value: "2", label: "2 minutes" },
|
||||||
|
{ value: "5", label: "5 minutes" },
|
||||||
|
{ value: "10", label: "10 minutes" },
|
||||||
|
{ value: "15", label: "15 minutes" },
|
||||||
|
{ value: "30", label: "30 minutes" },
|
||||||
|
]}
|
||||||
|
onChange={(v) => update({ idleTimeoutMin: Number(v) })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -340,6 +361,13 @@ function PerformanceTab({ settings, update }: { settings: Settings; update: (p:
|
|||||||
checked={settings.gpuAcceleration}
|
checked={settings.gpuAcceleration}
|
||||||
onChange={(v) => update({ gpuAcceleration: v })} />
|
onChange={(v) => update({ gpuAcceleration: v })} />
|
||||||
</div>
|
</div>
|
||||||
|
<div className={s.section}>
|
||||||
|
<p className={s.sectionTitle}>Idle / Splash Screen</p>
|
||||||
|
<Toggle label="Animated card background"
|
||||||
|
description="Show floating manga cards on the splash and idle screens. Disable if the animation feels slow on your machine."
|
||||||
|
checked={settings.splashCards ?? true}
|
||||||
|
onChange={(v) => update({ splashCards: v })} />
|
||||||
|
</div>
|
||||||
<div className={s.section}>
|
<div className={s.section}>
|
||||||
<p className={s.sectionTitle}>Interface</p>
|
<p className={s.sectionTitle}>Interface</p>
|
||||||
<Toggle label="Compact sidebar"
|
<Toggle label="Compact sidebar"
|
||||||
@@ -347,6 +375,18 @@ function PerformanceTab({ settings, update }: { settings: Settings; update: (p:
|
|||||||
checked={settings.compactSidebar}
|
checked={settings.compactSidebar}
|
||||||
onChange={(v) => update({ compactSidebar: v })} />
|
onChange={(v) => update({ compactSidebar: v })} />
|
||||||
</div>
|
</div>
|
||||||
|
<div className={s.section}>
|
||||||
|
<p className={s.sectionTitle}>Reader</p>
|
||||||
|
<Stepper
|
||||||
|
label="Input debounce"
|
||||||
|
description="Delay (ms) before page-turn input is processed. Increase if the reader feels laggy or skips pages. Set to 0 to disable."
|
||||||
|
value={settings.readerDebounceMs ?? 120}
|
||||||
|
min={0}
|
||||||
|
max={500}
|
||||||
|
step={20}
|
||||||
|
onChange={(v) => update({ readerDebounceMs: v })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -690,6 +730,133 @@ function FoldersTab() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Appearance tab ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const THEMES: { id: Theme; label: string; description: string; swatches: string[] }[] = [
|
||||||
|
{
|
||||||
|
id: "dark",
|
||||||
|
label: "Dark",
|
||||||
|
description: "Default near-black",
|
||||||
|
swatches: ["#101010", "#151515", "#a8c4a8", "#f0efec"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "high-contrast",
|
||||||
|
label: "High Contrast",
|
||||||
|
description: "Darker base, sharper text",
|
||||||
|
swatches: ["#080808", "#111111", "#bcd8bc", "#ffffff"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "light",
|
||||||
|
label: "Light",
|
||||||
|
description: "Warm off-white",
|
||||||
|
swatches: ["#f4f2ee", "#faf8f4", "#2a5a2a", "#1a1916"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "light-contrast",
|
||||||
|
label: "Light Contrast",
|
||||||
|
description: "Light with maximum text contrast",
|
||||||
|
swatches: ["#ece8e2", "#f5f2ec", "#183818", "#080806"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "midnight",
|
||||||
|
label: "Midnight",
|
||||||
|
description: "Deep blue-black tint",
|
||||||
|
swatches: ["#0c1020", "#101428", "#a8b4e8", "#eeeef8"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "warm",
|
||||||
|
label: "Warm",
|
||||||
|
description: "Amber and sepia tones",
|
||||||
|
swatches: ["#16130c", "#1c1810", "#e0b860", "#f5f0e0"],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function AppearanceTab({ settings, update }: { settings: Settings; update: (p: Partial<Settings>) => void }) {
|
||||||
|
const current = settings.theme ?? "dark";
|
||||||
|
return (
|
||||||
|
<div className={s.panel}>
|
||||||
|
<div className={s.section}>
|
||||||
|
<p className={s.sectionTitle}>Theme</p>
|
||||||
|
<div className={s.themeGrid}>
|
||||||
|
{THEMES.map((theme) => {
|
||||||
|
const active = current === theme.id;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={theme.id}
|
||||||
|
className={[s.themeCard, active ? s.themeCardActive : ""].join(" ")}
|
||||||
|
onClick={() => update({ theme: theme.id })}
|
||||||
|
title={theme.description}
|
||||||
|
>
|
||||||
|
<div className={s.themePreview}>
|
||||||
|
{/* Mini UI preview using the theme swatches */}
|
||||||
|
<div className={s.themePreviewBg} style={{ background: theme.swatches[0] }}>
|
||||||
|
<div className={s.themePreviewSidebar} style={{ background: theme.swatches[1] }} />
|
||||||
|
<div className={s.themePreviewContent}>
|
||||||
|
<div className={s.themePreviewAccent} style={{ background: theme.swatches[2] }} />
|
||||||
|
<div className={s.themePreviewText} style={{ background: theme.swatches[3] + "55" }} />
|
||||||
|
<div className={s.themePreviewText} style={{ background: theme.swatches[3] + "33", width: "60%" }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={s.themeCardInfo}>
|
||||||
|
<span className={s.themeCardLabel}>{theme.label}</span>
|
||||||
|
<span className={s.themeCardDesc}>{theme.description}</span>
|
||||||
|
</div>
|
||||||
|
{active && <span className={s.themeCardCheck}>✓</span>}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DevToolsTab() {
|
||||||
|
const [splashTriggered, setSplashTriggered] = useState(false);
|
||||||
|
|
||||||
|
function triggerSplash() {
|
||||||
|
setSplashTriggered(true);
|
||||||
|
setTimeout(() => setSplashTriggered(false), 200);
|
||||||
|
(window as any).__mokuShowSplash?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={s.panel}>
|
||||||
|
<div className={s.section}>
|
||||||
|
<p className={s.sectionTitle}>Splash Screen</p>
|
||||||
|
<div className={s.stepRow}>
|
||||||
|
<div className={s.toggleInfo}>
|
||||||
|
<span className={s.toggleLabel}>Preview idle screen</span>
|
||||||
|
<span className={s.toggleDesc}>Show the idle splash — dismiss with any click or key</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className={s.dangerBtn}
|
||||||
|
style={{ background: splashTriggered ? "var(--accent-fg)" : undefined,
|
||||||
|
color: splashTriggered ? "var(--bg-base)" : undefined,
|
||||||
|
borderColor: splashTriggered ? "var(--accent-fg)" : undefined,
|
||||||
|
transition: "all 0.15s ease" }}
|
||||||
|
onClick={triggerSplash}
|
||||||
|
>
|
||||||
|
Show idle
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={s.section}>
|
||||||
|
<p className={s.sectionTitle}>Build Info</p>
|
||||||
|
<div className={s.aboutBlock}>
|
||||||
|
<p className={s.aboutLine} style={{ fontFamily: "var(--font-mono, monospace)", fontSize: 11, color: "var(--text-faint)" }}>
|
||||||
|
Mode: {import.meta.env.MODE}
|
||||||
|
</p>
|
||||||
|
<p className={s.aboutLine} style={{ fontFamily: "var(--font-mono, monospace)", fontSize: 11, color: "var(--text-faint)", marginTop: "var(--sp-1)" }}>
|
||||||
|
Dev: {String(import.meta.env.DEV)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function AboutTab() {
|
function AboutTab() {
|
||||||
return (
|
return (
|
||||||
<div className={s.panel}>
|
<div className={s.panel}>
|
||||||
@@ -717,6 +884,8 @@ export default function SettingsModal() {
|
|||||||
const backdropRef = useRef<HTMLDivElement>(null);
|
const backdropRef = useRef<HTMLDivElement>(null);
|
||||||
const contentBodyRef = useRef<HTMLDivElement>(null);
|
const contentBodyRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
contentBodyRef.current?.scrollTo({ top: 0 });
|
contentBodyRef.current?.scrollTo({ top: 0 });
|
||||||
}, [tab]);
|
}, [tab]);
|
||||||
@@ -755,6 +924,7 @@ export default function SettingsModal() {
|
|||||||
</div>
|
</div>
|
||||||
<div className={s.contentBody} ref={contentBodyRef}>
|
<div className={s.contentBody} ref={contentBodyRef}>
|
||||||
{tab === "general" && <GeneralTab settings={settings} update={updateSettings} />}
|
{tab === "general" && <GeneralTab settings={settings} update={updateSettings} />}
|
||||||
|
{tab === "appearance" && <AppearanceTab settings={settings} update={updateSettings} />}
|
||||||
{tab === "reader" && <ReaderTab settings={settings} update={updateSettings} />}
|
{tab === "reader" && <ReaderTab settings={settings} update={updateSettings} />}
|
||||||
{tab === "library" && <LibraryTab settings={settings} update={updateSettings} />}
|
{tab === "library" && <LibraryTab settings={settings} update={updateSettings} />}
|
||||||
{tab === "performance" && <PerformanceTab settings={settings} update={updateSettings} />}
|
{tab === "performance" && <PerformanceTab settings={settings} update={updateSettings} />}
|
||||||
@@ -762,6 +932,7 @@ export default function SettingsModal() {
|
|||||||
{tab === "storage" && <StorageTab settings={settings} update={updateSettings} />}
|
{tab === "storage" && <StorageTab settings={settings} update={updateSettings} />}
|
||||||
{tab === "folders" && <FoldersTab />}
|
{tab === "folders" && <FoldersTab />}
|
||||||
{tab === "about" && <AboutTab />}
|
{tab === "about" && <AboutTab />}
|
||||||
|
{tab === "devtools" && <DevToolsTab />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,570 +0,0 @@
|
|||||||
import { useEffect, useState, useMemo, memo } from "react";
|
|
||||||
import { ArrowLeft, ArrowRight, Compass, List, BookOpen, Star, Fire } from "@phosphor-icons/react";
|
|
||||||
import { gql, thumbUrl } from "../../lib/client";
|
|
||||||
import { GET_ALL_MANGA, GET_LIBRARY, GET_SOURCES, FETCH_SOURCE_MANGA } from "../../lib/queries";
|
|
||||||
import { useStore } from "../../store";
|
|
||||||
import type { Manga, Source } from "../../lib/types";
|
|
||||||
import SourceList from "./SourceList";
|
|
||||||
import SourceBrowse from "./SourceBrowse";
|
|
||||||
import s from "./Explore.module.css";
|
|
||||||
|
|
||||||
// ── Frecency ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function frecencyScore(readAt: number, count: number): number {
|
|
||||||
const hoursSince = (Date.now() - readAt) / 3_600_000;
|
|
||||||
return count / Math.log(hoursSince + 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Ghost card ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function GhostCard() {
|
|
||||||
return <div className={s.ghostCard} aria-hidden />;
|
|
||||||
}
|
|
||||||
|
|
||||||
const GHOST_COUNT = 3;
|
|
||||||
|
|
||||||
// ── Skeleton row ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function SkeletonRow({ count = 8 }: { count?: number }) {
|
|
||||||
return (
|
|
||||||
<div className={s.skeletonRow}>
|
|
||||||
{Array.from({ length: count }).map((_, i) => (
|
|
||||||
<div key={i} className={s.cardSkeleton}>
|
|
||||||
<div className={["skeleton", s.coverSkeleton].join(" ")} />
|
|
||||||
<div className={["skeleton", s.titleSkeleton].join(" ")} />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Mini card ─────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const MiniCard = memo(function MiniCard({
|
|
||||||
manga,
|
|
||||||
onClick,
|
|
||||||
subtitle,
|
|
||||||
progress,
|
|
||||||
}: {
|
|
||||||
manga: Manga;
|
|
||||||
onClick: () => void;
|
|
||||||
subtitle?: string;
|
|
||||||
progress?: number;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<button className={s.card} onClick={onClick}>
|
|
||||||
<div className={s.coverWrap}>
|
|
||||||
<img
|
|
||||||
src={thumbUrl(manga.thumbnailUrl)}
|
|
||||||
alt={manga.title}
|
|
||||||
className={s.cover}
|
|
||||||
loading="lazy"
|
|
||||||
decoding="async"
|
|
||||||
/>
|
|
||||||
{manga.inLibrary && <span className={s.inLibraryBadge}>Saved</span>}
|
|
||||||
{progress !== undefined && progress > 0 && (
|
|
||||||
<div className={s.progressBar}>
|
|
||||||
<div className={s.progressFill} style={{ width: `${progress * 100}%` }} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<p className={s.title}>{manga.title}</p>
|
|
||||||
{subtitle && <p className={s.subtitle}>{subtitle}</p>}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Genre drill-down ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function GenreDrill({
|
|
||||||
genre,
|
|
||||||
manga,
|
|
||||||
sourceManga,
|
|
||||||
onBack,
|
|
||||||
onOpen,
|
|
||||||
}: {
|
|
||||||
genre: string;
|
|
||||||
manga: Manga[];
|
|
||||||
sourceManga: Manga[];
|
|
||||||
onBack: () => void;
|
|
||||||
onOpen: (m: Manga) => void;
|
|
||||||
}) {
|
|
||||||
const filtered = useMemo(() => {
|
|
||||||
const combined = new Map<number, Manga>();
|
|
||||||
[...manga, ...sourceManga]
|
|
||||||
.filter((m) => (m.genre ?? []).includes(genre))
|
|
||||||
.forEach((m) => combined.set(m.id, m));
|
|
||||||
return Array.from(combined.values());
|
|
||||||
}, [manga, sourceManga, genre]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={s.drillRoot}>
|
|
||||||
<div className={s.drillHeader}>
|
|
||||||
<button className={s.back} onClick={onBack}>
|
|
||||||
<ArrowLeft size={13} weight="light" />
|
|
||||||
<span>Explore</span>
|
|
||||||
</button>
|
|
||||||
<span className={s.drillTitle}>{genre}</span>
|
|
||||||
</div>
|
|
||||||
<div className={s.drillGrid}>
|
|
||||||
{filtered.map((m) => (
|
|
||||||
<button key={m.id} className={s.drillCard} onClick={() => onOpen(m)}>
|
|
||||||
<div className={s.coverWrap}>
|
|
||||||
<img
|
|
||||||
src={thumbUrl(m.thumbnailUrl)}
|
|
||||||
alt={m.title}
|
|
||||||
className={s.cover}
|
|
||||||
loading="lazy"
|
|
||||||
decoding="async"
|
|
||||||
/>
|
|
||||||
{m.inLibrary && <span className={s.inLibraryBadge}>Saved</span>}
|
|
||||||
</div>
|
|
||||||
<p className={s.title}>{m.title}</p>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
{filtered.length === 0 && (
|
|
||||||
<div className={s.empty}>No manga found for {genre}.</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Section ───────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function Section({
|
|
||||||
title,
|
|
||||||
icon,
|
|
||||||
onSeeAll,
|
|
||||||
loading,
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
title: string;
|
|
||||||
icon?: React.ReactNode;
|
|
||||||
onSeeAll?: () => void;
|
|
||||||
loading?: boolean;
|
|
||||||
children: React.ReactNode;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className={s.section}>
|
|
||||||
<div className={s.sectionHeader}>
|
|
||||||
<span className={s.sectionTitle}>
|
|
||||||
<span className={s.sectionTitleIcon}>
|
|
||||||
{icon}
|
|
||||||
{title}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
{onSeeAll && (
|
|
||||||
<button className={s.seeAll} onClick={onSeeAll}>
|
|
||||||
See all <ArrowRight size={11} weight="light" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{loading ? <SkeletonRow /> : children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Main ──────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
type ExploreMode = "explore" | "sources";
|
|
||||||
type DrillState = { type: "genre"; genre: string } | null;
|
|
||||||
|
|
||||||
export default function Explore() {
|
|
||||||
const [mode, setMode] = useState<ExploreMode>("explore");
|
|
||||||
const [drill, setDrill] = useState<DrillState>(null);
|
|
||||||
const activeSource = useStore((s) => s.activeSource);
|
|
||||||
|
|
||||||
if (activeSource) return <SourceBrowse />;
|
|
||||||
|
|
||||||
if (drill?.type === "genre" && mode === "explore") {
|
|
||||||
return <DrillWrapper drill={drill} onBack={() => setDrill(null)} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={s.root}>
|
|
||||||
<div className={s.header}>
|
|
||||||
<div className={s.headerLeft}>
|
|
||||||
<h1 className={s.heading}>Explore</h1>
|
|
||||||
<div className={s.tabs}>
|
|
||||||
<button
|
|
||||||
className={[s.tab, mode === "explore" ? s.tabActive : ""].join(" ").trim()}
|
|
||||||
onClick={() => setMode("explore")}
|
|
||||||
>
|
|
||||||
<Compass size={11} weight="bold" />
|
|
||||||
Explore
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={[s.tab, mode === "sources" ? s.tabActive : ""].join(" ").trim()}
|
|
||||||
onClick={() => setMode("sources")}
|
|
||||||
>
|
|
||||||
<List size={11} weight="bold" />
|
|
||||||
Sources
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{mode === "explore" ? <ExploreFeed onDrill={setDrill} /> : <SourceList />}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Drill wrapper ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function DrillWrapper({ drill, onBack }: { drill: DrillState; onBack: () => void }) {
|
|
||||||
const [allManga, setAllManga] = useState<Manga[]>([]);
|
|
||||||
const [sourceManga, setSourceManga] = useState<Manga[]>([]);
|
|
||||||
const setActiveManga = useStore((s) => s.setActiveManga);
|
|
||||||
const setNavPage = useStore((s) => s.setNavPage);
|
|
||||||
const settings = useStore((s) => s.settings);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
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(console.error);
|
|
||||||
|
|
||||||
const preferredLang = settings.preferredExtensionLang || "en";
|
|
||||||
gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
|
|
||||||
.then((d) => {
|
|
||||||
const all = d.sources.nodes.filter((src) => src.id !== "0");
|
|
||||||
const byName = new Map<string, Source[]>();
|
|
||||||
for (const src of all) {
|
|
||||||
if (!byName.has(src.name)) byName.set(src.name, []);
|
|
||||||
byName.get(src.name)!.push(src);
|
|
||||||
}
|
|
||||||
const picked: Source[] = [];
|
|
||||||
for (const group of byName.values()) {
|
|
||||||
const preferred = group.find((s) => s.lang === preferredLang);
|
|
||||||
picked.push(preferred ?? group.sort((a, b) => a.lang.localeCompare(b.lang))[0]);
|
|
||||||
}
|
|
||||||
return Promise.allSettled(
|
|
||||||
picked.map((src) =>
|
|
||||||
gql<{ fetchSourceManga: { mangas: Manga[] } }>(FETCH_SOURCE_MANGA, {
|
|
||||||
source: src.id, type: "POPULAR", page: 1, query: null,
|
|
||||||
}).then((d) => d.fetchSourceManga.mangas)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.then((results) => {
|
|
||||||
const seen = new Set<number>();
|
|
||||||
const merged: Manga[] = [];
|
|
||||||
for (const r of results) {
|
|
||||||
if (r.status === "fulfilled")
|
|
||||||
for (const m of r.value)
|
|
||||||
if (!seen.has(m.id)) { seen.add(m.id); merged.push(m); }
|
|
||||||
}
|
|
||||||
setSourceManga(merged);
|
|
||||||
})
|
|
||||||
.catch(console.error);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (!drill) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<GenreDrill
|
|
||||||
genre={drill.genre}
|
|
||||||
manga={allManga}
|
|
||||||
sourceManga={sourceManga}
|
|
||||||
onBack={onBack}
|
|
||||||
onOpen={(m) => { setActiveManga(m); setNavPage("library"); }}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Explore feed ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function ExploreFeed({ onDrill }: { onDrill: (d: DrillState) => void }) {
|
|
||||||
const [allManga, setAllManga] = useState<Manga[]>([]);
|
|
||||||
const [loadingLib, setLoadingLib] = useState(true);
|
|
||||||
// Popular row: deduped results from POPULAR fetch across all sources
|
|
||||||
const [popularManga, setPopularManga] = useState<Manga[]>([]);
|
|
||||||
const [loadingPopular, setLoadingPopular] = useState(true);
|
|
||||||
// Genre search results: genre → merged Manga[] from SEARCH per source
|
|
||||||
const [genreResults, setGenreResults] = useState<Map<string, Manga[]>>(new Map());
|
|
||||||
const [loadingGenres, setLoadingGenres] = useState(false);
|
|
||||||
const [sources, setSources] = useState<Source[]>([]);
|
|
||||||
|
|
||||||
const history = useStore((s) => s.history);
|
|
||||||
const settings = useStore((s) => s.settings);
|
|
||||||
const setActiveManga = useStore((s) => s.setActiveManga);
|
|
||||||
const setNavPage = useStore((s) => s.setNavPage);
|
|
||||||
|
|
||||||
// Load library
|
|
||||||
useEffect(() => {
|
|
||||||
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(console.error)
|
|
||||||
.finally(() => setLoadingLib(false));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Load sources → fetch POPULAR from all (for popular row),
|
|
||||||
// then once we know frecency genres, fire SEARCH per genre per source
|
|
||||||
useEffect(() => {
|
|
||||||
const preferredLang = settings.preferredExtensionLang || "en";
|
|
||||||
|
|
||||||
gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
|
|
||||||
.then((d) => {
|
|
||||||
const all = d.sources.nodes.filter((src) => src.id !== "0");
|
|
||||||
|
|
||||||
// Dedupe by name, pick preferred lang
|
|
||||||
const byName = new Map<string, Source[]>();
|
|
||||||
for (const src of all) {
|
|
||||||
if (!byName.has(src.name)) byName.set(src.name, []);
|
|
||||||
byName.get(src.name)!.push(src);
|
|
||||||
}
|
|
||||||
const picked: Source[] = [];
|
|
||||||
for (const group of byName.values()) {
|
|
||||||
const preferred = group.find((s) => s.lang === preferredLang);
|
|
||||||
picked.push(preferred ?? group.sort((a, b) => a.lang.localeCompare(b.lang))[0]);
|
|
||||||
}
|
|
||||||
|
|
||||||
setSources(picked);
|
|
||||||
if (picked.length === 0) { setLoadingPopular(false); return; }
|
|
||||||
|
|
||||||
// Fetch POPULAR from all sources for the popular row
|
|
||||||
return Promise.allSettled(
|
|
||||||
picked.map((src) =>
|
|
||||||
gql<{ fetchSourceManga: { mangas: Manga[] } }>(FETCH_SOURCE_MANGA, {
|
|
||||||
source: src.id, type: "POPULAR", page: 1, query: null,
|
|
||||||
}).then((d) => d.fetchSourceManga.mangas)
|
|
||||||
)
|
|
||||||
).then((results) => {
|
|
||||||
const seen = new Set<number>();
|
|
||||||
const merged: Manga[] = [];
|
|
||||||
for (const r of results)
|
|
||||||
if (r.status === "fulfilled")
|
|
||||||
for (const m of r.value)
|
|
||||||
if (!seen.has(m.id)) { seen.add(m.id); merged.push(m); }
|
|
||||||
setPopularManga(merged.slice(0, 30));
|
|
||||||
// Return picked sources for genre search phase
|
|
||||||
return picked;
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.catch(console.error)
|
|
||||||
.finally(() => setLoadingPopular(false));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Once library loaded AND sources ready, search each frecency genre across sources
|
|
||||||
const frecencyGenres = useMemo(() => {
|
|
||||||
const mangaScores = new Map<number, number>();
|
|
||||||
const mangaReadAt = new Map<number, number>();
|
|
||||||
for (const entry of history) {
|
|
||||||
mangaScores.set(entry.mangaId, (mangaScores.get(entry.mangaId) ?? 0) + 1);
|
|
||||||
if (entry.readAt > (mangaReadAt.get(entry.mangaId) ?? 0))
|
|
||||||
mangaReadAt.set(entry.mangaId, entry.readAt);
|
|
||||||
}
|
|
||||||
const genreWeights = new Map<string, number>();
|
|
||||||
const mangaMap = new Map(allManga.map((m) => [m.id, m]));
|
|
||||||
for (const [mangaId, count] of mangaScores.entries()) {
|
|
||||||
const score = frecencyScore(mangaReadAt.get(mangaId) ?? 0, count);
|
|
||||||
for (const genre of mangaMap.get(mangaId)?.genre ?? [])
|
|
||||||
genreWeights.set(genre, (genreWeights.get(genre) ?? 0) + score);
|
|
||||||
}
|
|
||||||
if (genreWeights.size === 0) {
|
|
||||||
allManga.filter((m) => m.inLibrary).forEach((m) =>
|
|
||||||
(m.genre ?? []).forEach((g) =>
|
|
||||||
genreWeights.set(g, (genreWeights.get(g) ?? 0) + 1)));
|
|
||||||
}
|
|
||||||
return Array.from(genreWeights.entries())
|
|
||||||
.sort((a, b) => b[1] - a[1])
|
|
||||||
.slice(0, 3) // top 3 genres only
|
|
||||||
.map(([g]) => g);
|
|
||||||
}, [allManga, history]);
|
|
||||||
|
|
||||||
// Fire genre searches once we have both genres and sources
|
|
||||||
useEffect(() => {
|
|
||||||
if (frecencyGenres.length === 0 || sources.length === 0) return;
|
|
||||||
setLoadingGenres(true);
|
|
||||||
|
|
||||||
// For each genre, search all sources concurrently, then merge results
|
|
||||||
// Cap to top 3 sources to limit requests (3 genres × 3 sources = 9 searches max)
|
|
||||||
const searchSources = sources.slice(0, 3);
|
|
||||||
|
|
||||||
Promise.allSettled(
|
|
||||||
frecencyGenres.map((genre) =>
|
|
||||||
Promise.allSettled(
|
|
||||||
searchSources.map((src) =>
|
|
||||||
gql<{ fetchSourceManga: { mangas: Manga[] } }>(FETCH_SOURCE_MANGA, {
|
|
||||||
source: src.id, type: "SEARCH", page: 1, query: genre,
|
|
||||||
}).then((d) => d.fetchSourceManga.mangas)
|
|
||||||
)
|
|
||||||
).then((results) => {
|
|
||||||
const seen = new Set<number>();
|
|
||||||
const merged: Manga[] = [];
|
|
||||||
for (const r of results)
|
|
||||||
if (r.status === "fulfilled")
|
|
||||||
for (const m of r.value)
|
|
||||||
if (!seen.has(m.id)) { seen.add(m.id); merged.push(m); }
|
|
||||||
return { genre, mangas: merged.slice(0, 24) };
|
|
||||||
})
|
|
||||||
)
|
|
||||||
).then((results) => {
|
|
||||||
const map = new Map<string, Manga[]>();
|
|
||||||
for (const r of results)
|
|
||||||
if (r.status === "fulfilled")
|
|
||||||
map.set(r.value.genre, r.value.mangas);
|
|
||||||
setGenreResults(map);
|
|
||||||
})
|
|
||||||
.catch(console.error)
|
|
||||||
.finally(() => setLoadingGenres(false));
|
|
||||||
}, [frecencyGenres.join(","), sources.map((s) => s.id).join(",")]);
|
|
||||||
|
|
||||||
function openManga(m: Manga) {
|
|
||||||
setActiveManga(m);
|
|
||||||
setNavPage("library");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Continue reading ────────────────────────────────────────────────────
|
|
||||||
const continueReading = useMemo(() => {
|
|
||||||
const mangaMap = new Map(allManga.map((m) => [m.id, m]));
|
|
||||||
const seen = new Set<number>();
|
|
||||||
const result: { manga: Manga; chapterName: string; progress: number }[] = [];
|
|
||||||
for (const entry of history) {
|
|
||||||
if (seen.has(entry.mangaId)) continue;
|
|
||||||
seen.add(entry.mangaId);
|
|
||||||
const manga = mangaMap.get(entry.mangaId);
|
|
||||||
if (!manga) continue;
|
|
||||||
result.push({
|
|
||||||
manga,
|
|
||||||
chapterName: entry.chapterName,
|
|
||||||
progress: entry.pageNumber > 0 ? Math.min(entry.pageNumber / 20, 1) : 0,
|
|
||||||
});
|
|
||||||
if (result.length >= 12) break;
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}, [history, allManga]);
|
|
||||||
|
|
||||||
// ── Recommended (frecency) ──────────────────────────────────────────────
|
|
||||||
const recommended = useMemo(() => {
|
|
||||||
if (allManga.length === 0 || frecencyGenres.length === 0) return [];
|
|
||||||
const continueIds = new Set(continueReading.map((r) => r.manga.id));
|
|
||||||
return allManga
|
|
||||||
.filter((m) => m.inLibrary && !continueIds.has(m.id) &&
|
|
||||||
frecencyGenres.some((g) => (m.genre ?? []).includes(g)))
|
|
||||||
.slice(0, 20);
|
|
||||||
}, [allManga, frecencyGenres, continueReading]);
|
|
||||||
|
|
||||||
const genresLoading = loadingLib || loadingGenres;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={s.body}>
|
|
||||||
|
|
||||||
{/* Continue Reading */}
|
|
||||||
{(continueReading.length > 0 || loadingLib) && (
|
|
||||||
<Section
|
|
||||||
title="Continue Reading"
|
|
||||||
icon={<BookOpen size={11} weight="bold" />}
|
|
||||||
loading={loadingLib}
|
|
||||||
>
|
|
||||||
<div className={s.row}>
|
|
||||||
{continueReading.map(({ manga, chapterName, progress }) => (
|
|
||||||
<MiniCard
|
|
||||||
key={manga.id}
|
|
||||||
manga={manga}
|
|
||||||
onClick={() => openManga(manga)}
|
|
||||||
subtitle={chapterName}
|
|
||||||
progress={progress}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
{Array.from({ length: GHOST_COUNT }).map((_, i) => (
|
|
||||||
<GhostCard key={`ghost-cr-${i}`} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Recommended */}
|
|
||||||
{(recommended.length > 0 || loadingLib) && (
|
|
||||||
<Section
|
|
||||||
title="Recommended for You"
|
|
||||||
icon={<Star size={11} weight="bold" />}
|
|
||||||
loading={loadingLib}
|
|
||||||
>
|
|
||||||
<div className={s.row}>
|
|
||||||
{recommended.map((m) => (
|
|
||||||
<MiniCard key={m.id} manga={m} onClick={() => openManga(m)} />
|
|
||||||
))}
|
|
||||||
{Array.from({ length: GHOST_COUNT }).map((_, i) => (
|
|
||||||
<GhostCard key={`ghost-rec-${i}`} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Popular across deduplicated sources */}
|
|
||||||
{(popularManga.length > 0 || loadingPopular) && (
|
|
||||||
<Section
|
|
||||||
title={
|
|
||||||
sources.length === 1
|
|
||||||
? `Popular on ${sources[0].displayName}`
|
|
||||||
: sources.length > 1
|
|
||||||
? `Popular across ${sources.length} sources`
|
|
||||||
: "Popular"
|
|
||||||
}
|
|
||||||
icon={<Fire size={11} weight="bold" />}
|
|
||||||
loading={loadingPopular}
|
|
||||||
>
|
|
||||||
{sources.length === 0 ? (
|
|
||||||
<div className={s.noSource}>No sources installed. Add extensions first.</div>
|
|
||||||
) : (
|
|
||||||
<div className={s.row}>
|
|
||||||
{popularManga.map((m) => (
|
|
||||||
<MiniCard key={m.id} manga={m} onClick={() => openManga(m)} />
|
|
||||||
))}
|
|
||||||
{Array.from({ length: GHOST_COUNT }).map((_, i) => (
|
|
||||||
<GhostCard key={`ghost-pop-${i}`} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Genre rows — searched from sources by genre name */}
|
|
||||||
{frecencyGenres.map((genre) => {
|
|
||||||
const items = genreResults.get(genre) ?? [];
|
|
||||||
const isLoading = genresLoading && items.length === 0;
|
|
||||||
if (!isLoading && items.length === 0) return null;
|
|
||||||
return (
|
|
||||||
<Section
|
|
||||||
key={genre}
|
|
||||||
title={genre}
|
|
||||||
onSeeAll={() => onDrill({ type: "genre", genre })}
|
|
||||||
loading={isLoading}
|
|
||||||
>
|
|
||||||
<div className={s.row}>
|
|
||||||
{items.map((m) => (
|
|
||||||
<MiniCard key={m.id} manga={m} onClick={() => openManga(m)} />
|
|
||||||
))}
|
|
||||||
{Array.from({ length: GHOST_COUNT }).map((_, i) => (
|
|
||||||
<GhostCard key={`ghost-${genre}-${i}`} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Section>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
{/* Empty state */}
|
|
||||||
{!loadingLib && !loadingPopular && !loadingGenres &&
|
|
||||||
continueReading.length === 0 && recommended.length === 0 &&
|
|
||||||
popularManga.length === 0 && frecencyGenres.every((g) => !genreResults.get(g)?.length) && (
|
|
||||||
<div className={s.empty}>
|
|
||||||
<span>Nothing to explore yet</span>
|
|
||||||
<span className={s.emptyHint}>
|
|
||||||
Add manga to your library or install sources to get started.
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useEffect, useState, useRef } from "react";
|
import { useEffect, useState, useRef } from "react";
|
||||||
import { ArrowLeft, MagnifyingGlass, ArrowLeft as Prev, ArrowRight as Next } from "@phosphor-icons/react";
|
import { ArrowLeft, MagnifyingGlass, ArrowLeft as Prev, ArrowRight as Next, BookmarkSimple, FolderSimplePlus, Folder } from "@phosphor-icons/react";
|
||||||
import { gql, thumbUrl } from "../../lib/client";
|
import { gql, thumbUrl } from "../../lib/client";
|
||||||
import { FETCH_SOURCE_MANGA } from "../../lib/queries";
|
import { FETCH_SOURCE_MANGA, UPDATE_MANGA } from "../../lib/queries";
|
||||||
|
import ContextMenu, { type ContextMenuEntry } from "../context/ContextMenu";
|
||||||
import { useStore } from "../../store";
|
import { useStore } from "../../store";
|
||||||
import type { Manga } from "../../lib/types";
|
import type { Manga } from "../../lib/types";
|
||||||
import s from "./SourceBrowse.module.css";
|
import s from "./SourceBrowse.module.css";
|
||||||
@@ -11,8 +12,12 @@ type BrowseType = "POPULAR" | "LATEST" | "SEARCH";
|
|||||||
export default function SourceBrowse() {
|
export default function SourceBrowse() {
|
||||||
const activeSource = useStore((state) => state.activeSource);
|
const activeSource = useStore((state) => state.activeSource);
|
||||||
const setActiveSource = useStore((state) => state.setActiveSource);
|
const setActiveSource = useStore((state) => state.setActiveSource);
|
||||||
const setActiveManga = useStore((state) => state.setActiveManga);
|
const setActiveManga = useStore((state) => state.setActiveManga);
|
||||||
const setNavPage = useStore((state) => state.setNavPage);
|
const setNavPage = useStore((state) => state.setNavPage);
|
||||||
|
const folders = useStore((state) => state.settings.folders);
|
||||||
|
const addFolder = useStore((state) => state.addFolder);
|
||||||
|
const assignMangaToFolder = useStore((state) => state.assignMangaToFolder);
|
||||||
|
const [ctx, setCtx] = useState<{ x: number; y: number; manga: Manga } | null>(null);
|
||||||
|
|
||||||
const [mangas, setMangas] = useState<Manga[]>([]);
|
const [mangas, setMangas] = useState<Manga[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -63,6 +68,45 @@ export default function SourceBrowse() {
|
|||||||
setNavPage("library");
|
setNavPage("library");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openCtx(e: React.MouseEvent, m: Manga) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setCtx({ x: e.clientX, y: e.clientY, manga: m });
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCtxItems(m: Manga): ContextMenuEntry[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: m.inLibrary ? "In Library" : "Add to library",
|
||||||
|
icon: <BookmarkSimple size={13} weight={m.inLibrary ? "fill" : "light"} />,
|
||||||
|
disabled: m.inLibrary,
|
||||||
|
onClick: () => gql(UPDATE_MANGA, { id: m.id, inLibrary: true })
|
||||||
|
.then(() => setMangas((prev) => prev.map((x) => x.id === m.id ? { ...x, inLibrary: true } : x)))
|
||||||
|
.catch(console.error),
|
||||||
|
},
|
||||||
|
...(folders.length > 0 ? [
|
||||||
|
{ separator: true } as ContextMenuEntry,
|
||||||
|
...folders.map((f): ContextMenuEntry => ({
|
||||||
|
label: f.mangaIds.includes(m.id) ? `✓ ${f.name}` : f.name,
|
||||||
|
icon: <Folder size={13} weight={f.mangaIds.includes(m.id) ? "fill" : "light"} />,
|
||||||
|
onClick: () => assignMangaToFolder(f.id, m.id),
|
||||||
|
})),
|
||||||
|
] : []),
|
||||||
|
{ separator: true },
|
||||||
|
{
|
||||||
|
label: "New folder & add",
|
||||||
|
icon: <FolderSimplePlus size={13} weight="light" />,
|
||||||
|
onClick: () => {
|
||||||
|
const name = prompt("Folder name:");
|
||||||
|
if (name?.trim()) {
|
||||||
|
const id = addFolder(name.trim());
|
||||||
|
assignMangaToFolder(id, m.id);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
if (!activeSource) return null;
|
if (!activeSource) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -120,7 +164,7 @@ export default function SourceBrowse() {
|
|||||||
) : (
|
) : (
|
||||||
<div className={s.grid}>
|
<div className={s.grid}>
|
||||||
{mangas.map((m) => (
|
{mangas.map((m) => (
|
||||||
<button key={m.id} className={s.card} onClick={() => openManga(m)}>
|
<button key={m.id} className={s.card} onClick={() => openManga(m)} onContextMenu={(e) => openCtx(e, m)}>
|
||||||
<div className={s.coverWrap}>
|
<div className={s.coverWrap}>
|
||||||
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} className={s.cover} />
|
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} className={s.cover} />
|
||||||
{m.inLibrary && <span className={s.inLibraryBadge}>In Library</span>}
|
{m.inLibrary && <span className={s.inLibraryBadge}>In Library</span>}
|
||||||
@@ -152,6 +196,14 @@ export default function SourceBrowse() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{ctx && (
|
||||||
|
<ContextMenu
|
||||||
|
x={ctx.x}
|
||||||
|
y={ctx.y}
|
||||||
|
items={buildCtxItems(ctx.manga)}
|
||||||
|
onClose={() => setCtx(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
/**
|
||||||
|
* Session-level request cache.
|
||||||
|
*
|
||||||
|
* Key design decisions:
|
||||||
|
* - Stores the Promise itself — concurrent callers await the same fetch (no thundering herd).
|
||||||
|
* - On real errors the entry is evicted so the next call retries.
|
||||||
|
* - AbortErrors do NOT evict — the request was cancelled by the user, not failed.
|
||||||
|
* This is critical: if we evicted on abort, rapid open/close would drain the browser's
|
||||||
|
* connection pool (Chromium allows only 6 concurrent connections to the same origin).
|
||||||
|
* - Subscribers are notified when a key is explicitly cleared (for reactive invalidation).
|
||||||
|
*/
|
||||||
|
const store = new Map<string, Promise<unknown>>();
|
||||||
|
const subs = new Map<string, Set<() => void>>();
|
||||||
|
|
||||||
|
export const cache = {
|
||||||
|
get<T>(key: string, fetcher: () => Promise<T>): Promise<T> {
|
||||||
|
if (!store.has(key)) {
|
||||||
|
store.set(key, fetcher().catch((err) => {
|
||||||
|
// Only evict on real failures, not user cancellations
|
||||||
|
if (err?.name !== "AbortError") store.delete(key);
|
||||||
|
return Promise.reject(err);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
return store.get(key) as Promise<T>;
|
||||||
|
},
|
||||||
|
has(key: string): boolean { return store.has(key); },
|
||||||
|
clear(key: string) {
|
||||||
|
store.delete(key);
|
||||||
|
subs.get(key)?.forEach((cb) => cb());
|
||||||
|
},
|
||||||
|
clearAll() {
|
||||||
|
store.clear();
|
||||||
|
subs.forEach((set) => set.forEach((cb) => cb()));
|
||||||
|
},
|
||||||
|
/** Subscribe to cache invalidation for a key. Returns unsubscribe fn. */
|
||||||
|
subscribe(key: string, cb: () => void): () => void {
|
||||||
|
if (!subs.has(key)) subs.set(key, new Set());
|
||||||
|
subs.get(key)!.add(cb);
|
||||||
|
return () => subs.get(key)?.delete(cb);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Cache key constants — single source of truth, prevents mismatches ─────────
|
||||||
|
export const CACHE_KEYS = {
|
||||||
|
LIBRARY: "library",
|
||||||
|
SOURCES: "sources",
|
||||||
|
POPULAR: "popular",
|
||||||
|
GENRE: (genre: string) => `genre:${genre}`,
|
||||||
|
MANGA: (id: number) => `manga:${id}`,
|
||||||
|
CHAPTERS: (id: number) => `chapters:${id}`,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// ── In-flight request deduplication (for non-cached calls) ────────────────────
|
||||||
|
//
|
||||||
|
// Some requests (chapter lists, manga detail) are NOT stored in the long-lived
|
||||||
|
// cache but still get fired multiple times when a user rapidly opens/closes a
|
||||||
|
// manga. This map deduplicates them so only one network round-trip is active at
|
||||||
|
// a time per key — regardless of how many components request it simultaneously.
|
||||||
|
//
|
||||||
|
const inflight = new Map<string, Promise<unknown>>();
|
||||||
|
|
||||||
|
export function deduped<T>(key: string, fetcher: () => Promise<T>): Promise<T> {
|
||||||
|
if (inflight.has(key)) return inflight.get(key) as Promise<T>;
|
||||||
|
const p = fetcher().finally(() => inflight.delete(key));
|
||||||
|
inflight.set(key, p);
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Source frecency helpers ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const FRECENCY_KEY = "moku-source-frecency";
|
||||||
|
const MAX_FRECENCY_SOURCES = 4;
|
||||||
|
|
||||||
|
type FrecencyMap = Record<string, number>;
|
||||||
|
|
||||||
|
function loadFrecency(): FrecencyMap {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(FRECENCY_KEY);
|
||||||
|
return raw ? JSON.parse(raw) : {};
|
||||||
|
} catch { return {}; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveFrecency(map: FrecencyMap) {
|
||||||
|
try { localStorage.setItem(FRECENCY_KEY, JSON.stringify(map)); } catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function recordSourceAccess(sourceId: string) {
|
||||||
|
if (!sourceId || sourceId === "0") return;
|
||||||
|
const map = loadFrecency();
|
||||||
|
map[sourceId] = (map[sourceId] ?? 0) + 1;
|
||||||
|
saveFrecency(map);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTopSources<T extends { id: string }>(sources: T[]): T[] {
|
||||||
|
const map = loadFrecency();
|
||||||
|
const withScore = sources.map((s) => ({ s, score: map[s.id] ?? 0 }));
|
||||||
|
const hasFrecency = withScore.some((x) => x.score > 0);
|
||||||
|
|
||||||
|
if (hasFrecency) {
|
||||||
|
return withScore
|
||||||
|
.sort((a, b) => b.score - a.score)
|
||||||
|
.slice(0, MAX_FRECENCY_SOURCES)
|
||||||
|
.map((x) => x.s);
|
||||||
|
}
|
||||||
|
return sources.slice(0, MAX_FRECENCY_SOURCES);
|
||||||
|
}
|
||||||
+53
-14
@@ -1,7 +1,6 @@
|
|||||||
const DEFAULT_URL = "http://127.0.0.1:4567";
|
const DEFAULT_URL = "http://127.0.0.1:4567";
|
||||||
|
|
||||||
function getServerUrl(): string {
|
function getServerUrl(): string {
|
||||||
// Read from persisted Zustand store if available, fall back to default
|
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem("moku-store");
|
const raw = localStorage.getItem("moku-store");
|
||||||
if (raw) {
|
if (raw) {
|
||||||
@@ -26,15 +25,55 @@ interface GQLResponse<T> {
|
|||||||
errors?: { message: string }[];
|
errors?: { message: string }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Retry with exponential backoff — Suwayomi may not be ready on first load
|
/** Sleep that resolves early if the signal is aborted — never blocks a cancelled request. */
|
||||||
async function fetchWithRetry(url: string, init: RequestInit, retries = 8, delayMs = 500): Promise<Response> {
|
function abortableSleep(ms: number, signal?: AbortSignal): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (signal?.aborted) { reject(new DOMException("Aborted", "AbortError")); return; }
|
||||||
|
const timer = setTimeout(resolve, ms);
|
||||||
|
signal?.addEventListener("abort", () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
reject(new DOMException("Aborted", "AbortError"));
|
||||||
|
}, { once: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retry wrapper with these guarantees:
|
||||||
|
* 1. AbortErrors always propagate immediately — no retry, no delay.
|
||||||
|
* 2. Retry delays are abort-aware — closing a manga mid-delay doesn't hang.
|
||||||
|
* 3. If the signal is already aborted before we even start, we bail instantly.
|
||||||
|
*/
|
||||||
|
async function fetchWithRetry(
|
||||||
|
url: string,
|
||||||
|
init: RequestInit,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
retries = 3,
|
||||||
|
delayMs = 300,
|
||||||
|
): Promise<Response> {
|
||||||
|
// Bail immediately if already aborted before we start
|
||||||
|
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
||||||
|
|
||||||
for (let i = 0; i < retries; i++) {
|
for (let i = 0; i < retries; i++) {
|
||||||
|
// Check abort at the top of every iteration
|
||||||
|
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(url, init);
|
const res = await fetch(url, { ...init, signal });
|
||||||
|
|
||||||
|
// Check abort again — fetch can return a response even after abort in some runtimes
|
||||||
|
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
||||||
|
|
||||||
return res;
|
return res;
|
||||||
} catch (e) {
|
} catch (e: any) {
|
||||||
|
// Never retry aborted requests
|
||||||
|
const isAbort = e?.name === "AbortError" || signal?.aborted;
|
||||||
|
if (isAbort) throw new DOMException("Aborted", "AbortError");
|
||||||
|
|
||||||
|
// Last retry — give up
|
||||||
if (i === retries - 1) throw e;
|
if (i === retries - 1) throw e;
|
||||||
await new Promise((r) => setTimeout(r, delayMs * Math.pow(1.5, i)));
|
|
||||||
|
// Abort-aware delay between retries
|
||||||
|
await abortableSleep(delayMs * Math.pow(1.5, i), signal);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
throw new Error("unreachable");
|
throw new Error("unreachable");
|
||||||
@@ -42,23 +81,23 @@ async function fetchWithRetry(url: string, init: RequestInit, retries = 8, delay
|
|||||||
|
|
||||||
export async function gql<T>(
|
export async function gql<T>(
|
||||||
query: string,
|
query: string,
|
||||||
variables?: Record<string, unknown>
|
variables?: Record<string, unknown>,
|
||||||
|
signal?: AbortSignal,
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const res = await fetchWithRetry(gqlUrl(), {
|
const res = await fetchWithRetry(gqlUrl(), {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ query, variables }),
|
body: JSON.stringify({ query, variables }),
|
||||||
});
|
}, signal);
|
||||||
|
|
||||||
if (!res.ok) {
|
// Check abort before reading the body — avoids hanging on res.json() after cancel
|
||||||
throw new Error(`Suwayomi HTTP ${res.status}`);
|
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
||||||
}
|
if (!res.ok) throw new Error(`Suwayomi HTTP ${res.status}`);
|
||||||
|
|
||||||
const json: GQLResponse<T> = await res.json();
|
const json: GQLResponse<T> = await res.json();
|
||||||
|
|
||||||
if (json.errors?.length) {
|
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
||||||
throw new Error(json.errors[0].message);
|
if (json.errors?.length) throw new Error(json.errors[0].message);
|
||||||
}
|
|
||||||
|
|
||||||
return json.data;
|
return json.data;
|
||||||
}
|
}
|
||||||
+31
-5
@@ -250,23 +250,49 @@ export const DEQUEUE_DOWNLOAD = `
|
|||||||
|
|
||||||
export const START_DOWNLOADER = `
|
export const START_DOWNLOADER = `
|
||||||
mutation StartDownloader {
|
mutation StartDownloader {
|
||||||
startDownloader {
|
startDownloader(input: {}) {
|
||||||
downloadStatus { state }
|
downloadStatus {
|
||||||
|
state
|
||||||
|
queue {
|
||||||
|
progress
|
||||||
|
state
|
||||||
|
chapter {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
pageCount
|
||||||
|
mangaId
|
||||||
|
manga { id title thumbnailUrl }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export const STOP_DOWNLOADER = `
|
export const STOP_DOWNLOADER = `
|
||||||
mutation StopDownloader {
|
mutation StopDownloader {
|
||||||
stopDownloader {
|
stopDownloader(input: {}) {
|
||||||
downloadStatus { state }
|
downloadStatus {
|
||||||
|
state
|
||||||
|
queue {
|
||||||
|
progress
|
||||||
|
state
|
||||||
|
chapter {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
pageCount
|
||||||
|
mangaId
|
||||||
|
manga { id title thumbnailUrl }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export const CLEAR_DOWNLOADER = `
|
export const CLEAR_DOWNLOADER = `
|
||||||
mutation ClearDownloader {
|
mutation ClearDownloader {
|
||||||
clearDownloader {
|
clearDownloader(input: {}) {
|
||||||
downloadStatus {
|
downloadStatus {
|
||||||
state
|
state
|
||||||
queue {
|
queue {
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import type { Source } from "./types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deduplicates sources by name, preferring the given language.
|
||||||
|
* This prevents fetching MangaDex EN + MangaDex ES + MangaDex FR separately.
|
||||||
|
*/
|
||||||
|
export function dedupeSources(sources: Source[], preferredLang: string): Source[] {
|
||||||
|
const byName = new Map<string, Source[]>();
|
||||||
|
for (const src of sources) {
|
||||||
|
if (src.id === "0") continue;
|
||||||
|
if (!byName.has(src.name)) byName.set(src.name, []);
|
||||||
|
byName.get(src.name)!.push(src);
|
||||||
|
}
|
||||||
|
const picked: Source[] = [];
|
||||||
|
for (const group of byName.values()) {
|
||||||
|
const preferred = group.find((s) => s.lang === preferredLang);
|
||||||
|
picked.push(preferred ?? group.sort((a, b) => a.lang.localeCompare(b.lang))[0]);
|
||||||
|
}
|
||||||
|
return picked;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deduplicates manga by title (case-insensitive), keeping the first occurrence.
|
||||||
|
* This eliminates the same series appearing from multiple sources in grids.
|
||||||
|
*/
|
||||||
|
export function dedupeMangaByTitle<T extends { id: number; title: string }>(items: T[]): T[] {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const out: T[] = [];
|
||||||
|
for (const m of items) {
|
||||||
|
const key = m.title.toLowerCase().trim();
|
||||||
|
if (!seen.has(key)) { seen.add(key); out.push(m); }
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deduplicates manga by id only (lossless — use when sources are already deduped).
|
||||||
|
*/
|
||||||
|
export function dedupeMangaById<T extends { id: number }>(items: T[]): T[] {
|
||||||
|
const seen = new Set<number>();
|
||||||
|
const out: T[] = [];
|
||||||
|
for (const m of items) {
|
||||||
|
if (!seen.has(m.id)) { seen.add(m.id); out.push(m); }
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
+48
-3
@@ -9,6 +9,13 @@ export type LibraryFilter = "all" | "library" | "downloaded" | string; // str
|
|||||||
export type NavPage = "library" | "sources" | "explore" | "downloads" | "extensions" | "history" | "search";
|
export type NavPage = "library" | "sources" | "explore" | "downloads" | "extensions" | "history" | "search";
|
||||||
export type ReadingDirection = "ltr" | "rtl";
|
export type ReadingDirection = "ltr" | "rtl";
|
||||||
export type ChapterSortDir = "desc" | "asc";
|
export type ChapterSortDir = "desc" | "asc";
|
||||||
|
export type Theme =
|
||||||
|
| "dark" // default — near-black
|
||||||
|
| "high-contrast" // darker + sharper text
|
||||||
|
| "light" // warm off-white
|
||||||
|
| "light-contrast" // light + max contrast
|
||||||
|
| "midnight" // blue-black tint
|
||||||
|
| "warm"; // amber/sepia tint
|
||||||
|
|
||||||
export interface HistoryEntry {
|
export interface HistoryEntry {
|
||||||
mangaId: number;
|
mangaId: number;
|
||||||
@@ -20,6 +27,14 @@ export interface HistoryEntry {
|
|||||||
readAt: number;
|
readAt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Toast {
|
||||||
|
id: string;
|
||||||
|
kind: "success" | "error" | "info" | "download";
|
||||||
|
title: string;
|
||||||
|
body?: string;
|
||||||
|
duration?: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ActiveDownload {
|
export interface ActiveDownload {
|
||||||
chapterId: number;
|
chapterId: number;
|
||||||
mangaId: number;
|
mangaId: number;
|
||||||
@@ -57,12 +72,18 @@ export interface Settings {
|
|||||||
autoStartServer: boolean;
|
autoStartServer: boolean;
|
||||||
preferredExtensionLang: string;
|
preferredExtensionLang: string;
|
||||||
keybinds: Keybinds;
|
keybinds: Keybinds;
|
||||||
|
idleTimeoutMin?: number;
|
||||||
|
splashCards?: boolean;
|
||||||
storageLimitGb: number | null;
|
storageLimitGb: number | null;
|
||||||
folders: Folder[];
|
folders: Folder[];
|
||||||
|
/** Debounce delay (ms) applied to the reader's scroll/page-change handler. 0 = off. */
|
||||||
|
readerDebounceMs: number;
|
||||||
|
/** UI colour theme. Applied as data-theme on <html>. */
|
||||||
|
theme: Theme;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_SETTINGS: Settings = {
|
export const DEFAULT_SETTINGS: Settings = {
|
||||||
pageStyle: "single",
|
pageStyle: "longstrip",
|
||||||
readingDirection: "ltr",
|
readingDirection: "ltr",
|
||||||
fitMode: "width",
|
fitMode: "width",
|
||||||
maxPageWidth: 900,
|
maxPageWidth: 900,
|
||||||
@@ -85,15 +106,25 @@ export const DEFAULT_SETTINGS: Settings = {
|
|||||||
autoStartServer: true,
|
autoStartServer: true,
|
||||||
preferredExtensionLang: "en",
|
preferredExtensionLang: "en",
|
||||||
keybinds: DEFAULT_KEYBINDS,
|
keybinds: DEFAULT_KEYBINDS,
|
||||||
|
idleTimeoutMin: 5,
|
||||||
|
splashCards: true,
|
||||||
storageLimitGb: null,
|
storageLimitGb: null,
|
||||||
folders: [],
|
folders: [],
|
||||||
|
readerDebounceMs: 120,
|
||||||
|
theme: "dark",
|
||||||
};
|
};
|
||||||
|
|
||||||
interface Store {
|
interface Store {
|
||||||
navPage: NavPage;
|
navPage: NavPage;
|
||||||
setNavPage: (page: NavPage) => void;
|
setNavPage: (page: NavPage) => void;
|
||||||
|
genreFilter: string;
|
||||||
|
setGenreFilter: (genre: string) => void;
|
||||||
|
searchPrefill: string;
|
||||||
|
setSearchPrefill: (q: string) => void;
|
||||||
activeManga: Manga | null;
|
activeManga: Manga | null;
|
||||||
setActiveManga: (manga: Manga | null) => void;
|
setActiveManga: (manga: Manga | null) => void;
|
||||||
|
previewManga: Manga | null;
|
||||||
|
setPreviewManga: (manga: Manga | null) => void;
|
||||||
activeChapter: Chapter | null;
|
activeChapter: Chapter | null;
|
||||||
activeChapterList: Chapter[];
|
activeChapterList: Chapter[];
|
||||||
openReader: (chapter: Chapter, chapterList: Chapter[]) => void;
|
openReader: (chapter: Chapter, chapterList: Chapter[]) => void;
|
||||||
@@ -116,6 +147,9 @@ interface Store {
|
|||||||
history: HistoryEntry[];
|
history: HistoryEntry[];
|
||||||
addHistory: (entry: HistoryEntry) => void;
|
addHistory: (entry: HistoryEntry) => void;
|
||||||
clearHistory: () => void;
|
clearHistory: () => void;
|
||||||
|
toasts: Toast[];
|
||||||
|
addToast: (toast: Omit<Toast, "id">) => void;
|
||||||
|
dismissToast: (id: string) => void;
|
||||||
settings: Settings;
|
settings: Settings;
|
||||||
updateSettings: (patch: Partial<Settings>) => void;
|
updateSettings: (patch: Partial<Settings>) => void;
|
||||||
resetKeybinds: () => void;
|
resetKeybinds: () => void;
|
||||||
@@ -138,8 +172,14 @@ export const useStore = create<Store>()(
|
|||||||
(set, get) => ({
|
(set, get) => ({
|
||||||
navPage: "library",
|
navPage: "library",
|
||||||
setNavPage: (navPage) => set({ navPage }),
|
setNavPage: (navPage) => set({ navPage }),
|
||||||
|
genreFilter: "",
|
||||||
|
setGenreFilter: (genreFilter) => set({ genreFilter }),
|
||||||
|
searchPrefill: "",
|
||||||
|
setSearchPrefill: (searchPrefill) => set({ searchPrefill }),
|
||||||
activeManga: null,
|
activeManga: null,
|
||||||
setActiveManga: (activeManga) => set({ activeManga }),
|
setActiveManga: (activeManga) => set({ activeManga }),
|
||||||
|
previewManga: null,
|
||||||
|
setPreviewManga: (previewManga) => set({ previewManga }),
|
||||||
activeChapter: null,
|
activeChapter: null,
|
||||||
activeChapterList: [],
|
activeChapterList: [],
|
||||||
openReader: (chapter, chapterList) =>
|
openReader: (chapter, chapterList) =>
|
||||||
@@ -166,16 +206,21 @@ export const useStore = create<Store>()(
|
|||||||
set((s) => {
|
set((s) => {
|
||||||
const existing = s.history.findIndex((h) => h.chapterId === entry.chapterId);
|
const existing = s.history.findIndex((h) => h.chapterId === entry.chapterId);
|
||||||
if (existing === 0) {
|
if (existing === 0) {
|
||||||
// Same chapter is already at the top — just update pageNumber and readAt in place
|
|
||||||
const updated = [...s.history];
|
const updated = [...s.history];
|
||||||
updated[0] = { ...updated[0], pageNumber: entry.pageNumber, readAt: entry.readAt };
|
updated[0] = { ...updated[0], pageNumber: entry.pageNumber, readAt: entry.readAt };
|
||||||
return { history: updated };
|
return { history: updated };
|
||||||
}
|
}
|
||||||
// New chapter or chapter not at top — remove old entry, prepend fresh
|
|
||||||
const deduped = s.history.filter((h) => h.chapterId !== entry.chapterId);
|
const deduped = s.history.filter((h) => h.chapterId !== entry.chapterId);
|
||||||
return { history: [entry, ...deduped].slice(0, 300) };
|
return { history: [entry, ...deduped].slice(0, 300) };
|
||||||
}),
|
}),
|
||||||
clearHistory: () => set({ history: [] }),
|
clearHistory: () => set({ history: [] }),
|
||||||
|
toasts: [],
|
||||||
|
addToast: (toast) =>
|
||||||
|
set((s) => ({
|
||||||
|
toasts: [...s.toasts, { ...toast, id: Math.random().toString(36).slice(2) }].slice(-5),
|
||||||
|
})),
|
||||||
|
dismissToast: (id) =>
|
||||||
|
set((s) => ({ toasts: s.toasts.filter((t) => t.id !== id) })),
|
||||||
settings: DEFAULT_SETTINGS,
|
settings: DEFAULT_SETTINGS,
|
||||||
updateSettings: (patch) =>
|
updateSettings: (patch) =>
|
||||||
set((s) => ({ settings: { ...s.settings, ...patch } })),
|
set((s) => ({ settings: { ...s.settings, ...patch } })),
|
||||||
|
|||||||
@@ -107,3 +107,156 @@
|
|||||||
--t-base: 0.14s ease;
|
--t-base: 0.14s ease;
|
||||||
--t-slow: 0.22s ease;
|
--t-slow: 0.22s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ─────────────────────────────────────────────
|
||||||
|
Themes
|
||||||
|
Applied via data-theme on <html>.
|
||||||
|
"dark" = default (no overrides needed, inherits :root).
|
||||||
|
───────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/* ── High Contrast (dark base, sharper text) ── */
|
||||||
|
[data-theme="high-contrast"] {
|
||||||
|
--bg-void: #000000;
|
||||||
|
--bg-base: #080808;
|
||||||
|
--bg-surface: #0d0d0d;
|
||||||
|
--bg-raised: #111111;
|
||||||
|
--bg-overlay: #171717;
|
||||||
|
--bg-subtle: #1e1e1e;
|
||||||
|
|
||||||
|
--border-dim: #252525;
|
||||||
|
--border-base: #303030;
|
||||||
|
--border-strong: #3e3e3e;
|
||||||
|
--border-focus: #5a7a5a;
|
||||||
|
|
||||||
|
/* Text bumped up significantly for contrast */
|
||||||
|
--text-primary: #ffffff;
|
||||||
|
--text-secondary: #e8e6e0;
|
||||||
|
--text-muted: #b0aea8;
|
||||||
|
--text-faint: #6e6c68;
|
||||||
|
--text-disabled: #303030;
|
||||||
|
|
||||||
|
--accent: #7aaa7a;
|
||||||
|
--accent-dim: #2e4a2e;
|
||||||
|
--accent-muted: #1e2e1e;
|
||||||
|
--accent-fg: #bcd8bc;
|
||||||
|
--accent-bright: #9fcf9f;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Light mode ── */
|
||||||
|
[data-theme="light"] {
|
||||||
|
--bg-void: #e8e6e2;
|
||||||
|
--bg-base: #eeece8;
|
||||||
|
--bg-surface: #f4f2ee;
|
||||||
|
--bg-raised: #faf8f4;
|
||||||
|
--bg-overlay: #ffffff;
|
||||||
|
--bg-subtle: #f0ede8;
|
||||||
|
|
||||||
|
--border-dim: #dedad4;
|
||||||
|
--border-base: #d0ccc6;
|
||||||
|
--border-strong: #bbb6ae;
|
||||||
|
--border-focus: #5a7a5a;
|
||||||
|
|
||||||
|
--text-primary: #1a1916;
|
||||||
|
--text-secondary: #2e2c28;
|
||||||
|
--text-muted: #5a5750;
|
||||||
|
--text-faint: #9a9890;
|
||||||
|
--text-disabled: #c8c4bc;
|
||||||
|
|
||||||
|
--accent: #4a724a;
|
||||||
|
--accent-dim: #c8dcc8;
|
||||||
|
--accent-muted: #deeade;
|
||||||
|
--accent-fg: #2a5a2a;
|
||||||
|
--accent-bright: #3a6a3a;
|
||||||
|
|
||||||
|
--color-error: #a03030;
|
||||||
|
--color-error-bg: #fce8e8;
|
||||||
|
--color-success: #2a6a2a;
|
||||||
|
--color-info: #2a4a7a;
|
||||||
|
--color-info-bg: #e8eef8;
|
||||||
|
--color-read: #e8e4dc;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Light High Contrast ── */
|
||||||
|
[data-theme="light-contrast"] {
|
||||||
|
--bg-void: #d8d4ce;
|
||||||
|
--bg-base: #e2deda;
|
||||||
|
--bg-surface: #ece8e2;
|
||||||
|
--bg-raised: #f5f2ec;
|
||||||
|
--bg-overlay: #ffffff;
|
||||||
|
--bg-subtle: #e4e0d8;
|
||||||
|
|
||||||
|
--border-dim: #c4c0b8;
|
||||||
|
--border-base: #b0aca4;
|
||||||
|
--border-strong: #989490;
|
||||||
|
--border-focus: #3a5a3a;
|
||||||
|
|
||||||
|
--text-primary: #080806;
|
||||||
|
--text-secondary: #181612;
|
||||||
|
--text-muted: #38342e;
|
||||||
|
--text-faint: #706c64;
|
||||||
|
--text-disabled: #b0ac a4;
|
||||||
|
|
||||||
|
--accent: #2a5a2a;
|
||||||
|
--accent-dim: #b0ccb0;
|
||||||
|
--accent-muted: #c8dcc8;
|
||||||
|
--accent-fg: #183818;
|
||||||
|
--accent-bright: #1e4e1e;
|
||||||
|
|
||||||
|
--color-error: #8a1a1a;
|
||||||
|
--color-error-bg: #f8e0e0;
|
||||||
|
--color-read: #e0dcd4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Midnight (deep blue-black tint) ── */
|
||||||
|
[data-theme="midnight"] {
|
||||||
|
--bg-void: #050810;
|
||||||
|
--bg-base: #080c18;
|
||||||
|
--bg-surface: #0c1020;
|
||||||
|
--bg-raised: #101428;
|
||||||
|
--bg-overlay: #151a30;
|
||||||
|
--bg-subtle: #1a2038;
|
||||||
|
|
||||||
|
--border-dim: #1a2035;
|
||||||
|
--border-base: #222840;
|
||||||
|
--border-strong: #2c3450;
|
||||||
|
--border-focus: #4a5c8a;
|
||||||
|
|
||||||
|
--text-primary: #eeeef8;
|
||||||
|
--text-secondary: #c0c4d8;
|
||||||
|
--text-muted: #808498;
|
||||||
|
--text-faint: #404860;
|
||||||
|
--text-disabled: #202840;
|
||||||
|
|
||||||
|
--accent: #6a7ab8;
|
||||||
|
--accent-dim: #252d50;
|
||||||
|
--accent-muted: #181e38;
|
||||||
|
--accent-fg: #a8b4e8;
|
||||||
|
--accent-bright: #8896d0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Warm (sepia / amber tinted) ── */
|
||||||
|
[data-theme="warm"] {
|
||||||
|
--bg-void: #0c0a06;
|
||||||
|
--bg-base: #100e08;
|
||||||
|
--bg-surface: #16130c;
|
||||||
|
--bg-raised: #1c1810;
|
||||||
|
--bg-overlay: #221e14;
|
||||||
|
--bg-subtle: #28241a;
|
||||||
|
|
||||||
|
--border-dim: #201c10;
|
||||||
|
--border-base: #2c2818;
|
||||||
|
--border-strong: #3a3420;
|
||||||
|
--border-focus: #6a5a30;
|
||||||
|
|
||||||
|
--text-primary: #f5f0e0;
|
||||||
|
--text-secondary: #d8d0b0;
|
||||||
|
--text-muted: #988c60;
|
||||||
|
--text-faint: #584e30;
|
||||||
|
--text-disabled: #302a18;
|
||||||
|
|
||||||
|
--accent: #c0902a;
|
||||||
|
--accent-dim: #3a2c10;
|
||||||
|
--accent-muted: #261e0c;
|
||||||
|
--accent-fg: #e0b860;
|
||||||
|
--accent-bright: #d0a040;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user