You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
213 lines
7.8 KiB
TypeScript
213 lines
7.8 KiB
TypeScript
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">
|
|
<div className="history-header">
|
|
<h3 className="history-title">최근 본 만화</h3>
|
|
<button
|
|
className="btn-ghost clear-history-btn"
|
|
onClick={() => {
|
|
localStorage.removeItem(HISTORY_KEY);
|
|
setHistory([]);
|
|
}}
|
|
>
|
|
기록 삭제
|
|
</button>
|
|
</div>
|
|
<div className="history-list">
|
|
{history.map((entry) => {
|
|
const displayTitle = entry.chapterTitle || (entry.title && entry.title !== entry.url ? entry.title : null);
|
|
return (
|
|
<div key={entry.url} className="history-item-wrapper">
|
|
<button
|
|
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>
|
|
<div className="history-info">
|
|
{displayTitle ? (
|
|
<>
|
|
<span className="history-manga-title">{displayTitle}</span>
|
|
<span className="history-url-sub">{entry.url}</span>
|
|
</>
|
|
) : (
|
|
<span className="history-url">{entry.url}</span>
|
|
)}
|
|
</div>
|
|
<span className="history-time">
|
|
{new Date(entry.timestamp).toLocaleDateString('ko-KR')}
|
|
</span>
|
|
</button>
|
|
<button
|
|
className="history-delete-btn"
|
|
title="이 항목 삭제"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
const updated = history.filter((h) => h.url !== entry.url);
|
|
localStorage.setItem(HISTORY_KEY, JSON.stringify(updated));
|
|
setHistory(updated);
|
|
}}
|
|
>
|
|
✕
|
|
</button>
|
|
</div>
|
|
);
|
|
})}
|
|
</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>
|
|
);
|
|
}
|