yuki / src /modules /comic /ComicLanding.tsx
OhMyDitzzy
anything
744336f
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>
);
}