Files
sapiens-mobile/sapiense-ai-app/app/article/[id].tsx
jungwoo choi 919afe56f2 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>
2025-10-23 14:30:25 +09:00

573 lines
16 KiB
TypeScript

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