From f457ade0711bda1a8a11620b0b04390f5ec56ac5 Mon Sep 17 00:00:00 2001 From: tapframe Date: Sat, 3 May 2025 16:02:27 +0530 Subject: [PATCH] Add search components: SearchBar, RecentSearches, ResultsCarousel, EmptyResults, SkeletonLoader, and SearchResultItem Introduce modular search components to enhance the SearchScreen functionality. Implement a SearchBar for user input, RecentSearches for displaying past queries, ResultsCarousel for showcasing search results, and EmptyResults for no-result scenarios. Include SkeletonLoader for loading states and SearchResultItem for individual result display. Update SearchScreen to utilize these components for improved organization and user experience. --- src/components/search/EmptyResults.tsx | 54 +++ src/components/search/README.md | 34 ++ src/components/search/RecentSearches.tsx | 75 ++++ src/components/search/ResultsCarousel.tsx | 62 ++++ src/components/search/SearchBar.tsx | 84 +++++ src/components/search/SearchResultItem.tsx | 75 ++++ src/components/search/SkeletonLoader.tsx | 108 ++++++ src/components/search/index.ts | 6 + src/screens/SearchScreen.tsx | 396 +++------------------ 9 files changed, 540 insertions(+), 354 deletions(-) create mode 100644 src/components/search/EmptyResults.tsx create mode 100644 src/components/search/README.md create mode 100644 src/components/search/RecentSearches.tsx create mode 100644 src/components/search/ResultsCarousel.tsx create mode 100644 src/components/search/SearchBar.tsx create mode 100644 src/components/search/SearchResultItem.tsx create mode 100644 src/components/search/SkeletonLoader.tsx create mode 100644 src/components/search/index.ts diff --git a/src/components/search/EmptyResults.tsx b/src/components/search/EmptyResults.tsx new file mode 100644 index 00000000..81524e8f --- /dev/null +++ b/src/components/search/EmptyResults.tsx @@ -0,0 +1,54 @@ +import React from 'react'; +import { View, Text, StyleSheet } from 'react-native'; +import { MaterialIcons } from '@expo/vector-icons'; +import { colors } from '../../styles'; + +interface EmptyResultsProps { + isDarkMode?: boolean; +} + +const EmptyResults: React.FC = ({ isDarkMode = true }) => { + return ( + + + + No results found + + + Try different keywords or check your spelling + + + ); +}; + +const styles = StyleSheet.create({ + emptyContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + paddingHorizontal: 32, + }, + emptyText: { + fontSize: 18, + fontWeight: 'bold', + marginTop: 16, + marginBottom: 8, + }, + emptySubtext: { + fontSize: 14, + textAlign: 'center', + lineHeight: 20, + }, +}); + +export default EmptyResults; \ No newline at end of file diff --git a/src/components/search/README.md b/src/components/search/README.md new file mode 100644 index 00000000..e941eee3 --- /dev/null +++ b/src/components/search/README.md @@ -0,0 +1,34 @@ +# Search Components + +This directory contains modular components used in the SearchScreen. + +## Components + +- **SearchBar**: Input field with search icon and clear button +- **SkeletonLoader**: Loading animation shown while searching +- **RecentSearches**: Shows recent search history +- **ResultsCarousel**: Horizontal scrolling list of search results by category +- **SearchResultItem**: Individual content card in the search results +- **EmptyResults**: Displayed when no search results are found + +## Usage + +```jsx +import { + SearchBar, + SkeletonLoader, + RecentSearches, + ResultsCarousel, + EmptyResults +} from '../components/search'; + +// Use components in your screen... +``` + +## Refactoring Benefits + +- Improved code organization +- Smaller, reusable components +- Better separation of concerns +- Easier maintenance and testing +- Reduced file size of main screen component \ No newline at end of file diff --git a/src/components/search/RecentSearches.tsx b/src/components/search/RecentSearches.tsx new file mode 100644 index 00000000..7308b40f --- /dev/null +++ b/src/components/search/RecentSearches.tsx @@ -0,0 +1,75 @@ +import React from 'react'; +import { View, Text, TouchableOpacity, StyleSheet } from 'react-native'; +import { MaterialIcons } from '@expo/vector-icons'; +import { colors } from '../../styles'; + +interface RecentSearchesProps { + searches: string[]; + onSearchSelect: (search: string) => void; + isDarkMode?: boolean; +} + +const RecentSearches: React.FC = ({ + searches, + onSearchSelect, + isDarkMode = true, +}) => { + if (searches.length === 0) return null; + + return ( + + + Recent Searches + + {searches.map((search, index) => ( + onSearchSelect(search)} + > + + + {search} + + + ))} + + ); +}; + +const styles = StyleSheet.create({ + recentSearchesContainer: { + paddingHorizontal: 0, + paddingBottom: 16, + }, + carouselTitle: { + fontSize: 18, + fontWeight: '700', + color: colors.white, + marginBottom: 12, + paddingHorizontal: 16, + }, + recentSearchItem: { + flexDirection: 'row', + alignItems: 'center', + paddingVertical: 10, + paddingHorizontal: 16, + }, + recentSearchIcon: { + marginRight: 12, + }, + recentSearchText: { + fontSize: 16, + flex: 1, + }, +}); + +export default RecentSearches; \ No newline at end of file diff --git a/src/components/search/ResultsCarousel.tsx b/src/components/search/ResultsCarousel.tsx new file mode 100644 index 00000000..d24d6707 --- /dev/null +++ b/src/components/search/ResultsCarousel.tsx @@ -0,0 +1,62 @@ +import React from 'react'; +import { View, Text, StyleSheet, FlatList } from 'react-native'; +import { colors } from '../../styles'; +import { StreamingContent } from '../../services/catalogService'; +import SearchResultItem from './SearchResultItem'; + +interface ResultsCarouselProps { + title: string; + items: StreamingContent[]; + onItemPress: (item: StreamingContent) => void; + isDarkMode?: boolean; +} + +const ResultsCarousel: React.FC = ({ + title, + items, + onItemPress, + isDarkMode = true, +}) => { + if (items.length === 0) return null; + + return ( + + + {title} ({items.length}) + + ( + + )} + keyExtractor={item => `${item.type}-${item.id}`} + horizontal + showsHorizontalScrollIndicator={false} + contentContainerStyle={styles.horizontalListContent} + /> + + ); +}; + +const styles = StyleSheet.create({ + carouselContainer: { + marginBottom: 24, + }, + carouselTitle: { + fontSize: 18, + fontWeight: '700', + color: colors.white, + marginBottom: 12, + paddingHorizontal: 16, + }, + horizontalListContent: { + paddingHorizontal: 16, + paddingRight: 8, + }, +}); + +export default ResultsCarousel; \ No newline at end of file diff --git a/src/components/search/SearchBar.tsx b/src/components/search/SearchBar.tsx new file mode 100644 index 00000000..889e5370 --- /dev/null +++ b/src/components/search/SearchBar.tsx @@ -0,0 +1,84 @@ +import React from 'react'; +import { View, TextInput, TouchableOpacity, StyleSheet } from 'react-native'; +import { MaterialIcons } from '@expo/vector-icons'; +import { colors } from '../../styles'; + +interface SearchBarProps { + query: string; + onChangeQuery: (text: string) => void; + onClear: () => void; + autoFocus?: boolean; +} + +const SearchBar: React.FC = ({ + query, + onChangeQuery, + onClear, + autoFocus = true +}) => { + return ( + + + + {query.length > 0 && ( + + + + )} + + ); +}; + +const styles = StyleSheet.create({ + searchBar: { + flexDirection: 'row', + alignItems: 'center', + borderRadius: 24, + paddingHorizontal: 16, + height: 48, + }, + searchIcon: { + marginRight: 12, + }, + searchInput: { + flex: 1, + fontSize: 16, + height: '100%', + }, + clearButton: { + padding: 4, + }, +}); + +export default SearchBar; \ No newline at end of file diff --git a/src/components/search/SearchResultItem.tsx b/src/components/search/SearchResultItem.tsx new file mode 100644 index 00000000..33dc73ad --- /dev/null +++ b/src/components/search/SearchResultItem.tsx @@ -0,0 +1,75 @@ +import React from 'react'; +import { View, Text, StyleSheet, TouchableOpacity, Dimensions } from 'react-native'; +import { Image } from 'expo-image'; +import { colors } from '../../styles'; +import { StreamingContent } from '../../services/catalogService'; + +const { width } = Dimensions.get('window'); +const HORIZONTAL_ITEM_WIDTH = width * 0.3; +const HORIZONTAL_POSTER_HEIGHT = HORIZONTAL_ITEM_WIDTH * 1.5; + +const PLACEHOLDER_POSTER = 'https://placehold.co/300x450/222222/CCCCCC?text=No+Poster'; + +interface SearchResultItemProps { + item: StreamingContent; + onPress: (item: StreamingContent) => void; + isDarkMode?: boolean; +} + +const SearchResultItem: React.FC = ({ + item, + onPress, + isDarkMode = true +}) => { + return ( + onPress(item)} + > + + + + + {item.name} + + + ); +}; + +const styles = StyleSheet.create({ + horizontalItem: { + width: HORIZONTAL_ITEM_WIDTH, + marginRight: 12, + }, + horizontalItemPosterContainer: { + width: HORIZONTAL_ITEM_WIDTH, + height: HORIZONTAL_POSTER_HEIGHT, + borderRadius: 8, + overflow: 'hidden', + backgroundColor: colors.darkBackground, + marginBottom: 8, + }, + horizontalItemPoster: { + width: '100%', + height: '100%', + }, + horizontalItemTitle: { + fontSize: 14, + fontWeight: '500', + lineHeight: 18, + textAlign: 'left', + }, +}); + +export default SearchResultItem; \ No newline at end of file diff --git a/src/components/search/SkeletonLoader.tsx b/src/components/search/SkeletonLoader.tsx new file mode 100644 index 00000000..0608d324 --- /dev/null +++ b/src/components/search/SkeletonLoader.tsx @@ -0,0 +1,108 @@ +import React from 'react'; +import { View, StyleSheet, Animated } from 'react-native'; +import { colors } from '../../styles'; + +const POSTER_WIDTH = 90; +const POSTER_HEIGHT = 135; + +const SkeletonLoader: React.FC = () => { + const pulseAnim = React.useRef(new Animated.Value(0)).current; + + React.useEffect(() => { + const pulse = Animated.loop( + Animated.sequence([ + Animated.timing(pulseAnim, { + toValue: 1, + duration: 1000, + useNativeDriver: true, + }), + Animated.timing(pulseAnim, { + toValue: 0, + duration: 1000, + useNativeDriver: true, + }), + ]) + ); + pulse.start(); + return () => pulse.stop(); + }, [pulseAnim]); + + const opacity = pulseAnim.interpolate({ + inputRange: [0, 1], + outputRange: [0.3, 0.7], + }); + + const renderSkeletonItem = () => ( + + + + + + + + + + + ); + + return ( + + {[...Array(5)].map((_, index) => ( + + {index === 0 && ( + + )} + {renderSkeletonItem()} + + ))} + + ); +}; + +const styles = StyleSheet.create({ + skeletonContainer: { + padding: 16, + }, + skeletonVerticalItem: { + flexDirection: 'row', + marginBottom: 16, + }, + skeletonPoster: { + width: POSTER_WIDTH, + height: POSTER_HEIGHT, + borderRadius: 8, + backgroundColor: colors.darkBackground, + }, + skeletonItemDetails: { + flex: 1, + marginLeft: 16, + justifyContent: 'center', + }, + skeletonMetaRow: { + flexDirection: 'row', + gap: 8, + marginTop: 8, + }, + skeletonTitle: { + height: 20, + width: '80%', + marginBottom: 8, + backgroundColor: colors.darkBackground, + borderRadius: 4, + }, + skeletonMeta: { + height: 14, + width: '30%', + backgroundColor: colors.darkBackground, + borderRadius: 4, + }, + skeletonSectionHeader: { + height: 24, + width: '40%', + backgroundColor: colors.darkBackground, + marginBottom: 16, + borderRadius: 4, + }, +}); + +export default SkeletonLoader; \ No newline at end of file diff --git a/src/components/search/index.ts b/src/components/search/index.ts new file mode 100644 index 00000000..0bd40d45 --- /dev/null +++ b/src/components/search/index.ts @@ -0,0 +1,6 @@ +export { default as SearchBar } from './SearchBar'; +export { default as SkeletonLoader } from './SkeletonLoader'; +export { default as RecentSearches } from './RecentSearches'; +export { default as SearchResultItem } from './SearchResultItem'; +export { default as ResultsCarousel } from './ResultsCarousel'; +export { default as EmptyResults } from './EmptyResults'; \ No newline at end of file diff --git a/src/screens/SearchScreen.tsx b/src/screens/SearchScreen.tsx index 63459f80..5a4c3755 100644 --- a/src/screens/SearchScreen.tsx +++ b/src/screens/SearchScreen.tsx @@ -3,95 +3,32 @@ import { View, Text, StyleSheet, - TextInput, - FlatList, - TouchableOpacity, - ActivityIndicator, - useColorScheme, + Keyboard, SafeAreaView, StatusBar, - Keyboard, - Dimensions, ScrollView, - Animated as RNAnimated, + Dimensions, } from 'react-native'; import { useNavigation } from '@react-navigation/native'; import { NavigationProp } from '@react-navigation/native'; -import { MaterialIcons } from '@expo/vector-icons'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import debounce from 'lodash/debounce'; import { colors } from '../styles'; import { catalogService, StreamingContent } from '../services/catalogService'; -import { Image } from 'expo-image'; -import debounce from 'lodash/debounce'; -import AsyncStorage from '@react-native-async-storage/async-storage'; -import Animated, { FadeIn, FadeOut, SlideInRight } from 'react-native-reanimated'; import { RootStackParamList } from '../navigation/AppNavigator'; import { logger } from '../utils/logger'; +import { + SearchBar, + SkeletonLoader, + RecentSearches, + ResultsCarousel, + EmptyResults +} from '../components/search'; -const { width } = Dimensions.get('window'); -const HORIZONTAL_ITEM_WIDTH = width * 0.3; -const HORIZONTAL_POSTER_HEIGHT = HORIZONTAL_ITEM_WIDTH * 1.5; -const POSTER_WIDTH = 90; -const POSTER_HEIGHT = 135; const RECENT_SEARCHES_KEY = 'recent_searches'; const MAX_RECENT_SEARCHES = 10; -const PLACEHOLDER_POSTER = 'https://placehold.co/300x450/222222/CCCCCC?text=No+Poster'; - -const SkeletonLoader = () => { - const pulseAnim = React.useRef(new RNAnimated.Value(0)).current; - - React.useEffect(() => { - const pulse = RNAnimated.loop( - RNAnimated.sequence([ - RNAnimated.timing(pulseAnim, { - toValue: 1, - duration: 1000, - useNativeDriver: true, - }), - RNAnimated.timing(pulseAnim, { - toValue: 0, - duration: 1000, - useNativeDriver: true, - }), - ]) - ); - pulse.start(); - return () => pulse.stop(); - }, [pulseAnim]); - - const opacity = pulseAnim.interpolate({ - inputRange: [0, 1], - outputRange: [0.3, 0.7], - }); - - const renderSkeletonItem = () => ( - - - - - - - - - - - ); - - return ( - - {[...Array(5)].map((_, index) => ( - - {index === 0 && ( - - )} - {renderSkeletonItem()} - - ))} - - ); -}; - -const SearchScreen = () => { +const SearchScreen: React.FC = () => { const navigation = useNavigation>(); const isDarkMode = true; const [query, setQuery] = useState(''); @@ -180,65 +117,13 @@ const SearchScreen = () => { loadRecentSearches(); }; - const renderRecentSearches = () => { - if (!showRecent || recentSearches.length === 0) return null; - - return ( - - - Recent Searches - - {recentSearches.map((search, index) => ( - { - setQuery(search); - Keyboard.dismiss(); - }} - > - - - {search} - - - ))} - - ); + const handleRecentSearchSelect = (search: string) => { + setQuery(search); + Keyboard.dismiss(); }; - const renderHorizontalItem = ({ item }: { item: StreamingContent }) => { - return ( - { - navigation.navigate('Metadata', { id: item.id, type: item.type }); - }} - > - - - - - {item.name} - - - ); + const handleItemPress = (item: StreamingContent) => { + navigation.navigate('Metadata', { id: item.id, type: item.type }); }; const movieResults = useMemo(() => { @@ -265,70 +150,17 @@ const SearchScreen = () => { Search - - - - {query.length > 0 && ( - - - - )} - + {searching ? ( ) : searched && !hasResultsToShow ? ( - - - - No results found - - - Try different keywords or check your spelling - - + ) : ( { keyboardShouldPersistTaps="handled" onScrollBeginDrag={Keyboard.dismiss} > - {!query.trim() && renderRecentSearches()} + {showRecent && ( + + )} {movieResults.length > 0 && ( - - Movies ({movieResults.length}) - `movie-${item.id}`} - horizontal - showsHorizontalScrollIndicator={false} - contentContainerStyle={styles.horizontalListContent} - /> - + )} {seriesResults.length > 0 && ( - - TV Shows ({seriesResults.length}) - `series-${item.id}`} - horizontal - showsHorizontalScrollIndicator={false} - contentContainerStyle={styles.horizontalListContent} - /> - + )} @@ -389,152 +217,12 @@ const styles = StyleSheet.create({ color: colors.white, letterSpacing: 0.5, }, - searchBar: { - flexDirection: 'row', - alignItems: 'center', - borderRadius: 24, - paddingHorizontal: 16, - height: 48, - }, - searchIcon: { - marginRight: 12, - }, - searchInput: { - flex: 1, - fontSize: 16, - height: '100%', - }, - clearButton: { - padding: 4, - }, scrollView: { flex: 1, }, scrollViewContent: { paddingBottom: 20, }, - carouselContainer: { - marginBottom: 24, - }, - carouselTitle: { - fontSize: 18, - fontWeight: '700', - color: colors.white, - marginBottom: 12, - paddingHorizontal: 16, - }, - horizontalListContent: { - paddingHorizontal: 16, - paddingRight: 8, - }, - horizontalItem: { - width: HORIZONTAL_ITEM_WIDTH, - marginRight: 12, - }, - horizontalItemPosterContainer: { - width: HORIZONTAL_ITEM_WIDTH, - height: HORIZONTAL_POSTER_HEIGHT, - borderRadius: 8, - overflow: 'hidden', - backgroundColor: colors.darkBackground, - marginBottom: 8, - }, - horizontalItemPoster: { - width: '100%', - height: '100%', - }, - horizontalItemTitle: { - fontSize: 14, - fontWeight: '500', - lineHeight: 18, - textAlign: 'left', - }, - recentSearchesContainer: { - paddingHorizontal: 0, - paddingBottom: 16, - }, - recentSearchItem: { - flexDirection: 'row', - alignItems: 'center', - paddingVertical: 10, - paddingHorizontal: 16, - }, - recentSearchIcon: { - marginRight: 12, - }, - recentSearchText: { - fontSize: 16, - flex: 1, - }, - loadingContainer: { - flex: 1, - justifyContent: 'center', - alignItems: 'center', - }, - loadingText: { - marginTop: 16, - fontSize: 16, - }, - emptyContainer: { - flex: 1, - justifyContent: 'center', - alignItems: 'center', - paddingHorizontal: 32, - }, - emptyText: { - fontSize: 18, - fontWeight: 'bold', - marginTop: 16, - marginBottom: 8, - }, - emptySubtext: { - fontSize: 14, - textAlign: 'center', - lineHeight: 20, - }, - skeletonContainer: { - padding: 16, - }, - skeletonVerticalItem: { - flexDirection: 'row', - marginBottom: 16, - }, - skeletonPoster: { - width: POSTER_WIDTH, - height: POSTER_HEIGHT, - borderRadius: 8, - backgroundColor: colors.darkBackground, - }, - skeletonItemDetails: { - flex: 1, - marginLeft: 16, - justifyContent: 'center', - }, - skeletonMetaRow: { - flexDirection: 'row', - gap: 8, - marginTop: 8, - }, - skeletonTitle: { - height: 20, - width: '80%', - marginBottom: 8, - backgroundColor: colors.darkBackground, - borderRadius: 4, - }, - skeletonMeta: { - height: 14, - width: '30%', - backgroundColor: colors.darkBackground, - borderRadius: 4, - }, - skeletonSectionHeader: { - height: 24, - width: '40%', - backgroundColor: colors.darkBackground, - marginBottom: 16, - borderRadius: 4, - }, }); export default SearchScreen; \ No newline at end of file