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

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>
);
}