feat: SAPIENS Mobile App - Initial commit
React Native mobile application for SAPIENS news platform. Consolidated all previous history into single commit. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
43
sapiense-ai-app/.gitignore
vendored
Normal file
@ -0,0 +1,43 @@
|
||||
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
|
||||
|
||||
# dependencies
|
||||
node_modules/
|
||||
|
||||
# Expo
|
||||
.expo/
|
||||
dist/
|
||||
web-build/
|
||||
expo-env.d.ts
|
||||
|
||||
# Native
|
||||
.kotlin/
|
||||
*.orig.*
|
||||
*.jks
|
||||
*.p8
|
||||
*.p12
|
||||
*.key
|
||||
*.mobileprovision
|
||||
|
||||
# Metro
|
||||
.metro-health-check*
|
||||
|
||||
# debug
|
||||
npm-debug.*
|
||||
yarn-debug.*
|
||||
yarn-error.*
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# local env files
|
||||
.env*.local
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
|
||||
app-example
|
||||
|
||||
# generated native folders
|
||||
/ios
|
||||
/android
|
||||
1
sapiense-ai-app/.vscode/extensions.json
vendored
Normal file
@ -0,0 +1 @@
|
||||
{ "recommendations": ["expo.vscode-expo-tools"] }
|
||||
7
sapiense-ai-app/.vscode/settings.json
vendored
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll": "explicit",
|
||||
"source.organizeImports": "explicit",
|
||||
"source.sortMembers": "explicit"
|
||||
}
|
||||
}
|
||||
50
sapiense-ai-app/README.md
Normal file
@ -0,0 +1,50 @@
|
||||
# Welcome to your Expo app 👋
|
||||
|
||||
This is an [Expo](https://expo.dev) project created with [`create-expo-app`](https://www.npmjs.com/package/create-expo-app).
|
||||
|
||||
## Get started
|
||||
|
||||
1. Install dependencies
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
2. Start the app
|
||||
|
||||
```bash
|
||||
npx expo start
|
||||
```
|
||||
|
||||
In the output, you'll find options to open the app in a
|
||||
|
||||
- [development build](https://docs.expo.dev/develop/development-builds/introduction/)
|
||||
- [Android emulator](https://docs.expo.dev/workflow/android-studio-emulator/)
|
||||
- [iOS simulator](https://docs.expo.dev/workflow/ios-simulator/)
|
||||
- [Expo Go](https://expo.dev/go), a limited sandbox for trying out app development with Expo
|
||||
|
||||
You can start developing by editing the files inside the **app** directory. This project uses [file-based routing](https://docs.expo.dev/router/introduction).
|
||||
|
||||
## Get a fresh project
|
||||
|
||||
When you're ready, run:
|
||||
|
||||
```bash
|
||||
npm run reset-project
|
||||
```
|
||||
|
||||
This command will move the starter code to the **app-example** directory and create a blank **app** directory where you can start developing.
|
||||
|
||||
## Learn more
|
||||
|
||||
To learn more about developing your project with Expo, look at the following resources:
|
||||
|
||||
- [Expo documentation](https://docs.expo.dev/): Learn fundamentals, or go into advanced topics with our [guides](https://docs.expo.dev/guides).
|
||||
- [Learn Expo tutorial](https://docs.expo.dev/tutorial/introduction/): Follow a step-by-step tutorial where you'll create a project that runs on Android, iOS, and the web.
|
||||
|
||||
## Join the community
|
||||
|
||||
Join our community of developers creating universal apps.
|
||||
|
||||
- [Expo on GitHub](https://github.com/expo/expo): View our open source platform and contribute.
|
||||
- [Discord community](https://chat.expo.dev): Chat with Expo users and ask questions.
|
||||
48
sapiense-ai-app/app.json
Normal file
@ -0,0 +1,48 @@
|
||||
{
|
||||
"expo": {
|
||||
"name": "sapiense-ai-app",
|
||||
"slug": "sapiense-ai-app",
|
||||
"version": "1.0.0",
|
||||
"orientation": "portrait",
|
||||
"icon": "./assets/images/icon.png",
|
||||
"scheme": "sapienseaiapp",
|
||||
"userInterfaceStyle": "automatic",
|
||||
"newArchEnabled": true,
|
||||
"ios": {
|
||||
"supportsTablet": true
|
||||
},
|
||||
"android": {
|
||||
"adaptiveIcon": {
|
||||
"backgroundColor": "#E6F4FE",
|
||||
"foregroundImage": "./assets/images/android-icon-foreground.png",
|
||||
"backgroundImage": "./assets/images/android-icon-background.png",
|
||||
"monochromeImage": "./assets/images/android-icon-monochrome.png"
|
||||
},
|
||||
"edgeToEdgeEnabled": true,
|
||||
"predictiveBackGestureEnabled": false
|
||||
},
|
||||
"web": {
|
||||
"output": "static",
|
||||
"favicon": "./assets/images/favicon.png"
|
||||
},
|
||||
"plugins": [
|
||||
"expo-router",
|
||||
[
|
||||
"expo-splash-screen",
|
||||
{
|
||||
"image": "./assets/images/splash-icon.png",
|
||||
"imageWidth": 200,
|
||||
"resizeMode": "contain",
|
||||
"backgroundColor": "#ffffff",
|
||||
"dark": {
|
||||
"backgroundColor": "#000000"
|
||||
}
|
||||
}
|
||||
]
|
||||
],
|
||||
"experiments": {
|
||||
"typedRoutes": true,
|
||||
"reactCompiler": true
|
||||
}
|
||||
}
|
||||
}
|
||||
36
sapiense-ai-app/app/(tabs)/_layout.tsx
Normal file
@ -0,0 +1,36 @@
|
||||
import { Tabs } from 'expo-router';
|
||||
import React from 'react';
|
||||
|
||||
import { HapticTab } from '@/components/haptic-tab';
|
||||
import { IconSymbol } from '@/components/ui/icon-symbol';
|
||||
import { Colors } from '@/constants/theme';
|
||||
import { useColorScheme } from '@/hooks/use-color-scheme';
|
||||
|
||||
export default function TabLayout() {
|
||||
const colorScheme = useColorScheme();
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
screenOptions={{
|
||||
tabBarActiveTintColor: Colors[colorScheme ?? 'light'].tint,
|
||||
headerShown: false,
|
||||
tabBarButton: HapticTab,
|
||||
tabBarStyle: { display: 'none' },
|
||||
}}>
|
||||
<Tabs.Screen
|
||||
name="index"
|
||||
options={{
|
||||
title: 'Home',
|
||||
tabBarIcon: ({ color }) => <IconSymbol size={28} name="house.fill" color={color} />,
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="explore"
|
||||
options={{
|
||||
title: 'Explore',
|
||||
tabBarIcon: ({ color }) => <IconSymbol size={28} name="paperplane.fill" color={color} />,
|
||||
}}
|
||||
/>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
112
sapiense-ai-app/app/(tabs)/explore.tsx
Normal file
@ -0,0 +1,112 @@
|
||||
import { Image } from 'expo-image';
|
||||
import { Platform, StyleSheet } from 'react-native';
|
||||
|
||||
import { Collapsible } from '@/components/ui/collapsible';
|
||||
import { ExternalLink } from '@/components/external-link';
|
||||
import ParallaxScrollView from '@/components/parallax-scroll-view';
|
||||
import { ThemedText } from '@/components/themed-text';
|
||||
import { ThemedView } from '@/components/themed-view';
|
||||
import { IconSymbol } from '@/components/ui/icon-symbol';
|
||||
import { Fonts } from '@/constants/theme';
|
||||
|
||||
export default function TabTwoScreen() {
|
||||
return (
|
||||
<ParallaxScrollView
|
||||
headerBackgroundColor={{ light: '#D0D0D0', dark: '#353636' }}
|
||||
headerImage={
|
||||
<IconSymbol
|
||||
size={310}
|
||||
color="#808080"
|
||||
name="chevron.left.forwardslash.chevron.right"
|
||||
style={styles.headerImage}
|
||||
/>
|
||||
}>
|
||||
<ThemedView style={styles.titleContainer}>
|
||||
<ThemedText
|
||||
type="title"
|
||||
style={{
|
||||
fontFamily: Fonts.rounded,
|
||||
}}>
|
||||
Explore
|
||||
</ThemedText>
|
||||
</ThemedView>
|
||||
<ThemedText>This app includes example code to help you get started.</ThemedText>
|
||||
<Collapsible title="File-based routing">
|
||||
<ThemedText>
|
||||
This app has two screens:{' '}
|
||||
<ThemedText type="defaultSemiBold">app/(tabs)/index.tsx</ThemedText> and{' '}
|
||||
<ThemedText type="defaultSemiBold">app/(tabs)/explore.tsx</ThemedText>
|
||||
</ThemedText>
|
||||
<ThemedText>
|
||||
The layout file in <ThemedText type="defaultSemiBold">app/(tabs)/_layout.tsx</ThemedText>{' '}
|
||||
sets up the tab navigator.
|
||||
</ThemedText>
|
||||
<ExternalLink href="https://docs.expo.dev/router/introduction">
|
||||
<ThemedText type="link">Learn more</ThemedText>
|
||||
</ExternalLink>
|
||||
</Collapsible>
|
||||
<Collapsible title="Android, iOS, and web support">
|
||||
<ThemedText>
|
||||
You can open this project on Android, iOS, and the web. To open the web version, press{' '}
|
||||
<ThemedText type="defaultSemiBold">w</ThemedText> in the terminal running this project.
|
||||
</ThemedText>
|
||||
</Collapsible>
|
||||
<Collapsible title="Images">
|
||||
<ThemedText>
|
||||
For static images, you can use the <ThemedText type="defaultSemiBold">@2x</ThemedText> and{' '}
|
||||
<ThemedText type="defaultSemiBold">@3x</ThemedText> suffixes to provide files for
|
||||
different screen densities
|
||||
</ThemedText>
|
||||
<Image
|
||||
source={require('@/assets/images/react-logo.png')}
|
||||
style={{ width: 100, height: 100, alignSelf: 'center' }}
|
||||
/>
|
||||
<ExternalLink href="https://reactnative.dev/docs/images">
|
||||
<ThemedText type="link">Learn more</ThemedText>
|
||||
</ExternalLink>
|
||||
</Collapsible>
|
||||
<Collapsible title="Light and dark mode components">
|
||||
<ThemedText>
|
||||
This template has light and dark mode support. The{' '}
|
||||
<ThemedText type="defaultSemiBold">useColorScheme()</ThemedText> hook lets you inspect
|
||||
what the user's current color scheme is, and so you can adjust UI colors accordingly.
|
||||
</ThemedText>
|
||||
<ExternalLink href="https://docs.expo.dev/develop/user-interface/color-themes/">
|
||||
<ThemedText type="link">Learn more</ThemedText>
|
||||
</ExternalLink>
|
||||
</Collapsible>
|
||||
<Collapsible title="Animations">
|
||||
<ThemedText>
|
||||
This template includes an example of an animated component. The{' '}
|
||||
<ThemedText type="defaultSemiBold">components/HelloWave.tsx</ThemedText> component uses
|
||||
the powerful{' '}
|
||||
<ThemedText type="defaultSemiBold" style={{ fontFamily: Fonts.mono }}>
|
||||
react-native-reanimated
|
||||
</ThemedText>{' '}
|
||||
library to create a waving hand animation.
|
||||
</ThemedText>
|
||||
{Platform.select({
|
||||
ios: (
|
||||
<ThemedText>
|
||||
The <ThemedText type="defaultSemiBold">components/ParallaxScrollView.tsx</ThemedText>{' '}
|
||||
component provides a parallax effect for the header image.
|
||||
</ThemedText>
|
||||
),
|
||||
})}
|
||||
</Collapsible>
|
||||
</ParallaxScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
headerImage: {
|
||||
color: '#808080',
|
||||
bottom: -90,
|
||||
left: -35,
|
||||
position: 'absolute',
|
||||
},
|
||||
titleContainer: {
|
||||
flexDirection: 'row',
|
||||
gap: 8,
|
||||
},
|
||||
});
|
||||
179
sapiense-ai-app/app/(tabs)/index.tsx
Normal file
@ -0,0 +1,179 @@
|
||||
import { useState } from 'react';
|
||||
import { View, Text, FlatList, ActivityIndicator, StyleSheet, SafeAreaView, TouchableOpacity, Image } from 'react-native';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useOutlets } from '@/hooks/useApi';
|
||||
import OutletCard from '@/components/OutletCard';
|
||||
import BottomTabBar from '@/components/BottomTabBar';
|
||||
import SearchModal from '@/components/SearchModal';
|
||||
import LanguageModal from '@/components/LanguageModal';
|
||||
import { useLanguage } from '@/contexts/LanguageContext';
|
||||
|
||||
export default function HomeScreen() {
|
||||
const [activeFilter, setActiveFilter] = useState<'people' | 'topics' | 'companies'>('people');
|
||||
const [showSearch, setShowSearch] = useState(false);
|
||||
const [showLanguage, setShowLanguage] = useState(false);
|
||||
const { language, setLanguage } = useLanguage();
|
||||
const router = useRouter();
|
||||
|
||||
const { data: outlets, isLoading, error } = useOutlets(activeFilter, language);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<SafeAreaView style={styles.container}>
|
||||
<View style={styles.errorContainer}>
|
||||
<Text style={styles.errorText}>Error: {error.message}</Text>
|
||||
<Text style={styles.text}>Check if the server is running on localhost:8050</Text>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container}>
|
||||
{/* Header */}
|
||||
<View style={styles.header}>
|
||||
<View style={styles.headerLeft}>
|
||||
<Image
|
||||
source={require('@/assets/images/sapiens-logo.png')}
|
||||
style={styles.logo}
|
||||
resizeMode="contain"
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.headerRight}>
|
||||
<TouchableOpacity style={styles.iconButton} onPress={() => setShowSearch(true)}>
|
||||
<Ionicons name="search-outline" size={18} color="#333" />
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={styles.iconButton} onPress={() => setShowLanguage(true)}>
|
||||
<Ionicons name="settings-outline" size={18} color="#333" />
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={styles.iconButton}>
|
||||
<Ionicons name="person-circle-outline" size={20} color="#333" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Content Area */}
|
||||
<View style={styles.contentContainer}>
|
||||
{showSearch ? (
|
||||
<SearchModal
|
||||
visible={showSearch}
|
||||
onClose={() => setShowSearch(false)}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{/* Outlets List */}
|
||||
{isLoading ? (
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" color="#007AFF" />
|
||||
<Text style={styles.text}>Loading outlets...</Text>
|
||||
</View>
|
||||
) : (
|
||||
<FlatList
|
||||
data={outlets || []}
|
||||
keyExtractor={(item: any, index) => `${activeFilter}-${item.id}-${index}`}
|
||||
renderItem={({ item }: any) => (
|
||||
<OutletCard
|
||||
id={item.id}
|
||||
name={item.name}
|
||||
description={item.description || item.focusSubject || item.category}
|
||||
category={item.category}
|
||||
focusSubject={item.focusSubject}
|
||||
avatar={item.avatar}
|
||||
articleCount={item.articleCount || 0}
|
||||
onPress={(id) => router.push(`/outlet/${id}`)}
|
||||
/>
|
||||
)}
|
||||
contentContainerStyle={styles.list}
|
||||
showsVerticalScrollIndicator={false}
|
||||
ListEmptyComponent={
|
||||
<Text style={styles.emptyText}>No outlets found</Text>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Bottom Tab Bar */}
|
||||
<BottomTabBar
|
||||
activeTab={activeFilter}
|
||||
onTabChange={setActiveFilter}
|
||||
/>
|
||||
|
||||
{/* Language Modal */}
|
||||
<LanguageModal
|
||||
visible={showLanguage}
|
||||
onClose={() => setShowLanguage(false)}
|
||||
currentLanguage={language}
|
||||
onSelectLanguage={setLanguage}
|
||||
/>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#f5f5f5',
|
||||
},
|
||||
header: {
|
||||
backgroundColor: '#fff',
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#e0e0e0',
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
},
|
||||
contentContainer: {
|
||||
flex: 1,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
headerLeft: {
|
||||
alignItems: 'flex-start',
|
||||
},
|
||||
logo: {
|
||||
height: 16,
|
||||
},
|
||||
headerRight: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
},
|
||||
iconButton: {
|
||||
padding: 8,
|
||||
},
|
||||
list: {
|
||||
padding: 16,
|
||||
paddingBottom: 16,
|
||||
},
|
||||
loadingContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
errorContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: 20,
|
||||
},
|
||||
text: {
|
||||
fontSize: 14,
|
||||
color: '#666',
|
||||
marginTop: 10,
|
||||
},
|
||||
errorText: {
|
||||
fontSize: 16,
|
||||
color: 'red',
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
emptyText: {
|
||||
fontSize: 14,
|
||||
color: '#666',
|
||||
textAlign: 'center',
|
||||
marginTop: 20,
|
||||
},
|
||||
});
|
||||
34
sapiense-ai-app/app/_layout.tsx
Normal file
@ -0,0 +1,34 @@
|
||||
import { DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native';
|
||||
import { Stack } from 'expo-router';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import { QueryClientProvider } from '@tanstack/react-query';
|
||||
import { queryClient } from '@/lib/queryClient';
|
||||
import 'react-native-reanimated';
|
||||
import '../global.css';
|
||||
|
||||
import { useColorScheme } from '@/hooks/use-color-scheme';
|
||||
import { LanguageProvider } from '@/contexts/LanguageContext';
|
||||
|
||||
export const unstable_settings = {
|
||||
anchor: '(tabs)',
|
||||
};
|
||||
|
||||
export default function RootLayout() {
|
||||
const colorScheme = useColorScheme();
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<LanguageProvider>
|
||||
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
|
||||
<Stack>
|
||||
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="outlet/[id]" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="article/[id]" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="modal" options={{ presentation: 'modal', title: 'Modal' }} />
|
||||
</Stack>
|
||||
<StatusBar style="auto" />
|
||||
</ThemeProvider>
|
||||
</LanguageProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
572
sapiense-ai-app/app/article/[id].tsx
Normal file
@ -0,0 +1,572 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { View, Text, ScrollView, Image, ActivityIndicator, StyleSheet, SafeAreaView, TouchableOpacity, FlatList } from 'react-native';
|
||||
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useArticle, useOutlet } from '@/hooks/useApi';
|
||||
import { useLanguage } from '@/contexts/LanguageContext';
|
||||
import ArticleCard from '@/components/ArticleCard';
|
||||
import LanguageModal from '@/components/LanguageModal';
|
||||
import CommentsDrawer from '@/components/CommentsDrawer';
|
||||
import { API_BASE_URL } from '@/lib/config';
|
||||
|
||||
export default function ArticleScreen() {
|
||||
const { id } = useLocalSearchParams<{ id: string }>();
|
||||
const router = useRouter();
|
||||
const { language, setLanguage } = useLanguage();
|
||||
const [imageLoaded, setImageLoaded] = useState(false);
|
||||
const [showLanguage, setShowLanguage] = useState(false);
|
||||
const [showComments, setShowComments] = useState(false);
|
||||
|
||||
const { data: article, isLoading, error } = useArticle(id || '', language, true);
|
||||
|
||||
// Get outlet data from article
|
||||
const articleData = article as any;
|
||||
const outletId = articleData?.outletId || articleData?.outlet_id;
|
||||
const { data: outlet } = useOutlet(outletId || '', language);
|
||||
|
||||
// Fetch latest articles for the feed
|
||||
// TODO: Re-enable when news-api has a compatible feed endpoint
|
||||
const { data: latestArticles } = useQuery({
|
||||
queryKey: ['/api/feed', { limit: 20, exclude: id }],
|
||||
queryFn: async () => {
|
||||
const response = await fetch(`${API_BASE_URL}/api/v1/${language}/articles/latest?limit=20`);
|
||||
return response.json();
|
||||
},
|
||||
enabled: false, // Disabled until API structure matches frontend expectations
|
||||
});
|
||||
|
||||
// Clean and process body content
|
||||
const cleanedBody = useMemo(() => {
|
||||
if (!article || !(article as any).body) return '';
|
||||
|
||||
const body = (article as any).body;
|
||||
// Remove "originally published" line
|
||||
const pattern = /^\s*(?:This article was\s+)?originally published\s+(?:at|on)\s+\S+\.?\s*(?:\r?\n){1,2}/i;
|
||||
const cleaned = body.replace(pattern, '').trimStart();
|
||||
|
||||
// Truncate at 3000 characters but complete the sentence
|
||||
if (cleaned.length <= 3000) return cleaned;
|
||||
|
||||
const truncatePoint = 3000;
|
||||
const afterTruncate = cleaned.substring(truncatePoint);
|
||||
const sentenceEndMatch = afterTruncate.match(/[.!?](?:\s|$)/);
|
||||
|
||||
if (sentenceEndMatch && sentenceEndMatch.index !== undefined) {
|
||||
const endIndex = truncatePoint + sentenceEndMatch.index + 1;
|
||||
return cleaned.substring(0, endIndex);
|
||||
}
|
||||
|
||||
return cleaned.substring(0, 3000);
|
||||
}, [article]);
|
||||
|
||||
const getInitials = (name: string) => {
|
||||
return name
|
||||
.split(' ')
|
||||
.map(word => word[0])
|
||||
.join('')
|
||||
.toUpperCase()
|
||||
.slice(0, 2);
|
||||
};
|
||||
|
||||
// Parse body content for rendering
|
||||
const parseBodyContent = (text: string) => {
|
||||
const lines = text.split('\n');
|
||||
const elements: Array<{ type: 'subtitle' | 'paragraph', content: string, key: string }> = [];
|
||||
let currentParagraph: string[] = [];
|
||||
let keyIndex = 0;
|
||||
|
||||
const flushParagraph = () => {
|
||||
if (currentParagraph.length > 0) {
|
||||
const paragraphText = currentParagraph.join('\n').trim();
|
||||
if (paragraphText) {
|
||||
elements.push({
|
||||
type: 'paragraph',
|
||||
content: paragraphText,
|
||||
key: `p-${keyIndex++}`
|
||||
});
|
||||
}
|
||||
currentParagraph = [];
|
||||
}
|
||||
};
|
||||
|
||||
lines.forEach((line) => {
|
||||
const trimmedLine = line.trim();
|
||||
|
||||
// Check for subtitle patterns
|
||||
const fullLineSubtitle = trimmedLine.match(/^\*\*(.*?)\*\*$/);
|
||||
const prefixedSubtitle = trimmedLine.match(/^\s*\*\*(.+?)\*\*\s*(.*)$/);
|
||||
|
||||
if (fullLineSubtitle && trimmedLine === `**${fullLineSubtitle[1]}**`) {
|
||||
flushParagraph();
|
||||
elements.push({
|
||||
type: 'subtitle',
|
||||
content: fullLineSubtitle[1],
|
||||
key: `subtitle-${keyIndex++}`
|
||||
});
|
||||
} else if (prefixedSubtitle && prefixedSubtitle[2].trim() !== '') {
|
||||
flushParagraph();
|
||||
elements.push({
|
||||
type: 'subtitle',
|
||||
content: prefixedSubtitle[1],
|
||||
key: `subtitle-${keyIndex++}`
|
||||
});
|
||||
if (prefixedSubtitle[2].trim()) {
|
||||
currentParagraph.push(prefixedSubtitle[2].trim());
|
||||
}
|
||||
} else if (trimmedLine === '') {
|
||||
flushParagraph();
|
||||
} else {
|
||||
currentParagraph.push(line);
|
||||
}
|
||||
});
|
||||
|
||||
flushParagraph();
|
||||
return elements;
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<SafeAreaView style={styles.container}>
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" color="#007AFF" />
|
||||
<Text style={styles.text}>Loading...</Text>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !article) {
|
||||
return (
|
||||
<SafeAreaView style={styles.container}>
|
||||
<View style={styles.errorContainer}>
|
||||
<Text style={styles.errorText}>Article not found</Text>
|
||||
<TouchableOpacity style={styles.backButton} onPress={() => router.back()}>
|
||||
<Text style={styles.backButtonText}>Go Back</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const bodyElements = articleData.subtopics && articleData.subtopics.length > 0
|
||||
? null
|
||||
: parseBodyContent(cleanedBody);
|
||||
|
||||
const outletData = outlet as any;
|
||||
const outletName = outletData?.name || articleData?.outletName || 'Unknown Outlet';
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container}>
|
||||
{/* Header */}
|
||||
<View style={styles.header}>
|
||||
<View style={styles.headerLeft}>
|
||||
{outletData?.avatar ? (
|
||||
<Image
|
||||
source={{ uri: outletData.avatar }}
|
||||
style={styles.outletAvatar}
|
||||
/>
|
||||
) : (
|
||||
<View style={[styles.avatarFallback, styles.outletAvatar]}>
|
||||
<Text style={styles.avatarText}>{getInitials(outletName)}</Text>
|
||||
</View>
|
||||
)}
|
||||
<View style={styles.outletInfo}>
|
||||
<Image
|
||||
source={require('@/assets/images/sapiens-logo.png')}
|
||||
style={styles.miniLogo}
|
||||
resizeMode="contain"
|
||||
/>
|
||||
<Text style={styles.outletName} numberOfLines={1}>{outletName}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.headerRight}>
|
||||
<TouchableOpacity style={styles.iconButton} onPress={() => setShowLanguage(true)}>
|
||||
<Ionicons name="settings-outline" size={18} color="#333" />
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={styles.iconButton}>
|
||||
<Ionicons name="person-circle-outline" size={20} color="#333" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<ScrollView style={styles.scrollView} showsVerticalScrollIndicator={false}>
|
||||
{/* Hero Image */}
|
||||
{articleData.thumbnail && (
|
||||
<View style={styles.heroContainer}>
|
||||
<Image
|
||||
source={{ uri: articleData.thumbnail }}
|
||||
style={[styles.heroImage, !imageLoaded && styles.heroImageHidden]}
|
||||
resizeMode="cover"
|
||||
onLoad={() => setImageLoaded(true)}
|
||||
/>
|
||||
{!imageLoaded && (
|
||||
<View style={styles.imagePlaceholder}>
|
||||
<ActivityIndicator size="large" color="#007AFF" />
|
||||
</View>
|
||||
)}
|
||||
{articleData.category && (
|
||||
<View style={styles.categoryBadge}>
|
||||
<Text style={styles.categoryText}>{articleData.category}</Text>
|
||||
</View>
|
||||
)}
|
||||
{articleData.timeAgo && (
|
||||
<View style={styles.timeContainer}>
|
||||
<Text style={styles.timeText}>{articleData.timeAgo}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Article Content Card */}
|
||||
<View style={styles.contentCard}>
|
||||
{/* Title */}
|
||||
<Text style={styles.title}>{articleData.title}</Text>
|
||||
|
||||
{/* Summary */}
|
||||
{articleData.summary && (
|
||||
<Text style={styles.summary}>{articleData.summary}</Text>
|
||||
)}
|
||||
|
||||
{/* Body - Subtopics or Parsed Content */}
|
||||
<View style={styles.body}>
|
||||
{articleData.subtopics && articleData.subtopics.length > 0 ? (
|
||||
articleData.subtopics.map((topic: any, index: number) => (
|
||||
<View key={index} style={styles.subtopicSection}>
|
||||
<Text style={styles.subtitle}>{topic.title}</Text>
|
||||
{Array.isArray(topic.content) ? (
|
||||
topic.content.map((paragraph: string, pIndex: number) => (
|
||||
<Text key={pIndex} style={styles.paragraph}>
|
||||
{paragraph}
|
||||
</Text>
|
||||
))
|
||||
) : (
|
||||
<Text style={styles.paragraph}>{topic.content}</Text>
|
||||
)}
|
||||
</View>
|
||||
))
|
||||
) : (
|
||||
bodyElements?.map((element) => (
|
||||
element.type === 'subtitle' ? (
|
||||
<Text key={element.key} style={styles.subtitle}>
|
||||
{element.content}
|
||||
</Text>
|
||||
) : (
|
||||
<Text key={element.key} style={styles.paragraph}>
|
||||
{element.content}
|
||||
</Text>
|
||||
)
|
||||
))
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Latest Articles Feed */}
|
||||
{latestArticles?.items && latestArticles.items.length > 0 && (
|
||||
<View style={styles.feedContainer}>
|
||||
<Text style={styles.feedTitle}>Latest Articles</Text>
|
||||
{latestArticles.items
|
||||
.filter((item: any) => item.newsId && item.newsId !== id)
|
||||
.slice(0, 10)
|
||||
.map((item: any) => (
|
||||
<View key={item.newsId} style={styles.feedItemWrapper}>
|
||||
<ArticleCard
|
||||
id={item.newsId}
|
||||
title={item.title}
|
||||
summary={item.summary}
|
||||
thumbnail={item.thumbnail}
|
||||
publishedAt={new Date(item.publishedAt)}
|
||||
timeAgo={item.timeAgo}
|
||||
outletName={item.outletName}
|
||||
category={item.category}
|
||||
onClick={(newsId) => {
|
||||
router.push(`/article/${newsId}`);
|
||||
}}
|
||||
isAd={item.isAd || false}
|
||||
tags={item.tags || []}
|
||||
/>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
|
||||
{/* Footer Action Bar */}
|
||||
<View style={styles.footer}>
|
||||
<TouchableOpacity style={styles.footerButton} onPress={() => router.back()}>
|
||||
<Ionicons name="chevron-back" size={24} color="#333" />
|
||||
</TouchableOpacity>
|
||||
|
||||
<View style={styles.footerActions}>
|
||||
<TouchableOpacity style={styles.actionButton} onPress={() => setShowComments(true)}>
|
||||
<Ionicons name="chatbubble-outline" size={22} color="#333" />
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={styles.actionButton}>
|
||||
<Ionicons name="share-outline" size={22} color="#333" />
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={styles.actionButton}>
|
||||
<Ionicons name="bookmark-outline" size={22} color="#333" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<TouchableOpacity style={styles.footerButton}>
|
||||
<Ionicons name="chevron-forward" size={24} color="#999" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Language Modal */}
|
||||
<LanguageModal
|
||||
visible={showLanguage}
|
||||
onClose={() => setShowLanguage(false)}
|
||||
currentLanguage={language}
|
||||
onSelectLanguage={setLanguage}
|
||||
/>
|
||||
|
||||
{/* Comments Drawer */}
|
||||
<CommentsDrawer
|
||||
articleId={id || ''}
|
||||
isOpen={showComments}
|
||||
onClose={() => setShowComments(false)}
|
||||
/>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#f5f5f5',
|
||||
},
|
||||
header: {
|
||||
backgroundColor: '#fff',
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#e0e0e0',
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 16,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
},
|
||||
headerLeft: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
flex: 1,
|
||||
gap: 12,
|
||||
},
|
||||
outletAvatar: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
},
|
||||
avatarFallback: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
backgroundColor: '#e0e0e0',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
avatarText: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: '#333',
|
||||
},
|
||||
outletInfo: {
|
||||
flexDirection: 'column',
|
||||
alignItems: 'flex-start',
|
||||
gap: 3,
|
||||
flex: 1,
|
||||
},
|
||||
miniLogo: {
|
||||
height: 10,
|
||||
width: 90,
|
||||
resizeMode: 'contain',
|
||||
marginLeft: 0,
|
||||
},
|
||||
outletName: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: '#000',
|
||||
maxWidth: 150,
|
||||
},
|
||||
headerRight: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
},
|
||||
iconButton: {
|
||||
padding: 8,
|
||||
},
|
||||
scrollView: {
|
||||
flex: 1,
|
||||
},
|
||||
heroContainer: {
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
aspectRatio: 16 / 9,
|
||||
backgroundColor: '#e0e0e0',
|
||||
},
|
||||
heroImage: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
heroImageHidden: {
|
||||
opacity: 0,
|
||||
},
|
||||
imagePlaceholder: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
backgroundColor: '#e0e0e0',
|
||||
},
|
||||
categoryBadge: {
|
||||
position: 'absolute',
|
||||
top: 16,
|
||||
left: 16,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.95)',
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 16,
|
||||
},
|
||||
categoryText: {
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
color: '#333',
|
||||
},
|
||||
timeContainer: {
|
||||
position: 'absolute',
|
||||
bottom: 12,
|
||||
left: 12,
|
||||
},
|
||||
timeText: {
|
||||
fontSize: 12,
|
||||
color: '#fff',
|
||||
fontWeight: '600',
|
||||
textShadowColor: 'rgba(0, 0, 0, 0.75)',
|
||||
textShadowOffset: { width: 0, height: 1 },
|
||||
textShadowRadius: 3,
|
||||
},
|
||||
contentCard: {
|
||||
backgroundColor: '#fff',
|
||||
marginTop: 16,
|
||||
marginHorizontal: 16,
|
||||
marginBottom: 24,
|
||||
padding: 20,
|
||||
borderRadius: 12,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 4,
|
||||
elevation: 3,
|
||||
},
|
||||
title: {
|
||||
fontSize: 24,
|
||||
fontWeight: 'bold',
|
||||
color: '#000',
|
||||
marginBottom: 12,
|
||||
lineHeight: 32,
|
||||
},
|
||||
summary: {
|
||||
fontSize: 16,
|
||||
color: '#666',
|
||||
marginBottom: 20,
|
||||
lineHeight: 24,
|
||||
},
|
||||
body: {
|
||||
marginBottom: 16,
|
||||
},
|
||||
subtopicSection: {
|
||||
marginBottom: 20,
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
color: '#000',
|
||||
marginTop: 20,
|
||||
marginBottom: 12,
|
||||
},
|
||||
paragraph: {
|
||||
fontSize: 16,
|
||||
color: '#333',
|
||||
lineHeight: 26,
|
||||
marginBottom: 16,
|
||||
},
|
||||
feedContainer: {
|
||||
marginTop: 32,
|
||||
marginHorizontal: 16,
|
||||
marginBottom: 32,
|
||||
},
|
||||
feedTitle: {
|
||||
fontSize: 20,
|
||||
fontWeight: 'bold',
|
||||
color: '#000',
|
||||
marginBottom: 16,
|
||||
},
|
||||
feedItemWrapper: {
|
||||
marginBottom: 12,
|
||||
},
|
||||
loadingContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
errorContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: 20,
|
||||
},
|
||||
text: {
|
||||
fontSize: 14,
|
||||
color: '#666',
|
||||
marginTop: 10,
|
||||
},
|
||||
errorText: {
|
||||
fontSize: 16,
|
||||
color: 'red',
|
||||
fontWeight: 'bold',
|
||||
marginBottom: 16,
|
||||
},
|
||||
backButton: {
|
||||
backgroundColor: '#007AFF',
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: 10,
|
||||
borderRadius: 8,
|
||||
},
|
||||
backButtonText: {
|
||||
color: '#fff',
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
footer: {
|
||||
backgroundColor: '#fff',
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: '#e0e0e0',
|
||||
paddingVertical: 8,
|
||||
paddingHorizontal: 8,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
},
|
||||
footerButton: {
|
||||
padding: 8,
|
||||
width: 40,
|
||||
alignItems: 'center',
|
||||
},
|
||||
footerActions: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
},
|
||||
actionButton: {
|
||||
padding: 8,
|
||||
width: 36,
|
||||
height: 36,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
});
|
||||
29
sapiense-ai-app/app/modal.tsx
Normal file
@ -0,0 +1,29 @@
|
||||
import { Link } from 'expo-router';
|
||||
import { StyleSheet } from 'react-native';
|
||||
|
||||
import { ThemedText } from '@/components/themed-text';
|
||||
import { ThemedView } from '@/components/themed-view';
|
||||
|
||||
export default function ModalScreen() {
|
||||
return (
|
||||
<ThemedView style={styles.container}>
|
||||
<ThemedText type="title">This is a modal</ThemedText>
|
||||
<Link href="/" dismissTo style={styles.link}>
|
||||
<ThemedText type="link">Go to home screen</ThemedText>
|
||||
</Link>
|
||||
</ThemedView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 20,
|
||||
},
|
||||
link: {
|
||||
marginTop: 15,
|
||||
paddingVertical: 15,
|
||||
},
|
||||
});
|
||||
444
sapiense-ai-app/app/outlet/[id].tsx
Normal file
@ -0,0 +1,444 @@
|
||||
import { useState } from 'react';
|
||||
import { View, Text, FlatList, ActivityIndicator, StyleSheet, SafeAreaView, Image, RefreshControl, TouchableOpacity, Modal } from 'react-native';
|
||||
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useOutlet, useArticlesByOutlet } from '@/hooks/useApi';
|
||||
import ArticleCard from '@/components/ArticleCard';
|
||||
import BottomTabBar from '@/components/BottomTabBar';
|
||||
import SearchModal from '@/components/SearchModal';
|
||||
import LanguageModal from '@/components/LanguageModal';
|
||||
import { useLanguage } from '@/contexts/LanguageContext';
|
||||
|
||||
export default function OutletScreen() {
|
||||
const { id } = useLocalSearchParams<{ id: string }>();
|
||||
const router = useRouter();
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [activeFilter, setActiveFilter] = useState<'people' | 'topics' | 'companies'>('people');
|
||||
const [showProfileModal, setShowProfileModal] = useState(false);
|
||||
const [showSearch, setShowSearch] = useState(false);
|
||||
const [showLanguage, setShowLanguage] = useState(false);
|
||||
const [viewMode, setViewMode] = useState<'card' | 'list'>('card');
|
||||
const { language, setLanguage } = useLanguage();
|
||||
|
||||
const { data: outlet, isLoading: outletLoading, error: outletError, refetch: refetchOutlet } = useOutlet(id || '', language);
|
||||
const { data: articlesResponse, isLoading: articlesLoading, refetch: refetchArticles } = useArticlesByOutlet(id || '', language);
|
||||
|
||||
const onRefresh = async () => {
|
||||
setRefreshing(true);
|
||||
await Promise.all([refetchOutlet(), refetchArticles()]);
|
||||
setRefreshing(false);
|
||||
};
|
||||
|
||||
const getInitials = (name: string) => {
|
||||
return name
|
||||
.split(' ')
|
||||
.map(word => word[0])
|
||||
.join('')
|
||||
.toUpperCase()
|
||||
.slice(0, 2);
|
||||
};
|
||||
|
||||
if (outletLoading) {
|
||||
return (
|
||||
<SafeAreaView style={styles.container}>
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" color="#007AFF" />
|
||||
<Text style={styles.text}>Loading...</Text>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
if (outletError || !outlet) {
|
||||
return (
|
||||
<SafeAreaView style={styles.container}>
|
||||
<View style={styles.errorContainer}>
|
||||
<Text style={styles.errorText}>Outlet not found</Text>
|
||||
<TouchableOpacity
|
||||
style={styles.backButton}
|
||||
onPress={() => router.back()}
|
||||
>
|
||||
<Text style={styles.backButtonText}>Go Back</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const outletData = outlet as any;
|
||||
// Extract articles array from paginated response
|
||||
const articlesData = (articlesResponse as any)?.articles || [];
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container}>
|
||||
{/* Header */}
|
||||
<View style={styles.header}>
|
||||
<View style={styles.headerLeft}>
|
||||
{outletData.avatar ? (
|
||||
<Image
|
||||
source={{ uri: outletData.avatar }}
|
||||
style={styles.outletAvatar}
|
||||
/>
|
||||
) : (
|
||||
<View style={[styles.avatarFallback, styles.outletAvatar]}>
|
||||
<Text style={styles.avatarText}>{getInitials(outletData.name)}</Text>
|
||||
</View>
|
||||
)}
|
||||
<View style={styles.outletInfo}>
|
||||
<Image
|
||||
source={require('@/assets/images/sapiens-logo.png')}
|
||||
style={styles.miniLogo}
|
||||
resizeMode="contain"
|
||||
/>
|
||||
<Text style={styles.outletName} numberOfLines={1}>{outletData.name}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.headerRight}>
|
||||
<TouchableOpacity style={styles.iconButton} onPress={() => setShowSearch(true)}>
|
||||
<Ionicons name="search-outline" size={18} color="#333" />
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={styles.iconButton} onPress={() => setShowLanguage(true)}>
|
||||
<Ionicons name="settings-outline" size={18} color="#333" />
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={styles.iconButton}>
|
||||
<Ionicons name="person-circle-outline" size={20} color="#333" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Content Area */}
|
||||
<View style={styles.contentContainer}>
|
||||
{showSearch ? (
|
||||
<SearchModal
|
||||
visible={showSearch}
|
||||
onClose={() => setShowSearch(false)}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{/* Articles List */}
|
||||
<FlatList
|
||||
data={articlesData.filter((item: any) => item.newsId)}
|
||||
keyExtractor={(item: any) => item.newsId}
|
||||
renderItem={({ item, index }: any) => (
|
||||
<View style={styles.articleWrapper}>
|
||||
<ArticleCard
|
||||
key={item.newsId}
|
||||
id={item.newsId}
|
||||
title={item.title}
|
||||
summary={item.summary}
|
||||
thumbnail={item.thumbnail}
|
||||
outletName={outletData?.name || 'Unknown Outlet'}
|
||||
category={outletData?.category || 'uncategorized'}
|
||||
publishedAt={new Date(item.publishedAt)}
|
||||
timeAgo={item.timeAgo}
|
||||
onClick={(newsId) => {
|
||||
router.push(`/article/${newsId}`);
|
||||
}}
|
||||
isAd={item.isAd || false}
|
||||
tags={item.tags || []}
|
||||
index={index}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
refreshControl={
|
||||
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
|
||||
}
|
||||
contentContainerStyle={styles.listContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
ListEmptyComponent={
|
||||
articlesLoading ? (
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" color="#007AFF" />
|
||||
<Text style={styles.text}>Loading articles...</Text>
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.emptyContainer}>
|
||||
<Text style={styles.emptyText}>No articles available yet.</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Footer Navigation */}
|
||||
<View style={styles.footer}>
|
||||
<TouchableOpacity style={styles.navButton} onPress={() => router.back()}>
|
||||
<Ionicons name="arrow-back" size={24} color="#333" />
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity onPress={() => router.push('/')}>
|
||||
<Image
|
||||
source={require('@/assets/images/sapiens-logo.png')}
|
||||
style={styles.footerLogo}
|
||||
resizeMode="contain"
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={styles.navButton}>
|
||||
<Ionicons name="arrow-forward" size={24} color="#333" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Profile Modal */}
|
||||
<Modal
|
||||
visible={showProfileModal}
|
||||
transparent
|
||||
animationType="fade"
|
||||
onRequestClose={() => setShowProfileModal(false)}
|
||||
>
|
||||
<TouchableOpacity
|
||||
style={styles.modalOverlay}
|
||||
activeOpacity={1}
|
||||
onPress={() => setShowProfileModal(false)}
|
||||
>
|
||||
<View style={styles.modalContent}>
|
||||
<View style={styles.modalHeader}>
|
||||
<View style={styles.modalProfileContainer}>
|
||||
{outletData.avatar ? (
|
||||
<Image
|
||||
source={{ uri: outletData.avatar }}
|
||||
style={styles.modalAvatar}
|
||||
/>
|
||||
) : (
|
||||
<View style={[styles.avatarFallback, styles.modalAvatar]}>
|
||||
<Text style={styles.avatarText}>{getInitials(outletData.name)}</Text>
|
||||
</View>
|
||||
)}
|
||||
<Text style={styles.modalName}>{outletData.name}</Text>
|
||||
</View>
|
||||
<TouchableOpacity onPress={() => setShowProfileModal(false)}>
|
||||
<Text style={styles.closeButton}>✕</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<Text style={styles.modalDescription}>
|
||||
{outletData.description || outletData.focusSubject}
|
||||
</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</Modal>
|
||||
|
||||
{/* Language Modal */}
|
||||
<LanguageModal
|
||||
visible={showLanguage}
|
||||
onClose={() => setShowLanguage(false)}
|
||||
currentLanguage={language}
|
||||
onSelectLanguage={setLanguage}
|
||||
/>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#f5f5f5',
|
||||
},
|
||||
header: {
|
||||
backgroundColor: '#fff',
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#e0e0e0',
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 16,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
},
|
||||
contentContainer: {
|
||||
flex: 1,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
headerLeft: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
flex: 1,
|
||||
gap: 12,
|
||||
},
|
||||
outletAvatar: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
},
|
||||
outletInfo: {
|
||||
flexDirection: 'column',
|
||||
alignItems: 'flex-start',
|
||||
gap: 3,
|
||||
flex: 1,
|
||||
},
|
||||
miniLogo: {
|
||||
height: 10,
|
||||
width: 90,
|
||||
resizeMode: 'contain',
|
||||
marginLeft: 0,
|
||||
},
|
||||
outletName: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: '#000',
|
||||
maxWidth: 150,
|
||||
},
|
||||
categoryBadge: {
|
||||
backgroundColor: '#f0f0f0',
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 4,
|
||||
borderRadius: 12,
|
||||
},
|
||||
categoryText: {
|
||||
fontSize: 11,
|
||||
color: '#666',
|
||||
fontWeight: '500',
|
||||
},
|
||||
headerRight: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
},
|
||||
profileContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
flex: 1,
|
||||
},
|
||||
avatar: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
},
|
||||
avatarFallback: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
backgroundColor: '#e0e0e0',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
avatarText: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: '#333',
|
||||
},
|
||||
profileInfo: {
|
||||
flex: 1,
|
||||
},
|
||||
sapiensLogo: {
|
||||
height: 10,
|
||||
marginBottom: 2,
|
||||
},
|
||||
nameRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
},
|
||||
name: {
|
||||
fontSize: 16,
|
||||
fontWeight: 'bold',
|
||||
color: '#000',
|
||||
},
|
||||
infoIcon: {
|
||||
fontSize: 16,
|
||||
color: '#007AFF',
|
||||
},
|
||||
iconButton: {
|
||||
padding: 8,
|
||||
},
|
||||
articleWrapper: {
|
||||
paddingHorizontal: 16,
|
||||
paddingTop: 16,
|
||||
},
|
||||
listContent: {
|
||||
paddingBottom: 16,
|
||||
},
|
||||
loadingContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: 20,
|
||||
},
|
||||
errorContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: 20,
|
||||
},
|
||||
emptyContainer: {
|
||||
padding: 32,
|
||||
alignItems: 'center',
|
||||
},
|
||||
text: {
|
||||
fontSize: 14,
|
||||
color: '#666',
|
||||
marginTop: 10,
|
||||
},
|
||||
errorText: {
|
||||
fontSize: 16,
|
||||
color: 'red',
|
||||
fontWeight: 'bold',
|
||||
marginBottom: 16,
|
||||
},
|
||||
emptyText: {
|
||||
fontSize: 14,
|
||||
color: '#666',
|
||||
textAlign: 'center',
|
||||
},
|
||||
modalOverlay: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
modalContent: {
|
||||
backgroundColor: '#fff',
|
||||
borderRadius: 12,
|
||||
padding: 20,
|
||||
width: '85%',
|
||||
maxWidth: 400,
|
||||
},
|
||||
modalHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-start',
|
||||
marginBottom: 16,
|
||||
},
|
||||
modalProfileContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
flex: 1,
|
||||
},
|
||||
modalAvatar: {
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 24,
|
||||
},
|
||||
modalName: {
|
||||
fontSize: 18,
|
||||
fontWeight: 'bold',
|
||||
color: '#000',
|
||||
flex: 1,
|
||||
},
|
||||
closeButton: {
|
||||
fontSize: 24,
|
||||
color: '#666',
|
||||
fontWeight: '300',
|
||||
},
|
||||
modalDescription: {
|
||||
fontSize: 14,
|
||||
color: '#666',
|
||||
lineHeight: 20,
|
||||
},
|
||||
footer: {
|
||||
backgroundColor: '#fff',
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: '#e0e0e0',
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 16,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
},
|
||||
navButton: {
|
||||
padding: 8,
|
||||
},
|
||||
footerLogo: {
|
||||
height: 16,
|
||||
},
|
||||
});
|
||||
BIN
sapiense-ai-app/assets/images/android-icon-background.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
sapiense-ai-app/assets/images/android-icon-foreground.png
Normal file
|
After Width: | Height: | Size: 77 KiB |
BIN
sapiense-ai-app/assets/images/android-icon-monochrome.png
Normal file
|
After Width: | Height: | Size: 4.0 KiB |
BIN
sapiense-ai-app/assets/images/favicon.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
sapiense-ai-app/assets/images/icon.png
Normal file
|
After Width: | Height: | Size: 384 KiB |
BIN
sapiense-ai-app/assets/images/partial-react-logo.png
Normal file
|
After Width: | Height: | Size: 5.0 KiB |
BIN
sapiense-ai-app/assets/images/react-logo.png
Normal file
|
After Width: | Height: | Size: 6.2 KiB |
BIN
sapiense-ai-app/assets/images/react-logo@2x.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
sapiense-ai-app/assets/images/react-logo@3x.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
sapiense-ai-app/assets/images/sapiens-logo.png
Normal file
|
After Width: | Height: | Size: 4.0 KiB |
BIN
sapiense-ai-app/assets/images/splash-icon.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
138
sapiense-ai-app/components/ArticleCard.tsx
Normal file
@ -0,0 +1,138 @@
|
||||
import { View, Text, TouchableOpacity, Image, StyleSheet } from 'react-native';
|
||||
|
||||
interface ArticleCardProps {
|
||||
id: string;
|
||||
title: string;
|
||||
summary: string;
|
||||
thumbnail: string;
|
||||
publishedAt: Date;
|
||||
timeAgo?: string;
|
||||
outletName: string;
|
||||
category: string;
|
||||
onClick: (id: string) => void;
|
||||
className?: string;
|
||||
isAd?: boolean;
|
||||
tags?: string[];
|
||||
index?: number;
|
||||
}
|
||||
|
||||
export default function ArticleCard({
|
||||
id,
|
||||
title,
|
||||
summary,
|
||||
thumbnail,
|
||||
timeAgo,
|
||||
onClick,
|
||||
isAd = false,
|
||||
index = 0,
|
||||
}: ArticleCardProps) {
|
||||
// Generate stable random data based on index and id
|
||||
const hash = id.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0);
|
||||
const baseMinutes = index * 5;
|
||||
const randomOffset = (hash % 5) + 1;
|
||||
const minutesAgo = baseMinutes + randomOffset;
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={styles.card}
|
||||
onPress={() => onClick(id)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
{/* Thumbnail */}
|
||||
<View style={styles.thumbnailContainer}>
|
||||
<Image
|
||||
source={{ uri: thumbnail }}
|
||||
style={styles.thumbnail}
|
||||
defaultSource={require('../assets/images/partial-react-logo.png')}
|
||||
/>
|
||||
{isAd && (
|
||||
<View style={styles.adBadge}>
|
||||
<Text style={styles.adText}>Ad</Text>
|
||||
</View>
|
||||
)}
|
||||
{!isAd && (
|
||||
<View style={styles.timeBadge}>
|
||||
<Text style={styles.timeText}>
|
||||
{timeAgo || `${minutesAgo} min ago`}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Content */}
|
||||
<View style={styles.content}>
|
||||
<Text style={styles.title} numberOfLines={2}>
|
||||
{title}
|
||||
</Text>
|
||||
<Text style={styles.summary} numberOfLines={2}>
|
||||
{summary}
|
||||
</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: {
|
||||
backgroundColor: '#fff',
|
||||
borderRadius: 12,
|
||||
marginBottom: 12,
|
||||
overflow: 'hidden',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 4,
|
||||
elevation: 3,
|
||||
},
|
||||
thumbnailContainer: {
|
||||
width: '100%',
|
||||
aspectRatio: 21 / 9,
|
||||
position: 'relative',
|
||||
},
|
||||
thumbnail: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
adBadge: {
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
left: 8,
|
||||
backgroundColor: '#f59e0b',
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 4,
|
||||
borderRadius: 4,
|
||||
},
|
||||
adText: {
|
||||
color: '#fff',
|
||||
fontSize: 10,
|
||||
fontWeight: '600',
|
||||
},
|
||||
timeBadge: {
|
||||
position: 'absolute',
|
||||
bottom: 8,
|
||||
left: 8,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.6)',
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 4,
|
||||
borderRadius: 4,
|
||||
},
|
||||
timeText: {
|
||||
color: '#fff',
|
||||
fontSize: 10,
|
||||
},
|
||||
content: {
|
||||
padding: 12,
|
||||
},
|
||||
title: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: '#000',
|
||||
marginBottom: 6,
|
||||
lineHeight: 22,
|
||||
},
|
||||
summary: {
|
||||
fontSize: 14,
|
||||
color: '#666',
|
||||
lineHeight: 20,
|
||||
},
|
||||
});
|
||||
107
sapiense-ai-app/components/BottomTabBar.tsx
Normal file
@ -0,0 +1,107 @@
|
||||
import { View, Text, TouchableOpacity, StyleSheet } from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
|
||||
interface BottomTabBarProps {
|
||||
activeTab: 'people' | 'topics' | 'companies';
|
||||
onTabChange: (tab: 'people' | 'topics' | 'companies') => void;
|
||||
showBackButton?: boolean;
|
||||
onBack?: () => void;
|
||||
}
|
||||
|
||||
export default function BottomTabBar({ activeTab, onTabChange, showBackButton, onBack }: BottomTabBarProps) {
|
||||
const tabs = [
|
||||
{ id: 'people' as const, label: 'People', icon: 'person-outline' as const },
|
||||
{ id: 'topics' as const, label: 'Topics', icon: 'newspaper-outline' as const },
|
||||
{ id: 'companies' as const, label: 'Companies', icon: 'business-outline' as const },
|
||||
];
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{showBackButton && onBack ? (
|
||||
<TouchableOpacity
|
||||
style={styles.backTab}
|
||||
onPress={onBack}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Ionicons
|
||||
name="arrow-back"
|
||||
size={24}
|
||||
color="#000"
|
||||
style={styles.icon}
|
||||
/>
|
||||
<Text style={styles.backLabel}>Back</Text>
|
||||
</TouchableOpacity>
|
||||
) : null}
|
||||
{tabs.map((tab) => (
|
||||
<TouchableOpacity
|
||||
key={tab.id}
|
||||
style={[
|
||||
styles.tab,
|
||||
activeTab === tab.id && styles.activeTab,
|
||||
]}
|
||||
onPress={() => onTabChange(tab.id)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Ionicons
|
||||
name={tab.icon}
|
||||
size={24}
|
||||
color={activeTab === tab.id ? '#000' : '#666'}
|
||||
style={styles.icon}
|
||||
/>
|
||||
<Text
|
||||
style={[
|
||||
styles.label,
|
||||
activeTab === tab.id && styles.activeLabel,
|
||||
]}
|
||||
>
|
||||
{tab.label}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
backgroundColor: '#fff',
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: '#e0e0e0',
|
||||
paddingBottom: 8,
|
||||
paddingTop: 8,
|
||||
},
|
||||
tab: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
paddingVertical: 8,
|
||||
},
|
||||
activeTab: {
|
||||
backgroundColor: '#f0f0f0',
|
||||
borderRadius: 8,
|
||||
},
|
||||
icon: {
|
||||
marginBottom: 4,
|
||||
},
|
||||
label: {
|
||||
fontSize: 12,
|
||||
color: '#666',
|
||||
},
|
||||
activeLabel: {
|
||||
color: '#000',
|
||||
fontWeight: '600',
|
||||
},
|
||||
backTab: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
paddingVertical: 8,
|
||||
backgroundColor: '#f0f0f0',
|
||||
borderRadius: 8,
|
||||
marginHorizontal: 4,
|
||||
},
|
||||
backLabel: {
|
||||
fontSize: 12,
|
||||
color: '#000',
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
452
sapiense-ai-app/components/CommentsDrawer.tsx
Normal file
@ -0,0 +1,452 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
Modal,
|
||||
TouchableOpacity,
|
||||
TextInput,
|
||||
FlatList,
|
||||
StyleSheet,
|
||||
Animated,
|
||||
Dimensions,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
ActivityIndicator,
|
||||
} from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { useQuery, useMutation } from '@tanstack/react-query';
|
||||
import { queryClient } from '@/lib/queryClient';
|
||||
import { API_BASE_URL } from '@/lib/config';
|
||||
|
||||
interface Comment {
|
||||
id: string;
|
||||
articleId: string;
|
||||
nickname: string;
|
||||
content: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface CommentsDrawerProps {
|
||||
articleId: string;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const SCREEN_HEIGHT = Dimensions.get('window').height;
|
||||
const DRAWER_HEIGHT = SCREEN_HEIGHT * 0.6;
|
||||
|
||||
export default function CommentsDrawer({
|
||||
articleId,
|
||||
isOpen,
|
||||
onClose,
|
||||
}: CommentsDrawerProps) {
|
||||
const [nickname, setNickname] = useState('');
|
||||
const [commentContent, setCommentContent] = useState('');
|
||||
const slideAnim = useRef(new Animated.Value(DRAWER_HEIGHT)).current;
|
||||
const opacityAnim = useRef(new Animated.Value(0)).current;
|
||||
|
||||
// Load saved nickname from AsyncStorage
|
||||
useEffect(() => {
|
||||
loadNickname();
|
||||
}, []);
|
||||
|
||||
const loadNickname = async () => {
|
||||
try {
|
||||
const saved = await AsyncStorage.getItem('commentNickname');
|
||||
if (saved) {
|
||||
setNickname(saved);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load nickname:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Animate drawer on open/close
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
Animated.parallel([
|
||||
Animated.timing(slideAnim, {
|
||||
toValue: 0,
|
||||
duration: 300,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(opacityAnim, {
|
||||
toValue: 1,
|
||||
duration: 200,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]).start();
|
||||
} else {
|
||||
Animated.parallel([
|
||||
Animated.timing(slideAnim, {
|
||||
toValue: DRAWER_HEIGHT,
|
||||
duration: 300,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(opacityAnim, {
|
||||
toValue: 0,
|
||||
duration: 200,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]).start();
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// Fetch comments
|
||||
const { data: commentsData, isLoading } = useQuery<{
|
||||
comments: Comment[];
|
||||
total: number;
|
||||
}>({
|
||||
queryKey: ['/api/v1/comments', articleId],
|
||||
queryFn: async () => {
|
||||
const response = await fetch(`${API_BASE_URL}/comments?articleId=${articleId}`);
|
||||
return response.json();
|
||||
},
|
||||
enabled: isOpen && !!articleId,
|
||||
});
|
||||
|
||||
// Create comment mutation
|
||||
const createComment = useMutation({
|
||||
mutationFn: async (data: { content: string; nickname: string }) => {
|
||||
const response = await fetch(`${API_BASE_URL}/comments`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
articleId,
|
||||
content: data.content,
|
||||
nickname: data.nickname,
|
||||
}),
|
||||
});
|
||||
return response.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/v1/comments', articleId] });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['/api/v1/articles', articleId, 'comment-count'],
|
||||
});
|
||||
setCommentContent('');
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!commentContent.trim() || !nickname.trim()) return;
|
||||
|
||||
try {
|
||||
await AsyncStorage.setItem('commentNickname', nickname);
|
||||
createComment.mutate({
|
||||
content: commentContent,
|
||||
nickname: nickname,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to save nickname:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const formatTimeAgo = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const seconds = Math.floor((now.getTime() - date.getTime()) / 1000);
|
||||
|
||||
if (seconds < 60) return 'just now';
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
const days = Math.floor(hours / 24);
|
||||
if (days < 7) return `${days}d ago`;
|
||||
const weeks = Math.floor(days / 7);
|
||||
if (weeks < 4) return `${weeks}w ago`;
|
||||
const months = Math.floor(days / 30);
|
||||
if (months < 12) return `${months}mo ago`;
|
||||
const years = Math.floor(days / 365);
|
||||
return `${years}y ago`;
|
||||
};
|
||||
|
||||
const comments = commentsData?.comments || [];
|
||||
const total = commentsData?.total || 0;
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={isOpen}
|
||||
transparent
|
||||
animationType="none"
|
||||
onRequestClose={onClose}
|
||||
>
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
style={styles.container}
|
||||
>
|
||||
{/* Backdrop */}
|
||||
<Animated.View
|
||||
style={[styles.backdrop, { opacity: opacityAnim }]}
|
||||
>
|
||||
<TouchableOpacity
|
||||
style={styles.backdropTouchable}
|
||||
activeOpacity={1}
|
||||
onPress={onClose}
|
||||
/>
|
||||
</Animated.View>
|
||||
|
||||
{/* Drawer */}
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.drawer,
|
||||
{
|
||||
transform: [{ translateY: slideAnim }],
|
||||
},
|
||||
]}
|
||||
>
|
||||
{/* Header */}
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title}>Comments ({total})</Text>
|
||||
<TouchableOpacity onPress={onClose} style={styles.closeButton}>
|
||||
<Ionicons name="close" size={24} color="#333" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Comments List */}
|
||||
<View style={styles.commentsContainer}>
|
||||
{isLoading ? (
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" color="#007AFF" />
|
||||
</View>
|
||||
) : comments.length === 0 ? (
|
||||
<View style={styles.emptyContainer}>
|
||||
<Text style={styles.emptyText}>
|
||||
No comments yet. Be the first to comment!
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<FlatList
|
||||
data={comments}
|
||||
keyExtractor={(item) => item.id}
|
||||
renderItem={({ item }) => (
|
||||
<View style={styles.commentCard}>
|
||||
<View style={styles.commentHeader}>
|
||||
<View style={styles.avatar}>
|
||||
<Text style={styles.avatarText}>
|
||||
{item.nickname.charAt(0).toUpperCase()}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.commentMeta}>
|
||||
<Text style={styles.nickname}>{item.nickname}</Text>
|
||||
<Text style={styles.timeAgo}>
|
||||
{formatTimeAgo(item.createdAt)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<Text style={styles.commentContent}>{item.content}</Text>
|
||||
</View>
|
||||
)}
|
||||
contentContainerStyle={styles.listContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Input Form */}
|
||||
<View style={styles.inputContainer}>
|
||||
{!nickname && (
|
||||
<TextInput
|
||||
style={styles.nicknameInput}
|
||||
placeholder="Your nickname"
|
||||
value={nickname}
|
||||
onChangeText={setNickname}
|
||||
maxLength={50}
|
||||
placeholderTextColor="#999"
|
||||
/>
|
||||
)}
|
||||
<View style={styles.commentInputRow}>
|
||||
<TextInput
|
||||
style={styles.commentInput}
|
||||
placeholder="Write a comment..."
|
||||
value={commentContent}
|
||||
onChangeText={setCommentContent}
|
||||
maxLength={500}
|
||||
multiline
|
||||
numberOfLines={3}
|
||||
placeholderTextColor="#999"
|
||||
/>
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.sendButton,
|
||||
(!commentContent.trim() || !nickname.trim() || createComment.isPending) &&
|
||||
styles.sendButtonDisabled,
|
||||
]}
|
||||
onPress={handleSubmit}
|
||||
disabled={
|
||||
!commentContent.trim() ||
|
||||
!nickname.trim() ||
|
||||
createComment.isPending
|
||||
}
|
||||
>
|
||||
{createComment.isPending ? (
|
||||
<ActivityIndicator size="small" color="#fff" />
|
||||
) : (
|
||||
<Ionicons name="send" size={18} color="#fff" />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</Animated.View>
|
||||
</KeyboardAvoidingView>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
backdrop: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.3)',
|
||||
},
|
||||
backdropTouchable: {
|
||||
flex: 1,
|
||||
},
|
||||
drawer: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
height: DRAWER_HEIGHT,
|
||||
backgroundColor: '#fff',
|
||||
borderTopLeftRadius: 16,
|
||||
borderTopRightRadius: 16,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: -2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 8,
|
||||
elevation: 5,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 16,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#e0e0e0',
|
||||
},
|
||||
title: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
color: '#000',
|
||||
},
|
||||
closeButton: {
|
||||
padding: 4,
|
||||
},
|
||||
commentsContainer: {
|
||||
flex: 1,
|
||||
},
|
||||
loadingContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
emptyContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: 32,
|
||||
},
|
||||
emptyText: {
|
||||
fontSize: 14,
|
||||
color: '#666',
|
||||
textAlign: 'center',
|
||||
},
|
||||
listContent: {
|
||||
padding: 16,
|
||||
},
|
||||
commentCard: {
|
||||
backgroundColor: '#f5f5f5',
|
||||
borderRadius: 12,
|
||||
padding: 12,
|
||||
marginBottom: 12,
|
||||
},
|
||||
commentHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: 8,
|
||||
},
|
||||
avatar: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 16,
|
||||
backgroundColor: '#007AFF20',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginRight: 8,
|
||||
},
|
||||
avatarText: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: '#007AFF',
|
||||
},
|
||||
commentMeta: {
|
||||
flex: 1,
|
||||
},
|
||||
nickname: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: '#000',
|
||||
marginBottom: 2,
|
||||
},
|
||||
timeAgo: {
|
||||
fontSize: 12,
|
||||
color: '#666',
|
||||
},
|
||||
commentContent: {
|
||||
fontSize: 14,
|
||||
color: '#333',
|
||||
lineHeight: 20,
|
||||
},
|
||||
inputContainer: {
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: '#e0e0e0',
|
||||
padding: 16,
|
||||
backgroundColor: '#fff',
|
||||
},
|
||||
nicknameInput: {
|
||||
borderWidth: 1,
|
||||
borderColor: '#e0e0e0',
|
||||
borderRadius: 8,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 10,
|
||||
fontSize: 14,
|
||||
marginBottom: 12,
|
||||
color: '#000',
|
||||
},
|
||||
commentInputRow: {
|
||||
flexDirection: 'row',
|
||||
gap: 8,
|
||||
},
|
||||
commentInput: {
|
||||
flex: 1,
|
||||
borderWidth: 1,
|
||||
borderColor: '#e0e0e0',
|
||||
borderRadius: 8,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 10,
|
||||
fontSize: 14,
|
||||
maxHeight: 80,
|
||||
color: '#000',
|
||||
},
|
||||
sendButton: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
backgroundColor: '#007AFF',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
sendButtonDisabled: {
|
||||
backgroundColor: '#ccc',
|
||||
},
|
||||
});
|
||||
135
sapiense-ai-app/components/LanguageModal.tsx
Normal file
@ -0,0 +1,135 @@
|
||||
import { View, Text, Modal, TouchableOpacity, StyleSheet } from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
|
||||
interface LanguageModalProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
currentLanguage: string;
|
||||
onSelectLanguage: (language: string) => void;
|
||||
}
|
||||
|
||||
const languages = [
|
||||
{ code: 'ko', name: '한국어', flag: '🇰🇷' },
|
||||
{ code: 'en', name: 'English', flag: '🇺🇸' },
|
||||
{ code: 'ja', name: '日本語', flag: '🇯🇵' },
|
||||
{ code: 'zh-cn', name: '简体中文', flag: '🇨🇳' },
|
||||
{ code: 'zh-tw', name: '繁體中文', flag: '🇹🇼' },
|
||||
{ code: 'fr', name: 'Français', flag: '🇫🇷' },
|
||||
{ code: 'de', name: 'Deutsch', flag: '🇩🇪' },
|
||||
{ code: 'es', name: 'Español', flag: '🇪🇸' },
|
||||
{ code: 'it', name: 'Italiano', flag: '🇮🇹' },
|
||||
];
|
||||
|
||||
export default function LanguageModal({ visible, onClose, currentLanguage, onSelectLanguage }: LanguageModalProps) {
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
transparent
|
||||
animationType="fade"
|
||||
onRequestClose={onClose}
|
||||
>
|
||||
<TouchableOpacity
|
||||
style={styles.overlay}
|
||||
activeOpacity={1}
|
||||
onPress={onClose}
|
||||
>
|
||||
<View style={styles.modalContainer}>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title}>Select Language</Text>
|
||||
<TouchableOpacity onPress={onClose}>
|
||||
<Ionicons name="close" size={24} color="#333" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<View style={styles.languageList}>
|
||||
{languages.map((lang) => (
|
||||
<TouchableOpacity
|
||||
key={lang.code}
|
||||
style={[
|
||||
styles.languageItem,
|
||||
currentLanguage === lang.code && styles.languageItemActive,
|
||||
]}
|
||||
onPress={() => {
|
||||
onSelectLanguage(lang.code);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<View style={styles.languageInfo}>
|
||||
<Text style={styles.flag}>{lang.flag}</Text>
|
||||
<Text style={[
|
||||
styles.languageName,
|
||||
currentLanguage === lang.code && styles.languageNameActive,
|
||||
]}>
|
||||
{lang.name}
|
||||
</Text>
|
||||
</View>
|
||||
{currentLanguage === lang.code && (
|
||||
<Ionicons name="checkmark" size={20} color="#007AFF" />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
overlay: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
modalContainer: {
|
||||
backgroundColor: '#fff',
|
||||
borderRadius: 12,
|
||||
width: '85%',
|
||||
maxWidth: 400,
|
||||
maxHeight: '70%',
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: 16,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#e0e0e0',
|
||||
},
|
||||
title: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
color: '#000',
|
||||
},
|
||||
languageList: {
|
||||
padding: 8,
|
||||
},
|
||||
languageItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: 12,
|
||||
borderRadius: 8,
|
||||
marginBottom: 4,
|
||||
},
|
||||
languageItemActive: {
|
||||
backgroundColor: '#f0f0f0',
|
||||
},
|
||||
languageInfo: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
},
|
||||
flag: {
|
||||
fontSize: 24,
|
||||
},
|
||||
languageName: {
|
||||
fontSize: 16,
|
||||
color: '#333',
|
||||
},
|
||||
languageNameActive: {
|
||||
fontWeight: '600',
|
||||
color: '#007AFF',
|
||||
},
|
||||
});
|
||||
112
sapiense-ai-app/components/OutletCard.tsx
Normal file
@ -0,0 +1,112 @@
|
||||
import { View, Text, TouchableOpacity, Image, StyleSheet } from 'react-native';
|
||||
|
||||
interface OutletCardProps {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
category: string;
|
||||
focusSubject: string;
|
||||
avatar?: string;
|
||||
articleCount: number;
|
||||
onPress: (id: string) => void;
|
||||
}
|
||||
|
||||
export default function OutletCard({
|
||||
id,
|
||||
name,
|
||||
description,
|
||||
avatar,
|
||||
articleCount,
|
||||
onPress,
|
||||
}: OutletCardProps) {
|
||||
const getInitials = (name: string) => {
|
||||
return name
|
||||
.split(' ')
|
||||
.map(word => word[0])
|
||||
.join('')
|
||||
.toUpperCase()
|
||||
.slice(0, 2);
|
||||
};
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={styles.card}
|
||||
onPress={() => onPress(id)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View style={styles.content}>
|
||||
{avatar ? (
|
||||
<Image
|
||||
source={{ uri: avatar }}
|
||||
style={styles.avatar}
|
||||
defaultSource={require('../assets/images/partial-react-logo.png')}
|
||||
/>
|
||||
) : (
|
||||
<View style={styles.avatarFallback}>
|
||||
<Text style={styles.avatarText}>{getInitials(name)}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={styles.textContainer}>
|
||||
<Text style={styles.name} numberOfLines={1}>
|
||||
{name}
|
||||
</Text>
|
||||
<Text style={styles.description} numberOfLines={1}>
|
||||
{description}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: {
|
||||
backgroundColor: '#fff',
|
||||
borderRadius: 12,
|
||||
padding: 12,
|
||||
marginBottom: 8,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 2,
|
||||
elevation: 2,
|
||||
},
|
||||
content: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
},
|
||||
avatar: {
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 24,
|
||||
},
|
||||
avatarFallback: {
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 24,
|
||||
backgroundColor: '#e0e0e0',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
avatarText: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: '#333',
|
||||
},
|
||||
textContainer: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
name: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: '#000',
|
||||
marginBottom: 4,
|
||||
},
|
||||
description: {
|
||||
fontSize: 14,
|
||||
color: '#666',
|
||||
},
|
||||
});
|
||||
126
sapiense-ai-app/components/SearchModal.tsx
Normal file
@ -0,0 +1,126 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { View, Text, TextInput, TouchableOpacity, StyleSheet, Animated, Dimensions } from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
|
||||
const { width } = Dimensions.get('window');
|
||||
|
||||
interface SearchModalProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function SearchModal({ visible, onClose }: SearchModalProps) {
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const [slideAnim] = useState(new Animated.Value(width));
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
Animated.spring(slideAnim, {
|
||||
toValue: 0,
|
||||
useNativeDriver: true,
|
||||
tension: 65,
|
||||
friction: 11,
|
||||
}).start();
|
||||
} else {
|
||||
Animated.timing(slideAnim, {
|
||||
toValue: width,
|
||||
duration: 250,
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
const handleClose = () => {
|
||||
Animated.timing(slideAnim, {
|
||||
toValue: width,
|
||||
duration: 250,
|
||||
useNativeDriver: true,
|
||||
}).start(() => {
|
||||
onClose();
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.container,
|
||||
{
|
||||
transform: [{ translateX: slideAnim }],
|
||||
},
|
||||
]}
|
||||
>
|
||||
<View style={styles.searchBar}>
|
||||
<Ionicons name="search-outline" size={20} color="#666" style={styles.searchIcon} />
|
||||
<TextInput
|
||||
style={styles.searchInput}
|
||||
placeholder="Search outlets, articles..."
|
||||
value={searchText}
|
||||
onChangeText={setSearchText}
|
||||
autoFocus
|
||||
returnKeyType="search"
|
||||
/>
|
||||
<TouchableOpacity onPress={handleClose} style={styles.closeButton}>
|
||||
<Ionicons name="close" size={24} color="#666" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<View style={styles.content}>
|
||||
{searchText === '' ? (
|
||||
<View style={styles.emptyState}>
|
||||
<Ionicons name="search-outline" size={64} color="#ccc" />
|
||||
<Text style={styles.emptyText}>Search for outlets or articles</Text>
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.emptyState}>
|
||||
<Text style={styles.emptyText}>No results found for "{searchText}"</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#fff',
|
||||
},
|
||||
searchBar: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#e0e0e0',
|
||||
backgroundColor: '#fff',
|
||||
},
|
||||
searchIcon: {
|
||||
marginRight: 8,
|
||||
},
|
||||
searchInput: {
|
||||
flex: 1,
|
||||
fontSize: 16,
|
||||
color: '#000',
|
||||
padding: 8,
|
||||
},
|
||||
closeButton: {
|
||||
padding: 4,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
padding: 16,
|
||||
},
|
||||
emptyState: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
emptyText: {
|
||||
fontSize: 16,
|
||||
color: '#999',
|
||||
marginTop: 16,
|
||||
textAlign: 'center',
|
||||
},
|
||||
});
|
||||
25
sapiense-ai-app/components/external-link.tsx
Normal file
@ -0,0 +1,25 @@
|
||||
import { Href, Link } from 'expo-router';
|
||||
import { openBrowserAsync, WebBrowserPresentationStyle } from 'expo-web-browser';
|
||||
import { type ComponentProps } from 'react';
|
||||
|
||||
type Props = Omit<ComponentProps<typeof Link>, 'href'> & { href: Href & string };
|
||||
|
||||
export function ExternalLink({ href, ...rest }: Props) {
|
||||
return (
|
||||
<Link
|
||||
target="_blank"
|
||||
{...rest}
|
||||
href={href}
|
||||
onPress={async (event) => {
|
||||
if (process.env.EXPO_OS !== 'web') {
|
||||
// Prevent the default behavior of linking to the default browser on native.
|
||||
event.preventDefault();
|
||||
// Open the link in an in-app browser.
|
||||
await openBrowserAsync(href, {
|
||||
presentationStyle: WebBrowserPresentationStyle.AUTOMATIC,
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
18
sapiense-ai-app/components/haptic-tab.tsx
Normal file
@ -0,0 +1,18 @@
|
||||
import { BottomTabBarButtonProps } from '@react-navigation/bottom-tabs';
|
||||
import { PlatformPressable } from '@react-navigation/elements';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
|
||||
export function HapticTab(props: BottomTabBarButtonProps) {
|
||||
return (
|
||||
<PlatformPressable
|
||||
{...props}
|
||||
onPressIn={(ev) => {
|
||||
if (process.env.EXPO_OS === 'ios') {
|
||||
// Add a soft haptic feedback when pressing down on the tabs.
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
}
|
||||
props.onPressIn?.(ev);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
19
sapiense-ai-app/components/hello-wave.tsx
Normal file
@ -0,0 +1,19 @@
|
||||
import Animated from 'react-native-reanimated';
|
||||
|
||||
export function HelloWave() {
|
||||
return (
|
||||
<Animated.Text
|
||||
style={{
|
||||
fontSize: 28,
|
||||
lineHeight: 32,
|
||||
marginTop: -6,
|
||||
animationName: {
|
||||
'50%': { transform: [{ rotate: '25deg' }] },
|
||||
},
|
||||
animationIterationCount: 4,
|
||||
animationDuration: '300ms',
|
||||
}}>
|
||||
👋
|
||||
</Animated.Text>
|
||||
);
|
||||
}
|
||||
79
sapiense-ai-app/components/parallax-scroll-view.tsx
Normal file
@ -0,0 +1,79 @@
|
||||
import type { PropsWithChildren, ReactElement } from 'react';
|
||||
import { StyleSheet } from 'react-native';
|
||||
import Animated, {
|
||||
interpolate,
|
||||
useAnimatedRef,
|
||||
useAnimatedStyle,
|
||||
useScrollOffset,
|
||||
} from 'react-native-reanimated';
|
||||
|
||||
import { ThemedView } from '@/components/themed-view';
|
||||
import { useColorScheme } from '@/hooks/use-color-scheme';
|
||||
import { useThemeColor } from '@/hooks/use-theme-color';
|
||||
|
||||
const HEADER_HEIGHT = 250;
|
||||
|
||||
type Props = PropsWithChildren<{
|
||||
headerImage: ReactElement;
|
||||
headerBackgroundColor: { dark: string; light: string };
|
||||
}>;
|
||||
|
||||
export default function ParallaxScrollView({
|
||||
children,
|
||||
headerImage,
|
||||
headerBackgroundColor,
|
||||
}: Props) {
|
||||
const backgroundColor = useThemeColor({}, 'background');
|
||||
const colorScheme = useColorScheme() ?? 'light';
|
||||
const scrollRef = useAnimatedRef<Animated.ScrollView>();
|
||||
const scrollOffset = useScrollOffset(scrollRef);
|
||||
const headerAnimatedStyle = useAnimatedStyle(() => {
|
||||
return {
|
||||
transform: [
|
||||
{
|
||||
translateY: interpolate(
|
||||
scrollOffset.value,
|
||||
[-HEADER_HEIGHT, 0, HEADER_HEIGHT],
|
||||
[-HEADER_HEIGHT / 2, 0, HEADER_HEIGHT * 0.75]
|
||||
),
|
||||
},
|
||||
{
|
||||
scale: interpolate(scrollOffset.value, [-HEADER_HEIGHT, 0, HEADER_HEIGHT], [2, 1, 1]),
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<Animated.ScrollView
|
||||
ref={scrollRef}
|
||||
style={{ backgroundColor, flex: 1 }}
|
||||
scrollEventThrottle={16}>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.header,
|
||||
{ backgroundColor: headerBackgroundColor[colorScheme] },
|
||||
headerAnimatedStyle,
|
||||
]}>
|
||||
{headerImage}
|
||||
</Animated.View>
|
||||
<ThemedView style={styles.content}>{children}</ThemedView>
|
||||
</Animated.ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
header: {
|
||||
height: HEADER_HEIGHT,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
padding: 32,
|
||||
gap: 16,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
});
|
||||
60
sapiense-ai-app/components/themed-text.tsx
Normal file
@ -0,0 +1,60 @@
|
||||
import { StyleSheet, Text, type TextProps } from 'react-native';
|
||||
|
||||
import { useThemeColor } from '@/hooks/use-theme-color';
|
||||
|
||||
export type ThemedTextProps = TextProps & {
|
||||
lightColor?: string;
|
||||
darkColor?: string;
|
||||
type?: 'default' | 'title' | 'defaultSemiBold' | 'subtitle' | 'link';
|
||||
};
|
||||
|
||||
export function ThemedText({
|
||||
style,
|
||||
lightColor,
|
||||
darkColor,
|
||||
type = 'default',
|
||||
...rest
|
||||
}: ThemedTextProps) {
|
||||
const color = useThemeColor({ light: lightColor, dark: darkColor }, 'text');
|
||||
|
||||
return (
|
||||
<Text
|
||||
style={[
|
||||
{ color },
|
||||
type === 'default' ? styles.default : undefined,
|
||||
type === 'title' ? styles.title : undefined,
|
||||
type === 'defaultSemiBold' ? styles.defaultSemiBold : undefined,
|
||||
type === 'subtitle' ? styles.subtitle : undefined,
|
||||
type === 'link' ? styles.link : undefined,
|
||||
style,
|
||||
]}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
default: {
|
||||
fontSize: 16,
|
||||
lineHeight: 24,
|
||||
},
|
||||
defaultSemiBold: {
|
||||
fontSize: 16,
|
||||
lineHeight: 24,
|
||||
fontWeight: '600',
|
||||
},
|
||||
title: {
|
||||
fontSize: 32,
|
||||
fontWeight: 'bold',
|
||||
lineHeight: 32,
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 20,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
link: {
|
||||
lineHeight: 30,
|
||||
fontSize: 16,
|
||||
color: '#0a7ea4',
|
||||
},
|
||||
});
|
||||
14
sapiense-ai-app/components/themed-view.tsx
Normal file
@ -0,0 +1,14 @@
|
||||
import { View, type ViewProps } from 'react-native';
|
||||
|
||||
import { useThemeColor } from '@/hooks/use-theme-color';
|
||||
|
||||
export type ThemedViewProps = ViewProps & {
|
||||
lightColor?: string;
|
||||
darkColor?: string;
|
||||
};
|
||||
|
||||
export function ThemedView({ style, lightColor, darkColor, ...otherProps }: ThemedViewProps) {
|
||||
const backgroundColor = useThemeColor({ light: lightColor, dark: darkColor }, 'background');
|
||||
|
||||
return <View style={[{ backgroundColor }, style]} {...otherProps} />;
|
||||
}
|
||||
45
sapiense-ai-app/components/ui/collapsible.tsx
Normal file
@ -0,0 +1,45 @@
|
||||
import { PropsWithChildren, useState } from 'react';
|
||||
import { StyleSheet, TouchableOpacity } from 'react-native';
|
||||
|
||||
import { ThemedText } from '@/components/themed-text';
|
||||
import { ThemedView } from '@/components/themed-view';
|
||||
import { IconSymbol } from '@/components/ui/icon-symbol';
|
||||
import { Colors } from '@/constants/theme';
|
||||
import { useColorScheme } from '@/hooks/use-color-scheme';
|
||||
|
||||
export function Collapsible({ children, title }: PropsWithChildren & { title: string }) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const theme = useColorScheme() ?? 'light';
|
||||
|
||||
return (
|
||||
<ThemedView>
|
||||
<TouchableOpacity
|
||||
style={styles.heading}
|
||||
onPress={() => setIsOpen((value) => !value)}
|
||||
activeOpacity={0.8}>
|
||||
<IconSymbol
|
||||
name="chevron.right"
|
||||
size={18}
|
||||
weight="medium"
|
||||
color={theme === 'light' ? Colors.light.icon : Colors.dark.icon}
|
||||
style={{ transform: [{ rotate: isOpen ? '90deg' : '0deg' }] }}
|
||||
/>
|
||||
|
||||
<ThemedText type="defaultSemiBold">{title}</ThemedText>
|
||||
</TouchableOpacity>
|
||||
{isOpen && <ThemedView style={styles.content}>{children}</ThemedView>}
|
||||
</ThemedView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
heading: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
},
|
||||
content: {
|
||||
marginTop: 6,
|
||||
marginLeft: 24,
|
||||
},
|
||||
});
|
||||
32
sapiense-ai-app/components/ui/icon-symbol.ios.tsx
Normal file
@ -0,0 +1,32 @@
|
||||
import { SymbolView, SymbolViewProps, SymbolWeight } from 'expo-symbols';
|
||||
import { StyleProp, ViewStyle } from 'react-native';
|
||||
|
||||
export function IconSymbol({
|
||||
name,
|
||||
size = 24,
|
||||
color,
|
||||
style,
|
||||
weight = 'regular',
|
||||
}: {
|
||||
name: SymbolViewProps['name'];
|
||||
size?: number;
|
||||
color: string;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
weight?: SymbolWeight;
|
||||
}) {
|
||||
return (
|
||||
<SymbolView
|
||||
weight={weight}
|
||||
tintColor={color}
|
||||
resizeMode="scaleAspectFit"
|
||||
name={name}
|
||||
style={[
|
||||
{
|
||||
width: size,
|
||||
height: size,
|
||||
},
|
||||
style,
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
41
sapiense-ai-app/components/ui/icon-symbol.tsx
Normal file
@ -0,0 +1,41 @@
|
||||
// Fallback for using MaterialIcons on Android and web.
|
||||
|
||||
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||
import { SymbolWeight, SymbolViewProps } from 'expo-symbols';
|
||||
import { ComponentProps } from 'react';
|
||||
import { OpaqueColorValue, type StyleProp, type TextStyle } from 'react-native';
|
||||
|
||||
type IconMapping = Record<SymbolViewProps['name'], ComponentProps<typeof MaterialIcons>['name']>;
|
||||
type IconSymbolName = keyof typeof MAPPING;
|
||||
|
||||
/**
|
||||
* Add your SF Symbols to Material Icons mappings here.
|
||||
* - see Material Icons in the [Icons Directory](https://icons.expo.fyi).
|
||||
* - see SF Symbols in the [SF Symbols](https://developer.apple.com/sf-symbols/) app.
|
||||
*/
|
||||
const MAPPING = {
|
||||
'house.fill': 'home',
|
||||
'paperplane.fill': 'send',
|
||||
'chevron.left.forwardslash.chevron.right': 'code',
|
||||
'chevron.right': 'chevron-right',
|
||||
} as IconMapping;
|
||||
|
||||
/**
|
||||
* An icon component that uses native SF Symbols on iOS, and Material Icons on Android and web.
|
||||
* This ensures a consistent look across platforms, and optimal resource usage.
|
||||
* Icon `name`s are based on SF Symbols and require manual mapping to Material Icons.
|
||||
*/
|
||||
export function IconSymbol({
|
||||
name,
|
||||
size = 24,
|
||||
color,
|
||||
style,
|
||||
}: {
|
||||
name: IconSymbolName;
|
||||
size?: number;
|
||||
color: string | OpaqueColorValue;
|
||||
style?: StyleProp<TextStyle>;
|
||||
weight?: SymbolWeight;
|
||||
}) {
|
||||
return <MaterialIcons color={color} size={size} name={MAPPING[name]} style={style} />;
|
||||
}
|
||||
53
sapiense-ai-app/constants/theme.ts
Normal file
@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Below are the colors that are used in the app. The colors are defined in the light and dark mode.
|
||||
* There are many other ways to style your app. For example, [Nativewind](https://www.nativewind.dev/), [Tamagui](https://tamagui.dev/), [unistyles](https://reactnativeunistyles.vercel.app), etc.
|
||||
*/
|
||||
|
||||
import { Platform } from 'react-native';
|
||||
|
||||
const tintColorLight = '#0a7ea4';
|
||||
const tintColorDark = '#fff';
|
||||
|
||||
export const Colors = {
|
||||
light: {
|
||||
text: '#11181C',
|
||||
background: '#fff',
|
||||
tint: tintColorLight,
|
||||
icon: '#687076',
|
||||
tabIconDefault: '#687076',
|
||||
tabIconSelected: tintColorLight,
|
||||
},
|
||||
dark: {
|
||||
text: '#ECEDEE',
|
||||
background: '#151718',
|
||||
tint: tintColorDark,
|
||||
icon: '#9BA1A6',
|
||||
tabIconDefault: '#9BA1A6',
|
||||
tabIconSelected: tintColorDark,
|
||||
},
|
||||
};
|
||||
|
||||
export const Fonts = Platform.select({
|
||||
ios: {
|
||||
/** iOS `UIFontDescriptorSystemDesignDefault` */
|
||||
sans: 'system-ui',
|
||||
/** iOS `UIFontDescriptorSystemDesignSerif` */
|
||||
serif: 'ui-serif',
|
||||
/** iOS `UIFontDescriptorSystemDesignRounded` */
|
||||
rounded: 'ui-rounded',
|
||||
/** iOS `UIFontDescriptorSystemDesignMonospaced` */
|
||||
mono: 'ui-monospace',
|
||||
},
|
||||
default: {
|
||||
sans: 'normal',
|
||||
serif: 'serif',
|
||||
rounded: 'normal',
|
||||
mono: 'monospace',
|
||||
},
|
||||
web: {
|
||||
sans: "system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif",
|
||||
serif: "Georgia, 'Times New Roman', serif",
|
||||
rounded: "'SF Pro Rounded', 'Hiragino Maru Gothic ProN', Meiryo, 'MS PGothic', sans-serif",
|
||||
mono: "SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace",
|
||||
},
|
||||
});
|
||||
26
sapiense-ai-app/contexts/LanguageContext.tsx
Normal file
@ -0,0 +1,26 @@
|
||||
import React, { createContext, useContext, useState, ReactNode } from 'react';
|
||||
|
||||
interface LanguageContextType {
|
||||
language: string;
|
||||
setLanguage: (language: string) => void;
|
||||
}
|
||||
|
||||
const LanguageContext = createContext<LanguageContextType | undefined>(undefined);
|
||||
|
||||
export function LanguageProvider({ children }: { children: ReactNode }) {
|
||||
const [language, setLanguage] = useState('ko');
|
||||
|
||||
return (
|
||||
<LanguageContext.Provider value={{ language, setLanguage }}>
|
||||
{children}
|
||||
</LanguageContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useLanguage() {
|
||||
const context = useContext(LanguageContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useLanguage must be used within a LanguageProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
10
sapiense-ai-app/eslint.config.js
Normal file
@ -0,0 +1,10 @@
|
||||
// https://docs.expo.dev/guides/using-eslint/
|
||||
const { defineConfig } = require('eslint/config');
|
||||
const expoConfig = require('eslint-config-expo/flat');
|
||||
|
||||
module.exports = defineConfig([
|
||||
expoConfig,
|
||||
{
|
||||
ignores: ['dist/*'],
|
||||
},
|
||||
]);
|
||||
3
sapiense-ai-app/global.css
Normal file
@ -0,0 +1,3 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
1
sapiense-ai-app/hooks/use-color-scheme.ts
Normal file
@ -0,0 +1 @@
|
||||
export { useColorScheme } from 'react-native';
|
||||
21
sapiense-ai-app/hooks/use-color-scheme.web.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useColorScheme as useRNColorScheme } from 'react-native';
|
||||
|
||||
/**
|
||||
* To support static rendering, this value needs to be re-calculated on the client side for web
|
||||
*/
|
||||
export function useColorScheme() {
|
||||
const [hasHydrated, setHasHydrated] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setHasHydrated(true);
|
||||
}, []);
|
||||
|
||||
const colorScheme = useRNColorScheme();
|
||||
|
||||
if (hasHydrated) {
|
||||
return colorScheme;
|
||||
}
|
||||
|
||||
return 'light';
|
||||
}
|
||||
21
sapiense-ai-app/hooks/use-theme-color.ts
Normal file
@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Learn more about light and dark modes:
|
||||
* https://docs.expo.dev/guides/color-schemes/
|
||||
*/
|
||||
|
||||
import { Colors } from '@/constants/theme';
|
||||
import { useColorScheme } from '@/hooks/use-color-scheme';
|
||||
|
||||
export function useThemeColor(
|
||||
props: { light?: string; dark?: string },
|
||||
colorName: keyof typeof Colors.light & keyof typeof Colors.dark
|
||||
) {
|
||||
const theme = useColorScheme() ?? 'light';
|
||||
const colorFromProps = props[theme];
|
||||
|
||||
if (colorFromProps) {
|
||||
return colorFromProps;
|
||||
} else {
|
||||
return Colors[theme][colorName];
|
||||
}
|
||||
}
|
||||
104
sapiense-ai-app/hooks/useApi.ts
Normal file
@ -0,0 +1,104 @@
|
||||
import { useQuery, useMutation, useQueryClient, useInfiniteQuery } from '@tanstack/react-query';
|
||||
import { outletsApi, articlesApi, searchApi, feedApi } from '../lib/api';
|
||||
|
||||
// Outlets hooks
|
||||
export function useOutlets(category?: string, language = 'ko') {
|
||||
return useQuery({
|
||||
queryKey: ['/outlets', category, language],
|
||||
queryFn: () => outletsApi.getAll(category, language),
|
||||
});
|
||||
}
|
||||
|
||||
export function useOutlet(id: string, language = 'ko') {
|
||||
return useQuery({
|
||||
queryKey: ['outlet', id, language],
|
||||
queryFn: () => outletsApi.getById(id, language),
|
||||
enabled: !!id && id.trim().length > 0,
|
||||
});
|
||||
}
|
||||
|
||||
// Articles hooks
|
||||
export function useArticles(language = 'en') {
|
||||
return useQuery({
|
||||
queryKey: ['/articles', language],
|
||||
queryFn: () => articlesApi.getAll(language),
|
||||
});
|
||||
}
|
||||
|
||||
export function useArticlesByOutlet(outletId: string, language = 'en') {
|
||||
return useQuery({
|
||||
queryKey: ['/articles', outletId, language],
|
||||
queryFn: () => articlesApi.getByOutlet(outletId, language),
|
||||
enabled: !!outletId,
|
||||
});
|
||||
}
|
||||
|
||||
export function useFeaturedArticles(limit = 10, language = 'en') {
|
||||
return useQuery({
|
||||
queryKey: ['/articles', 'featured', limit, language],
|
||||
queryFn: () => articlesApi.getFeatured(limit, language),
|
||||
});
|
||||
}
|
||||
|
||||
export function useArticle(id: string, language = 'en', useNewsId = true) {
|
||||
return useQuery({
|
||||
queryKey: ['/articles', id, language, useNewsId],
|
||||
queryFn: () => articlesApi.getById(id, language, useNewsId),
|
||||
enabled: !!id,
|
||||
});
|
||||
}
|
||||
|
||||
// Search hooks
|
||||
export function useSearch(
|
||||
query: string,
|
||||
type: 'all' | 'articles' | 'outlets' = 'all',
|
||||
language = 'en',
|
||||
outletId?: string
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: ['/search', query, type, language, outletId],
|
||||
queryFn: () => searchApi.search(query, type, language, outletId),
|
||||
enabled: !!query.trim(),
|
||||
});
|
||||
}
|
||||
|
||||
export function useSearchArticles(query: string, language = 'en') {
|
||||
return useQuery({
|
||||
queryKey: ['/search', query, 'articles', language],
|
||||
queryFn: () => searchApi.articles(query, language),
|
||||
enabled: !!query.trim(),
|
||||
});
|
||||
}
|
||||
|
||||
// Feed hooks for YouTube-style interface
|
||||
export function useFeed(filter: 'all' | 'people' | 'topics' | 'companies' = 'all') {
|
||||
return useInfiniteQuery({
|
||||
queryKey: ['/feed', filter],
|
||||
queryFn: ({ pageParam }) => {
|
||||
return feedApi.getFeed({
|
||||
cursor: pageParam,
|
||||
limit: 10,
|
||||
filter: filter,
|
||||
});
|
||||
},
|
||||
initialPageParam: undefined as string | undefined,
|
||||
getNextPageParam: (lastPage: any) => lastPage?.nextCursor,
|
||||
});
|
||||
}
|
||||
|
||||
export function useIncrementView(filter?: 'all' | 'people' | 'topics' | 'companies') {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: feedApi.incrementView,
|
||||
onSuccess: (_, articleId) => {
|
||||
console.log(`[DEBUG] View incremented for article ${articleId}, invalidating cache with filter:`, filter);
|
||||
if (filter) {
|
||||
queryClient.invalidateQueries({ queryKey: ['/feed', filter] });
|
||||
} else {
|
||||
queryClient.invalidateQueries({ queryKey: ['/feed'] });
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: ['/articles'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
128
sapiense-ai-app/lib/api.ts
Normal file
@ -0,0 +1,128 @@
|
||||
import { API_BASE_URL, API_TIMEOUT } from './config';
|
||||
|
||||
// Generic API request helper
|
||||
async function apiRequest<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), API_TIMEOUT);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
},
|
||||
signal: controller.signal,
|
||||
...options,
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`API Error (${response.status}): ${errorText || response.statusText}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
} catch (error) {
|
||||
clearTimeout(timeoutId);
|
||||
if (error instanceof Error) {
|
||||
if (error.name === 'AbortError') {
|
||||
throw new Error('Request timeout');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
throw new Error('Unknown error occurred');
|
||||
}
|
||||
}
|
||||
|
||||
// Outlets API
|
||||
export const outletsApi = {
|
||||
getAll: async (category?: string, language = 'ko') => {
|
||||
const params = new URLSearchParams();
|
||||
if (category) params.append('category', category);
|
||||
if (language) params.append('language', language);
|
||||
const response = await apiRequest<any>(`/api/v1/outlets?${params}`);
|
||||
|
||||
// API returns {"people": [...]} when category is specified
|
||||
// Extract the array from the categorized response
|
||||
if (category && response[category]) {
|
||||
return response[category];
|
||||
}
|
||||
return response;
|
||||
},
|
||||
|
||||
getById: (id: string, language = 'ko') => {
|
||||
const params = new URLSearchParams({ language });
|
||||
return apiRequest(`/api/v1/outlets/${id}?${params}`);
|
||||
},
|
||||
};
|
||||
|
||||
// Articles API
|
||||
export const articlesApi = {
|
||||
getAll: (language = 'ko') => {
|
||||
const params = new URLSearchParams({ page_size: '50' });
|
||||
return apiRequest(`/api/v1/${language}/articles?${params}`);
|
||||
},
|
||||
|
||||
getByOutlet: (outletId: string, language = 'ko') => {
|
||||
const params = new URLSearchParams({ page_size: '50' });
|
||||
return apiRequest(`/api/v1/${language}/outlets/${encodeURIComponent(outletId)}/articles?${params}`);
|
||||
},
|
||||
|
||||
getFeatured: (limit = 10, language = 'ko') => {
|
||||
const params = new URLSearchParams({ page_size: limit.toString() });
|
||||
return apiRequest(`/api/v1/${language}/articles?${params}`);
|
||||
},
|
||||
|
||||
getById: (id: string, language = 'ko', useNewsId = false) => {
|
||||
return apiRequest(`/api/v1/${language}/articles/${id}`);
|
||||
},
|
||||
};
|
||||
|
||||
// Search API
|
||||
export const searchApi = {
|
||||
search: async (
|
||||
query: string,
|
||||
type: 'all' | 'articles' | 'outlets' = 'all',
|
||||
language = 'en',
|
||||
outletId?: string
|
||||
) => {
|
||||
const params = new URLSearchParams({
|
||||
q: query,
|
||||
type,
|
||||
language,
|
||||
});
|
||||
|
||||
if (outletId) {
|
||||
params.append('outletId', outletId);
|
||||
}
|
||||
|
||||
return apiRequest(`/api/v1/search?${params}`);
|
||||
},
|
||||
|
||||
articles: (query: string, language = 'en') => {
|
||||
const params = new URLSearchParams({ q: query, language, limit: '20' });
|
||||
return apiRequest(`/api/v1/search/articles?${params}`);
|
||||
},
|
||||
};
|
||||
|
||||
// Feed API for YouTube-style interface
|
||||
export const feedApi = {
|
||||
getFeed: (params: {
|
||||
cursor?: string;
|
||||
limit?: number;
|
||||
filter?: 'all' | 'people' | 'topics' | 'companies';
|
||||
}) => {
|
||||
const searchParams = new URLSearchParams();
|
||||
if (params.cursor) searchParams.set('cursor', params.cursor);
|
||||
if (params.limit) searchParams.set('limit', params.limit.toString());
|
||||
if (params.filter) searchParams.set('filter', params.filter);
|
||||
|
||||
return apiRequest(`/api/v1/feed?${searchParams.toString()}`);
|
||||
},
|
||||
|
||||
incrementView: (articleId: string) =>
|
||||
apiRequest(`/api/v1/articles/${articleId}/view`, {
|
||||
method: 'POST',
|
||||
}),
|
||||
};
|
||||
32
sapiense-ai-app/lib/config.ts
Normal file
@ -0,0 +1,32 @@
|
||||
// API Configuration
|
||||
// For development with Expo, we need to use the computer's local IP address
|
||||
// You can find your IP by running: ifconfig | grep "inet " | grep -v 127.0.0.1
|
||||
|
||||
// TODO: Replace with your computer's IP address when testing on physical device
|
||||
// For iOS Simulator: use 'localhost'
|
||||
// For Android Emulator: use '10.0.2.2'
|
||||
// For physical device: use your computer's local IP (e.g., '192.168.1.100')
|
||||
|
||||
import Constants from 'expo-constants';
|
||||
import { Platform } from 'react-native';
|
||||
|
||||
// Get the API URL based on the platform
|
||||
function getApiUrl() {
|
||||
if (__DEV__) {
|
||||
// Development mode
|
||||
if (Platform.OS === 'android') {
|
||||
// Android emulator uses 10.0.2.2 to access localhost on the host
|
||||
return 'http://10.0.2.2:8050';
|
||||
}
|
||||
|
||||
// iOS simulator and web can use localhost
|
||||
// For physical device, replace 'localhost' with your computer's IP
|
||||
return 'http://localhost:8050';
|
||||
}
|
||||
|
||||
// Production mode - use your production API URL
|
||||
return 'https://your-production-api.com';
|
||||
}
|
||||
|
||||
export const API_BASE_URL = getApiUrl();
|
||||
export const API_TIMEOUT = 10000; // 10 seconds
|
||||
16
sapiense-ai-app/lib/queryClient.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import { QueryClient } from '@tanstack/react-query';
|
||||
|
||||
export const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnMount: false,
|
||||
refetchOnReconnect: true,
|
||||
retry: 1,
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
},
|
||||
mutations: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
14160
sapiense-ai-app/package-lock.json
generated
Normal file
51
sapiense-ai-app/package.json
Normal file
@ -0,0 +1,51 @@
|
||||
{
|
||||
"name": "sapiense-ai-app",
|
||||
"main": "expo-router/entry",
|
||||
"version": "1.0.0",
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
"reset-project": "node ./scripts/reset-project.js",
|
||||
"android": "expo start --android",
|
||||
"ios": "expo start --ios",
|
||||
"web": "expo start --web",
|
||||
"lint": "expo lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/vector-icons": "^15.0.2",
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
"@react-navigation/bottom-tabs": "^7.4.0",
|
||||
"@react-navigation/elements": "^2.6.3",
|
||||
"@react-navigation/native": "^7.1.8",
|
||||
"@tanstack/react-query": "^5.90.2",
|
||||
"expo": "~54.0.12",
|
||||
"expo-constants": "~18.0.9",
|
||||
"expo-font": "~14.0.8",
|
||||
"expo-haptics": "~15.0.7",
|
||||
"expo-image": "~3.0.8",
|
||||
"expo-linking": "~8.0.8",
|
||||
"expo-router": "~6.0.10",
|
||||
"expo-splash-screen": "~31.0.10",
|
||||
"expo-status-bar": "~3.0.8",
|
||||
"expo-symbols": "~1.0.7",
|
||||
"expo-system-ui": "~6.0.7",
|
||||
"expo-web-browser": "~15.0.8",
|
||||
"nativewind": "^4.2.1",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react-native": "0.81.4",
|
||||
"react-native-gesture-handler": "~2.28.0",
|
||||
"react-native-reanimated": "~4.1.1",
|
||||
"react-native-safe-area-context": "~5.6.0",
|
||||
"react-native-screens": "~4.16.0",
|
||||
"react-native-web": "~0.21.0",
|
||||
"react-native-worklets": "0.5.1",
|
||||
"tailwindcss": "^3.4.18"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "~19.1.0",
|
||||
"eslint": "^9.25.0",
|
||||
"eslint-config-expo": "~10.0.0",
|
||||
"typescript": "~5.9.2"
|
||||
},
|
||||
"private": true
|
||||
}
|
||||
112
sapiense-ai-app/scripts/reset-project.js
Executable file
@ -0,0 +1,112 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* This script is used to reset the project to a blank state.
|
||||
* It deletes or moves the /app, /components, /hooks, /scripts, and /constants directories to /app-example based on user input and creates a new /app directory with an index.tsx and _layout.tsx file.
|
||||
* You can remove the `reset-project` script from package.json and safely delete this file after running it.
|
||||
*/
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const readline = require("readline");
|
||||
|
||||
const root = process.cwd();
|
||||
const oldDirs = ["app", "components", "hooks", "constants", "scripts"];
|
||||
const exampleDir = "app-example";
|
||||
const newAppDir = "app";
|
||||
const exampleDirPath = path.join(root, exampleDir);
|
||||
|
||||
const indexContent = `import { Text, View } from "react-native";
|
||||
|
||||
export default function Index() {
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Text>Edit app/index.tsx to edit this screen.</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
`;
|
||||
|
||||
const layoutContent = `import { Stack } from "expo-router";
|
||||
|
||||
export default function RootLayout() {
|
||||
return <Stack />;
|
||||
}
|
||||
`;
|
||||
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
});
|
||||
|
||||
const moveDirectories = async (userInput) => {
|
||||
try {
|
||||
if (userInput === "y") {
|
||||
// Create the app-example directory
|
||||
await fs.promises.mkdir(exampleDirPath, { recursive: true });
|
||||
console.log(`📁 /${exampleDir} directory created.`);
|
||||
}
|
||||
|
||||
// Move old directories to new app-example directory or delete them
|
||||
for (const dir of oldDirs) {
|
||||
const oldDirPath = path.join(root, dir);
|
||||
if (fs.existsSync(oldDirPath)) {
|
||||
if (userInput === "y") {
|
||||
const newDirPath = path.join(root, exampleDir, dir);
|
||||
await fs.promises.rename(oldDirPath, newDirPath);
|
||||
console.log(`➡️ /${dir} moved to /${exampleDir}/${dir}.`);
|
||||
} else {
|
||||
await fs.promises.rm(oldDirPath, { recursive: true, force: true });
|
||||
console.log(`❌ /${dir} deleted.`);
|
||||
}
|
||||
} else {
|
||||
console.log(`➡️ /${dir} does not exist, skipping.`);
|
||||
}
|
||||
}
|
||||
|
||||
// Create new /app directory
|
||||
const newAppDirPath = path.join(root, newAppDir);
|
||||
await fs.promises.mkdir(newAppDirPath, { recursive: true });
|
||||
console.log("\n📁 New /app directory created.");
|
||||
|
||||
// Create index.tsx
|
||||
const indexPath = path.join(newAppDirPath, "index.tsx");
|
||||
await fs.promises.writeFile(indexPath, indexContent);
|
||||
console.log("📄 app/index.tsx created.");
|
||||
|
||||
// Create _layout.tsx
|
||||
const layoutPath = path.join(newAppDirPath, "_layout.tsx");
|
||||
await fs.promises.writeFile(layoutPath, layoutContent);
|
||||
console.log("📄 app/_layout.tsx created.");
|
||||
|
||||
console.log("\n✅ Project reset complete. Next steps:");
|
||||
console.log(
|
||||
`1. Run \`npx expo start\` to start a development server.\n2. Edit app/index.tsx to edit the main screen.${
|
||||
userInput === "y"
|
||||
? `\n3. Delete the /${exampleDir} directory when you're done referencing it.`
|
||||
: ""
|
||||
}`
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(`❌ Error during script execution: ${error.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
rl.question(
|
||||
"Do you want to move existing files to /app-example instead of deleting them? (Y/n): ",
|
||||
(answer) => {
|
||||
const userInput = answer.trim().toLowerCase() || "y";
|
||||
if (userInput === "y" || userInput === "n") {
|
||||
moveDirectories(userInput).finally(() => rl.close());
|
||||
} else {
|
||||
console.log("❌ Invalid input. Please enter 'Y' or 'N'.");
|
||||
rl.close();
|
||||
}
|
||||
}
|
||||
);
|
||||
12
sapiense-ai-app/tailwind.config.js
Normal file
@ -0,0 +1,12 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: [
|
||||
"./app/**/*.{js,jsx,ts,tsx}",
|
||||
"./components/**/*.{js,jsx,ts,tsx}"
|
||||
],
|
||||
presets: [require("nativewind/preset")],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
17
sapiense-ai-app/tsconfig.json
Normal file
@ -0,0 +1,17 @@
|
||||
{
|
||||
"extends": "expo/tsconfig.base",
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".expo/types/**/*.ts",
|
||||
"expo-env.d.ts"
|
||||
]
|
||||
}
|
||||