Spaces:
Paused
Paused
| 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 { | |
| // Ignore storage quota and browser privacy mode errors. | |
| } | |
| } | |
| 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 { | |
| // Ignore storage quota and browser privacy mode errors. | |
| } | |
| }; | |
| 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); | |
| // Individual article summaries | |
| const [articleSummaries, setArticleSummaries] = useState({}); | |
| const [articleSummaryLoading, setArticleSummaryLoading] = useState({}); | |
| // Filter states | |
| 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 { | |
| // Ignore storage quota and browser privacy mode errors. | |
| } | |
| }, [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) => { | |
| // Helper function to strip HTML tags | |
| const stripHtml = (html) => { | |
| if (!html) return ''; | |
| const tmp = document.createElement('DIV'); | |
| tmp.innerHTML = html; | |
| return tmp.textContent || tmp.innerText || ''; | |
| }; | |
| // Determine category color based on content or default | |
| // Map từ Brave subtype sang UI style | |
| const SUBTYPE_MAPPING = { | |
| // Tin tức / Bài viết chung | |
| article: { name: 'Tin tức', tone: 'news' }, | |
| news: { name: 'Thời sự', tone: 'news' }, | |
| // Mua sắm / Sản phẩm | |
| product: { name: 'Mua sắm', tone: 'commerce' }, | |
| // Ẩm thực / Công thức | |
| recipe: { name: 'Ẩm thực', tone: 'culture' }, | |
| // Hỏi đáp / Diễn đàn | |
| qa: { name: 'Hỏi đáp', tone: 'discussion' }, | |
| discussion: { name: 'Thảo luận', tone: 'discussion' }, | |
| // Đánh giá / Review | |
| review: { name: 'Review', tone: 'review' }, | |
| // Video (Cái này thường nằm ở type 'video_result' nhưng map luôn cho chắc) | |
| video_result: { name: 'Video', tone: 'video' }, | |
| // Phim ảnh | |
| movie: { name: 'Phim ảnh', tone: 'culture' }, | |
| // Mặc định (generic) | |
| 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 summary exists, toggle visibility | |
| if (currentSummary?.content) { | |
| setArticleSummaries(prev => ({ | |
| ...prev, | |
| [index]: { | |
| ...prev[index], | |
| visible: !prev[index].visible | |
| } | |
| })); | |
| return; | |
| } | |
| // If no summary yet, fetch it | |
| 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) { | |
| // Scrape the URL and summarize | |
| const data = await apiService.scrape(query); | |
| console.log('Scraped data:', data); | |
| // Create article from scraped content | |
| 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); | |
| // Tóm tắt bài báo đơn lẻ | |
| 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 { | |
| // Search first to show articles immediately | |
| const searchOptions = { | |
| freshness: time, | |
| language: source === 'all' ? 'vi' : source | |
| }; | |
| console.log('Searching...'); | |
| const searchData = await apiService.search(query, searchOptions); | |
| console.log('Search results:', searchData); | |
| // Check for family_friendly at the top level | |
| 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; | |
| } | |
| // Map Brave results to articles and show immediately | |
| let urls = []; | |
| let hasUnsafeContent = false; | |
| if (searchData.news?.results && searchData.news.results.length > 0) { | |
| // Check for unsafe content | |
| const unsafeResults = searchData.news.results.filter(r => r.family_friendly === false); | |
| if (unsafeResults.length > 0) { | |
| hasUnsafeContent = true; | |
| } | |
| const mappedArticles = searchData.news.results | |
| .filter(result => { | |
| // Loại bỏ nội dung không phù hợp | |
| if (result.family_friendly === false) { | |
| return false; | |
| } | |
| // Loại bỏ video và image results | |
| 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)); | |
| // Check if all results were filtered out | |
| 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) { | |
| // Check for unsafe content | |
| const unsafeResults = searchData.web.results.filter(r => r.family_friendly === false); | |
| if (unsafeResults.length > 0) { | |
| hasUnsafeContent = true; | |
| } | |
| const mappedArticles = searchData.web.results | |
| .filter(result => { | |
| // Loại bỏ nội dung không phù hợp | |
| if (result.family_friendly === false) { | |
| return false; | |
| } | |
| // Chỉ lấy search_result thông thường, bỏ video/image | |
| 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)); | |
| // Check if all results were filtered out | |
| 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; | |
| } | |
| // Now summarize in background | |
| setLoading(false); // End search loading | |
| setSummaryLoading(true); // Start summary loading | |
| 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 { | |
| // Ensure loading is stopped | |
| 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(); | |
| // Bootstrap cache on first render: | |
| // 1) Load manual search history for sidebar | |
| // 2) Load today's daily snapshot from dedicated storage | |
| // 3) Otherwise trigger daily auto-search once | |
| 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 | |