yuki / src /modules /comic /ComicReader.tsx
OhMyDitzzy
anything
744336f
import { useEffect, useState, useRef } from 'react';
import { useParams, useNavigate, useLocation } from 'react-router-dom';
import './ComicReader.css';
interface ComicImage {
index: number;
imageUrl: string;
}
interface ChapterInfo {
slug: string;
chapter: string;
}
interface ReaderData {
slug: string;
title: string;
images: ComicImage[];
totalImages: number;
prevChapter?: ChapterInfo | null;
nextChapter?: ChapterInfo | null;
allChapters?: ChapterInfo[];
}
export function ComicReader() {
const { sessionId } = useParams();
const navigate = useNavigate();
const location = useLocation();
const [data, setData] = useState<ReaderData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [currentPage, setCurrentPage] = useState(1);
const [showMenu, setShowMenu] = useState(false);
const [showControls, setShowControls] = useState(true);
const [showPageJump, setShowPageJump] = useState(false);
const [jumpPage, setJumpPage] = useState('');
const [loadingChapter, setLoadingChapter] = useState(false);
const [password, setPassword] = useState<string>('');
const [originalSessionId, setOriginalSessionId] = useState<string>('');
const imageRefs = useRef<{ [key: number]: HTMLImageElement | null }>({});
const controlsTimeout = useRef<number | null>(null);
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
const wsRef = useRef<WebSocket | null>(null);
const chapterData = location.state?.chapterData;
useEffect(() => {
const statePassword = location.state?.password;
const stateSessionId = location.state?.sessionId;
if (statePassword) {
setPassword(statePassword);
}
if (stateSessionId) {
setOriginalSessionId(stateSessionId);
}
}, [location.state]);
useEffect(() => {
if (!password || !originalSessionId) return;
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/ws`;
const ws = new WebSocket(wsUrl);
let isConnected = false;
wsRef.current = ws;
ws.onopen = () => {
isConnected = true;
};
ws.onmessage = (event) => {
try {
const message = JSON.parse(event.data);
if (message.type === 'chapter_data_response') {
setLoadingChapter(false);
if (message.success && message.data) {
const newData = {
...message.data,
allChapters: message.data.allChapters || data?.allChapters || []
};
setData(newData);
setCurrentPage(1);
scrollContainerRef.current?.scrollTo({ top: 0 });
} else {
setError(message.error || 'Failed to load chapter');
setTimeout(() => setError(''), 3000);
}
}
if (message.type === 'session_force_closed') {
setError('Session dihapus oleh admin. Silakan buat session baru.');
sessionStorage.removeItem(`comic_${sessionId}`);
setTimeout(() => {
navigate('/');
}, 3000);
}
} catch (err) {
console.error('[Reader WS] Parse error:', err);
}
};
ws.onerror = (err) => {
console.error('[Reader WS] Error:', err);
setLoadingChapter(false);
if (!isConnected) {
setError('Failed to connect to server');
}
};
ws.onclose = () => {
wsRef.current = null;
};
return () => {
if (ws.readyState === WebSocket.OPEN) {
ws.close();
}
};
}, [password, originalSessionId]);
useEffect(() => {
if (chapterData) {
setData(chapterData);
setLoading(false);
return;
}
if (!password || !originalSessionId) {
setError('Unauthorized access - please open from comic page');
setLoading(false);
return;
}
setError('Please open chapter from comic page');
setLoading(false);
}, [sessionId, password, chapterData, originalSessionId]);
useEffect(() => {
const resetTimeout = () => {
if (controlsTimeout.current) {
clearTimeout(controlsTimeout.current);
}
setShowControls(true);
controlsTimeout.current = window.setTimeout(() => {
setShowControls(false);
setShowMenu(false);
}, 3000);
};
const handleMove = () => resetTimeout();
window.addEventListener('mousemove', handleMove);
window.addEventListener('touchstart', handleMove);
return () => {
window.removeEventListener('mousemove', handleMove);
window.removeEventListener('touchstart', handleMove);
if (controlsTimeout.current) {
clearTimeout(controlsTimeout.current);
}
};
}, []);
useEffect(() => {
const handleScroll = () => {
if (!scrollContainerRef.current) return;
const container = scrollContainerRef.current;
const windowHeight = container.clientHeight;
let currentIdx = 1;
for (let i = 1; i <= (data?.totalImages || 0); i++) {
const img = imageRefs.current[i];
if (img) {
const rect = img.getBoundingClientRect();
const containerRect = container.getBoundingClientRect();
const imgCenter = rect.top + rect.height / 2 - containerRect.top;
if (imgCenter > 0 && imgCenter < windowHeight) {
currentIdx = i;
break;
}
}
}
setCurrentPage(currentIdx);
};
const container = scrollContainerRef.current;
if (container) {
container.addEventListener('scroll', handleScroll);
return () => container.removeEventListener('scroll', handleScroll);
}
}, [data]);
const scrollToPage = (page: number) => {
const img = imageRefs.current[page];
if (img && scrollContainerRef.current) {
const container = scrollContainerRef.current;
const containerRect = container.getBoundingClientRect();
const imgRect = img.getBoundingClientRect();
const scrollTo = container.scrollTop + (imgRect.top - containerRect.top);
container.scrollTo({ top: scrollTo, behavior: 'smooth' });
}
};
const nextChapter = () => {
if (data?.nextChapter) {
handleChapterNavigation(data.nextChapter.slug);
}
};
const prevChapter = () => {
if (data?.prevChapter) {
handleChapterNavigation(data.prevChapter.slug);
}
};
const handlePageJump = () => {
const page = parseInt(jumpPage);
if (page >= 1 && page <= (data?.totalImages || 1)) {
scrollToPage(page);
setShowPageJump(false);
setJumpPage('');
}
};
const handleChapterNavigation = (chapterSlug: string) => {
if (!password || !originalSessionId) {
setError('Missing authentication data');
return;
}
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) {
if (wsRef.current?.readyState === WebSocket.CONNECTING) {
setTimeout(() => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
handleChapterNavigation(chapterSlug);
} else {
setError('Connection not ready. Please try again.');
}
}, 1000);
return;
}
setError('Connection lost. Please refresh the page.');
console.error('[Reader] WebSocket not connected. State:', wsRef.current?.readyState);
return;
}
setLoadingChapter(true);
setShowMenu(false);
const requestData = {
type: 'request_chapter_data',
sessionId: originalSessionId,
password: password,
chapterSlug: chapterSlug,
allChapters: data?.allChapters || []
};
wsRef.current.send(JSON.stringify(requestData));
};
if (loading) {
return (
<div className="reader-loading">
<div className="spinner"></div>
<p>Loading chapter...</p>
</div>
);
}
if (loadingChapter) {
return (
<div className="reader-loading">
<div className="spinner"></div>
<p>Loading chapter...</p>
</div>
);
}
if (error || !data) {
return (
<div className="reader-error">
<div className="error-icon">⚠️</div>
<h2>Error</h2>
<p>{error || 'Failed to load chapter'}</p>
<button onClick={() => navigate(-1)}>Go Back</button>
</div>
);
}
return (
<div className={`comic-reader ${!showControls ? 'hide-controls' : ''}`}>
<div className="reader-header">
<button className="back-btn" onClick={() => navigate(-1)}>
← Back
</button>
<div className="reader-title">
<h1>{data.title}</h1>
<span className="page-indicator">
{currentPage} / {data.totalImages}
</span>
</div>
<button
className="menu-btn"
onClick={() => setShowMenu(!showMenu)}
>
</button>
</div>
{showMenu && (
<div className="hamburger-menu" onClick={() => setShowMenu(false)}>
<div className="menu-content" onClick={(e) => e.stopPropagation()}>
<button onClick={() => setShowMenu(false)} className="close-menu">✕</button>
<div className="menu-section">
<h3>Navigation</h3>
<button onClick={() => { scrollToPage(1); setShowMenu(false); }}>
⏮️ First Page
</button>
<button onClick={() => { scrollToPage(data.totalImages); setShowMenu(false); }}>
⏭️ Last Page
</button>
<button onClick={() => { setShowPageJump(true); setShowMenu(false); }}>
📍 Jump to Page
</button>
</div>
{(data.prevChapter || data.nextChapter) && (
<div className="menu-section">
<h3>Chapters</h3>
{data.prevChapter && (
<button onClick={() => handleChapterNavigation(data.prevChapter!.slug)}>
⬅️ {data.prevChapter.chapter}
</button>
)}
{data.nextChapter && (
<button onClick={() => handleChapterNavigation(data.nextChapter!.slug)}>
➡️ {data.nextChapter.chapter}
</button>
)}
</div>
)}
<div className="menu-section">
<h3>Info</h3>
<div className="menu-info">
<p>Total Pages: {data.totalImages}</p>
<p>Current: Page {currentPage}</p>
</div>
</div>
</div>
</div>
)}
{showPageJump && (
<div className="page-jump-modal" onClick={() => setShowPageJump(false)}>
<div className="page-jump-content" onClick={(e) => e.stopPropagation()}>
<h3>Jump to Page</h3>
<input
type="number"
min="1"
max={data.totalImages}
value={jumpPage}
onChange={(e) => setJumpPage(e.target.value)}
placeholder={`1-${data.totalImages}`}
autoFocus
onKeyPress={(e) => e.key === 'Enter' && handlePageJump()}
/>
<div className="page-jump-actions">
<button onClick={handlePageJump}>Go</button>
<button onClick={() => setShowPageJump(false)}>Cancel</button>
</div>
</div>
</div>
)}
<div className="reader-content" ref={scrollContainerRef}>
<div className="scroll-container">
{data.images.map((img) => (
<img
key={img.index}
ref={(el) => (imageRefs.current[img.index] = el)}
src={img.imageUrl}
alt={`Page ${img.index}`}
className="comic-page"
loading="lazy"
onError={(e) => {
(e.target as HTMLImageElement).src = 'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="800" height="1200"><rect width="800" height="1200" fill="%23222"/><text x="50%" y="50%" text-anchor="middle" fill="%23666" font-size="20">Failed to load page ' + img.index + '</text></svg>';
}}
/>
))}
</div>
</div>
<div className="reader-footer">
<div className="page-controls">
<button
className="nav-control-btn"
onClick={prevChapter}
disabled={!data.prevChapter}
>
← Prev
</button>
<div className="page-info">
<span>{currentPage} / {data.totalImages}</span>
<input
type="range"
min="1"
max={data.totalImages}
value={currentPage}
onChange={(e) => scrollToPage(parseInt(e.target.value))}
className="page-slider"
/>
</div>
<button
className="nav-control-btn"
onClick={nextChapter}
disabled={!data.nextChapter}
>
Next →
</button>
</div>
</div>
</div>
);
}