/ components / MangaCard.tsx
MangaCard.tsx
1 import React, { useState, useRef, useEffect } from 'react'; 2 import { 3 View, 4 Text, 5 Image, 6 StyleSheet, 7 ActivityIndicator, 8 Animated, 9 Pressable, 10 } from 'react-native'; 11 import { Colors, ColorScheme } from '@/constants/Colors'; 12 import { useTheme } from '@/constants/ThemeContext'; 13 import { 14 useImageCache, 15 useMangaImageCache, 16 type CacheContext, 17 } from '@/services/CacheImages'; 18 import * as FileSystem from 'expo-file-system'; 19 import { MangaCardProps, BookmarkStatus } from '@/types'; 20 import { useHapticFeedback } from '@/utils/haptics'; 21 import BottomPopup from './BottomPopup'; 22 import { getBookmarkPopupConfig, getMangaData, saveBookmark, removeBookmark } from '@/services/bookmarkService'; 23 import { Ionicons } from '@expo/vector-icons'; 24 25 interface EnhancedMangaCardProps extends MangaCardProps { 26 context?: CacheContext; 27 onLongPress?: () => void; 28 } 29 30 const MangaCard: React.FC<EnhancedMangaCardProps> = ({ 31 title, 32 imageUrl, 33 onPress, 34 lastReadChapter, 35 style, 36 context = 'search', 37 mangaId, 38 onLongPress, 39 onBookmarkChange, 40 }) => { 41 const { theme, systemTheme } = useTheme(); 42 const colorScheme = theme === 'system' ? systemTheme : (theme as ColorScheme); 43 const colors = Colors[colorScheme]; 44 const styles = getStyles(colors); 45 46 const [isLoading, setIsLoading] = useState(true); 47 const [hasError, setHasError] = useState(false); 48 const [showBookmarkPopup, setShowBookmarkPopup] = useState(false); 49 const [bookmarkStatus, setBookmarkStatus] = useState<BookmarkStatus | null>(null); 50 const scaleAnim = useRef(new Animated.Value(1)).current; 51 const haptics = useHapticFeedback(); 52 53 // Load bookmark status when component mounts or mangaId changes 54 useEffect(() => { 55 const loadBookmarkStatus = async () => { 56 if (mangaId) { 57 try { 58 const mangaData = await getMangaData(mangaId); 59 setBookmarkStatus(mangaData?.bookmarkStatus || null); 60 } catch (error) { 61 console.error('Error loading bookmark status:', error); 62 } 63 } 64 }; 65 66 loadBookmarkStatus(); 67 }, [mangaId]); 68 69 // Use appropriate caching strategy based on context 70 const searchCachedPath = useImageCache(imageUrl, context, mangaId); 71 const mangaCachedPath = useMangaImageCache(mangaId || '', imageUrl); 72 73 // Choose the right cached path based on context 74 const cachedImagePath = 75 context === 'manga' && mangaId ? mangaCachedPath : searchCachedPath; 76 77 const getImageSource = () => { 78 if ( 79 cachedImagePath && 80 typeof cachedImagePath === 'string' && 81 cachedImagePath.startsWith(FileSystem.cacheDirectory || '') 82 ) { 83 return { 84 uri: `file://${cachedImagePath}`, 85 }; 86 } 87 88 return { 89 uri: cachedImagePath || imageUrl, 90 }; 91 }; 92 93 const handleImageLoad = () => { 94 setIsLoading(false); 95 setHasError(false); 96 }; 97 98 const handleImageError = () => { 99 setIsLoading(false); 100 setHasError(true); 101 }; 102 103 const handlePressIn = () => { 104 haptics.onPress(); 105 Animated.spring(scaleAnim, { 106 toValue: 0.95, 107 useNativeDriver: true, 108 tension: 300, 109 friction: 10, 110 }).start(); 111 }; 112 113 const handlePressOut = () => { 114 Animated.spring(scaleAnim, { 115 toValue: 1, 116 useNativeDriver: true, 117 tension: 300, 118 friction: 10, 119 }).start(); 120 }; 121 122 const handleLongPress = async () => { 123 if (onLongPress) { 124 onLongPress(); 125 return; 126 } 127 128 if (!mangaId) return; 129 130 haptics.onLongPress(); 131 132 try { 133 const mangaData = await getMangaData(mangaId); 134 setBookmarkStatus(mangaData?.bookmarkStatus || null); 135 setShowBookmarkPopup(true); 136 } catch (error) { 137 console.error('Error fetching manga data for long press:', error); 138 } 139 }; 140 141 const handleSaveBookmark = async (status: BookmarkStatus) => { 142 if (!mangaId) return; 143 144 try { 145 // Update local state immediately for instant feedback 146 setBookmarkStatus(status); 147 setShowBookmarkPopup(false); 148 149 const mangaData = await getMangaData(mangaId); 150 const mockMangaDetails = { 151 title: title, 152 bannerImage: imageUrl, 153 chapters: [], 154 }; 155 156 await saveBookmark( 157 mangaId, 158 status, 159 mockMangaDetails, 160 mangaData?.readChapters || [], 161 (newStatus) => setBookmarkStatus(newStatus as BookmarkStatus | null), 162 () => {}, 163 () => {} 164 ); 165 166 // Notify parent component about bookmark change 167 if (onBookmarkChange) { 168 onBookmarkChange(mangaId, status); 169 } 170 } catch (error) { 171 console.error('Error saving bookmark:', error); 172 // Revert local state if there was an error 173 const mangaData = await getMangaData(mangaId); 174 setBookmarkStatus(mangaData?.bookmarkStatus || null); 175 } 176 }; 177 178 const handleRemoveBookmark = async () => { 179 if (!mangaId) return; 180 181 try { 182 // Update local state immediately for instant feedback 183 setBookmarkStatus(null); 184 setShowBookmarkPopup(false); 185 186 await removeBookmark( 187 mangaId, 188 (newStatus) => setBookmarkStatus(newStatus as BookmarkStatus | null), 189 () => {} 190 ); 191 192 // Notify parent component about bookmark change 193 if (onBookmarkChange) { 194 onBookmarkChange(mangaId, null); 195 } 196 } catch (error) { 197 console.error('Error removing bookmark:', error); 198 // Revert local state if there was an error 199 const mangaData = await getMangaData(mangaId); 200 setBookmarkStatus(mangaData?.bookmarkStatus || null); 201 } 202 }; 203 204 const bookmarkPopupConfig = getBookmarkPopupConfig( 205 bookmarkStatus, 206 title, 207 handleSaveBookmark, 208 handleRemoveBookmark 209 ); 210 211 return ( 212 <> 213 <Pressable 214 testID="manga-card" 215 onPress={onPress} 216 onPressIn={handlePressIn} 217 onPressOut={handlePressOut} 218 onLongPress={handleLongPress} 219 style={[styles.cardContainer, style]} 220 accessibilityRole="button" 221 accessibilityLabel={`Open ${title} manga details`} 222 accessibilityHint={ 223 lastReadChapter 224 ? `Last read: ${lastReadChapter}` 225 : 'Tap to view manga details' 226 } 227 > 228 <Animated.View style={{ transform: [{ scale: scaleAnim }] }}> 229 <View style={styles.imageContainer}> 230 <Image 231 source={getImageSource()} 232 style={styles.cardImage} 233 onLoad={handleImageLoad} 234 onError={handleImageError} 235 accessibilityLabel={`Cover image for ${title}`} 236 /> 237 {isLoading && ( 238 <View style={styles.loadingOverlay}> 239 <ActivityIndicator size="small" color={colors.primary} /> 240 </View> 241 )} 242 {hasError && ( 243 <View style={styles.errorOverlay}> 244 <Text style={styles.errorText}>Failed to load image</Text> 245 </View> 246 )} 247 {bookmarkStatus && context !== 'bookmark' && ( 248 <View style={styles.bookmarkIndicator}> 249 <Ionicons name="bookmark" size={16} color={colors.primary} /> 250 </View> 251 )} 252 </View> 253 <View style={styles.cardInfo}> 254 <Text 255 style={styles.cardTitle} 256 numberOfLines={2} 257 ellipsizeMode="tail" 258 accessibilityRole="header" 259 > 260 {title} 261 </Text> 262 {lastReadChapter && ( 263 <Text 264 style={styles.lastReadChapter} 265 numberOfLines={1} 266 ellipsizeMode="tail" 267 accessibilityLabel={`Last read chapter: ${lastReadChapter}`} 268 > 269 Last read: {lastReadChapter} 270 </Text> 271 )} 272 </View> 273 </Animated.View> 274 </Pressable> 275 276 <BottomPopup 277 visible={showBookmarkPopup} 278 title={bookmarkPopupConfig.title} 279 onClose={() => setShowBookmarkPopup(false)} 280 options={bookmarkPopupConfig.options} 281 /> 282 </> 283 ); 284 }; 285 286 const getStyles = (colors: typeof Colors.light) => 287 StyleSheet.create({ 288 cardContainer: { 289 borderRadius: 12, 290 overflow: 'hidden', 291 backgroundColor: colors.card, 292 }, 293 imageContainer: { 294 position: 'relative', 295 }, 296 cardImage: { 297 width: '100%', 298 aspectRatio: 3 / 4, 299 resizeMode: 'cover', 300 }, 301 loadingOverlay: { 302 position: 'absolute', 303 top: 0, 304 left: 0, 305 right: 0, 306 bottom: 0, 307 backgroundColor: colors.card, 308 justifyContent: 'center', 309 alignItems: 'center', 310 }, 311 errorOverlay: { 312 position: 'absolute', 313 top: 0, 314 left: 0, 315 right: 0, 316 bottom: 0, 317 backgroundColor: colors.card, 318 justifyContent: 'center', 319 alignItems: 'center', 320 padding: 8, 321 }, 322 errorText: { 323 fontSize: 12, 324 color: colors.tabIconDefault, 325 textAlign: 'center', 326 }, 327 cardInfo: { 328 padding: 8, 329 }, 330 cardTitle: { 331 fontSize: 14, 332 fontWeight: '600', 333 color: colors.text, 334 marginBottom: 4, 335 }, 336 lastReadChapter: { 337 fontSize: 12, 338 color: colors.tabIconDefault, 339 }, 340 bookmarkIndicator: { 341 position: 'absolute', 342 top: 8, 343 right: 8, 344 backgroundColor: colors.background, 345 borderRadius: 12, 346 width: 24, 347 height: 24, 348 justifyContent: 'center', 349 alignItems: 'center', 350 shadowColor: '#000', 351 shadowOffset: { width: 0, height: 1 }, 352 shadowOpacity: 0.2, 353 shadowRadius: 2, 354 elevation: 2, 355 }, 356 }); 357 358 export default MangaCard;