bookmarks.tsx
1 import React, { 2 useCallback, 3 useState, 4 useEffect, 5 useRef, 6 useMemo, 7 } from 'react'; 8 import { 9 View, 10 Text, 11 FlatList, 12 TouchableOpacity, 13 StyleSheet, 14 ActivityIndicator, 15 SafeAreaView, 16 ScrollView, 17 TextInput, 18 Platform, 19 Dimensions, 20 ListRenderItemInfo, 21 } from 'react-native'; 22 import AsyncStorage from '@react-native-async-storage/async-storage'; 23 import { getMangaData } from '@/services/bookmarkService'; 24 import { useRouter } from 'expo-router'; 25 import { useFocusEffect } from '@react-navigation/native'; 26 import { useTheme } from '@/constants/ThemeContext'; 27 import { Colors } from '@/constants/Colors'; 28 import { Ionicons } from '@expo/vector-icons'; 29 import MangaCard from '@/components/MangaCard'; 30 import { useSafeAreaInsets } from 'react-native-safe-area-context'; 31 import { BookmarkItem, BookmarkStatus } from '@/types'; 32 import Animated, { 33 useSharedValue, 34 useAnimatedStyle, 35 withTiming, 36 Easing, 37 runOnJS, 38 } from 'react-native-reanimated'; 39 import { 40 Gesture, 41 GestureDetector, 42 GestureHandlerRootView, 43 } from 'react-native-gesture-handler'; 44 import { imageCache } from '@/services/CacheImages'; 45 46 // Constants 47 const SECTIONS: BookmarkStatus[] = ['Reading', 'To Read', 'On Hold', 'Read']; 48 const SORT_OPTIONS = [ 49 { id: 'title-asc', label: 'Title (A‑Z)', icon: 'text' }, 50 { id: 'title-desc', label: 'Title (Z‑A)', icon: 'text' }, 51 { id: 'updated-desc', label: 'Last Read (Recent)', icon: 'time' }, 52 { id: 'updated-asc', label: 'Last Read (Oldest)', icon: 'time' }, 53 ]; 54 const { width: SCREEN_WIDTH } = Dimensions.get('window'); 55 const VIEW_MODE_STORAGE_KEY = 'bookmarksViewMode'; 56 57 // Types 58 type ViewMode = 'grid' | 'list'; 59 type AnimatedFlatListProps = Animated.AnimateProps< 60 React.ComponentProps<typeof FlatList<BookmarkItem>> 61 >; 62 63 // Animated Components 64 const AnimatedTouchableOpacity = 65 Animated.createAnimatedComponent(TouchableOpacity); 66 const AnimatedFlatList = Animated.createAnimatedComponent( 67 FlatList 68 ) as React.ComponentType<AnimatedFlatListProps>; 69 const AnimatedView = Animated.createAnimatedComponent(View); 70 71 // Helper component for preloading images 72 const ImagePreloader = ({ urls }: { urls: string[] }) => { 73 useEffect(() => { 74 urls.forEach((url) => { 75 if (url) imageCache.getCachedImagePath(url, 'bookmark'); 76 }); 77 }, [urls]); 78 return null; 79 }; 80 81 export default function BookmarksScreen() { 82 // State 83 const [bookmarks, setBookmarks] = useState<BookmarkItem[]>([]); 84 const [sectionData, setSectionData] = useState< 85 Record<BookmarkStatus, BookmarkItem[]> 86 >({ 87 Reading: [], 88 'On Hold': [], 89 'To Read': [], 90 Read: [], 91 }); 92 const [isLoading, setIsLoading] = useState(true); 93 const [isViewModeLoading, setIsViewModeLoading] = useState(true); 94 const [activeSection, setActiveSection] = useState<BookmarkStatus>('Reading'); 95 const [searchQuery, setSearchQuery] = useState(''); 96 const [sortOption, setSortOption] = useState(SORT_OPTIONS[0]?.id || 'title-asc'); 97 const [viewMode, setViewMode] = useState<ViewMode>('grid'); 98 const [showSortOptions, setShowSortOptions] = useState(false); 99 const [allImageUrls, setAllImageUrls] = useState<string[]>([]); 100 101 // Animation values 102 const translateX = useSharedValue(0); 103 const sortOptionsHeight = useSharedValue(0); 104 const isAnimating = useSharedValue(false); 105 106 // Refs 107 const router = useRouter(); 108 const flatListRef = useRef<FlatList<BookmarkItem>>(null); 109 const sectionScrollRef = useRef<ScrollView>(null); 110 const searchInputRef = useRef<TextInput>(null); 111 112 // Theme 113 const { actualTheme } = useTheme(); 114 const colors = Colors[actualTheme]; 115 const styles = getStyles(colors); 116 const insets = useSafeAreaInsets(); 117 118 // Load view mode preference 119 useEffect(() => { 120 const loadViewMode = async () => { 121 try { 122 const saved = (await AsyncStorage.getItem( 123 VIEW_MODE_STORAGE_KEY 124 )) as ViewMode | null; 125 if (saved) setViewMode(saved); 126 } catch (e) { 127 console.error('Failed to load view mode:', e); 128 } finally { 129 setIsViewModeLoading(false); 130 } 131 }; 132 133 loadViewMode(); 134 }, []); 135 136 // Process bookmarks: filter, sort and group by section 137 const processBookmarks = useCallback( 138 (items: BookmarkItem[], query: string, sort: string) => { 139 const sections: Record<BookmarkStatus, BookmarkItem[]> = { 140 Reading: [], 141 'On Hold': [], 142 'To Read': [], 143 Read: [], 144 }; 145 146 // Filter by search query 147 let filtered = items; 148 if (query.trim()) { 149 const q = query.toLowerCase(); 150 filtered = items.filter((it) => it.title.toLowerCase().includes(q)); 151 } 152 153 // Sort function based on selected option 154 const sortFn = (arr: BookmarkItem[]) => { 155 const a = [...arr]; 156 switch (sort) { 157 case 'title-asc': 158 a.sort((x, y) => x.title.localeCompare(y.title)); 159 break; 160 case 'title-desc': 161 a.sort((x, y) => y.title.localeCompare(x.title)); 162 break; 163 case 'updated-desc': 164 a.sort((x, y) => (y.lastUpdated ?? 0) - (x.lastUpdated ?? 0)); 165 break; 166 case 'updated-asc': 167 a.sort((x, y) => (x.lastUpdated ?? 0) - (y.lastUpdated ?? 0)); 168 break; 169 } 170 return a; 171 }; 172 173 // Group by section 174 filtered.forEach((it) => { 175 const status = it.status as BookmarkStatus; 176 if (status && sections[status]) { 177 sections[status].push(it); 178 } 179 }); 180 181 // Sort each section 182 for (const k in sections) { 183 sections[k as BookmarkStatus] = sortFn(sections[k as BookmarkStatus]); 184 } 185 186 setSectionData(sections); 187 setAllImageUrls(filtered.map((it) => it.imageUrl).filter(Boolean)); 188 }, 189 [] 190 ); 191 192 // Fetch bookmarks from storage 193 const fetchBookmarks = useCallback(async () => { 194 setIsLoading(true); 195 try { 196 const raw = await AsyncStorage.getItem('bookmarkKeys'); 197 const keys = raw ? JSON.parse(raw) : []; 198 const arr = await Promise.all( 199 keys.map(async (key: string) => { 200 const id = key.split('_')[1]; 201 if (!id) return null; 202 const d = await getMangaData(id); 203 if (!d) return null; 204 return { 205 id: d.id, 206 title: d.title, 207 status: (d.bookmarkStatus as BookmarkStatus) || 'Reading', 208 lastReadChapter: d.lastReadChapter 209 ? `Chapter ${d.lastReadChapter}` 210 : 'Not started', 211 imageUrl: d.bannerImage || '', 212 lastUpdated: d.lastUpdated ?? 0, 213 } as BookmarkItem; 214 }) 215 ); 216 setBookmarks(arr.filter((x): x is BookmarkItem => x != null)); 217 } catch (e) { 218 console.error('Failed to fetch bookmarks:', e); 219 } finally { 220 setIsLoading(false); 221 } 222 }, []); 223 224 // Initial fetch 225 useEffect(() => { 226 fetchBookmarks(); 227 }, [fetchBookmarks]); 228 229 // Process bookmarks when data, search or sort changes 230 useEffect(() => { 231 processBookmarks(bookmarks, searchQuery, sortOption); 232 }, [bookmarks, searchQuery, sortOption, processBookmarks]); 233 234 // Refresh bookmarks when screen comes into focus and changes detected 235 useFocusEffect( 236 useCallback(() => { 237 const checkForChanges = async () => { 238 const changed = await AsyncStorage.getItem('bookmarkChanged'); 239 if (changed === 'true') { 240 await fetchBookmarks(); 241 await AsyncStorage.setItem('bookmarkChanged', 'false'); 242 } 243 }; 244 245 checkForChanges(); 246 }, [fetchBookmarks]) 247 ); 248 249 // Animated styles 250 const contentAnim = useAnimatedStyle(() => ({ 251 transform: [{ translateX: translateX.value }], 252 })); 253 254 const sortOptsAnim = useAnimatedStyle(() => ({ 255 height: sortOptionsHeight.value, 256 overflow: 'hidden', 257 })); 258 259 // Event Handlers 260 const handleBookmarkPress = useCallback( 261 (id: string) => { 262 router.push(`/manga/${id}`); 263 }, 264 [router] 265 ); 266 267 const handleClearSearch = useCallback(() => { 268 setSearchQuery(''); 269 }, []); 270 271 const toggleSortOptions = useCallback(() => { 272 if (showSortOptions) { 273 sortOptionsHeight.value = withTiming(0, { duration: 200 }, () => 274 runOnJS(setShowSortOptions)(false) 275 ); 276 } else { 277 setShowSortOptions(true); 278 const height = SORT_OPTIONS.length * 40 + 10; 279 sortOptionsHeight.value = withTiming(height, { duration: 200 }); 280 } 281 }, [showSortOptions]); 282 283 const selectSort = useCallback((opt: string) => { 284 setSortOption(opt); 285 sortOptionsHeight.value = withTiming(0, { duration: 200 }, () => 286 runOnJS(setShowSortOptions)(false) 287 ); 288 }, []); 289 290 const toggleView = useCallback(async () => { 291 const newMode: ViewMode = viewMode === 'grid' ? 'list' : 'grid'; 292 setViewMode(newMode); 293 try { 294 await AsyncStorage.setItem(VIEW_MODE_STORAGE_KEY, newMode); 295 } catch (e) { 296 console.error('Failed to save view mode:', e); 297 } 298 }, [viewMode]); 299 300 // Section change animation completed 301 const onSectionAnimDone = useCallback((section: BookmarkStatus) => { 302 setActiveSection(section); 303 304 // Center tab in scroll view 305 const idx = SECTIONS.indexOf(section); 306 if (sectionScrollRef.current) { 307 const visibleTabs = Math.min(SECTIONS.length, 4); 308 const tabWidth = SCREEN_WIDTH / visibleTabs; 309 const scrollX = Math.max( 310 0, 311 idx * tabWidth - tabWidth * (visibleTabs / 2 - 0.5) 312 ); 313 sectionScrollRef.current.scrollTo({ x: scrollX, animated: true }); 314 } 315 316 // Scroll to top of list 317 flatListRef.current?.scrollToOffset({ offset: 0, animated: false }); 318 isAnimating.value = false; 319 }, []); 320 321 // Handle section change with animation 322 const changeSection = useCallback( 323 (section: BookmarkStatus) => { 324 if (section === activeSection || isAnimating.value) return; 325 326 isAnimating.value = true; 327 const currentIndex = SECTIONS.indexOf(activeSection); 328 const nextIndex = SECTIONS.indexOf(section); 329 const direction = currentIndex < nextIndex ? 1 : -1; 330 331 // Animate out 332 translateX.value = withTiming( 333 -direction * SCREEN_WIDTH, 334 { 335 duration: 250, 336 easing: Easing.out(Easing.cubic), 337 }, 338 (finished) => { 339 if (finished) { 340 // Jump to other side 341 translateX.value = direction * SCREEN_WIDTH; 342 // Update section 343 runOnJS(onSectionAnimDone)(section); 344 // Animate in 345 translateX.value = withTiming(0, { 346 duration: 250, 347 easing: Easing.out(Easing.cubic), 348 }); 349 } else { 350 runOnJS(() => { 351 isAnimating.value = false; 352 })(); 353 translateX.value = withTiming(0); 354 } 355 } 356 ); 357 }, 358 [activeSection, onSectionAnimDone] 359 ); 360 361 // Pan gesture for swipe between sections 362 const pan = useMemo( 363 () => 364 Gesture.Pan() 365 .activeOffsetX([-20, 20]) 366 .failOffsetY([-10, 10]) 367 .onUpdate((e) => { 368 if (!isAnimating.value) { 369 translateX.value = Math.max( 370 -SCREEN_WIDTH / 2, 371 Math.min(SCREEN_WIDTH / 2, e.translationX) 372 ); 373 } 374 }) 375 .onEnd((e) => { 376 if (isAnimating.value) return; 377 378 const { velocityX, translationX } = e; 379 const threshold = SCREEN_WIDTH * 0.25; 380 const velocityThreshold = 400; 381 382 if ( 383 Math.abs(translationX) > threshold || 384 Math.abs(velocityX) > velocityThreshold 385 ) { 386 const direction = translationX > 0 ? -1 : 1; 387 const currentIndex = SECTIONS.indexOf(activeSection); 388 const nextIndex = Math.max( 389 0, 390 Math.min(SECTIONS.length - 1, currentIndex + direction) 391 ); 392 393 if (currentIndex !== nextIndex) { 394 const section = SECTIONS[nextIndex]; 395 if (section) { 396 runOnJS(changeSection)(section); 397 } 398 } else { 399 translateX.value = withTiming(0, { 400 duration: 200, 401 easing: Easing.out(Easing.cubic), 402 }); 403 } 404 } else { 405 translateX.value = withTiming(0, { 406 duration: 200, 407 easing: Easing.out(Easing.cubic), 408 }); 409 } 410 }), 411 [activeSection, changeSection] 412 ); 413 414 // Render bookmark item (grid or list view) 415 const renderBookmarkItem = useCallback( 416 (info: ListRenderItemInfo<BookmarkItem>) => { 417 const item = info.item; 418 419 if (viewMode === 'grid') { 420 return ( 421 <View style={styles.bookmarkCardWrapper}> 422 <MangaCard 423 title={item.title} 424 imageUrl={item.imageUrl} 425 onPress={() => handleBookmarkPress(item.id)} 426 lastReadChapter={item.lastReadChapter} 427 context="bookmark" 428 mangaId={item.id} 429 onBookmarkChange={(_mangaId, newStatus) => { 430 // If unbookmarked (newStatus is null), refresh immediately 431 if (newStatus === null) { 432 fetchBookmarks(); 433 } else { 434 // For status changes, just refresh to update the display 435 fetchBookmarks(); 436 } 437 }} 438 /> 439 </View> 440 ); 441 } 442 443 return ( 444 <TouchableOpacity 445 style={styles.listItem} 446 onPress={() => handleBookmarkPress(item.id)} 447 activeOpacity={0.7} 448 > 449 <View style={styles.listItemImageContainer}> 450 <MangaCard 451 title="" 452 imageUrl={item.imageUrl} 453 onPress={() => {}} 454 lastReadChapter={null} 455 style={styles.listItemImage} 456 context="bookmark" 457 mangaId={item.id} 458 onBookmarkChange={(_mangaId, newStatus) => { 459 // If unbookmarked (newStatus is null), refresh immediately 460 if (newStatus === null) { 461 fetchBookmarks(); 462 } else { 463 // For status changes, just refresh to update the display 464 fetchBookmarks(); 465 } 466 }} 467 /> 468 </View> 469 <View style={styles.listItemContent}> 470 <Text style={styles.listItemTitle} numberOfLines={2}> 471 {item.title} 472 </Text> 473 <Text style={styles.listItemChapter}>{item.lastReadChapter}</Text> 474 </View> 475 <Ionicons 476 name="chevron-forward" 477 size={24} 478 color={colors.tabIconDefault} 479 /> 480 </TouchableOpacity> 481 ); 482 }, 483 [viewMode, handleBookmarkPress, styles, colors.tabIconDefault] 484 ); 485 486 // Render section button 487 const renderSectionButton = useCallback( 488 (title: BookmarkStatus) => { 489 // Select icon based on section 490 let icon: keyof typeof Ionicons.glyphMap = 'book'; 491 switch (title) { 492 case 'To Read': 493 icon = 'book-outline'; 494 break; 495 case 'Reading': 496 icon = 'book'; 497 break; 498 case 'On Hold': 499 icon = 'pause-circle-outline'; 500 break; 501 case 'Read': 502 icon = 'checkmark-circle-outline'; 503 break; 504 } 505 506 const count = sectionData[title]?.length ?? 0; 507 const isActive = title === activeSection; 508 509 return ( 510 <AnimatedTouchableOpacity 511 key={title} 512 style={[styles.sectionButton, isActive && styles.activeSectionButton]} 513 onPress={() => changeSection(title)} 514 activeOpacity={0.7} 515 > 516 <Ionicons 517 name={icon} 518 size={18} 519 color={isActive ? colors.card : colors.text} 520 style={styles.sectionButtonIcon} 521 /> 522 <Text 523 style={[ 524 styles.sectionButtonText, 525 isActive && styles.activeSectionButtonText, 526 ]} 527 > 528 {title} 529 </Text> 530 <View 531 style={[styles.sectionCount, isActive && styles.activeSectionCount]} 532 > 533 <Text 534 style={[ 535 styles.sectionCountText, 536 isActive && styles.activeSectionCountText, 537 ]} 538 > 539 {count} 540 </Text> 541 </View> 542 </AnimatedTouchableOpacity> 543 ); 544 }, 545 [activeSection, sectionData, changeSection, styles, colors] 546 ); 547 548 // Current section's items 549 const currentItems = useMemo( 550 () => sectionData[activeSection] || [], 551 [sectionData, activeSection] 552 ); 553 554 // Loading state 555 if (isLoading || isViewModeLoading) { 556 return ( 557 <SafeAreaView style={[styles.container, { paddingTop: insets.top }]}> 558 <View style={styles.loadingContainer}> 559 <ActivityIndicator size="large" color={colors.primary} /> 560 <Text style={styles.loadingText}>Loading...</Text> 561 </View> 562 </SafeAreaView> 563 ); 564 } 565 566 return ( 567 <GestureHandlerRootView style={{ flex: 1 }}> 568 <SafeAreaView style={[styles.container, { paddingTop: insets.top }]}> 569 <ImagePreloader urls={allImageUrls} /> 570 571 {/* Header */} 572 <View style={styles.header}> 573 <Text style={styles.headerTitle}>My Bookmarks</Text> 574 <View style={styles.headerButtons}> 575 <TouchableOpacity 576 style={styles.headerButton} 577 onPress={toggleSortOptions} 578 activeOpacity={0.7} 579 > 580 <Ionicons name="options-outline" size={22} color={colors.text} /> 581 </TouchableOpacity> 582 <TouchableOpacity 583 style={styles.headerButton} 584 onPress={toggleView} 585 activeOpacity={0.7} 586 > 587 <Ionicons 588 name={viewMode === 'grid' ? 'list-outline' : 'grid-outline'} 589 size={22} 590 color={colors.text} 591 /> 592 </TouchableOpacity> 593 </View> 594 </View> 595 596 {/* Search */} 597 <View style={styles.searchContainer}> 598 <View style={styles.searchInputContainer}> 599 <Ionicons 600 name="search" 601 size={20} 602 color={colors.tabIconDefault} 603 style={styles.searchIcon} 604 /> 605 <TextInput 606 ref={searchInputRef} 607 style={styles.searchInput} 608 placeholder="Search bookmarks..." 609 placeholderTextColor={colors.tabIconDefault} 610 value={searchQuery} 611 onChangeText={setSearchQuery} 612 /> 613 {searchQuery ? ( 614 <TouchableOpacity 615 onPress={handleClearSearch} 616 style={styles.clearButton} 617 hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }} 618 > 619 <Ionicons 620 name="close-circle" 621 size={20} 622 color={colors.tabIconDefault} 623 /> 624 </TouchableOpacity> 625 ) : null} 626 </View> 627 </View> 628 629 {/* Sort Options */} 630 <Animated.View 631 style={[styles.sortOptionsContainerWrapper, sortOptsAnim]} 632 > 633 {showSortOptions && ( 634 <View style={styles.sortOptionsContainer}> 635 {SORT_OPTIONS.map((opt) => ( 636 <TouchableOpacity 637 key={opt.id} 638 style={[ 639 styles.sortOption, 640 sortOption === opt.id && styles.activeSortOption, 641 ]} 642 onPress={() => selectSort(opt.id)} 643 activeOpacity={0.7} 644 > 645 <Ionicons 646 name={opt.icon as keyof typeof Ionicons.glyphMap} 647 size={18} 648 color={sortOption === opt.id ? colors.primary : colors.text} 649 style={styles.sortOptionIcon} 650 /> 651 <Text 652 style={[ 653 styles.sortOptionText, 654 sortOption === opt.id && styles.activeSortOptionText, 655 ]} 656 > 657 {opt.label} 658 </Text> 659 {sortOption === opt.id && ( 660 <Ionicons 661 name="checkmark" 662 size={18} 663 color={colors.primary} 664 /> 665 )} 666 </TouchableOpacity> 667 ))} 668 </View> 669 )} 670 </Animated.View> 671 672 {/* Section Tabs */} 673 <View style={styles.sectionButtonsContainer}> 674 <ScrollView 675 ref={sectionScrollRef} 676 horizontal 677 showsHorizontalScrollIndicator={false} 678 contentContainerStyle={styles.sectionButtonsScroll} 679 > 680 {SECTIONS.map(renderSectionButton)} 681 </ScrollView> 682 </View> 683 684 {/* Content Area with Gesture Detector */} 685 <GestureDetector gesture={pan}> 686 <View style={styles.contentWrapper}> 687 <AnimatedView style={[styles.contentContainer, contentAnim]}> 688 {currentItems.length === 0 ? ( 689 <View style={styles.emptyStateContainer}> 690 <Ionicons 691 name="bookmark-outline" 692 size={64} 693 color={colors.tabIconDefault} 694 /> 695 <Text style={styles.emptyStateText}> 696 {searchQuery 697 ? `No bookmarks found for "${searchQuery}"` 698 : `No ${activeSection.toLowerCase()} manga found`} 699 </Text> 700 {searchQuery ? ( 701 <TouchableOpacity 702 style={styles.clearSearchButton} 703 onPress={handleClearSearch} 704 > 705 <Text style={styles.clearSearchButtonText}> 706 Clear Search 707 </Text> 708 </TouchableOpacity> 709 ) : null} 710 </View> 711 ) : ( 712 <> 713 <Text style={styles.resultCount}> 714 {currentItems.length}{' '} 715 {currentItems.length > 1 ? 'mangas' : 'manga'} 716 </Text> 717 <AnimatedFlatList 718 //@ts-ignore 719 ref={flatListRef} 720 data={currentItems} 721 renderItem={renderBookmarkItem} 722 keyExtractor={(item) => item.id} 723 numColumns={viewMode === 'grid' ? 2 : 1} 724 key={viewMode} 725 extraData={[activeSection, viewMode]} 726 columnWrapperStyle={ 727 viewMode === 'grid' ? styles.columnWrapper : undefined 728 } 729 contentContainerStyle={styles.listContentContainer} 730 showsVerticalScrollIndicator={false} 731 removeClippedSubviews 732 initialNumToRender={10} 733 maxToRenderPerBatch={10} 734 windowSize={11} 735 keyboardShouldPersistTaps="always" 736 keyboardDismissMode="on-drag" 737 /> 738 </> 739 )} 740 </AnimatedView> 741 </View> 742 </GestureDetector> 743 </SafeAreaView> 744 </GestureHandlerRootView> 745 ); 746 } 747 748 const getStyles = (colors: typeof Colors.light) => 749 StyleSheet.create({ 750 container: { 751 flex: 1, 752 backgroundColor: colors.background, 753 }, 754 header: { 755 flexDirection: 'row', 756 justifyContent: 'space-between', 757 alignItems: 'center', 758 paddingHorizontal: 20, 759 paddingBottom: 10, 760 }, 761 headerTitle: { 762 fontSize: 26, 763 fontWeight: 'bold', 764 color: colors.text, 765 }, 766 headerButtons: { 767 flexDirection: 'row', 768 alignItems: 'center', 769 }, 770 headerButton: { 771 width: 38, 772 height: 38, 773 borderRadius: 19, 774 backgroundColor: colors.card, 775 justifyContent: 'center', 776 alignItems: 'center', 777 marginLeft: 10, 778 shadowColor: '#000', 779 shadowOffset: { width: 0, height: 1 }, 780 shadowOpacity: 0.1, 781 shadowRadius: 1.5, 782 elevation: 2, 783 }, 784 searchContainer: { 785 paddingHorizontal: 20, 786 marginVertical: 6, 787 }, 788 searchInputContainer: { 789 flexDirection: 'row', 790 alignItems: 'center', 791 backgroundColor: colors.card, 792 borderRadius: 8, 793 paddingHorizontal: 10, 794 height: 36, 795 shadowColor: '#000', 796 shadowOffset: { width: 0, height: 1 }, 797 shadowOpacity: 0.1, 798 shadowRadius: 1.5, 799 elevation: 2, 800 }, 801 searchIcon: { 802 marginRight: 5, 803 }, 804 searchInput: { 805 flex: 1, 806 fontSize: 15, 807 color: colors.text, 808 paddingVertical: Platform.OS === 'ios' ? 5 : 3, 809 }, 810 clearButton: { 811 padding: 3, 812 marginLeft: 3, 813 }, 814 sortOptionsContainerWrapper: { 815 marginHorizontal: 20, 816 borderRadius: 10, 817 marginBottom: 8, 818 overflow: 'hidden', 819 backgroundColor: colors.card, 820 shadowColor: '#000', 821 shadowOffset: { width: 0, height: 1 }, 822 shadowOpacity: 0.1, 823 shadowRadius: 1.5, 824 elevation: 2, 825 }, 826 sortOptionsContainer: { 827 borderRadius: 10, 828 paddingVertical: 5, 829 }, 830 sortOption: { 831 flexDirection: 'row', 832 alignItems: 'center', 833 paddingVertical: 9, 834 paddingHorizontal: 15, 835 height: 40, 836 }, 837 activeSortOption: { 838 backgroundColor: colors.primary + '20', 839 }, 840 sortOptionIcon: { 841 marginRight: 12, 842 }, 843 sortOptionText: { 844 fontSize: 14, 845 color: colors.text, 846 flex: 1, 847 }, 848 activeSortOptionText: { 849 color: colors.primary, 850 fontWeight: '600', 851 }, 852 sectionButtonsContainer: { 853 marginBottom: 8, 854 paddingBottom: 5, 855 }, 856 sectionButtonsScroll: { 857 paddingHorizontal: 15, 858 alignItems: 'center', 859 }, 860 sectionButton: { 861 flexDirection: 'row', 862 alignItems: 'center', 863 paddingVertical: 7, 864 paddingHorizontal: 14, 865 borderRadius: 18, 866 backgroundColor: colors.card, 867 marginHorizontal: 4, 868 shadowColor: '#000', 869 shadowOffset: { width: 0, height: 1 }, 870 shadowOpacity: 0.08, 871 shadowRadius: 1, 872 elevation: 1.5, 873 }, 874 activeSectionButton: { 875 backgroundColor: colors.primary, 876 elevation: 3, 877 }, 878 sectionButtonIcon: { 879 marginRight: 5, 880 }, 881 sectionButtonText: { 882 fontSize: 13, 883 fontWeight: '600', 884 color: colors.text, 885 }, 886 activeSectionButtonText: { 887 color: colors.card, 888 }, 889 sectionCount: { 890 backgroundColor: colors.background + '99', 891 minWidth: 20, 892 height: 20, 893 borderRadius: 10, 894 justifyContent: 'center', 895 alignItems: 'center', 896 marginLeft: 6, 897 paddingHorizontal: 5, 898 borderWidth: 1, 899 borderColor: colors.border, 900 }, 901 activeSectionCount: { 902 backgroundColor: colors.card + 'CC', 903 borderColor: colors.primary + '50', 904 }, 905 sectionCountText: { 906 fontSize: 11, 907 fontWeight: '700', 908 color: colors.text, 909 }, 910 activeSectionCountText: { 911 color: colors.primary, 912 }, 913 contentWrapper: { 914 flex: 1, 915 overflow: 'hidden', 916 }, 917 contentContainer: { 918 flex: 1, 919 backgroundColor: colors.background, 920 width: '100%', 921 }, 922 resultCount: { 923 paddingHorizontal: 20, 924 paddingBottom: 8, 925 fontSize: 13, 926 color: colors.tabIconDefault, 927 }, 928 listContentContainer: { 929 paddingHorizontal: 15, 930 paddingBottom: 80, 931 }, 932 columnWrapper: { 933 justifyContent: 'space-between', 934 }, 935 bookmarkCardWrapper: { 936 width: '48%', 937 marginBottom: 15, 938 }, 939 listItem: { 940 flexDirection: 'row', 941 backgroundColor: colors.card, 942 borderRadius: 10, 943 marginBottom: 10, 944 padding: 10, 945 alignItems: 'center', 946 shadowColor: '#000', 947 shadowOffset: { width: 0, height: 1 }, 948 shadowOpacity: 0.1, 949 shadowRadius: 1.5, 950 elevation: 2, 951 }, 952 listItemImageContainer: { 953 width: 65, 954 height: 90, 955 borderRadius: 6, 956 overflow: 'hidden', 957 backgroundColor: colors.border, 958 }, 959 listItemImage: { 960 width: '100%', 961 height: '100%', 962 borderRadius: 0, 963 }, 964 listItemContent: { 965 flex: 1, 966 marginLeft: 12, 967 marginRight: 8, 968 justifyContent: 'center', 969 }, 970 listItemTitle: { 971 fontSize: 15, 972 fontWeight: 'bold', 973 color: colors.text, 974 marginBottom: 4, 975 }, 976 listItemChapter: { 977 fontSize: 13, 978 color: colors.tabIconDefault, 979 }, 980 loadingContainer: { 981 flex: 1, 982 justifyContent: 'center', 983 alignItems: 'center', 984 backgroundColor: colors.background, 985 }, 986 loadingText: { 987 marginTop: 16, 988 fontSize: 16, 989 color: colors.text, 990 }, 991 emptyStateContainer: { 992 flex: 1, 993 justifyContent: 'center', 994 alignItems: 'center', 995 padding: 30, 996 marginTop: -50, 997 }, 998 emptyStateText: { 999 fontSize: 17, 1000 textAlign: 'center', 1001 marginTop: 20, 1002 marginBottom: 25, 1003 color: colors.tabIconDefault, 1004 lineHeight: 24, 1005 }, 1006 clearSearchButton: { 1007 backgroundColor: colors.primary, 1008 paddingVertical: 10, 1009 paddingHorizontal: 25, 1010 borderRadius: 20, 1011 }, 1012 clearSearchButtonText: { 1013 color: colors.card, 1014 fontWeight: '600', 1015 fontSize: 15, 1016 }, 1017 });