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": {
|
"experiments": {
|
||||||
"typedRoutes": true,
|
"typedRoutes": true,
|
||||||
"reactCompiler": 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/bottom-tabs": "^7.15.5",
|
||||||
"@react-navigation/elements": "^2.9.10",
|
"@react-navigation/elements": "^2.9.10",
|
||||||
"@react-navigation/native": "^7.1.33",
|
"@react-navigation/native": "^7.1.33",
|
||||||
|
"@tanstack/react-query": "^5.95.2",
|
||||||
|
"axios": "^1.14.0",
|
||||||
"expo": "55.0.10-canary-20260328-2049187",
|
"expo": "55.0.10-canary-20260328-2049187",
|
||||||
"expo-constants": "55.0.10-canary-20260328-2049187",
|
"expo-constants": "55.0.10-canary-20260328-2049187",
|
||||||
"expo-device": "55.0.11-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-glass-effect": "55.0.9-canary-20260328-2049187",
|
||||||
"expo-image": "55.0.7-canary-20260328-2049187",
|
"expo-image": "55.0.7-canary-20260328-2049187",
|
||||||
"expo-linking": "55.0.10-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-router": "55.0.9-canary-20260328-2049187",
|
||||||
"expo-splash-screen": "55.0.14-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-status-bar": "55.0.5-canary-20260328-2049187",
|
||||||
"expo-symbols": "55.0.6-canary-20260328-2049187",
|
"expo-symbols": "55.0.6-canary-20260328-2049187",
|
||||||
"expo-system-ui": "55.0.12-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",
|
"expo-web-browser": "55.0.11-canary-20260328-2049187",
|
||||||
"react": "19.2.0",
|
"react": "19.2.0",
|
||||||
"react-dom": "19.2.0",
|
"react-dom": "19.2.0",
|
||||||
"react-native": "0.83.4",
|
"react-native": "0.83.4",
|
||||||
"react-native-gesture-handler": "~2.30.0",
|
"react-native-gesture-handler": "~2.30.0",
|
||||||
"react-native-worklets": "0.7.2",
|
|
||||||
"react-native-reanimated": "4.2.1",
|
"react-native-reanimated": "4.2.1",
|
||||||
"react-native-safe-area-context": "~5.6.2",
|
"react-native-safe-area-context": "~5.6.2",
|
||||||
"react-native-screens": "~4.23.0",
|
"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": {
|
"devDependencies": {
|
||||||
"@types/react": "~19.2.2",
|
"@types/react": "~19.2.2",
|
||||||
|
"@types/xml2js": "^0.4.14",
|
||||||
"typescript": "~5.9.2"
|
"typescript": "~5.9.2"
|
||||||
},
|
},
|
||||||
"private": true
|
"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 React, { useState } from 'react';
|
||||||
import { SymbolView } from 'expo-symbols';
|
import {
|
||||||
import React from 'react';
|
Platform,
|
||||||
import { Platform, Pressable, ScrollView, StyleSheet } from 'react-native';
|
Pressable,
|
||||||
|
ScrollView,
|
||||||
|
StyleSheet,
|
||||||
|
TextInput,
|
||||||
|
View,
|
||||||
|
} from 'react-native';
|
||||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
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 { ThemedText } from '@/components/themed-text';
|
||||||
import { ThemedView } from '@/components/themed-view';
|
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 { BottomTabInset, MaxContentWidth, Spacing } from '@/constants/theme';
|
||||||
import { useTheme } from '@/hooks/use-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 safeAreaInsets = useSafeAreaInsets();
|
||||||
const insets = {
|
const insets = {
|
||||||
...safeAreaInsets,
|
...safeAreaInsets,
|
||||||
bottom: safeAreaInsets.bottom + BottomTabInset + Spacing.three,
|
bottom: safeAreaInsets.bottom + BottomTabInset + Spacing.three,
|
||||||
};
|
};
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
|
||||||
|
|
||||||
const contentPlatformStyle = Platform.select({
|
const contentPlatformStyle = Platform.select({
|
||||||
android: {
|
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 (
|
return (
|
||||||
<ScrollView
|
<ScrollView
|
||||||
style={[styles.scrollView, { backgroundColor: theme.background }]}
|
style={[styles.scrollView, { backgroundColor: theme.background }]}
|
||||||
contentInset={insets}
|
contentInset={insets}
|
||||||
contentContainerStyle={[styles.contentContainer, contentPlatformStyle]}>
|
contentContainerStyle={[styles.contentContainer, contentPlatformStyle]}>
|
||||||
<ThemedView style={styles.container}>
|
<ThemedView style={styles.container}>
|
||||||
<ThemedView style={styles.titleContainer}>
|
<ThemedView style={styles.header}>
|
||||||
<ThemedText type="subtitle">Explore</ThemedText>
|
<ThemedText type="title">{t('tab.explore')}</ThemedText>
|
||||||
<ThemedText style={styles.centerText} themeColor="textSecondary">
|
<ThemedText themeColor="textSecondary">{t('explore.discover')}</ThemedText>
|
||||||
This starter app includes example{'\n'}code to help you get started.
|
</ThemedView>
|
||||||
</ThemedText>
|
|
||||||
|
|
||||||
<ExternalLink href="https://docs.expo.dev" asChild>
|
<ThemedView style={styles.searchContainer}>
|
||||||
<Pressable style={({ pressed }) => pressed && styles.pressed}>
|
|
||||||
<ThemedView type="backgroundElement" style={styles.linkButton}>
|
|
||||||
<ThemedText type="link">Expo documentation</ThemedText>
|
|
||||||
<SymbolView
|
<SymbolView
|
||||||
tintColor={theme.text}
|
tintColor={theme.textSecondary}
|
||||||
name={{ ios: 'arrow.up.right.square', android: 'link', web: 'link' }}
|
name={{ ios: 'magnifyingglass', android: 'search', web: 'search' }}
|
||||||
size={12}
|
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>
|
</ThemedView>
|
||||||
|
|
||||||
|
<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>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</ExternalLink>
|
{CATEGORIES.map((category) => (
|
||||||
</ThemedView>
|
<Pressable
|
||||||
|
key={category.id}
|
||||||
<ThemedView style={styles.sectionsWrapper}>
|
style={[
|
||||||
<Collapsible title="File-based routing">
|
styles.categoryChip,
|
||||||
<ThemedText type="small">
|
selectedCategory === category.id && { backgroundColor: theme.text },
|
||||||
This app has two screens: <ThemedText type="code">src/app/index.tsx</ThemedText> and{' '}
|
]}
|
||||||
<ThemedText type="code">src/app/explore.tsx</ThemedText>
|
onPress={() =>
|
||||||
</ThemedText>
|
setSelectedCategory(
|
||||||
<ThemedText type="small">
|
selectedCategory === category.id ? null : category.id
|
||||||
The layout file in <ThemedText type="code">src/app/_layout.tsx</ThemedText> sets up
|
)
|
||||||
the tab navigator.
|
}>
|
||||||
</ThemedText>
|
<SymbolView
|
||||||
<ExternalLink href="https://docs.expo.dev/router/introduction">
|
tintColor={
|
||||||
<ThemedText type="linkPrimary">Learn more</ThemedText>
|
selectedCategory === category.id ? theme.background : theme.text
|
||||||
</ExternalLink>
|
}
|
||||||
</Collapsible>
|
name={(CATEGORY_ICONS[category.id] as any)}
|
||||||
|
size={14}
|
||||||
<Collapsible title="Android, iOS, and web support">
|
style={styles.categoryIcon}
|
||||||
<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.
|
|
||||||
</ThemedText>
|
|
||||||
<Image
|
|
||||||
source={require('@/assets/images/tutorial-web.png')}
|
|
||||||
style={styles.imageTutorial}
|
|
||||||
/>
|
/>
|
||||||
|
<ThemedText
|
||||||
|
style={[
|
||||||
|
styles.categoryChipText,
|
||||||
|
selectedCategory === category.id && { color: theme.background },
|
||||||
|
]}>
|
||||||
|
{category.name}
|
||||||
|
</ThemedText>
|
||||||
|
</Pressable>
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
</ThemedView>
|
</ThemedView>
|
||||||
</Collapsible>
|
|
||||||
|
|
||||||
<Collapsible title="Images">
|
<ThemedView style={styles.section}>
|
||||||
<ThemedText type="small">
|
<ThemedView style={styles.sectionHeader}>
|
||||||
For static images, you can use the <ThemedText type="code">@2x</ThemedText> and{' '}
|
<ThemedText type="section">{t('explore.trending')}</ThemedText>
|
||||||
<ThemedText type="code">@3x</ThemedText> suffixes to provide files for different
|
</ThemedView>
|
||||||
screen densities.
|
<ThemedView style={styles.feedsList}>
|
||||||
</ThemedText>
|
{filteredTrending.map((feed) => (
|
||||||
<Image source={require('@/assets/images/react-logo.png')} style={styles.imageReact} />
|
<Pressable
|
||||||
<ExternalLink href="https://reactnative.dev/docs/images">
|
key={feed.id}
|
||||||
<ThemedText type="linkPrimary">Learn more</ThemedText>
|
style={[
|
||||||
</ExternalLink>
|
styles.feedCard,
|
||||||
</Collapsible>
|
{ backgroundColor: theme.backgroundElement },
|
||||||
|
]}>
|
||||||
<Collapsible title="Light and dark mode components">
|
<ThemedView style={styles.feedCardContent}>
|
||||||
<ThemedText type="small">
|
<ThemedText type="defaultSemiBold" numberOfLines={1}>
|
||||||
This template has light and dark mode support. The{' '}
|
{feed.title}
|
||||||
<ThemedText type="code">useColorScheme()</ThemedText> hook lets you inspect what the
|
</ThemedText>
|
||||||
user's current color scheme is, and so you can adjust UI colors accordingly.
|
<ThemedText
|
||||||
</ThemedText>
|
themeColor="textSecondary"
|
||||||
<ExternalLink href="https://docs.expo.dev/develop/user-interface/color-themes/">
|
numberOfLines={2}
|
||||||
<ThemedText type="linkPrimary">Learn more</ThemedText>
|
style={styles.feedDescription}>
|
||||||
</ExternalLink>
|
{feed.description}
|
||||||
</Collapsible>
|
</ThemedText>
|
||||||
|
<ThemedView style={styles.feedMeta}>
|
||||||
<Collapsible title="Animations">
|
<ThemedText themeColor="textSecondary" type="small">
|
||||||
<ThemedText type="small">
|
{(feed.subscribers / 1000).toFixed(1)}K subscribers
|
||||||
This template includes an example of an animated component. The{' '}
|
</ThemedText>
|
||||||
<ThemedText type="code">src/components/ui/collapsible.tsx</ThemedText> component uses
|
</ThemedView>
|
||||||
the powerful <ThemedText type="code">react-native-reanimated</ThemedText> library to
|
</ThemedView>
|
||||||
animate opening this hint.
|
<Pressable style={[styles.addButton, { backgroundColor: theme.text }]}>
|
||||||
</ThemedText>
|
<SymbolView
|
||||||
</Collapsible>
|
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>
|
</ThemedView>
|
||||||
{Platform.OS === 'web' && <WebBadge />}
|
|
||||||
</ThemedView>
|
</ThemedView>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
);
|
);
|
||||||
@@ -138,44 +319,97 @@ const styles = StyleSheet.create({
|
|||||||
maxWidth: MaxContentWidth,
|
maxWidth: MaxContentWidth,
|
||||||
flexGrow: 1,
|
flexGrow: 1,
|
||||||
},
|
},
|
||||||
titleContainer: {
|
header: {
|
||||||
gap: Spacing.three,
|
|
||||||
alignItems: 'center',
|
|
||||||
paddingHorizontal: Spacing.four,
|
paddingHorizontal: Spacing.four,
|
||||||
paddingVertical: Spacing.six,
|
paddingVertical: Spacing.four,
|
||||||
|
gap: Spacing.two,
|
||||||
},
|
},
|
||||||
centerText: {
|
searchContainer: {
|
||||||
textAlign: 'center',
|
|
||||||
},
|
|
||||||
pressed: {
|
|
||||||
opacity: 0.7,
|
|
||||||
},
|
|
||||||
linkButton: {
|
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
paddingHorizontal: Spacing.four,
|
paddingHorizontal: Spacing.four,
|
||||||
paddingVertical: Spacing.two,
|
paddingVertical: Spacing.two,
|
||||||
borderRadius: Spacing.five,
|
marginHorizontal: Spacing.four,
|
||||||
justifyContent: 'center',
|
marginBottom: Spacing.two,
|
||||||
gap: Spacing.one,
|
|
||||||
alignItems: 'center',
|
|
||||||
},
|
|
||||||
sectionsWrapper: {
|
|
||||||
gap: Spacing.five,
|
|
||||||
paddingHorizontal: Spacing.four,
|
|
||||||
paddingTop: Spacing.three,
|
|
||||||
},
|
|
||||||
collapsibleContent: {
|
|
||||||
alignItems: 'center',
|
|
||||||
},
|
|
||||||
imageTutorial: {
|
|
||||||
width: '100%',
|
|
||||||
aspectRatio: 296 / 171,
|
|
||||||
borderRadius: Spacing.three,
|
borderRadius: Spacing.three,
|
||||||
marginTop: Spacing.two,
|
|
||||||
},
|
},
|
||||||
imageReact: {
|
searchIcon: {
|
||||||
width: 100,
|
marginRight: Spacing.two,
|
||||||
height: 100,
|
},
|
||||||
alignSelf: 'center',
|
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 React from 'react';
|
||||||
import { Platform, StyleSheet } from 'react-native';
|
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 { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
|
import { useFeedList } from '@/hooks/use-feed-list';
|
||||||
import { AnimatedIcon } from '@/components/animated-icon';
|
import { FeedItemCard } from '@/components/feed-item-card';
|
||||||
import { HintRow } from '@/components/hint-row';
|
|
||||||
import { ThemedText } from '@/components/themed-text';
|
import { ThemedText } from '@/components/themed-text';
|
||||||
import { ThemedView } from '@/components/themed-view';
|
import { ThemedView } from '@/components/themed-view';
|
||||||
import { WebBadge } from '@/components/web-badge';
|
import { Colors, MaxContentWidth, Spacing } from '@/constants/theme';
|
||||||
import { BottomTabInset, MaxContentWidth, Spacing } from '@/constants/theme';
|
import { useColorScheme } from 'react-native';
|
||||||
|
|
||||||
function getDevMenuHint() {
|
const EXTRACT_HTML_TEXT = (html: string): string => html.replace(/<[^>]*>/g, '');
|
||||||
if (Platform.OS === 'web') {
|
const EXCERPT_LENGTH = 200;
|
||||||
return <ThemedText type="small">use browser devtools</ThemedText>;
|
const ANIMATION_DELAY_MULTIPLIER = 50;
|
||||||
}
|
const MAX_ANIMATION_DELAY = 500;
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function HomeScreen() {
|
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 (
|
return (
|
||||||
<ThemedView style={styles.container}>
|
<ThemedView style={styles.container}>
|
||||||
<SafeAreaView style={styles.safeArea}>
|
<SafeAreaView style={styles.safeArea} edges={['top']}>
|
||||||
<ThemedView style={styles.heroSection}>
|
{renderHeader()}
|
||||||
<AnimatedIcon />
|
|
||||||
<ThemedText type="title" style={styles.title}>
|
|
||||||
Welcome to Expo
|
|
||||||
</ThemedText>
|
|
||||||
</ThemedView>
|
|
||||||
|
|
||||||
<ThemedText type="code" style={styles.code}>
|
<Animated.View style={{ opacity: fadeInAnim }}>
|
||||||
get started
|
{feedItems.length === 0 ? (
|
||||||
</ThemedText>
|
renderEmpty()
|
||||||
|
) : (
|
||||||
<ThemedView type="backgroundElement" style={styles.stepContainer}>
|
<FlatList
|
||||||
<HintRow
|
data={feedItems}
|
||||||
title="Try editing"
|
keyExtractor={(item) => item.id}
|
||||||
hint={<ThemedText type="code">src/app/index.tsx</ThemedText>}
|
renderItem={renderFeedItem}
|
||||||
|
onEndReached={loadMore}
|
||||||
|
onEndReachedThreshold={0.5}
|
||||||
|
ListFooterComponent={renderFooter}
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl
|
||||||
|
refreshing={isRefreshing}
|
||||||
|
onRefresh={refreshFeed}
|
||||||
|
colors={[colors.text]}
|
||||||
/>
|
/>
|
||||||
<HintRow title="Dev tools" hint={getDevMenuHint()} />
|
}
|
||||||
<HintRow
|
contentContainerStyle={styles.listContent}
|
||||||
title="Fresh start"
|
showsVerticalScrollIndicator={false}
|
||||||
hint={<ThemedText type="code">npm run reset-project</ThemedText>}
|
|
||||||
/>
|
/>
|
||||||
</ThemedView>
|
)}
|
||||||
|
</Animated.View>
|
||||||
{Platform.OS === 'web' && <WebBadge />}
|
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
</ThemedView>
|
</ThemedView>
|
||||||
);
|
);
|
||||||
@@ -64,35 +132,45 @@ export default function HomeScreen() {
|
|||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
container: {
|
container: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
justifyContent: 'center',
|
backgroundColor: Colors.light.background,
|
||||||
flexDirection: 'row',
|
|
||||||
},
|
},
|
||||||
safeArea: {
|
safeArea: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
|
},
|
||||||
|
header: {
|
||||||
paddingHorizontal: Spacing.four,
|
paddingHorizontal: Spacing.four,
|
||||||
alignItems: 'center',
|
paddingVertical: Spacing.three,
|
||||||
gap: Spacing.three,
|
|
||||||
paddingBottom: BottomTabInset + Spacing.three,
|
|
||||||
maxWidth: MaxContentWidth,
|
maxWidth: MaxContentWidth,
|
||||||
},
|
},
|
||||||
heroSection: {
|
headerTitle: {
|
||||||
alignItems: 'center',
|
fontSize: 32,
|
||||||
justifyContent: 'center',
|
fontWeight: '700',
|
||||||
flex: 1,
|
},
|
||||||
|
listContent: {
|
||||||
paddingHorizontal: Spacing.four,
|
paddingHorizontal: Spacing.four,
|
||||||
gap: Spacing.four,
|
paddingBottom: Spacing.six,
|
||||||
},
|
},
|
||||||
title: {
|
itemContainer: {
|
||||||
textAlign: 'center',
|
maxWidth: MaxContentWidth,
|
||||||
},
|
},
|
||||||
code: {
|
footer: {
|
||||||
textTransform: 'uppercase',
|
|
||||||
},
|
|
||||||
stepContainer: {
|
|
||||||
gap: Spacing.three,
|
|
||||||
alignSelf: 'stretch',
|
|
||||||
paddingHorizontal: Spacing.three,
|
|
||||||
paddingVertical: Spacing.four,
|
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 { useColorScheme } from 'react-native';
|
||||||
|
|
||||||
import { Colors } from '@/constants/theme';
|
import { Colors } from '@/constants/theme';
|
||||||
|
import { t } from '@/i18n';
|
||||||
|
|
||||||
export default function AppTabs() {
|
export default function AppTabs() {
|
||||||
const scheme = useColorScheme();
|
const scheme = useColorScheme();
|
||||||
@@ -14,7 +15,7 @@ export default function AppTabs() {
|
|||||||
indicatorColor={colors.backgroundElement}
|
indicatorColor={colors.backgroundElement}
|
||||||
labelStyle={{ selected: { color: colors.text } }}>
|
labelStyle={{ selected: { color: colors.text } }}>
|
||||||
<NativeTabs.Trigger name="index">
|
<NativeTabs.Trigger name="index">
|
||||||
<NativeTabs.Trigger.Label>Home</NativeTabs.Trigger.Label>
|
<NativeTabs.Trigger.Label>{t('tab.home')}</NativeTabs.Trigger.Label>
|
||||||
<NativeTabs.Trigger.Icon
|
<NativeTabs.Trigger.Icon
|
||||||
src={require('@/assets/images/tabIcons/home.png')}
|
src={require('@/assets/images/tabIcons/home.png')}
|
||||||
renderingMode="template"
|
renderingMode="template"
|
||||||
@@ -22,12 +23,28 @@ export default function AppTabs() {
|
|||||||
</NativeTabs.Trigger>
|
</NativeTabs.Trigger>
|
||||||
|
|
||||||
<NativeTabs.Trigger name="explore">
|
<NativeTabs.Trigger name="explore">
|
||||||
<NativeTabs.Trigger.Label>Explore</NativeTabs.Trigger.Label>
|
<NativeTabs.Trigger.Label>{t('tab.explore')}</NativeTabs.Trigger.Label>
|
||||||
<NativeTabs.Trigger.Icon
|
<NativeTabs.Trigger.Icon
|
||||||
src={require('@/assets/images/tabIcons/explore.png')}
|
src={require('@/assets/images/tabIcons/explore.png')}
|
||||||
renderingMode="template"
|
renderingMode="template"
|
||||||
/>
|
/>
|
||||||
</NativeTabs.Trigger>
|
</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>
|
</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';
|
import { useTheme } from '@/hooks/use-theme';
|
||||||
|
|
||||||
export type ThemedTextProps = TextProps & {
|
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;
|
themeColor?: ThemeColor;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -23,6 +23,8 @@ export function ThemedText({ style, type = 'default', themeColor, ...rest }: The
|
|||||||
type === 'link' && styles.link,
|
type === 'link' && styles.link,
|
||||||
type === 'linkPrimary' && styles.linkPrimary,
|
type === 'linkPrimary' && styles.linkPrimary,
|
||||||
type === 'code' && styles.code,
|
type === 'code' && styles.code,
|
||||||
|
type === 'section' && styles.section,
|
||||||
|
type === 'defaultSemiBold' && styles.defaultSemiBold,
|
||||||
style,
|
style,
|
||||||
]}
|
]}
|
||||||
{...rest}
|
{...rest}
|
||||||
@@ -46,6 +48,11 @@ const styles = StyleSheet.create({
|
|||||||
lineHeight: 24,
|
lineHeight: 24,
|
||||||
fontWeight: 500,
|
fontWeight: 500,
|
||||||
},
|
},
|
||||||
|
defaultSemiBold: {
|
||||||
|
fontSize: 16,
|
||||||
|
lineHeight: 24,
|
||||||
|
fontWeight: 600,
|
||||||
|
},
|
||||||
title: {
|
title: {
|
||||||
fontSize: 48,
|
fontSize: 48,
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
@@ -56,6 +63,11 @@ const styles = StyleSheet.create({
|
|||||||
lineHeight: 44,
|
lineHeight: 44,
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
},
|
},
|
||||||
|
section: {
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: 600,
|
||||||
|
lineHeight: 24,
|
||||||
|
},
|
||||||
link: {
|
link: {
|
||||||
lineHeight: 30,
|
lineHeight: 30,
|
||||||
fontSize: 14,
|
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