| import { useEffect, useRef, useState } from 'react'
|
| import SearchBox from './components/SearchBox'
|
| import FilterBar from './components/FilterBar'
|
| import NewsArticle from './components/NewsArticle'
|
| import SummaryBox from './components/SummaryBox'
|
| import HomeNewsGrid from './components/HomeNewsGrid'
|
| import apiService from './services/api.service'
|
| import './App.css'
|
|
|
| const HISTORY_KEY = 'newsai-search-history'
|
| const DAILY_SNAPSHOT_KEY = 'newsai-daily-snapshot'
|
| const MAX_HISTORY_ITEMS = 6
|
| const SUMMARY_TTS_STORAGE_KEY = 'newsai-summary-tts-jobs'
|
| const ARTICLE_TTS_STORAGE_KEY = 'newsai-article-tts-jobs'
|
| const ACTIVE_TTS_STATUSES = new Set(['queued', 'processing'])
|
|
|
| const createIdleTtsState = () => ({
|
| key: '',
|
| status: 'idle',
|
| audioUrl: '',
|
| error: '',
|
| createdAt: '',
|
| updatedAt: '',
|
| })
|
|
|
| const normalizeTtsState = (value) => {
|
| if (!value || typeof value !== 'object') return createIdleTtsState()
|
|
|
| return {
|
| key: typeof value.key === 'string' ? value.key : '',
|
| status: typeof value.status === 'string' ? value.status : 'idle',
|
| audioUrl: typeof value.audioUrl === 'string' ? value.audioUrl : '',
|
| error: typeof value.error === 'string' ? value.error : '',
|
| createdAt: typeof value.createdAt === 'string' ? value.createdAt : '',
|
| updatedAt: typeof value.updatedAt === 'string' ? value.updatedAt : '',
|
| }
|
| }
|
|
|
| const normalizeTtsStateMap = (value) => {
|
| if (!value || typeof value !== 'object' || Array.isArray(value)) return {}
|
|
|
| return Object.entries(value).reduce((accumulator, [key, state]) => {
|
| if (!key || typeof key !== 'string') return accumulator
|
| accumulator[key] = normalizeTtsState(state)
|
| return accumulator
|
| }, {})
|
| }
|
|
|
| const loadTtsStateMapFromStorage = (storageKey) => {
|
| if (typeof window === 'undefined') return {}
|
|
|
| try {
|
| const raw = window.localStorage.getItem(storageKey)
|
| if (!raw) return {}
|
| const parsed = JSON.parse(raw)
|
| return normalizeTtsStateMap(parsed)
|
| } catch {
|
| return {}
|
| }
|
| }
|
|
|
| const saveTtsStateMapToStorage = (storageKey, value) => {
|
| if (typeof window === 'undefined') return
|
|
|
| try {
|
| window.localStorage.setItem(storageKey, JSON.stringify(value))
|
| } catch {
|
|
|
| }
|
| }
|
|
|
| const getSummarySignature = (text = '', voice = '') => (text.trim() + voice).toLowerCase().replace(/\s+/g, ' ').slice(0, 280)
|
|
|
| const getArticleTtsSlot = (articleUrl, index) => {
|
| if (typeof articleUrl === 'string' && articleUrl.trim() && articleUrl !== '#') {
|
| return articleUrl
|
| }
|
| return `local-article-${index}`
|
| }
|
|
|
| const toTtsStateFromJob = (job, fallback = createIdleTtsState()) => {
|
| return {
|
| key: typeof job?.key === 'string' ? job.key : fallback.key,
|
| status: typeof job?.status === 'string' ? job.status : fallback.status,
|
| audioUrl: typeof job?.audioUrl === 'string' ? job.audioUrl : fallback.audioUrl,
|
| error: typeof job?.error === 'string' ? job.error : '',
|
| createdAt: typeof job?.createdAt === 'string' ? job.createdAt : fallback.createdAt,
|
| updatedAt: typeof job?.updatedAt === 'string' ? job.updatedAt : fallback.updatedAt,
|
| }
|
| }
|
|
|
| const isSameTtsState = (left, right) => {
|
| if (!left && !right) return true
|
| if (!left || !right) return false
|
|
|
| return (
|
| left.key === right.key &&
|
| left.status === right.status &&
|
| left.audioUrl === right.audioUrl &&
|
| left.error === right.error &&
|
| left.createdAt === right.createdAt &&
|
| left.updatedAt === right.updatedAt
|
| )
|
| }
|
|
|
| const getTtsErrorMessage = (error, fallbackMessage) => {
|
| return error?.response?.data?.error || error?.response?.data?.details || error?.message || fallbackMessage
|
| }
|
|
|
| const getLocalDayKey = (date = new Date()) => {
|
| const year = date.getFullYear();
|
| const month = String(date.getMonth() + 1).padStart(2, '0');
|
| const day = String(date.getDate()).padStart(2, '0');
|
| return `${year}-${month}-${day}`;
|
| };
|
|
|
| const formatDateForQuery = (date = new Date()) => {
|
| const day = String(date.getDate()).padStart(2, '0');
|
| const month = String(date.getMonth() + 1).padStart(2, '0');
|
| const year = date.getFullYear();
|
| return `${day}/${month}/${year}`;
|
| };
|
|
|
| const getDailyAutoQuery = (date = new Date()) => `tin tức việt nam ${formatDateForQuery(date)}`;
|
|
|
| const createHistoryId = () => {
|
| if (globalThis.crypto?.randomUUID) {
|
| return globalThis.crypto.randomUUID();
|
| }
|
| return `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
| };
|
|
|
| const normalizeHistoryEntry = (entry, index) => {
|
| if (typeof entry === 'string') {
|
| const query = entry.trim();
|
| if (!query) return null;
|
|
|
| return {
|
| id: `legacy-${index}-${query.toLowerCase().replace(/\s+/g, '-')}`,
|
| query,
|
| createdAt: new Date(0).toISOString(),
|
| dayKey: '',
|
| autoDaily: false,
|
| filters: {
|
| voice: 'Bắc',
|
| time: 'pd',
|
| source: 'all',
|
| },
|
| articles: [],
|
| summary: '',
|
| summaryReady: false,
|
| totalArticles: 0,
|
| };
|
| }
|
|
|
| if (!entry || typeof entry !== 'object') return null;
|
|
|
| const query = typeof entry.query === 'string' ? entry.query.trim() : '';
|
| if (!query) return null;
|
|
|
| return {
|
| id: typeof entry.id === 'string' && entry.id ? entry.id : createHistoryId(),
|
| query,
|
| createdAt: typeof entry.createdAt === 'string' ? entry.createdAt : new Date().toISOString(),
|
| dayKey: typeof entry.dayKey === 'string' ? entry.dayKey : '',
|
| autoDaily: Boolean(entry.autoDaily),
|
| filters: {
|
| voice: entry.filters?.voice || 'Bắc',
|
| time: entry.filters?.time || 'pd',
|
| source: entry.filters?.source || 'all',
|
| },
|
| articles: Array.isArray(entry.articles) ? entry.articles : [],
|
| summary: typeof entry.summary === 'string' ? entry.summary : '',
|
| summaryReady:
|
| typeof entry.summaryReady === 'boolean'
|
| ? entry.summaryReady
|
| : Boolean(typeof entry.summary === 'string' && entry.summary.trim().length > 0),
|
| totalArticles: Number.isFinite(entry.totalArticles) ? entry.totalArticles : 0,
|
| };
|
| };
|
|
|
| const loadHistoryFromStorage = () => {
|
| if (typeof window === 'undefined') return [];
|
|
|
| try {
|
| const rawHistory = window.localStorage.getItem(HISTORY_KEY);
|
| if (!rawHistory) return [];
|
|
|
| const parsedHistory = JSON.parse(rawHistory);
|
| if (!Array.isArray(parsedHistory)) return [];
|
|
|
| return parsedHistory
|
| .map((entry, index) => normalizeHistoryEntry(entry, index))
|
| .filter((entry) => entry && !entry.autoDaily)
|
| .slice(0, MAX_HISTORY_ITEMS);
|
| } catch {
|
| return [];
|
| }
|
| };
|
|
|
| const loadDailySnapshotFromStorage = () => {
|
| if (typeof window === 'undefined') return null;
|
|
|
| try {
|
| const rawSnapshot = window.localStorage.getItem(DAILY_SNAPSHOT_KEY);
|
| if (!rawSnapshot) return null;
|
|
|
| const parsedSnapshot = JSON.parse(rawSnapshot);
|
| const normalizedSnapshot = normalizeHistoryEntry(parsedSnapshot, 0);
|
| if (!normalizedSnapshot) return null;
|
|
|
| return {
|
| ...normalizedSnapshot,
|
| autoDaily: true,
|
| };
|
| } catch {
|
| return null;
|
| }
|
| };
|
|
|
| const saveDailySnapshotToStorage = (entry) => {
|
| if (typeof window === 'undefined') return;
|
|
|
| try {
|
| if (!entry) {
|
| window.localStorage.removeItem(DAILY_SNAPSHOT_KEY);
|
| return;
|
| }
|
|
|
| window.localStorage.setItem(DAILY_SNAPSHOT_KEY, JSON.stringify(entry));
|
| } catch {
|
|
|
| }
|
| };
|
|
|
| function App() {
|
| const [loading, setLoading] = useState(false);
|
| const [summaryLoading, setSummaryLoading] = useState(false);
|
| const [error, setError] = useState('');
|
| const [articles, setArticles] = useState([]);
|
| const [summary, setSummary] = useState(null);
|
| const [totalArticles, setTotalArticles] = useState(0);
|
|
|
|
|
| const [articleSummaries, setArticleSummaries] = useState({});
|
| const [articleSummaryLoading, setArticleSummaryLoading] = useState({});
|
|
|
|
|
| const [voice, setVoice] = useState('Bắc');
|
| const [time, setTime] = useState('pd');
|
| const [source, setSource] = useState('all');
|
| const [searchQuery, setSearchQuery] = useState('');
|
| const [searchHistory, setSearchHistory] = useState([]);
|
| const [historyReady, setHistoryReady] = useState(false);
|
| const [summaryTtsJobs, setSummaryTtsJobs] = useState(() => loadTtsStateMapFromStorage(SUMMARY_TTS_STORAGE_KEY));
|
| const [articleTtsJobs, setArticleTtsJobs] = useState(() => loadTtsStateMapFromStorage(ARTICLE_TTS_STORAGE_KEY));
|
| const [isMobileSummaryOpen, setIsMobileSummaryOpen] = useState(false);
|
| const [useDailyHomeLayout, setUseDailyHomeLayout] = useState(true);
|
| const hasBootstrappedRef = useRef(false);
|
| const handleSearchRef = useRef(() => {});
|
|
|
| const closeMobileSummaryPanel = () => {
|
| setIsMobileSummaryOpen(false);
|
| };
|
|
|
| const toggleMobileSummaryPanel = () => {
|
| setIsMobileSummaryOpen((previous) => !previous);
|
| };
|
|
|
| useEffect(() => {
|
| if (!historyReady || typeof window === 'undefined') return;
|
|
|
| try {
|
| const manualHistory = searchHistory.filter((entry) => !entry.autoDaily);
|
| window.localStorage.setItem(HISTORY_KEY, JSON.stringify(manualHistory));
|
| } catch {
|
|
|
| }
|
| }, [searchHistory, historyReady]);
|
|
|
| useEffect(() => {
|
| saveTtsStateMapToStorage(SUMMARY_TTS_STORAGE_KEY, summaryTtsJobs);
|
| }, [summaryTtsJobs]);
|
|
|
| useEffect(() => {
|
| saveTtsStateMapToStorage(ARTICLE_TTS_STORAGE_KEY, articleTtsJobs);
|
| }, [articleTtsJobs]);
|
|
|
| useEffect(() => {
|
| const pendingSummaryJobs = Object.entries(summaryTtsJobs).filter(
|
| ([, state]) => state.key && ACTIVE_TTS_STATUSES.has(state.status)
|
| );
|
| const pendingArticleJobs = Object.entries(articleTtsJobs).filter(
|
| ([, state]) => state.key && ACTIVE_TTS_STATUSES.has(state.status)
|
| );
|
|
|
| if (pendingSummaryJobs.length === 0 && pendingArticleJobs.length === 0) {
|
| return;
|
| }
|
|
|
| let canceled = false;
|
| let pollTimer = null;
|
|
|
| const fetchJobState = async (state) => {
|
| try {
|
| const job = await apiService.getTtsJob(state.key);
|
| return toTtsStateFromJob(job, state);
|
| } catch (error) {
|
| if (error?.response?.status === 404) {
|
| return {
|
| ...state,
|
| status: 'failed',
|
| error: 'Không tìm thấy tiến trình TTS trên server.',
|
| };
|
| }
|
|
|
| return state;
|
| }
|
| };
|
|
|
| const pollPendingJobs = async () => {
|
| const [summaryUpdates, articleUpdates] = await Promise.all([
|
| Promise.all(
|
| pendingSummaryJobs.map(async ([slot, state]) => {
|
| const nextState = await fetchJobState(state);
|
| return [slot, nextState];
|
| })
|
| ),
|
| Promise.all(
|
| pendingArticleJobs.map(async ([slot, state]) => {
|
| const nextState = await fetchJobState(state);
|
| return [slot, nextState];
|
| })
|
| ),
|
| ]);
|
|
|
| if (canceled) return;
|
|
|
| if (summaryUpdates.length > 0) {
|
| setSummaryTtsJobs((previous) => {
|
| let changed = false;
|
| const next = { ...previous };
|
|
|
| summaryUpdates.forEach(([slot, nextState]) => {
|
| const currentState = previous[slot];
|
| if (!currentState || isSameTtsState(currentState, nextState)) return;
|
| next[slot] = normalizeTtsState(nextState);
|
| changed = true;
|
| });
|
|
|
| return changed ? next : previous;
|
| });
|
| }
|
|
|
| if (articleUpdates.length > 0) {
|
| setArticleTtsJobs((previous) => {
|
| let changed = false;
|
| const next = { ...previous };
|
|
|
| articleUpdates.forEach(([slot, nextState]) => {
|
| const currentState = previous[slot];
|
| if (!currentState || isSameTtsState(currentState, nextState)) return;
|
| next[slot] = normalizeTtsState(nextState);
|
| changed = true;
|
| });
|
|
|
| return changed ? next : previous;
|
| });
|
| }
|
|
|
| pollTimer = window.setTimeout(pollPendingJobs, 2200);
|
| };
|
|
|
| pollPendingJobs();
|
|
|
| return () => {
|
| canceled = true;
|
| if (pollTimer) {
|
| window.clearTimeout(pollTimer);
|
| }
|
| };
|
| }, [summaryTtsJobs, articleTtsJobs]);
|
|
|
| const mapBraveResultToArticle = (result) => {
|
|
|
| const stripHtml = (html) => {
|
| if (!html) return '';
|
| const tmp = document.createElement('DIV');
|
| tmp.innerHTML = html;
|
| return tmp.textContent || tmp.innerText || '';
|
| };
|
|
|
|
|
|
|
| const SUBTYPE_MAPPING = {
|
|
|
| article: { name: 'Tin tức', tone: 'news' },
|
| news: { name: 'Thời sự', tone: 'news' },
|
|
|
|
|
| product: { name: 'Mua sắm', tone: 'commerce' },
|
|
|
|
|
| recipe: { name: 'Ẩm thực', tone: 'culture' },
|
|
|
|
|
| qa: { name: 'Hỏi đáp', tone: 'discussion' },
|
| discussion: { name: 'Thảo luận', tone: 'discussion' },
|
|
|
|
|
| review: { name: 'Review', tone: 'review' },
|
|
|
|
|
| video_result: { name: 'Video', tone: 'video' },
|
|
|
|
|
| movie: { name: 'Phim ảnh', tone: 'culture' },
|
|
|
|
|
| generic: { name: 'Kết quả', tone: 'generic' },
|
| };
|
|
|
| const getCategoryStyle = (subtype) => {
|
| const key = subtype?.toLowerCase() || 'generic';
|
| return SUBTYPE_MAPPING[key] || SUBTYPE_MAPPING.generic;
|
| };
|
|
|
| const style = getCategoryStyle(result.subtype);
|
|
|
| return {
|
| category: style.name,
|
| categoryTone: style.tone,
|
| source: result.profile?.name || result.meta_url?.hostname || 'Unknown',
|
| timeAgo: result.age || "",
|
| title: stripHtml(result.title) || 'Không có tiêu đề',
|
| description: stripHtml(result.description) || '',
|
| imageUrl: result.thumbnail?.src || result.thumbnail?.original || 'https://placehold.co/400x300?text=No+Image',
|
| imageAlt: stripHtml(result.title) || 'News image',
|
| articleUrl: result.url || '#'
|
| };
|
| };
|
|
|
| const applyHistorySnapshot = (entry) => {
|
| setError('');
|
| setLoading(false);
|
| setSummaryLoading(false);
|
| setArticleSummaries({});
|
| setArticleSummaryLoading({});
|
|
|
| setArticles(Array.isArray(entry.articles) ? entry.articles : []);
|
| setSummary(entry.summary || null);
|
| setTotalArticles(entry.totalArticles || entry.articles?.length || 0);
|
| };
|
|
|
| const upsertHistoryEntry = ({ query, articles: nextArticles, summary: nextSummary, totalCount, autoDaily = false }) => {
|
| const normalizedQuery = query.trim();
|
| if (!normalizedQuery || !Array.isArray(nextArticles) || nextArticles.length === 0) return;
|
|
|
| const entry = {
|
| id: createHistoryId(),
|
| query: normalizedQuery,
|
| createdAt: new Date().toISOString(),
|
| dayKey: getLocalDayKey(),
|
| autoDaily,
|
| filters: {
|
| voice,
|
| time,
|
| source,
|
| },
|
| articles: nextArticles,
|
| summary: nextSummary || '',
|
| summaryReady: Boolean(nextSummary && nextSummary.trim().length > 0),
|
| totalArticles: totalCount || nextArticles.length,
|
| };
|
|
|
| if (autoDaily) {
|
| saveDailySnapshotToStorage(entry);
|
| return;
|
| }
|
|
|
| setSearchHistory((previous) => {
|
| return [entry, ...previous].slice(0, MAX_HISTORY_ITEMS);
|
| });
|
| };
|
|
|
| const handleHistorySelect = (entry) => {
|
| if (!entry) return;
|
|
|
| const selectedEntry = searchHistory.find((item) => item.id === entry.id) || entry;
|
| const hasSnapshot = (selectedEntry.articles?.length || 0) > 0 || Boolean(selectedEntry.summary);
|
| const shouldUseHomeLayout = Boolean(selectedEntry.autoDaily && selectedEntry.dayKey === getLocalDayKey());
|
| setUseDailyHomeLayout(shouldUseHomeLayout);
|
|
|
| setSearchQuery(selectedEntry.query || '');
|
| setVoice(selectedEntry.filters?.voice || 'Bắc');
|
| setTime(selectedEntry.filters?.time || 'pd');
|
| setSource(selectedEntry.filters?.source || 'all');
|
| closeMobileSummaryPanel();
|
|
|
| if (hasSnapshot) {
|
| applyHistorySnapshot(selectedEntry);
|
|
|
| setSearchHistory((previous) => {
|
| const filtered = previous.filter((item) => item.id !== selectedEntry.id);
|
| return [selectedEntry, ...filtered].slice(0, MAX_HISTORY_ITEMS);
|
| });
|
| return;
|
| }
|
|
|
| handleSearch(selectedEntry.query, { autoDaily: false });
|
| };
|
|
|
| const handleArticleSummarize = async (articleUrl, index) => {
|
| const currentSummary = articleSummaries[index];
|
|
|
|
|
| if (currentSummary?.content) {
|
| setArticleSummaries(prev => ({
|
| ...prev,
|
| [index]: {
|
| ...prev[index],
|
| visible: !prev[index].visible
|
| }
|
| }));
|
| return;
|
| }
|
|
|
|
|
| setArticleSummaryLoading(prev => ({ ...prev, [index]: true }));
|
| const articleTtsSlot = getArticleTtsSlot(articleUrl, index);
|
|
|
| try {
|
| const data = await apiService.scrape(articleUrl);
|
| const summaryData = await apiService.summarizeNews(data.text, data.title);
|
|
|
| setArticleSummaries(prev => ({
|
| ...prev,
|
| [index]: {
|
| content: summaryData.summary,
|
| visible: true
|
| }
|
| }));
|
|
|
| setArticleTtsJobs((previous) => {
|
| if (!previous[articleTtsSlot]) return previous;
|
| const next = { ...previous };
|
| delete next[articleTtsSlot];
|
| return next;
|
| });
|
| } catch (err) {
|
| console.error('Article summarize error:', err);
|
| setArticleSummaries(prev => ({
|
| ...prev,
|
| [index]: {
|
| content: 'Không thể tóm tắt bài viết này: ' + (err.response?.data?.error || err.message),
|
| visible: true
|
| }
|
| }));
|
| } finally {
|
| setArticleSummaryLoading(prev => ({ ...prev, [index]: false }));
|
| }
|
| };
|
|
|
| const handleGenerateSummaryTts = async () => {
|
| const summaryText = typeof summary === 'string' ? summary.trim() : '';
|
| if (!summaryText) return;
|
|
|
| const signature = getSummarySignature(summaryText, voice);
|
| if (!signature) return;
|
|
|
| const currentState = summaryTtsJobs[signature] || createIdleTtsState();
|
| if (ACTIVE_TTS_STATUSES.has(currentState.status)) return;
|
|
|
| const VOICE_MAP = {
|
| 'Bắc': 'nam_bac.wav',
|
| 'Nam': 'nu_nam.wav',
|
| 'Trung': 'nam_trung.wav'
|
| };
|
|
|
| setSummaryTtsJobs((previous) => ({
|
| ...previous,
|
| [signature]: {
|
| ...normalizeTtsState(previous[signature]),
|
| status: 'queued',
|
| audioUrl: '',
|
| error: '',
|
| },
|
| }));
|
|
|
| try {
|
| const speakerAudio = VOICE_MAP[voice] || 'nam_bac.wav';
|
| const createdJob = await apiService.createTtsJob(summaryText, {
|
| language: 'vi',
|
| speakerAudio
|
| });
|
|
|
| if (!createdJob?.key) {
|
| throw new Error('Không nhận được key TTS từ server.');
|
| }
|
|
|
| setSummaryTtsJobs((previous) => ({
|
| ...previous,
|
| [signature]: {
|
| ...normalizeTtsState(previous[signature]),
|
| key: createdJob.key,
|
| status: createdJob.status || 'queued',
|
| createdAt: createdJob.createdAt || previous[signature]?.createdAt || '',
|
| error: '',
|
| },
|
| }));
|
| } catch (err) {
|
| setSummaryTtsJobs((previous) => ({
|
| ...previous,
|
| [signature]: {
|
| ...normalizeTtsState(previous[signature]),
|
| status: 'failed',
|
| error: getTtsErrorMessage(err, 'Không thể tạo audio cho bản tóm tắt.'),
|
| },
|
| }));
|
| }
|
| };
|
|
|
| const handleGenerateArticleTts = async (articleUrl, index) => {
|
| const summaryText = articleSummaries[index]?.content?.trim();
|
| const slot = getArticleTtsSlot(articleUrl, index);
|
|
|
| if (!summaryText || summaryText.startsWith('Không thể tóm tắt')) {
|
| setArticleTtsJobs((previous) => ({
|
| ...previous,
|
| [slot]: {
|
| ...normalizeTtsState(previous[slot]),
|
| status: 'failed',
|
| error: 'Hãy tạo bản tóm tắt hợp lệ trước khi chuyển thành giọng nói.',
|
| audioUrl: '',
|
| },
|
| }));
|
| return;
|
| }
|
|
|
| const currentState = articleTtsJobs[slot] || createIdleTtsState();
|
| if (ACTIVE_TTS_STATUSES.has(currentState.status)) return;
|
|
|
| const VOICE_MAP = {
|
| 'Bắc': 'nam_bac.wav',
|
| 'Nam': 'nu_nam.wav',
|
| 'Trung': 'nam_trung.wav'
|
| };
|
|
|
| try {
|
| const speakerAudio = VOICE_MAP[voice] || 'nam_bac.wav';
|
| const createdJob = await apiService.createTtsJob(summaryText, {
|
| language: 'vi',
|
| speakerAudio
|
| });
|
|
|
| if (!createdJob?.key) {
|
| throw new Error('Không nhận được key TTS từ server.');
|
| }
|
|
|
| setArticleTtsJobs((previous) => ({
|
| ...previous,
|
| [slot]: {
|
| ...normalizeTtsState(previous[slot]),
|
| key: createdJob.key,
|
| status: createdJob.status || 'queued',
|
| createdAt: createdJob.createdAt || previous[slot]?.createdAt || '',
|
| error: '',
|
| },
|
| }));
|
| } catch (err) {
|
| setArticleTtsJobs((previous) => ({
|
| ...previous,
|
| [slot]: {
|
| ...normalizeTtsState(previous[slot]),
|
| status: 'failed',
|
| error: getTtsErrorMessage(err, 'Không thể tạo audio cho bài viết này.'),
|
| },
|
| }));
|
| }
|
| };
|
|
|
| const handleDailyCardSummarize = async (articleUrl) => {
|
| if (!articleUrl || articleUrl === '#') return;
|
|
|
| setError('');
|
| setSummaryLoading(true);
|
|
|
| try {
|
| const data = await apiService.scrape(articleUrl);
|
| const summaryData = await apiService.summarizeNews(data.text, data.title);
|
| setSummary(summaryData.summary || '');
|
| setTotalArticles(1);
|
| setIsMobileSummaryOpen(true);
|
| } catch (err) {
|
| setError('Không thể tóm tắt bài viết này: ' + (err.response?.data?.error || err.message));
|
| } finally {
|
| setSummaryLoading(false);
|
| }
|
| };
|
|
|
| const handleSearch = async (rawQuery = searchQuery, options = {}) => {
|
| const { autoDaily = false } = options;
|
| const query = rawQuery.trim();
|
| if (!query) return;
|
|
|
| closeMobileSummaryPanel();
|
| setUseDailyHomeLayout(Boolean(autoDaily));
|
| setSearchQuery(query);
|
|
|
| setLoading(true);
|
| setSummaryLoading(false);
|
| setError('');
|
| setSummary(null);
|
| setTotalArticles(0);
|
| setArticles([]);
|
| setArticleSummaries({});
|
| setArticleSummaryLoading({});
|
| const isUrl = /^https?:\/\//i.test(query);
|
| let nextArticles = [];
|
| let nextSummary = '';
|
| let nextTotalArticles = 0;
|
|
|
| try {
|
| if (isUrl) {
|
|
|
| const data = await apiService.scrape(query);
|
| console.log('Scraped data:', data);
|
|
|
|
|
| const article = {
|
| category: "Tin tức",
|
| categoryTone: 'news',
|
| source: new URL(query).hostname,
|
| timeAgo: "Vừa xong",
|
| title: data.title || 'Không có tiêu đề',
|
| description: data.text?.substring(0, 200) + '...' || '',
|
| imageUrl: 'https://placehold.co/400x300?text=No+Image',
|
| imageAlt: data.title || 'Scraped content',
|
| articleUrl: query
|
| };
|
|
|
| nextArticles = [article];
|
| setArticles(nextArticles);
|
| setLoading(false);
|
| setSummaryLoading(true);
|
|
|
|
|
| try {
|
| const summaryData = await apiService.summarizeNews(data.text, data.title);
|
| nextSummary = summaryData.summary || '';
|
| nextTotalArticles = 1;
|
| setSummary(nextSummary || null);
|
| setTotalArticles(1);
|
| } catch (err) {
|
| console.error('Summarize error:', err);
|
| setError('Không thể tóm tắt bài viết này.');
|
| } finally {
|
| setSummaryLoading(false);
|
| }
|
| } else {
|
|
|
| const searchOptions = {
|
| freshness: time,
|
| language: source === 'all' ? 'vi' : source
|
| };
|
|
|
| console.log('Searching...');
|
| const searchData = await apiService.search(query, searchOptions);
|
| console.log('Search results:', searchData);
|
|
|
|
|
| if (searchData.web?.family_friendly === false || searchData.news?.family_friendly === false) {
|
| setError('Kết quả tìm kiếm chứa nội dung không phù hợp. Vui lòng thử từ khóa khác.');
|
| setArticles([]);
|
| setLoading(false);
|
| return;
|
| }
|
|
|
|
|
| let urls = [];
|
| let hasUnsafeContent = false;
|
|
|
| if (searchData.news?.results && searchData.news.results.length > 0) {
|
|
|
| const unsafeResults = searchData.news.results.filter(r => r.family_friendly === false);
|
| if (unsafeResults.length > 0) {
|
| hasUnsafeContent = true;
|
| }
|
|
|
| const mappedArticles = searchData.news.results
|
| .filter(result => {
|
|
|
| if (result.family_friendly === false) {
|
| return false;
|
| }
|
|
|
| const isVideoType = result.type === 'video_result' || result.subtype === 'video';
|
| const hasVideo = result.video !== undefined;
|
| const isImage = result.type === 'image_result';
|
| const isVideoUrl = result.url?.includes('youtube.com') ||
|
| result.url?.includes('tiktok.com') ||
|
| result.url?.includes('youtu.be');
|
| return !isVideoType && !hasVideo && !isImage && !isVideoUrl;
|
| })
|
| .slice(0, 10)
|
| .map((result) => mapBraveResultToArticle(result));
|
|
|
|
|
| if (mappedArticles.length === 0) {
|
| if (hasUnsafeContent) {
|
| setError('Kết quả tìm kiếm chứa nội dung không phù hợp. Vui lòng thử từ khóa khác.');
|
| } else {
|
| setError('Không tìm thấy kết quả nào');
|
| }
|
| setArticles([]);
|
| setLoading(false);
|
| return;
|
| }
|
|
|
| nextArticles = mappedArticles;
|
| setArticles(nextArticles);
|
| urls = searchData.news.results
|
| .filter(result => {
|
| if (result.family_friendly === false) {
|
| return false;
|
| }
|
| const isVideoType = result.type === 'video_result' || result.subtype === 'video';
|
| const hasVideo = result.video !== undefined;
|
| const isImage = result.type === 'image_result';
|
| const isVideoUrl = result.url?.includes('youtube.com') ||
|
| result.url?.includes('tiktok.com') ||
|
| result.url?.includes('youtu.be');
|
| return !isVideoType && !hasVideo && !isImage && !isVideoUrl;
|
| })
|
| .slice(0, 10)
|
| .map(r => r.url);
|
| } else if (searchData.web?.results && searchData.web.results.length > 0) {
|
|
|
| const unsafeResults = searchData.web.results.filter(r => r.family_friendly === false);
|
| if (unsafeResults.length > 0) {
|
| hasUnsafeContent = true;
|
| }
|
|
|
| const mappedArticles = searchData.web.results
|
| .filter(result => {
|
|
|
| if (result.family_friendly === false) {
|
| return false;
|
| }
|
|
|
| const isSearchResult = result.type === 'search_result';
|
| const isVideoType = result.subtype === 'video';
|
| const hasVideo = result.video !== undefined;
|
| const isImage = result.type === 'image_result';
|
| const isVideoUrl = result.url?.includes('youtube.com') ||
|
| result.url?.includes('tiktok.com') ||
|
| result.url?.includes('youtu.be');
|
| return isSearchResult && !isVideoType && !hasVideo && !isImage && !isVideoUrl;
|
| })
|
| .slice(0, 10)
|
| .map((result) => mapBraveResultToArticle(result));
|
|
|
|
|
| if (mappedArticles.length === 0) {
|
| if (hasUnsafeContent) {
|
| setError('Kết quả tìm kiếm chứa nội dung không phù hợp. Vui lòng thử từ khóa khác.');
|
| } else {
|
| setError('Không tìm thấy kết quả nào');
|
| }
|
| setArticles([]);
|
| setLoading(false);
|
| return;
|
| }
|
|
|
| nextArticles = mappedArticles;
|
| setArticles(nextArticles);
|
| urls = searchData.web.results
|
| .filter(result => {
|
| if (result.family_friendly === false) {
|
| return false;
|
| }
|
| const isSearchResult = result.type === 'search_result';
|
| const isVideoType = result.subtype === 'video';
|
| const hasVideo = result.video !== undefined;
|
| const isImage = result.type === 'image_result';
|
| const isVideoUrl = result.url?.includes('youtube.com') ||
|
| result.url?.includes('tiktok.com') ||
|
| result.url?.includes('youtu.be');
|
| return isSearchResult && !isVideoType && !hasVideo && !isImage && !isVideoUrl;
|
| })
|
| .slice(0, 10)
|
| .map(r => r.url);
|
| } else {
|
| setError('Không tìm thấy kết quả nào');
|
| setArticles([]);
|
| setLoading(false);
|
| return;
|
| }
|
|
|
|
|
| setLoading(false);
|
| setSummaryLoading(true);
|
|
|
| try {
|
| console.log('Summarizing articles...');
|
| const data = await apiService.scrapeAndSummarize(urls, query);
|
| console.log('Summary results:', data);
|
|
|
| if (data.summary) {
|
| nextSummary = data.summary;
|
| nextTotalArticles = data.totalArticles || nextArticles.length;
|
| setSummary(nextSummary);
|
| setTotalArticles(nextTotalArticles);
|
| }
|
| } catch (err) {
|
| console.error('Summarize error:', err);
|
| setError('Không thể tóm tắt: ' + (err.response?.data?.error || err.message));
|
| } finally {
|
| setSummaryLoading(false);
|
| }
|
| }
|
|
|
| if (nextArticles.length > 0) {
|
| upsertHistoryEntry({
|
| query,
|
| articles: nextArticles,
|
| summary: nextSummary,
|
| totalCount: nextTotalArticles || nextArticles.length,
|
| autoDaily,
|
| });
|
| }
|
| } catch (err) {
|
| setError(err.response?.data?.error || 'Đã xảy ra lỗi khi xử lý yêu cầu');
|
| console.error('Error:', err);
|
| setLoading(false);
|
| setSummaryLoading(false);
|
| } finally {
|
|
|
| if (isUrl) {
|
| setLoading(false);
|
| }
|
| }
|
| };
|
|
|
| const handleDailyResummary = async () => {
|
| if (loading || summaryLoading) return;
|
|
|
| const urls = articles
|
| .map((article) => article.articleUrl)
|
| .filter((url) => typeof url === 'string' && /^https?:\/\//i.test(url));
|
|
|
| if (urls.length === 0) {
|
| setError('Không có bài viết hợp lệ để tóm tắt lại.');
|
| return;
|
| }
|
|
|
| const dailyQuery = getDailyAutoQuery();
|
|
|
| setError('');
|
| setSummaryLoading(true);
|
|
|
| try {
|
| const data = await apiService.scrapeAndSummarize(urls, dailyQuery);
|
| const nextSummary = data.summary || '';
|
|
|
| if (!nextSummary.trim()) {
|
| setError('Không nhận được bản tóm tắt mới. Vui lòng thử lại.');
|
| return;
|
| }
|
|
|
| const nextTotalArticles = data.totalArticles || urls.length;
|
| setSummary(nextSummary);
|
| setTotalArticles(nextTotalArticles);
|
|
|
| upsertHistoryEntry({
|
| query: dailyQuery,
|
| articles,
|
| summary: nextSummary,
|
| totalCount: nextTotalArticles,
|
| autoDaily: true,
|
| });
|
| } catch (err) {
|
| setError('Không thể tóm tắt lại: ' + (err.response?.data?.error || err.message));
|
| } finally {
|
| setSummaryLoading(false);
|
| }
|
| };
|
|
|
| handleSearchRef.current = handleSearch;
|
|
|
| const summarySignature = getSummarySignature(summary || '');
|
| const summaryTtsState = summarySignature
|
| ? normalizeTtsState(summaryTtsJobs[summarySignature])
|
| : createIdleTtsState();
|
|
|
|
|
|
|
|
|
|
|
| useEffect(() => {
|
| if (hasBootstrappedRef.current) return;
|
| hasBootstrappedRef.current = true;
|
|
|
| const persistedHistory = loadHistoryFromStorage();
|
| const persistedDailySnapshot = loadDailySnapshotFromStorage();
|
| setSearchHistory(persistedHistory);
|
| setHistoryReady(true);
|
|
|
| const todayKey = getLocalDayKey();
|
| const dailyAutoQuery = getDailyAutoQuery();
|
| const todayAutoEntry =
|
| persistedDailySnapshot &&
|
| persistedDailySnapshot.dayKey === todayKey &&
|
| persistedDailySnapshot.articles.length > 0 &&
|
| persistedDailySnapshot.summaryReady
|
| ? persistedDailySnapshot
|
| : null;
|
|
|
| if (todayAutoEntry) {
|
| setSearchQuery(dailyAutoQuery);
|
| setUseDailyHomeLayout(true);
|
| setVoice(todayAutoEntry.filters?.voice || 'Bắc');
|
| setTime(todayAutoEntry.filters?.time || 'pd');
|
| setSource(todayAutoEntry.filters?.source || 'all');
|
| applyHistorySnapshot(todayAutoEntry);
|
| return;
|
| }
|
|
|
| setSearchQuery(dailyAutoQuery);
|
| handleSearchRef.current(dailyAutoQuery, { autoDaily: true });
|
| }, []);
|
|
|
| return (
|
| <div className="min-h-screen bg-background text-on-background font-body selection:bg-black selection:text-white">
|
| <aside className="hidden lg:fixed lg:inset-y-0 lg:left-0 lg:z-40 lg:flex lg:w-72 lg:flex-col lg:border-r lg:border-black/10 lg:bg-white lg:px-8 lg:py-8">
|
| <div className="mb-10 flex items-center gap-3">
|
| <div className="flex h-10 w-10 items-center justify-center bg-black text-white">
|
| <span className="material-symbols-outlined text-2xl">neurology</span>
|
| </div>
|
| <div>
|
| <h1 className="text-xl font-black uppercase tracking-tight">NewsAI</h1>
|
| <p className="mt-1 text-[10px] font-extrabold uppercase tracking-[0.2em] text-slate-500">
|
| Digital Curator
|
| </p>
|
| </div>
|
| </div>
|
|
|
| <div className="flex-1">
|
| <h2 className="mb-5 flex items-center gap-2 text-[11px] font-black uppercase tracking-[0.15em] text-black">
|
| <span className="material-symbols-outlined text-lg">history</span>
|
| Lịch sử tìm kiếm
|
| </h2>
|
|
|
| {searchHistory.length > 0 ? (
|
| <div className="flex flex-col gap-1">
|
| {searchHistory.map((item) => (
|
| <button
|
| key={item.id}
|
| type="button"
|
| onClick={() => handleHistorySelect(item)}
|
| className="group flex items-center gap-3 rounded-lg px-4 py-3 text-left transition-all hover:bg-slate-50"
|
| >
|
| <span className="material-symbols-outlined text-lg opacity-40 transition-all group-hover:text-black group-hover:opacity-100">
|
| history
|
| </span>
|
| <span className="truncate text-sm font-medium text-slate-600 group-hover:text-black">
|
| {item.query}
|
| </span>
|
| </button>
|
| ))}
|
| </div>
|
| ) : (
|
| <p className="rounded-lg border border-dashed border-slate-200 p-4 text-sm text-slate-400">
|
| Chưa có lượt tìm kiếm nào.
|
| </p>
|
| )}
|
| </div>
|
|
|
| <button
|
| type="button"
|
| className="mt-auto flex items-center gap-3 rounded-lg px-4 py-3 text-slate-500 transition-all hover:bg-slate-50 hover:text-black"
|
| >
|
| <span className="material-symbols-outlined">settings</span>
|
| <span className="text-sm font-bold">Cài đặt</span>
|
| </button>
|
| </aside>
|
|
|
| <main className="relative w-full lg:pl-72">
|
| <div className="w-full px-2 pb-12 pt-4 md:px-4 lg:px-6 lg:pt-6">
|
| <section className="mb-8 rounded-xl border border-black/10 bg-white p-5 lg:hidden">
|
| <div className="mb-4 flex items-center gap-3">
|
| <div className="flex h-10 w-10 items-center justify-center bg-black text-white">
|
| <span className="material-symbols-outlined text-2xl">neurology</span>
|
| </div>
|
| <div>
|
| <h1 className="text-xl font-black uppercase tracking-tight">NewsAI</h1>
|
| <p className="text-[10px] font-extrabold uppercase tracking-[0.2em] text-slate-500">Digital Curator</p>
|
| </div>
|
| </div>
|
|
|
| <div className="flex items-center gap-2 overflow-x-auto pb-1">
|
| {searchHistory.length > 0 ? searchHistory.map((item) => (
|
| <button
|
| key={`mobile-${item.id}`}
|
| type="button"
|
| onClick={() => handleHistorySelect(item)}
|
| className="whitespace-nowrap rounded-full border border-black/20 px-3 py-1.5 text-xs font-bold text-slate-600 transition-all hover:border-black hover:text-black"
|
| >
|
| {item.query}
|
| </button>
|
| )) : (
|
| <p className="text-xs font-medium text-slate-400">Lịch sử tìm kiếm sẽ xuất hiện tại đây.</p>
|
| )}
|
| </div>
|
| </section>
|
|
|
| <header className="mb-8">
|
| <div className="w-full">
|
| <SearchBox
|
| value={searchQuery}
|
| onChange={setSearchQuery}
|
| onSearch={handleSearch}
|
| loading={loading}
|
| />
|
| <div className="mt-7">
|
| <FilterBar
|
| voice={voice}
|
| onVoiceChange={setVoice}
|
| time={time}
|
| onTimeChange={setTime}
|
| source={source}
|
| onSourceChange={setSource}
|
| />
|
| </div>
|
| {error && (
|
| <div className="mt-6 border border-red-300 bg-red-50 px-4 py-3 text-sm font-medium text-red-700">
|
| {error}
|
| </div>
|
| )}
|
| </div>
|
| </header>
|
|
|
| <div className="grid items-start gap-6 xl:grid-cols-2">
|
| <section>
|
| <div className="mb-10 flex items-center justify-between border-b border-black pb-4">
|
| <h2 className="text-2xl font-black uppercase tracking-tight">
|
| {useDailyHomeLayout ? 'Bản tin đầu ngày' : 'Kết quả tìm kiếm'}
|
| </h2>
|
| {!useDailyHomeLayout && (
|
| <div className="flex items-center gap-1 text-[10px] font-black uppercase tracking-[0.2em] text-black/70">
|
| <span>Sắp xếp: Mới nhất</span>
|
| <span className="material-symbols-outlined text-sm">swap_vert</span>
|
| </div>
|
| )}
|
| </div>
|
|
|
| {useDailyHomeLayout ? (
|
| <HomeNewsGrid
|
| articles={articles}
|
| loading={loading}
|
| onSummarizeArticle={handleDailyCardSummarize}
|
| />
|
| ) : (
|
| <>
|
| {loading && (
|
| <div className="flex min-h-56 flex-col items-center justify-center rounded-xl border border-black/10 bg-white text-center">
|
| <span className="material-symbols-outlined animate-spin text-4xl text-black/40">progress_activity</span>
|
| <p className="mt-3 text-sm font-semibold text-slate-500">Đang phân tích và tìm bài viết...</p>
|
| </div>
|
| )}
|
|
|
| {!loading && articles.length === 0 && (
|
| <div className="flex min-h-56 flex-col items-center justify-center rounded-xl border border-dashed border-black/20 bg-white text-center">
|
| <span className="material-symbols-outlined text-6xl text-black/20">search</span>
|
| <p className="mt-4 text-lg font-medium text-slate-500">
|
| Nhập từ khóa hoặc đường dẫn bài báo để bắt đầu.
|
| </p>
|
| </div>
|
| )}
|
|
|
| {!loading && articles.length > 0 && (
|
| <div className="space-y-12">
|
| {articles.map((article, index) => (
|
| <NewsArticle
|
| key={`${article.articleUrl}-${index}`}
|
| {...article}
|
| onSummarize={() => handleArticleSummarize(article.articleUrl, index)}
|
| onGenerateTts={() => handleGenerateArticleTts(article.articleUrl, index)}
|
| summary={articleSummaries[index]?.content}
|
| summaryVisible={articleSummaries[index]?.visible}
|
| summaryLoading={articleSummaryLoading[index]}
|
| ttsStatus={articleTtsJobs[getArticleTtsSlot(article.articleUrl, index)]?.status || 'idle'}
|
| ttsAudioUrl={articleTtsJobs[getArticleTtsSlot(article.articleUrl, index)]?.audioUrl || ''}
|
| ttsError={articleTtsJobs[getArticleTtsSlot(article.articleUrl, index)]?.error || ''}
|
| />
|
| ))}
|
| </div>
|
| )}
|
| </>
|
| )}
|
| </section>
|
|
|
| <aside className="hidden md:block xl:sticky xl:top-8">
|
| <SummaryBox
|
| summary={summary}
|
| totalArticles={totalArticles}
|
| loading={summaryLoading}
|
| onGenerateTts={handleGenerateSummaryTts}
|
| ttsStatus={summaryTtsState.status}
|
| ttsAudioUrl={summaryTtsState.audioUrl}
|
| ttsError={summaryTtsState.error}
|
| onResummarize={useDailyHomeLayout ? handleDailyResummary : undefined}
|
| resummarizeDisabled={loading || summaryLoading || articles.length === 0}
|
| />
|
| </aside>
|
| </div>
|
|
|
| <div className="fixed bottom-5 right-5 z-40 md:hidden">
|
| <button
|
| type="button"
|
| onClick={toggleMobileSummaryPanel}
|
| aria-expanded={isMobileSummaryOpen}
|
| className="flex items-center gap-2 rounded-full border border-black bg-black px-5 py-3 text-xs font-black uppercase tracking-[0.14em] text-white shadow-lg transition-all hover:bg-slate-800"
|
| >
|
| <span className={`material-symbols-outlined text-base ${summaryLoading ? 'animate-spin' : ''}`}>
|
| {summaryLoading ? 'progress_activity' : isMobileSummaryOpen ? 'close' : 'summarize'}
|
| </span>
|
| {summaryLoading ? 'Đang tóm tắt' : isMobileSummaryOpen ? 'Đóng tóm tắt' : 'Mở tóm tắt'}
|
| </button>
|
| </div>
|
|
|
| <div
|
| className={`fixed inset-0 z-50 md:hidden ${isMobileSummaryOpen ? 'pointer-events-auto' : 'pointer-events-none'}`}
|
| aria-hidden={!isMobileSummaryOpen}
|
| onClick={closeMobileSummaryPanel}
|
| >
|
| <div
|
| className={`absolute inset-0 bg-black/45 transition-opacity duration-300 ${isMobileSummaryOpen ? 'opacity-100' : 'opacity-0'}`}
|
| />
|
|
|
| <section
|
| onClick={(event) => event.stopPropagation()}
|
| className={`absolute bottom-0 left-0 right-0 max-h-[88vh] overflow-y-auto rounded-t-2xl border-t-2 border-black bg-white p-4 transition-transform duration-300 ${isMobileSummaryOpen ? 'translate-y-0' : 'translate-y-full'}`}
|
| >
|
| <div className="mb-3 flex items-center justify-between">
|
| <h3 className="text-sm font-black uppercase tracking-[0.15em] text-black">Tóm tắt thông minh</h3>
|
| <button
|
| type="button"
|
| onClick={(event) => {
|
| event.preventDefault();
|
| event.stopPropagation();
|
| closeMobileSummaryPanel();
|
| }}
|
| className="rounded-full border border-black/20 p-2 text-black transition-all hover:bg-slate-100"
|
| aria-label="Đóng"
|
| >
|
| <span className="material-symbols-outlined">close</span>
|
| </button>
|
| </div>
|
|
|
| <SummaryBox
|
| summary={summary}
|
| totalArticles={totalArticles}
|
| loading={summaryLoading}
|
| onGenerateTts={handleGenerateSummaryTts}
|
| ttsStatus={summaryTtsState.status}
|
| ttsAudioUrl={summaryTtsState.audioUrl}
|
| ttsError={summaryTtsState.error}
|
| onResummarize={useDailyHomeLayout ? handleDailyResummary : undefined}
|
| resummarizeDisabled={loading || summaryLoading || articles.length === 0}
|
| />
|
| </section>
|
| </div>
|
|
|
| <footer className="mt-16 border-t border-black/10 pt-8 text-center">
|
| <p className="text-xs leading-relaxed text-slate-500">
|
| AI có thể mắc lỗi. Hãy kiểm tra lại thông tin quan trọng.
|
| <br />
|
| Được xây dựng bởi <span className="font-bold text-black/70">HCMUS - Machine Learning - 23KHDL1 - Nhóm 4</span>
|
| </p>
|
| </footer>
|
| </div>
|
| </main>
|
| </div>
|
| )
|
| }
|
|
|
| export default App
|
|
|