feat: complete mana-viewer implementation with fixes for navigation and UI
@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["tauri-apps.tauri-vscode", "rust-lang.rust-analyzer"]
|
||||
}
|
||||
@ -0,0 +1,7 @@
|
||||
# Tauri + React + Typescript
|
||||
|
||||
This template should help get you started developing with Tauri, React and Typescript in Vite.
|
||||
|
||||
## Recommended IDE Setup
|
||||
|
||||
- [VS Code](https://code.visualstudio.com/) + [Tauri](https://marketplace.visualstudio.com/items?itemName=tauri-apps.tauri-vscode) + [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer)
|
||||
@ -0,0 +1,77 @@
|
||||
# Mana Viewer — 개발 및 분석 종합 보고서
|
||||
|
||||
이 문서는 **Mana Viewer(마나 뷰어)** 프로젝트의 데스크탑 애플리케이션 아키텍처, 구현된 UI/UX 기능, 그리고 대상 웹사이트의 구조 분석 결과를 요약한 종합 보고서입니다.
|
||||
|
||||
---
|
||||
|
||||
## 1. 프로젝트 개요
|
||||
|
||||
- **목적**: 만화/웹툰을 쾌적하고 몰입감 있게 감상할 수 있는 데스크탑 뷰어 애플리케이션 개발
|
||||
- **프레임워크**: **Tauri v2** (Rust Backend + React/TypeScript Frontend)
|
||||
- **디자인 테마**: 다크 모드 기반의 글래스모피즘(Glassmorphism) 및 퍼플 액센트 테마
|
||||
|
||||
---
|
||||
|
||||
## 2. 애플리케이션 아키텍처 및 구현 현황
|
||||
|
||||
### 2.1 프론트엔드 (React + TypeScript)
|
||||
|
||||
- **[UrlInput.tsx](file:///Users/kim1634/mycloud/personal/workspace/mana-viewer/src/components/UrlInput.tsx)**
|
||||
- 만화 회차 URL 입력 및 로딩 인터페이스
|
||||
- 최근 읽은 URL 히스토리 저장 및 불러오기 (`localStorage` 연동)
|
||||
- 파싱 에러 및 소스 붙여넣기 모달 지원
|
||||
|
||||
- **[MangaViewer.tsx](file:///Users/kim1634/mycloud/personal/workspace/mana-viewer/src/components/MangaViewer.tsx)**
|
||||
- **단면 / 양면 모드 (Single / Spread)**: 한 화면에 1장 또는 2장의 이미지를 나란히 배치
|
||||
- **읽기 방향 (LTR / RTL)**: 한국 만화(좌→우) 및 일본 만화(우→좌) 방향 전환 지원
|
||||
- **시작 페이지 오프셋 (Spread Start Offset)**: 표지(1페이지)는 단독으로 보고, 설정한 오프셋 페이지부터 양면으로 매칭하는 오프셋 제어 로직
|
||||
- **3분할 클릭 존 (Click Zones)**: 화면 좌측(이전), 우측(다음), 중앙(툴바 토글) 클릭 반응
|
||||
- **키보드 단축키**: `←`/`→` (페이지 이동), `F` (전체화면), `S` (설정 패널)
|
||||
|
||||
- **[ViewerToolbar.tsx](file:///Users/kim1634/mycloud/personal/workspace/mana-viewer/src/components/ViewerToolbar.tsx)**
|
||||
- 상단 자동 숨김 툴바 (3초 간 무반응 시 감춤)
|
||||
- 회차 제목, 현재/전체 페이지 표시
|
||||
- 이전화 / 다음화 넘기기 네비게이션 버튼
|
||||
|
||||
- **[SettingsPanel.tsx](file:///Users/kim1634/mycloud/personal/workspace/mana-viewer/src/components/SettingsPanel.tsx)**
|
||||
- 슬라이드 방식의 설정 오버레이 패널
|
||||
- 뷰어 모드, 읽기 방향, 양면 시작 오프셋, 이미지 맞춤(너비/높이/원본), 배경색 설정
|
||||
|
||||
---
|
||||
|
||||
### 2.2 백엔드 (Rust / Tauri)
|
||||
|
||||
- **[parser.rs](file:///Users/kim1634/mycloud/personal/workspace/mana-viewer/src-tauri/src/parser.rs)**
|
||||
- `scraper` 크레이트를 활용한 HTML 데이터 추출
|
||||
- `#theme-viewer-data` 스크립트 태그 내 뷰어 토큰, 회차 ID, 메타데이터 추출
|
||||
- `.theme-viewer-prev`, `.theme-viewer-next` 및 텍스트 기반 이전화/다음화 네비게이션 추출
|
||||
|
||||
- **[commands.rs](file:///Users/kim1634/mycloud/personal/workspace/mana-viewer/src-tauri/src/commands.rs)**
|
||||
- `fetch_chapter`: 페이지 요청 및 메타데이터 수집
|
||||
- `fetch_image`: 이미지 Hotlink 방지 우회를 위한 `Referer` 헤더 추가 프록시 및 Base64 변환
|
||||
|
||||
---
|
||||
|
||||
## 3. 웹사이트 구조 분석 결과
|
||||
|
||||
분석 대상 페이지: `https://newtoki1.org/manhwa/8605/u-mokggzkw-74cw`
|
||||
|
||||
### 3.1 렌더링 방식 및 이미지 구동 구조
|
||||
1. **정적 HTML 태그 미사용**:
|
||||
- 만화 본문 이미지는 정적 `<img>` 태그로 렌더링되지 않음
|
||||
- 본문 영역 HTML에는 배너 광고 요소만 존재함
|
||||
2. **동적 API 로딩 방식**:
|
||||
- 페이지 하단 `<script type="application/json" id="theme-viewer-data">`에 회차 메타데이터(토큰, `workId`, `episodeId`, `imageApiPath` 등)가 매핑됨
|
||||
- Client-side JavaScript가 `/api/manhwa-images` 또는 `/api/webtoon-images` API에 `POST` 요청을 보내 실제 이미지 URL 목록을 응답받아 동적으로 렌더링함
|
||||
|
||||
### 3.2 네비게이션 구조
|
||||
- 이전화 / 다음화 링크는 `.theme-viewer-prev`, `.theme-viewer-next` 클래스 또는 상/하단 버튼 그룹(`a.btn`)으로 제공됨
|
||||
|
||||
---
|
||||
|
||||
## 4. 향후 확장 권장 사항
|
||||
|
||||
1. **로컬 파일 뷰어 (CBZ / ZIP / 폴더) 추가**:
|
||||
- 네트워크 연결이나 외부 보안 검증 제약 없이 로컬 디렉터리 및 만화 압축파일(CBZ/ZIP)을 직접 읽어오도록 확장 가능
|
||||
2. **Webview 기반 임베딩**:
|
||||
- 브라우저 표준 세션 환경을 그대로 이용할 수 있도록 Tauri 내부 Webview를 통한 통합 렌더링 방식 고려
|
||||
@ -0,0 +1,15 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="마나 뷰어 - 만화/만화책 전용 뷰어" />
|
||||
<title>마나 뷰어 — Manga Reader</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "tauri-app",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-opener": "^2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"@vitejs/plugin-react": "^4.6.0",
|
||||
"typescript": "~5.8.3",
|
||||
"vite": "^7.0.4",
|
||||
"@tauri-apps/cli": "^2"
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,6 @@
|
||||
<svg width="206" height="231" viewBox="0 0 206 231" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M143.143 84C143.143 96.1503 133.293 106 121.143 106C108.992 106 99.1426 96.1503 99.1426 84C99.1426 71.8497 108.992 62 121.143 62C133.293 62 143.143 71.8497 143.143 84Z" fill="#FFC131"/>
|
||||
<ellipse cx="84.1426" cy="147" rx="22" ry="22" transform="rotate(180 84.1426 147)" fill="#24C8DB"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M166.738 154.548C157.86 160.286 148.023 164.269 137.757 166.341C139.858 160.282 141 153.774 141 147C141 144.543 140.85 142.121 140.558 139.743C144.975 138.204 149.215 136.139 153.183 133.575C162.73 127.404 170.292 118.608 174.961 108.244C179.63 97.8797 181.207 86.3876 179.502 75.1487C177.798 63.9098 172.884 53.4021 165.352 44.8883C157.82 36.3744 147.99 30.2165 137.042 27.1546C126.095 24.0926 114.496 24.2568 103.64 27.6274C92.7839 30.998 83.1319 37.4317 75.8437 46.1553C74.9102 47.2727 74.0206 48.4216 73.176 49.5993C61.9292 50.8488 51.0363 54.0318 40.9629 58.9556C44.2417 48.4586 49.5653 38.6591 56.679 30.1442C67.0505 17.7298 80.7861 8.57426 96.2354 3.77762C111.685 -1.01901 128.19 -1.25267 143.769 3.10474C159.348 7.46215 173.337 16.2252 184.056 28.3411C194.775 40.457 201.767 55.4101 204.193 71.404C206.619 87.3978 204.374 103.752 197.73 118.501C191.086 133.25 180.324 145.767 166.738 154.548ZM41.9631 74.275L62.5557 76.8042C63.0459 72.813 63.9401 68.9018 65.2138 65.1274C57.0465 67.0016 49.2088 70.087 41.9631 74.275Z" fill="#FFC131"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M38.4045 76.4519C47.3493 70.6709 57.2677 66.6712 67.6171 64.6132C65.2774 70.9669 64 77.8343 64 85.0001C64 87.1434 64.1143 89.26 64.3371 91.3442C60.0093 92.8732 55.8533 94.9092 51.9599 97.4256C42.4128 103.596 34.8505 112.392 30.1816 122.756C25.5126 133.12 23.9357 144.612 25.6403 155.851C27.3449 167.09 32.2584 177.598 39.7906 186.112C47.3227 194.626 57.153 200.784 68.1003 203.846C79.0476 206.907 90.6462 206.743 101.502 203.373C112.359 200.002 122.011 193.568 129.299 184.845C130.237 183.722 131.131 182.567 131.979 181.383C143.235 180.114 154.132 176.91 164.205 171.962C160.929 182.49 155.596 192.319 148.464 200.856C138.092 213.27 124.357 222.426 108.907 227.222C93.458 232.019 76.9524 232.253 61.3736 227.895C45.7948 223.538 31.8055 214.775 21.0867 202.659C10.3679 190.543 3.37557 175.59 0.949823 159.596C-1.47592 143.602 0.768139 127.248 7.41237 112.499C14.0566 97.7497 24.8183 85.2327 38.4045 76.4519ZM163.062 156.711L163.062 156.711C162.954 156.773 162.846 156.835 162.738 156.897C162.846 156.835 162.954 156.773 163.062 156.711Z" fill="#24C8DB"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@ -0,0 +1,7 @@
|
||||
# Generated by Cargo
|
||||
# will have compiled files and executables
|
||||
/target/
|
||||
|
||||
# Generated by Tauri
|
||||
# will have schema files for capabilities auto-completion
|
||||
/gen/schemas
|
||||
@ -0,0 +1,23 @@
|
||||
[package]
|
||||
name = "tauri-app"
|
||||
version = "0.1.0"
|
||||
description = "Mana Viewer - Manga/Manhwa Reader"
|
||||
authors = ["you"]
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
name = "tauri_app_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = [] }
|
||||
tauri-plugin-opener = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
reqwest = { version = "0.12", features = ["json", "cookies"] }
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
base64 = "0.22"
|
||||
url = "2"
|
||||
@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
@ -0,0 +1,10 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Capability for the main window",
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"opener:default"
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 6.8 KiB |
|
After Width: | Height: | Size: 974 B |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 7.6 KiB |
|
After Width: | Height: | Size: 903 B |
|
After Width: | Height: | Size: 8.4 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 14 KiB |
@ -0,0 +1,497 @@
|
||||
use base64::engine::general_purpose::STANDARD;
|
||||
use base64::Engine;
|
||||
use reqwest::header::{HeaderMap, HeaderValue, REFERER, USER_AGENT};
|
||||
use serde::Deserialize;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tauri::{AppHandle, Emitter, Manager, WebviewUrl, WebviewWindowBuilder};
|
||||
use tokio::sync::Mutex;
|
||||
use url::Url;
|
||||
|
||||
use crate::types::ChapterData;
|
||||
|
||||
const HARVESTER_LABEL: &str = "harvester";
|
||||
const POLL_INTERVAL_MS: u64 = 500;
|
||||
const EVAL_TIMEOUT_MS: u64 = 2000;
|
||||
const STALL_BEFORE_ATTENTION_SECS: u64 = 15;
|
||||
const HARVEST_TIMEOUT_SECS: u64 = 90;
|
||||
/// After a (re)navigate, ignore `ready` for this long — a reused harvester's
|
||||
/// previous page can briefly still report its OLD `window.__mana` as ready
|
||||
/// until the new document actually replaces it.
|
||||
const SETTLE_GRACE_MS: u64 = 1200;
|
||||
|
||||
/// Global state shared across commands (just the shared HTTP client for image proxying)
|
||||
struct AppState {
|
||||
client: reqwest::Client,
|
||||
}
|
||||
|
||||
static STATE: std::sync::OnceLock<Arc<Mutex<AppState>>> = std::sync::OnceLock::new();
|
||||
static LATEST_REQ: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
fn get_state() -> Arc<Mutex<AppState>> {
|
||||
STATE.get_or_init(|| {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
USER_AGENT,
|
||||
HeaderValue::from_static(
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36",
|
||||
),
|
||||
);
|
||||
headers.insert(
|
||||
"Accept-Language",
|
||||
HeaderValue::from_static("ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7"),
|
||||
);
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.default_headers(headers)
|
||||
.cookie_store(true)
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.build()
|
||||
.expect("Failed to build HTTP client");
|
||||
|
||||
Arc::new(Mutex::new(AppState { client }))
|
||||
}).clone()
|
||||
}
|
||||
|
||||
fn extract_series_url(chapter_url: &str) -> Option<String> {
|
||||
Url::parse(chapter_url).ok().and_then(|mut u| {
|
||||
let mut segments: Vec<String> = u
|
||||
.path_segments()
|
||||
.map(|s| s.map(|seg| seg.to_string()).collect())
|
||||
.unwrap_or_default();
|
||||
if segments.len() > 1 {
|
||||
segments.pop();
|
||||
let new_path = format!("/{}", segments.join("/"));
|
||||
u.set_path(&new_path);
|
||||
Some(u.to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// State the page-injected collector script maintains at `window.__mana`.
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
struct HarvestState {
|
||||
#[serde(default)]
|
||||
ready: bool,
|
||||
#[serde(default)]
|
||||
images: Vec<String>,
|
||||
title: Option<String>,
|
||||
prev: Option<String>,
|
||||
next: Option<String>,
|
||||
href: Option<String>,
|
||||
}
|
||||
|
||||
/// Injected at document start: watches the DOM for the chapter's images to
|
||||
/// render and maintains `window.__mana` with the current best-effort snapshot.
|
||||
/// Site-specific selectors here are heuristics and may need tuning if the
|
||||
/// target site's markup changes.
|
||||
const INIT_SCRIPT: &str = r#"
|
||||
(function() {
|
||||
if (window.__manaInstalled) return;
|
||||
window.__manaInstalled = true;
|
||||
window.__mana = { ready: false };
|
||||
|
||||
var AD_HINTS = /ad|banner|gnb|lnb|footer|header|sns|menu|logo|popup|share|comment/i;
|
||||
// Filename/path hints for non-page images (ads, banners, UI chrome,
|
||||
// lazy-load placeholders) — checked against the resolved image URL itself,
|
||||
// since these often live outside the "ad" DOM containers the class-based
|
||||
// filter above targets.
|
||||
var FILENAME_AD_HINTS = /(^|[\/_.-])(ad|ads|banner|event|popup|logo|gnb|lnb|loading|spinner|placeholder|blank|noimage|no_image|notice|thumb|sns|share|button|btn|icon|dummy)([\/_.-]|$)/i;
|
||||
|
||||
function isLikelyPageImage(src) {
|
||||
try {
|
||||
var u = new URL(src, location.href);
|
||||
if (FILENAME_AD_HINTS.test(u.pathname)) return false;
|
||||
} catch (e) {
|
||||
// not a parseable URL — let other checks decide
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function isValidLink(href) {
|
||||
return !!href && href !== '#' && href.indexOf('javascript:') !== 0 && href.indexOf('mailto:') !== 0;
|
||||
}
|
||||
|
||||
function resolveUrl(href) {
|
||||
try { return new URL(href, location.href).href; } catch (e) { return href; }
|
||||
}
|
||||
|
||||
function findNav() {
|
||||
var prev = null, next = null;
|
||||
var pairs = [['.theme-viewer-prev', '.theme-viewer-next'], ['.btn-prev', '.btn-next'], ['.prev', '.next']];
|
||||
for (var i = 0; i < pairs.length; i++) {
|
||||
var p = document.querySelector(pairs[i][0]);
|
||||
var n = document.querySelector(pairs[i][1]);
|
||||
var pHref = p && p.getAttribute('href');
|
||||
var nHref = n && n.getAttribute('href');
|
||||
if (pHref && isValidLink(pHref)) prev = resolveUrl(pHref);
|
||||
if (nHref && isValidLink(nHref)) next = resolveUrl(nHref);
|
||||
if (prev || next) break;
|
||||
}
|
||||
if (!prev || !next) {
|
||||
var links = document.querySelectorAll('a');
|
||||
for (var j = 0; j < links.length; j++) {
|
||||
var a = links[j];
|
||||
var href = a.getAttribute('href');
|
||||
if (!href || !isValidLink(href)) continue;
|
||||
var text = (a.textContent || '').trim();
|
||||
if (!prev && text.indexOf('이전화') !== -1) prev = resolveUrl(href);
|
||||
if (!next && text.indexOf('다음화') !== -1) next = resolveUrl(href);
|
||||
}
|
||||
}
|
||||
return { prev: prev, next: next };
|
||||
}
|
||||
|
||||
function findTitle() {
|
||||
var sels = ['.toon-title', 'h1.title'];
|
||||
for (var i = 0; i < sels.length; i++) {
|
||||
var el = document.querySelector(sels[i]);
|
||||
if (el && el.textContent && el.textContent.trim()) return el.textContent.trim();
|
||||
}
|
||||
return document.title || null;
|
||||
}
|
||||
|
||||
window.__mana_found_pages = window.__mana_found_pages || {};
|
||||
window.__mana_found_list = window.__mana_found_list || [];
|
||||
|
||||
function collectImages() {
|
||||
var imgs = document.querySelectorAll('img');
|
||||
|
||||
// Check if the site uses data-theme-page
|
||||
var hasThemePage = document.querySelector('img[data-theme-page]');
|
||||
|
||||
for (var i = 0; i < imgs.length; i++) {
|
||||
var img = imgs[i];
|
||||
var src = img.getAttribute('data-src')
|
||||
|| img.getAttribute('data-original')
|
||||
|| img.getAttribute('data-lazy-src')
|
||||
|| img.getAttribute('data-lazy')
|
||||
|| img.currentSrc
|
||||
|| img.src
|
||||
|| '';
|
||||
|
||||
if (!src || src.indexOf('data:') === 0) continue;
|
||||
|
||||
var pageNum = img.getAttribute('data-theme-page');
|
||||
if (pageNum) {
|
||||
var p = parseInt(pageNum, 10);
|
||||
if (!window.__mana_found_pages[p]) {
|
||||
window.__mana_found_pages[p] = src;
|
||||
} else if (window.__mana_found_pages[p].indexOf('blank') !== -1 && src.indexOf('blank') === -1) {
|
||||
// Upgrade placeholder to real image if needed
|
||||
window.__mana_found_pages[p] = src;
|
||||
}
|
||||
} else {
|
||||
// Fallback heuristics for images without data-theme-page
|
||||
if (!isLikelyPageImage(src)) continue;
|
||||
var w = img.naturalWidth || parseInt(img.getAttribute('width') || '', 10) || 0;
|
||||
var h = img.naturalHeight || parseInt(img.getAttribute('height') || '', 10) || 0;
|
||||
if (w > 0 && h > 0 && w >= h) continue;
|
||||
if (w > 0 && w < 150) continue;
|
||||
|
||||
if (window.__mana_found_list.indexOf(src) === -1) {
|
||||
window.__mana_found_list.push(src);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Combine both sources
|
||||
var maxPage = 0;
|
||||
for (var k in window.__mana_found_pages) {
|
||||
if (parseInt(k, 10) > maxPage) maxPage = parseInt(k, 10);
|
||||
}
|
||||
var out = [];
|
||||
for (var j = 1; j <= maxPage; j++) {
|
||||
if (window.__mana_found_pages[j]) {
|
||||
out.push(window.__mana_found_pages[j]);
|
||||
}
|
||||
}
|
||||
for (var m = 0; m < window.__mana_found_list.length; m++) {
|
||||
var s = window.__mana_found_list[m];
|
||||
if (out.indexOf(s) === -1) {
|
||||
out.push(s);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
var lastCount = -1;
|
||||
var stableTicks = 0;
|
||||
var debounceTimer = null;
|
||||
|
||||
var lastLoggedCount = -1;
|
||||
var sampleLogsLeft = 4;
|
||||
|
||||
function logSampleIfChanged(count) {
|
||||
if (count === lastLoggedCount || sampleLogsLeft <= 0) return;
|
||||
lastLoggedCount = count;
|
||||
sampleLogsLeft--;
|
||||
var imgs = document.querySelectorAll('img');
|
||||
console.log('[mana] total <img> tags found:', imgs.length);
|
||||
for (var k = 0; k < Math.min(8, imgs.length); k++) {
|
||||
console.log('[mana] sample img[' + k + ']:', imgs[k].outerHTML.slice(0, 400));
|
||||
}
|
||||
}
|
||||
|
||||
function evaluate() {
|
||||
logSampleIfChanged(document.querySelectorAll('img').length);
|
||||
var images = collectImages();
|
||||
|
||||
if (images.length > 0 && images.length === lastCount) {
|
||||
stableTicks++;
|
||||
} else {
|
||||
stableTicks = 0;
|
||||
}
|
||||
lastCount = images.length;
|
||||
|
||||
var nav = findNav();
|
||||
var isReady = images.length > 0 && stableTicks >= 2;
|
||||
|
||||
console.log('[mana] evaluate: images=' + images.length + ' stableTicks=' + stableTicks + ' href=' + location.href);
|
||||
window.__mana = {
|
||||
ready: isReady,
|
||||
images: images,
|
||||
title: findTitle(),
|
||||
prev: nav.prev,
|
||||
next: nav.next,
|
||||
href: location.href
|
||||
};
|
||||
}
|
||||
|
||||
function scheduleEvaluate() {
|
||||
if (debounceTimer) clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(evaluate, 200);
|
||||
}
|
||||
|
||||
var observer = new MutationObserver(scheduleEvaluate);
|
||||
observer.observe(document.documentElement, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: true,
|
||||
attributeFilter: ['src', 'data-src']
|
||||
});
|
||||
|
||||
// Periodic fallback: naturalWidth/lazy-load settling doesn't always fire a
|
||||
// DOM mutation, so re-check on an interval too.
|
||||
setInterval(evaluate, 200);
|
||||
scheduleEvaluate();
|
||||
})();
|
||||
"#;
|
||||
|
||||
/// Reads back the collector's current snapshot. Always resolves to an object
|
||||
/// (never throws) so `eval_with_callback` has something to serialize.
|
||||
const COLLECTOR_JS: &str = r#"(function(){ try { return window.__mana || {ready:false}; } catch(e) { return {ready:false}; } })()"#;
|
||||
|
||||
async fn get_or_create_harvester(
|
||||
app: &AppHandle,
|
||||
url: &Url,
|
||||
) -> Result<tauri::WebviewWindow, String> {
|
||||
if let Some(win) = app.get_webview_window(HARVESTER_LABEL) {
|
||||
win.navigate(url.clone()).map_err(|e| e.to_string())?;
|
||||
// The site's own anti-bot appears to gate its image-list request on
|
||||
// the tab actually being visible (confirmed: a fully hidden window
|
||||
// gets a 409 on that request instead of real chapter images) — so
|
||||
// this window must stay visible for the harvest to work, unlike a
|
||||
// regular headless scrape.
|
||||
let _ = win.show();
|
||||
return Ok(win);
|
||||
}
|
||||
|
||||
let win = WebviewWindowBuilder::new(app, HARVESTER_LABEL, WebviewUrl::External(url.clone()))
|
||||
.title("Mana Viewer — site session")
|
||||
.inner_size(420.0, 620.0)
|
||||
.position(24.0, 24.0)
|
||||
.decorations(false)
|
||||
.visible(true)
|
||||
.initialization_script(INIT_SCRIPT)
|
||||
.build()
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// Debug builds only: open the Web Inspector for the harvester so its
|
||||
// console/DOM can be inspected directly — useful while tuning the
|
||||
// collector heuristics.
|
||||
#[cfg(debug_assertions)]
|
||||
|
||||
|
||||
Ok(win)
|
||||
}
|
||||
|
||||
fn eval_harvest_state(win: &tauri::WebviewWindow) -> Result<HarvestState, String> {
|
||||
let (tx, rx) = std::sync::mpsc::channel::<String>();
|
||||
win.eval_with_callback(COLLECTOR_JS, move |json| {
|
||||
let _ = tx.send(json);
|
||||
})
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
match rx.recv_timeout(Duration::from_millis(EVAL_TIMEOUT_MS)) {
|
||||
Ok(json_str) => serde_json::from_str::<HarvestState>(&json_str)
|
||||
.map_err(|e| format!("harvest state parse error: {} body={}", e, json_str)),
|
||||
Err(_) => Err("eval timed out".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn poll_harvest(app: AppHandle, req_id: u64, url: String) {
|
||||
if Url::parse(&url).is_err() {
|
||||
let _ = app.emit(
|
||||
"chapter-error",
|
||||
serde_json::json!({ "reqId": req_id, "error": format!("invalid url: {}", url) }),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let start = tokio::time::Instant::now();
|
||||
let mut attention_sent = false;
|
||||
let mut tick: u32 = 0;
|
||||
let mut last_emitted_len: usize = 0;
|
||||
|
||||
loop {
|
||||
if LATEST_REQ.load(Ordering::SeqCst) != req_id {
|
||||
eprintln!("[harvest#{}] superseded, stopping", req_id);
|
||||
return; // superseded by a newer load
|
||||
}
|
||||
if start.elapsed() >= Duration::from_secs(HARVEST_TIMEOUT_SECS) {
|
||||
eprintln!("[harvest#{}] timed out after {}s", req_id, HARVEST_TIMEOUT_SECS);
|
||||
let _ = app.emit(
|
||||
"chapter-error",
|
||||
serde_json::json!({ "reqId": req_id, "error": "timeout waiting for page to render" }),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(win) = app.get_webview_window(HARVESTER_LABEL) else {
|
||||
eprintln!("[harvest#{}] harvester window disappeared", req_id);
|
||||
return;
|
||||
};
|
||||
|
||||
// Run the blocking channel recv on a blocking thread so we don't stall
|
||||
// the async runtime while waiting for the webview's JS callback.
|
||||
let win_clone = win.clone();
|
||||
let state = tokio::task::spawn_blocking(move || eval_harvest_state(&win_clone))
|
||||
.await
|
||||
.unwrap_or_else(|e| Err(e.to_string()));
|
||||
|
||||
tick += 1;
|
||||
match &state {
|
||||
Ok(s) => {
|
||||
if tick % 4 == 0 || s.ready {
|
||||
eprintln!(
|
||||
"[harvest#{}] t={}ms ready={} images={} href={:?}",
|
||||
req_id,
|
||||
start.elapsed().as_millis(),
|
||||
s.ready,
|
||||
s.images.len(),
|
||||
s.href
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(e) => eprintln!("[harvest#{}] eval error: {}", req_id, e),
|
||||
}
|
||||
|
||||
let settled = start.elapsed() >= Duration::from_millis(SETTLE_GRACE_MS);
|
||||
|
||||
if let Ok(state) = state {
|
||||
let current_len = state.images.len();
|
||||
if current_len > last_emitted_len && current_len > 0 {
|
||||
let chapter = ChapterData {
|
||||
images: state.images.clone(),
|
||||
prev_chapter: state.prev.clone(),
|
||||
next_chapter: state.next.clone(),
|
||||
title: state.title.clone(),
|
||||
series_url: state.href.as_deref().and_then(extract_series_url),
|
||||
};
|
||||
let _ = app.emit(
|
||||
"chapter-update",
|
||||
serde_json::json!({ "reqId": req_id, "data": chapter, "href": state.href }),
|
||||
);
|
||||
last_emitted_len = current_len;
|
||||
}
|
||||
|
||||
if settled && state.ready {
|
||||
if LATEST_REQ.load(Ordering::SeqCst) != req_id {
|
||||
return;
|
||||
}
|
||||
let chapter = ChapterData {
|
||||
images: state.images,
|
||||
prev_chapter: state.prev,
|
||||
next_chapter: state.next,
|
||||
title: state.title,
|
||||
series_url: state.href.as_deref().and_then(extract_series_url),
|
||||
};
|
||||
eprintln!("[harvest#{}] ready with {} images", req_id, chapter.images.len());
|
||||
let _ = app.emit(
|
||||
"chapter-ready",
|
||||
serde_json::json!({ "reqId": req_id, "data": chapter, "href": state.href }),
|
||||
);
|
||||
let _ = win.hide();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if !attention_sent && start.elapsed() >= Duration::from_secs(STALL_BEFORE_ATTENTION_SECS) {
|
||||
eprintln!("[harvest#{}] stalled, showing harvester window", req_id);
|
||||
let _ = win.show();
|
||||
let _ = win.set_focus();
|
||||
let _ = app.emit(
|
||||
"chapter-progress",
|
||||
serde_json::json!({ "reqId": req_id, "status": "needs-attention" }),
|
||||
);
|
||||
attention_sent = true;
|
||||
}
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(POLL_INTERVAL_MS)).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Load a chapter by opening it in a real embedded WebView (so the site's own
|
||||
/// anti-bot/ad flow runs naturally) and harvesting the rendered image list.
|
||||
#[tauri::command]
|
||||
pub async fn open_chapter(app: AppHandle, url: String, req_id: u64) -> Result<(), String> {
|
||||
LATEST_REQ.store(req_id, Ordering::SeqCst);
|
||||
|
||||
let target = Url::parse(&url).map_err(|e| format!("invalid url: {}", e))?;
|
||||
get_or_create_harvester(&app, &target).await?;
|
||||
|
||||
tauri::async_runtime::spawn(poll_harvest(app, req_id, url));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Proxy an image through the Rust backend (bypasses hotlink/referer checks).
|
||||
#[tauri::command]
|
||||
pub async fn fetch_image(url: String, referer: String) -> Result<String, String> {
|
||||
let state = get_state();
|
||||
let state_lock = state.lock().await;
|
||||
|
||||
let response = state_lock.client
|
||||
.get(&url)
|
||||
.header(REFERER, &referer)
|
||||
.header("Accept", "image/avif,image/webp,image/apng,image/*,*/*;q=0.8")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Image fetch error: {}", e))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(format!("Image HTTP {}", response.status().as_u16()));
|
||||
}
|
||||
|
||||
let content_type = response
|
||||
.headers()
|
||||
.get("content-type")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("image/jpeg")
|
||||
.to_string();
|
||||
|
||||
let bytes = response.bytes().await.map_err(|e| format!("Image read error: {}", e))?;
|
||||
|
||||
let encoded = STANDARD.encode(&bytes);
|
||||
|
||||
let mime = if content_type.contains("image/") {
|
||||
content_type.split(';').next().unwrap_or("image/jpeg").to_string()
|
||||
} else {
|
||||
"image/jpeg".to_string()
|
||||
};
|
||||
|
||||
Ok(format!("data:{};base64,{}", mime, encoded))
|
||||
}
|
||||
@ -0,0 +1,16 @@
|
||||
mod types;
|
||||
mod commands;
|
||||
|
||||
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
commands::open_chapter,
|
||||
commands::fetch_image,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
@ -0,0 +1,6 @@
|
||||
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
tauri_app_lib::run()
|
||||
}
|
||||
@ -0,0 +1,22 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct ChapterData {
|
||||
pub images: Vec<String>,
|
||||
pub prev_chapter: Option<String>,
|
||||
pub next_chapter: Option<String>,
|
||||
pub title: Option<String>,
|
||||
pub series_url: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct FetchError {
|
||||
pub message: String,
|
||||
pub kind: String,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for FetchError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}: {}", self.kind, self.message)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,35 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "tauri-app",
|
||||
"version": "0.1.0",
|
||||
"identifier": "com.manaviewer.app",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
"devUrl": "http://localhost:1420",
|
||||
"beforeBuildCommand": "npm run build",
|
||||
"frontendDist": "../dist"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "tauri-app",
|
||||
"width": 800,
|
||||
"height": 600
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
]
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,54 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useChapter } from './hooks/useChapter';
|
||||
import { useViewerSettings } from './hooks/useViewerSettings';
|
||||
import { UrlInput } from './components/UrlInput';
|
||||
import { MangaViewer } from './components/MangaViewer';
|
||||
import './App.css';
|
||||
|
||||
export default function App() {
|
||||
const {
|
||||
chapter,
|
||||
currentUrl,
|
||||
loadState,
|
||||
error,
|
||||
needsAttention,
|
||||
isNavigating,
|
||||
navError,
|
||||
loadChapter,
|
||||
fetchImage,
|
||||
navigateTo,
|
||||
} = useChapter();
|
||||
|
||||
const { settings, updateSettings } = useViewerSettings();
|
||||
|
||||
const handleGoHome = useCallback(() => {
|
||||
// Reload the app to go back to URL input
|
||||
window.location.reload();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="app-root">
|
||||
{loadState !== 'success' || !chapter ? (
|
||||
<UrlInput
|
||||
onLoad={loadChapter}
|
||||
isLoading={loadState === 'loading'}
|
||||
needsAttention={needsAttention}
|
||||
error={error}
|
||||
/>
|
||||
) : (
|
||||
<MangaViewer
|
||||
chapter={chapter}
|
||||
currentUrl={currentUrl}
|
||||
settings={settings}
|
||||
onSettingsChange={updateSettings}
|
||||
onNavigate={navigateTo}
|
||||
onGoHome={handleGoHome}
|
||||
fetchImage={fetchImage}
|
||||
isNavigating={isNavigating}
|
||||
navError={navError}
|
||||
needsAttention={needsAttention}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
@ -0,0 +1,82 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import type { FitMode } from '../types';
|
||||
|
||||
interface MangaPageProps {
|
||||
imageUrl: string;
|
||||
referer: string;
|
||||
fitMode: FitMode;
|
||||
fetchImage: (url: string, referer: string) => Promise<string>;
|
||||
}
|
||||
|
||||
export function MangaPage({ imageUrl, referer, fitMode, fetchImage }: MangaPageProps) {
|
||||
const [src, setSrc] = useState<string>('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError(false);
|
||||
setSrc('');
|
||||
|
||||
fetchImage(imageUrl, referer)
|
||||
.then((dataUri) => {
|
||||
if (!cancelled) {
|
||||
setSrc(dataUri);
|
||||
setLoading(false);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setError(true);
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [imageUrl, referer]);
|
||||
|
||||
const fitClass = {
|
||||
width: 'fit-width',
|
||||
height: 'fit-height',
|
||||
original: 'fit-original',
|
||||
}[fitMode];
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="page-placeholder">
|
||||
<div className="page-spinner">
|
||||
<svg className="spin" viewBox="0 0 24 24" fill="none">
|
||||
<circle cx="12" cy="12" r="10" stroke="#7c3aed" strokeWidth="2" strokeDasharray="40" strokeDashoffset="10" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="page-error">
|
||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="#ef4444" strokeWidth="2">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<line x1="12" y1="8" x2="12" y2="12"/>
|
||||
<line x1="12" y1="16" x2="12.01" y2="16"/>
|
||||
</svg>
|
||||
<span>이미지를 불러올 수 없습니다</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`manga-page ${fitClass}`}>
|
||||
<img
|
||||
src={src}
|
||||
alt=""
|
||||
draggable={false}
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,336 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import type { ViewerSettings } from '../types';
|
||||
import type { ChapterData } from '../types';
|
||||
import { MangaPage } from './MangaPage';
|
||||
import { ViewerToolbar } from './ViewerToolbar';
|
||||
import { SettingsPanel } from './SettingsPanel';
|
||||
|
||||
interface MangaViewerProps {
|
||||
chapter: ChapterData;
|
||||
currentUrl: string;
|
||||
settings: ViewerSettings;
|
||||
onSettingsChange: (s: Partial<ViewerSettings>) => void;
|
||||
onNavigate: (url: string | null) => void;
|
||||
onGoHome: () => void;
|
||||
fetchImage: (url: string, referer: string) => Promise<string>;
|
||||
isNavigating: boolean;
|
||||
navError: string | null;
|
||||
needsAttention: boolean;
|
||||
}
|
||||
|
||||
function buildSlots(images: string[], offset: number): Array<string[]> {
|
||||
const slots: Array<string[]> = [];
|
||||
let i = 0;
|
||||
|
||||
// Pages before offset: single
|
||||
while (i < offset && i < images.length) {
|
||||
slots.push([images[i]]);
|
||||
i++;
|
||||
}
|
||||
|
||||
// Remaining pages: pairs in natural order [Page N, Page N+1]
|
||||
while (i < images.length) {
|
||||
if (i + 1 < images.length) {
|
||||
slots.push([images[i], images[i + 1]]);
|
||||
i += 2;
|
||||
} else {
|
||||
slots.push([images[i]]);
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
return slots;
|
||||
}
|
||||
|
||||
export function MangaViewer({
|
||||
chapter,
|
||||
currentUrl,
|
||||
settings,
|
||||
onSettingsChange,
|
||||
onNavigate,
|
||||
onGoHome,
|
||||
fetchImage,
|
||||
isNavigating,
|
||||
navError,
|
||||
needsAttention,
|
||||
}: MangaViewerProps) {
|
||||
const [slotIndex, setSlotIndex] = useState(0);
|
||||
const [showToolbar, setShowToolbar] = useState(true);
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const [dismissNavError, setDismissNavError] = useState(false);
|
||||
const [boundaryWarning, setBoundaryWarning] = useState<'first' | 'last' | null>(null);
|
||||
const toolbarTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setDismissNavError(false);
|
||||
if (!navError) return;
|
||||
const t = setTimeout(() => setDismissNavError(true), 6000);
|
||||
return () => clearTimeout(t);
|
||||
}, [navError]);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
const handleFullscreenChange = () => {
|
||||
setIsFullscreen(!!document.fullscreenElement);
|
||||
};
|
||||
document.addEventListener('fullscreenchange', handleFullscreenChange);
|
||||
return () => document.removeEventListener('fullscreenchange', handleFullscreenChange);
|
||||
}, []);
|
||||
|
||||
const slots =
|
||||
settings.mode === 'spread'
|
||||
? buildSlots(chapter.images, settings.spreadStartOffset)
|
||||
: chapter.images.map((img) => [img]);
|
||||
|
||||
const totalSlots = slots.length;
|
||||
const currentSlot = slots[slotIndex] ?? [];
|
||||
|
||||
// Compute global page numbers for display
|
||||
const firstPageNum = slotIndex === 0
|
||||
? (settings.mode === 'spread' ? 0 : 0)
|
||||
: slots.slice(0, slotIndex).reduce((acc, s) => acc + s.length, 0);
|
||||
|
||||
const displayPage = firstPageNum + 1;
|
||||
const totalPages = chapter.images.length;
|
||||
|
||||
// Reset state on new chapter
|
||||
useEffect(() => {
|
||||
setSlotIndex(0);
|
||||
setShowSettings(false);
|
||||
setBoundaryWarning(null);
|
||||
}, [currentUrl]);
|
||||
|
||||
const goNext = useCallback(() => {
|
||||
if (slotIndex < totalSlots - 1) {
|
||||
setSlotIndex((i) => i + 1);
|
||||
setBoundaryWarning(null);
|
||||
} else if (chapter.next_chapter) {
|
||||
if (boundaryWarning === 'last') {
|
||||
onNavigate(chapter.next_chapter);
|
||||
} else {
|
||||
setBoundaryWarning('last');
|
||||
}
|
||||
}
|
||||
}, [slotIndex, totalSlots, chapter.next_chapter, boundaryWarning, onNavigate]);
|
||||
|
||||
const goPrev = useCallback(() => {
|
||||
if (slotIndex > 0) {
|
||||
setSlotIndex((i) => i - 1);
|
||||
setBoundaryWarning(null);
|
||||
} else if (chapter.prev_chapter) {
|
||||
if (boundaryWarning === 'first') {
|
||||
onNavigate(chapter.prev_chapter);
|
||||
} else {
|
||||
setBoundaryWarning('first');
|
||||
}
|
||||
}
|
||||
}, [slotIndex, chapter.prev_chapter, boundaryWarning, onNavigate]);
|
||||
|
||||
// Keyboard navigation
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
|
||||
switch (e.key) {
|
||||
case 'ArrowRight':
|
||||
e.preventDefault();
|
||||
goNext();
|
||||
break;
|
||||
case 'ArrowLeft':
|
||||
e.preventDefault();
|
||||
goPrev();
|
||||
break;
|
||||
case 'f':
|
||||
case 'F':
|
||||
toggleFullscreen();
|
||||
break;
|
||||
case 's':
|
||||
case 'S':
|
||||
setShowSettings((v) => !v);
|
||||
break;
|
||||
case 'Escape':
|
||||
if (showSettings) setShowSettings(false);
|
||||
break;
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handler);
|
||||
return () => window.removeEventListener('keydown', handler);
|
||||
}, [goNext, goPrev, showSettings, settings.direction]);
|
||||
|
||||
// Auto-hide toolbar
|
||||
const resetToolbarTimer = useCallback(() => {
|
||||
setShowToolbar(true);
|
||||
if (toolbarTimer.current) clearTimeout(toolbarTimer.current);
|
||||
toolbarTimer.current = setTimeout(() => {
|
||||
if (!showSettings) setShowToolbar(false);
|
||||
}, 3000);
|
||||
}, [showSettings]);
|
||||
|
||||
useEffect(() => {
|
||||
resetToolbarTimer();
|
||||
return () => {
|
||||
if (toolbarTimer.current) clearTimeout(toolbarTimer.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const toggleFullscreen = () => {
|
||||
if (!document.fullscreenElement) {
|
||||
containerRef.current?.requestFullscreen();
|
||||
setIsFullscreen(true);
|
||||
} else {
|
||||
document.exitFullscreen();
|
||||
setIsFullscreen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const bgMap = {
|
||||
black: '#000000',
|
||||
white: '#f8f8f8',
|
||||
gray: '#1a1a1a',
|
||||
};
|
||||
|
||||
// Click zones: left third = prev, right third = next, middle = toggle toolbar
|
||||
const handleZoneClick = (e: React.MouseEvent) => {
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const third = rect.width / 3;
|
||||
if (x < third) {
|
||||
goPrev();
|
||||
} else if (x > third * 2) {
|
||||
goNext();
|
||||
} else {
|
||||
setShowToolbar((v) => !v);
|
||||
}
|
||||
};
|
||||
|
||||
const referer = new URL(currentUrl).origin;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="manga-viewer"
|
||||
style={{ backgroundColor: bgMap[settings.backgroundColor] }}
|
||||
onMouseMove={resetToolbarTimer}
|
||||
onClick={handleZoneClick}
|
||||
>
|
||||
{/* Toolbar */}
|
||||
<ViewerToolbar
|
||||
visible={showToolbar}
|
||||
title={chapter.title}
|
||||
currentPage={displayPage}
|
||||
totalPages={totalPages}
|
||||
currentSlot={slotIndex}
|
||||
totalSlots={totalSlots}
|
||||
hasPrev={slotIndex > 0 || !!chapter.prev_chapter}
|
||||
hasNext={slotIndex < totalSlots - 1 || !!chapter.next_chapter}
|
||||
settings={settings}
|
||||
isFullscreen={isFullscreen}
|
||||
onPrevChapter={() => onNavigate(chapter.prev_chapter)}
|
||||
onNextChapter={() => onNavigate(chapter.next_chapter)}
|
||||
onToggleSettings={() => setShowSettings((v) => !v)}
|
||||
onToggleFullscreen={toggleFullscreen}
|
||||
onGoHome={onGoHome}
|
||||
onSettingsChange={onSettingsChange}
|
||||
/>
|
||||
|
||||
{/* Page navigation arrows (floating in center) */}
|
||||
<button
|
||||
className={`page-nav-btn page-nav-prev ${!(slotIndex > 0 || !!chapter.prev_chapter) ? 'disabled' : ''}`}
|
||||
onClick={(e) => { e.stopPropagation(); goPrev(); }}
|
||||
disabled={!(slotIndex > 0 || !!chapter.prev_chapter)}
|
||||
title="이전 페이지 (←)"
|
||||
style={{ opacity: showToolbar ? 1 : 0, pointerEvents: showToolbar ? 'auto' : 'none' }}
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||||
<path d="M15 18l-6-6 6-6"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
className={`page-nav-btn page-nav-next ${!(slotIndex < totalSlots - 1 || !!chapter.next_chapter) ? 'disabled' : ''}`}
|
||||
onClick={(e) => { e.stopPropagation(); goNext(); }}
|
||||
disabled={!(slotIndex < totalSlots - 1 || !!chapter.next_chapter)}
|
||||
title="다음 페이지 (→)"
|
||||
style={{ opacity: showToolbar ? 1 : 0, pointerEvents: showToolbar ? 'auto' : 'none' }}
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||||
<path d="M9 18l6-6-6-6"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Full-screen Loading Overlay */}
|
||||
{isNavigating && (
|
||||
<div className="full-loading-overlay" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="loading-spinner-large" />
|
||||
<div className="loading-text">
|
||||
{needsAttention
|
||||
? '열린 창에서 사이트 인증(캡차/로그인 등)을 완료해주세요.'
|
||||
: '챕터를 불러오는 중...'}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Boundary Warning */}
|
||||
{boundaryWarning && (
|
||||
<div className="boundary-warning">
|
||||
{boundaryWarning === 'first' ? '첫 페이지입니다. 이전 화로 이동하려면 한 번 더 누르세요.' : '마지막 페이지입니다. 다음 화로 이동하려면 한 번 더 누르세요.'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error Toast */}
|
||||
{!isNavigating && navError && !dismissNavError && (
|
||||
<div className="nav-status-toast" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="error-banner">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/>
|
||||
</svg>
|
||||
<span>{navError}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
|
||||
{/* Page display */}
|
||||
<div
|
||||
className={`viewer-stage ${settings.mode === 'spread' && currentSlot.length === 2 ? 'spread-mode' : 'single-mode'}`}
|
||||
style={{ direction: settings.direction }}
|
||||
>
|
||||
{currentSlot.map((imgUrl, idx) => (
|
||||
<MangaPage
|
||||
key={imgUrl + idx}
|
||||
imageUrl={imgUrl}
|
||||
referer={referer}
|
||||
fitMode={settings.fitMode}
|
||||
fetchImage={fetchImage}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Click zone overlays (visual hints) */}
|
||||
<div className="click-zones" aria-hidden>
|
||||
<div className="zone zone-prev" />
|
||||
<div className="zone zone-middle" />
|
||||
<div className="zone zone-next" />
|
||||
</div>
|
||||
|
||||
{/* Page progress bar */}
|
||||
<div className="progress-bar-container">
|
||||
<div
|
||||
className="progress-bar"
|
||||
style={{ width: `${((slotIndex + 1) / totalSlots) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Settings panel */}
|
||||
{showSettings && (
|
||||
<SettingsPanel
|
||||
settings={settings}
|
||||
totalImages={chapter.images.length}
|
||||
onChange={onSettingsChange}
|
||||
onClose={() => setShowSettings(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,176 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import type { HistoryEntry } from '../types';
|
||||
|
||||
interface UrlInputProps {
|
||||
onLoad: (url: string) => void;
|
||||
isLoading: boolean;
|
||||
needsAttention: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
const HISTORY_KEY = 'mana-viewer-history';
|
||||
|
||||
function getHistory(): HistoryEntry[] {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(HISTORY_KEY) || '[]');
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function saveHistory(entry: HistoryEntry) {
|
||||
const history = getHistory().filter((h) => h.url !== entry.url);
|
||||
history.unshift(entry);
|
||||
localStorage.setItem(HISTORY_KEY, JSON.stringify(history.slice(0, 20)));
|
||||
}
|
||||
|
||||
export function UrlInput({
|
||||
onLoad,
|
||||
isLoading,
|
||||
needsAttention,
|
||||
error,
|
||||
}: UrlInputProps) {
|
||||
const [url, setUrl] = useState('');
|
||||
const [history, setHistory] = useState<HistoryEntry[]>(getHistory);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
const handleLoad = () => {
|
||||
if (!url.trim()) return;
|
||||
saveHistory({ url, title: url, timestamp: Date.now() });
|
||||
setHistory(getHistory());
|
||||
onLoad(url);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') handleLoad();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="url-input-screen">
|
||||
{isLoading && (
|
||||
<div className="full-loading-overlay" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="loading-spinner-large" />
|
||||
<div className="loading-text">
|
||||
{needsAttention
|
||||
? '열린 창에서 사이트 인증(캡차/로그인 등)을 완료해주세요.'
|
||||
: '챕터를 불러오는 중...'}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="url-input-container">
|
||||
{/* Logo / Hero */}
|
||||
<div className="hero-section">
|
||||
<div className="app-logo">
|
||||
<svg viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="48" height="48" rx="12" fill="url(#logo-gradient)" />
|
||||
<path d="M14 10h20v4H14zM10 14h4v24h-4zM34 14h4v24h-4zM14 34h20v4H14z" fill="white" opacity="0.9"/>
|
||||
<rect x="18" y="18" width="12" height="2" fill="white" opacity="0.6"/>
|
||||
<rect x="18" y="22" width="12" height="2" fill="white" opacity="0.6"/>
|
||||
<rect x="18" y="26" width="8" height="2" fill="white" opacity="0.6"/>
|
||||
<defs>
|
||||
<linearGradient id="logo-gradient" x1="0" y1="0" x2="48" y2="48" gradientUnits="userSpaceOnUse">
|
||||
<stop stopColor="#7c3aed"/>
|
||||
<stop offset="1" stopColor="#4f46e5"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
<span>마나 뷰어</span>
|
||||
</div>
|
||||
<p className="hero-subtitle">만화 URL을 입력하면 쾌적한 뷰어로 읽을 수 있습니다</p>
|
||||
</div>
|
||||
|
||||
{/* URL Input */}
|
||||
<div className="url-form">
|
||||
<div className="input-row">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="url"
|
||||
className="url-field"
|
||||
placeholder="https://newtoki.org/manhwa/..."
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<button
|
||||
className="btn-primary load-btn"
|
||||
onClick={handleLoad}
|
||||
disabled={isLoading || !url.trim()}
|
||||
>
|
||||
{isLoading ? (
|
||||
<span className="spinner" />
|
||||
) : (
|
||||
<>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||||
<path d="M5 12h14M12 5l7 7-7 7"/>
|
||||
</svg>
|
||||
불러오기
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Needs-attention hint: the site's own challenge (Cloudflare/login/etc.)
|
||||
is being shown in a real browser window for the user to solve. */}
|
||||
{isLoading && needsAttention && (
|
||||
<div className="error-banner">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/>
|
||||
</svg>
|
||||
<span>열린 창에서 사이트 인증(캡차/로그인 등)을 완료해주세요.</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error display */}
|
||||
{error && (
|
||||
<div className="error-banner">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/>
|
||||
</svg>
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* History */}
|
||||
{history.length > 0 && (
|
||||
<div className="history-section">
|
||||
<h3 className="history-title">최근 기록</h3>
|
||||
<div className="history-list">
|
||||
{history.map((entry) => (
|
||||
<button
|
||||
key={entry.url}
|
||||
className="history-item"
|
||||
onClick={() => {
|
||||
setUrl(entry.url);
|
||||
onLoad(entry.url);
|
||||
}}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M12 2a10 10 0 1 0 10 10A10 10 0 0 0 12 2zm0 18a8 8 0 1 1 8-8 8 8 0 0 1-8 8z"/>
|
||||
<path d="M12 6v6l4 2"/>
|
||||
</svg>
|
||||
<span className="history-url">{entry.url}</span>
|
||||
<span className="history-time">
|
||||
{new Date(entry.timestamp).toLocaleDateString('ko-KR')}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Hint */}
|
||||
<div className="keyboard-hints">
|
||||
<span><kbd>←</kbd><kbd>→</kbd> 페이지 이동</span>
|
||||
<span><kbd>F</kbd> 전체화면</span>
|
||||
<span><kbd>S</kbd> 설정</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,160 @@
|
||||
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
import type { ChapterData } from '../types';
|
||||
|
||||
type LoadState = 'idle' | 'loading' | 'success' | 'error';
|
||||
|
||||
interface ChapterReadyPayload {
|
||||
reqId: number;
|
||||
data: ChapterData;
|
||||
href?: string;
|
||||
}
|
||||
|
||||
interface ChapterErrorPayload {
|
||||
reqId: number;
|
||||
error: string;
|
||||
}
|
||||
|
||||
interface ChapterProgressPayload {
|
||||
reqId: number;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export function useChapter() {
|
||||
const [chapter, setChapter] = useState<ChapterData | null>(null);
|
||||
const [currentUrl, setCurrentUrl] = useState<string>('');
|
||||
const [loadState, setLoadState] = useState<LoadState>('idle');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [needsAttention, setNeedsAttention] = useState(false);
|
||||
// True while re-loading a chapter (e.g. prev/next) when one is already on
|
||||
// screen — kept separate from `loadState` so a slow/failed reload doesn't
|
||||
// yank the reader back to the URL input screen.
|
||||
const [isNavigating, setIsNavigating] = useState(false);
|
||||
const [navError, setNavError] = useState<string | null>(null);
|
||||
|
||||
// Cache of loaded image data URIs
|
||||
const imageCache = useRef<Map<string, string>>(new Map());
|
||||
// Monotonic request id: guards against stale responses from superseded loads
|
||||
const reqIdRef = useRef(0);
|
||||
const latestReqId = useRef(0);
|
||||
// Mirrors `chapter !== null` for use inside the event listener closures below.
|
||||
const hasChapterRef = useRef(false);
|
||||
const currentUrlRef = useRef('');
|
||||
|
||||
useEffect(() => {
|
||||
hasChapterRef.current = chapter !== null;
|
||||
}, [chapter]);
|
||||
|
||||
useEffect(() => {
|
||||
const unlistenUpdate = listen<ChapterReadyPayload>('chapter-update', (event) => {
|
||||
const { reqId, data, href } = event.payload;
|
||||
if (reqId !== latestReqId.current) return; // stale
|
||||
setChapter(data);
|
||||
setCurrentUrl(href ?? currentUrlRef.current);
|
||||
// Change to success state so it shows up in MangaViewer immediately
|
||||
setLoadState('success');
|
||||
setNeedsAttention(false);
|
||||
setNavError(null);
|
||||
});
|
||||
|
||||
const unlistenReady = listen<ChapterReadyPayload>('chapter-ready', (event) => {
|
||||
const { reqId, data, href } = event.payload;
|
||||
if (reqId !== latestReqId.current) return; // stale
|
||||
setChapter(data);
|
||||
setCurrentUrl(href ?? currentUrlRef.current);
|
||||
setLoadState('success');
|
||||
setNeedsAttention(false);
|
||||
setIsNavigating(false);
|
||||
setNavError(null);
|
||||
});
|
||||
|
||||
const unlistenError = listen<ChapterErrorPayload>('chapter-error', (event) => {
|
||||
if (event.payload.reqId !== latestReqId.current) return;
|
||||
setNeedsAttention(false);
|
||||
if (hasChapterRef.current) {
|
||||
// Keep showing the current chapter; surface the failure as a toast.
|
||||
setIsNavigating(false);
|
||||
setNavError(event.payload.error);
|
||||
} else {
|
||||
setError(event.payload.error);
|
||||
setLoadState('error');
|
||||
}
|
||||
});
|
||||
|
||||
const unlistenProgress = listen<ChapterProgressPayload>('chapter-progress', (event) => {
|
||||
if (event.payload.reqId !== latestReqId.current) return;
|
||||
if (event.payload.status === 'needs-attention') {
|
||||
setNeedsAttention(true);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
unlistenUpdate.then((fn) => fn());
|
||||
unlistenReady.then((fn) => fn());
|
||||
unlistenError.then((fn) => fn());
|
||||
unlistenProgress.then((fn) => fn());
|
||||
};
|
||||
}, []);
|
||||
|
||||
const loadChapter = useCallback(async (url: string) => {
|
||||
if (!url.trim()) return;
|
||||
const reqId = ++reqIdRef.current;
|
||||
latestReqId.current = reqId;
|
||||
currentUrlRef.current = url;
|
||||
setNeedsAttention(false);
|
||||
setNavError(null);
|
||||
|
||||
if (hasChapterRef.current) {
|
||||
setIsNavigating(true);
|
||||
} else {
|
||||
setLoadState('loading');
|
||||
setError(null);
|
||||
}
|
||||
|
||||
try {
|
||||
await invoke('open_chapter', { url, reqId });
|
||||
} catch (err) {
|
||||
if (reqId !== latestReqId.current) return;
|
||||
if (hasChapterRef.current) {
|
||||
setIsNavigating(false);
|
||||
setNavError(String(err));
|
||||
} else {
|
||||
setError(String(err));
|
||||
setLoadState('error');
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchImage = useCallback(async (imageUrl: string, referer: string): Promise<string> => {
|
||||
const cached = imageCache.current.get(imageUrl);
|
||||
if (cached) return cached;
|
||||
|
||||
try {
|
||||
const dataUri = await invoke<string>('fetch_image', { url: imageUrl, referer });
|
||||
imageCache.current.set(imageUrl, dataUri);
|
||||
return dataUri;
|
||||
} catch {
|
||||
return imageUrl; // Fallback: try direct URL
|
||||
}
|
||||
}, []);
|
||||
|
||||
const navigateTo = useCallback(async (url: string | null) => {
|
||||
if (!url) return;
|
||||
imageCache.current.clear();
|
||||
await loadChapter(url);
|
||||
}, [loadChapter]);
|
||||
|
||||
return {
|
||||
chapter,
|
||||
currentUrl,
|
||||
loadState,
|
||||
error,
|
||||
needsAttention,
|
||||
isNavigating,
|
||||
navError,
|
||||
loadChapter,
|
||||
fetchImage,
|
||||
navigateTo,
|
||||
};
|
||||
}
|
||||
@ -0,0 +1,37 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import type { ViewerSettings } from '../types';
|
||||
|
||||
const STORAGE_KEY = 'mana-viewer-settings';
|
||||
|
||||
const defaultSettings: ViewerSettings = {
|
||||
mode: 'spread',
|
||||
direction: 'ltr',
|
||||
fitMode: 'width',
|
||||
backgroundColor: 'black',
|
||||
spreadStartOffset: 0,
|
||||
};
|
||||
|
||||
export function useViewerSettings() {
|
||||
const [settings, setSettings] = useState<ViewerSettings>(() => {
|
||||
try {
|
||||
const saved = localStorage.getItem(STORAGE_KEY);
|
||||
return saved ? { ...defaultSettings, ...JSON.parse(saved) } : defaultSettings;
|
||||
} catch {
|
||||
return defaultSettings;
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
|
||||
}, [settings]);
|
||||
|
||||
const updateSettings = useCallback((partial: Partial<ViewerSettings>) => {
|
||||
setSettings((prev) => ({ ...prev, ...partial }));
|
||||
}, []);
|
||||
|
||||
const resetSettings = useCallback(() => {
|
||||
setSettings(defaultSettings);
|
||||
}, []);
|
||||
|
||||
return { settings, updateSettings, resetSettings };
|
||||
}
|
||||
@ -0,0 +1,9 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import App from "./App";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
@ -0,0 +1,27 @@
|
||||
export interface ChapterData {
|
||||
images: string[];
|
||||
prev_chapter: string | null;
|
||||
next_chapter: string | null;
|
||||
title: string | null;
|
||||
series_url: string | null;
|
||||
}
|
||||
|
||||
export type ViewerMode = 'single' | 'spread';
|
||||
export type ReadingDirection = 'ltr' | 'rtl';
|
||||
export type FitMode = 'width' | 'height' | 'original';
|
||||
export type BackgroundColor = 'black' | 'white' | 'gray';
|
||||
|
||||
export interface ViewerSettings {
|
||||
mode: ViewerMode;
|
||||
direction: ReadingDirection;
|
||||
fitMode: FitMode;
|
||||
backgroundColor: BackgroundColor;
|
||||
/** 0-indexed page that starts the first "paired" spread. Pages before this are shown solo. */
|
||||
spreadStartOffset: number;
|
||||
}
|
||||
|
||||
export interface HistoryEntry {
|
||||
url: string;
|
||||
title: string;
|
||||
timestamp: number;
|
||||
}
|
||||
@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@ -0,0 +1,32 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
// @ts-expect-error process is a nodejs global
|
||||
const host = process.env.TAURI_DEV_HOST;
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig(async () => ({
|
||||
plugins: [react()],
|
||||
|
||||
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
|
||||
//
|
||||
// 1. prevent Vite from obscuring rust errors
|
||||
clearScreen: false,
|
||||
// 2. tauri expects a fixed port, fail if that port is not available
|
||||
server: {
|
||||
port: 1420,
|
||||
strictPort: true,
|
||||
host: host || false,
|
||||
hmr: host
|
||||
? {
|
||||
protocol: "ws",
|
||||
host,
|
||||
port: 1421,
|
||||
}
|
||||
: undefined,
|
||||
watch: {
|
||||
// 3. tell Vite to ignore watching `src-tauri`
|
||||
ignored: ["**/src-tauri/**"],
|
||||
},
|
||||
},
|
||||
}));
|
||||