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