From 513fb23a04c3f278123032d48efe141616c81507 Mon Sep 17 00:00:00 2001 From: Astersa Date: Sun, 15 Mar 2026 15:02:34 +0700 Subject: [PATCH] Feat: Apply AI --- src/app/(tabs)/profile.jsx | 20 +- src/app/story/chapter/[id].jsx | 35 ++- src/features/chapters/api.js | 10 + .../chapters/reader/ReaderChatbot.jsx | 260 ++++++++++++++++++ src/i18n/locales/en.js | 20 +- src/i18n/locales/vi.js | 12 + src/navigation/AppNavigator.jsx | 1 + src/services/api/endpoints.js | 1 + 8 files changed, 348 insertions(+), 11 deletions(-) create mode 100644 src/features/chapters/reader/ReaderChatbot.jsx diff --git a/src/app/(tabs)/profile.jsx b/src/app/(tabs)/profile.jsx index 1933a8c..f7d2c4a 100644 --- a/src/app/(tabs)/profile.jsx +++ b/src/app/(tabs)/profile.jsx @@ -17,7 +17,7 @@ import { authApi } from '../../features/auth/api'; import { useSettings } from '../../features/settings/hooks'; import RegisterScreen from '../(auth)/register'; import LoginScreen from '../(auth)/login'; -import { useNavigation } from '@react-navigation/native'; +import { useNavigation, useRoute } from '@react-navigation/native'; // ---------- Unauthenticated view ---------- function GuestView() { @@ -79,11 +79,11 @@ function UserProfile() { const initials = user?.username || user?.name ? (user.username || user.name) - .split(' ') - .map((w) => w[0]) - .slice(0, 2) - .join('') - .toUpperCase() + .split(' ') + .map((w) => w[0]) + .slice(0, 2) + .join('') + .toUpperCase() : '?'; return ( @@ -311,6 +311,14 @@ function makeStyles(colors) { export default function ProfileTab() { const { isAuthenticated } = useAuth(); const { colors } = useSettings(); + const navigation = useNavigation(); + const route = useRoute(); + + React.useEffect(() => { + if (isAuthenticated && route.params?.goBack) { + navigation.goBack(); + } + }, [isAuthenticated]); return ( diff --git a/src/app/story/chapter/[id].jsx b/src/app/story/chapter/[id].jsx index 9580faf..ebb0b5f 100644 --- a/src/app/story/chapter/[id].jsx +++ b/src/app/story/chapter/[id].jsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState, useRef, useMemo } from 'react'; +import { useEffect, useState, useRef, useMemo } from 'react'; import { View, Text, @@ -21,6 +21,7 @@ import { getChaptersByComic, getChapterById } from '../../../features/chapters/a import { useRoute, useNavigation } from '@react-navigation/native'; import { updateReadingHistory } from '../../../features/bookmarks/api'; import { useAuth } from '../../../features/auth/hooks'; +import { ReaderChatbot } from '../../../features/chapters/reader/ReaderChatbot'; const { width } = Dimensions.get('window'); const ITEM_HEIGHT = 64; @@ -129,6 +130,24 @@ function makeStyles(colors) { chapterItemText: { fontSize: 15, color: colors.textSecondary }, chapterItemTextActive: { color: colors.primary, fontWeight: 'bold' }, activeIndicator: { color: colors.primary, fontSize: 20 }, + chatbotFab: { + position: 'absolute', + bottom: 90, + right: 20, + width: 48, + height: 48, + borderRadius: 24, + backgroundColor: colors.primary, + justifyContent: 'center', + alignItems: 'center', + zIndex: 20, + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.3, + shadowRadius: 4, + elevation: 6, + }, + chatbotFabText: { fontSize: 22 }, }); } @@ -149,6 +168,7 @@ export default function ChapterDetail() { const [chapterModal, setChapterModal] = useState(false); const [sortOrder, setSortOrder] = useState('asc'); const [imageLoading, setImageLoading] = useState({}); + const [chatbotVisible, setChatbotVisible] = useState(false); const chapterListRef = useRef(null); const uiAnim = useRef(new Animated.Value(1)).current; @@ -371,6 +391,19 @@ export default function ChapterDetail() { + {/* CHATBOT FAB */} + setChatbotVisible(true)}> + 🤖 + + + {/* CHATBOT MODAL */} + setChatbotVisible(false)} + comicId={comicId} + currentChapterNumber={chapter?.chapterNumber} + /> + {/* CHAPTER MODAL */} diff --git a/src/features/chapters/api.js b/src/features/chapters/api.js index a0bda2f..b25d95f 100644 --- a/src/features/chapters/api.js +++ b/src/features/chapters/api.js @@ -1,4 +1,5 @@ import { axiosInstance } from '../../services/api/axios'; +import { endpoints } from '../../services/api/endpoints'; export async function getChaptersByComic(comicId) { const response = await axiosInstance.get(`/api/chapters/comic/${comicId}`); @@ -9,3 +10,12 @@ export async function getChapterById(chapterId) { const response = await axiosInstance.get(`/api/chapters/${chapterId}`); return response.data; } + +export async function askReaderChatbot(comicId, message, currentChapterNumber) { + const { data } = await axiosInstance.post( + endpoints.readerChatbot(comicId), + { message, currentChapterNumber }, + { requiresAuth: true } + ); + return data; +} diff --git a/src/features/chapters/reader/ReaderChatbot.jsx b/src/features/chapters/reader/ReaderChatbot.jsx new file mode 100644 index 0000000..b5388f7 --- /dev/null +++ b/src/features/chapters/reader/ReaderChatbot.jsx @@ -0,0 +1,260 @@ +import { useState, useRef, useEffect } from 'react'; +import { + View, + Text, + Modal, + TouchableOpacity, + TextInput, + ScrollView, + ActivityIndicator, + StyleSheet, + KeyboardAvoidingView, + Platform, +} from 'react-native'; +import { useTranslation } from 'react-i18next'; +import { useSettings } from '../../settings/hooks'; +import { useAuth } from '../../auth/hooks'; +import { askReaderChatbot } from '../api'; +import { useNavigation } from '@react-navigation/native'; + +export function ReaderChatbot({ visible, onClose, comicId, currentChapterNumber }) { + const { colors, language } = useSettings(); + const { isAuthenticated } = useAuth(); + const { t } = useTranslation(); + const styles = makeStyles(colors); + const navigation = useNavigation(); + + const handleLoginPress = () => { + onClose(); + navigation.navigate('Profile', { goBack: true }); + }; + + + const [input, setInput] = useState(''); + const [messages, setMessages] = useState([]); + const [loading, setLoading] = useState(false); + const scrollRef = useRef(null); + + useEffect(() => { + if (messages.length > 0) { + setTimeout(() => scrollRef.current?.scrollToEnd({ animated: true }), 100); + } + }, [messages]); + + const sendMessage = async (text) => { + const question = text ?? input.trim(); + if (!question || loading) return; + setInput(''); + setLoading(true); + try { + const data = await askReaderChatbot(comicId, question, currentChapterNumber); + setMessages((prev) => [...prev, { question, response: data.responses }]); + } catch { + setMessages((prev) => [ + ...prev, + { + question, + response: { + vi: t('story.chatbot.errorMessage'), + en: t('story.chatbot.errorMessage'), + }, + }, + ]); + } finally { + setLoading(false); + } + }; + const samples = t('story.chatbot.samples', { returnObjects: true }); + + + return ( + + + + + {/* Header */} + + {t('story.chatbot.title')} + + + + + + {/* Messages / Quick questions */} + + {!isAuthenticated && ( + + + {t('home.loginRequiredPrefix')} + + {t('home.loginRequiredLink')} + + {t('home.loginRequiredSuffix')} + + + )} + + {isAuthenticated && messages.length === 0 && ( + + {t('story.chatbot.hint')} + {Array.isArray(samples) && + samples.map((q, i) => ( + sendMessage(q)}> + {q} + + ))} + + )} + + {isAuthenticated && + messages.map((msg, i) => ( + + + {msg.question} + + + + {language === 'vi' ? msg.response.vi : msg.response.en} + + + + ))} + + {isAuthenticated && loading && ( + + + {t('story.chatbot.thinking')} + + )} + + + {/* Input row */} + {isAuthenticated && ( + + + sendMessage()} + disabled={!input.trim() || loading} + > + + + + )} + + + + ); +} + +function makeStyles(colors) { + return StyleSheet.create({ + overlay: { flex: 1, justifyContent: 'flex-end' }, + backdrop: { ...StyleSheet.absoluteFillObject, backgroundColor: 'rgba(0,0,0,0.5)' }, + sheet: { + backgroundColor: colors.surface, + borderTopLeftRadius: 20, + borderTopRightRadius: 20, + maxHeight: '80%', + minHeight: '65%', + }, + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + padding: 16, + borderBottomWidth: 1, + borderBottomColor: colors.border, + }, + headerTitle: { fontSize: 17, fontWeight: '700', color: colors.text }, + closeBtn: { fontSize: 18, color: colors.textSecondary, paddingHorizontal: 4 }, + body: { flex: 1 }, + bodyContent: { padding: 16, gap: 12 }, + loginRequiredContainer: { alignItems: 'center', paddingHorizontal: 12 }, + loginRequiredText: { color: colors.textSecondary, fontSize: 14, textAlign: 'center' }, + loginRequiredLink: { color: colors.primary, fontWeight: '600' }, + hint: { color: colors.textSecondary, fontSize: 14, marginBottom: 12 }, + sample: { + backgroundColor: colors.card, + borderRadius: 12, + padding: 12, + marginBottom: 8, + borderWidth: 1, + borderColor: colors.border, + }, + sampleText: { color: colors.primary, fontSize: 14 }, + messageGroup: { gap: 8, marginBottom: 8 }, + questionBubble: { + alignSelf: 'flex-end', + backgroundColor: colors.primary, + borderRadius: 14, + borderBottomRightRadius: 4, + padding: 10, + maxWidth: '80%', + }, + questionText: { color: '#fff', fontSize: 14 }, + responseBubble: { + alignSelf: 'flex-start', + backgroundColor: colors.card, + borderRadius: 14, + borderBottomLeftRadius: 4, + padding: 10, + maxWidth: '90%', + borderWidth: 1, + borderColor: colors.border, + }, + responseText: { color: colors.text, fontSize: 14, lineHeight: 20 }, + loadingRow: { flexDirection: 'row', alignItems: 'center', gap: 8 }, + loadingText: { color: colors.textSecondary, fontSize: 14 }, + inputRow: { + flexDirection: 'row', + alignItems: 'flex-end', + padding: 12, + borderTopWidth: 1, + borderTopColor: colors.border, + gap: 8, + marginBottom: 20, + }, + input: { + flex: 1, + backgroundColor: colors.card, + borderRadius: 20, + paddingHorizontal: 14, + paddingVertical: 8, + color: colors.text, + fontSize: 14, + maxHeight: 100, + borderWidth: 1, + borderColor: colors.border, + }, + sendBtn: { + backgroundColor: colors.primary, + width: 40, + height: 40, + borderRadius: 20, + justifyContent: 'center', + alignItems: 'center', + }, + sendBtnDisabled: { backgroundColor: colors.border }, + sendBtnText: { color: '#fff', fontSize: 16 }, + }); +} diff --git a/src/i18n/locales/en.js b/src/i18n/locales/en.js index dfb6fbf..07b69cb 100644 --- a/src/i18n/locales/en.js +++ b/src/i18n/locales/en.js @@ -56,10 +56,10 @@ recommended: 'Recommended', recentlyRead: 'Recently Read', popular: 'Popular', - loginRequired: 'Sign in to see this feature', + loginRequired: 'Sign in to have this feature', loginRequiredPrefix: '', loginRequiredLink: 'Sign in', - loginRequiredSuffix: ' to see this feature', + loginRequiredSuffix: ' to have this feature', noHistory: 'No reading history yet', goBack: 'Go Back', filter: { @@ -83,12 +83,12 @@ }, favorites: { loading: 'Loading favorites...', - loginRequired: 'Sign in to see your followed comics.', + loginRequired: 'Sign in to have this feature', empty: "You haven't followed any comics yet.", }, history: { loading: 'Loading history...', - loginRequired: 'Sign in to see your reading history.', + loginRequired: 'Sign in to have this feature', empty: 'Your history is empty.', deleteTitle: 'Delete History', deleteConfirm: 'Remove "{{title}}" from reading list?', @@ -142,6 +142,18 @@ prev: '❮ Previous', next: 'Next ❯', }, + chatbot: { + title: 'AI Reader Assistant', + hint: 'Ask AI about this manga:', + placeholder: 'Type your question...', + thinking: 'Thinking...', + errorMessage: 'Something went wrong. Please try again.', + samples: [ + 'What happened last chapter?', + 'What happened from the beginning until current chapter?', + 'Summarize the content of this manga', + ], + }, notFound: 'Comic not found', readContinue: 'Read', status: { diff --git a/src/i18n/locales/vi.js b/src/i18n/locales/vi.js index 0527f01..f69ca8b 100644 --- a/src/i18n/locales/vi.js +++ b/src/i18n/locales/vi.js @@ -142,6 +142,18 @@ prev: '❮ Trước', next: 'Sau ❯', }, + chatbot: { + title: 'AI Trợ Lý Đọc Truyện', + hint: 'Hỏi AI về bộ truyện này:', + placeholder: 'Nhập câu hỏi...', + thinking: 'Đang suy nghĩ...', + errorMessage: 'Có lỗi xảy ra. Vui lòng thử lại.', + samples: [ + 'Chương trước có chuyện gì xảy ra?', + 'Tóm tắt từ đầu đến chương hiện tại', + 'Tóm tắt nội dung bộ manga này', + ], + }, notFound: 'Không tìm thấy truyện', readContinue: 'Đọc tiếp', status: { diff --git a/src/navigation/AppNavigator.jsx b/src/navigation/AppNavigator.jsx index 7307a40..78d95d3 100644 --- a/src/navigation/AppNavigator.jsx +++ b/src/navigation/AppNavigator.jsx @@ -73,6 +73,7 @@ const AppNavigator = () => { + ); }; diff --git a/src/services/api/endpoints.js b/src/services/api/endpoints.js index 774da5f..1254ee5 100644 --- a/src/services/api/endpoints.js +++ b/src/services/api/endpoints.js @@ -6,4 +6,5 @@ export const endpoints = { }, readingHistory: '/api/reading-history', genres: '/api/genres', + readerChatbot: (comicId) => `/api/comics/${comicId}/reader-chatbot`, };