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:
572
sapiense-ai-app/app/article/[id].tsx
Normal file
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',
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user