index.tsx
1 import React, { useState, useEffect, useCallback } from 'react'; 2 import { 3 StyleSheet, 4 View, 5 Text, 6 TouchableOpacity, 7 FlatList, 8 ScrollView, 9 Image, 10 Dimensions, 11 } from 'react-native'; 12 import { useRouter } from 'expo-router'; 13 import { useFocusEffect } from '@react-navigation/native'; 14 import { useTheme } from '@/constants/ThemeContext'; 15 import { Ionicons, MaterialCommunityIcons } from '@expo/vector-icons'; 16 import { Colors } from '@/constants/Colors'; 17 import { LinearGradient } from 'expo-linear-gradient'; 18 import { MANGA_API_URL } from '@/constants/Config'; 19 import MangaCard from '@/components/MangaCard'; 20 import { 21 RecentlyReadSkeleton, 22 TrendingSkeleton, 23 NewReleasesSkeleton, 24 FeaturedMangaSkeleton, 25 } from '@/components/SkeletonLoading'; 26 import { SmoothRefreshControl } from '@/components/SmoothRefreshControl'; 27 import { PageTransition } from '@/components/PageTransition'; 28 import { 29 parseMostViewedManga, 30 parseNewReleases, 31 } from '@/services/mangaFireService'; 32 import { useSafeAreaInsets } from 'react-native-safe-area-context'; 33 import { useCloudflareDetection } from '@/hooks/useCloudflareDetection'; 34 import axios from 'axios'; 35 import { MangaItem, RecentMangaItem } from '@/types'; 36 import { getRecentlyReadManga } from '@/services/readChapterService'; 37 38 const { width: SCREEN_WIDTH } = Dimensions.get('window'); 39 40 const TRENDING_CARD_WIDTH = 200; 41 const TRENDING_CARD_HEIGHT = 260; 42 const FEATURED_HEIGHT = 280; 43 const RECENTLY_READ_CARD_WIDTH = Math.min(160, (SCREEN_WIDTH - 64) / 2); 44 45 const DEFAULT_MANGA_COVER = 46 'https://static.mangafire.to/default/img/no-image.jpg'; 47 48 export default function HomeScreen() { 49 const router = useRouter(); 50 const { actualTheme, accentColor } = useTheme(); 51 const colors = Colors[actualTheme]; 52 const themeColors = { ...colors, primary: accentColor || colors.primary }; 53 const { checkForCloudflare, resetCloudflareDetection } = 54 useCloudflareDetection(); 55 const insets = useSafeAreaInsets(); 56 57 const [mostViewedManga, setMostViewedManga] = useState<MangaItem[]>([]); 58 const [newReleases, setNewReleases] = useState<MangaItem[]>([]); 59 const [isLoading, setIsLoading] = useState<boolean>(true); 60 const [isRefreshing, setIsRefreshing] = useState<boolean>(false); 61 const [error, setError] = useState<string | null>(null); 62 const [featuredManga, setFeaturedManga] = useState<MangaItem | null>(null); 63 64 const [recentlyReadManga, setRecentlyReadManga] = useState<RecentMangaItem[]>( 65 [] 66 ); 67 const [isRecentMangaLoading, setIsRecentMangaLoading] = 68 useState<boolean>(true); 69 70 const fetchMangaData = useCallback(async () => { 71 try { 72 setError(null); 73 if (!isRefreshing) { 74 setIsLoading(true); 75 } 76 77 const response = await axios.get(`${MANGA_API_URL}/home`, { 78 headers: { 79 'User-Agent': 80 'Mozilla/5.0 (iPhone; CPU iPhone OS 14_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.0 Mobile/15E148 Safari/604.1', 81 }, 82 timeout: 10000, 83 }); 84 85 const html = response.data as string; 86 87 if (checkForCloudflare(html)) { 88 return; 89 } 90 91 const parsedMostViewed = parseMostViewedManga(html); 92 const parsedNewReleases = parseNewReleases(html); 93 94 setMostViewedManga(parsedMostViewed); 95 setNewReleases(parsedNewReleases); 96 97 if (parsedMostViewed.length > 0) { 98 setFeaturedManga(parsedMostViewed[0] || null); 99 } 100 } catch (error) { 101 console.error('Error fetching manga data:', error); 102 setError( 103 'An error occurred while fetching manga data. Please try again.' 104 ); 105 } finally { 106 setIsLoading(false); 107 setIsRefreshing(false); 108 } 109 }, [isRefreshing, checkForCloudflare]); 110 111 const fetchRecentlyReadManga = useCallback(async () => { 112 try { 113 setIsRecentMangaLoading(true); 114 const recentManga = await getRecentlyReadManga(6); 115 116 const processedManga = recentManga.map((manga) => ({ 117 ...manga, 118 bannerImage: manga.bannerImage || DEFAULT_MANGA_COVER, 119 })); 120 121 setRecentlyReadManga(processedManga); 122 } catch (error) { 123 console.error('Error fetching recently read manga:', error); 124 } finally { 125 setIsRecentMangaLoading(false); 126 } 127 }, []); 128 129 useEffect(() => { 130 fetchMangaData(); 131 fetchRecentlyReadManga(); 132 return () => { 133 resetCloudflareDetection(); 134 }; 135 }, []); 136 137 useFocusEffect( 138 useCallback(() => { 139 fetchRecentlyReadManga(); 140 }, [fetchRecentlyReadManga]) 141 ); 142 143 const handleRefresh = useCallback(() => { 144 setIsRefreshing(true); 145 fetchMangaData(); 146 fetchRecentlyReadManga(); 147 }, [fetchMangaData, fetchRecentlyReadManga]); 148 149 const renderSectionTitle = useCallback( 150 (title: string, iconName: keyof typeof Ionicons.glyphMap) => ( 151 <View style={styles.sectionTitleContainer}> 152 <View 153 style={[ 154 styles.iconBackground, 155 { backgroundColor: themeColors.primary + '20' }, 156 ]} 157 > 158 <Ionicons 159 name={iconName} 160 size={20} 161 color={themeColors.primary} 162 accessibilityElementsHidden={true} 163 /> 164 </View> 165 <Text 166 style={[styles.sectionTitle, { color: themeColors.text }]} 167 accessibilityRole="header" 168 > 169 {title} 170 </Text> 171 </View> 172 ), 173 [themeColors] 174 ); 175 176 const renderTrendingItem = useCallback( 177 ({ item, index }: { item: MangaItem; index: number }) => ( 178 <TouchableOpacity 179 style={[styles.trendingItem, { marginLeft: index === 0 ? 16 : 12 }]} 180 onPress={() => router.navigate(`/manga/${item.id}`)} 181 activeOpacity={0.7} 182 accessibilityRole="button" 183 accessibilityLabel={`View ${item.title}`} 184 accessibilityHint={ 185 item.rank 186 ? `Ranked #${item.rank} in trending` 187 : 'Currently trending manga' 188 } 189 > 190 <Image 191 source={{ uri: item.imageUrl }} 192 style={styles.trendingImage} 193 accessibilityLabel={`Cover image for ${item.title}`} 194 /> 195 <LinearGradient 196 colors={['transparent', 'rgba(0,0,0,0.9)']} 197 style={styles.trendingGradient} 198 > 199 <View style={styles.trendingContent}> 200 <Text style={styles.trendingTitle} numberOfLines={2}> 201 {item.title} 202 </Text> 203 {item.rank && ( 204 <View 205 style={[ 206 styles.rankBadge, 207 { backgroundColor: themeColors.primary }, 208 ]} 209 > 210 <Text style={styles.rankText}>#{item.rank}</Text> 211 </View> 212 )} 213 </View> 214 </LinearGradient> 215 </TouchableOpacity> 216 ), 217 [router, themeColors.primary] 218 ); 219 220 const renderRecentlyReadItem = useCallback( 221 ({ item, index }: { item: RecentMangaItem; index: number }) => { 222 const lastReadChapter = item.lastReadChapter 223 ? `Chapter ${item.lastReadChapter}` 224 : 'Not started'; 225 226 return ( 227 <View 228 style={[ 229 styles.recentlyReadItem, 230 { marginLeft: index === 0 ? 16 : 12 }, 231 ]} 232 > 233 <MangaCard 234 title={item.title} 235 imageUrl={item.bannerImage} 236 onPress={() => router.navigate(`/manga/${item.id}`)} 237 lastReadChapter={lastReadChapter} 238 style={styles.recentlyReadCard} 239 context="manga" 240 mangaId={item.id} 241 /> 242 <Text 243 style={[styles.recentlyReadTitle, { color: themeColors.text }]} 244 numberOfLines={2} 245 > 246 {item.title} 247 </Text> 248 </View> 249 ); 250 }, 251 [router, themeColors.text] 252 ); 253 254 const renderNewReleaseGrid = useCallback(() => { 255 return ( 256 <View style={styles.newReleaseGrid}> 257 {newReleases.map((item) => ( 258 <View key={item.id} style={styles.newReleaseWrapper}> 259 <TouchableOpacity 260 onPress={() => router.navigate(`/manga/${item.id}`)} 261 activeOpacity={0.7} 262 style={styles.newReleaseCard} 263 > 264 <MangaCard 265 title={item.title} 266 imageUrl={item.imageUrl} 267 onPress={() => router.navigate(`/manga/${item.id}`)} 268 lastReadChapter={null} 269 style={styles.card} 270 context="manga" 271 mangaId={item.id} 272 /> 273 <View style={styles.titleContainer}> 274 <Text 275 style={[styles.mangaTitle, { color: themeColors.text }]} 276 numberOfLines={2} 277 > 278 {item.title} 279 </Text> 280 </View> 281 </TouchableOpacity> 282 </View> 283 ))} 284 </View> 285 ); 286 }, [newReleases, router, themeColors.text]); 287 288 const renderContinueReadingSection = useCallback(() => { 289 if (isRecentMangaLoading) { 290 return <RecentlyReadSkeleton />; 291 } 292 293 if (recentlyReadManga.length === 0) { 294 return ( 295 <View 296 style={[ 297 styles.emptyStateContainer, 298 { backgroundColor: themeColors.card + '50' }, 299 ]} 300 > 301 <Ionicons 302 name="book-outline" 303 size={40} 304 color={themeColors.text + '70'} 305 /> 306 <Text 307 style={[styles.emptyStateText, { color: themeColors.text + '90' }]} 308 > 309 Manga you're reading will appear here 310 </Text> 311 <TouchableOpacity 312 style={[ 313 styles.browseButton, 314 { backgroundColor: themeColors.primary + '20' }, 315 ]} 316 onPress={() => router.navigate('/mangasearch')} 317 > 318 <Text 319 style={[styles.browseButtonText, { color: themeColors.primary }]} 320 > 321 Browse Manga 322 </Text> 323 </TouchableOpacity> 324 </View> 325 ); 326 } 327 328 return ( 329 <FlatList 330 data={recentlyReadManga} 331 renderItem={renderRecentlyReadItem} 332 keyExtractor={(item) => item.id} 333 horizontal 334 showsHorizontalScrollIndicator={false} 335 contentContainerStyle={styles.recentlyReadList} 336 decelerationRate="fast" 337 getItemLayout={(_data, index) => ({ 338 length: RECENTLY_READ_CARD_WIDTH + 12, 339 offset: (RECENTLY_READ_CARD_WIDTH + 12) * index, 340 index, 341 })} 342 removeClippedSubviews={true} 343 maxToRenderPerBatch={3} 344 updateCellsBatchingPeriod={100} 345 windowSize={8} 346 /> 347 ); 348 }, [ 349 isRecentMangaLoading, 350 recentlyReadManga, 351 themeColors, 352 router, 353 renderRecentlyReadItem, 354 ]); 355 356 const renderFeaturedManga = useCallback(() => { 357 if (!featuredManga) return null; 358 359 return ( 360 <TouchableOpacity 361 style={[styles.featuredContainer, { marginTop: insets.top + 16 }]} 362 onPress={() => router.navigate(`/manga/${featuredManga.id}`)} 363 activeOpacity={0.8} 364 > 365 <Image 366 source={{ uri: featuredManga.imageUrl }} 367 style={styles.featuredImage} 368 /> 369 <LinearGradient 370 colors={['transparent', 'rgba(0,0,0,0.8)']} 371 style={styles.featuredGradient} 372 > 373 <View style={styles.featuredContent}> 374 <View style={styles.featuredBadge}> 375 <MaterialCommunityIcons name="fire" size={16} color="#FFF" /> 376 <Text style={styles.featuredBadgeText}>Featured</Text> 377 </View> 378 <Text style={styles.featuredTitle} numberOfLines={2}> 379 {featuredManga.title} 380 </Text> 381 <TouchableOpacity 382 style={[ 383 styles.readNowButton, 384 { backgroundColor: themeColors.primary }, 385 ]} 386 onPress={() => router.navigate(`/manga/${featuredManga.id}`)} 387 > 388 <Text style={styles.readNowText}>Read Now</Text> 389 </TouchableOpacity> 390 </View> 391 </LinearGradient> 392 </TouchableOpacity> 393 ); 394 }, [featuredManga, insets.top, router, themeColors.primary]); 395 396 if (isLoading) { 397 return ( 398 <View 399 style={[styles.container, { backgroundColor: themeColors.background }]} 400 > 401 <ScrollView 402 showsVerticalScrollIndicator={false} 403 contentContainerStyle={[styles.content]} 404 > 405 <FeaturedMangaSkeleton /> 406 407 <View style={styles.section}> 408 {renderSectionTitle('Continue Reading', 'book')} 409 <RecentlyReadSkeleton /> 410 </View> 411 412 <View style={styles.section}> 413 {renderSectionTitle('Trending Now', 'trophy')} 414 <TrendingSkeleton /> 415 </View> 416 417 <View style={styles.section}> 418 {renderSectionTitle('New Releases', 'sparkles')} 419 <NewReleasesSkeleton /> 420 </View> 421 </ScrollView> 422 </View> 423 ); 424 } 425 426 return ( 427 <View 428 style={[styles.container, { backgroundColor: themeColors.background }]} 429 > 430 <ScrollView 431 showsVerticalScrollIndicator={false} 432 contentContainerStyle={[styles.content]} 433 refreshControl={ 434 <SmoothRefreshControl 435 refreshing={isRefreshing} 436 onRefresh={handleRefresh} 437 /> 438 } 439 > 440 {error ? ( 441 <View 442 style={[styles.errorContainer, { paddingTop: insets.top + 20 }]} 443 > 444 <Ionicons 445 name="alert-circle-outline" 446 size={48} 447 color={themeColors.notification} 448 /> 449 <Text 450 style={[styles.errorText, { color: themeColors.notification }]} 451 > 452 {error} 453 </Text> 454 <TouchableOpacity 455 style={[ 456 styles.retryButton, 457 { backgroundColor: themeColors.primary }, 458 ]} 459 onPress={fetchMangaData} 460 > 461 <Text style={styles.retryButtonText}>Retry</Text> 462 </TouchableOpacity> 463 </View> 464 ) : ( 465 <> 466 <PageTransition transitionType="fade" duration={400}> 467 {renderFeaturedManga()} 468 </PageTransition> 469 470 <PageTransition transitionType="slide" duration={400} delay={100}> 471 <View style={styles.section}> 472 {renderSectionTitle('Continue Reading', 'book')} 473 {renderContinueReadingSection()} 474 </View> 475 </PageTransition> 476 477 <PageTransition transitionType="slide" duration={400} delay={200}> 478 <View style={styles.section}> 479 {renderSectionTitle('Trending Now', 'trophy')} 480 <FlatList 481 data={mostViewedManga.slice(1)} 482 renderItem={renderTrendingItem} 483 keyExtractor={(item) => item.id} 484 horizontal 485 showsHorizontalScrollIndicator={false} 486 contentContainerStyle={styles.trendingList} 487 decelerationRate="fast" 488 snapToInterval={TRENDING_CARD_WIDTH + 12} 489 snapToAlignment="start" 490 getItemLayout={(_data, index) => ({ 491 length: TRENDING_CARD_WIDTH + 12, 492 offset: (TRENDING_CARD_WIDTH + 12) * index, 493 index, 494 })} 495 removeClippedSubviews={true} 496 maxToRenderPerBatch={5} 497 updateCellsBatchingPeriod={100} 498 windowSize={10} 499 /> 500 </View> 501 </PageTransition> 502 503 <PageTransition transitionType="slide" duration={400} delay={300}> 504 <View style={styles.section}> 505 {renderSectionTitle('New Releases', 'sparkles')} 506 {renderNewReleaseGrid()} 507 </View> 508 </PageTransition> 509 </> 510 )} 511 </ScrollView> 512 </View> 513 ); 514 } 515 516 const styles = StyleSheet.create({ 517 container: { 518 flex: 1, 519 }, 520 content: { 521 paddingBottom: 100, 522 }, 523 section: { 524 marginBottom: 24, 525 }, 526 sectionTitleContainer: { 527 flexDirection: 'row', 528 alignItems: 'center', 529 paddingHorizontal: 16, 530 marginBottom: 16, 531 }, 532 iconBackground: { 533 width: 36, 534 height: 36, 535 borderRadius: 18, 536 alignItems: 'center', 537 justifyContent: 'center', 538 marginRight: 12, 539 }, 540 sectionTitle: { 541 fontSize: 20, 542 fontWeight: 'bold', 543 }, 544 featuredContainer: { 545 height: FEATURED_HEIGHT, 546 marginHorizontal: 16, 547 marginBottom: 24, 548 borderRadius: 16, 549 overflow: 'hidden', 550 shadowColor: '#000', 551 shadowOffset: { width: 0, height: 4 }, 552 shadowOpacity: 0.2, 553 shadowRadius: 8, 554 elevation: 8, 555 }, 556 featuredImage: { 557 width: '100%', 558 height: '100%', 559 resizeMode: 'cover', 560 }, 561 featuredGradient: { 562 position: 'absolute', 563 bottom: 0, 564 left: 0, 565 right: 0, 566 height: '70%', 567 justifyContent: 'flex-end', 568 padding: 16, 569 }, 570 featuredContent: { 571 alignItems: 'flex-start', 572 }, 573 featuredBadge: { 574 flexDirection: 'row', 575 alignItems: 'center', 576 backgroundColor: 'rgba(255, 59, 48, 0.8)', 577 paddingHorizontal: 10, 578 paddingVertical: 5, 579 borderRadius: 12, 580 marginBottom: 8, 581 }, 582 featuredBadgeText: { 583 color: '#FFFFFF', 584 fontSize: 12, 585 fontWeight: 'bold', 586 marginLeft: 4, 587 }, 588 featuredTitle: { 589 color: '#FFFFFF', 590 fontSize: 22, 591 fontWeight: 'bold', 592 marginBottom: 12, 593 textShadowColor: 'rgba(0, 0, 0, 0.7)', 594 textShadowOffset: { width: 0, height: 1 }, 595 textShadowRadius: 3, 596 }, 597 readNowButton: { 598 paddingHorizontal: 20, 599 paddingVertical: 8, 600 borderRadius: 20, 601 }, 602 readNowText: { 603 color: '#FFFFFF', 604 fontSize: 14, 605 fontWeight: 'bold', 606 }, 607 trendingList: { 608 paddingRight: 16, 609 paddingBottom: 8, 610 }, 611 trendingItem: { 612 width: TRENDING_CARD_WIDTH, 613 height: TRENDING_CARD_HEIGHT, 614 borderRadius: 16, 615 overflow: 'hidden', 616 shadowColor: '#000', 617 shadowOffset: { width: 0, height: 3 }, 618 shadowOpacity: 0.15, 619 shadowRadius: 8, 620 elevation: 6, 621 }, 622 trendingImage: { 623 width: '100%', 624 height: '100%', 625 resizeMode: 'cover', 626 }, 627 trendingGradient: { 628 position: 'absolute', 629 bottom: 0, 630 left: 0, 631 right: 0, 632 height: '50%', 633 justifyContent: 'flex-end', 634 padding: 12, 635 }, 636 trendingContent: { 637 flexDirection: 'row', 638 justifyContent: 'space-between', 639 alignItems: 'flex-end', 640 }, 641 trendingTitle: { 642 flex: 1, 643 color: '#FFFFFF', 644 fontSize: 16, 645 fontWeight: 'bold', 646 marginRight: 8, 647 textShadowColor: 'rgba(0, 0, 0, 0.7)', 648 textShadowOffset: { width: 0, height: 1 }, 649 textShadowRadius: 3, 650 }, 651 rankBadge: { 652 paddingHorizontal: 10, 653 paddingVertical: 4, 654 borderRadius: 12, 655 justifyContent: 'center', 656 alignItems: 'center', 657 }, 658 rankText: { 659 color: '#FFFFFF', 660 fontSize: 12, 661 fontWeight: 'bold', 662 }, 663 newReleaseGrid: { 664 flexDirection: 'row', 665 flexWrap: 'wrap', 666 paddingHorizontal: 16, 667 }, 668 newReleaseWrapper: { 669 width: '50%', 670 padding: 8, 671 }, 672 newReleaseCard: { 673 borderRadius: 12, 674 overflow: 'hidden', 675 shadowColor: '#000', 676 shadowOffset: { width: 0, height: 2 }, 677 shadowOpacity: 0.1, 678 shadowRadius: 4, 679 elevation: 3, 680 }, 681 card: { 682 width: '100%', 683 aspectRatio: 3 / 4, 684 borderRadius: 12, 685 }, 686 titleContainer: { 687 marginTop: 8, 688 paddingHorizontal: 4, 689 paddingBottom: 4, 690 }, 691 mangaTitle: { 692 fontSize: 14, 693 fontWeight: 'bold', 694 }, 695 emptyStateContainer: { 696 marginHorizontal: 16, 697 borderRadius: 16, 698 padding: 24, 699 alignItems: 'center', 700 justifyContent: 'center', 701 }, 702 emptyStateText: { 703 fontSize: 16, 704 textAlign: 'center', 705 marginTop: 12, 706 marginBottom: 16, 707 }, 708 browseButton: { 709 paddingHorizontal: 20, 710 paddingVertical: 10, 711 borderRadius: 20, 712 }, 713 browseButtonText: { 714 fontSize: 14, 715 fontWeight: 'bold', 716 }, 717 loadingContainer: { 718 flex: 1, 719 justifyContent: 'center', 720 alignItems: 'center', 721 }, 722 loadingIndicator: { 723 marginTop: 20, 724 }, 725 loadingText: { 726 marginTop: 16, 727 fontSize: 16, 728 }, 729 errorContainer: { 730 flex: 1, 731 alignItems: 'center', 732 justifyContent: 'center', 733 padding: 20, 734 }, 735 errorText: { 736 fontSize: 16, 737 textAlign: 'center', 738 marginTop: 12, 739 marginBottom: 20, 740 }, 741 retryButton: { 742 paddingHorizontal: 30, 743 paddingVertical: 10, 744 borderRadius: 8, 745 elevation: 2, 746 }, 747 retryButtonText: { 748 color: '#FFFFFF', 749 fontSize: 16, 750 fontWeight: 'bold', 751 }, 752 recentlyReadList: { 753 paddingRight: 16, 754 paddingBottom: 8, 755 }, 756 recentlyReadItem: { 757 width: RECENTLY_READ_CARD_WIDTH, 758 marginRight: 12, 759 }, 760 recentlyReadCard: { 761 width: '100%', 762 aspectRatio: 3 / 4, 763 borderRadius: 12, 764 overflow: 'hidden', 765 }, 766 recentlyReadTitle: { 767 fontSize: 12, 768 fontWeight: 'bold', 769 marginTop: 6, 770 textAlign: 'center', 771 }, 772 });