init
This commit is contained in:
186
src/app/add-feed.tsx
Normal file
186
src/app/add-feed.tsx
Normal file
@@ -0,0 +1,186 @@
|
||||
// Add Feed Screen
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
View,
|
||||
TextInput,
|
||||
Button,
|
||||
Text,
|
||||
StyleSheet,
|
||||
Alert,
|
||||
ActivityIndicator,
|
||||
} from 'react-native';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { useFeedStore } from '@/stores/feed-store';
|
||||
import { parseFeed } from '@/services/feed-service';
|
||||
import { generateId } from '@/utils/helpers';
|
||||
import { t } from '@/i18n';
|
||||
|
||||
export default function AddFeedScreen() {
|
||||
const router = useRouter();
|
||||
const [url, setUrl] = useState('');
|
||||
const [title, setTitle] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [preview, setPreview] = useState<any>(null);
|
||||
const addSubscription = useFeedStore((state) => state.addSubscription);
|
||||
|
||||
const handlePreview = async () => {
|
||||
if (!url) {
|
||||
Alert.alert('Error', 'Please enter a feed URL');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setPreview(null);
|
||||
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
const text = await response.text();
|
||||
const result = await parseFeed(url, text);
|
||||
|
||||
if (result.success && result.feed) {
|
||||
setPreview(result.feed);
|
||||
} else {
|
||||
Alert.alert('Error', result.error || 'Failed to parse feed');
|
||||
}
|
||||
} catch (error) {
|
||||
Alert.alert('Error', 'Failed to fetch feed');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAdd = async () => {
|
||||
if (!url) {
|
||||
Alert.alert('Error', 'Please enter a feed URL');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!preview) {
|
||||
Alert.alert('Error', 'Please preview the feed first');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const subscription = {
|
||||
id: generateId(),
|
||||
url,
|
||||
title: title || preview.title,
|
||||
enabled: true,
|
||||
fetchInterval: 60, // Default 1 hour
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
await addSubscription(subscription);
|
||||
router.back();
|
||||
} catch (error) {
|
||||
Alert.alert('Error', 'Failed to add subscription');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.title}>{t('feed.add')}</Text>
|
||||
|
||||
<View style={styles.inputContainer}>
|
||||
<Text style={styles.label}>URL</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="https://example.com/feed.xml"
|
||||
value={url}
|
||||
onChangeText={setUrl}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
keyboardType="url"
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.inputContainer}>
|
||||
<Text style={styles.label}>Title (optional)</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="My Feed"
|
||||
value={title}
|
||||
onChangeText={setTitle}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<Button
|
||||
title={loading && !preview ? 'Loading...' : 'Preview'}
|
||||
onPress={handlePreview}
|
||||
disabled={loading || !url}
|
||||
/>
|
||||
|
||||
{preview && (
|
||||
<View style={styles.previewContainer}>
|
||||
<Text style={styles.previewTitle}>{preview.title}</Text>
|
||||
<Text style={styles.previewDescription}>
|
||||
{preview.description || 'No description'}
|
||||
</Text>
|
||||
<Text style={styles.previewItems}>
|
||||
{preview.items.length} items
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<Button
|
||||
title={loading ? 'Adding...' : 'Add Feed'}
|
||||
onPress={handleAdd}
|
||||
disabled={loading || !preview}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
padding: 20,
|
||||
paddingTop: 60,
|
||||
},
|
||||
title: {
|
||||
fontSize: 24,
|
||||
fontWeight: 'bold',
|
||||
marginBottom: 30,
|
||||
},
|
||||
inputContainer: {
|
||||
marginBottom: 20,
|
||||
},
|
||||
label: {
|
||||
fontSize: 14,
|
||||
marginBottom: 8,
|
||||
},
|
||||
input: {
|
||||
borderWidth: 1,
|
||||
borderColor: '#ccc',
|
||||
borderRadius: 8,
|
||||
padding: 12,
|
||||
fontSize: 16,
|
||||
},
|
||||
previewContainer: {
|
||||
marginTop: 20,
|
||||
padding: 16,
|
||||
borderWidth: 1,
|
||||
borderColor: '#ddd',
|
||||
borderRadius: 8,
|
||||
},
|
||||
previewTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: 'bold',
|
||||
marginBottom: 8,
|
||||
},
|
||||
previewDescription: {
|
||||
fontSize: 14,
|
||||
color: '#666',
|
||||
marginBottom: 8,
|
||||
},
|
||||
previewItems: {
|
||||
fontSize: 12,
|
||||
color: '#999',
|
||||
},
|
||||
});
|
||||
270
src/app/article/[id].tsx
Normal file
270
src/app/article/[id].tsx
Normal file
@@ -0,0 +1,270 @@
|
||||
// Article Detail View
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
View,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Share,
|
||||
Linking,
|
||||
Image,
|
||||
Pressable,
|
||||
} from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { SymbolView } from 'expo-symbols';
|
||||
import { useLocalSearchParams } from 'expo-router';
|
||||
|
||||
import { ThemedText } from '@/components/themed-text';
|
||||
import { ThemedView } from '@/components/themed-view';
|
||||
import { useTheme } from '@/hooks/use-theme';
|
||||
import { useBookmarkStore } from '@/stores/bookmark-store';
|
||||
import { FeedItem } from '@/types/feed';
|
||||
import { getFeedItems, getAllFeedItems } from '@/services/database';
|
||||
import { Colors, Spacing, MaxContentWidth, BottomTabInset } from '@/constants/theme';
|
||||
|
||||
export default function ArticleDetailScreen() {
|
||||
const { id } = useLocalSearchParams<{ id: string }>();
|
||||
const insets = useSafeAreaInsets();
|
||||
const theme = useTheme();
|
||||
const { isBookmarked, toggleBookmark } = useBookmarkStore();
|
||||
|
||||
const [article, setArticle] = useState<FeedItem | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const bookmarked = id ? isBookmarked(id) : false;
|
||||
|
||||
useEffect(() => {
|
||||
async function loadArticle() {
|
||||
if (!id) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const items = await getFeedItems(undefined, 500);
|
||||
const found = items.find(item => item.id === id);
|
||||
setArticle(found || null);
|
||||
} catch (error) {
|
||||
console.error('Failed to load article:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
loadArticle();
|
||||
}, [id]);
|
||||
|
||||
const handleShare = async () => {
|
||||
if (!article?.link) return;
|
||||
|
||||
try {
|
||||
await Share.share({
|
||||
message: `${article.title}\n\n${article.link}`,
|
||||
title: article.title,
|
||||
url: article.link,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Share error:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenInBrowser = () => {
|
||||
if (article?.link) {
|
||||
Linking.openURL(article.link);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (date?: Date) => {
|
||||
if (!date) return '';
|
||||
return new Date(date).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
});
|
||||
};
|
||||
|
||||
const renderContent = (content?: string) => {
|
||||
if (!content) return null;
|
||||
|
||||
return (
|
||||
<ThemedText style={styles.content}>
|
||||
{content}
|
||||
</ThemedText>
|
||||
);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<ThemedView style={styles.container}>
|
||||
<ThemedView style={styles.loadingContainer}>
|
||||
<ThemedText>Loading...</ThemedText>
|
||||
</ThemedView>
|
||||
</ThemedView>
|
||||
);
|
||||
}
|
||||
|
||||
if (!article) {
|
||||
return (
|
||||
<ThemedView style={styles.container}>
|
||||
<ThemedView style={styles.errorContainer}>
|
||||
<ThemedText>Article not found</ThemedText>
|
||||
</ThemedView>
|
||||
</ThemedView>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ThemedView style={styles.container}>
|
||||
<ScrollView
|
||||
style={styles.scrollView}
|
||||
contentContainerStyle={[
|
||||
styles.contentContainer,
|
||||
{ paddingBottom: insets.bottom + BottomTabInset + Spacing.four },
|
||||
]}>
|
||||
<ThemedView style={styles.header}>
|
||||
<ThemedText type="title" style={styles.title}>
|
||||
{article.title}
|
||||
</ThemedText>
|
||||
|
||||
{article.author && (
|
||||
<ThemedText type="small" themeColor="textSecondary" style={styles.author}>
|
||||
By {article.author}
|
||||
</ThemedText>
|
||||
)}
|
||||
|
||||
{article.published && (
|
||||
<ThemedText type="small" themeColor="textSecondary">
|
||||
{formatDate(article.published)}
|
||||
</ThemedText>
|
||||
)}
|
||||
</ThemedView>
|
||||
|
||||
<ThemedView style={styles.actions}>
|
||||
<Pressable
|
||||
style={[styles.actionButton, { backgroundColor: theme.backgroundElement }]}
|
||||
onPress={() => id && toggleBookmark(id)}>
|
||||
<SymbolView
|
||||
tintColor={bookmarked ? '#007AFF' : theme.text}
|
||||
name={bookmarked ? 'bookmark_fill' as any : 'bookmark' as any}
|
||||
size={20}
|
||||
/>
|
||||
</Pressable>
|
||||
|
||||
<Pressable
|
||||
style={[styles.actionButton, { backgroundColor: theme.backgroundElement }]}
|
||||
onPress={handleShare}>
|
||||
<SymbolView
|
||||
tintColor={theme.text}
|
||||
name={{ ios: 'square.and.arrow.up', android: 'share', web: 'share' }}
|
||||
size={20}
|
||||
/>
|
||||
</Pressable>
|
||||
|
||||
<Pressable
|
||||
style={[styles.actionButton, { backgroundColor: theme.backgroundElement }]}
|
||||
onPress={handleOpenInBrowser}>
|
||||
<SymbolView
|
||||
tintColor={theme.text}
|
||||
name={{ ios: 'safari', android: 'public', web: 'globe' }}
|
||||
size={20}
|
||||
/>
|
||||
</Pressable>
|
||||
</ThemedView>
|
||||
|
||||
{article.description && (
|
||||
<ThemedView style={styles.section}>
|
||||
<ThemedText type="default" style={styles.description}>
|
||||
{article.description}
|
||||
</ThemedText>
|
||||
</ThemedView>
|
||||
)}
|
||||
|
||||
{article.content && (
|
||||
<ThemedView style={styles.section}>
|
||||
{renderContent(article.content)}
|
||||
</ThemedView>
|
||||
)}
|
||||
|
||||
{article.link && (
|
||||
<Pressable
|
||||
style={[styles.readMoreButton, { backgroundColor: theme.text }]}
|
||||
onPress={handleOpenInBrowser}>
|
||||
<ThemedText style={[styles.readMoreText, { color: theme.background }]}>
|
||||
Read Full Article
|
||||
</ThemedText>
|
||||
</Pressable>
|
||||
)}
|
||||
</ScrollView>
|
||||
</ThemedView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
scrollView: {
|
||||
flex: 1,
|
||||
},
|
||||
contentContainer: {
|
||||
maxWidth: MaxContentWidth,
|
||||
marginHorizontal: 'auto',
|
||||
flexGrow: 1,
|
||||
},
|
||||
loadingContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
errorContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
header: {
|
||||
padding: Spacing.four,
|
||||
gap: Spacing.two,
|
||||
},
|
||||
title: {
|
||||
fontSize: 24,
|
||||
fontWeight: '700',
|
||||
lineHeight: 32,
|
||||
},
|
||||
author: {
|
||||
marginTop: Spacing.two,
|
||||
},
|
||||
actions: {
|
||||
flexDirection: 'row',
|
||||
paddingHorizontal: Spacing.four,
|
||||
paddingVertical: Spacing.two,
|
||||
gap: Spacing.two,
|
||||
},
|
||||
actionButton: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 22,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
section: {
|
||||
padding: Spacing.four,
|
||||
paddingTop: 0,
|
||||
},
|
||||
description: {
|
||||
fontSize: 16,
|
||||
lineHeight: 24,
|
||||
color: '#333',
|
||||
},
|
||||
content: {
|
||||
fontSize: 16,
|
||||
lineHeight: 26,
|
||||
},
|
||||
readMoreButton: {
|
||||
margin: Spacing.four,
|
||||
padding: Spacing.three,
|
||||
borderRadius: Spacing.three,
|
||||
alignItems: 'center',
|
||||
},
|
||||
readMoreText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
@@ -1,24 +1,117 @@
|
||||
import { Image } from 'expo-image';
|
||||
import { SymbolView } from 'expo-symbols';
|
||||
import React from 'react';
|
||||
import { Platform, Pressable, ScrollView, StyleSheet } from 'react-native';
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Platform,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
TextInput,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { SymbolView } from 'expo-symbols';
|
||||
|
||||
import { ExternalLink } from '@/components/external-link';
|
||||
import { ThemedText } from '@/components/themed-text';
|
||||
import { ThemedView } from '@/components/themed-view';
|
||||
import { Collapsible } from '@/components/ui/collapsible';
|
||||
import { WebBadge } from '@/components/web-badge';
|
||||
import { BottomTabInset, MaxContentWidth, Spacing } from '@/constants/theme';
|
||||
import { useTheme } from '@/hooks/use-theme';
|
||||
import { t } from '@/i18n';
|
||||
|
||||
export default function TabTwoScreen() {
|
||||
type CategoryIcon = {
|
||||
ios: string;
|
||||
android: string;
|
||||
web: string;
|
||||
};
|
||||
|
||||
const CATEGORY_ICONS: Record<string, CategoryIcon> = {
|
||||
tech: { ios: 'laptopcomputer', android: 'laptop', web: 'laptop' },
|
||||
news: { ios: 'doc.text', android: 'article', web: 'article' },
|
||||
sports: { ios: 'figure.run', android: 'run', web: 'run' },
|
||||
business: { ios: 'briefcase', android: 'briefcase', web: 'briefcase' },
|
||||
entertainment: { ios: 'star', android: 'star', web: 'star' },
|
||||
health: { ios: 'heart.fill', android: 'heart', web: 'heart' },
|
||||
science: { ios: 'beaker', android: 'flask', web: 'flask' },
|
||||
food: { ios: 'bowl.with.spoon', android: 'restaurant', web: 'restaurant' },
|
||||
};
|
||||
|
||||
const CATEGORIES = [
|
||||
{ id: 'tech', name: 'Technology' },
|
||||
{ id: 'news', name: 'News' },
|
||||
{ id: 'sports', name: 'Sports' },
|
||||
{ id: 'business', name: 'Business' },
|
||||
{ id: 'entertainment', name: 'Entertainment' },
|
||||
{ id: 'health', name: 'Health' },
|
||||
{ id: 'science', name: 'Science' },
|
||||
{ id: 'food', name: 'Food' },
|
||||
];
|
||||
|
||||
const TRENDING_FEEDS = [
|
||||
{
|
||||
id: '1',
|
||||
title: 'TechCrunch',
|
||||
description: 'Latest technology news and startups',
|
||||
category: 'tech',
|
||||
subscribers: 125000,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
title: 'The Verge',
|
||||
description: 'Technology, science, art, and culture',
|
||||
category: 'tech',
|
||||
subscribers: 98000,
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
title: 'Hacker News',
|
||||
description: 'News about hacking and startups',
|
||||
category: 'tech',
|
||||
subscribers: 87000,
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
title: 'BBC News',
|
||||
description: 'Breaking news and features',
|
||||
category: 'news',
|
||||
subscribers: 156000,
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
title: 'ESPN',
|
||||
description: 'Sports news and highlights',
|
||||
category: 'sports',
|
||||
subscribers: 112000,
|
||||
},
|
||||
];
|
||||
|
||||
const RECOMMENDED_PODCASTS = [
|
||||
{
|
||||
id: '1',
|
||||
title: 'The Daily',
|
||||
description: 'News explained',
|
||||
publisher: 'The New York Times',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
title: 'Reply All',
|
||||
description: 'About the internet',
|
||||
publisher: 'Gimlet Media',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
title: '99% Invisible',
|
||||
description: 'Design and architecture',
|
||||
publisher: 'Roman Mars',
|
||||
},
|
||||
];
|
||||
|
||||
export default function ExploreScreen() {
|
||||
const safeAreaInsets = useSafeAreaInsets();
|
||||
const insets = {
|
||||
...safeAreaInsets,
|
||||
bottom: safeAreaInsets.bottom + BottomTabInset + Spacing.three,
|
||||
};
|
||||
const theme = useTheme();
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
|
||||
|
||||
const contentPlatformStyle = Platform.select({
|
||||
android: {
|
||||
@@ -33,94 +126,182 @@ export default function TabTwoScreen() {
|
||||
},
|
||||
});
|
||||
|
||||
const filteredTrending = TRENDING_FEEDS.filter((feed) => {
|
||||
const matchesSearch =
|
||||
feed.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
feed.description.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
const matchesCategory = selectedCategory ? feed.category === selectedCategory : true;
|
||||
return matchesSearch && matchesCategory;
|
||||
});
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
style={[styles.scrollView, { backgroundColor: theme.background }]}
|
||||
contentInset={insets}
|
||||
contentContainerStyle={[styles.contentContainer, contentPlatformStyle]}>
|
||||
<ThemedView style={styles.container}>
|
||||
<ThemedView style={styles.titleContainer}>
|
||||
<ThemedText type="subtitle">Explore</ThemedText>
|
||||
<ThemedText style={styles.centerText} themeColor="textSecondary">
|
||||
This starter app includes example{'\n'}code to help you get started.
|
||||
</ThemedText>
|
||||
|
||||
<ExternalLink href="https://docs.expo.dev" asChild>
|
||||
<Pressable style={({ pressed }) => pressed && styles.pressed}>
|
||||
<ThemedView type="backgroundElement" style={styles.linkButton}>
|
||||
<ThemedText type="link">Expo documentation</ThemedText>
|
||||
<SymbolView
|
||||
tintColor={theme.text}
|
||||
name={{ ios: 'arrow.up.right.square', android: 'link', web: 'link' }}
|
||||
size={12}
|
||||
/>
|
||||
</ThemedView>
|
||||
</Pressable>
|
||||
</ExternalLink>
|
||||
<ThemedView style={styles.header}>
|
||||
<ThemedText type="title">{t('tab.explore')}</ThemedText>
|
||||
<ThemedText themeColor="textSecondary">{t('explore.discover')}</ThemedText>
|
||||
</ThemedView>
|
||||
|
||||
<ThemedView style={styles.sectionsWrapper}>
|
||||
<Collapsible title="File-based routing">
|
||||
<ThemedText type="small">
|
||||
This app has two screens: <ThemedText type="code">src/app/index.tsx</ThemedText> and{' '}
|
||||
<ThemedText type="code">src/app/explore.tsx</ThemedText>
|
||||
</ThemedText>
|
||||
<ThemedText type="small">
|
||||
The layout file in <ThemedText type="code">src/app/_layout.tsx</ThemedText> sets up
|
||||
the tab navigator.
|
||||
</ThemedText>
|
||||
<ExternalLink href="https://docs.expo.dev/router/introduction">
|
||||
<ThemedText type="linkPrimary">Learn more</ThemedText>
|
||||
</ExternalLink>
|
||||
</Collapsible>
|
||||
<ThemedView style={styles.searchContainer}>
|
||||
<SymbolView
|
||||
tintColor={theme.textSecondary}
|
||||
name={{ ios: 'magnifyingglass', android: 'search', web: 'search' }}
|
||||
size={18}
|
||||
style={styles.searchIcon}
|
||||
/>
|
||||
<TextInput
|
||||
style={[styles.searchInput, { color: theme.text }]}
|
||||
placeholder={t('explore.searchPlaceholder')}
|
||||
placeholderTextColor={theme.textSecondary}
|
||||
value={searchQuery}
|
||||
onChangeText={setSearchQuery}
|
||||
clearButtonMode="while-editing"
|
||||
/>
|
||||
</ThemedView>
|
||||
|
||||
<Collapsible title="Android, iOS, and web support">
|
||||
<ThemedView type="backgroundElement" style={styles.collapsibleContent}>
|
||||
<ThemedText type="small">
|
||||
You can open this project on Android, iOS, and the web. To open the web version,
|
||||
press <ThemedText type="smallBold">w</ThemedText> in the terminal running this
|
||||
project.
|
||||
<ThemedView style={styles.categoriesContainer}>
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={styles.categoriesContent}>
|
||||
<Pressable
|
||||
style={[
|
||||
styles.categoryChip,
|
||||
!selectedCategory && { backgroundColor: theme.text },
|
||||
]}
|
||||
onPress={() => setSelectedCategory(null)}>
|
||||
<ThemedText
|
||||
style={[
|
||||
styles.categoryChipText,
|
||||
!selectedCategory && { color: theme.background },
|
||||
]}>
|
||||
{t('explore.all')}
|
||||
</ThemedText>
|
||||
<Image
|
||||
source={require('@/assets/images/tutorial-web.png')}
|
||||
style={styles.imageTutorial}
|
||||
/>
|
||||
</ThemedView>
|
||||
</Collapsible>
|
||||
|
||||
<Collapsible title="Images">
|
||||
<ThemedText type="small">
|
||||
For static images, you can use the <ThemedText type="code">@2x</ThemedText> and{' '}
|
||||
<ThemedText type="code">@3x</ThemedText> suffixes to provide files for different
|
||||
screen densities.
|
||||
</ThemedText>
|
||||
<Image source={require('@/assets/images/react-logo.png')} style={styles.imageReact} />
|
||||
<ExternalLink href="https://reactnative.dev/docs/images">
|
||||
<ThemedText type="linkPrimary">Learn more</ThemedText>
|
||||
</ExternalLink>
|
||||
</Collapsible>
|
||||
|
||||
<Collapsible title="Light and dark mode components">
|
||||
<ThemedText type="small">
|
||||
This template has light and dark mode support. The{' '}
|
||||
<ThemedText type="code">useColorScheme()</ThemedText> hook lets you inspect what the
|
||||
user's current color scheme is, and so you can adjust UI colors accordingly.
|
||||
</ThemedText>
|
||||
<ExternalLink href="https://docs.expo.dev/develop/user-interface/color-themes/">
|
||||
<ThemedText type="linkPrimary">Learn more</ThemedText>
|
||||
</ExternalLink>
|
||||
</Collapsible>
|
||||
|
||||
<Collapsible title="Animations">
|
||||
<ThemedText type="small">
|
||||
This template includes an example of an animated component. The{' '}
|
||||
<ThemedText type="code">src/components/ui/collapsible.tsx</ThemedText> component uses
|
||||
the powerful <ThemedText type="code">react-native-reanimated</ThemedText> library to
|
||||
animate opening this hint.
|
||||
</ThemedText>
|
||||
</Collapsible>
|
||||
</Pressable>
|
||||
{CATEGORIES.map((category) => (
|
||||
<Pressable
|
||||
key={category.id}
|
||||
style={[
|
||||
styles.categoryChip,
|
||||
selectedCategory === category.id && { backgroundColor: theme.text },
|
||||
]}
|
||||
onPress={() =>
|
||||
setSelectedCategory(
|
||||
selectedCategory === category.id ? null : category.id
|
||||
)
|
||||
}>
|
||||
<SymbolView
|
||||
tintColor={
|
||||
selectedCategory === category.id ? theme.background : theme.text
|
||||
}
|
||||
name={(CATEGORY_ICONS[category.id] as any)}
|
||||
size={14}
|
||||
style={styles.categoryIcon}
|
||||
/>
|
||||
<ThemedText
|
||||
style={[
|
||||
styles.categoryChipText,
|
||||
selectedCategory === category.id && { color: theme.background },
|
||||
]}>
|
||||
{category.name}
|
||||
</ThemedText>
|
||||
</Pressable>
|
||||
))}
|
||||
</ScrollView>
|
||||
</ThemedView>
|
||||
|
||||
<ThemedView style={styles.section}>
|
||||
<ThemedView style={styles.sectionHeader}>
|
||||
<ThemedText type="section">{t('explore.trending')}</ThemedText>
|
||||
</ThemedView>
|
||||
<ThemedView style={styles.feedsList}>
|
||||
{filteredTrending.map((feed) => (
|
||||
<Pressable
|
||||
key={feed.id}
|
||||
style={[
|
||||
styles.feedCard,
|
||||
{ backgroundColor: theme.backgroundElement },
|
||||
]}>
|
||||
<ThemedView style={styles.feedCardContent}>
|
||||
<ThemedText type="defaultSemiBold" numberOfLines={1}>
|
||||
{feed.title}
|
||||
</ThemedText>
|
||||
<ThemedText
|
||||
themeColor="textSecondary"
|
||||
numberOfLines={2}
|
||||
style={styles.feedDescription}>
|
||||
{feed.description}
|
||||
</ThemedText>
|
||||
<ThemedView style={styles.feedMeta}>
|
||||
<ThemedText themeColor="textSecondary" type="small">
|
||||
{(feed.subscribers / 1000).toFixed(1)}K subscribers
|
||||
</ThemedText>
|
||||
</ThemedView>
|
||||
</ThemedView>
|
||||
<Pressable style={[styles.addButton, { backgroundColor: theme.text }]}>
|
||||
<SymbolView
|
||||
tintColor={theme.background}
|
||||
name={{ ios: 'plus', android: 'add', web: 'add' }}
|
||||
size={16}
|
||||
/>
|
||||
</Pressable>
|
||||
</Pressable>
|
||||
))}
|
||||
{filteredTrending.length === 0 && (
|
||||
<ThemedView style={styles.emptyState}>
|
||||
<ThemedText themeColor="textSecondary">
|
||||
{t('explore.noFeeds')}
|
||||
</ThemedText>
|
||||
</ThemedView>
|
||||
)}
|
||||
</ThemedView>
|
||||
</ThemedView>
|
||||
|
||||
<ThemedView style={styles.section}>
|
||||
<ThemedView style={styles.sectionHeader}>
|
||||
<ThemedText type="section">{t('explore.recommended')}</ThemedText>
|
||||
</ThemedView>
|
||||
<ThemedView style={styles.feedsList}>
|
||||
{RECOMMENDED_PODCASTS.map((podcast) => (
|
||||
<Pressable
|
||||
key={podcast.id}
|
||||
style={[
|
||||
styles.feedCard,
|
||||
{ backgroundColor: theme.backgroundElement },
|
||||
]}>
|
||||
<ThemedView style={styles.feedCardContent}>
|
||||
<ThemedView style={styles.podcastHeader}>
|
||||
<SymbolView
|
||||
tintColor={theme.textSecondary}
|
||||
name={{ ios: 'waveform', android: 'equalizer', web: 'equalizer' }}
|
||||
size={14}
|
||||
style={styles.podcastIcon}
|
||||
/>
|
||||
<ThemedText type="defaultSemiBold" numberOfLines={1}>
|
||||
{podcast.title}
|
||||
</ThemedText>
|
||||
</ThemedView>
|
||||
<ThemedText
|
||||
themeColor="textSecondary"
|
||||
numberOfLines={2}
|
||||
style={styles.feedDescription}>
|
||||
{podcast.description} • {podcast.publisher}
|
||||
</ThemedText>
|
||||
</ThemedView>
|
||||
<Pressable style={[styles.addButton, { backgroundColor: theme.text }]}>
|
||||
<SymbolView
|
||||
tintColor={theme.background}
|
||||
name={{ ios: 'plus', android: 'add', web: 'add' }}
|
||||
size={16}
|
||||
/>
|
||||
</Pressable>
|
||||
</Pressable>
|
||||
))}
|
||||
</ThemedView>
|
||||
</ThemedView>
|
||||
{Platform.OS === 'web' && <WebBadge />}
|
||||
</ThemedView>
|
||||
</ScrollView>
|
||||
);
|
||||
@@ -138,44 +319,97 @@ const styles = StyleSheet.create({
|
||||
maxWidth: MaxContentWidth,
|
||||
flexGrow: 1,
|
||||
},
|
||||
titleContainer: {
|
||||
gap: Spacing.three,
|
||||
alignItems: 'center',
|
||||
header: {
|
||||
paddingHorizontal: Spacing.four,
|
||||
paddingVertical: Spacing.six,
|
||||
paddingVertical: Spacing.four,
|
||||
gap: Spacing.two,
|
||||
},
|
||||
centerText: {
|
||||
textAlign: 'center',
|
||||
},
|
||||
pressed: {
|
||||
opacity: 0.7,
|
||||
},
|
||||
linkButton: {
|
||||
searchContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: Spacing.four,
|
||||
paddingVertical: Spacing.two,
|
||||
borderRadius: Spacing.five,
|
||||
justifyContent: 'center',
|
||||
gap: Spacing.one,
|
||||
alignItems: 'center',
|
||||
},
|
||||
sectionsWrapper: {
|
||||
gap: Spacing.five,
|
||||
paddingHorizontal: Spacing.four,
|
||||
paddingTop: Spacing.three,
|
||||
},
|
||||
collapsibleContent: {
|
||||
alignItems: 'center',
|
||||
},
|
||||
imageTutorial: {
|
||||
width: '100%',
|
||||
aspectRatio: 296 / 171,
|
||||
marginHorizontal: Spacing.four,
|
||||
marginBottom: Spacing.two,
|
||||
borderRadius: Spacing.three,
|
||||
marginTop: Spacing.two,
|
||||
},
|
||||
imageReact: {
|
||||
width: 100,
|
||||
height: 100,
|
||||
alignSelf: 'center',
|
||||
searchIcon: {
|
||||
marginRight: Spacing.two,
|
||||
},
|
||||
searchInput: {
|
||||
flex: 1,
|
||||
fontSize: 16,
|
||||
paddingVertical: Spacing.one,
|
||||
},
|
||||
categoriesContainer: {
|
||||
marginBottom: Spacing.four,
|
||||
},
|
||||
categoriesContent: {
|
||||
paddingHorizontal: Spacing.four,
|
||||
gap: Spacing.two,
|
||||
},
|
||||
categoryChip: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: Spacing.three,
|
||||
paddingVertical: Spacing.two,
|
||||
borderRadius: Spacing.six,
|
||||
backgroundColor: '#e5e5ea',
|
||||
minWidth: 80,
|
||||
},
|
||||
categoryChipText: {
|
||||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
},
|
||||
categoryIcon: {
|
||||
marginRight: Spacing.one,
|
||||
},
|
||||
section: {
|
||||
marginBottom: Spacing.four,
|
||||
},
|
||||
sectionHeader: {
|
||||
paddingHorizontal: Spacing.four,
|
||||
paddingVertical: Spacing.two,
|
||||
},
|
||||
feedsList: {
|
||||
gap: Spacing.two,
|
||||
paddingHorizontal: Spacing.four,
|
||||
},
|
||||
feedCard: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
padding: Spacing.three,
|
||||
borderRadius: Spacing.three,
|
||||
},
|
||||
feedCardContent: {
|
||||
flex: 1,
|
||||
gap: Spacing.one,
|
||||
},
|
||||
feedDescription: {
|
||||
fontSize: 14,
|
||||
marginTop: Spacing.one,
|
||||
},
|
||||
feedMeta: {
|
||||
marginTop: Spacing.one,
|
||||
},
|
||||
addButton: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 16,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginLeft: Spacing.two,
|
||||
},
|
||||
podcastHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: Spacing.two,
|
||||
},
|
||||
podcastIcon: {
|
||||
flexShrink: 0,
|
||||
},
|
||||
emptyState: {
|
||||
paddingVertical: Spacing.six,
|
||||
alignItems: 'center',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,61 +1,129 @@
|
||||
import * as Device from 'expo-device';
|
||||
import { Platform, StyleSheet } from 'react-native';
|
||||
import React from 'react';
|
||||
import { FlatList, StyleSheet, View, Platform } from 'react-native';
|
||||
import { RefreshControl } from 'react-native';
|
||||
import { Animated, Easing } from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
|
||||
import { AnimatedIcon } from '@/components/animated-icon';
|
||||
import { HintRow } from '@/components/hint-row';
|
||||
import { useFeedList } from '@/hooks/use-feed-list';
|
||||
import { FeedItemCard } from '@/components/feed-item-card';
|
||||
import { ThemedText } from '@/components/themed-text';
|
||||
import { ThemedView } from '@/components/themed-view';
|
||||
import { WebBadge } from '@/components/web-badge';
|
||||
import { BottomTabInset, MaxContentWidth, Spacing } from '@/constants/theme';
|
||||
import { Colors, MaxContentWidth, Spacing } from '@/constants/theme';
|
||||
import { useColorScheme } from 'react-native';
|
||||
|
||||
function getDevMenuHint() {
|
||||
if (Platform.OS === 'web') {
|
||||
return <ThemedText type="small">use browser devtools</ThemedText>;
|
||||
}
|
||||
if (Device.isDevice) {
|
||||
return (
|
||||
<ThemedText type="small">
|
||||
shake device or press <ThemedText type="code">m</ThemedText> in terminal
|
||||
</ThemedText>
|
||||
);
|
||||
}
|
||||
const shortcut = Platform.OS === 'android' ? 'cmd+m (or ctrl+m)' : 'cmd+d';
|
||||
return (
|
||||
<ThemedText type="small">
|
||||
press <ThemedText type="code">{shortcut}</ThemedText>
|
||||
</ThemedText>
|
||||
);
|
||||
}
|
||||
const EXTRACT_HTML_TEXT = (html: string): string => html.replace(/<[^>]*>/g, '');
|
||||
const EXCERPT_LENGTH = 200;
|
||||
const ANIMATION_DELAY_MULTIPLIER = 50;
|
||||
const MAX_ANIMATION_DELAY = 500;
|
||||
|
||||
export default function HomeScreen() {
|
||||
const { feedItems, loading, hasMore, loadMore, refreshFeed, isRefreshing, error } = useFeedList();
|
||||
const scheme = useColorScheme();
|
||||
const colors = Colors[scheme === 'unspecified' ? 'light' : scheme];
|
||||
|
||||
const fadeInAnim = React.useRef(new Animated.Value(0)).current;
|
||||
|
||||
React.useEffect(() => {
|
||||
const anim = Animated.timing(fadeInAnim, {
|
||||
toValue: 1,
|
||||
duration: 300,
|
||||
easing: Easing.ease,
|
||||
useNativeDriver: true,
|
||||
});
|
||||
anim.start();
|
||||
return () => anim.stop();
|
||||
}, []);
|
||||
|
||||
const renderFeedItem = React.useCallback(({ item, index }: { item: any; index: number }) => {
|
||||
const itemFadeIn = React.useMemo(() => new Animated.Value(0), []);
|
||||
|
||||
React.useEffect(() => {
|
||||
const anim = Animated.timing(itemFadeIn, {
|
||||
toValue: 1,
|
||||
duration: 200,
|
||||
delay: Math.min(index * ANIMATION_DELAY_MULTIPLIER, MAX_ANIMATION_DELAY),
|
||||
easing: Easing.ease,
|
||||
useNativeDriver: true,
|
||||
});
|
||||
const timeout = setTimeout(() => anim.start(), 100);
|
||||
return () => {
|
||||
clearTimeout(timeout);
|
||||
anim.stop();
|
||||
};
|
||||
}, [itemFadeIn, index]);
|
||||
|
||||
return (
|
||||
<Animated.View style={[styles.itemContainer, { opacity: itemFadeIn, transform: [{ translateY: itemFadeIn.interpolate({ inputRange: [0, 1], outputRange: [10, 0] }) }] }]}>
|
||||
<FeedItemCard item={item} />
|
||||
</Animated.View>
|
||||
);
|
||||
}, []);
|
||||
|
||||
const renderHeader = () => (
|
||||
<ThemedView style={styles.header}>
|
||||
<ThemedText type="title" style={styles.headerTitle}>Today</ThemedText>
|
||||
</ThemedView>
|
||||
);
|
||||
|
||||
const renderFooter = () => {
|
||||
if (!loading) return null;
|
||||
return (
|
||||
<ThemedView style={styles.footer}>
|
||||
<ThemedText type="small">Loading more...</ThemedText>
|
||||
</ThemedView>
|
||||
);
|
||||
};
|
||||
|
||||
const renderEmpty = () => (
|
||||
<ThemedView style={styles.emptyContainer}>
|
||||
{error ? (
|
||||
<>
|
||||
<ThemedText type="title">Error Loading Feed</ThemedText>
|
||||
<ThemedText type="default" style={styles.emptySubtitle}>
|
||||
{error}
|
||||
</ThemedText>
|
||||
<ThemedText type="small" style={styles.retryHint}>
|
||||
Pull down to retry
|
||||
</ThemedText>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ThemedText type="title">No articles yet</ThemedText>
|
||||
<ThemedText type="default" style={styles.emptySubtitle}>
|
||||
Add a feed to start reading
|
||||
</ThemedText>
|
||||
</>
|
||||
)}
|
||||
</ThemedView>
|
||||
);
|
||||
|
||||
return (
|
||||
<ThemedView style={styles.container}>
|
||||
<SafeAreaView style={styles.safeArea}>
|
||||
<ThemedView style={styles.heroSection}>
|
||||
<AnimatedIcon />
|
||||
<ThemedText type="title" style={styles.title}>
|
||||
Welcome to Expo
|
||||
</ThemedText>
|
||||
</ThemedView>
|
||||
|
||||
<ThemedText type="code" style={styles.code}>
|
||||
get started
|
||||
</ThemedText>
|
||||
|
||||
<ThemedView type="backgroundElement" style={styles.stepContainer}>
|
||||
<HintRow
|
||||
title="Try editing"
|
||||
hint={<ThemedText type="code">src/app/index.tsx</ThemedText>}
|
||||
/>
|
||||
<HintRow title="Dev tools" hint={getDevMenuHint()} />
|
||||
<HintRow
|
||||
title="Fresh start"
|
||||
hint={<ThemedText type="code">npm run reset-project</ThemedText>}
|
||||
/>
|
||||
</ThemedView>
|
||||
|
||||
{Platform.OS === 'web' && <WebBadge />}
|
||||
<SafeAreaView style={styles.safeArea} edges={['top']}>
|
||||
{renderHeader()}
|
||||
|
||||
<Animated.View style={{ opacity: fadeInAnim }}>
|
||||
{feedItems.length === 0 ? (
|
||||
renderEmpty()
|
||||
) : (
|
||||
<FlatList
|
||||
data={feedItems}
|
||||
keyExtractor={(item) => item.id}
|
||||
renderItem={renderFeedItem}
|
||||
onEndReached={loadMore}
|
||||
onEndReachedThreshold={0.5}
|
||||
ListFooterComponent={renderFooter}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={isRefreshing}
|
||||
onRefresh={refreshFeed}
|
||||
colors={[colors.text]}
|
||||
/>
|
||||
}
|
||||
contentContainerStyle={styles.listContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
/>
|
||||
)}
|
||||
</Animated.View>
|
||||
</SafeAreaView>
|
||||
</ThemedView>
|
||||
);
|
||||
@@ -64,35 +132,45 @@ export default function HomeScreen() {
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
flexDirection: 'row',
|
||||
backgroundColor: Colors.light.background,
|
||||
},
|
||||
safeArea: {
|
||||
flex: 1,
|
||||
},
|
||||
header: {
|
||||
paddingHorizontal: Spacing.four,
|
||||
alignItems: 'center',
|
||||
gap: Spacing.three,
|
||||
paddingBottom: BottomTabInset + Spacing.three,
|
||||
paddingVertical: Spacing.three,
|
||||
maxWidth: MaxContentWidth,
|
||||
},
|
||||
heroSection: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flex: 1,
|
||||
headerTitle: {
|
||||
fontSize: 32,
|
||||
fontWeight: '700',
|
||||
},
|
||||
listContent: {
|
||||
paddingHorizontal: Spacing.four,
|
||||
gap: Spacing.four,
|
||||
paddingBottom: Spacing.six,
|
||||
},
|
||||
title: {
|
||||
textAlign: 'center',
|
||||
itemContainer: {
|
||||
maxWidth: MaxContentWidth,
|
||||
},
|
||||
code: {
|
||||
textTransform: 'uppercase',
|
||||
},
|
||||
stepContainer: {
|
||||
gap: Spacing.three,
|
||||
alignSelf: 'stretch',
|
||||
paddingHorizontal: Spacing.three,
|
||||
footer: {
|
||||
paddingVertical: Spacing.four,
|
||||
borderRadius: Spacing.four,
|
||||
alignItems: 'center',
|
||||
},
|
||||
emptyContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: Spacing.four,
|
||||
gap: Spacing.two,
|
||||
},
|
||||
emptySubtitle: {
|
||||
textAlign: 'center',
|
||||
color: '#8e8e93',
|
||||
},
|
||||
retryHint: {
|
||||
marginTop: Spacing.three,
|
||||
color: '#007AFF',
|
||||
fontSize: 12,
|
||||
},
|
||||
});
|
||||
|
||||
200
src/app/search.tsx
Normal file
200
src/app/search.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { StyleSheet, ScrollView, KeyboardAvoidingView, Platform, TouchableOpacity } from 'react-native';
|
||||
import { SearchInput } from '@/components/search-input';
|
||||
import { SearchSort } from '@/components/search-sort';
|
||||
import { SearchResults } from '@/components/search-results';
|
||||
import { SearchFilters } from '@/components/search-filters';
|
||||
import { FilterOptions } from '@/components/filter-options';
|
||||
import { combinedSearch } from '@/services/search-service';
|
||||
import { useSearchStore } from '@/stores/search-store';
|
||||
import { getAllSubscriptions } from '@/services/database';
|
||||
import { SearchResult, SearchSortOption, SearchFilters as SearchFiltersType } from '@/types/feed';
|
||||
import { ThemedText } from '@/components/themed-text';
|
||||
import { ThemedView } from '@/components/themed-view';
|
||||
import { Colors, Spacing } from '@/constants/theme';
|
||||
import { useColorScheme } from 'react-native';
|
||||
|
||||
export default function SearchScreen() {
|
||||
const scheme = useColorScheme();
|
||||
const colors = Colors[scheme === 'unspecified' ? 'light' : scheme];
|
||||
|
||||
const { query, setSort, setPage, setLoading, setError, sort, page, pageSize, loadHistory, loading, filters, setFilters } = useSearchStore();
|
||||
|
||||
const [articles, setArticles] = useState<SearchResult[]>([]);
|
||||
const [feeds, setFeeds] = useState<SearchResult[]>([]);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const [isInitialLoad, setIsInitialLoad] = useState(true);
|
||||
const [filterModalVisible, setFilterModalVisible] = useState(false);
|
||||
const [availableFeeds, setAvailableFeeds] = useState<Array<{ id: string; title: string }>>([]);
|
||||
|
||||
// Load search history and available feeds on mount
|
||||
useEffect(() => {
|
||||
loadHistory();
|
||||
|
||||
const loadAvailableFeeds = async () => {
|
||||
try {
|
||||
const subscriptions = await getAllSubscriptions();
|
||||
setAvailableFeeds(subscriptions.map(s => ({ id: s.id, title: s.title })));
|
||||
} catch (error) {
|
||||
console.error('Failed to load available feeds:', error);
|
||||
}
|
||||
};
|
||||
|
||||
loadAvailableFeeds();
|
||||
}, []);
|
||||
|
||||
// Perform search when query, sort, filters, or page changes
|
||||
useEffect(() => {
|
||||
const performSearch = async () => {
|
||||
if (!query || query.trim().length === 0) {
|
||||
setArticles([]);
|
||||
setFeeds([]);
|
||||
setHasMore(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isInitialLoad) {
|
||||
setLoading(true);
|
||||
}
|
||||
|
||||
try {
|
||||
const results = await combinedSearch(query, {
|
||||
filters,
|
||||
sort,
|
||||
page,
|
||||
pageSize,
|
||||
});
|
||||
|
||||
if (page === 1) {
|
||||
setArticles(results.articles);
|
||||
setFeeds(results.feeds);
|
||||
} else {
|
||||
setArticles(prev => [...prev, ...results.articles]);
|
||||
}
|
||||
|
||||
setHasMore(results.articles.length >= pageSize);
|
||||
setError(null);
|
||||
} catch (error) {
|
||||
setError(error instanceof Error ? error.message : 'Search failed');
|
||||
console.error('Search error:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setIsInitialLoad(false);
|
||||
}
|
||||
};
|
||||
|
||||
const timer = setTimeout(performSearch, 300);
|
||||
return () => clearTimeout(timer);
|
||||
}, [query, sort, filters, page, pageSize]);
|
||||
|
||||
const handleSortChange = (newSort: SearchSortOption) => {
|
||||
setSort(newSort);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const handleFilterChange = (newFilters: Partial<SearchFiltersType>) => {
|
||||
setFilters(newFilters);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const handleClearAllFilters = () => {
|
||||
setFilters({});
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const handleApplyFilters = (newFilters: SearchFiltersType) => {
|
||||
setFilters(newFilters);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const handleLoadMore = () => {
|
||||
if (!loading && hasMore) {
|
||||
setPage(page + 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResultPress = (result: SearchResult) => {
|
||||
console.log('Result pressed:', result);
|
||||
};
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
style={styles.container}
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
keyboardVerticalOffset={0}
|
||||
>
|
||||
<ScrollView
|
||||
style={styles.scrollView}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
scrollEnabled={false}
|
||||
>
|
||||
<SearchInput autoFocus />
|
||||
<ThemedView style={styles.filtersRow}>
|
||||
<SearchSort sort={sort} onSortChange={handleSortChange} />
|
||||
<TouchableOpacity
|
||||
style={styles.filterButton}
|
||||
onPress={() => setFilterModalVisible(true)}
|
||||
>
|
||||
<ThemedText style={styles.filterButtonText}>🔍 Filters</ThemedText>
|
||||
</TouchableOpacity>
|
||||
</ThemedView>
|
||||
<SearchFilters
|
||||
filters={filters}
|
||||
onFilterChange={handleFilterChange}
|
||||
onClearAll={handleClearAllFilters}
|
||||
availableFeeds={availableFeeds}
|
||||
/>
|
||||
</ScrollView>
|
||||
|
||||
<ScrollView
|
||||
style={styles.resultsContainer}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
<SearchResults
|
||||
articles={articles}
|
||||
feeds={feeds}
|
||||
loading={loading}
|
||||
onLoadMore={handleLoadMore}
|
||||
onResultPress={handleResultPress}
|
||||
hasMore={hasMore}
|
||||
/>
|
||||
</ScrollView>
|
||||
|
||||
<FilterOptions
|
||||
visible={filterModalVisible}
|
||||
onClose={() => setFilterModalVisible(false)}
|
||||
currentFilters={filters}
|
||||
onApplyFilters={handleApplyFilters}
|
||||
availableFeeds={availableFeeds}
|
||||
/>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
scrollView: {
|
||||
zIndex: 10,
|
||||
},
|
||||
filtersRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
},
|
||||
filterButton: {
|
||||
paddingHorizontal: Spacing.three,
|
||||
paddingVertical: Spacing.two,
|
||||
borderRadius: Spacing.two,
|
||||
backgroundColor: '#e5e5ea',
|
||||
marginLeft: Spacing.two,
|
||||
},
|
||||
filterButtonText: {
|
||||
fontSize: 14,
|
||||
color: '#8e8e93',
|
||||
},
|
||||
resultsContainer: {
|
||||
flex: 1,
|
||||
},
|
||||
});
|
||||
6
src/app/settings.tsx
Normal file
6
src/app/settings.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import React from 'react';
|
||||
import SettingsScreen from '@/components/settings';
|
||||
|
||||
export default function SettingsPage() {
|
||||
return <SettingsScreen />;
|
||||
}
|
||||
Reference in New Issue
Block a user