init
This commit is contained in:
1
.vscode/extensions.json
vendored
1
.vscode/extensions.json
vendored
@@ -1 +0,0 @@
|
||||
{ "recommendations": ["expo.vscode-expo-tools"] }
|
||||
7
.vscode/settings.json
vendored
7
.vscode/settings.json
vendored
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll": "explicit",
|
||||
"source.organizeImports": "explicit",
|
||||
"source.sortMembers": "explicit"
|
||||
}
|
||||
}
|
||||
9
app.json
9
app.json
@@ -39,6 +39,13 @@
|
||||
"experiments": {
|
||||
"typedRoutes": true,
|
||||
"reactCompiler": true
|
||||
}
|
||||
},
|
||||
"extra": {
|
||||
"router": {},
|
||||
"eas": {
|
||||
"projectId": "006dd772-380c-4e20-9754-42a15ad83fc9"
|
||||
}
|
||||
},
|
||||
"owner": "mikefrenodev"
|
||||
}
|
||||
}
|
||||
|
||||
BIN
assets/images/tabIcons/search.png
Normal file
BIN
assets/images/tabIcons/search.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 160 B |
BIN
assets/images/tabIcons/search@2x.png
Normal file
BIN
assets/images/tabIcons/search@2x.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 161 B |
BIN
assets/images/tabIcons/search@3x.png
Normal file
BIN
assets/images/tabIcons/search@3x.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 163 B |
BIN
assets/images/tabIcons/settings.png
Normal file
BIN
assets/images/tabIcons/settings.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 244 B |
BIN
assets/images/tabIcons/settings@2x.png
Normal file
BIN
assets/images/tabIcons/settings@2x.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 383 B |
BIN
assets/images/tabIcons/settings@3x.png
Normal file
BIN
assets/images/tabIcons/settings@3x.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 513 B |
7902
package-lock.json
generated
Normal file
7902
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
13
package.json
13
package.json
@@ -14,6 +14,8 @@
|
||||
"@react-navigation/bottom-tabs": "^7.15.5",
|
||||
"@react-navigation/elements": "^2.9.10",
|
||||
"@react-navigation/native": "^7.1.33",
|
||||
"@tanstack/react-query": "^5.95.2",
|
||||
"axios": "^1.14.0",
|
||||
"expo": "55.0.10-canary-20260328-2049187",
|
||||
"expo-constants": "55.0.10-canary-20260328-2049187",
|
||||
"expo-device": "55.0.11-canary-20260328-2049187",
|
||||
@@ -21,24 +23,31 @@
|
||||
"expo-glass-effect": "55.0.9-canary-20260328-2049187",
|
||||
"expo-image": "55.0.7-canary-20260328-2049187",
|
||||
"expo-linking": "55.0.10-canary-20260328-2049187",
|
||||
"expo-localization": "^55.0.10-canary-20260328-bdc6273",
|
||||
"expo-notifications": "^55.0.15-canary-20260328-bdc6273",
|
||||
"expo-router": "55.0.9-canary-20260328-2049187",
|
||||
"expo-splash-screen": "55.0.14-canary-20260328-2049187",
|
||||
"expo-sqlite": "^55.0.12-canary-20260328-bdc6273",
|
||||
"expo-status-bar": "55.0.5-canary-20260328-2049187",
|
||||
"expo-symbols": "55.0.6-canary-20260328-2049187",
|
||||
"expo-system-ui": "55.0.12-canary-20260328-2049187",
|
||||
"expo-task-manager": "^55.0.11-canary-20260328-bdc6273",
|
||||
"expo-web-browser": "55.0.11-canary-20260328-2049187",
|
||||
"react": "19.2.0",
|
||||
"react-dom": "19.2.0",
|
||||
"react-native": "0.83.4",
|
||||
"react-native-gesture-handler": "~2.30.0",
|
||||
"react-native-worklets": "0.7.2",
|
||||
"react-native-reanimated": "4.2.1",
|
||||
"react-native-safe-area-context": "~5.6.2",
|
||||
"react-native-screens": "~4.23.0",
|
||||
"react-native-web": "~0.21.0"
|
||||
"react-native-web": "~0.21.0",
|
||||
"react-native-worklets": "0.7.2",
|
||||
"xml2js": "^0.6.2",
|
||||
"zustand": "^5.0.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "~19.2.2",
|
||||
"@types/xml2js": "^0.4.14",
|
||||
"typescript": "~5.9.2"
|
||||
},
|
||||
"private": true
|
||||
|
||||
92
src/ISSUE_PLAN.md
Normal file
92
src/ISSUE_PLAN.md
Normal file
@@ -0,0 +1,92 @@
|
||||
# Plan
|
||||
|
||||
## FRE-517: Build settings and preferences screen
|
||||
|
||||
### Overview
|
||||
Create a settings screen with the following sections:
|
||||
- Sync intervals
|
||||
- Notifications
|
||||
- Theme (light/dark/system)
|
||||
- Reading preferences
|
||||
- Account management
|
||||
|
||||
### Implementation Plan
|
||||
|
||||
#### 1. Types and Interfaces
|
||||
- [ ] Add `SyncInterval` type to `src/types/feed.ts`
|
||||
- [ ] Add `NotificationPreference` type to `src/types/feed.ts`
|
||||
- [ ] Add `ReadingPreference` type to `src/types/feed.ts`
|
||||
- [ ] Add `AccountSettings` type to `src/types/global.d.ts`
|
||||
|
||||
#### 2. Settings State Management
|
||||
- [ ] Create `settings-store` in `src/stores/settings-store.ts`
|
||||
- [ ] Initialize default settings
|
||||
- [ ] Add actions for updating all preference types
|
||||
|
||||
#### 3. Settings Screen Component
|
||||
- [ ] Create `src/components/settings.tsx` with:
|
||||
- Sync interval selector
|
||||
- Notification toggle switches
|
||||
- Theme selector
|
||||
- Reading preference toggles
|
||||
- Account section placeholder
|
||||
|
||||
#### 4. Settings Navigation
|
||||
- [ ] Add route `src/app/settings.tsx`
|
||||
- [ ] Update `app-tabs.tsx` to include settings tab
|
||||
- [ ] Add navigation to `AppLayout.tsx`
|
||||
|
||||
#### 5. Integration
|
||||
- [ ] Connect settings to feed subscription intervals
|
||||
- [ ] Connect theme settings to `useTheme` hook
|
||||
- [ ] Connect notifications to expo-notifications
|
||||
|
||||
---
|
||||
|
||||
## FRE-520: Add push notifications
|
||||
|
||||
### Overview
|
||||
Implement push notifications for new articles, episode releases, and custom alerts using expo-notifications.
|
||||
|
||||
### Implementation Plan
|
||||
|
||||
#### 1. Notifications Types
|
||||
- [ ] Add `NotificationType` enum to `src/types/global.d.ts`
|
||||
- [ ] Add `NotificationConfig` interface to `src/types/global.d.ts`
|
||||
- [ ] Add `PushNotificationConfig` interface to `src/types/global.d.ts`
|
||||
|
||||
#### 2. Notifications Service
|
||||
- [ ] Create `src/services/notification-service.ts`
|
||||
- [ ] Implement `requestPermission()`
|
||||
- [ ] Implement `scheduleNotification(type: NotificationType)`
|
||||
- [ ] Implement `onNotificationReceived()`
|
||||
- [ ] Implement `onNotificationOpened()`
|
||||
- [ ] Implement `clearAllNotifications()`
|
||||
|
||||
#### 3. Notification Handlers
|
||||
- [ ] Create `src/hooks/use-notifications.ts`
|
||||
- [ ] Add notification state and actions
|
||||
- [ ] Add notification display component
|
||||
|
||||
#### 4. Notification Settings
|
||||
- [ ] Add notification type preferences in settings
|
||||
- [ ] Add push notification toggle in settings
|
||||
- [ ] Add custom alert configuration
|
||||
|
||||
#### 5. Integration
|
||||
- [ ] Connect notifications to feed updates
|
||||
- [ ] Schedule notifications on feed fetch
|
||||
- [ ] Handle notification deep linking
|
||||
|
||||
---
|
||||
|
||||
## Dependencies and Tools
|
||||
- expo-notifications (already installed)
|
||||
- expo-task-manager (already installed)
|
||||
- expo-sqlite (already installed)
|
||||
- zustand (already installed)
|
||||
|
||||
## Notes
|
||||
- Settings will persist via zustand persist middleware
|
||||
- Notifications will use Expo's token-based system
|
||||
- Feed subscriptions will respect sync interval settings
|
||||
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>
|
||||
<SafeAreaView style={styles.safeArea} edges={['top']}>
|
||||
{renderHeader()}
|
||||
|
||||
<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 />}
|
||||
<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 />;
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import React from 'react';
|
||||
import { useColorScheme } from 'react-native';
|
||||
|
||||
import { Colors } from '@/constants/theme';
|
||||
import { t } from '@/i18n';
|
||||
|
||||
export default function AppTabs() {
|
||||
const scheme = useColorScheme();
|
||||
@@ -14,7 +15,7 @@ export default function AppTabs() {
|
||||
indicatorColor={colors.backgroundElement}
|
||||
labelStyle={{ selected: { color: colors.text } }}>
|
||||
<NativeTabs.Trigger name="index">
|
||||
<NativeTabs.Trigger.Label>Home</NativeTabs.Trigger.Label>
|
||||
<NativeTabs.Trigger.Label>{t('tab.home')}</NativeTabs.Trigger.Label>
|
||||
<NativeTabs.Trigger.Icon
|
||||
src={require('@/assets/images/tabIcons/home.png')}
|
||||
renderingMode="template"
|
||||
@@ -22,12 +23,28 @@ export default function AppTabs() {
|
||||
</NativeTabs.Trigger>
|
||||
|
||||
<NativeTabs.Trigger name="explore">
|
||||
<NativeTabs.Trigger.Label>Explore</NativeTabs.Trigger.Label>
|
||||
<NativeTabs.Trigger.Label>{t('tab.explore')}</NativeTabs.Trigger.Label>
|
||||
<NativeTabs.Trigger.Icon
|
||||
src={require('@/assets/images/tabIcons/explore.png')}
|
||||
renderingMode="template"
|
||||
/>
|
||||
</NativeTabs.Trigger>
|
||||
|
||||
<NativeTabs.Trigger name="search">
|
||||
<NativeTabs.Trigger.Label>Search</NativeTabs.Trigger.Label>
|
||||
<NativeTabs.Trigger.Icon
|
||||
src={require('@/assets/images/tabIcons/search.png')}
|
||||
renderingMode="template"
|
||||
/>
|
||||
</NativeTabs.Trigger>
|
||||
|
||||
<NativeTabs.Trigger name="settings">
|
||||
<NativeTabs.Trigger.Label>{t('tab.settings')}</NativeTabs.Trigger.Label>
|
||||
<NativeTabs.Trigger.Icon
|
||||
src={require('@/assets/images/tabIcons/settings.png')}
|
||||
renderingMode="template"
|
||||
/>
|
||||
</NativeTabs.Trigger>
|
||||
</NativeTabs>
|
||||
);
|
||||
}
|
||||
|
||||
134
src/components/article-card.tsx
Normal file
134
src/components/article-card.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
import React from 'react';
|
||||
import { StyleSheet, TouchableOpacity, Linking } from 'react-native';
|
||||
import { SearchResult } from '@/types/feed';
|
||||
import { ThemedText } from './themed-text';
|
||||
import { ThemedView } from './themed-view';
|
||||
import { Colors, Spacing } from '@/constants/theme';
|
||||
import { useColorScheme } from 'react-native';
|
||||
|
||||
interface ArticleCardProps {
|
||||
result: SearchResult;
|
||||
onPress?: (result: SearchResult) => void;
|
||||
}
|
||||
|
||||
export function ArticleCard({ result, onPress }: ArticleCardProps) {
|
||||
const scheme = useColorScheme();
|
||||
const colors = Colors[scheme === 'unspecified' ? 'light' : scheme];
|
||||
|
||||
const handlePress = () => {
|
||||
if (onPress) {
|
||||
onPress(result);
|
||||
} else if (result.link) {
|
||||
Linking.openURL(result.link);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (date?: Date) => {
|
||||
if (!date) return '';
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - date.getTime();
|
||||
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (days === 0) {
|
||||
const hours = Math.floor(diff / (1000 * 60 * 60));
|
||||
return hours === 0 ? 'Just now' : `${hours}h ago`;
|
||||
} else if (days === 1) {
|
||||
return 'Yesterday';
|
||||
} else if (days < 7) {
|
||||
return `${days}d ago`;
|
||||
} else {
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[styles.container, { backgroundColor: colors.background }]}
|
||||
onPress={handlePress}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<ThemedView style={styles.content}>
|
||||
<ThemedText type="smallBold" style={styles.title} numberOfLines={2}>
|
||||
{result.title}
|
||||
</ThemedText>
|
||||
|
||||
{result.snippet && (
|
||||
<ThemedText type="small" style={styles.snippet} numberOfLines={2}>
|
||||
{result.snippet}
|
||||
</ThemedText>
|
||||
)}
|
||||
|
||||
<ThemedView style={styles.metaRow}>
|
||||
{result.feedTitle && (
|
||||
<ThemedText type="small" style={styles.feedTitle} numberOfLines={1}>
|
||||
{result.feedTitle}
|
||||
</ThemedText>
|
||||
)}
|
||||
{result.published && (
|
||||
<ThemedText type="small" style={styles.date}>
|
||||
{formatDate(result.published)}
|
||||
</ThemedText>
|
||||
)}
|
||||
</ThemedView>
|
||||
|
||||
{result.type === 'feed' && (
|
||||
<ThemedView style={styles.feedBadge}>
|
||||
<ThemedText type="small" style={styles.feedBadgeText}>
|
||||
Feed
|
||||
</ThemedText>
|
||||
</ThemedView>
|
||||
)}
|
||||
</ThemedView>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
borderRadius: Spacing.three,
|
||||
padding: Spacing.three,
|
||||
marginBottom: Spacing.two,
|
||||
borderWidth: 1,
|
||||
borderColor: '#e5e5ea',
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
},
|
||||
title: {
|
||||
fontSize: 16,
|
||||
marginBottom: Spacing.one,
|
||||
},
|
||||
snippet: {
|
||||
fontSize: 14,
|
||||
color: '#8e8e93',
|
||||
marginBottom: Spacing.two,
|
||||
lineHeight: 20,
|
||||
},
|
||||
metaRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: Spacing.two,
|
||||
},
|
||||
feedTitle: {
|
||||
color: '#007AFF',
|
||||
flex: 1,
|
||||
},
|
||||
date: {
|
||||
color: '#8e8e93',
|
||||
fontSize: 12,
|
||||
},
|
||||
feedBadge: {
|
||||
alignSelf: 'flex-start',
|
||||
paddingHorizontal: Spacing.two,
|
||||
paddingVertical: Spacing.one,
|
||||
backgroundColor: '#007AFF20',
|
||||
borderRadius: Spacing.one,
|
||||
marginTop: Spacing.two,
|
||||
},
|
||||
feedBadgeText: {
|
||||
color: '#007AFF',
|
||||
fontSize: 11,
|
||||
fontWeight: '600',
|
||||
textTransform: 'uppercase',
|
||||
},
|
||||
});
|
||||
169
src/components/feed-item-card.tsx
Normal file
169
src/components/feed-item-card.tsx
Normal file
@@ -0,0 +1,169 @@
|
||||
import React from 'react';
|
||||
import { StyleSheet, TouchableOpacity, Linking } from 'react-native';
|
||||
import { FeedItem } from '@/types/feed';
|
||||
import { ThemedText } from './themed-text';
|
||||
import { ThemedView } from './themed-view';
|
||||
import { Colors, Spacing } from '@/constants/theme';
|
||||
import { useColorScheme } from 'react-native';
|
||||
|
||||
const EXTRACT_HTML_TEXT = (html: string): string => html.replace(/<[^>]*>/g, '');
|
||||
const EXCERPT_LENGTH = 200;
|
||||
|
||||
interface FeedItemCardProps {
|
||||
item: FeedItem;
|
||||
onPress?: (item: FeedItem) => void;
|
||||
onLongPress?: (item: FeedItem) => void;
|
||||
}
|
||||
|
||||
export function FeedItemCard({ item, onPress, onLongPress }: FeedItemCardProps) {
|
||||
const scheme = useColorScheme();
|
||||
const colors = Colors[scheme === 'unspecified' ? 'light' : scheme];
|
||||
|
||||
const handlePress = () => {
|
||||
if (onPress) {
|
||||
onPress(item);
|
||||
} else if (item.link) {
|
||||
Linking.openURL(item.link);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLongPress = () => {
|
||||
if (onLongPress) {
|
||||
onLongPress(item);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (date?: Date) => {
|
||||
if (!date) return '';
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - date.getTime();
|
||||
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (days === 0) {
|
||||
const hours = Math.floor(diff / (1000 * 60 * 60));
|
||||
return hours === 0 ? 'Just now' : `${hours}h ago`;
|
||||
} else if (days === 1) {
|
||||
return 'Yesterday';
|
||||
} else if (days < 7) {
|
||||
return `${days}d ago`;
|
||||
} else {
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
};
|
||||
|
||||
const excerpt = React.useMemo(() => {
|
||||
if (item.content) {
|
||||
const htmlContent = EXTRACT_HTML_TEXT(item.content);
|
||||
return htmlContent.length > EXCERPT_LENGTH ? htmlContent.substring(0, EXCERPT_LENGTH) + '...' : htmlContent;
|
||||
}
|
||||
if (item.description) {
|
||||
const htmlDesc = EXTRACT_HTML_TEXT(item.description);
|
||||
return htmlDesc.length > EXCERPT_LENGTH ? htmlDesc.substring(0, EXCERPT_LENGTH) + '...' : htmlDesc;
|
||||
}
|
||||
return '';
|
||||
}, [item.content, item.description]);
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[styles.container, { backgroundColor: colors.background }]}
|
||||
onPress={handlePress}
|
||||
onLongPress={handleLongPress}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<ThemedView style={styles.content}>
|
||||
{item.subscriptionTitle && (
|
||||
<ThemedText type="small" style={styles.feedTitle} numberOfLines={1}>
|
||||
{item.subscriptionTitle}
|
||||
</ThemedText>
|
||||
)}
|
||||
|
||||
<ThemedText type="smallBold" style={styles.title} numberOfLines={2}>
|
||||
{item.title}
|
||||
</ThemedText>
|
||||
|
||||
{excerpt && (
|
||||
<ThemedText type="small" style={styles.excerpt} numberOfLines={3}>
|
||||
{excerpt}
|
||||
</ThemedText>
|
||||
)}
|
||||
|
||||
<ThemedView style={styles.metaRow}>
|
||||
{item.author && (
|
||||
<ThemedText type="small" style={styles.author} numberOfLines={1}>
|
||||
{item.author}
|
||||
</ThemedText>
|
||||
)}
|
||||
{item.published && (
|
||||
<ThemedText type="small" style={styles.date}>
|
||||
{formatDate(item.published)}
|
||||
</ThemedText>
|
||||
)}
|
||||
</ThemedView>
|
||||
|
||||
{item.enclosure && item.enclosure.type.includes('audio') && (
|
||||
<ThemedView style={styles.audioBadge}>
|
||||
<ThemedText type="small" style={styles.audioBadgeText}>
|
||||
🎧 Audio
|
||||
</ThemedText>
|
||||
</ThemedView>
|
||||
)}
|
||||
</ThemedView>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
borderRadius: Spacing.three,
|
||||
padding: Spacing.three,
|
||||
marginBottom: Spacing.two,
|
||||
borderWidth: 1,
|
||||
borderColor: '#e5e5ea',
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
},
|
||||
feedTitle: {
|
||||
fontSize: 12,
|
||||
color: '#007AFF',
|
||||
marginBottom: Spacing.half,
|
||||
fontWeight: '500',
|
||||
},
|
||||
title: {
|
||||
fontSize: 16,
|
||||
marginBottom: Spacing.one,
|
||||
},
|
||||
excerpt: {
|
||||
fontSize: 14,
|
||||
color: '#8e8e93',
|
||||
marginBottom: Spacing.two,
|
||||
lineHeight: 20,
|
||||
},
|
||||
metaRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: Spacing.two,
|
||||
},
|
||||
author: {
|
||||
color: '#8e8e93',
|
||||
fontSize: 12,
|
||||
flex: 1,
|
||||
},
|
||||
date: {
|
||||
color: '#8e8e93',
|
||||
fontSize: 12,
|
||||
},
|
||||
audioBadge: {
|
||||
alignSelf: 'flex-start',
|
||||
paddingHorizontal: Spacing.two,
|
||||
paddingVertical: Spacing.one,
|
||||
backgroundColor: '#007AFF20',
|
||||
borderRadius: Spacing.one,
|
||||
marginTop: Spacing.two,
|
||||
},
|
||||
audioBadgeText: {
|
||||
color: '#007AFF',
|
||||
fontSize: 11,
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
337
src/components/filter-options.tsx
Normal file
337
src/components/filter-options.tsx
Normal file
@@ -0,0 +1,337 @@
|
||||
import React, { useState } from 'react';
|
||||
import { StyleSheet, ScrollView, TouchableOpacity, Modal, View } from 'react-native';
|
||||
import { SearchFilters } from '@/types/feed';
|
||||
import { ThemedText } from './themed-text';
|
||||
import { ThemedView } from './themed-view';
|
||||
import { Colors, Spacing } from '@/constants/theme';
|
||||
import { useColorScheme } from 'react-native';
|
||||
|
||||
interface FilterOptionsProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
currentFilters: SearchFilters;
|
||||
onApplyFilters: (filters: SearchFilters) => void;
|
||||
availableFeeds: Array<{ id: string; title: string }>;
|
||||
}
|
||||
|
||||
export function FilterOptions({ visible, onClose, currentFilters, onApplyFilters, availableFeeds }: FilterOptionsProps) {
|
||||
const scheme = useColorScheme();
|
||||
const colors = Colors[scheme === 'unspecified' ? 'light' : scheme];
|
||||
|
||||
const [localFilters, setLocalFilters] = useState<SearchFilters>(currentFilters);
|
||||
const [selectedContentType, setSelectedContentType] = useState<'all' | 'article' | 'audio' | 'video'>(
|
||||
localFilters.contentType || 'all'
|
||||
);
|
||||
const [selectedFeeds, setSelectedFeeds] = useState<string[]>(localFilters.feedIds || []);
|
||||
const [dateFrom, setDateFrom] = useState<string>(localFilters.dateFrom?.toISOString().split('T')[0] || '');
|
||||
const [dateTo, setDateTo] = useState<string>(localFilters.dateTo?.toISOString().split('T')[0] || '');
|
||||
|
||||
const handleApply = () => {
|
||||
const filters: SearchFilters = {};
|
||||
|
||||
if (selectedContentType !== 'all') {
|
||||
filters.contentType = selectedContentType;
|
||||
}
|
||||
|
||||
if (selectedFeeds.length > 0) {
|
||||
filters.feedIds = selectedFeeds;
|
||||
}
|
||||
|
||||
if (dateFrom) {
|
||||
filters.dateFrom = new Date(dateFrom);
|
||||
}
|
||||
|
||||
if (dateTo) {
|
||||
filters.dateTo = new Date(dateTo);
|
||||
}
|
||||
|
||||
onApplyFilters(filters);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setLocalFilters({});
|
||||
setSelectedContentType('all');
|
||||
setSelectedFeeds([]);
|
||||
setDateFrom('');
|
||||
setDateTo('');
|
||||
};
|
||||
|
||||
const toggleFeed = (feedId: string) => {
|
||||
setSelectedFeeds(prev =>
|
||||
prev.includes(feedId)
|
||||
? prev.filter(id => id !== feedId)
|
||||
: [...prev, feedId]
|
||||
);
|
||||
};
|
||||
|
||||
const contentTypeOptions = [
|
||||
{ value: 'all', label: 'All Types', icon: '📋' },
|
||||
{ value: 'article', label: 'Articles', icon: '📄' },
|
||||
{ value: 'audio', label: 'Audio', icon: '🎵' },
|
||||
{ value: 'video', label: 'Video', icon: '🎬' },
|
||||
];
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
animationType="slide"
|
||||
onRequestClose={onClose}
|
||||
>
|
||||
<ThemedView style={styles.container}>
|
||||
{/* Header */}
|
||||
<ThemedView style={styles.header}>
|
||||
<ThemedText style={styles.headerTitle}>Filters</ThemedText>
|
||||
<TouchableOpacity onPress={onClose} style={styles.closeButton}>
|
||||
<ThemedText style={styles.closeButtonText}>Done</ThemedText>
|
||||
</TouchableOpacity>
|
||||
</ThemedView>
|
||||
|
||||
{/* Content */}
|
||||
<ScrollView style={styles.content}>
|
||||
{/* Content Type Filter */}
|
||||
<ThemedView style={styles.section}>
|
||||
<ThemedText style={styles.sectionTitle}>Content Type</ThemedText>
|
||||
<ThemedView style={styles.optionRow}>
|
||||
{contentTypeOptions.map(option => (
|
||||
<TouchableOpacity
|
||||
key={option.value}
|
||||
style={[
|
||||
styles.optionButton,
|
||||
selectedContentType === option.value && styles.optionButtonSelected
|
||||
]}
|
||||
onPress={() => setSelectedContentType(option.value as any)}
|
||||
>
|
||||
<ThemedText style={styles.optionIcon}>{option.icon}</ThemedText>
|
||||
<ThemedText style={[
|
||||
styles.optionLabel,
|
||||
selectedContentType === option.value && styles.optionLabelSelected
|
||||
]}>
|
||||
{option.label}
|
||||
</ThemedText>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</ThemedView>
|
||||
</ThemedView>
|
||||
|
||||
{/* Date Range Filter */}
|
||||
<ThemedView style={styles.section}>
|
||||
<ThemedText style={styles.sectionTitle}>Date Range</ThemedText>
|
||||
<ThemedView style={styles.dateRow}>
|
||||
<ThemedView style={styles.dateInputContainer}>
|
||||
<ThemedText style={styles.dateLabel}>From</ThemedText>
|
||||
<ThemedText style={styles.dateValue}>
|
||||
{dateFrom || 'Any date'}
|
||||
</ThemedText>
|
||||
</ThemedView>
|
||||
<ThemedText style={styles.dateSeparator}>to</ThemedText>
|
||||
<ThemedView style={styles.dateInputContainer}>
|
||||
<ThemedText style={styles.dateLabel}>To</ThemedText>
|
||||
<ThemedText style={styles.dateValue}>
|
||||
{dateTo || 'Now'}
|
||||
</ThemedText>
|
||||
</ThemedView>
|
||||
</ThemedView>
|
||||
<ThemedText style={styles.dateHint}>Tap to select dates (coming soon)</ThemedText>
|
||||
</ThemedView>
|
||||
|
||||
{/* Feed Filter */}
|
||||
<ThemedView style={styles.section}>
|
||||
<ThemedText style={styles.sectionTitle}>Feeds ({selectedFeeds.length} selected)</ThemedText>
|
||||
<ScrollView
|
||||
style={styles.feedsList}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{availableFeeds.map(feed => (
|
||||
<TouchableOpacity
|
||||
key={feed.id}
|
||||
style={[
|
||||
styles.feedOption,
|
||||
selectedFeeds.includes(feed.id) && styles.feedOptionSelected
|
||||
]}
|
||||
onPress={() => toggleFeed(feed.id)}
|
||||
>
|
||||
<ThemedText style={styles.feedCheckbox}>
|
||||
{selectedFeeds.includes(feed.id) ? '☑' : '☐'}
|
||||
</ThemedText>
|
||||
<ThemedText style={styles.feedTitle} numberOfLines={1}>
|
||||
{feed.title}
|
||||
</ThemedText>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</ScrollView>
|
||||
</ThemedView>
|
||||
</ScrollView>
|
||||
|
||||
{/* Footer Actions */}
|
||||
<ThemedView style={styles.footer}>
|
||||
<TouchableOpacity
|
||||
style={[styles.footerButton, styles.resetButton]}
|
||||
onPress={handleReset}
|
||||
>
|
||||
<ThemedText style={styles.resetButtonText}>Reset</ThemedText>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.footerButton, styles.applyButton]}
|
||||
onPress={handleApply}
|
||||
>
|
||||
<ThemedText style={styles.applyButtonText}>Apply</ThemedText>
|
||||
</TouchableOpacity>
|
||||
</ThemedView>
|
||||
</ThemedView>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: Spacing.three,
|
||||
paddingVertical: Spacing.three,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#e5e5ea',
|
||||
},
|
||||
headerTitle: {
|
||||
fontSize: 20,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
closeButton: {
|
||||
paddingHorizontal: Spacing.two,
|
||||
paddingVertical: Spacing.one,
|
||||
},
|
||||
closeButtonText: {
|
||||
fontSize: 16,
|
||||
color: '#007AFF',
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
},
|
||||
section: {
|
||||
paddingVertical: Spacing.three,
|
||||
paddingHorizontal: Spacing.three,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#e5e5ea',
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
marginBottom: Spacing.three,
|
||||
color: '#8e8e93',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
optionRow: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: Spacing.two,
|
||||
},
|
||||
optionButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: Spacing.three,
|
||||
paddingVertical: Spacing.two,
|
||||
borderRadius: Spacing.two,
|
||||
backgroundColor: '#e5e5ea',
|
||||
gap: Spacing.one,
|
||||
},
|
||||
optionButtonSelected: {
|
||||
backgroundColor: '#007AFF',
|
||||
},
|
||||
optionIcon: {
|
||||
fontSize: 14,
|
||||
},
|
||||
optionLabel: {
|
||||
fontSize: 14,
|
||||
color: '#8e8e93',
|
||||
},
|
||||
optionLabelSelected: {
|
||||
color: '#ffffff',
|
||||
},
|
||||
dateRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: Spacing.two,
|
||||
},
|
||||
dateInputContainer: {
|
||||
flex: 1,
|
||||
},
|
||||
dateLabel: {
|
||||
fontSize: 12,
|
||||
color: '#8e8e93',
|
||||
marginBottom: Spacing.one,
|
||||
},
|
||||
dateValue: {
|
||||
fontSize: 16,
|
||||
paddingVertical: Spacing.two,
|
||||
paddingHorizontal: Spacing.two,
|
||||
borderRadius: Spacing.two,
|
||||
backgroundColor: '#e5e5ea',
|
||||
},
|
||||
dateSeparator: {
|
||||
fontSize: 16,
|
||||
color: '#8e8e93',
|
||||
},
|
||||
dateHint: {
|
||||
fontSize: 12,
|
||||
color: '#8e8e93',
|
||||
marginTop: Spacing.two,
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
feedsList: {
|
||||
maxHeight: 200,
|
||||
},
|
||||
feedOption: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: Spacing.two,
|
||||
paddingHorizontal: Spacing.two,
|
||||
gap: Spacing.two,
|
||||
},
|
||||
feedOptionSelected: {
|
||||
backgroundColor: '#e5f2ff',
|
||||
borderRadius: Spacing.two,
|
||||
marginLeft: -Spacing.two,
|
||||
marginRight: -Spacing.two,
|
||||
},
|
||||
feedCheckbox: {
|
||||
fontSize: 16,
|
||||
},
|
||||
feedTitle: {
|
||||
flex: 1,
|
||||
fontSize: 14,
|
||||
},
|
||||
footer: {
|
||||
flexDirection: 'row',
|
||||
padding: Spacing.three,
|
||||
gap: Spacing.three,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: '#e5e5ea',
|
||||
},
|
||||
footerButton: {
|
||||
flex: 1,
|
||||
paddingVertical: Spacing.three,
|
||||
borderRadius: Spacing.two,
|
||||
alignItems: 'center',
|
||||
},
|
||||
resetButton: {
|
||||
backgroundColor: '#e5e5ea',
|
||||
},
|
||||
resetButtonText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: '#8e8e93',
|
||||
},
|
||||
applyButton: {
|
||||
backgroundColor: '#007AFF',
|
||||
},
|
||||
applyButtonText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: '#ffffff',
|
||||
},
|
||||
});
|
||||
216
src/components/search-filters.tsx
Normal file
216
src/components/search-filters.tsx
Normal file
@@ -0,0 +1,216 @@
|
||||
import React from 'react';
|
||||
import { StyleSheet, ScrollView, TouchableOpacity, View } from 'react-native';
|
||||
import { SearchFilters } from '@/types/feed';
|
||||
import { ThemedText } from './themed-text';
|
||||
import { ThemedView } from './themed-view';
|
||||
import { Colors, Spacing } from '@/constants/theme';
|
||||
import { useColorScheme } from 'react-native';
|
||||
|
||||
interface FilterChipProps {
|
||||
label: string;
|
||||
value?: string;
|
||||
onRemove: () => void;
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
function FilterChip({ label, value, onRemove, icon }: FilterChipProps) {
|
||||
const scheme = useColorScheme();
|
||||
const colors = Colors[scheme === 'unspecified' ? 'light' : scheme];
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={styles.chip}
|
||||
onPress={onRemove}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
{icon && <ThemedText style={styles.chipIcon}>{icon}</ThemedText>}
|
||||
<ThemedText style={styles.chipText}>
|
||||
{label}:
|
||||
</ThemedText>
|
||||
<ThemedText style={styles.chipValue}>
|
||||
{value}
|
||||
</ThemedText>
|
||||
<ThemedText style={styles.chipClose}>✕</ThemedText>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
interface SearchFiltersProps {
|
||||
filters: SearchFilters;
|
||||
onFilterChange: (filters: Partial<SearchFilters>) => void;
|
||||
onClearAll: () => void;
|
||||
availableFeeds?: Array<{ id: string; title: string }>;
|
||||
}
|
||||
|
||||
export function SearchFilters({ filters, onFilterChange, onClearAll, availableFeeds }: SearchFiltersProps) {
|
||||
const scheme = useColorScheme();
|
||||
const colors = Colors[scheme === 'unspecified' ? 'light' : scheme];
|
||||
|
||||
const getFeedTitle = (feedId: string) => {
|
||||
const feed = availableFeeds?.find(f => f.id === feedId);
|
||||
return feed?.title || feedId;
|
||||
};
|
||||
|
||||
const formatDate = (date: Date) => {
|
||||
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
||||
};
|
||||
|
||||
const renderFilterChips = () => {
|
||||
const chips: any[] = [];
|
||||
|
||||
// Date range filter
|
||||
if (filters.dateFrom || filters.dateTo) {
|
||||
const fromLabel = filters.dateFrom ? formatDate(filters.dateFrom) : 'Any date';
|
||||
const toLabel = filters.dateTo ? formatDate(filters.dateTo) : 'Now';
|
||||
chips.push(
|
||||
<FilterChip
|
||||
key="date"
|
||||
label="Date"
|
||||
value={`${fromLabel} – ${toLabel}`}
|
||||
icon="📅"
|
||||
onRemove={() => onFilterChange({ dateFrom: undefined, dateTo: undefined })}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Feed filters
|
||||
if (filters.feedIds && filters.feedIds.length > 0) {
|
||||
const feedCount = filters.feedIds.length;
|
||||
const feedTitle = feedCount === 1
|
||||
? getFeedTitle(filters.feedIds[0])
|
||||
: `${feedCount} feeds`;
|
||||
chips.push(
|
||||
<FilterChip
|
||||
key="feeds"
|
||||
label="Feed"
|
||||
value={feedTitle}
|
||||
icon="📰"
|
||||
onRemove={() => onFilterChange({ feedIds: undefined })}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Author filters
|
||||
if (filters.authors && filters.authors.length > 0) {
|
||||
const authorCount = filters.authors.length;
|
||||
const authorLabel = authorCount === 1
|
||||
? filters.authors[0]
|
||||
: `${authorCount} authors`;
|
||||
chips.push(
|
||||
<FilterChip
|
||||
key="authors"
|
||||
label="Author"
|
||||
value={authorLabel}
|
||||
icon="✍️"
|
||||
onRemove={() => onFilterChange({ authors: undefined })}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Content type filter
|
||||
if (filters.contentType) {
|
||||
const contentTypeLabels: Record<string, string> = {
|
||||
article: 'Articles',
|
||||
audio: 'Audio',
|
||||
video: 'Video',
|
||||
};
|
||||
const contentTypeIcons: Record<string, string> = {
|
||||
article: '📄',
|
||||
audio: '🎵',
|
||||
video: '🎬',
|
||||
};
|
||||
chips.push(
|
||||
<FilterChip
|
||||
key="contentType"
|
||||
label="Type"
|
||||
value={contentTypeLabels[filters.contentType]}
|
||||
icon={contentTypeIcons[filters.contentType]}
|
||||
onRemove={() => onFilterChange({ contentType: undefined })}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return chips;
|
||||
};
|
||||
|
||||
const hasActiveFilters =
|
||||
filters.dateFrom ||
|
||||
filters.dateTo ||
|
||||
(filters.feedIds?.length ?? 0) > 0 ||
|
||||
(filters.authors?.length ?? 0) > 0 ||
|
||||
filters.contentType !== undefined;
|
||||
|
||||
if (!hasActiveFilters) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ThemedView style={styles.container}>
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={styles.chipsContainer}
|
||||
>
|
||||
{renderFilterChips()}
|
||||
</ScrollView>
|
||||
{hasActiveFilters && (
|
||||
<TouchableOpacity
|
||||
style={styles.clearAllButton}
|
||||
onPress={onClearAll}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<ThemedText style={styles.clearAllText}>Clear all</ThemedText>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</ThemedView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: Spacing.three,
|
||||
paddingVertical: Spacing.two,
|
||||
gap: Spacing.two,
|
||||
},
|
||||
chipsContainer: {
|
||||
flex: 1,
|
||||
gap: Spacing.two,
|
||||
},
|
||||
chip: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: Spacing.three,
|
||||
paddingVertical: Spacing.one,
|
||||
borderRadius: Spacing.two,
|
||||
backgroundColor: '#e5e5ea',
|
||||
gap: Spacing.one,
|
||||
},
|
||||
chipIcon: {
|
||||
fontSize: 12,
|
||||
},
|
||||
chipText: {
|
||||
fontSize: 12,
|
||||
color: '#8e8e93',
|
||||
fontWeight: '600',
|
||||
},
|
||||
chipValue: {
|
||||
fontSize: 12,
|
||||
color: '#000000',
|
||||
},
|
||||
chipClose: {
|
||||
fontSize: 14,
|
||||
color: '#8e8e93',
|
||||
marginLeft: Spacing.one,
|
||||
},
|
||||
clearAllButton: {
|
||||
paddingHorizontal: Spacing.two,
|
||||
paddingVertical: Spacing.one,
|
||||
},
|
||||
clearAllText: {
|
||||
fontSize: 12,
|
||||
color: '#007AFF',
|
||||
fontWeight: '500',
|
||||
},
|
||||
});
|
||||
180
src/components/search-input.tsx
Normal file
180
src/components/search-input.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
import React, { useRef, useEffect } from 'react';
|
||||
import { StyleSheet, TouchableOpacity, TextInput, ViewStyle, TextStyle } from 'react-native';
|
||||
import { useSearchStore } from '@/stores/search-store';
|
||||
import { ThemedText } from './themed-text';
|
||||
import { ThemedView } from './themed-view';
|
||||
import { Colors, Spacing } from '@/constants/theme';
|
||||
import { useColorScheme } from 'react-native';
|
||||
|
||||
interface SearchInputProps {
|
||||
onSearch?: (query: string) => void;
|
||||
style?: ViewStyle;
|
||||
inputStyle?: TextStyle;
|
||||
placeholder?: string;
|
||||
autoFocus?: boolean;
|
||||
}
|
||||
|
||||
export function SearchInput({
|
||||
onSearch,
|
||||
style,
|
||||
inputStyle,
|
||||
placeholder = 'Search articles and feeds...',
|
||||
autoFocus = false,
|
||||
}: SearchInputProps) {
|
||||
const scheme = useColorScheme();
|
||||
const colors = Colors[scheme === 'unspecified' ? 'light' : scheme];
|
||||
|
||||
const { query, setQuery, clearSearch, searchHistory } = useSearchStore();
|
||||
const inputRef = useRef<TextInput>(null);
|
||||
|
||||
// Debounced search handler
|
||||
useEffect(() => {
|
||||
if (!onSearch) return;
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
onSearch(query);
|
||||
}, 300);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [query, onSearch]);
|
||||
|
||||
const handleClear = () => {
|
||||
setQuery('');
|
||||
inputRef.current?.focus();
|
||||
};
|
||||
|
||||
const handleClearSearch = () => {
|
||||
clearSearch();
|
||||
inputRef.current?.focus();
|
||||
};
|
||||
|
||||
return (
|
||||
<ThemedView style={[styles.container, style]}>
|
||||
<ThemedView style={styles.inputContainer}>
|
||||
<ThemedText style={styles.searchIcon}>🔍</ThemedText>
|
||||
<TextInput
|
||||
ref={inputRef}
|
||||
value={query}
|
||||
onChangeText={setQuery}
|
||||
placeholder={placeholder}
|
||||
placeholderTextColor={colors.textSecondary}
|
||||
style={[styles.input, inputStyle]}
|
||||
autoFocus={autoFocus}
|
||||
returnKeyType="search"
|
||||
keyboardType="default"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
clearButtonMode="while-editing"
|
||||
selectionColor={colors.text}
|
||||
/>
|
||||
{query ? (
|
||||
<TouchableOpacity
|
||||
onPress={handleClear}
|
||||
style={[styles.clearButton, { backgroundColor: colors.backgroundElement }]}
|
||||
>
|
||||
<ThemedText style={styles.clearIcon}>✕</ThemedText>
|
||||
</TouchableOpacity>
|
||||
) : null}
|
||||
</ThemedView>
|
||||
|
||||
{query && (
|
||||
<TouchableOpacity
|
||||
onPress={handleClearSearch}
|
||||
style={styles.clearSearchButton}
|
||||
>
|
||||
<ThemedText type="small" style={styles.clearSearchText}>
|
||||
Clear search
|
||||
</ThemedText>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
{searchHistory.length > 0 && !query && (
|
||||
<ThemedView style={styles.historyContainer}>
|
||||
<ThemedText type="small" style={styles.historyLabel}>
|
||||
Recent searches:
|
||||
</ThemedText>
|
||||
<ThemedView style={styles.historyList}>
|
||||
{searchHistory.map((item) => (
|
||||
<TouchableOpacity
|
||||
key={item.id}
|
||||
style={styles.historyItem}
|
||||
onPress={() => {
|
||||
setQuery(item.query);
|
||||
onSearch?.(item.query);
|
||||
}}
|
||||
>
|
||||
<ThemedText type="small" style={styles.historyText}>
|
||||
{item.query}
|
||||
</ThemedText>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</ThemedView>
|
||||
</ThemedView>
|
||||
)}
|
||||
</ThemedView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
paddingHorizontal: Spacing.three,
|
||||
paddingBottom: Spacing.two,
|
||||
},
|
||||
inputContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: '#f6f6f6',
|
||||
borderRadius: Spacing.three,
|
||||
paddingHorizontal: Spacing.three,
|
||||
height: 44,
|
||||
},
|
||||
searchIcon: {
|
||||
fontSize: 18,
|
||||
marginRight: Spacing.two,
|
||||
},
|
||||
input: {
|
||||
flex: 1,
|
||||
fontSize: 16,
|
||||
height: '100%',
|
||||
},
|
||||
clearButton: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: 12,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
clearIcon: {
|
||||
fontSize: 14,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
clearSearchButton: {
|
||||
alignItems: 'center',
|
||||
paddingVertical: Spacing.two,
|
||||
},
|
||||
clearSearchText: {
|
||||
color: '#007AFF',
|
||||
},
|
||||
historyContainer: {
|
||||
marginTop: Spacing.two,
|
||||
},
|
||||
historyLabel: {
|
||||
fontSize: 12,
|
||||
color: '#8e8e93',
|
||||
marginBottom: Spacing.two,
|
||||
},
|
||||
historyList: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: Spacing.two,
|
||||
},
|
||||
historyItem: {
|
||||
paddingHorizontal: Spacing.three,
|
||||
paddingVertical: Spacing.two,
|
||||
backgroundColor: '#e5e5ea',
|
||||
borderRadius: Spacing.two,
|
||||
},
|
||||
historyText: {
|
||||
fontSize: 14,
|
||||
},
|
||||
});
|
||||
172
src/components/search-results.tsx
Normal file
172
src/components/search-results.tsx
Normal file
@@ -0,0 +1,172 @@
|
||||
import React, { useState } from 'react';
|
||||
import { StyleSheet, FlatList, ViewToken, ActivityIndicator } from 'react-native';
|
||||
import { SearchResult } from '@/types/feed';
|
||||
import { ThemedText } from './themed-text';
|
||||
import { ThemedView } from './themed-view';
|
||||
import { ArticleCard } from './article-card';
|
||||
import { Colors, Spacing } from '@/constants/theme';
|
||||
import { useColorScheme } from 'react-native';
|
||||
|
||||
interface SearchResultsProps {
|
||||
articles: SearchResult[];
|
||||
feeds: SearchResult[];
|
||||
loading: boolean;
|
||||
onLoadMore?: () => void;
|
||||
onResultPress?: (result: SearchResult) => void;
|
||||
hasMore?: boolean;
|
||||
}
|
||||
|
||||
export function SearchResults({
|
||||
articles,
|
||||
feeds,
|
||||
loading,
|
||||
onLoadMore,
|
||||
onResultPress,
|
||||
hasMore = false,
|
||||
}: SearchResultsProps) {
|
||||
const scheme = useColorScheme();
|
||||
const colors = Colors[scheme === 'unspecified' ? 'light' : scheme];
|
||||
|
||||
const [visibleArticles, setVisibleArticles] = useState<Set<string>>(new Set());
|
||||
const [visibleFeeds, setVisibleFeeds] = useState<Set<string>>(new Set());
|
||||
|
||||
const keyExtractor = (item: SearchResult) => item.id;
|
||||
|
||||
const onViewableItemsChangedArticles = ({ viewableItems }: { viewableItems: ViewToken[] }) => {
|
||||
const newVisible = new Set(visibleArticles);
|
||||
viewableItems.forEach(item => {
|
||||
if (item.isViewable) {
|
||||
newVisible.add(item.key);
|
||||
}
|
||||
});
|
||||
setVisibleArticles(newVisible);
|
||||
};
|
||||
|
||||
const onViewableItemsChangedFeeds = ({ viewableItems }: { viewableItems: ViewToken[] }) => {
|
||||
const newVisible = new Set(visibleFeeds);
|
||||
viewableItems.forEach(item => {
|
||||
if (item.isViewable) {
|
||||
newVisible.add(item.key);
|
||||
}
|
||||
});
|
||||
setVisibleFeeds(newVisible);
|
||||
};
|
||||
|
||||
const viewabilityConfig = {
|
||||
itemVisiblePercentThreshold: 50,
|
||||
};
|
||||
|
||||
const renderArticle = ({ item }: { item: SearchResult }) => (
|
||||
<ArticleCard result={item} onPress={onResultPress} />
|
||||
);
|
||||
|
||||
const renderFeed = ({ item }: { item: SearchResult }) => (
|
||||
<ArticleCard result={item} onPress={onResultPress} />
|
||||
);
|
||||
|
||||
const renderFooter = () => {
|
||||
if (!loading || !hasMore) return null;
|
||||
return (
|
||||
<ThemedView style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="small" color={colors.text} />
|
||||
</ThemedView>
|
||||
);
|
||||
};
|
||||
|
||||
const renderEmptyState = () => {
|
||||
if (!loading && articles.length === 0 && feeds.length === 0) {
|
||||
return (
|
||||
<ThemedView style={styles.emptyContainer}>
|
||||
<ThemedText type="section" style={styles.emptyTitle}>
|
||||
No results found
|
||||
</ThemedText>
|
||||
<ThemedText type="small" style={styles.emptySubtitle}>
|
||||
Try different keywords or check your filters
|
||||
</ThemedText>
|
||||
</ThemedView>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<ThemedView style={styles.container}>
|
||||
{feeds.length > 0 && (
|
||||
<ThemedView style={styles.section}>
|
||||
<ThemedText type="smallBold" style={styles.sectionHeader}>
|
||||
Feeds ({feeds.length})
|
||||
</ThemedText>
|
||||
<FlatList
|
||||
data={feeds}
|
||||
renderItem={renderFeed}
|
||||
keyExtractor={keyExtractor}
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={styles.feedsList}
|
||||
onViewableItemsChanged={onViewableItemsChangedFeeds}
|
||||
viewabilityConfig={viewabilityConfig}
|
||||
/>
|
||||
</ThemedView>
|
||||
)}
|
||||
|
||||
<ThemedView style={styles.section}>
|
||||
{(articles.length > 0 || loading) && (
|
||||
<ThemedText type="smallBold" style={styles.sectionHeader}>
|
||||
Articles ({articles.length})
|
||||
</ThemedText>
|
||||
)}
|
||||
|
||||
{renderEmptyState()}
|
||||
|
||||
<FlatList
|
||||
data={articles}
|
||||
renderItem={renderArticle}
|
||||
keyExtractor={keyExtractor}
|
||||
onEndReached={onLoadMore}
|
||||
onEndReachedThreshold={0.5}
|
||||
ListFooterComponent={renderFooter}
|
||||
onViewableItemsChanged={onViewableItemsChangedArticles}
|
||||
viewabilityConfig={viewabilityConfig}
|
||||
contentContainerStyle={styles.articlesList}
|
||||
/>
|
||||
</ThemedView>
|
||||
</ThemedView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
section: {
|
||||
paddingHorizontal: Spacing.three,
|
||||
paddingBottom: Spacing.three,
|
||||
},
|
||||
sectionHeader: {
|
||||
fontSize: 14,
|
||||
marginBottom: Spacing.three,
|
||||
marginTop: Spacing.two,
|
||||
},
|
||||
feedsList: {
|
||||
paddingRight: Spacing.three,
|
||||
},
|
||||
articlesList: {
|
||||
paddingBottom: Spacing.four,
|
||||
},
|
||||
loadingContainer: {
|
||||
paddingVertical: Spacing.four,
|
||||
alignItems: 'center',
|
||||
},
|
||||
emptyContainer: {
|
||||
paddingVertical: Spacing.six * 2,
|
||||
alignItems: 'center',
|
||||
},
|
||||
emptyTitle: {
|
||||
textAlign: 'center',
|
||||
marginBottom: Spacing.two,
|
||||
},
|
||||
emptySubtitle: {
|
||||
textAlign: 'center',
|
||||
color: '#8e8e93',
|
||||
},
|
||||
});
|
||||
82
src/components/search-sort.tsx
Normal file
82
src/components/search-sort.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import React from 'react';
|
||||
import { StyleSheet, ScrollView, TouchableOpacity } from 'react-native';
|
||||
import { SearchSortOption } from '@/types/feed';
|
||||
import { ThemedText } from './themed-text';
|
||||
import { ThemedView } from './themed-view';
|
||||
import { Colors, Spacing } from '@/constants/theme';
|
||||
import { useColorScheme } from 'react-native';
|
||||
|
||||
const SORT_OPTIONS: { value: SearchSortOption; label: string; icon: string }[] = [
|
||||
{ value: 'relevance', label: 'Relevance', icon: '⭐' },
|
||||
{ value: 'date_desc', label: 'Newest', icon: '📅' },
|
||||
{ value: 'date_asc', label: 'Oldest', icon: '🕐' },
|
||||
{ value: 'title_asc', label: 'A-Z', icon: '🔤' },
|
||||
{ value: 'title_desc', label: 'Z-A', icon: '🔡' },
|
||||
{ value: 'feed_asc', label: 'Feed A-Z', icon: '📰' },
|
||||
{ value: 'feed_desc', label: 'Feed Z-A', icon: '📰' },
|
||||
];
|
||||
|
||||
interface SearchSortProps {
|
||||
sort: SearchSortOption;
|
||||
onSortChange: (sort: SearchSortOption) => void;
|
||||
}
|
||||
|
||||
export function SearchSort({ sort, onSortChange }: SearchSortProps) {
|
||||
const scheme = useColorScheme();
|
||||
const colors = Colors[scheme === 'unspecified' ? 'light' : scheme];
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={styles.container}
|
||||
>
|
||||
{SORT_OPTIONS.map((option) => {
|
||||
const isActive = sort === option.value;
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={option.value}
|
||||
style={[
|
||||
styles.button,
|
||||
isActive && { backgroundColor: '#007AFF' }
|
||||
]}
|
||||
onPress={() => onSortChange(option.value)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<ThemedText
|
||||
style={[
|
||||
styles.buttonText,
|
||||
isActive && styles.activeButtonText
|
||||
]}
|
||||
>
|
||||
{option.icon} {option.label}
|
||||
</ThemedText>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
paddingHorizontal: Spacing.three,
|
||||
paddingVertical: Spacing.two,
|
||||
gap: Spacing.two,
|
||||
},
|
||||
button: {
|
||||
paddingHorizontal: Spacing.three,
|
||||
paddingVertical: Spacing.two,
|
||||
borderRadius: Spacing.two,
|
||||
backgroundColor: '#e5e5ea',
|
||||
minWidth: 80,
|
||||
alignItems: 'center',
|
||||
},
|
||||
buttonText: {
|
||||
fontSize: 14,
|
||||
color: '#8e8e93',
|
||||
},
|
||||
activeButtonText: {
|
||||
color: '#ffffff',
|
||||
},
|
||||
});
|
||||
548
src/components/settings.tsx
Normal file
548
src/components/settings.tsx
Normal file
@@ -0,0 +1,548 @@
|
||||
import React from 'react';
|
||||
import { ScrollView, StyleSheet, Switch, View, TextInput, Pressable } from 'react-native';
|
||||
import { useColorScheme } from 'react-native';
|
||||
|
||||
import { ThemedText } from '@/components/themed-text';
|
||||
import { ThemedView } from '@/components/themed-view';
|
||||
import { Collapsible } from '@/components/ui/collapsible';
|
||||
import { SYNC_INTERVALS, SyncInterval, NotificationPreferences, ReadingPreferences } from '@/types/feed';
|
||||
import { AccountSettings } from '@/types/global';
|
||||
import { useTheme } from '@/hooks/use-theme';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
|
||||
export default function SettingsScreen() {
|
||||
const colorScheme = useColorScheme();
|
||||
const theme = useTheme();
|
||||
|
||||
const {
|
||||
syncInterval,
|
||||
setSyncInterval,
|
||||
theme: themeSetting,
|
||||
setTheme,
|
||||
notificationPreferences,
|
||||
setNotificationPreferences,
|
||||
readingPreferences,
|
||||
setReadingPreferences,
|
||||
accountSettings,
|
||||
setAccountSettings,
|
||||
} = useSettingsStore();
|
||||
|
||||
// Sync interval handler
|
||||
const handleSyncIntervalChange = (value: number) => {
|
||||
const interval = SYNC_INTERVALS.find((i) => i.value === value);
|
||||
if (interval) {
|
||||
setSyncInterval(interval);
|
||||
}
|
||||
};
|
||||
|
||||
// Notification handlers
|
||||
const handleNotificationToggle = (key: keyof NotificationPreferences, value: boolean) => {
|
||||
setNotificationPreferences({ [key]: value });
|
||||
};
|
||||
|
||||
// Reading preference handlers
|
||||
const handleReadingToggle = (key: keyof ReadingPreferences, value: boolean) => {
|
||||
setReadingPreferences({ [key]: value });
|
||||
};
|
||||
|
||||
const handleFontSizeChange = (size: ReadingPreferences['fontSize']) => {
|
||||
setReadingPreferences({ fontSize: size });
|
||||
};
|
||||
|
||||
const handleLineHeightChange = (height: ReadingPreferences['lineHeight']) => {
|
||||
setReadingPreferences({ lineHeight: height });
|
||||
};
|
||||
|
||||
// Theme handler
|
||||
const handleThemeChange = (theme: 'light' | 'dark' | 'system') => {
|
||||
setTheme(theme);
|
||||
};
|
||||
|
||||
// Account settings handlers
|
||||
const handleAccountSettingChange = (key: keyof AccountSettings, value: string | boolean) => {
|
||||
setAccountSettings({ [key]: value });
|
||||
};
|
||||
|
||||
return (
|
||||
<ThemedView style={styles.container}>
|
||||
<ScrollView style={styles.scrollView} contentContainerStyle={styles.scrollContent}>
|
||||
{/* Sync Settings */}
|
||||
<ThemedView style={styles.section}>
|
||||
<ThemedText type="section" style={styles.sectionTitle}>Sync Settings</ThemedText>
|
||||
<ThemedText type="defaultSemiBold" style={styles.sectionDescription}>
|
||||
Choose how often your feeds should be updated.
|
||||
</ThemedText>
|
||||
|
||||
<View style={styles.intervalContainer}>
|
||||
{SYNC_INTERVALS.map((interval) => (
|
||||
<Pressable
|
||||
key={interval.value}
|
||||
onPress={() => handleSyncIntervalChange(interval.value)}
|
||||
style={({ pressed }) => [
|
||||
styles.intervalItem,
|
||||
{
|
||||
backgroundColor:
|
||||
syncInterval.value === interval.value
|
||||
? theme.backgroundSelected
|
||||
: theme.backgroundElement,
|
||||
opacity: pressed ? 0.7 : 1,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<ThemedText style={styles.intervalLabel}>
|
||||
{interval.label}
|
||||
</ThemedText>
|
||||
{syncInterval.value === interval.value && (
|
||||
<View style={styles.checkmark}>
|
||||
<View style={styles.checkmarkCircle} />
|
||||
</View>
|
||||
)}
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
</ThemedView>
|
||||
|
||||
{/* Theme */}
|
||||
<ThemedView style={styles.section}>
|
||||
<ThemedText type="section" style={styles.sectionTitle}>Theme</ThemedText>
|
||||
<ThemedText type="defaultSemiBold" style={styles.sectionDescription}>
|
||||
Choose your preferred appearance.
|
||||
</ThemedText>
|
||||
|
||||
<View style={styles.themeContainer}>
|
||||
<Pressable
|
||||
onPress={() => handleThemeChange('light')}
|
||||
style={({ pressed }) => [
|
||||
styles.themeButton,
|
||||
{ opacity: pressed ? 0.7 : 1 },
|
||||
]}
|
||||
>
|
||||
<View style={[
|
||||
styles.themeCircle,
|
||||
{ backgroundColor: themeSetting === 'light' ? '#ffffff' : theme.backgroundElement }
|
||||
]} />
|
||||
<ThemedText style={styles.themeLabel}>Light</ThemedText>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={() => handleThemeChange('dark')}
|
||||
style={({ pressed }) => [
|
||||
styles.themeButton,
|
||||
{ opacity: pressed ? 0.7 : 1 },
|
||||
]}
|
||||
>
|
||||
<View style={[
|
||||
styles.themeCircle,
|
||||
{ backgroundColor: themeSetting === 'dark' ? '#000000' : theme.backgroundElement }
|
||||
]} />
|
||||
<ThemedText style={styles.themeLabel}>Dark</ThemedText>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={() => handleThemeChange('system')}
|
||||
style={({ pressed }) => [
|
||||
styles.themeButton,
|
||||
{ opacity: pressed ? 0.7 : 1 },
|
||||
]}
|
||||
>
|
||||
<View style={[
|
||||
styles.themeCircle,
|
||||
{ backgroundColor: themeSetting === 'system' ? theme.backgroundSelected : theme.backgroundElement }
|
||||
]} />
|
||||
<ThemedText style={styles.themeLabel}>System</ThemedText>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<ThemedView type="backgroundElement" style={styles.warningBox}>
|
||||
<ThemedText type="smallBold" style={styles.warningTitle}>⚠️ Note</ThemedText>
|
||||
<ThemedText type="small" style={styles.warningText}>
|
||||
Changing theme will apply on app restart.
|
||||
</ThemedText>
|
||||
</ThemedView>
|
||||
</ThemedView>
|
||||
|
||||
{/* Notifications */}
|
||||
<ThemedView style={styles.section}>
|
||||
<ThemedText type="section" style={styles.sectionTitle}>Notifications</ThemedText>
|
||||
<ThemedText type="defaultSemiBold" style={styles.sectionDescription}>
|
||||
Manage how you receive notifications.
|
||||
</ThemedText>
|
||||
|
||||
<ThemedView style={styles.prefGroup}>
|
||||
<View style={styles.prefHeader}>
|
||||
<ThemedText style={styles.prefTitle}>New Articles</ThemedText>
|
||||
<Switch
|
||||
value={notificationPreferences.newArticles}
|
||||
onValueChange={(value) => handleNotificationToggle('newArticles', value)}
|
||||
trackColor={{ false: theme.textSecondary, true: theme.text }}
|
||||
thumbColor="white"
|
||||
/>
|
||||
</View>
|
||||
</ThemedView>
|
||||
|
||||
<ThemedView style={styles.prefGroup}>
|
||||
<View style={styles.prefHeader}>
|
||||
<ThemedText style={styles.prefTitle}>Episode Releases</ThemedText>
|
||||
<Switch
|
||||
value={notificationPreferences.episodeReleases}
|
||||
onValueChange={(value) => handleNotificationToggle('episodeReleases', value)}
|
||||
trackColor={{ false: theme.textSecondary, true: theme.text }}
|
||||
thumbColor="white"
|
||||
/>
|
||||
</View>
|
||||
</ThemedView>
|
||||
|
||||
<ThemedView style={styles.prefGroup}>
|
||||
<View style={styles.prefHeader}>
|
||||
<ThemedText style={styles.prefTitle}>Custom Alerts</ThemedText>
|
||||
<Switch
|
||||
value={notificationPreferences.customAlerts}
|
||||
onValueChange={(value) => handleNotificationToggle('customAlerts', value)}
|
||||
trackColor={{ false: theme.textSecondary, true: theme.text }}
|
||||
thumbColor="white"
|
||||
/>
|
||||
</View>
|
||||
</ThemedView>
|
||||
|
||||
<Collapsible title="Sound & Vibration">
|
||||
<ThemedView style={styles.prefGroup}>
|
||||
<View style={styles.prefHeader}>
|
||||
<ThemedText style={styles.prefTitle}>Sound</ThemedText>
|
||||
<Switch
|
||||
value={notificationPreferences.sound}
|
||||
onValueChange={(value) => handleNotificationToggle('sound', value)}
|
||||
trackColor={{ false: theme.textSecondary, true: theme.text }}
|
||||
thumbColor="white"
|
||||
/>
|
||||
</View>
|
||||
</ThemedView>
|
||||
|
||||
<ThemedView style={styles.prefGroup}>
|
||||
<View style={styles.prefHeader}>
|
||||
<ThemedText style={styles.prefTitle}>Vibration</ThemedText>
|
||||
<Switch
|
||||
value={notificationPreferences.vibration}
|
||||
onValueChange={(value) => handleNotificationToggle('vibration', value)}
|
||||
trackColor={{ false: theme.textSecondary, true: theme.text }}
|
||||
thumbColor="white"
|
||||
/>
|
||||
</View>
|
||||
</ThemedView>
|
||||
</Collapsible>
|
||||
</ThemedView>
|
||||
|
||||
{/* Reading Preferences */}
|
||||
<ThemedView style={styles.section}>
|
||||
<ThemedText type="section" style={styles.sectionTitle}>Reading Preferences</ThemedText>
|
||||
<ThemedText type="defaultSemiBold" style={styles.sectionDescription}>
|
||||
Customize your reading experience.
|
||||
</ThemedText>
|
||||
|
||||
<ThemedView style={styles.prefGroup}>
|
||||
<ThemedText style={styles.prefTitle}>Font Size</ThemedText>
|
||||
<View style={styles.fontSizeSelector}>
|
||||
{(['small', 'medium', 'large', 'xlarge'] as ReadingPreferences['fontSize'][]).map((size) => (
|
||||
<Pressable
|
||||
key={size}
|
||||
onPress={() => handleFontSizeChange(size)}
|
||||
style={({ pressed }) => [
|
||||
styles.fontSizeButton,
|
||||
{
|
||||
backgroundColor:
|
||||
readingPreferences.fontSize === size
|
||||
? theme.backgroundSelected
|
||||
: theme.backgroundElement,
|
||||
opacity: pressed ? 0.7 : 1,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<ThemedText style={[
|
||||
styles.fontSizeLabel,
|
||||
size === 'small' && { fontSize: 12 },
|
||||
size === 'medium' && { fontSize: 16 },
|
||||
size === 'large' && { fontSize: 20 },
|
||||
size === 'xlarge' && { fontSize: 24 }
|
||||
]}>
|
||||
{size === 'small' ? 'A' : size === 'medium' ? 'B' : size === 'large' ? 'C' : 'D'}
|
||||
</ThemedText>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
</ThemedView>
|
||||
|
||||
<ThemedView style={styles.prefGroup}>
|
||||
<ThemedText style={styles.prefTitle}>Line Height</ThemedText>
|
||||
<View style={styles.lineHeightSelector}>
|
||||
{(['normal', 'relaxed', 'loose'] as ReadingPreferences['lineHeight'][]).map((height) => (
|
||||
<Pressable
|
||||
key={height}
|
||||
onPress={() => handleLineHeightChange(height)}
|
||||
style={({ pressed }) => [
|
||||
styles.lineHeightButton,
|
||||
{
|
||||
backgroundColor:
|
||||
readingPreferences.lineHeight === height
|
||||
? theme.backgroundSelected
|
||||
: theme.backgroundElement,
|
||||
opacity: pressed ? 0.7 : 1,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<ThemedText style={styles.lineHeightLabel}>{height.charAt(0).toUpperCase() + height.slice(1)}</ThemedText>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
</ThemedView>
|
||||
|
||||
<ThemedView style={styles.prefGroup}>
|
||||
<View style={styles.prefHeader}>
|
||||
<ThemedText style={styles.prefTitle}>Show Reading Time</ThemedText>
|
||||
<Switch
|
||||
value={readingPreferences.showReadingTime}
|
||||
onValueChange={(value) => handleReadingToggle('showReadingTime', value)}
|
||||
trackColor={{ false: theme.textSecondary, true: theme.text }}
|
||||
thumbColor="white"
|
||||
/>
|
||||
</View>
|
||||
</ThemedView>
|
||||
|
||||
<ThemedView style={styles.prefGroup}>
|
||||
<View style={styles.prefHeader}>
|
||||
<ThemedText style={styles.prefTitle}>Show Author</ThemedText>
|
||||
<Switch
|
||||
value={readingPreferences.showAuthor}
|
||||
onValueChange={(value) => handleReadingToggle('showAuthor', value)}
|
||||
trackColor={{ false: theme.textSecondary, true: theme.text }}
|
||||
thumbColor="white"
|
||||
/>
|
||||
</View>
|
||||
</ThemedView>
|
||||
|
||||
<ThemedView style={styles.prefGroup}>
|
||||
<View style={styles.prefHeader}>
|
||||
<ThemedText style={styles.prefTitle}>Show Date</ThemedText>
|
||||
<Switch
|
||||
value={readingPreferences.showDate}
|
||||
onValueChange={(value) => handleReadingToggle('showDate', value)}
|
||||
trackColor={{ false: theme.textSecondary, true: theme.text }}
|
||||
thumbColor="white"
|
||||
/>
|
||||
</View>
|
||||
</ThemedView>
|
||||
</ThemedView>
|
||||
|
||||
{/* Account Settings */}
|
||||
<ThemedView style={styles.section}>
|
||||
<ThemedText type="section" style={styles.sectionTitle}>Account Settings</ThemedText>
|
||||
<ThemedText type="defaultSemiBold" style={styles.sectionDescription}>
|
||||
Manage your account preferences.
|
||||
</ThemedText>
|
||||
|
||||
<ThemedView style={styles.prefGroup}>
|
||||
<ThemedText style={styles.prefTitle}>Email</ThemedText>
|
||||
<TextInput
|
||||
style={[styles.input, { backgroundColor: theme.backgroundElement }]}
|
||||
placeholder="Enter your email"
|
||||
value={accountSettings.email}
|
||||
onChangeText={(text) => handleAccountSettingChange('email', text)}
|
||||
keyboardType="email-address"
|
||||
autoCapitalize="none"
|
||||
/>
|
||||
</ThemedView>
|
||||
|
||||
<ThemedView style={styles.prefGroup}>
|
||||
<ThemedText style={styles.prefTitle}>Privacy Level</ThemedText>
|
||||
<View style={styles.privacySelector}>
|
||||
{(['public', 'private', 'anonymous'] as AccountSettings['privacy'][]).map((privacy) => (
|
||||
<Pressable
|
||||
key={privacy}
|
||||
onPress={() => handleAccountSettingChange('privacy', privacy)}
|
||||
style={({ pressed }) => [
|
||||
styles.privacyButton,
|
||||
{
|
||||
backgroundColor:
|
||||
accountSettings.privacy === privacy
|
||||
? theme.backgroundSelected
|
||||
: theme.backgroundElement,
|
||||
opacity: pressed ? 0.7 : 1,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<ThemedText style={styles.privacyLabel}>
|
||||
{privacy.charAt(0).toUpperCase() + privacy.slice(1)}
|
||||
</ThemedText>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
</ThemedView>
|
||||
|
||||
<ThemedView style={styles.prefGroup}>
|
||||
<ThemedText style={styles.prefTitle}>Language</ThemedText>
|
||||
<TextInput
|
||||
style={[styles.input, { backgroundColor: theme.backgroundElement }]}
|
||||
placeholder="e.g., en-US"
|
||||
value={accountSettings.language}
|
||||
onChangeText={(text) => handleAccountSettingChange('language', text)}
|
||||
/>
|
||||
</ThemedView>
|
||||
|
||||
<ThemedView style={styles.prefGroup}>
|
||||
<ThemedText style={styles.prefTitle}>Timezone</ThemedText>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="e.g., UTC"
|
||||
value={accountSettings.timezone}
|
||||
onChangeText={(text) => handleAccountSettingChange('timezone', text)}
|
||||
/>
|
||||
</ThemedView>
|
||||
|
||||
<ThemedView style={styles.prefGroup}>
|
||||
<View style={styles.prefHeader}>
|
||||
<ThemedText style={styles.prefTitle}>Notifications Enabled</ThemedText>
|
||||
<Switch
|
||||
value={accountSettings.notificationsEnabled}
|
||||
onValueChange={(value) => handleAccountSettingChange('notificationsEnabled', value)}
|
||||
trackColor={{ false: theme.textSecondary, true: theme.text }}
|
||||
thumbColor="white"
|
||||
/>
|
||||
</View>
|
||||
</ThemedView>
|
||||
</ThemedView>
|
||||
</ScrollView>
|
||||
</ThemedView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
scrollView: {
|
||||
flex: 1,
|
||||
},
|
||||
scrollContent: {
|
||||
paddingBottom: 24,
|
||||
},
|
||||
section: {
|
||||
marginBottom: 24,
|
||||
paddingHorizontal: 16,
|
||||
},
|
||||
sectionTitle: {
|
||||
marginBottom: 4,
|
||||
},
|
||||
sectionDescription: {
|
||||
marginBottom: 16,
|
||||
},
|
||||
intervalContainer: {
|
||||
gap: 8,
|
||||
},
|
||||
intervalItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: 16,
|
||||
borderRadius: 12,
|
||||
gap: 12,
|
||||
},
|
||||
intervalLabel: {
|
||||
fontSize: 14,
|
||||
},
|
||||
checkmark: {
|
||||
width: 20,
|
||||
height: 20,
|
||||
borderRadius: 10,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
checkmarkCircle: {
|
||||
width: 12,
|
||||
height: 12,
|
||||
borderRadius: 6,
|
||||
backgroundColor: '#4CAF50',
|
||||
},
|
||||
themeContainer: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-around',
|
||||
alignItems: 'center',
|
||||
marginBottom: 16,
|
||||
},
|
||||
themeButton: {
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
},
|
||||
themeCircle: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
},
|
||||
themeLabel: {
|
||||
fontSize: 12,
|
||||
},
|
||||
warningBox: {
|
||||
padding: 12,
|
||||
borderRadius: 8,
|
||||
marginBottom: 16,
|
||||
},
|
||||
warningTitle: {
|
||||
marginBottom: 4,
|
||||
},
|
||||
warningText: {},
|
||||
prefGroup: {
|
||||
marginBottom: 8,
|
||||
},
|
||||
prefHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
prefTitle: {
|
||||
fontSize: 14,
|
||||
},
|
||||
fontSizeSelector: {
|
||||
flexDirection: 'row',
|
||||
gap: 8,
|
||||
},
|
||||
fontSizeButton: {
|
||||
padding: 12,
|
||||
borderRadius: 8,
|
||||
minWidth: 44,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
fontSizeLabel: {
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
lineHeightSelector: {
|
||||
flexDirection: 'row',
|
||||
gap: 8,
|
||||
},
|
||||
lineHeightButton: {
|
||||
flex: 1,
|
||||
padding: 12,
|
||||
borderRadius: 8,
|
||||
alignItems: 'center',
|
||||
},
|
||||
lineHeightLabel: {
|
||||
fontSize: 12,
|
||||
textAlign: 'center',
|
||||
},
|
||||
input: {
|
||||
borderRadius: 8,
|
||||
padding: 12,
|
||||
fontSize: 14,
|
||||
marginTop: 4,
|
||||
},
|
||||
privacySelector: {
|
||||
flexDirection: 'row',
|
||||
gap: 8,
|
||||
marginTop: 4,
|
||||
},
|
||||
privacyButton: {
|
||||
flex: 1,
|
||||
padding: 12,
|
||||
borderRadius: 8,
|
||||
alignItems: 'center',
|
||||
},
|
||||
privacyLabel: {
|
||||
fontSize: 14,
|
||||
textAlign: 'center',
|
||||
},
|
||||
});
|
||||
@@ -4,7 +4,7 @@ import { Fonts, ThemeColor } from '@/constants/theme';
|
||||
import { useTheme } from '@/hooks/use-theme';
|
||||
|
||||
export type ThemedTextProps = TextProps & {
|
||||
type?: 'default' | 'title' | 'small' | 'smallBold' | 'subtitle' | 'link' | 'linkPrimary' | 'code';
|
||||
type?: 'default' | 'title' | 'small' | 'smallBold' | 'subtitle' | 'link' | 'linkPrimary' | 'code' | 'section' | 'defaultSemiBold';
|
||||
themeColor?: ThemeColor;
|
||||
};
|
||||
|
||||
@@ -23,6 +23,8 @@ export function ThemedText({ style, type = 'default', themeColor, ...rest }: The
|
||||
type === 'link' && styles.link,
|
||||
type === 'linkPrimary' && styles.linkPrimary,
|
||||
type === 'code' && styles.code,
|
||||
type === 'section' && styles.section,
|
||||
type === 'defaultSemiBold' && styles.defaultSemiBold,
|
||||
style,
|
||||
]}
|
||||
{...rest}
|
||||
@@ -46,6 +48,11 @@ const styles = StyleSheet.create({
|
||||
lineHeight: 24,
|
||||
fontWeight: 500,
|
||||
},
|
||||
defaultSemiBold: {
|
||||
fontSize: 16,
|
||||
lineHeight: 24,
|
||||
fontWeight: 600,
|
||||
},
|
||||
title: {
|
||||
fontSize: 48,
|
||||
fontWeight: 600,
|
||||
@@ -56,6 +63,11 @@ const styles = StyleSheet.create({
|
||||
lineHeight: 44,
|
||||
fontWeight: 600,
|
||||
},
|
||||
section: {
|
||||
fontSize: 20,
|
||||
fontWeight: 600,
|
||||
lineHeight: 24,
|
||||
},
|
||||
link: {
|
||||
lineHeight: 30,
|
||||
fontSize: 14,
|
||||
|
||||
82
src/hooks/use-feed-list.ts
Normal file
82
src/hooks/use-feed-list.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { FeedItem } from '@/types/feed';
|
||||
import { getAllFeedItems } from '@/services/database';
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
export function useFeedList() {
|
||||
const [localFeedItems, setLocalFeedItems] = useState<FeedItem[]>([]);
|
||||
const [page, setPage] = useState(0);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const loadMore = useCallback(async () => {
|
||||
if (loadingMore || !hasMore || isRefreshing) return;
|
||||
|
||||
setLoadingMore(true);
|
||||
|
||||
try {
|
||||
const offset = page * PAGE_SIZE;
|
||||
const items = await getAllFeedItems(PAGE_SIZE + 1, offset);
|
||||
|
||||
if (items.length > PAGE_SIZE) {
|
||||
const newItems = items.slice(0, PAGE_SIZE);
|
||||
setLocalFeedItems(prev => [...prev, ...newItems]);
|
||||
setHasMore(true);
|
||||
} else {
|
||||
setLocalFeedItems(prev => [...prev, ...items]);
|
||||
setHasMore(false);
|
||||
}
|
||||
|
||||
setPage(prev => prev + 1);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
console.error('Failed to load more items:', err);
|
||||
setError('Failed to load more items. Please try again.');
|
||||
} finally {
|
||||
setLoadingMore(false);
|
||||
}
|
||||
}, [loadingMore, hasMore, isRefreshing, page]);
|
||||
|
||||
const refreshFeed = useCallback(async () => {
|
||||
if (isRefreshing) return;
|
||||
|
||||
setIsRefreshing(true);
|
||||
setPage(0);
|
||||
setHasMore(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const items = await getAllFeedItems(PAGE_SIZE + 1);
|
||||
|
||||
if (items.length > PAGE_SIZE) {
|
||||
setLocalFeedItems(items.slice(0, PAGE_SIZE));
|
||||
setHasMore(true);
|
||||
} else {
|
||||
setLocalFeedItems(items);
|
||||
setHasMore(false);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to refresh feed:', err);
|
||||
setError('Failed to refresh feed. Please try again.');
|
||||
} finally {
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
}, [isRefreshing]);
|
||||
|
||||
useEffect(() => {
|
||||
refreshFeed();
|
||||
}, []);
|
||||
|
||||
return {
|
||||
feedItems: localFeedItems,
|
||||
loading: loadingMore,
|
||||
hasMore,
|
||||
loadMore,
|
||||
refreshFeed,
|
||||
isRefreshing,
|
||||
error,
|
||||
};
|
||||
}
|
||||
99
src/hooks/use-offline.ts
Normal file
99
src/hooks/use-offline.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
// Offline/Online Network Hook
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { hasLocalData, getAllLocalFeedItems, getLocalFeedItems, syncAllFeeds } from '@/services/sync-service';
|
||||
import { FeedItem } from '@/types/feed';
|
||||
|
||||
type NetworkStatus = 'online' | 'offline' | 'unknown';
|
||||
|
||||
interface UseOfflineReturn {
|
||||
isOnline: boolean;
|
||||
networkStatus: NetworkStatus;
|
||||
hasOfflineData: boolean;
|
||||
isSyncing: boolean;
|
||||
lastSyncTime: Date | null;
|
||||
syncNow: () => Promise<void>;
|
||||
getOfflineFeedItems: (subscriptionId?: string, limit?: number, offset?: number) => Promise<FeedItem[]>;
|
||||
}
|
||||
|
||||
export function useOffline(): UseOfflineReturn {
|
||||
const [networkStatus, setNetworkStatus] = useState<NetworkStatus>('unknown');
|
||||
const [hasOfflineData, setHasOfflineData] = useState(false);
|
||||
const [isSyncing, setIsSyncing] = useState(false);
|
||||
const [lastSyncTime, setLastSyncTime] = useState<Date | null>(null);
|
||||
|
||||
// Check network status (simplified - in production would use @react-native-community/netinfo)
|
||||
const checkNetworkStatus = useCallback(async () => {
|
||||
try {
|
||||
// Try to fetch from a known endpoint to test connectivity
|
||||
const response = await fetch('https://www.google.com/favicon.ico', {
|
||||
method: 'HEAD',
|
||||
mode: 'no-cors',
|
||||
});
|
||||
setNetworkStatus('online');
|
||||
return true;
|
||||
} catch {
|
||||
setNetworkStatus('offline');
|
||||
return false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Check if we have offline data
|
||||
const checkOfflineData = useCallback(async () => {
|
||||
const hasData = await hasLocalData();
|
||||
setHasOfflineData(hasData);
|
||||
return hasData;
|
||||
}, []);
|
||||
|
||||
// Sync now
|
||||
const syncNow = useCallback(async () => {
|
||||
if (networkStatus === 'offline') {
|
||||
console.log('[Offline] Cannot sync while offline');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSyncing(true);
|
||||
try {
|
||||
await syncAllFeeds();
|
||||
setLastSyncTime(new Date());
|
||||
await checkOfflineData();
|
||||
} finally {
|
||||
setIsSyncing(false);
|
||||
}
|
||||
}, [networkStatus, checkOfflineData]);
|
||||
|
||||
// Get offline feed items
|
||||
const getOfflineFeedItems = useCallback(async (
|
||||
subscriptionId?: string,
|
||||
limit: number = 50,
|
||||
offset: number = 0
|
||||
): Promise<FeedItem[]> => {
|
||||
if (subscriptionId) {
|
||||
return getLocalFeedItems(subscriptionId, limit, offset);
|
||||
}
|
||||
return getAllLocalFeedItems(limit);
|
||||
}, []);
|
||||
|
||||
// Initial checks
|
||||
useEffect(() => {
|
||||
checkNetworkStatus();
|
||||
checkOfflineData();
|
||||
|
||||
// Periodic network check
|
||||
const interval = setInterval(() => {
|
||||
checkNetworkStatus();
|
||||
}, 30000); // Every 30 seconds
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [checkNetworkStatus, checkOfflineData]);
|
||||
|
||||
return {
|
||||
isOnline: networkStatus === 'online',
|
||||
networkStatus,
|
||||
hasOfflineData,
|
||||
isSyncing,
|
||||
lastSyncTime,
|
||||
syncNow,
|
||||
getOfflineFeedItems,
|
||||
};
|
||||
}
|
||||
113
src/i18n/index.ts
Normal file
113
src/i18n/index.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
// Internationalization Setup
|
||||
|
||||
import { getLocales } from 'expo-localization';
|
||||
|
||||
type IntlConfig = {
|
||||
enableHighAccuracy: boolean;
|
||||
fallbackLocale: string;
|
||||
supportedLocales: string[];
|
||||
};
|
||||
|
||||
const intlConfig: IntlConfig = {
|
||||
enableHighAccuracy: true,
|
||||
fallbackLocale: 'en',
|
||||
supportedLocales: ['en', 'es', 'fr', 'de', 'ja', 'ko', 'zh'],
|
||||
};
|
||||
|
||||
export const locales = {
|
||||
en: {
|
||||
name: 'English',
|
||||
strings: {
|
||||
'app.name': 'Rssuper',
|
||||
'tab.home': 'Home',
|
||||
'tab.explore': 'Explore',
|
||||
'tab.settings': 'Settings',
|
||||
'feed.add': 'Add Feed',
|
||||
'feed.loading': 'Loading feeds...',
|
||||
'feed.error': 'Error loading feed',
|
||||
'feed.noItems': 'No items in this feed',
|
||||
'explore.discover': 'Discover new feeds and podcasts',
|
||||
'explore.searchPlaceholder': 'Search feeds, topics, podcasts',
|
||||
'explore.all': 'All',
|
||||
'explore.trending': 'Trending Feeds',
|
||||
'explore.recommended': 'Recommended Podcasts',
|
||||
'explore.noFeeds': 'No feeds found matching your criteria',
|
||||
'common.cancel': 'Cancel',
|
||||
'common.save': 'Save',
|
||||
'common.delete': 'Delete',
|
||||
'common.edit': 'Edit',
|
||||
},
|
||||
},
|
||||
es: {
|
||||
name: 'Espa\u00f1ol',
|
||||
strings: {
|
||||
'app.name': 'Rssuper',
|
||||
'tab.home': 'Inicio',
|
||||
'tab.explore': 'Explorar',
|
||||
'tab.settings': 'Ajustes',
|
||||
'feed.add': 'A\u00f1adir Feed',
|
||||
'feed.loading': 'Cargando feeds...',
|
||||
'feed.error': 'Error cargando feed',
|
||||
'feed.noItems': 'No hay items en este feed',
|
||||
'explore.discover': 'Descubre nuevos feeds y podcasts',
|
||||
'explore.searchPlaceholder': 'Buscar feeds, temas, podcasts',
|
||||
'explore.all': 'Todo',
|
||||
'explore.trending': 'Feeds en tendencia',
|
||||
'explore.recommended': 'Podcasts recomendados',
|
||||
'explore.noFeeds': 'No se encontraron feeds',
|
||||
'common.cancel': 'Cancelar',
|
||||
'common.save': 'Guardar',
|
||||
'common.delete': 'Eliminar',
|
||||
'common.edit': 'Editar',
|
||||
},
|
||||
},
|
||||
fr: {
|
||||
name: 'Fran\u00e7ais',
|
||||
strings: {
|
||||
'app.name': 'Rssuper',
|
||||
'tab.home': 'Accueil',
|
||||
'tab.explore': 'Explorer',
|
||||
'tab.settings': 'Paramètres',
|
||||
'feed.add': 'Ajouter un flux',
|
||||
'feed.loading': 'Chargement des flux...',
|
||||
'feed.error': 'Erreur de chargement',
|
||||
'feed.noItems': 'Aucun \u00e9l\u00e9ment dans ce flux',
|
||||
'explore.discover': 'D\u00e9couvrez de nouveaux flux et podcasts',
|
||||
'explore.searchPlaceholder': 'Rechercher des flux, th\u00e8mes, podcasts',
|
||||
'explore.all': 'Tout',
|
||||
'explore.trending': 'Flux tendances',
|
||||
'explore.recommended': 'Podcasts recommand\u00e9s',
|
||||
'explore.noFeeds': 'Aucun flux trouv\u00e9',
|
||||
'common.cancel': 'Annuler',
|
||||
'common.save': 'Enregistrer',
|
||||
'common.delete': 'Supprimer',
|
||||
'common.edit': 'Modifier',
|
||||
},
|
||||
},
|
||||
// Add more languages as needed
|
||||
};
|
||||
|
||||
export function t(key: string, locale?: string): string {
|
||||
const currentLocale = locale || getLocales()[0]?.languageTag?.split('-')[0] || 'en';
|
||||
const localeData = locales[currentLocale as keyof typeof locales];
|
||||
|
||||
if (!localeData) return key;
|
||||
|
||||
return localeData.strings[key as keyof typeof localeData.strings] || key;
|
||||
}
|
||||
|
||||
export function useTranslation() {
|
||||
const currentLocale = getLocales()[0]?.languageTag?.split('-')[0] || 'en';
|
||||
|
||||
return {
|
||||
t: (key: string) => t(key, currentLocale),
|
||||
locale: currentLocale,
|
||||
locales: Object.entries(locales).map(([code, data]) => ({
|
||||
code,
|
||||
name: data.name,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export { intlConfig };
|
||||
export default intlConfig;
|
||||
39
src/services/api.ts
Normal file
39
src/services/api.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
// API Layer Setup
|
||||
|
||||
import axios, { AxiosInstance, AxiosError } from 'axios';
|
||||
|
||||
const API_BASE_URL = process.env.EXPO_PUBLIC_API_URL || 'http://localhost:3000/api';
|
||||
|
||||
// Create axios instance with defaults
|
||||
const apiClient: AxiosInstance = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
timeout: 15000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
// Request interceptor for auth tokens
|
||||
apiClient.interceptors.request.use(
|
||||
(config) => {
|
||||
// TODO: Add auth token from store
|
||||
// const token = useAuthStore.getState().token;
|
||||
// if (token) {
|
||||
// config.headers.Authorization = `Bearer ${token}`;
|
||||
// }
|
||||
return config;
|
||||
},
|
||||
(error) => Promise.reject(error)
|
||||
);
|
||||
|
||||
// Response interceptor for error handling
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error: AxiosError) => {
|
||||
// TODO: Add centralized error handling
|
||||
console.error('API Error:', error.response?.status, error.message);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
export default apiClient;
|
||||
162
src/services/audio-player.ts
Normal file
162
src/services/audio-player.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
// Audio Player Service for Podcast Playback
|
||||
|
||||
import { Audio, AVPlaybackStatus, AVPlaybackStatusSuccess } from 'expo-av';
|
||||
import { FeedItem } from '@/types/feed';
|
||||
|
||||
type PlaybackState = 'idle' | 'loading' | 'playing' | 'paused' | 'error';
|
||||
type PlaybackSpeed = 0.5 | 0.75 | 1 | 1.25 | 1.5 | 1.75 | 2;
|
||||
|
||||
interface PlayerState {
|
||||
state: PlaybackState;
|
||||
currentItem: FeedItem | null;
|
||||
position: number;
|
||||
duration: number;
|
||||
playbackSpeed: PlaybackSpeed;
|
||||
isBuffering: boolean;
|
||||
}
|
||||
|
||||
type StateChangeCallback = (state: PlayerState) => void;
|
||||
|
||||
class AudioPlayerService {
|
||||
private sound: Audio.Sound | null = null;
|
||||
private state: PlayerState = {
|
||||
state: 'idle',
|
||||
currentItem: null,
|
||||
position: 0,
|
||||
duration: 0,
|
||||
playbackSpeed: 1,
|
||||
isBuffering: false,
|
||||
};
|
||||
private listeners: Set<StateChangeCallback> = new Set();
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
await Audio.setAudioModeAsync({
|
||||
allowsRecordingIOS: false,
|
||||
playsInSilentModeIOS: true,
|
||||
staysActiveInBackground: true,
|
||||
shouldDuckAndroid: true,
|
||||
playThroughEarpieceAndroid: false,
|
||||
});
|
||||
}
|
||||
|
||||
subscribe(callback: StateChangeCallback): () => void {
|
||||
this.listeners.add(callback);
|
||||
callback(this.state);
|
||||
return () => this.listeners.delete(callback);
|
||||
}
|
||||
|
||||
private updateState(updates: Partial<PlayerState>): void {
|
||||
this.state = { ...this.state, ...updates };
|
||||
this.listeners.forEach(cb => cb(this.state));
|
||||
}
|
||||
|
||||
async play(item: FeedItem): Promise<void> {
|
||||
if (!item.enclosure?.url) {
|
||||
this.updateState({ state: 'error' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.updateState({ state: 'loading', currentItem: item, isBuffering: true });
|
||||
|
||||
// Unload previous sound
|
||||
if (this.sound) {
|
||||
await this.sound.unloadAsync();
|
||||
this.sound = null;
|
||||
}
|
||||
|
||||
// Create and load new sound
|
||||
const { sound } = await Audio.Sound.createAsync(
|
||||
{ uri: item.enclosure.url },
|
||||
{ shouldPlay: true, progressUpdateIntervalMillis: 1000 },
|
||||
this.onPlaybackStatusUpdate
|
||||
);
|
||||
|
||||
this.sound = sound;
|
||||
} catch (error) {
|
||||
console.error('Playback error:', error);
|
||||
this.updateState({ state: 'error' });
|
||||
}
|
||||
}
|
||||
|
||||
private onPlaybackStatusUpdate = (status: AVPlaybackStatus) => {
|
||||
if (!status.isLoaded) {
|
||||
if (status.error) {
|
||||
this.updateState({ state: 'error' });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const loadedStatus = status as AVPlaybackStatusSuccess;
|
||||
|
||||
this.updateState({
|
||||
state: loadedStatus.isPlaying ? 'playing' : 'paused',
|
||||
position: loadedStatus.positionMillis,
|
||||
duration: loadedStatus.durationMillis || 0,
|
||||
isBuffering: loadedStatus.isBuffering || false,
|
||||
});
|
||||
};
|
||||
|
||||
async pause(): Promise<void> {
|
||||
if (this.sound) {
|
||||
await this.sound.pauseAsync();
|
||||
}
|
||||
}
|
||||
|
||||
async resume(): Promise<void> {
|
||||
if (this.sound) {
|
||||
await this.sound.playAsync();
|
||||
}
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
if (this.sound) {
|
||||
await this.sound.stopAsync();
|
||||
await this.sound.unloadAsync();
|
||||
this.sound = null;
|
||||
}
|
||||
this.updateState({
|
||||
state: 'idle',
|
||||
currentItem: null,
|
||||
position: 0,
|
||||
duration: 0,
|
||||
});
|
||||
}
|
||||
|
||||
async seekTo(positionMs: number): Promise<void> {
|
||||
if (this.sound) {
|
||||
await this.sound.setPositionAsync(positionMs);
|
||||
}
|
||||
}
|
||||
|
||||
async setPlaybackSpeed(speed: PlaybackSpeed): Promise<void> {
|
||||
if (this.sound) {
|
||||
await this.sound.setRateAsync(speed, true);
|
||||
this.updateState({ playbackSpeed: speed });
|
||||
}
|
||||
}
|
||||
|
||||
async skipForward(seconds: number = 30): Promise<void> {
|
||||
if (this.sound) {
|
||||
const newPosition = Math.min(
|
||||
this.state.position + seconds * 1000,
|
||||
this.state.duration
|
||||
);
|
||||
await this.sound.setPositionAsync(newPosition);
|
||||
}
|
||||
}
|
||||
|
||||
async skipBackward(seconds: number = 10): Promise<void> {
|
||||
if (this.sound) {
|
||||
const newPosition = Math.max(this.state.position - seconds * 1000, 0);
|
||||
await this.sound.setPositionAsync(newPosition);
|
||||
}
|
||||
}
|
||||
|
||||
getState(): PlayerState {
|
||||
return this.state;
|
||||
}
|
||||
}
|
||||
|
||||
export const audioPlayer = new AudioPlayerService();
|
||||
export type { PlaybackState, PlaybackSpeed, PlayerState };
|
||||
90
src/services/background-sync.ts
Normal file
90
src/services/background-sync.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
// Background Sync Service using expo-task-manager
|
||||
|
||||
import * as TaskManager from 'expo-task-manager';
|
||||
import { syncAllFeeds, getFeedsDueForSync } from './sync-service';
|
||||
import { useFeedStore } from '@/stores/feed-store';
|
||||
|
||||
const BACKGROUND_SYNC_TASK_NAME = 'BACKGROUND_SYNC_TASK';
|
||||
const BACKGROUND_SYNC_INTERVAL = 15; // minutes
|
||||
|
||||
// Task manager result types
|
||||
enum BackgroundFetchResult {
|
||||
NoData = 'noData',
|
||||
NewData = 'newData',
|
||||
Failed = 'failed',
|
||||
}
|
||||
|
||||
// Define the background task
|
||||
TaskManager.defineTask(BACKGROUND_SYNC_TASK_NAME, async () => {
|
||||
try {
|
||||
console.log('[Background] Starting background sync...');
|
||||
|
||||
const subscriptions = useFeedStore.getState().subscriptions;
|
||||
if (subscriptions.length === 0) {
|
||||
console.log('[Background] No subscriptions to sync');
|
||||
return BackgroundFetchResult.NoData;
|
||||
}
|
||||
|
||||
const result = await syncAllFeeds();
|
||||
|
||||
if (result.success) {
|
||||
console.log(`[Background] Synced ${result.totalItemsSynced} items`);
|
||||
return BackgroundFetchResult.NewData;
|
||||
} else {
|
||||
console.log('[Background] Sync completed with errors');
|
||||
return BackgroundFetchResult.Failed;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Background] Sync error:', error);
|
||||
return BackgroundFetchResult.Failed;
|
||||
}
|
||||
});
|
||||
|
||||
// Register background fetch task
|
||||
export async function registerBackgroundSync(): Promise<boolean> {
|
||||
try {
|
||||
const task = TaskManager.defineTask(BACKGROUND_SYNC_TASK_NAME, async () => {
|
||||
try {
|
||||
console.log('[Background] Starting background sync...');
|
||||
|
||||
const subscriptions = useFeedStore.getState().subscriptions;
|
||||
if (subscriptions.length === 0) {
|
||||
console.log('[Background] No subscriptions to sync');
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await syncAllFeeds();
|
||||
console.log(`[Background] Synced ${result.totalItemsSynced} items`);
|
||||
} catch (error) {
|
||||
console.error('[Background] Sync error:', error);
|
||||
}
|
||||
});
|
||||
|
||||
const status = await TaskManager.isTaskRegisteredAsync(BACKGROUND_SYNC_TASK_NAME);
|
||||
if (status) {
|
||||
console.log('[Background] Background sync already registered');
|
||||
return true;
|
||||
}
|
||||
|
||||
console.log('[Background] Background sync registered successfully');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('[Background] Failed to register background sync:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Unregister background fetch task
|
||||
export async function unregisterBackgroundSync(): Promise<void> {
|
||||
try {
|
||||
await TaskManager.unregisterTaskAsync(BACKGROUND_SYNC_TASK_NAME);
|
||||
console.log('[Background] Background sync unregistered');
|
||||
} catch (error) {
|
||||
console.error('[Background] Failed to unregister:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if background sync task is registered
|
||||
export async function isBackgroundSyncRegistered(): Promise<boolean> {
|
||||
return TaskManager.isTaskRegisteredAsync(BACKGROUND_SYNC_TASK_NAME);
|
||||
}
|
||||
320
src/services/database.ts
Normal file
320
src/services/database.ts
Normal file
@@ -0,0 +1,320 @@
|
||||
// Database Layer for Feed Storage
|
||||
|
||||
import * as SQLite from 'expo-sqlite';
|
||||
import { FeedSubscription, FeedItem } from '@/types/feed';
|
||||
|
||||
let db: SQLite.SQLiteDatabase | null = null;
|
||||
|
||||
export async function initDatabase(): Promise<void> {
|
||||
if (db) return;
|
||||
|
||||
try {
|
||||
db = await SQLite.openDatabaseAsync('rssuper.db');
|
||||
|
||||
await db.execAsync(`
|
||||
CREATE TABLE IF NOT EXISTS subscriptions (
|
||||
id TEXT PRIMARY KEY,
|
||||
url TEXT NOT NULL UNIQUE,
|
||||
title TEXT,
|
||||
category TEXT,
|
||||
enabled INTEGER DEFAULT 1,
|
||||
fetch_interval INTEGER DEFAULT 60,
|
||||
http_auth_username TEXT,
|
||||
http_auth_password TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
last_fetched_at DATETIME,
|
||||
next_fetch_at DATETIME,
|
||||
error TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS feeds (
|
||||
id TEXT PRIMARY KEY,
|
||||
subscription_id TEXT NOT NULL,
|
||||
title TEXT,
|
||||
link TEXT,
|
||||
description TEXT,
|
||||
author TEXT,
|
||||
published DATETIME,
|
||||
updated DATETIME,
|
||||
content TEXT,
|
||||
guid TEXT UNIQUE,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (subscription_id) REFERENCES subscriptions(id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_feeds_subscription ON feeds(subscription_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_feeds_published ON feeds(published DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_subscriptions_next_fetch ON subscriptions(next_fetch_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_feeds_author ON feeds(author);
|
||||
CREATE INDEX IF NOT EXISTS idx_feeds_link ON feeds(link);
|
||||
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS feeds_fts USING fts5(
|
||||
title,
|
||||
description,
|
||||
content,
|
||||
content='feeds',
|
||||
content_rowid='rowid'
|
||||
);
|
||||
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS subscriptions_fts USING fts5(
|
||||
title,
|
||||
url,
|
||||
content='subscriptions',
|
||||
content_rowid='rowid'
|
||||
);
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS feeds_ai AFTER INSERT ON feeds BEGIN
|
||||
INSERT INTO feeds_fts(rowid, title, description, content)
|
||||
VALUES (new.rowid, new.title, new.description, new.content);
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS feeds_ad AFTER DELETE ON feeds BEGIN
|
||||
INSERT INTO feeds_fts(feeds_fts, rowid, title, description, content)
|
||||
VALUES ('delete', old.rowid, old.title, old.description, old.content);
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS feeds_au AFTER UPDATE ON feeds BEGIN
|
||||
INSERT INTO feeds_fts(feeds_fts, rowid, title, description, content)
|
||||
VALUES ('delete', old.rowid, old.title, old.description, old.content);
|
||||
INSERT INTO feeds_fts(rowid, title, description, content)
|
||||
VALUES (new.rowid, new.title, new.description, new.content);
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS subscriptions_ai AFTER INSERT ON subscriptions BEGIN
|
||||
INSERT INTO subscriptions_fts(rowid, title, url)
|
||||
VALUES (new.rowid, new.title, new.url);
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS subscriptions_ad AFTER DELETE ON subscriptions BEGIN
|
||||
INSERT INTO subscriptions_fts(subscriptions_fts, rowid, title, url)
|
||||
VALUES ('delete', old.rowid, old.title, old.url);
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS subscriptions_au AFTER UPDATE ON subscriptions BEGIN
|
||||
INSERT INTO subscriptions_fts(subscriptions_fts, rowid, title, url)
|
||||
VALUES ('delete', old.rowid, old.title, old.url);
|
||||
INSERT INTO subscriptions_fts(rowid, title, url)
|
||||
VALUES (new.rowid, new.title, new.url);
|
||||
END;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS search_history (
|
||||
id TEXT PRIMARY KEY,
|
||||
query TEXT NOT NULL,
|
||||
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_search_history_timestamp ON search_history(timestamp DESC);
|
||||
`);
|
||||
|
||||
console.log('Database initialized successfully');
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize database:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to get database instance
|
||||
export async function getDb(): Promise<SQLite.SQLiteDatabase> {
|
||||
if (!db) await initDatabase();
|
||||
if (!db) throw new Error('Database not initialized');
|
||||
return db;
|
||||
}
|
||||
|
||||
// Subscription CRUD
|
||||
|
||||
export async function saveSubscription(sub: FeedSubscription): Promise<void> {
|
||||
const database = await getDb();
|
||||
|
||||
await database.runAsync(
|
||||
`INSERT OR REPLACE INTO subscriptions
|
||||
(id, url, title, category, enabled, fetch_interval,
|
||||
http_auth_username, http_auth_password, updated_at,
|
||||
last_fetched_at, next_fetch_at, error)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, ?, ?, ?)`,
|
||||
[
|
||||
sub.id,
|
||||
sub.url,
|
||||
sub.title,
|
||||
sub.category || null,
|
||||
sub.enabled ? 1 : 0,
|
||||
sub.fetchInterval,
|
||||
sub.httpAuth?.username || null,
|
||||
sub.httpAuth?.password || null,
|
||||
sub.lastFetchedAt?.toISOString() || null,
|
||||
sub.nextFetchAt?.toISOString() || null,
|
||||
sub.error || null,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
export async function getSubscription(id: string): Promise<FeedSubscription | null> {
|
||||
const database = await getDb();
|
||||
|
||||
const row = await database.getFirstAsync<any>(
|
||||
'SELECT * FROM subscriptions WHERE id = ?',
|
||||
[id]
|
||||
);
|
||||
|
||||
if (!row) return null;
|
||||
|
||||
return rowToSubscription(row);
|
||||
}
|
||||
|
||||
export async function getAllSubscriptions(): Promise<FeedSubscription[]> {
|
||||
const database = await getDb();
|
||||
|
||||
const rows = await database.getAllAsync<any>('SELECT * FROM subscriptions ORDER BY title');
|
||||
return rows.map(rowToSubscription);
|
||||
}
|
||||
|
||||
export async function deleteSubscription(id: string): Promise<void> {
|
||||
const database = await getDb();
|
||||
|
||||
await database.runAsync('DELETE FROM subscriptions WHERE id = ?', [id]);
|
||||
await database.runAsync('DELETE FROM feeds WHERE subscription_id = ?', [id]);
|
||||
}
|
||||
|
||||
// Feed Item CRUD
|
||||
|
||||
export async function saveFeedItems(
|
||||
subscriptionId: string,
|
||||
items: FeedItem[]
|
||||
): Promise<void> {
|
||||
const database = await getDb();
|
||||
|
||||
const insertQuery = `
|
||||
INSERT OR REPLACE INTO feeds
|
||||
(id, subscription_id, title, link, description, author,
|
||||
published, updated, content, guid)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`;
|
||||
|
||||
for (const item of items) {
|
||||
await database.runAsync(insertQuery, [
|
||||
item.id,
|
||||
subscriptionId,
|
||||
item.title || null,
|
||||
item.link || null,
|
||||
item.description || null,
|
||||
item.author || null,
|
||||
item.published?.toISOString() || null,
|
||||
item.updated?.toISOString() || null,
|
||||
item.content || null,
|
||||
item.guid || null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getFeedItems(
|
||||
subscriptionId?: string,
|
||||
limit: number = 50,
|
||||
offset: number = 0
|
||||
): Promise<FeedItem[]> {
|
||||
const database = await getDb();
|
||||
|
||||
let query = 'SELECT f.*, s.title as subscription_title FROM feeds f '
|
||||
+ 'LEFT JOIN subscriptions s ON f.subscription_id = s.id ';
|
||||
|
||||
const params: any[] = [];
|
||||
|
||||
if (subscriptionId) {
|
||||
query += 'WHERE f.subscription_id = ? ';
|
||||
params.push(subscriptionId);
|
||||
}
|
||||
|
||||
query += 'ORDER BY f.published DESC LIMIT ? OFFSET ?';
|
||||
params.push(limit, offset);
|
||||
|
||||
const rows = await database.getAllAsync<any>(query, params);
|
||||
return rows.map(rowToFeedItem);
|
||||
}
|
||||
|
||||
export async function getAllFeedItems(limit: number = 100, offset: number = 0): Promise<FeedItem[]> {
|
||||
const database = await getDb();
|
||||
|
||||
const rows = await database.getAllAsync<any>(
|
||||
'SELECT f.*, s.title as subscription_title FROM feeds f '
|
||||
+ 'LEFT JOIN subscriptions s ON f.subscription_id = s.id '
|
||||
+ 'ORDER BY f.published DESC LIMIT ? OFFSET ?',
|
||||
[limit, offset]
|
||||
);
|
||||
return rows.map(rowToFeedItem);
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
|
||||
function rowToSubscription(row: any): FeedSubscription {
|
||||
return {
|
||||
id: row.id,
|
||||
url: row.url,
|
||||
title: row.title || 'Untitled',
|
||||
category: row.category,
|
||||
enabled: row.enabled === 1,
|
||||
fetchInterval: row.fetch_interval,
|
||||
httpAuth: row.http_auth_username && row.http_auth_password ? {
|
||||
username: row.http_auth_username,
|
||||
password: row.http_auth_password,
|
||||
} : undefined,
|
||||
createdAt: new Date(row.created_at),
|
||||
updatedAt: new Date(row.updated_at),
|
||||
lastFetchedAt: row.last_fetched_at ? new Date(row.last_fetched_at) : undefined,
|
||||
nextFetchAt: row.next_fetch_at ? new Date(row.next_fetch_at) : undefined,
|
||||
error: row.error,
|
||||
};
|
||||
}
|
||||
|
||||
function rowToFeedItem(row: any): FeedItem {
|
||||
return {
|
||||
id: row.id,
|
||||
title: row.title || '',
|
||||
link: row.link,
|
||||
description: row.description,
|
||||
content: row.content,
|
||||
author: row.author,
|
||||
published: row.published ? new Date(row.published) : undefined,
|
||||
updated: row.updated ? new Date(row.updated) : undefined,
|
||||
guid: row.guid,
|
||||
subscriptionTitle: row.subscription_title,
|
||||
};
|
||||
}
|
||||
|
||||
export async function closeDatabase(): Promise<void> {
|
||||
if (db) {
|
||||
await db.closeAsync();
|
||||
db = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Search History Functions
|
||||
|
||||
export async function addToSearchHistory(query: string): Promise<void> {
|
||||
const database = await getDb();
|
||||
const id = `${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
await database.runAsync(
|
||||
'INSERT OR REPLACE INTO search_history (id, query, timestamp) VALUES (?, ?, CURRENT_TIMESTAMP)',
|
||||
[id, query]
|
||||
);
|
||||
}
|
||||
|
||||
export async function getSearchHistory(limit: number = 10): Promise<{ query: string; timestamp: string }[]> {
|
||||
const database = await getDb();
|
||||
|
||||
const rows = await database.getAllAsync<any>(
|
||||
'SELECT query, timestamp FROM search_history ORDER BY timestamp DESC LIMIT ?',
|
||||
[limit]
|
||||
);
|
||||
|
||||
return rows.map(row => ({ query: row.query, timestamp: row.timestamp }));
|
||||
}
|
||||
|
||||
export async function clearSearchHistory(): Promise<void> {
|
||||
const database = await getDb();
|
||||
await database.runAsync('DELETE FROM search_history');
|
||||
}
|
||||
|
||||
export async function removeSearchHistoryItem(query: string): Promise<void> {
|
||||
const database = await getDb();
|
||||
await database.runAsync('DELETE FROM search_history WHERE query = ?', [query]);
|
||||
}
|
||||
185
src/services/feed-service.ts
Normal file
185
src/services/feed-service.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
import axios from 'axios';
|
||||
import { parseStringPromise } from 'xml2js';
|
||||
import { Feed, FeedItem, ParseResult, FeedSubscription } from '@/types/feed';
|
||||
|
||||
const parseOptions = {
|
||||
explicitArray: false,
|
||||
mergeAttrs: true,
|
||||
trim: true,
|
||||
};
|
||||
|
||||
// Feed detection
|
||||
function detectFeedType(data: string): 'rss' | 'atom' | 'unknown' {
|
||||
if (data.includes('<rss')) return 'rss';
|
||||
if (data.includes('<feed')) return 'atom';
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
// RSS 2.0 Parser with iTunes podcast support
|
||||
async function parseRSS(xml: any): Promise<Feed> {
|
||||
const rss = xml.rss;
|
||||
const channel = rss?.channel;
|
||||
|
||||
if (!channel) {
|
||||
throw new Error('Invalid RSS: No channel found');
|
||||
}
|
||||
|
||||
// Extract iTunes namespace fields
|
||||
const itunes = channel['itunes:navigation'] || {};
|
||||
const itunesImage = itunes.image || channel['itunes:image'];
|
||||
|
||||
const items: FeedItem[] = (channel.item || []).map((item: any) => {
|
||||
const itunesItem = item['itunes:navigation'] || {};
|
||||
const enclosure = item.enclosure?.['$'] || item.enclosure;
|
||||
|
||||
return {
|
||||
id: item.guid?.['$']?.value || item.link || crypto.randomUUID(),
|
||||
title: item.title,
|
||||
link: item.link,
|
||||
description: item.description || itunesItem.summary,
|
||||
content: item['content:encoded'] || item.description,
|
||||
author: item.author || itunesItem.author,
|
||||
published: item.pubDate ? new Date(item.pubDate) : undefined,
|
||||
categories: item.category?.map((c: any) => c) || [],
|
||||
enclosure: enclosure ? {
|
||||
url: enclosure.url,
|
||||
type: enclosure.type,
|
||||
length: enclosure.length ? parseInt(enclosure.length) : undefined,
|
||||
} : undefined,
|
||||
guid: item.guid?.['$']?.value,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
title: channel.title || 'Untitled Feed',
|
||||
link: channel.link,
|
||||
description: channel.description,
|
||||
subtitle: itunes.summary,
|
||||
language: channel.language,
|
||||
lastBuildDate: channel.lastBuildDate ? new Date(channel.lastBuildDate) : undefined,
|
||||
generator: channel.generator,
|
||||
ttl: channel.ttl ? parseInt(channel.ttl) : undefined,
|
||||
items,
|
||||
rawUrl: '',
|
||||
};
|
||||
}
|
||||
|
||||
// Atom 1.0 Parser
|
||||
async function parseAtom(xml: any): Promise<Feed> {
|
||||
const feed = xml.feed;
|
||||
|
||||
if (!feed) {
|
||||
throw new Error('Invalid Atom: No feed found');
|
||||
}
|
||||
|
||||
const entries: FeedItem[] = (feed.entry || []).map((entry: any) => ({
|
||||
id: entry.id?.$?.value || entry.link?.href || crypto.randomUUID(),
|
||||
title: entry.title,
|
||||
link: Array.isArray(entry.link)
|
||||
? entry.link.find((l: any) => !l.rel)?.href
|
||||
: entry.link?.href,
|
||||
summary: entry.summary,
|
||||
content: entry.content,
|
||||
author: Array.isArray(entry.author)
|
||||
? entry.author[0]?.name
|
||||
: entry.author?.name,
|
||||
published: entry.published ? new Date(entry.published) : undefined,
|
||||
updated: entry.updated ? new Date(entry.updated) : undefined,
|
||||
categories: (feed.category || []).map((c: any) => c.term || c),
|
||||
guid: entry.id?.$?.value,
|
||||
}));
|
||||
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
title: feed.title || 'Untitled Feed',
|
||||
link: feed.link?.href,
|
||||
subtitle: feed.subtitle,
|
||||
updated: feed.updated ? new Date(feed.updated) : undefined,
|
||||
generator: feed.generator,
|
||||
items: entries,
|
||||
rawUrl: '',
|
||||
};
|
||||
}
|
||||
|
||||
// Main parse function
|
||||
export async function parseFeed(url: string, data: string): Promise<ParseResult> {
|
||||
try {
|
||||
const feedType = detectFeedType(data);
|
||||
|
||||
if (feedType === 'unknown') {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Could not detect feed type',
|
||||
feedType,
|
||||
};
|
||||
}
|
||||
|
||||
const xml = await parseStringPromise(data, parseOptions);
|
||||
let feed: Feed;
|
||||
|
||||
if (feedType === 'rss') {
|
||||
feed = await parseRSS(xml);
|
||||
} else {
|
||||
feed = await parseAtom(xml);
|
||||
}
|
||||
|
||||
feed.rawUrl = url;
|
||||
feed.lastFetchedAt = new Date();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
feed,
|
||||
feedType,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error occurred',
|
||||
feedType: detectFeedType(data),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch feed from URL
|
||||
export async function fetchFeed(
|
||||
url: string,
|
||||
auth?: { username: string; password: string }
|
||||
): Promise<ParseResult> {
|
||||
try {
|
||||
const config: any = {
|
||||
timeout: 15000,
|
||||
validateStatus: (status: number) => status >= 200 && status < 400,
|
||||
};
|
||||
|
||||
if (auth) {
|
||||
config.auth = {
|
||||
username: auth.username,
|
||||
password: auth.password,
|
||||
};
|
||||
}
|
||||
|
||||
const response = await axios.get(url, config);
|
||||
return await parseFeed(url, response.data);
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to fetch feed',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Batch fetch multiple feeds
|
||||
export async function fetchFeeds(
|
||||
subscriptions: FeedSubscription[]
|
||||
): Promise<Map<string, ParseResult>> {
|
||||
const results = new Map<string, ParseResult>();
|
||||
|
||||
for (const subscription of subscriptions) {
|
||||
const auth = subscription.httpAuth;
|
||||
const result = await fetchFeed(subscription.url, auth);
|
||||
results.set(subscription.id, result);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
276
src/services/notification-service.ts
Normal file
276
src/services/notification-service.ts
Normal file
@@ -0,0 +1,276 @@
|
||||
// Notification Service for Push Notifications
|
||||
|
||||
import { Platform } from 'react-native';
|
||||
import * as Notifications from 'expo-notifications';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
import { NotificationType } from '@/types/global';
|
||||
|
||||
// Define local types compatible with expo-notifications
|
||||
interface NotificationConfig {
|
||||
id: string;
|
||||
type: NotificationType;
|
||||
title: string;
|
||||
body: string;
|
||||
data?: Record<string, unknown>;
|
||||
urgency?: 'normal' | 'high';
|
||||
sound?: string;
|
||||
badge?: number;
|
||||
}
|
||||
|
||||
// Configure notification behavior
|
||||
Notifications.setNotificationHandler({
|
||||
handleNotification: async () => {
|
||||
const settings = useSettingsStore.getState();
|
||||
|
||||
// Check if notifications are enabled
|
||||
if (!settings.accountSettings.notificationsEnabled) {
|
||||
return {
|
||||
shouldShowBanner: false,
|
||||
shouldShowList: false,
|
||||
shouldPlaySound: false,
|
||||
shouldSetBadge: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
shouldShowBanner: true,
|
||||
shouldShowList: true,
|
||||
shouldPlaySound: settings.notificationPreferences.sound,
|
||||
shouldVibrate: settings.notificationPreferences.vibration,
|
||||
shouldSetBadge: settings.notificationPreferences.badgeCount,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// Notification channel configuration (Android)
|
||||
export async function registerNotificationChannels(): Promise<void> {
|
||||
if (Platform.OS === 'android') {
|
||||
await Notifications.setNotificationChannelAsync('default', {
|
||||
name: 'default',
|
||||
importance: Notifications.AndroidImportance.MAX,
|
||||
vibrationPattern: [0, 250, 250, 250],
|
||||
lightColor: '#FF231F7C',
|
||||
});
|
||||
|
||||
await Notifications.setNotificationChannelAsync('new-articles', {
|
||||
name: 'new-articles',
|
||||
importance: Notifications.AndroidImportance.DEFAULT,
|
||||
vibrationPattern: [0, 100],
|
||||
});
|
||||
|
||||
await Notifications.setNotificationChannelAsync('episode-releases', {
|
||||
name: 'episode-releases',
|
||||
importance: Notifications.AndroidImportance.DEFAULT,
|
||||
vibrationPattern: [0, 100],
|
||||
});
|
||||
|
||||
await Notifications.setNotificationChannelAsync('custom-alerts', {
|
||||
name: 'custom-alerts',
|
||||
importance: Notifications.AndroidImportance.MAX,
|
||||
vibrationPattern: [0, 250, 250, 250],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Request notification permissions
|
||||
export async function requestNotificationPermissions(): Promise<boolean> {
|
||||
const { status } = await Notifications.requestPermissionsAsync();
|
||||
return status === 'granted';
|
||||
}
|
||||
|
||||
// Get current permission status
|
||||
export async function getNotificationPermissionStatus(): Promise<'denied' | 'granted' | 'undetermined'> {
|
||||
const { status } = await Notifications.getPermissionsAsync();
|
||||
return status;
|
||||
}
|
||||
|
||||
// Schedule a local notification
|
||||
export async function scheduleNotification(
|
||||
notification: NotificationConfig
|
||||
): Promise<string | null> {
|
||||
const settings = useSettingsStore.getState();
|
||||
|
||||
// Check if notification type is enabled
|
||||
const isEnabled = getNotificationTypeEnabled(notification.type, settings);
|
||||
if (!isEnabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const trigger: Notifications.SchedulableNotificationTriggerInput = {
|
||||
type: Notifications.SchedulableTriggerInputTypes.TIME_INTERVAL,
|
||||
seconds: 1,
|
||||
};
|
||||
|
||||
const content: Notifications.NotificationContentInput = {
|
||||
title: notification.title,
|
||||
body: notification.body,
|
||||
data: notification.data,
|
||||
sound: notification.sound,
|
||||
badge: notification.badge,
|
||||
priority: notification.urgency === 'high'
|
||||
? Notifications.AndroidNotificationPriority.HIGH
|
||||
: Notifications.AndroidNotificationPriority.DEFAULT,
|
||||
};
|
||||
|
||||
// Add channel ID for Android
|
||||
if (Platform.OS === 'android') {
|
||||
const channelId = getChannelIdForType(notification.type);
|
||||
if (channelId) {
|
||||
(trigger as Notifications.TimeIntervalTriggerInput).channelId = channelId;
|
||||
}
|
||||
}
|
||||
|
||||
const scheduled = await Notifications.scheduleNotificationAsync({
|
||||
content,
|
||||
trigger,
|
||||
});
|
||||
|
||||
return scheduled;
|
||||
}
|
||||
|
||||
// Schedule notification at a specific time
|
||||
export async function scheduleNotificationAtTime(
|
||||
notification: NotificationConfig,
|
||||
date: Date
|
||||
): Promise<string | null> {
|
||||
const settings = useSettingsStore.getState();
|
||||
|
||||
const isEnabled = getNotificationTypeEnabled(notification.type, settings);
|
||||
if (!isEnabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const trigger: Notifications.DateTriggerInput = {
|
||||
type: Notifications.SchedulableTriggerInputTypes.DATE,
|
||||
date,
|
||||
};
|
||||
|
||||
const content: Notifications.NotificationContentInput = {
|
||||
title: notification.title,
|
||||
body: notification.body,
|
||||
data: notification.data,
|
||||
sound: notification.sound,
|
||||
badge: notification.badge,
|
||||
};
|
||||
|
||||
// Add channel ID for Android
|
||||
if (Platform.OS === 'android') {
|
||||
const channelId = getChannelIdForType(notification.type);
|
||||
if (channelId) {
|
||||
trigger.channelId = channelId;
|
||||
}
|
||||
}
|
||||
|
||||
const scheduled = await Notifications.scheduleNotificationAsync({
|
||||
content,
|
||||
trigger,
|
||||
});
|
||||
|
||||
return scheduled;
|
||||
}
|
||||
|
||||
// Cancel a scheduled notification
|
||||
export async function cancelNotification(
|
||||
identifier: string
|
||||
): Promise<void> {
|
||||
await Notifications.cancelScheduledNotificationAsync(identifier);
|
||||
}
|
||||
|
||||
// Cancel all scheduled notifications
|
||||
export async function cancelAllNotifications(): Promise<void> {
|
||||
await Notifications.cancelAllScheduledNotificationsAsync();
|
||||
}
|
||||
|
||||
// Get all scheduled notifications
|
||||
export async function getScheduledNotifications(): Promise<
|
||||
Notifications.NotificationRequest[]
|
||||
> {
|
||||
return await Notifications.getAllScheduledNotificationsAsync();
|
||||
}
|
||||
|
||||
// Show an immediate notification
|
||||
export async function showNotification(
|
||||
notification: NotificationConfig
|
||||
): Promise<void> {
|
||||
const settings = useSettingsStore.getState();
|
||||
|
||||
const isEnabled = getNotificationTypeEnabled(notification.type, settings);
|
||||
if (!isEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const content: Notifications.NotificationContentInput = {
|
||||
title: notification.title,
|
||||
body: notification.body,
|
||||
data: notification.data,
|
||||
sound: notification.sound,
|
||||
badge: notification.badge,
|
||||
};
|
||||
|
||||
// For immediate notification, use null trigger
|
||||
await Notifications.scheduleNotificationAsync({
|
||||
content,
|
||||
trigger: null,
|
||||
});
|
||||
}
|
||||
|
||||
// Set badge count
|
||||
export async function setBadgeCount(count: number): Promise<void> {
|
||||
await Notifications.setBadgeCountAsync(count);
|
||||
}
|
||||
|
||||
// Get badge count
|
||||
export async function getBadgeCount(): Promise<number> {
|
||||
return await Notifications.getBadgeCountAsync();
|
||||
}
|
||||
|
||||
// Add notification listener
|
||||
export function addNotificationListener(
|
||||
callback: (notification: Notifications.Notification) => void
|
||||
): () => void {
|
||||
const subscription = Notifications.addNotificationReceivedListener(callback);
|
||||
return () => subscription.remove();
|
||||
}
|
||||
|
||||
// Add response listener
|
||||
export function addNotificationResponseListener(
|
||||
callback: (response: Notifications.NotificationResponse) => void
|
||||
): () => void {
|
||||
const subscription = Notifications.addNotificationResponseReceivedListener(callback);
|
||||
return () => subscription.remove();
|
||||
}
|
||||
|
||||
// Check if notification type is enabled based on settings
|
||||
function getNotificationTypeEnabled(
|
||||
type: NotificationType,
|
||||
settings: any
|
||||
): boolean {
|
||||
switch (type) {
|
||||
case NotificationType.NEW_ARTICLE:
|
||||
return settings.notificationPreferences.newArticles;
|
||||
case NotificationType.EPISODE_RELEASE:
|
||||
return settings.notificationPreferences.episodeReleases;
|
||||
case NotificationType.CUSTOM_ALERT:
|
||||
return settings.notificationPreferences.customAlerts;
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Get channel ID for notification type (Android)
|
||||
function getChannelIdForType(type: NotificationType): string | undefined {
|
||||
switch (type) {
|
||||
case NotificationType.NEW_ARTICLE:
|
||||
return 'new-articles';
|
||||
case NotificationType.EPISODE_RELEASE:
|
||||
return 'episode-releases';
|
||||
case NotificationType.CUSTOM_ALERT:
|
||||
return 'custom-alerts';
|
||||
default:
|
||||
return 'default';
|
||||
}
|
||||
}
|
||||
|
||||
// Export notification types for use in other modules
|
||||
export type { NotificationConfig };
|
||||
export { NotificationType };
|
||||
13
src/services/query-client.ts
Normal file
13
src/services/query-client.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
// React Query Configuration
|
||||
|
||||
import { QueryClient } from '@tanstack/react-query';
|
||||
|
||||
export const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
266
src/services/search-service.ts
Normal file
266
src/services/search-service.ts
Normal file
@@ -0,0 +1,266 @@
|
||||
// Search Service
|
||||
|
||||
import { FeedItem, SearchFilters, SearchSortOption, SearchResult } from '@/types/feed';
|
||||
import { getDb } from './database';
|
||||
|
||||
export interface SearchOptions {
|
||||
filters?: SearchFilters;
|
||||
sort?: SearchSortOption;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export async function searchArticles(
|
||||
query: string,
|
||||
options: SearchOptions = {}
|
||||
): Promise<SearchResult[]> {
|
||||
if (!query || query.trim().length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const database = await getDb();
|
||||
const { filters = {}, sort = 'relevance', page = 1, pageSize = 20 } = options;
|
||||
|
||||
const offset = (page - 1) * pageSize;
|
||||
|
||||
// Build WHERE clause for filters
|
||||
const whereClauses: string[] = [];
|
||||
const params: any[] = [];
|
||||
|
||||
// Date filters
|
||||
if (filters.dateFrom) {
|
||||
whereClauses.push('f.published >= ?');
|
||||
params.push(filters.dateFrom.toISOString());
|
||||
}
|
||||
if (filters.dateTo) {
|
||||
whereClauses.push('f.published <= ?');
|
||||
params.push(filters.dateTo.toISOString());
|
||||
}
|
||||
|
||||
// Feed filters
|
||||
if (filters.feedIds && filters.feedIds.length > 0) {
|
||||
const placeholders = filters.feedIds.map(() => '?').join(',');
|
||||
whereClauses.push(`f.subscription_id IN (${placeholders})`);
|
||||
params.push(...filters.feedIds);
|
||||
}
|
||||
|
||||
// Author filters
|
||||
if (filters.authors && filters.authors.length > 0) {
|
||||
const placeholders = filters.authors.map(() => '?').join(',');
|
||||
whereClauses.push(`f.author IN (${placeholders})`);
|
||||
params.push(...filters.authors);
|
||||
}
|
||||
|
||||
// Content type filter
|
||||
if (filters.contentType === 'audio') {
|
||||
whereClauses.push('(f.content LIKE "%enclosure%" OR f.description LIKE "%enclosure%")');
|
||||
} else if (filters.contentType === 'video') {
|
||||
whereClauses.push('(f.content LIKE "%video%" OR f.description LIKE "%video%")');
|
||||
}
|
||||
|
||||
const whereClause = whereClauses.length > 0
|
||||
? `WHERE ${whereClauses.join(' AND ')}`
|
||||
: '';
|
||||
|
||||
// Build ORDER BY clause
|
||||
let orderByClause = '';
|
||||
switch (sort) {
|
||||
case 'date_desc':
|
||||
orderByClause = 'ORDER BY f.published DESC';
|
||||
break;
|
||||
case 'date_asc':
|
||||
orderByClause = 'ORDER BY f.published ASC';
|
||||
break;
|
||||
case 'title_asc':
|
||||
orderByClause = 'ORDER BY f.title ASC';
|
||||
break;
|
||||
case 'title_desc':
|
||||
orderByClause = 'ORDER BY f.title DESC';
|
||||
break;
|
||||
case 'feed_asc':
|
||||
orderByClause = 'ORDER BY s.title ASC';
|
||||
break;
|
||||
case 'feed_desc':
|
||||
orderByClause = 'ORDER BY s.title DESC';
|
||||
break;
|
||||
case 'relevance':
|
||||
default:
|
||||
// For relevance, we rely on FTS ranking
|
||||
break;
|
||||
}
|
||||
|
||||
// Use FTS for full-text search when available
|
||||
let results: any[] = [];
|
||||
|
||||
try {
|
||||
// Try FTS search first
|
||||
const ftsQuery = `SELECT f.rowid, f.rank FROM feeds_fts
|
||||
WHERE feeds_fts MATCH ? ${whereClause}`;
|
||||
|
||||
const ftsResults = await database.getAllAsync<any>(ftsQuery, [...params, query]);
|
||||
|
||||
if (ftsResults.length > 0) {
|
||||
const rowIds = ftsResults.map((r: any) => r.rowid);
|
||||
const placeholders = rowIds.map(() => '?').join(',');
|
||||
|
||||
const selectQuery = `
|
||||
SELECT f.*, s.title as subscription_title, feeds_fts.rank
|
||||
FROM feeds f
|
||||
INNER JOIN feeds_fts ON f.rowid = feeds_fts.rowid
|
||||
LEFT JOIN subscriptions s ON f.subscription_id = s.id
|
||||
WHERE f.rowid IN (${placeholders})
|
||||
${orderByClause || 'ORDER BY feeds_fts.rank'}
|
||||
LIMIT ? OFFSET ?
|
||||
`;
|
||||
|
||||
results = await database.getAllAsync<any>(
|
||||
selectQuery,
|
||||
[...rowIds, pageSize, offset]
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
// Fallback to LIKE search if FTS fails
|
||||
console.warn('FTS search failed, falling back to LIKE search:', error);
|
||||
|
||||
const likeQuery = `
|
||||
SELECT f.*, s.title as subscription_title
|
||||
FROM feeds f
|
||||
LEFT JOIN subscriptions s ON f.subscription_id = s.id
|
||||
${whereClause}
|
||||
WHERE f.title LIKE ? OR f.description LIKE ? OR f.content LIKE ?
|
||||
${orderByClause}
|
||||
LIMIT ? OFFSET ?
|
||||
`;
|
||||
|
||||
const likeParams = [
|
||||
`%${query}%`,
|
||||
`%${query}%`,
|
||||
`%${query}%`,
|
||||
...params,
|
||||
pageSize,
|
||||
offset
|
||||
];
|
||||
|
||||
results = await database.getAllAsync<any>(likeQuery, likeParams);
|
||||
}
|
||||
|
||||
return results.map(row => ({
|
||||
id: row.id,
|
||||
type: 'article' as const,
|
||||
title: row.title || 'Untitled',
|
||||
snippet: extractSnippet(row.title, row.description, row.content, query),
|
||||
link: row.link,
|
||||
feedTitle: row.subscription_title,
|
||||
published: row.published ? new Date(row.published) : undefined,
|
||||
score: row.rank !== undefined ? row.rank : undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function searchFeeds(
|
||||
query: string,
|
||||
limit: number = 10
|
||||
): Promise<SearchResult[]> {
|
||||
if (!query || query.trim().length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const database = await getDb();
|
||||
|
||||
try {
|
||||
// Try FTS search first
|
||||
const ftsQuery = `SELECT s.rowid, s.title, s.url, subscriptions_fts.rank
|
||||
FROM subscriptions s
|
||||
INNER JOIN subscriptions_fts ON s.rowid = subscriptions_fts.rowid
|
||||
WHERE subscriptions_fts MATCH ?
|
||||
ORDER BY subscriptions_fts.rank
|
||||
LIMIT ?`;
|
||||
|
||||
const ftsResults = await database.getAllAsync<any>(ftsQuery, [query, limit]);
|
||||
|
||||
return ftsResults.map((row: any) => ({
|
||||
id: row.rowid.toString(),
|
||||
type: 'feed' as const,
|
||||
title: row.title || 'Untitled Feed',
|
||||
link: row.url,
|
||||
score: row.rank !== undefined ? row.rank : undefined,
|
||||
}));
|
||||
} catch (error) {
|
||||
// Fallback to LIKE search if FTS fails
|
||||
console.warn('FTS feed search failed, falling back to LIKE search:', error);
|
||||
|
||||
const likeQuery = `
|
||||
SELECT id, title, url
|
||||
FROM subscriptions
|
||||
WHERE title LIKE ? OR url LIKE ?
|
||||
LIMIT ?
|
||||
`;
|
||||
|
||||
const likeResults = await database.getAllAsync<any>(
|
||||
likeQuery,
|
||||
[`%${query}%`, `%${query}%`, limit]
|
||||
);
|
||||
|
||||
return likeResults.map((row: any) => ({
|
||||
id: row.id,
|
||||
type: 'feed' as const,
|
||||
title: row.title || 'Untitled Feed',
|
||||
link: row.url,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
export async function combinedSearch(
|
||||
query: string,
|
||||
options: SearchOptions = {}
|
||||
): Promise<{ articles: SearchResult[]; feeds: SearchResult[] }> {
|
||||
const [articles, feeds] = await Promise.all([
|
||||
searchArticles(query, options),
|
||||
searchFeeds(query, options.pageSize || 10)
|
||||
]);
|
||||
|
||||
return { articles, feeds };
|
||||
}
|
||||
|
||||
// Helper function to extract a snippet highlighting the search terms
|
||||
function extractSnippet(
|
||||
title: string | null,
|
||||
description: string | null,
|
||||
content: string | null,
|
||||
query: string
|
||||
): string | undefined {
|
||||
const texts = [
|
||||
title || '',
|
||||
description || '',
|
||||
content || ''
|
||||
].filter(Boolean);
|
||||
|
||||
if (texts.length === 0) return undefined;
|
||||
|
||||
const queryLower = query.toLowerCase();
|
||||
const words = queryLower.split(/\s+/).filter(w => w.length > 0);
|
||||
|
||||
for (const text of texts) {
|
||||
const textLower = text.toLowerCase();
|
||||
const matchIndex = words.some(word => textLower.includes(word))
|
||||
? textLower.indexOf(words.find(w => textLower.includes(w)) || '')
|
||||
: -1;
|
||||
|
||||
if (matchIndex >= 0) {
|
||||
const start = Math.max(0, matchIndex - 50);
|
||||
const end = Math.min(text.length, matchIndex + query.length + 100);
|
||||
let snippet = text.substring(start, end);
|
||||
|
||||
if (start > 0) snippet = '...' + snippet;
|
||||
if (end < text.length) snippet = snippet + '...';
|
||||
|
||||
return snippet.trim();
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to first 150 chars of description or content
|
||||
const fallback = description || content || title || '';
|
||||
if (fallback.length > 150) {
|
||||
return fallback.substring(0, 150).trim() + '...';
|
||||
}
|
||||
return fallback.trim() || undefined;
|
||||
}
|
||||
133
src/services/sync-service.ts
Normal file
133
src/services/sync-service.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
// Feed Sync Service
|
||||
|
||||
import { fetchFeeds, fetchFeed } from './feed-service';
|
||||
import { useFeedStore } from '@/stores/feed-store';
|
||||
import { FeedSubscription, FeedItem } from '@/types/feed';
|
||||
import {
|
||||
saveFeedItems,
|
||||
getAllFeedItems,
|
||||
getFeedItems as getFeedItemsFromDb,
|
||||
saveSubscription,
|
||||
} from './database';
|
||||
|
||||
// Calculate next fetch time based on interval
|
||||
function calculateNextFetchTime(intervalMinutes: number): Date {
|
||||
const now = new Date();
|
||||
now.setMinutes(now.getMinutes() + intervalMinutes);
|
||||
return now;
|
||||
}
|
||||
|
||||
// Manual sync function for a single feed
|
||||
export async function syncFeed(subscription: FeedSubscription): Promise<{ success: boolean; itemsSynced: number }> {
|
||||
const result = await fetchFeed(
|
||||
subscription.url,
|
||||
subscription.httpAuth
|
||||
);
|
||||
|
||||
if (result.success && result.feed) {
|
||||
// Save feed items to database
|
||||
if (result.feed.items && result.feed.items.length > 0) {
|
||||
await saveFeedItems(subscription.id, result.feed.items);
|
||||
}
|
||||
|
||||
// Update subscription in store and database
|
||||
const updates = {
|
||||
lastFetchedAt: new Date(),
|
||||
nextFetchAt: calculateNextFetchTime(subscription.fetchInterval),
|
||||
error: undefined,
|
||||
title: result.feed.title,
|
||||
};
|
||||
|
||||
useFeedStore.getState().updateSubscription(subscription.id, updates);
|
||||
await saveSubscription({ ...subscription, ...updates });
|
||||
|
||||
return { success: true, itemsSynced: result.feed.items?.length || 0 };
|
||||
} else {
|
||||
useFeedStore.getState().updateSubscription(subscription.id, {
|
||||
error: result.error || 'Unknown error',
|
||||
});
|
||||
return { success: false, itemsSynced: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
// Sync all enabled feeds
|
||||
export async function syncAllFeeds(): Promise<{ success: boolean; totalItemsSynced: number }> {
|
||||
const subscriptions = useFeedStore
|
||||
.getState()
|
||||
.subscriptions.filter((sub) => sub.enabled);
|
||||
|
||||
let totalItemsSynced = 0;
|
||||
let hasErrors = false;
|
||||
|
||||
for (const subscription of subscriptions) {
|
||||
const result = await syncFeed(subscription);
|
||||
if (!result.success) {
|
||||
hasErrors = true;
|
||||
}
|
||||
totalItemsSynced += result.itemsSynced;
|
||||
}
|
||||
|
||||
return { success: !hasErrors, totalItemsSynced };
|
||||
}
|
||||
|
||||
// Get feeds that are due for sync
|
||||
export function getFeedsDueForSync(): FeedSubscription[] {
|
||||
const now = new Date();
|
||||
return useFeedStore.getState().subscriptions.filter(
|
||||
(sub) => sub.enabled && new Date(sub.nextFetchAt || 0) <= now
|
||||
);
|
||||
}
|
||||
|
||||
// Get feed items from local database (offline support)
|
||||
export async function getLocalFeedItems(
|
||||
subscriptionId?: string,
|
||||
limit: number = 50,
|
||||
offset: number = 0
|
||||
): Promise<FeedItem[]> {
|
||||
return getFeedItemsFromDb(subscriptionId, limit, offset);
|
||||
}
|
||||
|
||||
// Get all feed items from local database
|
||||
export async function getAllLocalFeedItems(limit: number = 100): Promise<FeedItem[]> {
|
||||
return getAllFeedItems(limit);
|
||||
}
|
||||
|
||||
// Check if we have any local data
|
||||
export async function hasLocalData(): Promise<boolean> {
|
||||
const items = await getAllFeedItems(1);
|
||||
return items.length > 0;
|
||||
}
|
||||
|
||||
// Conflict resolution strategies
|
||||
export type ConflictResolutionStrategy = 'newer' | 'older' | 'local' | 'remote';
|
||||
|
||||
interface ConflictResolutionOptions {
|
||||
strategy: ConflictResolutionStrategy;
|
||||
localItem: FeedItem;
|
||||
remoteItem: FeedItem;
|
||||
}
|
||||
|
||||
export function resolveConflict(options: ConflictResolutionOptions): FeedItem {
|
||||
const { strategy, localItem, remoteItem } = options;
|
||||
|
||||
switch (strategy) {
|
||||
case 'newer':
|
||||
const localDate = localItem.updated || localItem.published || new Date(0);
|
||||
const remoteDate = remoteItem.updated || remoteItem.published || new Date(0);
|
||||
return remoteDate > localDate ? remoteItem : localItem;
|
||||
|
||||
case 'older':
|
||||
const olderLocalDate = localItem.updated || localItem.published || new Date();
|
||||
const olderRemoteDate = remoteItem.updated || remoteItem.published || new Date();
|
||||
return olderRemoteDate < olderLocalDate ? remoteItem : localItem;
|
||||
|
||||
case 'local':
|
||||
return localItem;
|
||||
|
||||
case 'remote':
|
||||
return remoteItem;
|
||||
|
||||
default:
|
||||
return remoteItem;
|
||||
}
|
||||
}
|
||||
63
src/stores/bookmark-store.ts
Normal file
63
src/stores/bookmark-store.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
// Bookmark Store
|
||||
|
||||
import { create } from 'zustand';
|
||||
import { persist, createJSONStorage } from 'zustand/middleware';
|
||||
|
||||
interface BookmarkState {
|
||||
bookmarkedIds: Set<string>;
|
||||
|
||||
// Actions
|
||||
addBookmark: (articleId: string) => void;
|
||||
removeBookmark: (articleId: string) => void;
|
||||
toggleBookmark: (articleId: string) => void;
|
||||
isBookmarked: (articleId: string) => boolean;
|
||||
getBookmarks: () => string[];
|
||||
}
|
||||
|
||||
export const useBookmarkStore = create<BookmarkState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
bookmarkedIds: new Set<string>(),
|
||||
|
||||
addBookmark: (articleId: string) =>
|
||||
set((state) => ({
|
||||
bookmarkedIds: new Set([...state.bookmarkedIds, articleId]),
|
||||
})),
|
||||
|
||||
removeBookmark: (articleId: string) =>
|
||||
set((state) => {
|
||||
const newSet = new Set(state.bookmarkedIds);
|
||||
newSet.delete(articleId);
|
||||
return { bookmarkedIds: newSet };
|
||||
}),
|
||||
|
||||
toggleBookmark: (articleId: string) =>
|
||||
set((state) => {
|
||||
const newSet = new Set(state.bookmarkedIds);
|
||||
if (newSet.has(articleId)) {
|
||||
newSet.delete(articleId);
|
||||
} else {
|
||||
newSet.add(articleId);
|
||||
}
|
||||
return { bookmarkedIds: newSet };
|
||||
}),
|
||||
|
||||
isBookmarked: (articleId: string) =>
|
||||
get().bookmarkedIds.has(articleId),
|
||||
|
||||
getBookmarks: () =>
|
||||
Array.from(get().bookmarkedIds),
|
||||
}),
|
||||
{
|
||||
name: 'rssuper-bookmarks',
|
||||
storage: createJSONStorage(() => localStorage),
|
||||
partialize: (state) => ({
|
||||
bookmarkedIds: Array.from(state.bookmarkedIds),
|
||||
}),
|
||||
merge: (persisted: any, current) => ({
|
||||
...current,
|
||||
bookmarkedIds: new Set(persisted?.bookmarkedIds || []),
|
||||
}),
|
||||
}
|
||||
)
|
||||
);
|
||||
103
src/stores/feed-store.ts
Normal file
103
src/stores/feed-store.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { create } from 'zustand';
|
||||
import { FeedSubscription, FeedItem } from '@/types/feed';
|
||||
import {
|
||||
getAllSubscriptions,
|
||||
saveSubscription,
|
||||
deleteSubscription as deleteSubscriptionDb,
|
||||
getAllFeedItems,
|
||||
getFeedItems as getFeedItemsFromDb,
|
||||
} from '@/services/database';
|
||||
|
||||
interface FeedStoreState {
|
||||
subscriptions: FeedSubscription[];
|
||||
feedItems: FeedItem[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
|
||||
// Actions
|
||||
addSubscription: (subscription: FeedSubscription) => Promise<void>;
|
||||
updateSubscription: (id: string, updates: Partial<FeedSubscription>) => void;
|
||||
removeSubscription: (id: string) => Promise<void>;
|
||||
loadSubscriptions: () => Promise<void>;
|
||||
loadFeedItems: (subscriptionId?: string, limit?: number, offset?: number) => Promise<void>;
|
||||
setLoading: (loading: boolean) => void;
|
||||
setError: (error: string | null) => void;
|
||||
clearError: () => void;
|
||||
}
|
||||
|
||||
export const useFeedStore = create<FeedStoreState>((set, get) => ({
|
||||
subscriptions: [],
|
||||
feedItems: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
|
||||
addSubscription: async (subscription) => {
|
||||
try {
|
||||
await saveSubscription(subscription);
|
||||
set((state) => ({
|
||||
subscriptions: [...state.subscriptions, subscription],
|
||||
}));
|
||||
} catch (error) {
|
||||
set({ error: error instanceof Error ? error.message : 'Failed to add subscription' });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
updateSubscription: (id, updates) =>
|
||||
set((state) => ({
|
||||
subscriptions: state.subscriptions.map((sub) =>
|
||||
sub.id === id ? { ...sub, ...updates } : sub
|
||||
),
|
||||
})),
|
||||
|
||||
removeSubscription: async (id) => {
|
||||
try {
|
||||
await deleteSubscriptionDb(id);
|
||||
set((state) => ({
|
||||
subscriptions: state.subscriptions.filter((sub) => sub.id !== id),
|
||||
}));
|
||||
} catch (error) {
|
||||
set({ error: error instanceof Error ? error.message : 'Failed to remove subscription' });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
loadSubscriptions: async () => {
|
||||
try {
|
||||
set({ loading: true });
|
||||
const subscriptions = await getAllSubscriptions();
|
||||
set({ subscriptions, loading: false });
|
||||
} catch (error) {
|
||||
set({
|
||||
error: error instanceof Error ? error.message : 'Failed to load subscriptions',
|
||||
loading: false
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
loadFeedItems: async (subscriptionId?: string, limit: number = 50, offset: number = 0) => {
|
||||
try {
|
||||
set({ loading: true });
|
||||
const feedItems = subscriptionId
|
||||
? await getFeedItemsFromDb(subscriptionId, limit, offset)
|
||||
: await getAllFeedItems(limit);
|
||||
set({ feedItems, loading: false });
|
||||
} catch (error) {
|
||||
set({
|
||||
error: error instanceof Error ? error.message : 'Failed to load feed items',
|
||||
loading: false
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
setLoading: (loading) =>
|
||||
set({ loading }),
|
||||
|
||||
setError: (error) =>
|
||||
set({ error }),
|
||||
|
||||
clearError: () =>
|
||||
set({ error: null }),
|
||||
}));
|
||||
126
src/stores/search-store.ts
Normal file
126
src/stores/search-store.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { create } from 'zustand';
|
||||
import { SearchFilters, SearchSortOption, SearchHistoryItem } from '@/types/feed';
|
||||
import { addToSearchHistory, getSearchHistory, clearSearchHistory, removeSearchHistoryItem } from '@/services/database';
|
||||
|
||||
interface SearchStoreState {
|
||||
// State
|
||||
query: string;
|
||||
filters: SearchFilters;
|
||||
sort: SearchSortOption;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
searchHistory: SearchHistoryItem[];
|
||||
|
||||
// Actions
|
||||
setQuery: (query: string) => void;
|
||||
setFilters: (filters: Partial<SearchFilters>) => void;
|
||||
setSort: (sort: SearchSortOption) => void;
|
||||
setPage: (page: number) => void;
|
||||
setLoading: (loading: boolean) => void;
|
||||
setError: (error: string | null) => void;
|
||||
clearSearch: () => void;
|
||||
addToHistory: (query: string) => Promise<void>;
|
||||
loadHistory: () => Promise<void>;
|
||||
clearHistory: () => Promise<void>;
|
||||
removeFromHistory: (query: string) => Promise<void>;
|
||||
resetSearch: () => void;
|
||||
}
|
||||
|
||||
export const useSearchStore = create<SearchStoreState>((set, get) => ({
|
||||
// Initial state
|
||||
query: '',
|
||||
filters: {},
|
||||
sort: 'relevance',
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
loading: false,
|
||||
error: null,
|
||||
searchHistory: [],
|
||||
|
||||
// Actions
|
||||
setQuery: (query) => set({ query, page: 1 }),
|
||||
|
||||
setFilters: (filters) =>
|
||||
set((state) => ({
|
||||
filters: { ...state.filters, ...filters },
|
||||
page: 1
|
||||
})),
|
||||
|
||||
setSort: (sort) => set({ sort, page: 1 }),
|
||||
|
||||
setPage: (page) => set({ page }),
|
||||
|
||||
setLoading: (loading) => set({ loading }),
|
||||
|
||||
setError: (error) => set({ error }),
|
||||
|
||||
clearSearch: () => {
|
||||
get().addToHistory(get().query).catch(console.error);
|
||||
set({
|
||||
query: '',
|
||||
filters: {},
|
||||
sort: 'relevance',
|
||||
page: 1,
|
||||
error: null,
|
||||
});
|
||||
},
|
||||
|
||||
addToHistory: async (query) => {
|
||||
if (!query || query.trim().length === 0) return;
|
||||
|
||||
try {
|
||||
await addToSearchHistory(query);
|
||||
await get().loadHistory();
|
||||
} catch (error) {
|
||||
console.error('Failed to add to search history:', error);
|
||||
}
|
||||
},
|
||||
|
||||
loadHistory: async () => {
|
||||
try {
|
||||
const history = await getSearchHistory(10);
|
||||
set({
|
||||
searchHistory: history.map(h => ({
|
||||
id: `${h.query}_${h.timestamp}`,
|
||||
query: h.query,
|
||||
timestamp: new Date(h.timestamp),
|
||||
}))
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to load search history:', error);
|
||||
}
|
||||
},
|
||||
|
||||
clearHistory: async () => {
|
||||
try {
|
||||
await clearSearchHistory();
|
||||
set({ searchHistory: [] });
|
||||
} catch (error) {
|
||||
console.error('Failed to clear search history:', error);
|
||||
}
|
||||
},
|
||||
|
||||
removeFromHistory: async (query) => {
|
||||
try {
|
||||
await removeSearchHistoryItem(query);
|
||||
set((state) => ({
|
||||
searchHistory: state.searchHistory.filter(h => h.query !== query),
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('Failed to remove from search history:', error);
|
||||
}
|
||||
},
|
||||
|
||||
resetSearch: () => {
|
||||
set({
|
||||
query: '',
|
||||
filters: {},
|
||||
sort: 'relevance',
|
||||
page: 1,
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
},
|
||||
}));
|
||||
102
src/stores/settings-store.ts
Normal file
102
src/stores/settings-store.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import { AccountSettings } from '@/types/global';
|
||||
import { SyncInterval, SYNC_INTERVALS, NotificationPreferences, ReadingPreferences } from '@/types/feed';
|
||||
|
||||
export interface SettingsState {
|
||||
// Sync Settings
|
||||
syncInterval: SyncInterval;
|
||||
setSyncInterval: (interval: SyncInterval) => void;
|
||||
|
||||
// Theme Settings
|
||||
theme: 'system' | 'light' | 'dark';
|
||||
setTheme: (theme: 'system' | 'light' | 'dark') => void;
|
||||
|
||||
// Notification Preferences
|
||||
notificationPreferences: NotificationPreferences;
|
||||
setNotificationPreferences: (prefs: Partial<NotificationPreferences>) => void;
|
||||
|
||||
// Reading Preferences
|
||||
readingPreferences: ReadingPreferences;
|
||||
setReadingPreferences: (prefs: Partial<ReadingPreferences>) => void;
|
||||
|
||||
// Account Settings
|
||||
accountSettings: AccountSettings;
|
||||
setAccountSettings: (settings: Partial<AccountSettings>) => void;
|
||||
|
||||
// Actions
|
||||
resetSettings: () => void;
|
||||
}
|
||||
|
||||
export const DEFAULT_SETTINGS = {
|
||||
syncInterval: SYNC_INTERVALS[2], // Default: Every 30 minutes
|
||||
theme: 'system',
|
||||
notificationPreferences: {
|
||||
newArticles: true,
|
||||
episodeReleases: true,
|
||||
customAlerts: false,
|
||||
badgeCount: true,
|
||||
sound: true,
|
||||
vibration: true,
|
||||
},
|
||||
readingPreferences: {
|
||||
fontSize: 'medium',
|
||||
lineHeight: 'normal',
|
||||
showTableOfContents: false,
|
||||
showReadingTime: true,
|
||||
showAuthor: true,
|
||||
showDate: true,
|
||||
},
|
||||
accountSettings: {
|
||||
email: '',
|
||||
notificationsEnabled: true,
|
||||
privacy: 'private',
|
||||
language: 'en-US',
|
||||
timezone: 'UTC',
|
||||
theme: 'system',
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const useSettingsStore = create<SettingsState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
...DEFAULT_SETTINGS,
|
||||
setSyncInterval: (interval) => set({ syncInterval: interval }),
|
||||
setTheme: (theme) => set({ theme }),
|
||||
setNotificationPreferences: (prefs) =>
|
||||
set({
|
||||
notificationPreferences: {
|
||||
...get().notificationPreferences,
|
||||
...prefs,
|
||||
},
|
||||
}),
|
||||
setReadingPreferences: (prefs) =>
|
||||
set({
|
||||
readingPreferences: {
|
||||
...get().readingPreferences,
|
||||
...prefs,
|
||||
},
|
||||
}),
|
||||
setAccountSettings: (settings) =>
|
||||
set({
|
||||
accountSettings: {
|
||||
...get().accountSettings,
|
||||
...settings,
|
||||
},
|
||||
}),
|
||||
resetSettings: () => set(DEFAULT_SETTINGS),
|
||||
}),
|
||||
{
|
||||
name: 'rssuper-settings',
|
||||
partialize: (state) => ({
|
||||
syncInterval: state.syncInterval,
|
||||
theme: state.theme,
|
||||
notificationPreferences: state.notificationPreferences,
|
||||
readingPreferences: state.readingPreferences,
|
||||
accountSettings: state.accountSettings,
|
||||
}),
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
export default useSettingsStore;
|
||||
147
src/types/feed.ts
Normal file
147
src/types/feed.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
// Feed Types
|
||||
|
||||
export interface FeedItem {
|
||||
id: string;
|
||||
title: string;
|
||||
link?: string;
|
||||
description?: string;
|
||||
content?: string;
|
||||
author?: string;
|
||||
published?: Date;
|
||||
updated?: Date;
|
||||
categories?: string[];
|
||||
enclosure?: {
|
||||
url: string;
|
||||
type: string;
|
||||
length?: number;
|
||||
};
|
||||
guid?: string;
|
||||
subscriptionTitle?: string;
|
||||
}
|
||||
|
||||
export interface Feed {
|
||||
id: string;
|
||||
title: string;
|
||||
link?: string;
|
||||
description?: string;
|
||||
subtitle?: string;
|
||||
language?: string;
|
||||
lastBuildDate?: Date;
|
||||
updated?: Date;
|
||||
generator?: string;
|
||||
ttl?: number;
|
||||
items: FeedItem[];
|
||||
rawUrl: string;
|
||||
lastFetchedAt?: Date;
|
||||
nextFetchAt?: Date;
|
||||
}
|
||||
|
||||
export interface FeedSubscription {
|
||||
id: string;
|
||||
url: string;
|
||||
title: string;
|
||||
category?: string;
|
||||
enabled: boolean;
|
||||
fetchInterval: number; // in minutes
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
lastFetchedAt?: Date;
|
||||
nextFetchAt?: Date;
|
||||
error?: string;
|
||||
httpAuth?: {
|
||||
username: string;
|
||||
password: string;
|
||||
};
|
||||
}
|
||||
|
||||
// Sync Interval Options
|
||||
export type SyncInterval = {
|
||||
label: string;
|
||||
value: number; // in minutes
|
||||
};
|
||||
|
||||
export const SYNC_INTERVALS: SyncInterval[] = [
|
||||
{ label: 'Every 5 minutes', value: 5 },
|
||||
{ label: 'Every 15 minutes', value: 15 },
|
||||
{ label: 'Every 30 minutes', value: 30 },
|
||||
{ label: 'Every hour', value: 60 },
|
||||
{ label: 'Every 4 hours', value: 240 },
|
||||
{ label: 'Every 8 hours', value: 480 },
|
||||
{ label: 'Daily', value: 1440 },
|
||||
{ label: 'Weekly', value: 10080 },
|
||||
{ label: 'Monthly', value: 72000 },
|
||||
];
|
||||
|
||||
// Notification Preferences
|
||||
export interface NotificationPreferences {
|
||||
newArticles: boolean;
|
||||
episodeReleases: boolean;
|
||||
customAlerts: boolean;
|
||||
badgeCount: boolean;
|
||||
sound: boolean;
|
||||
vibration: boolean;
|
||||
}
|
||||
|
||||
// Reading Preferences
|
||||
export interface ReadingPreferences {
|
||||
fontSize: 'small' | 'medium' | 'large' | 'xlarge';
|
||||
lineHeight: 'normal' | 'relaxed' | 'loose';
|
||||
showTableOfContents: boolean;
|
||||
showReadingTime: boolean;
|
||||
showAuthor: boolean;
|
||||
showDate: boolean;
|
||||
}
|
||||
|
||||
// Account Settings
|
||||
export interface AccountSettings {
|
||||
email: string;
|
||||
notificationsEnabled: boolean;
|
||||
privacy: 'public' | 'private' | 'anonymous';
|
||||
language: string;
|
||||
timezone: string;
|
||||
theme: 'system' | 'light' | 'dark';
|
||||
}
|
||||
|
||||
// Parse Result Types
|
||||
|
||||
export interface ParseResult {
|
||||
success: boolean;
|
||||
feed?: Feed;
|
||||
error?: string;
|
||||
feedType?: 'rss' | 'atom' | 'unknown';
|
||||
}
|
||||
|
||||
// Search Types
|
||||
|
||||
export interface SearchQuery {
|
||||
query: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export interface SearchFilters {
|
||||
dateFrom?: Date;
|
||||
dateTo?: Date;
|
||||
feedIds?: string[];
|
||||
authors?: string[];
|
||||
contentType?: 'article' | 'audio' | 'video';
|
||||
}
|
||||
|
||||
export type SearchSortOption = 'relevance' | 'date_desc' | 'date_asc' | 'title_asc' | 'title_desc' | 'feed_asc' | 'feed_desc';
|
||||
|
||||
export interface SearchResult {
|
||||
id: string;
|
||||
type: 'article' | 'feed';
|
||||
title: string;
|
||||
snippet?: string;
|
||||
link?: string;
|
||||
feedTitle?: string;
|
||||
published?: Date;
|
||||
score?: number;
|
||||
}
|
||||
|
||||
export interface SearchHistoryItem {
|
||||
id: string;
|
||||
query: string;
|
||||
timestamp: Date;
|
||||
}
|
||||
61
src/types/global.d.ts
vendored
Normal file
61
src/types/global.d.ts
vendored
Normal file
@@ -0,0 +1,61 @@
|
||||
// Type declarations for untyped modules
|
||||
|
||||
declare module 'xml2js' {
|
||||
export function parseStringPromise(
|
||||
xmlString: string,
|
||||
options?: any
|
||||
): Promise<any>;
|
||||
}
|
||||
|
||||
// Crypto for React Native
|
||||
interface Crypto {
|
||||
randomUUID(): string;
|
||||
}
|
||||
|
||||
declare global {
|
||||
var crypto: Crypto | undefined;
|
||||
}
|
||||
|
||||
// Notification Types
|
||||
export enum NotificationType {
|
||||
NEW_ARTICLE = 'NEW_ARTICLE',
|
||||
EPISODE_RELEASE = 'EPISODE_RELEASE',
|
||||
CUSTOM_ALERT = 'CUSTOM_ALERT',
|
||||
UPGRADE_PROMO = 'UPGRADE_PROMO',
|
||||
}
|
||||
|
||||
export interface NotificationConfig {
|
||||
id: string;
|
||||
type: NotificationType;
|
||||
title: string;
|
||||
body: string;
|
||||
data: Record<string, unknown>;
|
||||
urgency?: 'normal' | 'high';
|
||||
sound?: string;
|
||||
badge?: number;
|
||||
category?: string;
|
||||
threadId?: string;
|
||||
}
|
||||
|
||||
export interface PushNotificationConfig {
|
||||
enabled: boolean;
|
||||
notificationTypes: NotificationType[];
|
||||
foreground: NotificationConfig;
|
||||
critical: NotificationConfig;
|
||||
alert: NotificationConfig;
|
||||
badge: NotificationConfig;
|
||||
sound: NotificationConfig;
|
||||
vibration: NotificationConfig;
|
||||
}
|
||||
|
||||
// Account Settings
|
||||
export interface AccountSettings {
|
||||
email: string;
|
||||
notificationsEnabled: boolean;
|
||||
privacy: 'public' | 'private' | 'anonymous';
|
||||
language: string;
|
||||
timezone: string;
|
||||
theme: 'system' | 'light' | 'dark';
|
||||
}
|
||||
|
||||
export {};
|
||||
65
src/utils/helpers.ts
Normal file
65
src/utils/helpers.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
// Utility functions
|
||||
|
||||
export function generateId(): string {
|
||||
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
// Fallback for environments without crypto
|
||||
return `id_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
}
|
||||
|
||||
export function formatDate(date: Date | string): string {
|
||||
const d = typeof date === 'string' ? new Date(date) : date;
|
||||
return d.toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
export function truncateText(text: string, maxLength: number): string {
|
||||
if (text.length <= maxLength) return text;
|
||||
return text.substring(0, maxLength).trim() + '...';
|
||||
}
|
||||
|
||||
export function stripHtml(html: string): string {
|
||||
const tmp = document.createElement('div');
|
||||
tmp.innerHTML = html;
|
||||
return tmp.textContent || tmp.innerText || '';
|
||||
}
|
||||
|
||||
export function parseFeedUrl(url: string): { host: string; path: string } | null {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
return {
|
||||
host: parsed.hostname,
|
||||
path: parsed.pathname,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function guessFeedUrl(baseUrl: string): string[] {
|
||||
const commonFeedPaths = [
|
||||
'/feed',
|
||||
'/rss',
|
||||
'/rss.xml',
|
||||
'/feed.xml',
|
||||
'/atom.xml',
|
||||
'/atom',
|
||||
'/opml',
|
||||
];
|
||||
|
||||
try {
|
||||
const url = new URL(baseUrl);
|
||||
// Ensure trailing slash for path concatenation
|
||||
const base = url.pathname.endsWith('/') ? url.pathname : `${url.pathname}/`;
|
||||
|
||||
return commonFeedPaths.map((path) => `${url.origin}${base}${path}`);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user