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>
This commit is contained in:
jungwoo choi
2025-10-23 14:30:25 +09:00
commit 919afe56f2
1516 changed files with 64072 additions and 0 deletions

43
sapiense-ai-app/.gitignore vendored Normal file
View 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

View File

@ -0,0 +1 @@
{ "recommendations": ["expo.vscode-expo-tools"] }

7
sapiense-ai-app/.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,7 @@
{
"editor.codeActionsOnSave": {
"source.fixAll": "explicit",
"source.organizeImports": "explicit",
"source.sortMembers": "explicit"
}
}

50
sapiense-ai-app/README.md Normal file
View 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
View 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
}
}
}

View 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>
);
}

View 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&apos;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,
},
});

View 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,
},
});

View 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>
);
}

View 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',
},
});

View 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,
},
});

View 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,
},
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 384 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View 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,
},
});

View 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',
},
});

View 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',
},
});

View 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',
},
});

View 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',
},
});

View 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',
},
});

View 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,
});
}
}}
/>
);
}

View 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);
}}
/>
);
}

View 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>
);
}

View 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',
},
});

View 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',
},
});

View 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} />;
}

View 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,
},
});

View 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,
]}
/>
);
}

View 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} />;
}

View 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",
},
});

View 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;
}

View 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/*'],
},
]);

View File

@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

View File

@ -0,0 +1 @@
export { useColorScheme } from 'react-native';

View 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';
}

View 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];
}
}

View 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
View 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',
}),
};

View 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

View 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

File diff suppressed because it is too large Load Diff

View 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
}

View 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();
}
}
);

View 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: [],
}

View File

@ -0,0 +1,17 @@
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true,
"paths": {
"@/*": [
"./*"
]
}
},
"include": [
"**/*.ts",
"**/*.tsx",
".expo/types/**/*.ts",
"expo-env.d.ts"
]
}