Files
sapiens-mobile/sapiense-ai-app/components/CommentsDrawer.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

453 lines
12 KiB
TypeScript

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