| import { useEffect, useState } from 'react'; | |
| import { useParams, useNavigate } from 'react-router-dom'; | |
| import './ComicLanding.css'; | |
| interface Chapter { | |
| slug: string; | |
| chapter: string; | |
| date: string; | |
| views: string; | |
| } | |
| interface ComicData { | |
| slug: string; | |
| title: string; | |
| indonesiaTitle: string; | |
| type: string; | |
| author: string; | |
| status: string; | |
| genre: string[]; | |
| synopsis: string; | |
| thumbnailUrl: string; | |
| chapters: Chapter[]; | |
| } | |
| export function ComicLanding() { | |
| const { sessionId } = useParams(); | |
| const navigate = useNavigate(); | |
| const [isAuth, setIsAuth] = useState(false); | |
| const [password, setPassword] = useState(''); | |
| const [comicData, setComicData] = useState<ComicData | null>(null); | |
| const [loading, setLoading] = useState(true); | |
| const [error, setError] = useState(''); | |
| const [searchQuery, setSearchQuery] = useState(''); | |
| const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc'); | |
| const [loadingChapter, setLoadingChapter] = useState(false); | |
| const [isSynopsisExpanded, setIsSynopsisExpanded] = useState(false); | |
| const [isChaptersExpanded, setIsChaptersExpanded] = useState(false); | |
| const [ws, setWs] = useState<WebSocket | null>(null); | |
| const SYNOPSIS_CHAR_LIMIT = 200; | |
| const CHAPTERS_INITIAL_DISPLAY = 20; | |
| useEffect(() => { | |
| const storedSession = sessionStorage.getItem(`comic_${sessionId}`); | |
| if (storedSession) { | |
| const session = JSON.parse(storedSession); | |
| const now = Date.now(); | |
| if (now < session.expiresAt) { | |
| setIsAuth(true); | |
| setPassword(session.password); | |
| setLoading(false); | |
| return; | |
| } else { | |
| sessionStorage.removeItem(`comic_${sessionId}`); | |
| } | |
| } | |
| const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; | |
| const wsUrl = `${protocol}//${window.location.host}/ws`; | |
| const websocket = new WebSocket(wsUrl); | |
| websocket.onopen = () => { | |
| websocket.send(JSON.stringify({ | |
| type: 'validate_comic_password', | |
| sessionId | |
| })); | |
| }; | |
| websocket.onmessage = (event) => { | |
| const message = JSON.parse(event.data); | |
| if (message.type === 'password_validation_response') { | |
| if (!message.success) { | |
| setError(message.error || 'Session tidak valid'); | |
| setLoading(false); | |
| } else { | |
| setLoading(false); | |
| } | |
| } | |
| if (message.type === 'session_force_closed') { | |
| setError('Session dihapus oleh admin. Silakan buat session baru.'); | |
| sessionStorage.removeItem(`comic_${sessionId}`); | |
| setTimeout(() => { | |
| navigate('/'); | |
| }, 3000); | |
| } | |
| if (message.type === 'comic_data_response') { | |
| if (message.success) { | |
| setComicData(message.data); | |
| setIsAuth(true); | |
| setPassword((currentPassword) => { | |
| sessionStorage.setItem(`comic_${sessionId}`, JSON.stringify({ | |
| password: currentPassword, | |
| expiresAt: message.expiresAt | |
| })); | |
| return currentPassword; | |
| }); | |
| setLoadingChapter(false); | |
| } else { | |
| setError(message.error || 'Gagal memuat data'); | |
| setLoadingChapter(false); | |
| } | |
| } | |
| if (message.type === 'chapter_data_response') { | |
| setLoadingChapter(false); | |
| if (message.success) { | |
| setPassword((currentPassword) => { | |
| navigate(`/read/${sessionId}_chapter`, { | |
| state: { | |
| chapterData: message.data, | |
| password: currentPassword, | |
| sessionId: sessionId | |
| } | |
| }); | |
| return currentPassword; | |
| }); | |
| } else { | |
| setError(message.error || 'Gagal memuat chapter'); | |
| setTimeout(() => setError(''), 3000); | |
| } | |
| } | |
| }; | |
| websocket.onerror = () => { | |
| setError('Koneksi WebSocket gagal'); | |
| setLoading(false); | |
| }; | |
| setWs(websocket); | |
| return () => { | |
| if (websocket.readyState === WebSocket.OPEN || | |
| websocket.readyState === WebSocket.CONNECTING) { | |
| websocket.close(); | |
| } | |
| }; | |
| }, [sessionId, navigate]); | |
| useEffect(() => { | |
| const storedSession = sessionStorage.getItem(`comic_${sessionId}`); | |
| if (isAuth && !comicData && password) { | |
| if (ws && ws.readyState === WebSocket.OPEN) { | |
| ws.send(JSON.stringify({ | |
| type: 'request_comic_data', | |
| sessionId, | |
| password | |
| })); | |
| } else if (!ws && storedSession) { | |
| const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; | |
| const wsUrl = `${protocol}//${window.location.host}/ws`; | |
| const websocket = new WebSocket(wsUrl); | |
| websocket.onopen = () => { | |
| websocket.send(JSON.stringify({ | |
| type: 'request_comic_data', | |
| sessionId, | |
| password | |
| })); | |
| }; | |
| websocket.onmessage = (event) => { | |
| const message = JSON.parse(event.data); | |
| if (message.type === 'comic_data_response') { | |
| if (message.success) { | |
| setComicData(message.data); | |
| } else { | |
| setError(message.error || 'Gagal memuat data'); | |
| } | |
| } | |
| }; | |
| setWs(websocket); | |
| } | |
| } | |
| }, [isAuth, ws, comicData, sessionId, password]); | |
| const handlePasswordSubmit = (e: React.FormEvent) => { | |
| e.preventDefault(); | |
| if (!password.trim()) { | |
| setError('Password tidak boleh kosong'); | |
| return; | |
| } | |
| if (ws) { | |
| ws.send(JSON.stringify({ | |
| type: 'request_comic_data', | |
| sessionId, | |
| password: password.trim() | |
| })); | |
| } | |
| }; | |
| const handleReadChapter = (chapterSlug: string) => { | |
| if (!ws) { | |
| setError('WebSocket tidak terhubung'); | |
| return; | |
| } | |
| if (!password) { | |
| setError('Password tidak tersedia'); | |
| return; | |
| } | |
| setLoadingChapter(true); | |
| ws.send(JSON.stringify({ | |
| type: 'request_chapter_data', | |
| sessionId, | |
| password, | |
| chapterSlug | |
| })); | |
| }; | |
| const filteredChapters = comicData?.chapters.filter(ch => | |
| ch.chapter.toLowerCase().includes(searchQuery.toLowerCase()) | |
| ) || []; | |
| const sortedChapters = [...filteredChapters].sort((a, b) => { | |
| const numA = parseFloat(a.chapter.match(/[\d.]+/)?.[0] || '0'); | |
| const numB = parseFloat(b.chapter.match(/[\d.]+/)?.[0] || '0'); | |
| return sortOrder === 'asc' ? numA - numB : numB - numA; | |
| }); | |
| const shouldTruncateSynopsis = comicData && comicData.synopsis.length > SYNOPSIS_CHAR_LIMIT; | |
| const displayedSynopsis = comicData && shouldTruncateSynopsis && !isSynopsisExpanded | |
| ? comicData.synopsis.slice(0, SYNOPSIS_CHAR_LIMIT) + '...' | |
| : comicData?.synopsis; | |
| const shouldShowMoreChapters = sortedChapters.length > CHAPTERS_INITIAL_DISPLAY; | |
| const displayedChapters = isChaptersExpanded || searchQuery | |
| ? sortedChapters | |
| : sortedChapters.slice(0, CHAPTERS_INITIAL_DISPLAY); | |
| if (loading) { | |
| return ( | |
| <div className="comic-landing"> | |
| <div className="loading-container"> | |
| <div className="spinner"></div> | |
| <p>Loading...</p> | |
| </div> | |
| </div> | |
| ); | |
| } | |
| if (error && !isAuth) { | |
| return ( | |
| <div className="comic-landing"> | |
| <div className="error-container"> | |
| <div className="error-icon">⚠️</div> | |
| <h2>Oops!</h2> | |
| <p>{error}</p> | |
| <button onClick={() => navigate('/')}>Kembali ke Home</button> | |
| </div> | |
| </div> | |
| ); | |
| } | |
| if (!isAuth) { | |
| return ( | |
| <div className="comic-landing"> | |
| <div className="auth-container"> | |
| <div className="auth-card"> | |
| <div className="auth-header"> | |
| <div className="lock-icon">🔒</div> | |
| <h2>Protected Content</h2> | |
| <p>Masukkan password untuk melanjutkan</p> | |
| </div> | |
| <form onSubmit={handlePasswordSubmit} className="auth-form"> | |
| <div className="input-group"> | |
| <input | |
| type="password" | |
| placeholder="Masukkan password" | |
| value={password} | |
| onChange={(e) => setPassword(e.target.value)} | |
| className="password-input" | |
| autoFocus | |
| /> | |
| </div> | |
| {error && <div className="error-message">{error}</div>} | |
| <button type="submit" className="submit-btn"> | |
| Unlock | |
| </button> | |
| </form> | |
| <div className="auth-footer"> | |
| <p>Password adalah yang kamu set di bot WhatsApp</p> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| } | |
| if (!comicData) { | |
| return ( | |
| <div className="comic-landing"> | |
| <div className="loading-container"> | |
| <div className="spinner"></div> | |
| <p>Memuat data comic...</p> | |
| </div> | |
| </div> | |
| ); | |
| } | |
| return ( | |
| <div className="comic-landing"> | |
| {loadingChapter && ( | |
| <div className="loading-overlay"> | |
| <div className="loading-container"> | |
| <div className="spinner"></div> | |
| <p>Loading chapter...</p> | |
| </div> | |
| </div> | |
| )} | |
| {error && isAuth && ( | |
| <div className="error-toast"> | |
| {error} | |
| </div> | |
| )} | |
| <div className="hero-section"> | |
| <div className="hero-backdrop" style={{ backgroundImage: `url(${comicData.thumbnailUrl})` }}></div> | |
| <div className="hero-content"> | |
| <div className="hero-thumbnail"> | |
| <img src={comicData.thumbnailUrl} alt={comicData.title} /> | |
| <div className="type-badge">{comicData.type}</div> | |
| </div> | |
| <div className="hero-info"> | |
| <h1 className="comic-title">{comicData.title}</h1> | |
| {comicData.indonesiaTitle && ( | |
| <h2 className="comic-subtitle">{comicData.indonesiaTitle}</h2> | |
| )} | |
| <div className="meta-info"> | |
| <div className="meta-item"> | |
| <span className="meta-label">Author:</span> | |
| <span className="meta-value">{comicData.author}</span> | |
| </div> | |
| <div className="meta-item"> | |
| <span className="meta-label">Status:</span> | |
| <span className={`meta-value status-${comicData.status.toLowerCase()}`}> | |
| {comicData.status} | |
| </span> | |
| </div> | |
| <div className="meta-item"> | |
| <span className="meta-label">Total Chapters:</span> | |
| <span className="meta-value">{comicData.chapters.length}</span> | |
| </div> | |
| </div> | |
| <div className="genre-tags"> | |
| {comicData.genre.map((g, i) => ( | |
| <span key={i} className="genre-tag">{g}</span> | |
| ))} | |
| </div> | |
| <div className="synopsis"> | |
| <h3>Synopsis</h3> | |
| <div className="synopsis-content"> | |
| <p>{displayedSynopsis}</p> | |
| {shouldTruncateSynopsis && ( | |
| <button | |
| className="expand-btn" | |
| onClick={() => setIsSynopsisExpanded(!isSynopsisExpanded)} | |
| > | |
| {isSynopsisExpanded ? 'Show Less' : 'Read More'} | |
| </button> | |
| )} | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <div className="chapters-section"> | |
| <div className="chapters-header"> | |
| <h2>Chapters</h2> | |
| <div className="chapters-controls"> | |
| <div className="search-box"> | |
| <input | |
| type="text" | |
| placeholder="Search chapters..." | |
| value={searchQuery} | |
| onChange={(e) => setSearchQuery(e.target.value)} | |
| /> | |
| </div> | |
| <button | |
| className="sort-btn" | |
| onClick={() => setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc')} | |
| > | |
| {sortOrder === 'asc' ? '↑ Oldest First' : '↓ Latest First'} | |
| </button> | |
| </div> | |
| </div> | |
| <div className="chapters-list-wrapper"> | |
| <div className={`chapters-list ${!isChaptersExpanded && shouldShowMoreChapters ? 'has-fade' : ''}`}> | |
| {displayedChapters.length === 0 ? ( | |
| <div className="no-results"> | |
| <p>No chapters found</p> | |
| </div> | |
| ) : ( | |
| displayedChapters.map((chapter, index) => ( | |
| <div | |
| key={index} | |
| className="chapter-item" | |
| onClick={() => handleReadChapter(chapter.slug)} | |
| > | |
| <div className="chapter-info"> | |
| <div className="chapter-number">{chapter.chapter}</div> | |
| <div className="chapter-meta"> | |
| <span className="chapter-date">{chapter.date}</span> | |
| <span className="chapter-views">{chapter.views}</span> | |
| </div> | |
| </div> | |
| <div className="chapter-action"> | |
| <span className="read-btn">Read →</span> | |
| </div> | |
| </div> | |
| )) | |
| )} | |
| </div> | |
| {shouldShowMoreChapters && !searchQuery && ( | |
| <div className="show-more-container"> | |
| <button | |
| className="expand-btn show-more-chapters" | |
| onClick={() => setIsChaptersExpanded(!isChaptersExpanded)} | |
| > | |
| {isChaptersExpanded | |
| ? 'Show Less' | |
| : `Show More (${sortedChapters.length - CHAPTERS_INITIAL_DISPLAY} more chapters)` | |
| } | |
| </button> | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| } |