/ 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;