ML-LT / src /App.jsx
wokogaming's picture
Upload 57 files
05abd64 verified
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