wokogaming commited on
Commit
05abd64
·
verified ·
1 Parent(s): 41543bd

Upload 57 files

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
.dockerignore ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ node_modules
2
+ npm-debug.log
3
+ .env
4
+ .git
5
+ .gitignore
6
+ Dockerfile
7
+ render.yaml
8
+ tts_fastapi
.env.example ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ # Backend API URL
2
+ VITE_API_URL=http://localhost:5000
.gitignore ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Logs
2
+ logs
3
+ *.log
4
+ npm-debug.log*
5
+ yarn-debug.log*
6
+ yarn-error.log*
7
+ pnpm-debug.log*
8
+ lerna-debug.log*
9
+
10
+ node_modules
11
+ dist
12
+ dist-ssr
13
+ *.local
14
+
15
+ # Editor directories and files
16
+ .vscode/*
17
+ !.vscode/extensions.json
18
+ .idea
19
+ .DS_Store
20
+ *.suo
21
+ *.ntvs*
22
+ *.njsproj
23
+ *.sln
24
+ *.sw?
Dockerfile ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Build Stage
2
+ FROM node:20-slim AS build
3
+ WORKDIR /app
4
+ COPY package*.json ./
5
+ RUN npm install
6
+ COPY . .
7
+ RUN npm run build
8
+
9
+ # Production Stage
10
+ FROM nginx:stable-alpine
11
+ COPY --from=build /app/dist /usr/share/nginx/html
12
+ EXPOSE 80
13
+ CMD ["nginx", "-g", "daemon off;"]
README.md CHANGED
@@ -1,10 +1,16 @@
1
- ---
2
- title: ML LT
3
- emoji: 🌖
4
- colorFrom: pink
5
- colorTo: purple
6
- sdk: docker
7
- pinned: false
8
- ---
9
-
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
1
+ # React + Vite
2
+
3
+ This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
4
+
5
+ Currently, two official plugins are available:
6
+
7
+ - [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
8
+ - [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
9
+
10
+ ## React Compiler
11
+
12
+ The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
13
+
14
+ ## Expanding the ESLint configuration
15
+
16
+ If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.
config/app.config.js ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ export default {
2
+ PORT: process.env.PORT || 5000,
3
+ NODE_ENV: process.env.NODE_ENV || 'development',
4
+ CORS_ORIGIN: process.env.CORS_ORIGIN || '*'
5
+ };
config/brave.config.js ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ export default {
2
+ API_KEY: process.env.BRAVE_API_KEY,
3
+ BASE_URL: 'https://api.search.brave.com/res/v1/web/search'
4
+ };
config/gemini.config.js ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ export default {
2
+ API_KEY: process.env.GEMINI_API_KEY,
3
+ MODEL: 'gemini-2.5-flash'
4
+ };
config/puppeteer.config.js ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ export default {
2
+ HEADLESS: 'new',
3
+ ARGS: ['--no-sandbox', '--disable-setuid-sandbox'],
4
+ TIMEOUT: 60000,
5
+ WAIT_UNTIL: 'domcontentloaded'
6
+ };
config/supabase.config.js ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ export default {
2
+ URL: process.env.SUPABASE_URL || '',
3
+ KEY: process.env.SUPABASE_KEY || '',
4
+ TTS_BUCKET: process.env.SUPABASE_TTS_BUCKET || 'tts_audio'
5
+ };
config/tts.config.js ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ export default {
2
+ SERVICE_URL: process.env.TTS_SERVICE_URL || 'http://127.0.0.1:8001',
3
+ TIMEOUT_MS: Number(process.env.TTS_SERVICE_TIMEOUT_MS || 600000)
4
+ };
controllers/gemini.controller.js ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import geminiService from '../services/gemini.service.js';
2
+
3
+ class GeminiController {
4
+ async generate(req, res) {
5
+ try {
6
+ const { prompt } = req.body;
7
+
8
+ if (!prompt) {
9
+ return res.status(400).json({ error: 'Prompt is required' });
10
+ }
11
+
12
+ const data = await geminiService.generateContent(prompt);
13
+ res.json(data);
14
+ } catch (error) {
15
+ console.error('Gemini Error:', error.message);
16
+ res.status(500).json({
17
+ error: 'Failed to generate content',
18
+ details: error.message
19
+ });
20
+ }
21
+ }
22
+
23
+ async summarize(req, res) {
24
+ try {
25
+ const { content, title } = req.body;
26
+
27
+ if (!content) {
28
+ return res.status(400).json({ error: 'Content is required' });
29
+ }
30
+
31
+ const data = await geminiService.summarizeNews(content, title);
32
+ res.json(data);
33
+ } catch (error) {
34
+ console.error('Gemini Summarize Error:', error.message);
35
+ res.status(500).json({
36
+ error: 'Failed to summarize content',
37
+ details: error.message
38
+ });
39
+ }
40
+ }
41
+ }
42
+ export default new GeminiController();
controllers/scrape.controller.js ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import scrapeService from '../services/scrape.service.js';
2
+
3
+ class ScrapeController {
4
+ async scrape(req, res) {
5
+ try {
6
+ const { url } = req.body;
7
+
8
+ if (!url) {
9
+ return res.status(400).json({ error: 'URL is required' });
10
+ }
11
+
12
+ const data = await scrapeService.scrapeUrl(url);
13
+ res.json(data);
14
+ } catch (error) {
15
+ console.error('Scrape Error:', error.message);
16
+ res.status(500).json({
17
+ error: 'Failed to scrape URL',
18
+ details: error.message
19
+ });
20
+ }
21
+ }
22
+ }
23
+
24
+ export default new ScrapeController();
controllers/search.controller.js ADDED
@@ -0,0 +1,261 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import braveService from '../services/brave.service.js';
2
+ import scrapeService from '../services/scrape.service.js';
3
+ import geminiService from '../services/gemini.service.js';
4
+ import pLimit from 'p-limit';
5
+
6
+ // Max 5 URLs, max 3 concurrent scrapes (axios is fast; Puppeteer fallback is slow)
7
+ const MAX_SCRAPE_URLS = 5;
8
+ const scrapeLimit = pLimit(3);
9
+
10
+ // Wrap a promise with a hard timeout
11
+ const withTimeout = (promise, ms, label) =>
12
+ Promise.race([
13
+ promise,
14
+ new Promise((_, reject) =>
15
+ setTimeout(() => reject(new Error(`Timeout after ${ms}ms: ${label}`)), ms)
16
+ ),
17
+ ]);
18
+
19
+ class SearchController {
20
+ async search(req, res) {
21
+ try {
22
+ const { query, language, freshness } = req.body;
23
+
24
+ if (!query) {
25
+ return res.status(400).json({ error: 'Query is required' });
26
+ }
27
+
28
+ const options = {};
29
+ if (language) options.language = language;
30
+ if (freshness) options.freshness = freshness;
31
+
32
+ const data = await braveService.search(query, options);
33
+ res.json(data);
34
+ } catch (error) {
35
+ console.error('Search Error:', error.message);
36
+ res.status(500).json({
37
+ error: 'Failed to perform search',
38
+ details: error.response?.data || error.message
39
+ });
40
+ }
41
+ }
42
+
43
+ async searchAndSummarize(req, res) {
44
+ try {
45
+ const { query, language, freshness } = req.body;
46
+
47
+ if (!query) {
48
+ return res.status(400).json({ error: 'Query is required' });
49
+ }
50
+
51
+ const options = {};
52
+ if (language) options.language = language;
53
+ if (freshness) options.freshness = freshness;
54
+
55
+ // 1. Search bằng Brave
56
+ console.log('[BRAVE API] Starting search request...');
57
+ let searchData;
58
+ try {
59
+ searchData = await braveService.search(query, options);
60
+ console.log('[BRAVE API] Search successful, found results');
61
+ } catch (err) {
62
+ console.error('[BRAVE API] ERROR:', err.message);
63
+ console.error('[BRAVE API] Status:', err.response?.status);
64
+ console.error('[BRAVE API] Details:', err.response?.data);
65
+
66
+ if (err.response?.status === 429) {
67
+ return res.status(429).json({
68
+ error: 'Rate limit exceeded',
69
+ api: 'Brave Search API',
70
+ details: 'Vuot qua gioi han API Brave Search. Vui long thu lai sau it phut.'
71
+ });
72
+ }
73
+ throw err;
74
+ }
75
+
76
+ // 2. Lấy URLs từ kết quả (ưu tiên news, fallback về web)
77
+ let urls = [];
78
+ if (searchData.news?.results && searchData.news.results.length > 0) {
79
+ urls = searchData.news.results.slice(0, MAX_SCRAPE_URLS).map(r => r.url);
80
+ } else if (searchData.web?.results && searchData.web.results.length > 0) {
81
+ urls = searchData.web.results
82
+ .filter(r => r.type === 'search_result')
83
+ .slice(0, MAX_SCRAPE_URLS)
84
+ .map(r => r.url);
85
+ }
86
+
87
+ if (urls.length === 0) {
88
+ return res.status(404).json({ error: 'Khong tim thay ket qua nao' });
89
+ }
90
+
91
+ // 3. Scrape tất cả URLs bằng Puppeteer
92
+ console.log(`[PUPPETEER] Scraping ${urls.length} articles...`);
93
+ const scrapePromises = urls.map(async (url, index) => {
94
+ try {
95
+ const scraped = await scrapeService.scrapeUrl(url);
96
+ console.log(`[PUPPETEER] Successfully scraped: ${url}`);
97
+ return {
98
+ title: scraped.title || `Bai ${index + 1}`,
99
+ source: new URL(url).hostname,
100
+ content: scraped.text || '',
101
+ url: url
102
+ };
103
+ } catch (err) {
104
+ console.error(`[PUPPETEER] Failed to scrape ${url}:`, err.message);
105
+ return null;
106
+ }
107
+ });
108
+
109
+ const articles = (await Promise.all(scrapePromises)).filter(a => a !== null);
110
+ console.log(`[PUPPETEER] Successfully scraped ${articles.length}/${urls.length} articles`);
111
+
112
+ if (articles.length === 0) {
113
+ return res.status(500).json({ error: 'Khong the scrape duoc bai bao nao' });
114
+ }
115
+
116
+ // 4. Gửi tất cả vào Gemini để tóm tắt
117
+ console.log(`[GEMINI API] Starting summarization of ${articles.length} articles...`);
118
+ let summary;
119
+ try {
120
+ summary = await geminiService.summarizeMultipleNews(articles, query);
121
+ console.log('[GEMINI API] Summarization successful');
122
+ } catch (err) {
123
+ console.error('[GEMINI API] ERROR:', err.message);
124
+ console.error('[GEMINI API] Status:', err.response?.status);
125
+ console.error('[GEMINI API] Details:', err.response?.data);
126
+
127
+ if (err.message.includes('PROHIBITED_CONTENT')) {
128
+ return res.status(400).json({
129
+ error: 'Prohibited content',
130
+ api: 'Google Gemini API',
131
+ details: 'Nội dung không phù hợp. Gemini AI đã chặn nội dung này vì vi phạm tiêu chuẩn an toàn.'
132
+ });
133
+ }
134
+
135
+ if (err.response?.status === 429 || err.message.includes('429')) {
136
+ return res.status(429).json({
137
+ error: 'Rate limit exceeded',
138
+ api: 'Google Gemini API',
139
+ details: 'Vuot qua gioi han API Gemini. Vui long thu lai sau it phut.'
140
+ });
141
+ }
142
+ throw err;
143
+ }
144
+
145
+ console.log('[SUCCESS] Request completed successfully');
146
+ res.json({
147
+ summary: summary.summary,
148
+ totalArticles: summary.totalArticles,
149
+ articles: articles.map(a => ({
150
+ title: a.title,
151
+ source: a.source,
152
+ url: a.url
153
+ }))
154
+ });
155
+ } catch (error) {
156
+ console.error('[ERROR] Search and Summarize Error:', error.message);
157
+ console.error('[ERROR] Stack:', error.stack);
158
+
159
+ res.status(500).json({
160
+ error: 'Failed to search and summarize',
161
+ details: error.response?.data?.error || error.message
162
+ });
163
+ }
164
+ }
165
+
166
+ async scrapeAndSummarize(req, res) {
167
+ const tTotal = Date.now();
168
+ try {
169
+ const { urls: rawUrls, query } = req.body;
170
+
171
+ if (!rawUrls || !Array.isArray(rawUrls) || rawUrls.length === 0) {
172
+ return res.status(400).json({ error: 'URLs array is required' });
173
+ }
174
+
175
+ // Cap to MAX_SCRAPE_URLS to avoid long waits
176
+ const urls = rawUrls.slice(0, MAX_SCRAPE_URLS);
177
+ console.log(`\n${'='.repeat(60)}`);
178
+ console.log(`[REQUEST] scrapeAndSummarize — ${urls.length} URLs, query="${query?.substring(0,40)}"`);
179
+ urls.forEach((u, i) => console.log(` [${i+1}] ${u.substring(0, 80)}`));
180
+
181
+ // 1. Scrape URLs (axios fast path, Puppeteer fallback) with 30s total timeout
182
+ const tScrapeStart = Date.now();
183
+ console.log(`[SCRAPE] ⏳ Starting scrape of ${urls.length} URLs (concurrency: 3)...`);
184
+ const scrapeWork = Promise.all(
185
+ urls.map((url, index) =>
186
+ scrapeLimit(async () => {
187
+ const t = Date.now();
188
+ try {
189
+ const scraped = await scrapeService.scrapeUrl(url);
190
+ console.log(`[SCRAPE] ✅ [${index+1}/${urls.length}] ${Date.now()-t}ms — ${url.substring(0, 60)}`);
191
+ return {
192
+ title: scraped.title || `Bài ${index + 1}`,
193
+ source: new URL(url).hostname,
194
+ content: scraped.text || '',
195
+ url,
196
+ };
197
+ } catch (err) {
198
+ console.error(`[SCRAPE] ❌ [${index+1}/${urls.length}] ${Date.now()-t}ms — ${url.substring(0, 60)}: ${err.message}`);
199
+ return null;
200
+ }
201
+ })
202
+ )
203
+ );
204
+
205
+ const rawArticles = await withTimeout(scrapeWork, 30000, 'scrapeAndSummarize');
206
+ const articles = rawArticles.filter((a) => a !== null);
207
+ console.log(`[SCRAPE] 🏁 Done: ${articles.length}/${urls.length} OK in ${Date.now()-tScrapeStart}ms (total elapsed: ${Date.now()-tTotal}ms)`);
208
+
209
+ if (articles.length === 0) {
210
+ return res.status(500).json({ error: 'Khong the scrape duoc bai bao nao' });
211
+ }
212
+
213
+ // 2. Send to Gemini
214
+ const tGeminiStart = Date.now();
215
+ console.log(`[GEMINI] ⏳ Summarizing ${articles.length} articles...`);
216
+ let summary;
217
+ try {
218
+ summary = await geminiService.summarizeMultipleNews(articles, query || '');
219
+ console.log(`[GEMINI] ✅ Done in ${Date.now()-tGeminiStart}ms (total elapsed: ${Date.now()-tTotal}ms)`);
220
+ } catch (err) {
221
+ console.error('[GEMINI API] ERROR:', err.message);
222
+ console.error('[GEMINI API] Status:', err.response?.status);
223
+ console.error('[GEMINI API] Details:', err.response?.data);
224
+
225
+ if (err.message.includes('PROHIBITED_CONTENT')) {
226
+ return res.status(400).json({
227
+ error: 'Prohibited content',
228
+ api: 'Google Gemini API',
229
+ details: 'Nội dung không phù hợp. Gemini AI đã chặn nội dung này vì vi phạm tiêu chuẩn an toàn.'
230
+ });
231
+ }
232
+
233
+ if (err.response?.status === 429 || err.message.includes('429')) {
234
+ return res.status(429).json({
235
+ error: 'Rate limit exceeded',
236
+ api: 'Google Gemini API',
237
+ details: 'Vuot qua gioi han API Gemini. Vui long thu lai sau it phut.'
238
+ });
239
+ }
240
+ throw err;
241
+ }
242
+
243
+ console.log(`[SUCCESS] 🏁 scrapeAndSummarize done in ${Date.now()-tTotal}ms total`);
244
+ console.log('='.repeat(60));
245
+ res.json({
246
+ summary: summary.summary,
247
+ totalArticles: summary.totalArticles
248
+ });
249
+ } catch (error) {
250
+ console.error('[ERROR] Scrape and Summarize Error:', error.message);
251
+ console.error('[ERROR] Stack:', error.stack);
252
+
253
+ res.status(500).json({
254
+ error: 'Failed to scrape and summarize',
255
+ details: error.message
256
+ });
257
+ }
258
+ }
259
+ }
260
+
261
+ export default new SearchController();
controllers/tts.controller.js ADDED
@@ -0,0 +1,142 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import ttsService from '../services/tts.service.js';
2
+ import ttsJobService from '../services/ttsJob.service.js';
3
+ import audioTranscodeService from '../services/audioTranscode.service.js';
4
+
5
+ class TtsController {
6
+ handleServiceError(error, res, defaultMessage) {
7
+ console.error('[TTS Bridge] Error:', error.message);
8
+
9
+ if (error.response) {
10
+ return res.status(error.response.status).json({
11
+ error: defaultMessage,
12
+ details: error.response.data
13
+ });
14
+ }
15
+
16
+ return res.status(500).json({
17
+ error: defaultMessage,
18
+ details: error.message
19
+ });
20
+ }
21
+
22
+ async health(req, res) {
23
+ try {
24
+ const data = await ttsService.health();
25
+ return res.json(data);
26
+ } catch (error) {
27
+ return this.handleServiceError(error, res, 'Failed to reach TTS service');
28
+ }
29
+ }
30
+
31
+ async synthesize(req, res) {
32
+ try {
33
+ const { text, language, speaker_audio } = req.body;
34
+
35
+ if (!text) {
36
+ return res.status(400).json({ error: 'Text is required' });
37
+ }
38
+
39
+ const data = await ttsService.synthesize({
40
+ text,
41
+ language,
42
+ speaker_audio
43
+ });
44
+
45
+ if (!data?.audio_base64) {
46
+ return res.status(502).json({ error: 'TTS service returned empty audio payload' });
47
+ }
48
+
49
+ const wavBuffer = Buffer.from(data.audio_base64, 'base64');
50
+ const mp3Buffer = await audioTranscodeService.convertWavToMp3(wavBuffer);
51
+
52
+ const responsePayload = {
53
+ ...data,
54
+ audio_base64: mp3Buffer.toString('base64'),
55
+ format: 'mp3',
56
+ mime_type: 'audio/mpeg',
57
+ size_bytes: mp3Buffer.length
58
+ };
59
+
60
+ return res.json(responsePayload);
61
+ } catch (error) {
62
+ return this.handleServiceError(error, res, 'Failed to synthesize speech');
63
+ }
64
+ }
65
+
66
+ async createTask(req, res) {
67
+ try {
68
+ const { text, language, speaker_audio } = req.body;
69
+
70
+ if (!text) {
71
+ return res.status(400).json({ error: 'Text is required' });
72
+ }
73
+
74
+ const data = await ttsService.createTask({
75
+ text,
76
+ language,
77
+ speaker_audio
78
+ });
79
+
80
+ return res.status(202).json(data);
81
+ } catch (error) {
82
+ return this.handleServiceError(error, res, 'Failed to dispatch TTS task');
83
+ }
84
+ }
85
+
86
+ async getTask(req, res) {
87
+ try {
88
+ const { taskId } = req.params;
89
+
90
+ if (!taskId) {
91
+ return res.status(400).json({ error: 'taskId is required' });
92
+ }
93
+
94
+ const data = await ttsService.getTask(taskId);
95
+ return res.json(data);
96
+ } catch (error) {
97
+ return this.handleServiceError(error, res, 'Failed to fetch TTS task status');
98
+ }
99
+ }
100
+
101
+ async createStorageJob(req, res) {
102
+ try {
103
+ const { text, language, speaker_audio } = req.body;
104
+
105
+ if (!text) {
106
+ return res.status(400).json({ error: 'Text is required' });
107
+ }
108
+
109
+ const data = ttsJobService.createJob({
110
+ text,
111
+ language,
112
+ speaker_audio
113
+ });
114
+
115
+ return res.status(202).json(data);
116
+ } catch (error) {
117
+ return this.handleServiceError(error, res, 'Failed to create TTS storage job');
118
+ }
119
+ }
120
+
121
+ async getStorageJob(req, res) {
122
+ try {
123
+ const { key } = req.params;
124
+
125
+ if (!key) {
126
+ return res.status(400).json({ error: 'key is required' });
127
+ }
128
+
129
+ const data = ttsJobService.getJob(key);
130
+
131
+ if (!data) {
132
+ return res.status(404).json({ error: 'TTS job not found' });
133
+ }
134
+
135
+ return res.json(data);
136
+ } catch (error) {
137
+ return this.handleServiceError(error, res, 'Failed to fetch TTS storage job');
138
+ }
139
+ }
140
+ }
141
+
142
+ export default new TtsController();
eslint.config.js ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import js from '@eslint/js'
2
+ import globals from 'globals'
3
+ import reactHooks from 'eslint-plugin-react-hooks'
4
+ import reactRefresh from 'eslint-plugin-react-refresh'
5
+ import { defineConfig, globalIgnores } from 'eslint/config'
6
+
7
+ export default defineConfig([
8
+ globalIgnores(['dist']),
9
+ {
10
+ files: ['**/*.{js,jsx}'],
11
+ extends: [
12
+ js.configs.recommended,
13
+ reactHooks.configs.flat.recommended,
14
+ reactRefresh.configs.vite,
15
+ ],
16
+ languageOptions: {
17
+ ecmaVersion: 2020,
18
+ globals: globals.browser,
19
+ parserOptions: {
20
+ ecmaVersion: 'latest',
21
+ ecmaFeatures: { jsx: true },
22
+ sourceType: 'module',
23
+ },
24
+ },
25
+ rules: {
26
+ 'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
27
+ },
28
+ },
29
+ ])
index.html ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="vi">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <link rel="icon" type="image/svg+xml" href="/vite.svg" />
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
+ <title>NewsAI - Digital Curator</title>
8
+ <script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
9
+ <link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet"/>
10
+ <link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
11
+ <script>
12
+ tailwind.config = {
13
+ darkMode: "class",
14
+ theme: {
15
+ extend: {
16
+ colors: {
17
+ "background": "#f6f7fb",
18
+ "on-background": "#0b0b0b",
19
+ "surface": "#ffffff",
20
+ "outline": "rgba(15, 23, 42, 0.16)",
21
+ },
22
+ fontFamily: {
23
+ "headline": ["Manrope", "sans-serif"],
24
+ "body": ["Inter", "sans-serif"],
25
+ "label": ["Inter", "sans-serif"],
26
+ },
27
+ borderRadius: { "DEFAULT": "0.25rem", "sm": "0.125rem", "md": "0.375rem", "lg": "0.5rem", "xl": "0.75rem", "2xl": "1rem" },
28
+ },
29
+ },
30
+ }
31
+ </script>
32
+ </head>
33
+ <body>
34
+ <div id="root"></div>
35
+ <script type="module" src="/src/main.jsx"></script>
36
+ </body>
37
+ </html>
package-lock.json ADDED
The diff for this file is too large to render. See raw diff
 
package.json ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "frontend",
3
+ "private": true,
4
+ "version": "0.0.0",
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "vite",
8
+ "build": "vite build",
9
+ "lint": "eslint .",
10
+ "preview": "vite preview"
11
+ },
12
+ "dependencies": {
13
+ "axios": "^1.13.5",
14
+ "quill": "^2.0.3",
15
+ "react": "^19.2.0",
16
+ "react-dom": "^19.2.0"
17
+ },
18
+ "devDependencies": {
19
+ "@eslint/js": "^9.39.1",
20
+ "@types/react": "^19.2.7",
21
+ "@types/react-dom": "^19.2.3",
22
+ "@vitejs/plugin-react": "^5.1.1",
23
+ "eslint": "^9.39.1",
24
+ "eslint-plugin-react-hooks": "^7.0.1",
25
+ "eslint-plugin-react-refresh": "^0.4.24",
26
+ "globals": "^16.5.0",
27
+ "vite": "^7.3.1"
28
+ }
29
+ }
public/vite.svg ADDED
routes/gemini.routes.js ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ import express from 'express';
2
+ import geminiController from '../controllers/gemini.controller.js';
3
+
4
+ const router = express.Router();
5
+
6
+ router.post('/gemini', geminiController.generate.bind(geminiController));
7
+ router.post('/gemini/summarize', geminiController.summarize.bind(geminiController));
8
+
9
+ export default router;
routes/index.js ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import express from 'express';
2
+ import searchRoutes from './search.routes.js';
3
+ import scrapeRoutes from './scrape.routes.js';
4
+ import geminiRoutes from './gemini.routes.js';
5
+ import ttsRoutes from './tts.routes.js';
6
+
7
+ const router = express.Router();
8
+
9
+ // Health check
10
+ router.get('/health', (req, res) => {
11
+ res.json({ status: 'ok', message: 'Server is running' });
12
+ });
13
+
14
+ // Mount routes
15
+ router.use('/', searchRoutes);
16
+ router.use('/', scrapeRoutes);
17
+ router.use('/', geminiRoutes);
18
+ router.use('/', ttsRoutes);
19
+
20
+ export default router;
routes/scrape.routes.js ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ import express from 'express';
2
+ import scrapeController from '../controllers/scrape.controller.js';
3
+
4
+ const router = express.Router();
5
+
6
+ router.post('/scrape', scrapeController.scrape.bind(scrapeController));
7
+
8
+ export default router;
routes/search.routes.js ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ import express from 'express';
2
+ import searchController from '../controllers/search.controller.js';
3
+
4
+ const router = express.Router();
5
+
6
+ router.post('/search', searchController.search.bind(searchController));
7
+ router.post('/search-summarize', searchController.searchAndSummarize.bind(searchController));
8
+ router.post('/scrape-summarize', searchController.scrapeAndSummarize.bind(searchController));
9
+
10
+ export default router;
routes/tts.routes.js ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import express from 'express';
2
+ import ttsController from '../controllers/tts.controller.js';
3
+
4
+ const router = express.Router();
5
+
6
+ router.get('/tts/health', ttsController.health.bind(ttsController));
7
+ router.post('/tts/synthesize', ttsController.synthesize.bind(ttsController));
8
+ router.post('/tts/tasks', ttsController.createTask.bind(ttsController));
9
+ router.get('/tts/tasks/:taskId', ttsController.getTask.bind(ttsController));
10
+ router.post('/tts/jobs', ttsController.createStorageJob.bind(ttsController));
11
+ router.get('/tts/jobs/:key', ttsController.getStorageJob.bind(ttsController));
12
+
13
+ export default router;
server.js ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import 'dotenv/config';
2
+ import express from 'express';
3
+ import cors from 'cors';
4
+ import appConfig from './config/app.config.js';
5
+ import routes from './routes/index.js';
6
+
7
+ const app = express();
8
+
9
+ // Middleware
10
+ app.use(cors({
11
+ origin: appConfig.CORS_ORIGIN
12
+ }));
13
+ app.use(express.json());
14
+
15
+ // Mount API routes
16
+ app.use('/api', routes);
17
+
18
+ // Start server
19
+ app.listen(appConfig.PORT, () => {
20
+ console.log(`Server is running on port ${appConfig.PORT}`);
21
+ console.log(`Environment: ${appConfig.NODE_ENV}`);
22
+ });
services/audioTranscode.service.js ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { spawn } from 'child_process';
2
+ import ffmpegStaticPath from 'ffmpeg-static';
3
+
4
+ const DEFAULT_MP3_BITRATE = process.env.TTS_MP3_BITRATE || '64k';
5
+
6
+ class AudioTranscodeService {
7
+ constructor() {
8
+ this.ffmpegCommand = ffmpegStaticPath || process.env.FFMPEG_PATH || 'ffmpeg';
9
+ }
10
+
11
+ async convertWavToMp3(wavBuffer) {
12
+ if (!wavBuffer || wavBuffer.length === 0) {
13
+ throw new Error('WAV buffer is empty');
14
+ }
15
+
16
+ return new Promise((resolve, reject) => {
17
+ const ffmpeg = spawn(this.ffmpegCommand, [
18
+ '-hide_banner',
19
+ '-loglevel',
20
+ 'error',
21
+ '-f',
22
+ 'wav',
23
+ '-i',
24
+ 'pipe:0',
25
+ '-vn',
26
+ '-codec:a',
27
+ 'libmp3lame',
28
+ '-b:a',
29
+ DEFAULT_MP3_BITRATE,
30
+ '-f',
31
+ 'mp3',
32
+ 'pipe:1'
33
+ ]);
34
+
35
+ const outputChunks = [];
36
+ const errorChunks = [];
37
+
38
+ ffmpeg.stdout.on('data', (chunk) => outputChunks.push(chunk));
39
+ ffmpeg.stderr.on('data', (chunk) => errorChunks.push(chunk));
40
+
41
+ ffmpeg.on('error', (error) => {
42
+ reject(
43
+ new Error(
44
+ `Cannot run ffmpeg command "${this.ffmpegCommand}": ${error.message}`
45
+ )
46
+ );
47
+ });
48
+
49
+ ffmpeg.on('close', (code) => {
50
+ if (code !== 0) {
51
+ const stderr = Buffer.concat(errorChunks).toString('utf8').trim();
52
+ reject(new Error(stderr || `ffmpeg exited with code ${code}`));
53
+ return;
54
+ }
55
+
56
+ const mp3Buffer = Buffer.concat(outputChunks);
57
+ if (!mp3Buffer.length) {
58
+ reject(new Error('ffmpeg produced an empty MP3 output'));
59
+ return;
60
+ }
61
+
62
+ resolve(mp3Buffer);
63
+ });
64
+
65
+ ffmpeg.stdin.write(wavBuffer);
66
+ ffmpeg.stdin.end();
67
+ });
68
+ }
69
+ }
70
+
71
+ export default new AudioTranscodeService();
services/brave.service.js ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import axios from 'axios';
2
+ import braveConfig from '../config/brave.config.js';
3
+
4
+ class BraveService {
5
+ async search(query, options = {}) {
6
+ if (!braveConfig.API_KEY) {
7
+ throw new Error('Brave API key not configured');
8
+ }
9
+
10
+ const params = {
11
+ q: query,
12
+ search_lang: 'vi',
13
+ count: 10
14
+ };
15
+
16
+ // Add freshness filter if provided
17
+ if (options.freshness) {
18
+ params.freshness = options.freshness;
19
+ }
20
+
21
+ console.log('[BRAVE API] Request params:', JSON.stringify(params, null, 2));
22
+
23
+ const response = await axios.get(braveConfig.BASE_URL, {
24
+ params,
25
+ headers: {
26
+ 'Accept': 'application/json',
27
+ 'X-Subscription-Token': braveConfig.API_KEY
28
+ }
29
+ }).catch(err => {
30
+ if (err.response?.status === 429) {
31
+ throw new Error('Brave API rate limit exceeded. Please try again later.');
32
+ }
33
+ throw err;
34
+ });
35
+
36
+ console.log('[BRAVE API] Response keys:', Object.keys(response.data));
37
+ console.log('[BRAVE API] Has news results:', !!response.data.news?.results);
38
+ console.log('[BRAVE API] Has web results:', !!response.data.web?.results);
39
+ if (response.data.news?.results) {
40
+ console.log('[BRAVE API] News results count:', response.data.news.results.length);
41
+ }
42
+ if (response.data.web?.results) {
43
+ console.log('[BRAVE API] Web results count:', response.data.web.results.length);
44
+ }
45
+
46
+ return response.data;
47
+ }
48
+ }
49
+
50
+ export default new BraveService();
services/gemini.service.js ADDED
@@ -0,0 +1,135 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { GoogleGenerativeAI } from '@google/generative-ai';
2
+ import geminiConfig from '../config/gemini.config.js';
3
+
4
+ class GeminiService {
5
+ constructor() {
6
+ if (!geminiConfig.API_KEY) {
7
+ console.warn('Gemini API key not configured');
8
+ } else {
9
+ this.genAI = new GoogleGenerativeAI(geminiConfig.API_KEY);
10
+ }
11
+ }
12
+
13
+ async generateContent(prompt) {
14
+ if (!geminiConfig.API_KEY) {
15
+ throw new Error('Gemini API key not configured');
16
+ }
17
+
18
+ const model = this.genAI.getGenerativeModel({ model: geminiConfig.MODEL });
19
+ const result = await model.generateContent(prompt);
20
+ const response = await result.response;
21
+ const text = response.text();
22
+
23
+ return { text };
24
+ }
25
+
26
+ async summarizeNews(content, title = '') {
27
+ if (!geminiConfig.API_KEY) {
28
+ throw new Error('Gemini API key not configured');
29
+ }
30
+
31
+ const customInstruction = `
32
+ Nhiệm vụ: Dưới đây là bài tin tức. Hãy biên tập lại TOÀN BỘ các tin này thành một bản tin tổng hợp ngắn gọn, không được thiếu bất kì bài nào.
33
+
34
+ Yêu cầu biên tập:
35
+ 0. Chỉ lấy nội dung dài nhất của một bài viết để tóm tắt, tránh bị lạc đề do quảng cáo trong bài.
36
+ 1. Văn phong: Chính luận, trang trọng, gãy gọn, dứt khoát (đặc trưng của bản tin Thời sự).
37
+ 2. Cấu trúc:
38
+ - Nhóm các tin liên quan lại với nhau (nếu có).
39
+ - Mỗi tin được tóm lược thành 2-3 câu, rõ ràng, dễ hiểu.
40
+ 3. Độ dài: Mỗi tin khoảng 50-80 từ.
41
+ 4. Tuyệt đối khách quan, không lồng ghép cảm xúc cá nhân.
42
+ 5. Loại bỏ các bài trùng nội dung nếu có.
43
+ 6. Chỉ trả về nội dung bài nói, bắt đầu bằng cách nói bản tổng hợp này có gì, các đoạn sau
44
+ đọc phải có câu chuyển (ví dụ: "Tiếp theo là tin về...", "Chuyển sang tin tiếp theo...", "Tin cuối cùng...") và topic sentence.
45
+ 7. Không chú thích gì cả, phản hồi trông như lời nói của biên tập viên
46
+ 8. Không dùng formal markdown, chỉ phản hồi thuần văn bản.
47
+
48
+ Dữ liệu đầu vào:
49
+ - Tiêu đề gốc: ${title}
50
+ - Nội dung gốc:
51
+ ${content}
52
+
53
+ Hãy bắt đầu bản tin:
54
+ `;
55
+
56
+ const model = this.genAI.getGenerativeModel({ model: geminiConfig.MODEL });
57
+ const result = await model.generateContent(customInstruction);
58
+ const response = await result.response;
59
+ const text = response.text();
60
+
61
+ return { summary: text };
62
+ }
63
+
64
+ async summarizeMultipleNews(articles, query = '') {
65
+ if (!geminiConfig.API_KEY) {
66
+ throw new Error('Gemini API key not configured');
67
+ }
68
+
69
+ // Format tất cả các bài báo thành 1 prompt
70
+ let formattedContent = `Đóng vai: Biên tập viên Ban Thời sự - Đài Truyền hình Việt Nam (VTV).
71
+ Từ khóa tìm kiếm: "${query}"
72
+ Nhiệm vụ: Dưới đây là ${articles.length} bài tin tức. Hãy biên tập lại TOÀN BỘ các tin này thành một bản tin tổng hợp ngắn gọn.
73
+
74
+ Yêu cầu biên tập:
75
+ 0. **QUAN TRỌNG**: Kiểm tra độ liên quan:
76
+ - Nếu nội dung các bài báo KHÔNG liên quan đến từ khóa tìm kiếm "${query}", hãy DỪNG LẠI và chỉ trả về: "Không tìm được bài viết liên quan đến từ khóa này." nếu query là "tin tức việt nam + ngày" thì sumary bình thường.
77
+ - Chỉ tiếp tục tóm tắt các bài báo CÓ LIÊN QUAN đến từ khóa.
78
+ - Chỉ lấy nội dung dài nhất của một bài viết để tóm tắt, tránh bị lạc đề do quảng cáo trong bài.
79
+ 1. Văn phong: Chính luận, trang trọng, gãy gọn, dứt khoát (đặc trưng của bản tin Thời sự).
80
+ 2. Cấu trúc:
81
+ - Nhóm các tin liên quan lại với nhau (nếu có).
82
+ - Mỗi tin được tóm lược thành 2-3 câu, rõ ràng, dễ hiểu.
83
+ 3. Độ dài: Mỗi tin khoảng 50-80 từ.
84
+ 4. Tuyệt đối khách quan, không lồng ghép cảm xúc cá nhân.
85
+ 5. Loại bỏ các bài trùng nội dung nếu có.
86
+ 6. Chỉ trả về nội dung bài nói, bắt đầu bằng cách nói bản tổng hợp này có gì, các đoạn sau
87
+ đọc phải có câu chuyển (ví dụ: "Tiếp theo là tin về...", "Chuyển sang tin tiếp theo...", "Tin cuối cùng...") và topic sentence.
88
+ 7. Không chú thích gì cả, phản hồi trông như lời nói của biên tập viên
89
+ 8. Không dùng formal markdown, chỉ phản hồi thuần văn bản.
90
+ ---
91
+ DỮ LIỆU ĐẦU VÀO:
92
+
93
+ `;
94
+
95
+ articles.forEach((article, index) => {
96
+ formattedContent += `\n[BÀI ${index + 1}]\n`;
97
+ formattedContent += `Tiêu đề: ${article.title}\n`;
98
+ formattedContent += `Nguồn: ${article.source}\n`;
99
+ formattedContent += `Nội dung:\n${article.content}\n`;
100
+ formattedContent += `\n---\n`;
101
+ });
102
+
103
+ formattedContent += `\n\nHãy bắt đầu bản tin tổng hợp:`;
104
+
105
+ const model = this.genAI.getGenerativeModel({ model: geminiConfig.MODEL });
106
+ const result = await model.generateContent(formattedContent);
107
+ const response = await result.response;
108
+
109
+ // Check if response was blocked due to safety filters
110
+ if (!response.candidates || response.candidates.length === 0) {
111
+ throw new Error('PROHIBITED_CONTENT: Nội dung bị chặn bởi bộ lọc an toàn của Gemini AI');
112
+ }
113
+
114
+ const candidate = response.candidates[0];
115
+ if (candidate.finishReason === 'SAFETY') {
116
+ const safetyRatings = candidate.safetyRatings || [];
117
+ console.log('[GEMINI API] Content blocked by safety filters:', safetyRatings);
118
+ throw new Error('PROHIBITED_CONTENT: Nội dung vi phạm tiêu chuẩn an toàn');
119
+ }
120
+
121
+ let text;
122
+ try {
123
+ text = response.text();
124
+ } catch (textError) {
125
+ if (textError.message.includes('PROHIBITED_CONTENT') || textError.message.includes('blocked')) {
126
+ throw new Error('PROHIBITED_CONTENT: Nội dung không phù hợp được phát hiện');
127
+ }
128
+ throw textError;
129
+ }
130
+
131
+ return { summary: text, totalArticles: articles.length };
132
+ }
133
+ }
134
+
135
+ export default new GeminiService();
services/scrape.service.js ADDED
@@ -0,0 +1,256 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import axios from 'axios';
2
+ import * as cheerio from 'cheerio';
3
+ import puppeteer from 'puppeteer';
4
+ import puppeteerConfig from '../config/puppeteer.config.js';
5
+ import fs from 'fs';
6
+ import os from 'os';
7
+ import path from 'path';
8
+
9
+ // ─── Shared browser-like headers ──────────────────────────────────────────────
10
+ const BROWSER_HEADERS = {
11
+ 'User-Agent':
12
+ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
13
+ 'Accept':
14
+ 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8',
15
+ 'Accept-Language': 'vi-VN,vi;q=0.9,en-US;q=0.8,en;q=0.7',
16
+ 'Accept-Encoding': 'gzip, deflate, br',
17
+ 'Cache-Control': 'no-cache',
18
+ 'Pragma': 'no-cache',
19
+ 'Sec-Fetch-Dest': 'document',
20
+ 'Sec-Fetch-Mode': 'navigate',
21
+ 'Sec-Fetch-Site': 'none',
22
+ 'Upgrade-Insecure-Requests': '1',
23
+ };
24
+
25
+ // ─── Content selectors (ordered by priority) ──────────────────────────────────
26
+ const ARTICLE_SELECTORS = [
27
+ 'article',
28
+ '[class*="article-body"]',
29
+ '[class*="article-content"]',
30
+ '[class*="post-content"]',
31
+ '[class*="entry-content"]',
32
+ '[itemprop="articleBody"]',
33
+ '.content-detail', // VnExpress
34
+ '.fck_detail', // VnExpress
35
+ '#main-detail-body', // Tuổi Trẻ
36
+ '.detail-content', // Thanh Niên
37
+ '.singular-content', // Dân Trí
38
+ 'main',
39
+ '#content',
40
+ '.content',
41
+ ];
42
+
43
+ // ─── Fast scrape via axios + cheerio ──────────────────────────────────────────
44
+ async function scrapeWithAxios(url) {
45
+ const t0 = Date.now();
46
+ const shortUrl = url.substring(0, 60);
47
+ console.log(`[AXIOS] ⏳ Fetching: ${shortUrl}`);
48
+
49
+ const response = await axios.get(url, {
50
+ headers: { ...BROWSER_HEADERS, Referer: new URL(url).origin },
51
+ timeout: 8000, // 8s hard limit
52
+ maxRedirects: 5,
53
+ responseType: 'arraybuffer', // handle encoding correctly
54
+ });
55
+ console.log(`[AXIOS] ✅ Got HTTP ${response.status} in ${Date.now() - t0}ms — ${shortUrl}`);
56
+
57
+ // Detect charset from Content-Type header
58
+ const contentType = response.headers['content-type'] || '';
59
+ const charsetMatch = contentType.match(/charset=([^\s;]+)/i);
60
+ const charset = charsetMatch ? charsetMatch[1].toLowerCase() : 'utf-8';
61
+
62
+ let html;
63
+ try {
64
+ html = new TextDecoder(charset).decode(response.data);
65
+ } catch {
66
+ html = new TextDecoder('utf-8').decode(response.data);
67
+ }
68
+
69
+ const t1 = Date.now();
70
+ const $ = cheerio.load(html);
71
+
72
+ // Remove noise elements
73
+ $('script, style, noscript, nav, header, footer, aside, [class*="ads"], [class*="banner"], [id*="ads"], [class*="related"], [class*="comment"]').remove();
74
+
75
+ const title = $('title').text().trim() || $('h1').first().text().trim() || '';
76
+
77
+ // Try selectors in order
78
+ let text = '';
79
+ let foundSelector = 'none';
80
+ for (const sel of ARTICLE_SELECTORS) {
81
+ const el = $(sel).first();
82
+ const content = el.text().replace(/\s+/g, ' ').trim();
83
+ if (content.length > 300) {
84
+ text = content;
85
+ foundSelector = sel;
86
+ break;
87
+ }
88
+ }
89
+
90
+ // Fallback: body text
91
+ if (!text) {
92
+ text = $('body').text().replace(/\s+/g, ' ').trim();
93
+ foundSelector = 'body';
94
+ }
95
+
96
+ console.log(`[AXIOS] 📄 selector="${foundSelector}" chars=${text.length} parse=${Date.now()-t1}ms total=${Date.now()-t0}ms — ${shortUrl}`);
97
+
98
+ return {
99
+ title,
100
+ url,
101
+ text: text.substring(0, 3000),
102
+ selector: foundSelector,
103
+ textLength: text.length,
104
+ };
105
+ }
106
+
107
+ // ─── Slow scrape via Puppeteer (fallback) ─────────────────────────────────────
108
+ async function scrapeWithPuppeteer(url) {
109
+ let browser = null;
110
+ let tempDir = null;
111
+ const t0 = Date.now();
112
+ const shortUrl = url.substring(0, 60);
113
+
114
+ try {
115
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'puppeteer-'));
116
+ console.log(`[PUPPETEER] 🚀 Launching browser for: ${shortUrl}`);
117
+
118
+ browser = await puppeteer.launch({
119
+ headless: puppeteerConfig.HEADLESS,
120
+ userDataDir: tempDir,
121
+ args: [
122
+ ...puppeteerConfig.ARGS,
123
+ '--disable-blink-features=AutomationControlled',
124
+ '--disable-dev-shm-usage',
125
+ '--no-first-run',
126
+ '--no-default-browser-check',
127
+ ],
128
+ });
129
+
130
+ const page = await browser.newPage();
131
+ await page.setUserAgent(BROWSER_HEADERS['User-Agent']);
132
+ await page.setViewport({ width: 1920, height: 1080 });
133
+ await page.setExtraHTTPHeaders({
134
+ 'Accept-Language': BROWSER_HEADERS['Accept-Language'],
135
+ Accept: BROWSER_HEADERS['Accept'],
136
+ });
137
+
138
+ // Block heavy assets
139
+ await page.setRequestInterception(true);
140
+ page.on('request', (req) => {
141
+ const t = req.resourceType();
142
+ if (['image', 'stylesheet', 'font', 'media'].includes(t)) {
143
+ req.abort();
144
+ } else {
145
+ req.continue();
146
+ }
147
+ });
148
+
149
+ await page.evaluateOnNewDocument(() => {
150
+ Object.defineProperty(navigator, 'webdriver', { get: () => false });
151
+ window.chrome = { runtime: {} };
152
+ });
153
+
154
+ const tNav = Date.now();
155
+ console.log(`[PUPPETEER] ⏳ Navigating (browser launch took ${tNav - t0}ms)...`);
156
+ await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 25000 });
157
+ console.log(`[PUPPETEER] ✅ DOM loaded in ${Date.now() - tNav}ms — ${shortUrl}`);
158
+ await new Promise((r) => setTimeout(r, 800));
159
+
160
+ const data = await page.evaluate((selectors) => {
161
+ const removeEls = document.querySelectorAll(
162
+ 'script,style,noscript,nav,header,footer,aside'
163
+ );
164
+ removeEls.forEach((el) => el.remove());
165
+
166
+ const title =
167
+ document.title ||
168
+ document.querySelector('h1')?.innerText ||
169
+ '';
170
+ let text = '';
171
+ let foundSelector = 'none';
172
+
173
+ for (const sel of selectors) {
174
+ const el = document.querySelector(sel);
175
+ if (el && (el.innerText || '').length > 300) {
176
+ text = el.innerText;
177
+ foundSelector = sel;
178
+ break;
179
+ }
180
+ }
181
+
182
+ if (!text) {
183
+ text = document.body?.innerText || '';
184
+ foundSelector = 'body';
185
+ }
186
+
187
+ return {
188
+ title: title.trim(),
189
+ url: window.location.href,
190
+ text: text.replace(/\s+/g, ' ').trim().substring(0, 3000),
191
+ selector: foundSelector,
192
+ textLength: text.length,
193
+ };
194
+ }, ARTICLE_SELECTORS);
195
+
196
+ console.log(`[PUPPETEER] 📄 selector="${data.selector}" chars=${data.textLength} total=${Date.now()-t0}ms — ${shortUrl}`);
197
+ return data;
198
+ } finally {
199
+ if (browser) {
200
+ await browser.close();
201
+ await new Promise((r) => setTimeout(r, 500));
202
+ }
203
+ if (tempDir) {
204
+ try {
205
+ fs.rmSync(tempDir, { recursive: true, force: true });
206
+ } catch {
207
+ // ignore cleanup errors
208
+ }
209
+ }
210
+ }
211
+ }
212
+
213
+ const IS_PRODUCTION = process.env.NODE_ENV === 'production';
214
+
215
+ // ─── Public API ───────────────────────────────────────────────────────────────
216
+ class ScrapeService {
217
+ /**
218
+ * Production: axios+cheerio only (Puppeteer uses 150-300MB RAM per instance
219
+ * which OOM-kills the process on Render's 512MB free tier).
220
+ * Development: axios first, Puppeteer fallback if content is thin/blocked.
221
+ */
222
+ async scrapeUrl(url) {
223
+ try {
224
+ const data = await scrapeWithAxios(url);
225
+
226
+ if (data.textLength >= 300) {
227
+ return data;
228
+ }
229
+
230
+ // axios returned thin content
231
+ if (IS_PRODUCTION) {
232
+ console.warn(`[SCRAPE] axios thin content (${data.textLength} chars) — returning as-is (Puppeteer disabled in production)`);
233
+ return data; // return what we have; don't risk OOM
234
+ }
235
+
236
+ console.warn(`[SCRAPE] axios thin content (${data.textLength} chars) — falling back to Puppeteer`);
237
+ } catch (err) {
238
+ const status = err.response?.status;
239
+ const msg = `${status || err.message}`;
240
+
241
+ if (IS_PRODUCTION) {
242
+ console.warn(`[SCRAPE] axios failed (${msg}) — skipping Puppeteer in production, returning empty`);
243
+ // Return empty stub so the slot is skipped downstream
244
+ throw err;
245
+ }
246
+
247
+ console.warn(`[SCRAPE] axios failed (${msg}) — falling back to Puppeteer`);
248
+ }
249
+
250
+ // Development fallback only
251
+ return scrapeWithPuppeteer(url);
252
+ }
253
+ }
254
+
255
+ export default new ScrapeService();
256
+
services/tts.service.js ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import axios from 'axios';
2
+ import ttsConfig from '../config/tts.config.js';
3
+
4
+ class TtsService {
5
+ constructor() {
6
+ this.client = axios.create({
7
+ baseURL: ttsConfig.SERVICE_URL,
8
+ timeout: ttsConfig.TIMEOUT_MS
9
+ });
10
+ }
11
+
12
+ async health() {
13
+ const response = await this.client.get('/health');
14
+ return response.data;
15
+ }
16
+
17
+ async synthesize(payload) {
18
+ const response = await this.client.post('/v1/tts', payload);
19
+ return response.data;
20
+ }
21
+
22
+ async createTask(payload) {
23
+ const response = await this.client.post('/v1/tasks/tts', payload);
24
+ return response.data;
25
+ }
26
+
27
+ async getTask(taskId) {
28
+ const response = await this.client.get(`/v1/tasks/${encodeURIComponent(taskId)}`);
29
+ return response.data;
30
+ }
31
+ }
32
+
33
+ export default new TtsService();
services/ttsJob.service.js ADDED
@@ -0,0 +1,143 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { randomUUID } from 'crypto';
2
+ import { createClient } from '@supabase/supabase-js';
3
+ import supabaseConfig from '../config/supabase.config.js';
4
+ import audioTranscodeService from './audioTranscode.service.js';
5
+ import ttsService from './tts.service.js';
6
+
7
+ const MAX_JOBS = 300;
8
+
9
+ class TtsJobService {
10
+ constructor() {
11
+ this.jobs = new Map();
12
+ this.supabase = null;
13
+
14
+ if (supabaseConfig.URL && supabaseConfig.KEY) {
15
+ this.supabase = createClient(supabaseConfig.URL, supabaseConfig.KEY);
16
+ }
17
+ }
18
+
19
+ createJob(payload) {
20
+ const key = randomUUID();
21
+ const now = new Date().toISOString();
22
+
23
+ const job = {
24
+ key,
25
+ status: 'queued',
26
+ createdAt: now,
27
+ updatedAt: now,
28
+ error: null,
29
+ audioUrl: null
30
+ };
31
+
32
+ this.jobs.set(key, job);
33
+ this.trimJobs();
34
+
35
+ this.processJob(key, payload).catch((error) => {
36
+ this.updateJob(key, {
37
+ status: 'failed',
38
+ error: error.message || 'Unknown error'
39
+ });
40
+ });
41
+
42
+ return {
43
+ key,
44
+ status: 'queued',
45
+ createdAt: now
46
+ };
47
+ }
48
+
49
+ getJob(key) {
50
+ return this.jobs.get(key) || null;
51
+ }
52
+
53
+ updateJob(key, patch) {
54
+ const existing = this.jobs.get(key);
55
+ if (!existing) return;
56
+
57
+ this.jobs.set(key, {
58
+ ...existing,
59
+ ...patch,
60
+ updatedAt: new Date().toISOString()
61
+ });
62
+ }
63
+
64
+ trimJobs() {
65
+ if (this.jobs.size <= MAX_JOBS) return;
66
+
67
+ const removableCount = this.jobs.size - MAX_JOBS;
68
+ const keys = [...this.jobs.keys()].slice(0, removableCount);
69
+ keys.forEach((key) => this.jobs.delete(key));
70
+ }
71
+
72
+ async uploadAudio({ key, fileBuffer, format }) {
73
+ if (!this.supabase) {
74
+ throw new Error('Supabase is not configured. Please set SUPABASE_URL and SUPABASE_KEY');
75
+ }
76
+
77
+ const objectPath = `${key}.${format}`;
78
+ const contentType = format === 'mp3' ? 'audio/mpeg' : 'audio/wav';
79
+
80
+ const { error: uploadError } = await this.supabase.storage
81
+ .from(supabaseConfig.TTS_BUCKET)
82
+ .upload(objectPath, fileBuffer, {
83
+ contentType,
84
+ upsert: true,
85
+ cacheControl: '31536000'
86
+ });
87
+
88
+ if (uploadError) {
89
+ throw new Error(`Supabase upload failed: ${uploadError.message}`);
90
+ }
91
+
92
+ const publicResult = this.supabase.storage
93
+ .from(supabaseConfig.TTS_BUCKET)
94
+ .getPublicUrl(objectPath);
95
+
96
+ let audioUrl = publicResult?.data?.publicUrl || null;
97
+
98
+ if (!audioUrl) {
99
+ const signedResult = await this.supabase.storage
100
+ .from(supabaseConfig.TTS_BUCKET)
101
+ .createSignedUrl(objectPath, 60 * 60 * 24 * 7);
102
+
103
+ if (signedResult.error) {
104
+ throw new Error(`Supabase signed URL failed: ${signedResult.error.message}`);
105
+ }
106
+
107
+ audioUrl = signedResult.data.signedUrl;
108
+ }
109
+
110
+ return { audioUrl };
111
+ }
112
+
113
+ async processJob(key, payload) {
114
+ this.updateJob(key, {
115
+ status: 'processing',
116
+ error: null
117
+ });
118
+
119
+ const synthData = await ttsService.synthesize(payload);
120
+
121
+ if (!synthData?.audio_base64) {
122
+ throw new Error('FastAPI did not return audio data');
123
+ }
124
+
125
+ const wavBuffer = Buffer.from(synthData.audio_base64, 'base64');
126
+ const uploadBuffer = await audioTranscodeService.convertWavToMp3(wavBuffer);
127
+ const format = 'mp3';
128
+
129
+ const uploadResult = await this.uploadAudio({
130
+ key,
131
+ fileBuffer: uploadBuffer,
132
+ format
133
+ });
134
+
135
+ this.updateJob(key, {
136
+ status: 'completed',
137
+ error: null,
138
+ audioUrl: uploadResult.audioUrl
139
+ });
140
+ }
141
+ }
142
+
143
+ export default new TtsJobService();
src/App.css ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .material-symbols-outlined {
2
+ font-variation-settings: 'FILL' 0, 'wght' 450, 'GRAD' 0, 'opsz' 24;
3
+ }
4
+
5
+ @keyframes summary-spin {
6
+ from {
7
+ transform: rotate(0deg);
8
+ }
9
+ to {
10
+ transform: rotate(360deg);
11
+ }
12
+ }
13
+
14
+ @keyframes summary-shimmer {
15
+ 0% {
16
+ background-position: -220px 0;
17
+ }
18
+ 100% {
19
+ background-position: calc(220px + 100%) 0;
20
+ }
21
+ }
22
+
23
+ .summary-panel {
24
+ backdrop-filter: blur(2px);
25
+ }
26
+
27
+ .summary-spin {
28
+ animation: summary-spin 1.2s linear infinite;
29
+ }
30
+
31
+ .summary-shimmer {
32
+ background: linear-gradient(90deg, #e5e7eb 0px, #f8fafc 40px, #e5e7eb 80px);
33
+ background-size: 220px 100%;
34
+ animation: summary-shimmer 1.3s linear infinite;
35
+ }
36
+
37
+ @keyframes fade-in-up {
38
+ from {
39
+ opacity: 0;
40
+ transform: translateY(12px);
41
+ }
42
+ to {
43
+ opacity: 1;
44
+ transform: translateY(0);
45
+ }
46
+ }
47
+
48
+ main section,
49
+ main header {
50
+ animation: fade-in-up 0.45s ease-out both;
51
+ }
52
+
53
+ main section:nth-of-type(2) {
54
+ animation-delay: 80ms;
55
+ }
56
+
57
+ main section:nth-of-type(3) {
58
+ animation-delay: 160ms;
59
+ }
60
+
61
+ @media (max-width: 1023px) {
62
+ main section,
63
+ main header {
64
+ animation-duration: 0.35s;
65
+ }
66
+ }
src/App.jsx ADDED
@@ -0,0 +1,1288 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useRef, useState } from 'react'
2
+ import SearchBox from './components/SearchBox'
3
+ import FilterBar from './components/FilterBar'
4
+ import NewsArticle from './components/NewsArticle'
5
+ import SummaryBox from './components/SummaryBox'
6
+ import HomeNewsGrid from './components/HomeNewsGrid'
7
+ import apiService from './services/api.service'
8
+ import './App.css'
9
+
10
+ const HISTORY_KEY = 'newsai-search-history'
11
+ const DAILY_SNAPSHOT_KEY = 'newsai-daily-snapshot'
12
+ const MAX_HISTORY_ITEMS = 6
13
+ const SUMMARY_TTS_STORAGE_KEY = 'newsai-summary-tts-jobs'
14
+ const ARTICLE_TTS_STORAGE_KEY = 'newsai-article-tts-jobs'
15
+ const ACTIVE_TTS_STATUSES = new Set(['queued', 'processing'])
16
+
17
+ const createIdleTtsState = () => ({
18
+ key: '',
19
+ status: 'idle',
20
+ audioUrl: '',
21
+ error: '',
22
+ createdAt: '',
23
+ updatedAt: '',
24
+ })
25
+
26
+ const normalizeTtsState = (value) => {
27
+ if (!value || typeof value !== 'object') return createIdleTtsState()
28
+
29
+ return {
30
+ key: typeof value.key === 'string' ? value.key : '',
31
+ status: typeof value.status === 'string' ? value.status : 'idle',
32
+ audioUrl: typeof value.audioUrl === 'string' ? value.audioUrl : '',
33
+ error: typeof value.error === 'string' ? value.error : '',
34
+ createdAt: typeof value.createdAt === 'string' ? value.createdAt : '',
35
+ updatedAt: typeof value.updatedAt === 'string' ? value.updatedAt : '',
36
+ }
37
+ }
38
+
39
+ const normalizeTtsStateMap = (value) => {
40
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return {}
41
+
42
+ return Object.entries(value).reduce((accumulator, [key, state]) => {
43
+ if (!key || typeof key !== 'string') return accumulator
44
+ accumulator[key] = normalizeTtsState(state)
45
+ return accumulator
46
+ }, {})
47
+ }
48
+
49
+ const loadTtsStateMapFromStorage = (storageKey) => {
50
+ if (typeof window === 'undefined') return {}
51
+
52
+ try {
53
+ const raw = window.localStorage.getItem(storageKey)
54
+ if (!raw) return {}
55
+ const parsed = JSON.parse(raw)
56
+ return normalizeTtsStateMap(parsed)
57
+ } catch {
58
+ return {}
59
+ }
60
+ }
61
+
62
+ const saveTtsStateMapToStorage = (storageKey, value) => {
63
+ if (typeof window === 'undefined') return
64
+
65
+ try {
66
+ window.localStorage.setItem(storageKey, JSON.stringify(value))
67
+ } catch {
68
+ // Ignore storage quota and browser privacy mode errors.
69
+ }
70
+ }
71
+
72
+ const getSummarySignature = (text = '', voice = '') => (text.trim() + voice).toLowerCase().replace(/\s+/g, ' ').slice(0, 280)
73
+
74
+ const getArticleTtsSlot = (articleUrl, index) => {
75
+ if (typeof articleUrl === 'string' && articleUrl.trim() && articleUrl !== '#') {
76
+ return articleUrl
77
+ }
78
+ return `local-article-${index}`
79
+ }
80
+
81
+ const toTtsStateFromJob = (job, fallback = createIdleTtsState()) => {
82
+ return {
83
+ key: typeof job?.key === 'string' ? job.key : fallback.key,
84
+ status: typeof job?.status === 'string' ? job.status : fallback.status,
85
+ audioUrl: typeof job?.audioUrl === 'string' ? job.audioUrl : fallback.audioUrl,
86
+ error: typeof job?.error === 'string' ? job.error : '',
87
+ createdAt: typeof job?.createdAt === 'string' ? job.createdAt : fallback.createdAt,
88
+ updatedAt: typeof job?.updatedAt === 'string' ? job.updatedAt : fallback.updatedAt,
89
+ }
90
+ }
91
+
92
+ const isSameTtsState = (left, right) => {
93
+ if (!left && !right) return true
94
+ if (!left || !right) return false
95
+
96
+ return (
97
+ left.key === right.key &&
98
+ left.status === right.status &&
99
+ left.audioUrl === right.audioUrl &&
100
+ left.error === right.error &&
101
+ left.createdAt === right.createdAt &&
102
+ left.updatedAt === right.updatedAt
103
+ )
104
+ }
105
+
106
+ const getTtsErrorMessage = (error, fallbackMessage) => {
107
+ return error?.response?.data?.error || error?.response?.data?.details || error?.message || fallbackMessage
108
+ }
109
+
110
+ const getLocalDayKey = (date = new Date()) => {
111
+ const year = date.getFullYear();
112
+ const month = String(date.getMonth() + 1).padStart(2, '0');
113
+ const day = String(date.getDate()).padStart(2, '0');
114
+ return `${year}-${month}-${day}`;
115
+ };
116
+
117
+ const formatDateForQuery = (date = new Date()) => {
118
+ const day = String(date.getDate()).padStart(2, '0');
119
+ const month = String(date.getMonth() + 1).padStart(2, '0');
120
+ const year = date.getFullYear();
121
+ return `${day}/${month}/${year}`;
122
+ };
123
+
124
+ const getDailyAutoQuery = (date = new Date()) => `tin tức việt nam ${formatDateForQuery(date)}`;
125
+
126
+ const createHistoryId = () => {
127
+ if (globalThis.crypto?.randomUUID) {
128
+ return globalThis.crypto.randomUUID();
129
+ }
130
+ return `${Date.now()}-${Math.random().toString(16).slice(2)}`;
131
+ };
132
+
133
+ const normalizeHistoryEntry = (entry, index) => {
134
+ if (typeof entry === 'string') {
135
+ const query = entry.trim();
136
+ if (!query) return null;
137
+
138
+ return {
139
+ id: `legacy-${index}-${query.toLowerCase().replace(/\s+/g, '-')}`,
140
+ query,
141
+ createdAt: new Date(0).toISOString(),
142
+ dayKey: '',
143
+ autoDaily: false,
144
+ filters: {
145
+ voice: 'Bắc',
146
+ time: 'pd',
147
+ source: 'all',
148
+ },
149
+ articles: [],
150
+ summary: '',
151
+ summaryReady: false,
152
+ totalArticles: 0,
153
+ };
154
+ }
155
+
156
+ if (!entry || typeof entry !== 'object') return null;
157
+
158
+ const query = typeof entry.query === 'string' ? entry.query.trim() : '';
159
+ if (!query) return null;
160
+
161
+ return {
162
+ id: typeof entry.id === 'string' && entry.id ? entry.id : createHistoryId(),
163
+ query,
164
+ createdAt: typeof entry.createdAt === 'string' ? entry.createdAt : new Date().toISOString(),
165
+ dayKey: typeof entry.dayKey === 'string' ? entry.dayKey : '',
166
+ autoDaily: Boolean(entry.autoDaily),
167
+ filters: {
168
+ voice: entry.filters?.voice || 'Bắc',
169
+ time: entry.filters?.time || 'pd',
170
+ source: entry.filters?.source || 'all',
171
+ },
172
+ articles: Array.isArray(entry.articles) ? entry.articles : [],
173
+ summary: typeof entry.summary === 'string' ? entry.summary : '',
174
+ summaryReady:
175
+ typeof entry.summaryReady === 'boolean'
176
+ ? entry.summaryReady
177
+ : Boolean(typeof entry.summary === 'string' && entry.summary.trim().length > 0),
178
+ totalArticles: Number.isFinite(entry.totalArticles) ? entry.totalArticles : 0,
179
+ };
180
+ };
181
+
182
+ const loadHistoryFromStorage = () => {
183
+ if (typeof window === 'undefined') return [];
184
+
185
+ try {
186
+ const rawHistory = window.localStorage.getItem(HISTORY_KEY);
187
+ if (!rawHistory) return [];
188
+
189
+ const parsedHistory = JSON.parse(rawHistory);
190
+ if (!Array.isArray(parsedHistory)) return [];
191
+
192
+ return parsedHistory
193
+ .map((entry, index) => normalizeHistoryEntry(entry, index))
194
+ .filter((entry) => entry && !entry.autoDaily)
195
+ .slice(0, MAX_HISTORY_ITEMS);
196
+ } catch {
197
+ return [];
198
+ }
199
+ };
200
+
201
+ const loadDailySnapshotFromStorage = () => {
202
+ if (typeof window === 'undefined') return null;
203
+
204
+ try {
205
+ const rawSnapshot = window.localStorage.getItem(DAILY_SNAPSHOT_KEY);
206
+ if (!rawSnapshot) return null;
207
+
208
+ const parsedSnapshot = JSON.parse(rawSnapshot);
209
+ const normalizedSnapshot = normalizeHistoryEntry(parsedSnapshot, 0);
210
+ if (!normalizedSnapshot) return null;
211
+
212
+ return {
213
+ ...normalizedSnapshot,
214
+ autoDaily: true,
215
+ };
216
+ } catch {
217
+ return null;
218
+ }
219
+ };
220
+
221
+ const saveDailySnapshotToStorage = (entry) => {
222
+ if (typeof window === 'undefined') return;
223
+
224
+ try {
225
+ if (!entry) {
226
+ window.localStorage.removeItem(DAILY_SNAPSHOT_KEY);
227
+ return;
228
+ }
229
+
230
+ window.localStorage.setItem(DAILY_SNAPSHOT_KEY, JSON.stringify(entry));
231
+ } catch {
232
+ // Ignore storage quota and browser privacy mode errors.
233
+ }
234
+ };
235
+
236
+ function App() {
237
+ const [loading, setLoading] = useState(false);
238
+ const [summaryLoading, setSummaryLoading] = useState(false);
239
+ const [error, setError] = useState('');
240
+ const [articles, setArticles] = useState([]);
241
+ const [summary, setSummary] = useState(null);
242
+ const [totalArticles, setTotalArticles] = useState(0);
243
+
244
+ // Individual article summaries
245
+ const [articleSummaries, setArticleSummaries] = useState({});
246
+ const [articleSummaryLoading, setArticleSummaryLoading] = useState({});
247
+
248
+ // Filter states
249
+ const [voice, setVoice] = useState('Bắc');
250
+ const [time, setTime] = useState('pd');
251
+ const [source, setSource] = useState('all');
252
+ const [searchQuery, setSearchQuery] = useState('');
253
+ const [searchHistory, setSearchHistory] = useState([]);
254
+ const [historyReady, setHistoryReady] = useState(false);
255
+ const [summaryTtsJobs, setSummaryTtsJobs] = useState(() => loadTtsStateMapFromStorage(SUMMARY_TTS_STORAGE_KEY));
256
+ const [articleTtsJobs, setArticleTtsJobs] = useState(() => loadTtsStateMapFromStorage(ARTICLE_TTS_STORAGE_KEY));
257
+ const [isMobileSummaryOpen, setIsMobileSummaryOpen] = useState(false);
258
+ const [useDailyHomeLayout, setUseDailyHomeLayout] = useState(true);
259
+ const hasBootstrappedRef = useRef(false);
260
+ const handleSearchRef = useRef(() => {});
261
+
262
+ const closeMobileSummaryPanel = () => {
263
+ setIsMobileSummaryOpen(false);
264
+ };
265
+
266
+ const toggleMobileSummaryPanel = () => {
267
+ setIsMobileSummaryOpen((previous) => !previous);
268
+ };
269
+
270
+ useEffect(() => {
271
+ if (!historyReady || typeof window === 'undefined') return;
272
+
273
+ try {
274
+ const manualHistory = searchHistory.filter((entry) => !entry.autoDaily);
275
+ window.localStorage.setItem(HISTORY_KEY, JSON.stringify(manualHistory));
276
+ } catch {
277
+ // Ignore storage quota and browser privacy mode errors.
278
+ }
279
+ }, [searchHistory, historyReady]);
280
+
281
+ useEffect(() => {
282
+ saveTtsStateMapToStorage(SUMMARY_TTS_STORAGE_KEY, summaryTtsJobs);
283
+ }, [summaryTtsJobs]);
284
+
285
+ useEffect(() => {
286
+ saveTtsStateMapToStorage(ARTICLE_TTS_STORAGE_KEY, articleTtsJobs);
287
+ }, [articleTtsJobs]);
288
+
289
+ useEffect(() => {
290
+ const pendingSummaryJobs = Object.entries(summaryTtsJobs).filter(
291
+ ([, state]) => state.key && ACTIVE_TTS_STATUSES.has(state.status)
292
+ );
293
+ const pendingArticleJobs = Object.entries(articleTtsJobs).filter(
294
+ ([, state]) => state.key && ACTIVE_TTS_STATUSES.has(state.status)
295
+ );
296
+
297
+ if (pendingSummaryJobs.length === 0 && pendingArticleJobs.length === 0) {
298
+ return;
299
+ }
300
+
301
+ let canceled = false;
302
+ let pollTimer = null;
303
+
304
+ const fetchJobState = async (state) => {
305
+ try {
306
+ const job = await apiService.getTtsJob(state.key);
307
+ return toTtsStateFromJob(job, state);
308
+ } catch (error) {
309
+ if (error?.response?.status === 404) {
310
+ return {
311
+ ...state,
312
+ status: 'failed',
313
+ error: 'Không tìm thấy tiến trình TTS trên server.',
314
+ };
315
+ }
316
+
317
+ return state;
318
+ }
319
+ };
320
+
321
+ const pollPendingJobs = async () => {
322
+ const [summaryUpdates, articleUpdates] = await Promise.all([
323
+ Promise.all(
324
+ pendingSummaryJobs.map(async ([slot, state]) => {
325
+ const nextState = await fetchJobState(state);
326
+ return [slot, nextState];
327
+ })
328
+ ),
329
+ Promise.all(
330
+ pendingArticleJobs.map(async ([slot, state]) => {
331
+ const nextState = await fetchJobState(state);
332
+ return [slot, nextState];
333
+ })
334
+ ),
335
+ ]);
336
+
337
+ if (canceled) return;
338
+
339
+ if (summaryUpdates.length > 0) {
340
+ setSummaryTtsJobs((previous) => {
341
+ let changed = false;
342
+ const next = { ...previous };
343
+
344
+ summaryUpdates.forEach(([slot, nextState]) => {
345
+ const currentState = previous[slot];
346
+ if (!currentState || isSameTtsState(currentState, nextState)) return;
347
+ next[slot] = normalizeTtsState(nextState);
348
+ changed = true;
349
+ });
350
+
351
+ return changed ? next : previous;
352
+ });
353
+ }
354
+
355
+ if (articleUpdates.length > 0) {
356
+ setArticleTtsJobs((previous) => {
357
+ let changed = false;
358
+ const next = { ...previous };
359
+
360
+ articleUpdates.forEach(([slot, nextState]) => {
361
+ const currentState = previous[slot];
362
+ if (!currentState || isSameTtsState(currentState, nextState)) return;
363
+ next[slot] = normalizeTtsState(nextState);
364
+ changed = true;
365
+ });
366
+
367
+ return changed ? next : previous;
368
+ });
369
+ }
370
+
371
+ pollTimer = window.setTimeout(pollPendingJobs, 2200);
372
+ };
373
+
374
+ pollPendingJobs();
375
+
376
+ return () => {
377
+ canceled = true;
378
+ if (pollTimer) {
379
+ window.clearTimeout(pollTimer);
380
+ }
381
+ };
382
+ }, [summaryTtsJobs, articleTtsJobs]);
383
+
384
+ const mapBraveResultToArticle = (result) => {
385
+ // Helper function to strip HTML tags
386
+ const stripHtml = (html) => {
387
+ if (!html) return '';
388
+ const tmp = document.createElement('DIV');
389
+ tmp.innerHTML = html;
390
+ return tmp.textContent || tmp.innerText || '';
391
+ };
392
+
393
+ // Determine category color based on content or default
394
+ // Map từ Brave subtype sang UI style
395
+ const SUBTYPE_MAPPING = {
396
+ // Tin tức / Bài viết chung
397
+ article: { name: 'Tin tức', tone: 'news' },
398
+ news: { name: 'Thời sự', tone: 'news' },
399
+
400
+ // Mua sắm / Sản phẩm
401
+ product: { name: 'Mua sắm', tone: 'commerce' },
402
+
403
+ // Ẩm thực / Công thức
404
+ recipe: { name: 'Ẩm thực', tone: 'culture' },
405
+
406
+ // Hỏi đáp / Diễn đàn
407
+ qa: { name: 'Hỏi đáp', tone: 'discussion' },
408
+ discussion: { name: 'Thảo luận', tone: 'discussion' },
409
+
410
+ // Đánh giá / Review
411
+ review: { name: 'Review', tone: 'review' },
412
+
413
+ // Video (Cái này thường nằm ở type 'video_result' nhưng map luôn cho chắc)
414
+ video_result: { name: 'Video', tone: 'video' },
415
+
416
+ // Phim ảnh
417
+ movie: { name: 'Phim ảnh', tone: 'culture' },
418
+
419
+ // Mặc định (generic)
420
+ generic: { name: 'Kết quả', tone: 'generic' },
421
+ };
422
+
423
+ const getCategoryStyle = (subtype) => {
424
+ const key = subtype?.toLowerCase() || 'generic';
425
+ return SUBTYPE_MAPPING[key] || SUBTYPE_MAPPING.generic;
426
+ };
427
+
428
+ const style = getCategoryStyle(result.subtype);
429
+
430
+ return {
431
+ category: style.name,
432
+ categoryTone: style.tone,
433
+ source: result.profile?.name || result.meta_url?.hostname || 'Unknown',
434
+ timeAgo: result.age || "",
435
+ title: stripHtml(result.title) || 'Không có tiêu đề',
436
+ description: stripHtml(result.description) || '',
437
+ imageUrl: result.thumbnail?.src || result.thumbnail?.original || 'https://placehold.co/400x300?text=No+Image',
438
+ imageAlt: stripHtml(result.title) || 'News image',
439
+ articleUrl: result.url || '#'
440
+ };
441
+ };
442
+
443
+ const applyHistorySnapshot = (entry) => {
444
+ setError('');
445
+ setLoading(false);
446
+ setSummaryLoading(false);
447
+ setArticleSummaries({});
448
+ setArticleSummaryLoading({});
449
+
450
+ setArticles(Array.isArray(entry.articles) ? entry.articles : []);
451
+ setSummary(entry.summary || null);
452
+ setTotalArticles(entry.totalArticles || entry.articles?.length || 0);
453
+ };
454
+
455
+ const upsertHistoryEntry = ({ query, articles: nextArticles, summary: nextSummary, totalCount, autoDaily = false }) => {
456
+ const normalizedQuery = query.trim();
457
+ if (!normalizedQuery || !Array.isArray(nextArticles) || nextArticles.length === 0) return;
458
+
459
+ const entry = {
460
+ id: createHistoryId(),
461
+ query: normalizedQuery,
462
+ createdAt: new Date().toISOString(),
463
+ dayKey: getLocalDayKey(),
464
+ autoDaily,
465
+ filters: {
466
+ voice,
467
+ time,
468
+ source,
469
+ },
470
+ articles: nextArticles,
471
+ summary: nextSummary || '',
472
+ summaryReady: Boolean(nextSummary && nextSummary.trim().length > 0),
473
+ totalArticles: totalCount || nextArticles.length,
474
+ };
475
+
476
+ if (autoDaily) {
477
+ saveDailySnapshotToStorage(entry);
478
+ return;
479
+ }
480
+
481
+ setSearchHistory((previous) => {
482
+ return [entry, ...previous].slice(0, MAX_HISTORY_ITEMS);
483
+ });
484
+ };
485
+
486
+ const handleHistorySelect = (entry) => {
487
+ if (!entry) return;
488
+
489
+ const selectedEntry = searchHistory.find((item) => item.id === entry.id) || entry;
490
+ const hasSnapshot = (selectedEntry.articles?.length || 0) > 0 || Boolean(selectedEntry.summary);
491
+ const shouldUseHomeLayout = Boolean(selectedEntry.autoDaily && selectedEntry.dayKey === getLocalDayKey());
492
+ setUseDailyHomeLayout(shouldUseHomeLayout);
493
+
494
+ setSearchQuery(selectedEntry.query || '');
495
+ setVoice(selectedEntry.filters?.voice || 'Bắc');
496
+ setTime(selectedEntry.filters?.time || 'pd');
497
+ setSource(selectedEntry.filters?.source || 'all');
498
+ closeMobileSummaryPanel();
499
+
500
+ if (hasSnapshot) {
501
+ applyHistorySnapshot(selectedEntry);
502
+
503
+ setSearchHistory((previous) => {
504
+ const filtered = previous.filter((item) => item.id !== selectedEntry.id);
505
+ return [selectedEntry, ...filtered].slice(0, MAX_HISTORY_ITEMS);
506
+ });
507
+ return;
508
+ }
509
+
510
+ handleSearch(selectedEntry.query, { autoDaily: false });
511
+ };
512
+
513
+ const handleArticleSummarize = async (articleUrl, index) => {
514
+ const currentSummary = articleSummaries[index];
515
+
516
+ // If summary exists, toggle visibility
517
+ if (currentSummary?.content) {
518
+ setArticleSummaries(prev => ({
519
+ ...prev,
520
+ [index]: {
521
+ ...prev[index],
522
+ visible: !prev[index].visible
523
+ }
524
+ }));
525
+ return;
526
+ }
527
+
528
+ // If no summary yet, fetch it
529
+ setArticleSummaryLoading(prev => ({ ...prev, [index]: true }));
530
+ const articleTtsSlot = getArticleTtsSlot(articleUrl, index);
531
+
532
+ try {
533
+ const data = await apiService.scrape(articleUrl);
534
+ const summaryData = await apiService.summarizeNews(data.text, data.title);
535
+
536
+ setArticleSummaries(prev => ({
537
+ ...prev,
538
+ [index]: {
539
+ content: summaryData.summary,
540
+ visible: true
541
+ }
542
+ }));
543
+
544
+ setArticleTtsJobs((previous) => {
545
+ if (!previous[articleTtsSlot]) return previous;
546
+ const next = { ...previous };
547
+ delete next[articleTtsSlot];
548
+ return next;
549
+ });
550
+ } catch (err) {
551
+ console.error('Article summarize error:', err);
552
+ setArticleSummaries(prev => ({
553
+ ...prev,
554
+ [index]: {
555
+ content: 'Không thể tóm tắt bài viết này: ' + (err.response?.data?.error || err.message),
556
+ visible: true
557
+ }
558
+ }));
559
+ } finally {
560
+ setArticleSummaryLoading(prev => ({ ...prev, [index]: false }));
561
+ }
562
+ };
563
+
564
+ const handleGenerateSummaryTts = async () => {
565
+ const summaryText = typeof summary === 'string' ? summary.trim() : '';
566
+ if (!summaryText) return;
567
+
568
+ const signature = getSummarySignature(summaryText, voice);
569
+ if (!signature) return;
570
+
571
+ const currentState = summaryTtsJobs[signature] || createIdleTtsState();
572
+ if (ACTIVE_TTS_STATUSES.has(currentState.status)) return;
573
+
574
+ const VOICE_MAP = {
575
+ 'Bắc': 'nam_bac.wav',
576
+ 'Nam': 'nu_nam.wav',
577
+ 'Trung': 'nam_trung.wav'
578
+ };
579
+
580
+ setSummaryTtsJobs((previous) => ({
581
+ ...previous,
582
+ [signature]: {
583
+ ...normalizeTtsState(previous[signature]),
584
+ status: 'queued',
585
+ audioUrl: '',
586
+ error: '',
587
+ },
588
+ }));
589
+
590
+ try {
591
+ const speakerAudio = VOICE_MAP[voice] || 'nam_bac.wav';
592
+ const createdJob = await apiService.createTtsJob(summaryText, {
593
+ language: 'vi',
594
+ speakerAudio
595
+ });
596
+
597
+ if (!createdJob?.key) {
598
+ throw new Error('Không nhận được key TTS từ server.');
599
+ }
600
+
601
+ setSummaryTtsJobs((previous) => ({
602
+ ...previous,
603
+ [signature]: {
604
+ ...normalizeTtsState(previous[signature]),
605
+ key: createdJob.key,
606
+ status: createdJob.status || 'queued',
607
+ createdAt: createdJob.createdAt || previous[signature]?.createdAt || '',
608
+ error: '',
609
+ },
610
+ }));
611
+ } catch (err) {
612
+ setSummaryTtsJobs((previous) => ({
613
+ ...previous,
614
+ [signature]: {
615
+ ...normalizeTtsState(previous[signature]),
616
+ status: 'failed',
617
+ error: getTtsErrorMessage(err, 'Không thể tạo audio cho bản tóm tắt.'),
618
+ },
619
+ }));
620
+ }
621
+ };
622
+
623
+ const handleGenerateArticleTts = async (articleUrl, index) => {
624
+ const summaryText = articleSummaries[index]?.content?.trim();
625
+ const slot = getArticleTtsSlot(articleUrl, index);
626
+
627
+ if (!summaryText || summaryText.startsWith('Không thể tóm tắt')) {
628
+ setArticleTtsJobs((previous) => ({
629
+ ...previous,
630
+ [slot]: {
631
+ ...normalizeTtsState(previous[slot]),
632
+ status: 'failed',
633
+ 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.',
634
+ audioUrl: '',
635
+ },
636
+ }));
637
+ return;
638
+ }
639
+
640
+ const currentState = articleTtsJobs[slot] || createIdleTtsState();
641
+ if (ACTIVE_TTS_STATUSES.has(currentState.status)) return;
642
+
643
+ const VOICE_MAP = {
644
+ 'Bắc': 'nam_bac.wav',
645
+ 'Nam': 'nu_nam.wav',
646
+ 'Trung': 'nam_trung.wav'
647
+ };
648
+
649
+ try {
650
+ const speakerAudio = VOICE_MAP[voice] || 'nam_bac.wav';
651
+ const createdJob = await apiService.createTtsJob(summaryText, {
652
+ language: 'vi',
653
+ speakerAudio
654
+ });
655
+
656
+ if (!createdJob?.key) {
657
+ throw new Error('Không nhận được key TTS từ server.');
658
+ }
659
+
660
+ setArticleTtsJobs((previous) => ({
661
+ ...previous,
662
+ [slot]: {
663
+ ...normalizeTtsState(previous[slot]),
664
+ key: createdJob.key,
665
+ status: createdJob.status || 'queued',
666
+ createdAt: createdJob.createdAt || previous[slot]?.createdAt || '',
667
+ error: '',
668
+ },
669
+ }));
670
+ } catch (err) {
671
+ setArticleTtsJobs((previous) => ({
672
+ ...previous,
673
+ [slot]: {
674
+ ...normalizeTtsState(previous[slot]),
675
+ status: 'failed',
676
+ error: getTtsErrorMessage(err, 'Không thể tạo audio cho bài viết này.'),
677
+ },
678
+ }));
679
+ }
680
+ };
681
+
682
+ const handleDailyCardSummarize = async (articleUrl) => {
683
+ if (!articleUrl || articleUrl === '#') return;
684
+
685
+ setError('');
686
+ setSummaryLoading(true);
687
+
688
+ try {
689
+ const data = await apiService.scrape(articleUrl);
690
+ const summaryData = await apiService.summarizeNews(data.text, data.title);
691
+ setSummary(summaryData.summary || '');
692
+ setTotalArticles(1);
693
+ setIsMobileSummaryOpen(true);
694
+ } catch (err) {
695
+ setError('Không thể tóm tắt bài viết này: ' + (err.response?.data?.error || err.message));
696
+ } finally {
697
+ setSummaryLoading(false);
698
+ }
699
+ };
700
+
701
+ const handleSearch = async (rawQuery = searchQuery, options = {}) => {
702
+ const { autoDaily = false } = options;
703
+ const query = rawQuery.trim();
704
+ if (!query) return;
705
+
706
+ closeMobileSummaryPanel();
707
+ setUseDailyHomeLayout(Boolean(autoDaily));
708
+ setSearchQuery(query);
709
+
710
+ setLoading(true);
711
+ setSummaryLoading(false);
712
+ setError('');
713
+ setSummary(null);
714
+ setTotalArticles(0);
715
+ setArticles([]);
716
+ setArticleSummaries({});
717
+ setArticleSummaryLoading({});
718
+ const isUrl = /^https?:\/\//i.test(query);
719
+ let nextArticles = [];
720
+ let nextSummary = '';
721
+ let nextTotalArticles = 0;
722
+
723
+ try {
724
+ if (isUrl) {
725
+ // Scrape the URL and summarize
726
+ const data = await apiService.scrape(query);
727
+ console.log('Scraped data:', data);
728
+
729
+ // Create article from scraped content
730
+ const article = {
731
+ category: "Tin tức",
732
+ categoryTone: 'news',
733
+ source: new URL(query).hostname,
734
+ timeAgo: "Vừa xong",
735
+ title: data.title || 'Không có tiêu đề',
736
+ description: data.text?.substring(0, 200) + '...' || '',
737
+ imageUrl: 'https://placehold.co/400x300?text=No+Image',
738
+ imageAlt: data.title || 'Scraped content',
739
+ articleUrl: query
740
+ };
741
+
742
+ nextArticles = [article];
743
+ setArticles(nextArticles);
744
+ setLoading(false);
745
+ setSummaryLoading(true);
746
+
747
+ // Tóm tắt bài báo đơn lẻ
748
+ try {
749
+ const summaryData = await apiService.summarizeNews(data.text, data.title);
750
+ nextSummary = summaryData.summary || '';
751
+ nextTotalArticles = 1;
752
+ setSummary(nextSummary || null);
753
+ setTotalArticles(1);
754
+ } catch (err) {
755
+ console.error('Summarize error:', err);
756
+ setError('Không thể tóm tắt bài viết này.');
757
+ } finally {
758
+ setSummaryLoading(false);
759
+ }
760
+ } else {
761
+ // Search first to show articles immediately
762
+ const searchOptions = {
763
+ freshness: time,
764
+ language: source === 'all' ? 'vi' : source
765
+ };
766
+
767
+ console.log('Searching...');
768
+ const searchData = await apiService.search(query, searchOptions);
769
+ console.log('Search results:', searchData);
770
+
771
+ // Check for family_friendly at the top level
772
+ if (searchData.web?.family_friendly === false || searchData.news?.family_friendly === false) {
773
+ 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.');
774
+ setArticles([]);
775
+ setLoading(false);
776
+ return;
777
+ }
778
+
779
+ // Map Brave results to articles and show immediately
780
+ let urls = [];
781
+ let hasUnsafeContent = false;
782
+
783
+ if (searchData.news?.results && searchData.news.results.length > 0) {
784
+ // Check for unsafe content
785
+ const unsafeResults = searchData.news.results.filter(r => r.family_friendly === false);
786
+ if (unsafeResults.length > 0) {
787
+ hasUnsafeContent = true;
788
+ }
789
+
790
+ const mappedArticles = searchData.news.results
791
+ .filter(result => {
792
+ // Loại bỏ nội dung không phù hợp
793
+ if (result.family_friendly === false) {
794
+ return false;
795
+ }
796
+ // Loại bỏ video và image results
797
+ const isVideoType = result.type === 'video_result' || result.subtype === 'video';
798
+ const hasVideo = result.video !== undefined;
799
+ const isImage = result.type === 'image_result';
800
+ const isVideoUrl = result.url?.includes('youtube.com') ||
801
+ result.url?.includes('tiktok.com') ||
802
+ result.url?.includes('youtu.be');
803
+ return !isVideoType && !hasVideo && !isImage && !isVideoUrl;
804
+ })
805
+ .slice(0, 10)
806
+ .map((result) => mapBraveResultToArticle(result));
807
+
808
+ // Check if all results were filtered out
809
+ if (mappedArticles.length === 0) {
810
+ if (hasUnsafeContent) {
811
+ 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.');
812
+ } else {
813
+ setError('Không tìm thấy kết quả nào');
814
+ }
815
+ setArticles([]);
816
+ setLoading(false);
817
+ return;
818
+ }
819
+
820
+ nextArticles = mappedArticles;
821
+ setArticles(nextArticles);
822
+ urls = searchData.news.results
823
+ .filter(result => {
824
+ if (result.family_friendly === false) {
825
+ return false;
826
+ }
827
+ const isVideoType = result.type === 'video_result' || result.subtype === 'video';
828
+ const hasVideo = result.video !== undefined;
829
+ const isImage = result.type === 'image_result';
830
+ const isVideoUrl = result.url?.includes('youtube.com') ||
831
+ result.url?.includes('tiktok.com') ||
832
+ result.url?.includes('youtu.be');
833
+ return !isVideoType && !hasVideo && !isImage && !isVideoUrl;
834
+ })
835
+ .slice(0, 10)
836
+ .map(r => r.url);
837
+ } else if (searchData.web?.results && searchData.web.results.length > 0) {
838
+ // Check for unsafe content
839
+ const unsafeResults = searchData.web.results.filter(r => r.family_friendly === false);
840
+ if (unsafeResults.length > 0) {
841
+ hasUnsafeContent = true;
842
+ }
843
+
844
+ const mappedArticles = searchData.web.results
845
+ .filter(result => {
846
+ // Loại bỏ nội dung không phù hợp
847
+ if (result.family_friendly === false) {
848
+ return false;
849
+ }
850
+ // Chỉ lấy search_result thông thường, bỏ video/image
851
+ const isSearchResult = result.type === 'search_result';
852
+ const isVideoType = result.subtype === 'video';
853
+ const hasVideo = result.video !== undefined;
854
+ const isImage = result.type === 'image_result';
855
+ const isVideoUrl = result.url?.includes('youtube.com') ||
856
+ result.url?.includes('tiktok.com') ||
857
+ result.url?.includes('youtu.be');
858
+ return isSearchResult && !isVideoType && !hasVideo && !isImage && !isVideoUrl;
859
+ })
860
+ .slice(0, 10)
861
+ .map((result) => mapBraveResultToArticle(result));
862
+
863
+ // Check if all results were filtered out
864
+ if (mappedArticles.length === 0) {
865
+ if (hasUnsafeContent) {
866
+ 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.');
867
+ } else {
868
+ setError('Không tìm thấy kết quả nào');
869
+ }
870
+ setArticles([]);
871
+ setLoading(false);
872
+ return;
873
+ }
874
+
875
+ nextArticles = mappedArticles;
876
+ setArticles(nextArticles);
877
+ urls = searchData.web.results
878
+ .filter(result => {
879
+ if (result.family_friendly === false) {
880
+ return false;
881
+ }
882
+ const isSearchResult = result.type === 'search_result';
883
+ const isVideoType = result.subtype === 'video';
884
+ const hasVideo = result.video !== undefined;
885
+ const isImage = result.type === 'image_result';
886
+ const isVideoUrl = result.url?.includes('youtube.com') ||
887
+ result.url?.includes('tiktok.com') ||
888
+ result.url?.includes('youtu.be');
889
+ return isSearchResult && !isVideoType && !hasVideo && !isImage && !isVideoUrl;
890
+ })
891
+ .slice(0, 10)
892
+ .map(r => r.url);
893
+ } else {
894
+ setError('Không tìm thấy kết quả nào');
895
+ setArticles([]);
896
+ setLoading(false);
897
+ return;
898
+ }
899
+
900
+ // Now summarize in background
901
+ setLoading(false); // End search loading
902
+ setSummaryLoading(true); // Start summary loading
903
+
904
+ try {
905
+ console.log('Summarizing articles...');
906
+ const data = await apiService.scrapeAndSummarize(urls, query);
907
+ console.log('Summary results:', data);
908
+
909
+ if (data.summary) {
910
+ nextSummary = data.summary;
911
+ nextTotalArticles = data.totalArticles || nextArticles.length;
912
+ setSummary(nextSummary);
913
+ setTotalArticles(nextTotalArticles);
914
+ }
915
+ } catch (err) {
916
+ console.error('Summarize error:', err);
917
+ setError('Không thể tóm tắt: ' + (err.response?.data?.error || err.message));
918
+ } finally {
919
+ setSummaryLoading(false);
920
+ }
921
+ }
922
+
923
+ if (nextArticles.length > 0) {
924
+ upsertHistoryEntry({
925
+ query,
926
+ articles: nextArticles,
927
+ summary: nextSummary,
928
+ totalCount: nextTotalArticles || nextArticles.length,
929
+ autoDaily,
930
+ });
931
+ }
932
+ } catch (err) {
933
+ setError(err.response?.data?.error || 'Đã xảy ra lỗi khi xử lý yêu cầu');
934
+ console.error('Error:', err);
935
+ setLoading(false);
936
+ setSummaryLoading(false);
937
+ } finally {
938
+ // Ensure loading is stopped
939
+ if (isUrl) {
940
+ setLoading(false);
941
+ }
942
+ }
943
+ };
944
+
945
+ const handleDailyResummary = async () => {
946
+ if (loading || summaryLoading) return;
947
+
948
+ const urls = articles
949
+ .map((article) => article.articleUrl)
950
+ .filter((url) => typeof url === 'string' && /^https?:\/\//i.test(url));
951
+
952
+ if (urls.length === 0) {
953
+ setError('Không có bài viết hợp lệ để tóm tắt lại.');
954
+ return;
955
+ }
956
+
957
+ const dailyQuery = getDailyAutoQuery();
958
+
959
+ setError('');
960
+ setSummaryLoading(true);
961
+
962
+ try {
963
+ const data = await apiService.scrapeAndSummarize(urls, dailyQuery);
964
+ const nextSummary = data.summary || '';
965
+
966
+ if (!nextSummary.trim()) {
967
+ setError('Không nhận được bản tóm tắt mới. Vui lòng thử lại.');
968
+ return;
969
+ }
970
+
971
+ const nextTotalArticles = data.totalArticles || urls.length;
972
+ setSummary(nextSummary);
973
+ setTotalArticles(nextTotalArticles);
974
+
975
+ upsertHistoryEntry({
976
+ query: dailyQuery,
977
+ articles,
978
+ summary: nextSummary,
979
+ totalCount: nextTotalArticles,
980
+ autoDaily: true,
981
+ });
982
+ } catch (err) {
983
+ setError('Không thể tóm tắt lại: ' + (err.response?.data?.error || err.message));
984
+ } finally {
985
+ setSummaryLoading(false);
986
+ }
987
+ };
988
+
989
+ handleSearchRef.current = handleSearch;
990
+
991
+ const summarySignature = getSummarySignature(summary || '');
992
+ const summaryTtsState = summarySignature
993
+ ? normalizeTtsState(summaryTtsJobs[summarySignature])
994
+ : createIdleTtsState();
995
+
996
+ // Bootstrap cache on first render:
997
+ // 1) Load manual search history for sidebar
998
+ // 2) Load today's daily snapshot from dedicated storage
999
+ // 3) Otherwise trigger daily auto-search once
1000
+ useEffect(() => {
1001
+ if (hasBootstrappedRef.current) return;
1002
+ hasBootstrappedRef.current = true;
1003
+
1004
+ const persistedHistory = loadHistoryFromStorage();
1005
+ const persistedDailySnapshot = loadDailySnapshotFromStorage();
1006
+ setSearchHistory(persistedHistory);
1007
+ setHistoryReady(true);
1008
+
1009
+ const todayKey = getLocalDayKey();
1010
+ const dailyAutoQuery = getDailyAutoQuery();
1011
+ const todayAutoEntry =
1012
+ persistedDailySnapshot &&
1013
+ persistedDailySnapshot.dayKey === todayKey &&
1014
+ persistedDailySnapshot.articles.length > 0 &&
1015
+ persistedDailySnapshot.summaryReady
1016
+ ? persistedDailySnapshot
1017
+ : null;
1018
+
1019
+ if (todayAutoEntry) {
1020
+ setSearchQuery(dailyAutoQuery);
1021
+ setUseDailyHomeLayout(true);
1022
+ setVoice(todayAutoEntry.filters?.voice || 'Bắc');
1023
+ setTime(todayAutoEntry.filters?.time || 'pd');
1024
+ setSource(todayAutoEntry.filters?.source || 'all');
1025
+ applyHistorySnapshot(todayAutoEntry);
1026
+ return;
1027
+ }
1028
+
1029
+ setSearchQuery(dailyAutoQuery);
1030
+ handleSearchRef.current(dailyAutoQuery, { autoDaily: true });
1031
+ }, []);
1032
+
1033
+ return (
1034
+ <div className="min-h-screen bg-background text-on-background font-body selection:bg-black selection:text-white">
1035
+ <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">
1036
+ <div className="mb-10 flex items-center gap-3">
1037
+ <div className="flex h-10 w-10 items-center justify-center bg-black text-white">
1038
+ <span className="material-symbols-outlined text-2xl">neurology</span>
1039
+ </div>
1040
+ <div>
1041
+ <h1 className="text-xl font-black uppercase tracking-tight">NewsAI</h1>
1042
+ <p className="mt-1 text-[10px] font-extrabold uppercase tracking-[0.2em] text-slate-500">
1043
+ Digital Curator
1044
+ </p>
1045
+ </div>
1046
+ </div>
1047
+
1048
+ <div className="flex-1">
1049
+ <h2 className="mb-5 flex items-center gap-2 text-[11px] font-black uppercase tracking-[0.15em] text-black">
1050
+ <span className="material-symbols-outlined text-lg">history</span>
1051
+ Lịch sử tìm kiếm
1052
+ </h2>
1053
+
1054
+ {searchHistory.length > 0 ? (
1055
+ <div className="flex flex-col gap-1">
1056
+ {searchHistory.map((item) => (
1057
+ <button
1058
+ key={item.id}
1059
+ type="button"
1060
+ onClick={() => handleHistorySelect(item)}
1061
+ className="group flex items-center gap-3 rounded-lg px-4 py-3 text-left transition-all hover:bg-slate-50"
1062
+ >
1063
+ <span className="material-symbols-outlined text-lg opacity-40 transition-all group-hover:text-black group-hover:opacity-100">
1064
+ history
1065
+ </span>
1066
+ <span className="truncate text-sm font-medium text-slate-600 group-hover:text-black">
1067
+ {item.query}
1068
+ </span>
1069
+ </button>
1070
+ ))}
1071
+ </div>
1072
+ ) : (
1073
+ <p className="rounded-lg border border-dashed border-slate-200 p-4 text-sm text-slate-400">
1074
+ Chưa có lượt tìm kiếm nào.
1075
+ </p>
1076
+ )}
1077
+ </div>
1078
+
1079
+ <button
1080
+ type="button"
1081
+ 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"
1082
+ >
1083
+ <span className="material-symbols-outlined">settings</span>
1084
+ <span className="text-sm font-bold">Cài đặt</span>
1085
+ </button>
1086
+ </aside>
1087
+
1088
+ <main className="relative w-full lg:pl-72">
1089
+ <div className="w-full px-2 pb-12 pt-4 md:px-4 lg:px-6 lg:pt-6">
1090
+ <section className="mb-8 rounded-xl border border-black/10 bg-white p-5 lg:hidden">
1091
+ <div className="mb-4 flex items-center gap-3">
1092
+ <div className="flex h-10 w-10 items-center justify-center bg-black text-white">
1093
+ <span className="material-symbols-outlined text-2xl">neurology</span>
1094
+ </div>
1095
+ <div>
1096
+ <h1 className="text-xl font-black uppercase tracking-tight">NewsAI</h1>
1097
+ <p className="text-[10px] font-extrabold uppercase tracking-[0.2em] text-slate-500">Digital Curator</p>
1098
+ </div>
1099
+ </div>
1100
+
1101
+ <div className="flex items-center gap-2 overflow-x-auto pb-1">
1102
+ {searchHistory.length > 0 ? searchHistory.map((item) => (
1103
+ <button
1104
+ key={`mobile-${item.id}`}
1105
+ type="button"
1106
+ onClick={() => handleHistorySelect(item)}
1107
+ 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"
1108
+ >
1109
+ {item.query}
1110
+ </button>
1111
+ )) : (
1112
+ <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>
1113
+ )}
1114
+ </div>
1115
+ </section>
1116
+
1117
+ <header className="mb-8">
1118
+ <div className="w-full">
1119
+ <SearchBox
1120
+ value={searchQuery}
1121
+ onChange={setSearchQuery}
1122
+ onSearch={handleSearch}
1123
+ loading={loading}
1124
+ />
1125
+ <div className="mt-7">
1126
+ <FilterBar
1127
+ voice={voice}
1128
+ onVoiceChange={setVoice}
1129
+ time={time}
1130
+ onTimeChange={setTime}
1131
+ source={source}
1132
+ onSourceChange={setSource}
1133
+ />
1134
+ </div>
1135
+ {error && (
1136
+ <div className="mt-6 border border-red-300 bg-red-50 px-4 py-3 text-sm font-medium text-red-700">
1137
+ {error}
1138
+ </div>
1139
+ )}
1140
+ </div>
1141
+ </header>
1142
+
1143
+ <div className="grid items-start gap-6 xl:grid-cols-2">
1144
+ <section>
1145
+ <div className="mb-10 flex items-center justify-between border-b border-black pb-4">
1146
+ <h2 className="text-2xl font-black uppercase tracking-tight">
1147
+ {useDailyHomeLayout ? 'Bản tin đầu ngày' : 'Kết quả tìm kiếm'}
1148
+ </h2>
1149
+ {!useDailyHomeLayout && (
1150
+ <div className="flex items-center gap-1 text-[10px] font-black uppercase tracking-[0.2em] text-black/70">
1151
+ <span>Sắp xếp: Mới nhất</span>
1152
+ <span className="material-symbols-outlined text-sm">swap_vert</span>
1153
+ </div>
1154
+ )}
1155
+ </div>
1156
+
1157
+ {useDailyHomeLayout ? (
1158
+ <HomeNewsGrid
1159
+ articles={articles}
1160
+ loading={loading}
1161
+ onSummarizeArticle={handleDailyCardSummarize}
1162
+ />
1163
+ ) : (
1164
+ <>
1165
+ {loading && (
1166
+ <div className="flex min-h-56 flex-col items-center justify-center rounded-xl border border-black/10 bg-white text-center">
1167
+ <span className="material-symbols-outlined animate-spin text-4xl text-black/40">progress_activity</span>
1168
+ <p className="mt-3 text-sm font-semibold text-slate-500">Đang phân tích và tìm bài viết...</p>
1169
+ </div>
1170
+ )}
1171
+
1172
+ {!loading && articles.length === 0 && (
1173
+ <div className="flex min-h-56 flex-col items-center justify-center rounded-xl border border-dashed border-black/20 bg-white text-center">
1174
+ <span className="material-symbols-outlined text-6xl text-black/20">search</span>
1175
+ <p className="mt-4 text-lg font-medium text-slate-500">
1176
+ Nhập từ khóa hoặc đường dẫn bài báo để bắt đầu.
1177
+ </p>
1178
+ </div>
1179
+ )}
1180
+
1181
+ {!loading && articles.length > 0 && (
1182
+ <div className="space-y-12">
1183
+ {articles.map((article, index) => (
1184
+ <NewsArticle
1185
+ key={`${article.articleUrl}-${index}`}
1186
+ {...article}
1187
+ onSummarize={() => handleArticleSummarize(article.articleUrl, index)}
1188
+ onGenerateTts={() => handleGenerateArticleTts(article.articleUrl, index)}
1189
+ summary={articleSummaries[index]?.content}
1190
+ summaryVisible={articleSummaries[index]?.visible}
1191
+ summaryLoading={articleSummaryLoading[index]}
1192
+ ttsStatus={articleTtsJobs[getArticleTtsSlot(article.articleUrl, index)]?.status || 'idle'}
1193
+ ttsAudioUrl={articleTtsJobs[getArticleTtsSlot(article.articleUrl, index)]?.audioUrl || ''}
1194
+ ttsError={articleTtsJobs[getArticleTtsSlot(article.articleUrl, index)]?.error || ''}
1195
+ />
1196
+ ))}
1197
+ </div>
1198
+ )}
1199
+ </>
1200
+ )}
1201
+ </section>
1202
+
1203
+ <aside className="hidden md:block xl:sticky xl:top-8">
1204
+ <SummaryBox
1205
+ summary={summary}
1206
+ totalArticles={totalArticles}
1207
+ loading={summaryLoading}
1208
+ onGenerateTts={handleGenerateSummaryTts}
1209
+ ttsStatus={summaryTtsState.status}
1210
+ ttsAudioUrl={summaryTtsState.audioUrl}
1211
+ ttsError={summaryTtsState.error}
1212
+ onResummarize={useDailyHomeLayout ? handleDailyResummary : undefined}
1213
+ resummarizeDisabled={loading || summaryLoading || articles.length === 0}
1214
+ />
1215
+ </aside>
1216
+ </div>
1217
+
1218
+ <div className="fixed bottom-5 right-5 z-40 md:hidden">
1219
+ <button
1220
+ type="button"
1221
+ onClick={toggleMobileSummaryPanel}
1222
+ aria-expanded={isMobileSummaryOpen}
1223
+ 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"
1224
+ >
1225
+ <span className={`material-symbols-outlined text-base ${summaryLoading ? 'animate-spin' : ''}`}>
1226
+ {summaryLoading ? 'progress_activity' : isMobileSummaryOpen ? 'close' : 'summarize'}
1227
+ </span>
1228
+ {summaryLoading ? 'Đang tóm tắt' : isMobileSummaryOpen ? 'Đóng tóm tắt' : 'Mở tóm tắt'}
1229
+ </button>
1230
+ </div>
1231
+
1232
+ <div
1233
+ className={`fixed inset-0 z-50 md:hidden ${isMobileSummaryOpen ? 'pointer-events-auto' : 'pointer-events-none'}`}
1234
+ aria-hidden={!isMobileSummaryOpen}
1235
+ onClick={closeMobileSummaryPanel}
1236
+ >
1237
+ <div
1238
+ className={`absolute inset-0 bg-black/45 transition-opacity duration-300 ${isMobileSummaryOpen ? 'opacity-100' : 'opacity-0'}`}
1239
+ />
1240
+
1241
+ <section
1242
+ onClick={(event) => event.stopPropagation()}
1243
+ 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'}`}
1244
+ >
1245
+ <div className="mb-3 flex items-center justify-between">
1246
+ <h3 className="text-sm font-black uppercase tracking-[0.15em] text-black">Tóm tắt thông minh</h3>
1247
+ <button
1248
+ type="button"
1249
+ onClick={(event) => {
1250
+ event.preventDefault();
1251
+ event.stopPropagation();
1252
+ closeMobileSummaryPanel();
1253
+ }}
1254
+ className="rounded-full border border-black/20 p-2 text-black transition-all hover:bg-slate-100"
1255
+ aria-label="Đóng"
1256
+ >
1257
+ <span className="material-symbols-outlined">close</span>
1258
+ </button>
1259
+ </div>
1260
+
1261
+ <SummaryBox
1262
+ summary={summary}
1263
+ totalArticles={totalArticles}
1264
+ loading={summaryLoading}
1265
+ onGenerateTts={handleGenerateSummaryTts}
1266
+ ttsStatus={summaryTtsState.status}
1267
+ ttsAudioUrl={summaryTtsState.audioUrl}
1268
+ ttsError={summaryTtsState.error}
1269
+ onResummarize={useDailyHomeLayout ? handleDailyResummary : undefined}
1270
+ resummarizeDisabled={loading || summaryLoading || articles.length === 0}
1271
+ />
1272
+ </section>
1273
+ </div>
1274
+
1275
+ <footer className="mt-16 border-t border-black/10 pt-8 text-center">
1276
+ <p className="text-xs leading-relaxed text-slate-500">
1277
+ AI có thể mắc lỗi. Hãy kiểm tra lại thông tin quan trọng.
1278
+ <br />
1279
+ Được xây dựng bởi <span className="font-bold text-black/70">HCMUS - Machine Learning - 23KHDL1 - Nhóm 4</span>
1280
+ </p>
1281
+ </footer>
1282
+ </div>
1283
+ </main>
1284
+ </div>
1285
+ )
1286
+ }
1287
+
1288
+ export default App
src/AppHome.jsx ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import Navbar from './components/Navbar'
2
+ import Hero from './components/Hero'
3
+ import SearchBox from './components/SearchBox'
4
+ import FilterBar from './components/FilterBar'
5
+ import NewsArticle from './components/NewsArticle'
6
+ import './App.css'
7
+
8
+ function App() {
9
+ const articles = [
10
+ {
11
+ category: "Kinh tế",
12
+ categoryColor: "bg-blue-50 text-blue-600",
13
+ source: "VnExpress",
14
+ timeAgo: "2 giờ trước",
15
+ title: "Giá vàng SJC tiếp tục lập đỉnh mới, vượt mốc 80 triệu đồng/lượng",
16
+ description: "Sáng nay, giá vàng SJC trong nước tiếp tục đà tăng mạnh, chính thức vượt qua mốc lịch sử 80 triệu đồng mỗi lượng. Các chuyên gia nhận định biến động này chịu ảnh hưởng lớn từ thị trường thế giới và nhu cầu tích trữ cuối năm tăng cao...",
17
+ imageUrl: "https://lh3.googleusercontent.com/aida-public/AB6AXuAHQK_DJaRIYVpQqBaMCtZCwm0qJ2lIIAYE7BHijZwWo4YQGbUJWcmRrWwpzrLr7N0_w7b96S-dTcCT9QT2_hYX9e6Fn4iHCg5x4X6EhEzG8nDmPrcFEjsJFEz7lE55sy8KOyI4kMCpXEsuJoHUEhsMRrP1ZMFs5FjXnnA27RpA4EHJm4QOHmr75rlB6KkmQY1v1mxpQNj4-Zo4x8b4EuuXBIfhfYNN_L4HYMmtzSC-hnAdQCdGd5CjLC-r9Y4opNfJoYY1k9OkS1w",
18
+ imageAlt: "Vàng SJC",
19
+ voiceType: "Bắc",
20
+ articleUrl: "#"
21
+ },
22
+ {
23
+ category: "Công nghệ",
24
+ categoryColor: "bg-green-50 text-green-600",
25
+ source: "Tuổi Trẻ",
26
+ timeAgo: "5 giờ trước",
27
+ title: "Việt Nam đặt mục tiêu phổ cập 5G vào năm 2025",
28
+ description: "Bộ Thông tin và Truyền thông vừa công bố lộ trình phổ cập mạng 5G trên toàn quốc. Theo đó, đến năm 2025, 100% dân số sẽ được tiếp cận với hạ tầng mạng di động thế hệ mới, mở đường cho phát triển kinh tế số...",
29
+ imageUrl: "https://lh3.googleusercontent.com/aida-public/AB6AXuBrkQ2BBQBgsy-3HhjUw7R26kS3eKrNLBlhGWTid8HXuxX6X06qAwWsYThgHxKJxW-17cEo-28TZ0NUA3x2QQtl2PfWBmvgU8-CbbUj9R3bvyjNQPtEyH2GoCqqK73Py_sts5k24HWN5iO_OIwfdnIsa1sLHSsaqYGIrlzkuLOMyP2lfRQQnE7K4pFre3NTHZm0ZvJrwB_rzWi9AQDkT4CUPwOcCFSyLprwHNsHcQNiKI6ZbSVmXP7eljfX9JJubMcw93LZJjVMixg",
30
+ imageAlt: "Công nghệ 5G",
31
+ voiceType: "Nam",
32
+ articleUrl: "#"
33
+ },
34
+ {
35
+ category: "Giao thông",
36
+ categoryColor: "bg-orange-50 text-orange-600",
37
+ source: "Dân Trí",
38
+ timeAgo: "8 giờ trước",
39
+ title: "Hoàn thành cao tốc Bắc - Nam đoạn Diễn Châu - Bãi Vọt",
40
+ description: "Dự án thành phần cao tốc Bắc - Nam phía Đông giai đoạn 1 đoạn Diễn Châu - Bãi Vọt đã chính thức thông xe kỹ thuật. Công trình giúp rút ngắn thời gian di chuyển từ Hà Nội về Nghệ An xuống còn hơn 3 giờ...",
41
+ imageUrl: "https://lh3.googleusercontent.com/aida-public/AB6AXuBvOWzv5Wt7dypGPCUuBQcNXQN3dD8RbYO5n_BONKalLmqWjB2uTRDhCM9BN1LhATZLw0a--nzP5bveNszmigBUh0qVhE-eJbcIsBIC9kV3cMnRp3XyDVRgUDESXrI8HaZPDNkKVWfuxBhgzxdFkDf5E_V4XUK30oiYNRaUDa4Z80G58HWM_5SrO22qJvgzHDQkFfysg2HM8ETD-R5Z0hxcMdWHV8mvAWbBTle_FP-zK6BzbZc7_gU5BS057_N-C22BADd9g4OOBA8",
42
+ imageAlt: "Cao tốc",
43
+ voiceType: "Trung",
44
+ articleUrl: "#"
45
+ }
46
+ ];
47
+
48
+ return (
49
+ <div className="bg-background-light text-slate-800 font-display h-screen flex flex-col overflow-y-auto selection:bg-gray-200">
50
+ <Navbar />
51
+ <main className="flex-1 flex flex-col items-center w-full max-w-5xl mx-auto px-4 py-12 md:py-20">
52
+ <Hero />
53
+ <div className="w-full max-w-3xl space-y-4">
54
+ <SearchBox />
55
+ <FilterBar />
56
+ </div>
57
+ <div className="w-full max-w-3xl mt-16 space-y-6">
58
+ <h2 className="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-4 pl-1">Kết quả tóm tắt mới nhất</h2>
59
+ {articles.map((article, index) => (
60
+ <NewsArticle key={index} {...article} />
61
+ ))}
62
+ </div>
63
+ <div className="mt-20 text-center px-4 w-full">
64
+ <p className="text-xs text-slate-400">
65
+ AI có thể mắc lỗi. Hãy kiểm tra lại thông tin quan trọng. Được xây dựng bởi <span className="font-semibold text-slate-500">NewsAI Team</span>
66
+ </p>
67
+ </div>
68
+ </main>
69
+ </div>
70
+ )
71
+ }
72
+
73
+ export default App
src/assets/react.svg ADDED
src/components/FilterBar.jsx ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ function FilterBar({ voice, onVoiceChange, time, onTimeChange, source, onSourceChange }) {
2
+ return (
3
+ <div className="flex flex-wrap items-center justify-center gap-3 md:gap-4">
4
+ <label className="relative flex items-center gap-2 rounded-full border border-black/20 bg-white px-4 py-2.5 text-xs font-black uppercase tracking-[0.1em] text-black transition-all hover:border-black">
5
+ <span className="material-symbols-outlined text-base">record_voice_over</span>
6
+ <span>Giọng đọc</span>
7
+ <select
8
+ value={voice}
9
+ onChange={(event) => onVoiceChange(event.target.value)}
10
+ className="cursor-pointer border-0 bg-transparent py-0 pl-0 pr-6 text-[11px] font-black uppercase tracking-[0.12em] text-slate-600 focus:ring-0"
11
+ >
12
+ <option value="Bắc">Nam (Bắc)</option>
13
+ <option value="Nam">Nữ (Nam)</option>
14
+ <option value="Trung">Nam (Trung)</option>
15
+ </select>
16
+ </label>
17
+
18
+ <label className="relative flex items-center gap-2 rounded-full border border-black/20 bg-white px-4 py-2.5 text-xs font-black uppercase tracking-[0.1em] text-black transition-all hover:border-black">
19
+ <span className="material-symbols-outlined text-base">schedule</span>
20
+ <span>Thời gian</span>
21
+ <select
22
+ value={time}
23
+ onChange={(event) => onTimeChange(event.target.value)}
24
+ className="cursor-pointer border-0 bg-transparent py-0 pl-0 pr-6 text-[11px] font-black uppercase tracking-[0.12em] text-slate-600 focus:ring-0"
25
+ >
26
+ <option value="pd">24h qua</option>
27
+ <option value="pw">7 ngày</option>
28
+ <option value="pm">Tháng này</option>
29
+ <option value="py">Năm nay</option>
30
+ </select>
31
+ </label>
32
+
33
+ <label className="relative flex items-center gap-2 rounded-full border border-black/20 bg-white px-4 py-2.5 text-xs font-black uppercase tracking-[0.1em] text-black transition-all hover:border-black">
34
+ <span className="material-symbols-outlined text-base">public</span>
35
+ <span>Nguồn tin</span>
36
+ <select
37
+ value={source}
38
+ onChange={(event) => onSourceChange(event.target.value)}
39
+ className="cursor-pointer border-0 bg-transparent py-0 pl-0 pr-6 text-[11px] font-black uppercase tracking-[0.12em] text-slate-600 focus:ring-0"
40
+ >
41
+ <option value="all">Tất cả</option>
42
+ <option value="vi">Trong nước</option>
43
+ <option value="en">Quốc tế</option>
44
+ </select>
45
+ </label>
46
+
47
+ <button
48
+ type="button"
49
+ className="rounded-full border border-black/20 p-2.5 text-black transition-all hover:border-black hover:bg-black hover:text-white"
50
+ >
51
+ <span className="material-symbols-outlined text-lg">tune</span>
52
+ </button>
53
+ </div>
54
+ );
55
+ }
56
+
57
+ export default FilterBar;
src/components/Hero.jsx ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ function Hero() {
2
+ return (
3
+ <div className="text-center mb-12 space-y-3 max-w-2xl">
4
+ <h1 className="text-3xl md:text-5xl font-bold text-slate-900 tracking-tight">Tóm tắt tin tức thông minh</h1>
5
+ <p className="text-slate-500 text-lg">Cập nhật nhanh chóng, nghe tin tức mọi lúc với giọng đọc đa vùng miền.</p>
6
+ </div>
7
+ );
8
+ }
9
+
10
+ export default Hero;
src/components/HomeNewsGrid.jsx ADDED
@@ -0,0 +1,233 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState } from 'react';
2
+
3
+ const TONE_BADGE_CLASS = {
4
+ news: 'bg-black text-white',
5
+ commerce: 'bg-emerald-100 text-emerald-700',
6
+ culture: 'bg-indigo-100 text-indigo-700',
7
+ discussion: 'bg-violet-100 text-violet-700',
8
+ review: 'bg-pink-100 text-pink-700',
9
+ video: 'bg-red-100 text-red-700',
10
+ generic: 'bg-slate-100 text-slate-700',
11
+ };
12
+
13
+ function HomeNewsGrid({ articles, loading, onSummarizeArticle }) {
14
+ const [copiedUrl, setCopiedUrl] = useState('');
15
+ const featuredArticle = articles?.[0] || null;
16
+ const sideArticles = articles?.slice(1, 5) || [];
17
+
18
+ const handleCopyLink = async (articleUrl) => {
19
+ if (!articleUrl || articleUrl === '#') return;
20
+
21
+ try {
22
+ await navigator.clipboard.writeText(articleUrl);
23
+ setCopiedUrl(articleUrl);
24
+ window.setTimeout(() => setCopiedUrl(''), 1800);
25
+ } catch {
26
+ setCopiedUrl('');
27
+ }
28
+ };
29
+
30
+ if (loading) {
31
+ return (
32
+ <div className="grid grid-cols-1 gap-5 lg:grid-cols-12">
33
+ <div className="h-[330px] animate-pulse rounded-xl bg-slate-200 lg:col-span-8" />
34
+ <div className="h-[330px] animate-pulse rounded-xl bg-slate-200 lg:col-span-4" />
35
+ <div className="h-64 animate-pulse rounded-xl bg-slate-200 lg:col-span-4" />
36
+ <div className="h-64 animate-pulse rounded-xl bg-slate-200 lg:col-span-4" />
37
+ <div className="h-64 animate-pulse rounded-xl bg-slate-200 lg:col-span-4" />
38
+ </div>
39
+ );
40
+ }
41
+
42
+ if (!featuredArticle) {
43
+ return (
44
+ <div className="flex min-h-64 flex-col items-center justify-center rounded-xl border border-dashed border-black/20 bg-white text-center">
45
+ <span className="material-symbols-outlined text-6xl text-black/20">search</span>
46
+ <p className="mt-4 text-lg font-medium text-slate-500">
47
+ Chưa có dữ liệu tin tức đầu ngày.
48
+ </p>
49
+ </div>
50
+ );
51
+ }
52
+
53
+ return (
54
+ <div className="grid grid-cols-1 gap-5 lg:grid-cols-12">
55
+ <article className="group relative overflow-hidden rounded-xl border border-transparent bg-white shadow-[0px_12px_32px_rgba(25,28,30,0.06)] transition-all hover:border-slate-100 hover:shadow-xl lg:col-span-8">
56
+ <div className="relative aspect-[16/9] w-full">
57
+ <img className="h-full w-full object-cover" src={featuredArticle.imageUrl} alt={featuredArticle.imageAlt} />
58
+ <div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/20 to-transparent" />
59
+ <div className="absolute left-5 right-5 top-5 flex items-center justify-between">
60
+ <span className="rounded-sm bg-black px-3 py-1 text-[10px] font-black uppercase tracking-[0.16em] text-white">
61
+ Featured
62
+ </span>
63
+ <span className="rounded-sm bg-white/90 px-3 py-1 text-[10px] font-black uppercase tracking-[0.12em] text-black">
64
+ {featuredArticle.source}
65
+ </span>
66
+ </div>
67
+ <div className="absolute bottom-5 left-5 right-5">
68
+ <h2 className="mb-2 text-2xl font-black leading-tight tracking-tight text-white md:text-3xl">
69
+ {featuredArticle.title}
70
+ </h2>
71
+ <div className="flex items-center justify-between gap-3">
72
+ <p className="text-sm font-medium text-white/80">
73
+ {featuredArticle.timeAgo || 'Hôm nay'} • {featuredArticle.category}
74
+ </p>
75
+
76
+ <div className="flex items-center gap-2">
77
+ <a
78
+ href={featuredArticle.articleUrl}
79
+ target="_blank"
80
+ rel="noopener noreferrer"
81
+ title="Đọc bài gốc"
82
+ className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-white/90 text-black transition-all hover:bg-white"
83
+ >
84
+ <span className="material-symbols-outlined text-base leading-none">article</span>
85
+ </a>
86
+ <button
87
+ type="button"
88
+ title="Tóm tắt bài"
89
+ onClick={() => onSummarizeArticle?.(featuredArticle.articleUrl)}
90
+ className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-white/20 text-white transition-all hover:bg-white/30"
91
+ >
92
+ <span className="material-symbols-outlined text-base leading-none">auto_awesome</span>
93
+ </button>
94
+ <button
95
+ type="button"
96
+ title="Sao chép link"
97
+ onClick={() => handleCopyLink(featuredArticle.articleUrl)}
98
+ className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-white/20 text-white transition-all hover:bg-white/30"
99
+ >
100
+ <span className="material-symbols-outlined text-base leading-none">
101
+ {copiedUrl === featuredArticle.articleUrl ? 'check' : 'share'}
102
+ </span>
103
+ </button>
104
+ </div>
105
+ </div>
106
+ {copiedUrl === featuredArticle.articleUrl && (
107
+ <p className="mt-2 text-xs font-bold text-emerald-300">Đã copy link bài viết</p>
108
+ )}
109
+ </div>
110
+ </div>
111
+ </article>
112
+
113
+ <article className="rounded-xl bg-white p-5 shadow-[0px_12px_32px_rgba(25,28,30,0.06)] lg:col-span-4">
114
+ {sideArticles[0] ? (
115
+ <>
116
+ <div className="mb-4 flex items-center justify-between">
117
+ <span
118
+ className={`rounded-sm px-3 py-1 text-[10px] font-black uppercase tracking-[0.14em] ${
119
+ TONE_BADGE_CLASS[sideArticles[0].categoryTone] || TONE_BADGE_CLASS.generic
120
+ }`}
121
+ >
122
+ {sideArticles[0].category}
123
+ </span>
124
+ <span className="text-xs font-semibold text-slate-500">{sideArticles[0].timeAgo || 'Hôm nay'}</span>
125
+ </div>
126
+ <div className="mb-4 aspect-video w-full overflow-hidden rounded-lg">
127
+ <img className="h-full w-full object-cover" src={sideArticles[0].imageUrl} alt={sideArticles[0].imageAlt} />
128
+ </div>
129
+ <h3 className="text-lg font-bold leading-snug tracking-tight text-black">{sideArticles[0].title}</h3>
130
+ <div className="mt-5 flex items-center justify-between gap-3">
131
+ <span className="text-xs font-semibold text-slate-500">{sideArticles[0].timeAgo || 'Hôm nay'}</span>
132
+ <div className="flex items-center gap-2">
133
+ <a
134
+ href={sideArticles[0].articleUrl}
135
+ target="_blank"
136
+ rel="noopener noreferrer"
137
+ title="Đọc bài gốc"
138
+ className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-full text-slate-500 transition-all hover:bg-slate-100 hover:text-black"
139
+ >
140
+ <span className="material-symbols-outlined text-base leading-none">article</span>
141
+ </a>
142
+ <button
143
+ type="button"
144
+ title="Tóm tắt bài"
145
+ onClick={() => onSummarizeArticle?.(sideArticles[0].articleUrl)}
146
+ className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-full text-slate-500 transition-all hover:bg-slate-100 hover:text-black"
147
+ >
148
+ <span className="material-symbols-outlined text-base leading-none">auto_awesome</span>
149
+ </button>
150
+ <button
151
+ type="button"
152
+ title="Sao chép link"
153
+ onClick={() => handleCopyLink(sideArticles[0].articleUrl)}
154
+ className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-full text-slate-500 transition-all hover:bg-slate-100 hover:text-black"
155
+ >
156
+ <span className="material-symbols-outlined text-base leading-none">
157
+ {copiedUrl === sideArticles[0].articleUrl ? 'check' : 'share'}
158
+ </span>
159
+ </button>
160
+ </div>
161
+ </div>
162
+ {copiedUrl === sideArticles[0].articleUrl && (
163
+ <p className="mt-2 text-[11px] font-bold text-emerald-600">Đã copy link bài viết</p>
164
+ )}
165
+ </>
166
+ ) : (
167
+ <div className="flex h-full min-h-64 flex-col items-center justify-center rounded-lg border border-dashed border-slate-200 text-center">
168
+ <span className="material-symbols-outlined text-4xl text-slate-300">newspaper</span>
169
+ <p className="mt-3 text-sm font-medium text-slate-500">Đang chuẩn bị tin tức đầu ngày...</p>
170
+ </div>
171
+ )}
172
+ </article>
173
+
174
+ {sideArticles.slice(1).map((article) => (
175
+ <article
176
+ key={article.articleUrl}
177
+ className="flex flex-col overflow-hidden rounded-xl border border-transparent bg-white transition-all hover:border-slate-200 lg:col-span-4"
178
+ >
179
+ <div className="h-48 w-full overflow-hidden">
180
+ <img className="h-full w-full object-cover" src={article.imageUrl} alt={article.imageAlt} />
181
+ </div>
182
+ <div className="flex flex-1 flex-col p-5">
183
+ <span
184
+ className={`mb-2 inline-flex w-fit rounded-sm px-2 py-1 text-[10px] font-black uppercase tracking-[0.14em] ${
185
+ TONE_BADGE_CLASS[article.categoryTone] || TONE_BADGE_CLASS.generic
186
+ }`}
187
+ >
188
+ {article.category}
189
+ </span>
190
+ <h3 className="mb-4 text-lg font-bold leading-tight tracking-tight text-black">{article.title}</h3>
191
+ <div className="mt-auto flex items-center justify-between gap-3">
192
+ <span className="text-xs font-medium text-slate-500">{article.timeAgo || 'Hôm nay'}</span>
193
+ <div className="flex items-center gap-2">
194
+ <a
195
+ href={article.articleUrl}
196
+ target="_blank"
197
+ rel="noopener noreferrer"
198
+ title="Đọc bài gốc"
199
+ className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-full text-slate-500 transition-all hover:bg-slate-100 hover:text-black"
200
+ >
201
+ <span className="material-symbols-outlined text-base leading-none">article</span>
202
+ </a>
203
+ <button
204
+ type="button"
205
+ title="Tóm tắt bài"
206
+ onClick={() => onSummarizeArticle?.(article.articleUrl)}
207
+ className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-full text-slate-500 transition-all hover:bg-slate-100 hover:text-black"
208
+ >
209
+ <span className="material-symbols-outlined text-base leading-none">auto_awesome</span>
210
+ </button>
211
+ <button
212
+ type="button"
213
+ title="Sao chép link"
214
+ onClick={() => handleCopyLink(article.articleUrl)}
215
+ className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-full text-slate-500 transition-all hover:bg-slate-100 hover:text-black"
216
+ >
217
+ <span className="material-symbols-outlined text-base leading-none">
218
+ {copiedUrl === article.articleUrl ? 'check' : 'share'}
219
+ </span>
220
+ </button>
221
+ </div>
222
+ </div>
223
+ {copiedUrl === article.articleUrl && (
224
+ <p className="mt-2 text-[11px] font-bold text-emerald-600">Đã copy link bài viết</p>
225
+ )}
226
+ </div>
227
+ </article>
228
+ ))}
229
+ </div>
230
+ );
231
+ }
232
+
233
+ export default HomeNewsGrid;
src/components/HomePage.jsx ADDED
@@ -0,0 +1,195 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useMemo, useState } from 'react';
2
+
3
+ const TONE_BADGE_CLASS = {
4
+ news: 'bg-black text-white',
5
+ commerce: 'bg-emerald-100 text-emerald-700',
6
+ culture: 'bg-indigo-100 text-indigo-700',
7
+ discussion: 'bg-violet-100 text-violet-700',
8
+ review: 'bg-pink-100 text-pink-700',
9
+ video: 'bg-red-100 text-red-700',
10
+ generic: 'bg-slate-100 text-slate-700',
11
+ };
12
+
13
+ function HomePage({ articles, loading, onSearch, onOpenWorkspace }) {
14
+ const [query, setQuery] = useState('');
15
+
16
+ const featuredArticle = articles?.[0] || null;
17
+ const sideArticles = useMemo(() => articles.slice(1, 5), [articles]);
18
+
19
+ const handleSubmit = (event) => {
20
+ event.preventDefault();
21
+ if (!query.trim() || !onSearch) return;
22
+ onSearch(query.trim());
23
+ };
24
+
25
+ return (
26
+ <div className="min-h-screen bg-[#f7f9fb] text-slate-900 selection:bg-black selection:text-white">
27
+ <header className="sticky top-0 z-40 border-b border-black/5 bg-[#f7f9fb]/95 px-5 py-4 backdrop-blur md:px-8 lg:px-12">
28
+ <div className="mx-auto flex w-full max-w-7xl items-center justify-between gap-4">
29
+ <div className="flex items-center gap-8">
30
+ <span className="text-2xl font-black tracking-tight text-black">NewsAI</span>
31
+ <nav className="hidden items-center gap-6 md:flex">
32
+ <button type="button" className="border-b-2 border-black pb-1 text-sm font-bold text-black">
33
+ Discover
34
+ </button>
35
+ <button type="button" className="text-sm font-medium text-slate-500 transition-colors hover:text-black">
36
+ Briefings
37
+ </button>
38
+ <button type="button" className="text-sm font-medium text-slate-500 transition-colors hover:text-black">
39
+ Channels
40
+ </button>
41
+ </nav>
42
+ </div>
43
+
44
+ <button
45
+ type="button"
46
+ onClick={onOpenWorkspace}
47
+ className="rounded-lg bg-black px-5 py-2 text-sm font-bold text-white transition-all hover:bg-slate-800"
48
+ >
49
+ Vào trang tìm kiếm
50
+ </button>
51
+ </div>
52
+ </header>
53
+
54
+ <main className="px-4 pb-16 pt-10 md:px-8 lg:px-12">
55
+ <section className="mx-auto mb-14 w-full max-w-5xl text-center">
56
+ <h1 className="mb-10 text-4xl font-black tracking-tight text-black md:text-5xl lg:text-6xl">
57
+ Chào buổi sáng,
58
+ <br />
59
+ Khám phá tin tức AI hôm nay
60
+ </h1>
61
+
62
+ <form onSubmit={handleSubmit} className="relative mx-auto mb-8 w-full max-w-2xl">
63
+ <span className="material-symbols-outlined pointer-events-none absolute left-5 top-1/2 -translate-y-1/2 text-slate-400">
64
+ search
65
+ </span>
66
+ <input
67
+ type="text"
68
+ value={query}
69
+ onChange={(event) => setQuery(event.target.value)}
70
+ placeholder="Tìm kiếm tin tức hoặc chủ đề AI..."
71
+ className="h-16 w-full rounded-full border border-black/10 bg-white pl-14 pr-5 text-base font-medium text-black shadow-[0px_12px_32px_rgba(25,28,30,0.06)] outline-none transition-all focus:border-black/30"
72
+ />
73
+ </form>
74
+
75
+ <div className="flex flex-wrap items-center justify-center gap-4">
76
+ <button
77
+ type="button"
78
+ onClick={onOpenWorkspace}
79
+ className="flex items-center gap-2 rounded-full border border-slate-200 bg-white px-6 py-2.5 text-sm font-semibold text-slate-700 transition-colors hover:bg-slate-50"
80
+ >
81
+ <span className="material-symbols-outlined text-lg">check_circle</span>
82
+ Xem trang chi tiết
83
+ </button>
84
+ <button
85
+ type="button"
86
+ onClick={() => onSearch?.('tin tức AI hôm nay')}
87
+ className="flex items-center gap-2 rounded-full bg-black px-8 py-2.5 text-sm font-bold text-white transition-all hover:scale-[1.02]"
88
+ >
89
+ <span className="material-symbols-outlined text-lg" style={{ fontVariationSettings: "'FILL' 1" }}>
90
+ bolt
91
+ </span>
92
+ Tóm tắt ngay
93
+ </button>
94
+ </div>
95
+ </section>
96
+
97
+ <section className="mx-auto max-w-7xl">
98
+ {loading && (
99
+ <div className="grid grid-cols-1 gap-6 md:grid-cols-12">
100
+ <div className="md:col-span-8 h-[320px] animate-pulse rounded-xl bg-slate-200" />
101
+ <div className="md:col-span-4 h-[320px] animate-pulse rounded-xl bg-slate-200" />
102
+ <div className="md:col-span-4 h-64 animate-pulse rounded-xl bg-slate-200" />
103
+ <div className="md:col-span-4 h-64 animate-pulse rounded-xl bg-slate-200" />
104
+ <div className="md:col-span-4 h-64 animate-pulse rounded-xl bg-slate-200" />
105
+ </div>
106
+ )}
107
+
108
+ {!loading && featuredArticle && (
109
+ <div className="grid grid-cols-1 gap-6 md:grid-cols-12">
110
+ <article className="group relative overflow-hidden rounded-xl border border-transparent bg-white shadow-[0px_12px_32px_rgba(25,28,30,0.06)] transition-all hover:border-slate-100 hover:shadow-xl md:col-span-8">
111
+ <div className="relative aspect-[16/9] w-full">
112
+ <img className="h-full w-full object-cover" src={featuredArticle.imageUrl} alt={featuredArticle.imageAlt} />
113
+ <div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/20 to-transparent" />
114
+ <div className="absolute left-6 right-6 top-6 flex items-center justify-between">
115
+ <span className="rounded-sm bg-black px-3 py-1 text-[10px] font-black uppercase tracking-[0.18em] text-white">
116
+ Featured
117
+ </span>
118
+ <span className="rounded-sm bg-white/90 px-3 py-1 text-[10px] font-black uppercase tracking-[0.12em] text-black">
119
+ {featuredArticle.source}
120
+ </span>
121
+ </div>
122
+ <div className="absolute bottom-6 left-6 right-6">
123
+ <h2 className="mb-3 text-2xl font-black leading-tight tracking-tight text-white md:text-3xl">
124
+ {featuredArticle.title}
125
+ </h2>
126
+ <p className="text-sm font-medium text-white/80">{featuredArticle.timeAgo || 'Hôm nay'} • {featuredArticle.category}</p>
127
+ </div>
128
+ </div>
129
+ </article>
130
+
131
+ <article className="rounded-xl bg-white p-6 shadow-[0px_12px_32px_rgba(25,28,30,0.06)] md:col-span-4">
132
+ {sideArticles[0] ? (
133
+ <>
134
+ <div className="mb-4 flex items-center justify-between">
135
+ <span
136
+ className={`rounded-sm px-3 py-1 text-[10px] font-black uppercase tracking-[0.14em] ${
137
+ TONE_BADGE_CLASS[sideArticles[0].categoryTone] || TONE_BADGE_CLASS.generic
138
+ }`}
139
+ >
140
+ {sideArticles[0].category}
141
+ </span>
142
+ <span className="text-xs font-semibold text-slate-500">{sideArticles[0].timeAgo || 'Hôm nay'}</span>
143
+ </div>
144
+ <div className="mb-4 aspect-video w-full overflow-hidden rounded-lg">
145
+ <img className="h-full w-full object-cover" src={sideArticles[0].imageUrl} alt={sideArticles[0].imageAlt} />
146
+ </div>
147
+ <h3 className="text-xl font-bold leading-snug tracking-tight text-black">{sideArticles[0].title}</h3>
148
+ </>
149
+ ) : (
150
+ <div className="flex h-full min-h-64 flex-col items-center justify-center rounded-lg border border-dashed border-slate-200 text-center">
151
+ <span className="material-symbols-outlined text-4xl text-slate-300">newspaper</span>
152
+ <p className="mt-3 text-sm font-medium text-slate-500">Đang chuẩn bị tin tức đầu ngày...</p>
153
+ </div>
154
+ )}
155
+ </article>
156
+
157
+ {sideArticles.slice(1).map((article) => (
158
+ <article
159
+ key={article.articleUrl}
160
+ className="flex flex-col overflow-hidden rounded-xl border border-transparent bg-white transition-all hover:border-slate-200 md:col-span-4"
161
+ >
162
+ <div className="h-48 w-full overflow-hidden">
163
+ <img className="h-full w-full object-cover" src={article.imageUrl} alt={article.imageAlt} />
164
+ </div>
165
+ <div className="flex flex-1 flex-col p-6">
166
+ <span
167
+ className={`mb-2 inline-flex w-fit rounded-sm px-2 py-1 text-[10px] font-black uppercase tracking-[0.14em] ${
168
+ TONE_BADGE_CLASS[article.categoryTone] || TONE_BADGE_CLASS.generic
169
+ }`}
170
+ >
171
+ {article.category}
172
+ </span>
173
+ <h3 className="mb-4 text-lg font-bold leading-tight tracking-tight text-black">{article.title}</h3>
174
+ <span className="mt-auto text-xs font-medium text-slate-500">{article.timeAgo || 'Hôm nay'}</span>
175
+ </div>
176
+ </article>
177
+ ))}
178
+ </div>
179
+ )}
180
+
181
+ {!loading && !featuredArticle && (
182
+ <div className="flex min-h-64 flex-col items-center justify-center rounded-xl border border-dashed border-black/20 bg-white text-center">
183
+ <span className="material-symbols-outlined text-6xl text-black/20">search</span>
184
+ <p className="mt-4 text-lg font-medium text-slate-500">
185
+ Chưa có dữ liệu tin tức đầu ngày. Hãy thử tìm kiếm ngay.
186
+ </p>
187
+ </div>
188
+ )}
189
+ </section>
190
+ </main>
191
+ </div>
192
+ );
193
+ }
194
+
195
+ export default HomePage;
src/components/Navbar.jsx ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ function Navbar() {
2
+ return (
3
+ <nav className="w-full py-4 px-6 flex items-center justify-between border-b border-gray-100 bg-white sticky top-0 z-50">
4
+ <div className="flex items-center gap-2">
5
+ <span className="material-symbols-outlined text-2xl text-slate-900">auto_stories</span>
6
+ <span className="text-lg font-semibold tracking-tight text-slate-900">NewsAI</span>
7
+ </div>
8
+ <div className="hidden md:flex items-center gap-6 text-sm font-medium text-slate-500">
9
+ <a className="hover:text-slate-900 transition-colors" href="#">Giới thiệu</a>
10
+ <a className="hover:text-slate-900 transition-colors" href="#">Tính năng</a>
11
+ <a className="hover:text-slate-900 transition-colors" href="#">Bảng giá</a>
12
+ </div>
13
+ </nav>
14
+ );
15
+ }
16
+
17
+ export default Navbar;
src/components/NewsArticle.jsx ADDED
@@ -0,0 +1,167 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState } from 'react';
2
+
3
+ const TONE_CLASSNAMES = {
4
+ news: 'bg-black text-white border-black',
5
+ commerce: 'bg-emerald-50 text-emerald-700 border-emerald-200',
6
+ culture: 'bg-indigo-50 text-indigo-700 border-indigo-200',
7
+ discussion: 'bg-violet-50 text-violet-700 border-violet-200',
8
+ review: 'bg-pink-50 text-pink-700 border-pink-200',
9
+ video: 'bg-red-50 text-red-700 border-red-200',
10
+ generic: 'bg-slate-50 text-slate-700 border-slate-200',
11
+ };
12
+
13
+ function NewsArticle({
14
+ category,
15
+ categoryTone,
16
+ source,
17
+ timeAgo,
18
+ title,
19
+ description,
20
+ imageUrl,
21
+ imageAlt,
22
+ articleUrl,
23
+ onSummarize,
24
+ onGenerateTts,
25
+ summary,
26
+ summaryVisible,
27
+ summaryLoading,
28
+ ttsStatus = 'idle',
29
+ ttsAudioUrl = '',
30
+ ttsError = '',
31
+ }) {
32
+ const [copied, setCopied] = useState(false);
33
+ const hasSummary = !!summary;
34
+ const showSummary = hasSummary && summaryVisible;
35
+ const isGeneratingAudio = ttsStatus === 'queued' || ttsStatus === 'processing';
36
+ const categoryClasses = TONE_CLASSNAMES[categoryTone] || TONE_CLASSNAMES.generic;
37
+
38
+ const handleShare = async () => {
39
+ if (!articleUrl || articleUrl === '#') return;
40
+
41
+ try {
42
+ await navigator.clipboard.writeText(articleUrl);
43
+ setCopied(true);
44
+ window.setTimeout(() => setCopied(false), 1800);
45
+ } catch {
46
+ setCopied(false);
47
+ }
48
+ };
49
+
50
+ return (
51
+ <article className="group flex flex-col gap-6">
52
+ <div className="flex flex-col gap-6 md:flex-row md:gap-10">
53
+ <div className="h-44 w-full shrink-0 overflow-hidden border border-black bg-slate-100 md:w-60">
54
+ <img
55
+ alt={imageAlt}
56
+ className="h-full w-full object-cover transition-all duration-700 group-hover:scale-105"
57
+ src={imageUrl}
58
+ />
59
+ </div>
60
+
61
+ <div className="flex flex-1 flex-col">
62
+ <div className="mb-4 flex flex-wrap items-center gap-3">
63
+ <span className={`border px-2 py-0.5 text-[10px] font-black uppercase tracking-[0.12em] ${categoryClasses}`}>
64
+ {category}
65
+ </span>
66
+ <span className="text-xs font-bold text-black">{source}</span>
67
+ <span className="h-1 w-1 rounded-full bg-black" />
68
+ <span className="text-xs font-bold text-slate-400">{timeAgo || 'Vừa xong'}</span>
69
+ </div>
70
+
71
+ <h3 className="text-2xl font-black leading-tight tracking-tight text-black transition-all group-hover:underline group-hover:decoration-2 group-hover:underline-offset-4">
72
+ {title}
73
+ </h3>
74
+
75
+ <p className="mt-3 line-clamp-2 text-sm font-medium leading-relaxed text-slate-500">
76
+ {description}
77
+ </p>
78
+
79
+ <div className="mt-6 flex flex-wrap items-center justify-between gap-4">
80
+ <div className="flex flex-wrap items-center gap-6">
81
+ <a
82
+ className="flex items-center gap-2 text-[11px] font-black uppercase tracking-[0.13em] text-black transition-all hover:opacity-60"
83
+ href={articleUrl}
84
+ target="_blank"
85
+ rel="noopener noreferrer"
86
+ >
87
+ <span className="material-symbols-outlined text-lg">article</span>
88
+ Đọc bài gốc
89
+ </a>
90
+
91
+ <button
92
+ type="button"
93
+ onClick={onSummarize}
94
+ disabled={summaryLoading}
95
+ className="flex items-center gap-2 text-[11px] font-black uppercase tracking-[0.13em] text-slate-400 transition-all hover:text-black disabled:cursor-not-allowed disabled:opacity-70"
96
+ >
97
+ <span className={`material-symbols-outlined text-lg ${summaryLoading ? 'animate-spin' : ''}`}>
98
+ {summaryLoading ? 'progress_activity' : showSummary ? 'expand_less' : 'summarize'}
99
+ </span>
100
+ {summaryLoading ? 'Đang tóm tắt...' : showSummary ? 'Thu gọn tóm tắt' : hasSummary ? 'Xem lại tóm tắt' : 'Tóm tắt bài viết'}
101
+ </button>
102
+
103
+ <button
104
+ type="button"
105
+ onClick={onGenerateTts}
106
+ disabled={!hasSummary || summaryLoading || isGeneratingAudio}
107
+ className="flex items-center gap-2 text-[11px] font-black uppercase tracking-[0.13em] text-slate-400 transition-all hover:text-black disabled:cursor-not-allowed disabled:opacity-70"
108
+ >
109
+ <span className={`material-symbols-outlined text-lg ${isGeneratingAudio ? 'animate-spin' : ''}`}>
110
+ {isGeneratingAudio ? 'progress_activity' : 'play_arrow'}
111
+ </span>
112
+ {isGeneratingAudio ? 'Đang tạo audio...' : ttsAudioUrl ? 'Tạo lại audio' : 'Nghe audio'}
113
+ </button>
114
+ </div>
115
+
116
+ <div className="flex items-center gap-2">
117
+ {copied && (
118
+ <span className="text-[10px] font-black uppercase tracking-[0.12em] text-emerald-600">
119
+ Đã copy
120
+ </span>
121
+ )}
122
+ <button
123
+ type="button"
124
+ onClick={handleShare}
125
+ className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-full text-black transition-all hover:bg-slate-100"
126
+ title="Sao chép link bài viết"
127
+ >
128
+ <span className="material-symbols-outlined text-base leading-none">{copied ? 'check' : 'share'}</span>
129
+ </button>
130
+ </div>
131
+ </div>
132
+ </div>
133
+ </div>
134
+
135
+ {(summaryLoading || showSummary) && (
136
+ <div className="border-l-2 border-black bg-slate-50 px-5 py-4">
137
+ {summaryLoading ? (
138
+ <div className="flex items-center gap-3 text-sm font-medium text-slate-600">
139
+ <span className="material-symbols-outlined animate-spin text-lg">progress_activity</span>
140
+ Đang tóm tắt bài viết...
141
+ </div>
142
+ ) : (
143
+ <div>
144
+ <div className="mb-2 flex items-center gap-2">
145
+ <span className="material-symbols-outlined text-base text-black">summarize</span>
146
+ <h4 className="text-xs font-black uppercase tracking-[0.14em] text-black">Tóm tắt nhanh</h4>
147
+ </div>
148
+ <p className="whitespace-pre-wrap text-sm leading-relaxed text-slate-700">{summary}</p>
149
+
150
+ {ttsError ? (
151
+ <p className="mt-3 text-xs font-semibold text-red-600">{ttsError}</p>
152
+ ) : null}
153
+
154
+ {ttsAudioUrl ? (
155
+ <div className="mt-4">
156
+ <audio controls className="w-full" src={ttsAudioUrl} preload="none" />
157
+ </div>
158
+ ) : null}
159
+ </div>
160
+ )}
161
+ </div>
162
+ )}
163
+ </article>
164
+ );
165
+ }
166
+
167
+ export default NewsArticle;
src/components/SearchBox.jsx ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ function SearchBox({ value, onChange, onSearch, loading }) {
2
+ const handleSubmit = (event) => {
3
+ event.preventDefault();
4
+ if (!value?.trim() || !onSearch) return;
5
+ onSearch(value.trim());
6
+ };
7
+
8
+ return (
9
+ <form onSubmit={handleSubmit} className="mx-auto w-full max-w-[860px]">
10
+ <div className="group relative">
11
+ <div className="pointer-events-none absolute inset-0 rounded-full border-2 border-black/10 transition-all group-focus-within:border-black/40" />
12
+ <div className="relative flex items-center gap-4 rounded-full border-2 border-black bg-white px-6 py-4 shadow-[8px_8px_0px_0px_rgba(0,0,0,0.05)] transition-all group-hover:shadow-[10px_10px_0px_0px_rgba(0,0,0,0.08)] md:px-9 md:py-5">
13
+ <span className="material-symbols-outlined shrink-0 text-black">search</span>
14
+ <input
15
+ type="text"
16
+ value={value}
17
+ onChange={(event) => onChange?.(event.target.value)}
18
+ disabled={loading}
19
+ placeholder="Nhập từ khóa hoặc dán URL của tin tức..."
20
+ className="w-full border-0 bg-transparent text-base font-medium text-black placeholder:text-slate-400 focus:ring-0 md:text-lg"
21
+ />
22
+ <button
23
+ type="submit"
24
+ disabled={loading || !value?.trim()}
25
+ className="flex shrink-0 items-center gap-2 rounded-full bg-black px-5 py-2.5 text-xs font-black uppercase tracking-[0.15em] text-white transition-all hover:bg-slate-800 disabled:cursor-not-allowed disabled:bg-slate-400 md:px-8"
26
+ >
27
+ <span className="material-symbols-outlined text-base">
28
+ {loading ? 'progress_activity' : 'arrow_forward'}
29
+ </span>
30
+ <span className="hidden sm:inline">Tìm kiếm</span>
31
+ </button>
32
+ </div>
33
+ </div>
34
+ </form>
35
+ );
36
+ }
37
+
38
+ export default SearchBox;
src/components/SummaryBox.jsx ADDED
@@ -0,0 +1,132 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState } from 'react';
2
+
3
+ function SummaryBox({
4
+ summary,
5
+ totalArticles,
6
+ loading,
7
+ onResummarize,
8
+ resummarizeDisabled = false,
9
+ onGenerateTts,
10
+ ttsStatus = 'idle',
11
+ ttsAudioUrl = '',
12
+ ttsError = '',
13
+ }) {
14
+ const [copied, setCopied] = useState(false);
15
+
16
+ const handleCopy = async () => {
17
+ if (!summary) return;
18
+ try {
19
+ await navigator.clipboard.writeText(summary);
20
+ setCopied(true);
21
+ window.setTimeout(() => setCopied(false), 2000);
22
+ } catch {
23
+ setCopied(false);
24
+ }
25
+ };
26
+
27
+ const renderedSummary = summary
28
+ ?.split('\n')
29
+ .map((line) => line.trim())
30
+ .filter(Boolean);
31
+
32
+ const hasSummary = renderedSummary?.length > 0;
33
+ const isGeneratingAudio = ttsStatus === 'queued' || ttsStatus === 'processing';
34
+
35
+ return (
36
+ <div className="summary-panel overflow-hidden border-2 border-black bg-white p-6 shadow-[10px_10px_0px_0px_rgba(0,0,0,0.04)] md:p-7">
37
+ <div className="mb-6 flex items-center gap-3">
38
+ <span className="h-[2px] w-8 bg-black" />
39
+ <h2 className="text-xs font-black uppercase tracking-[0.22em] text-black">Bản tóm tắt AI</h2>
40
+ {loading ? (
41
+ <span className="ml-auto rounded-full border border-black/20 bg-white px-3 py-1 text-[10px] font-black uppercase tracking-[0.12em] text-black animate-pulse">
42
+ Đang tóm tắt...
43
+ </span>
44
+ ) : totalArticles ? (
45
+ <span className="ml-auto rounded-full border border-black/20 bg-white px-3 py-1 text-[10px] font-black uppercase tracking-[0.12em] text-black">
46
+ {totalArticles} bài
47
+ </span>
48
+ ) : null}
49
+ </div>
50
+
51
+ {onResummarize ? (
52
+ <div className="mb-6 flex justify-end">
53
+ <button
54
+ type="button"
55
+ onClick={onResummarize}
56
+ disabled={resummarizeDisabled}
57
+ className="flex items-center gap-2 rounded-full border border-black bg-white px-4 py-2 text-[10px] font-black uppercase tracking-[0.14em] text-black transition-all hover:bg-black hover:text-white disabled:cursor-not-allowed disabled:opacity-60"
58
+ >
59
+ <span className={`material-symbols-outlined text-sm ${loading ? 'animate-spin' : ''}`}>
60
+ {loading ? 'progress_activity' : 'autorenew'}
61
+ </span>
62
+ Re-summary hôm nay
63
+ </button>
64
+ </div>
65
+ ) : null}
66
+
67
+ {loading ? (
68
+ <div className="space-y-4 rounded-lg border border-black/10 bg-slate-50 p-5">
69
+ <div className="flex items-center gap-3 text-slate-600">
70
+ <span className="material-symbols-outlined summary-spin text-2xl">progress_activity</span>
71
+ <p className="text-sm font-semibold">Đang tạo bản tóm tắt thông minh...</p>
72
+ </div>
73
+ <div className="summary-shimmer h-3 rounded" />
74
+ <div className="summary-shimmer h-3 w-[88%] rounded" />
75
+ <div className="summary-shimmer h-3 w-[76%] rounded" />
76
+ <div className="summary-shimmer h-3 w-[82%] rounded" />
77
+ </div>
78
+ ) : !hasSummary ? (
79
+ <div className="rounded-lg border border-dashed border-black/20 bg-slate-50 p-5 text-center">
80
+ <span className="material-symbols-outlined text-4xl text-black/25">auto_awesome</span>
81
+ <p className="mt-3 text-sm font-medium text-slate-500">
82
+ Chọn hoặc tìm kiếm tin tức để tạo bản tóm tắt.
83
+ </p>
84
+ </div>
85
+ ) : (
86
+ <div>
87
+ <div className="max-h-[48vh] space-y-3 overflow-y-auto pr-1 text-[15px] font-medium leading-relaxed text-slate-600 md:text-base">
88
+ {renderedSummary?.map((line, index) => (
89
+ <p key={`${index}-${line}`} className={/^\d+\./.test(line) ? 'font-semibold text-black' : ''}>
90
+ {line}
91
+ </p>
92
+ ))}
93
+ </div>
94
+
95
+ <div className="mt-8 flex flex-wrap items-center gap-4">
96
+ <button
97
+ type="button"
98
+ onClick={onGenerateTts}
99
+ disabled={!hasSummary || isGeneratingAudio}
100
+ className="flex items-center gap-3 bg-black px-8 py-4 text-xs font-black uppercase tracking-[0.15em] text-white transition-all hover:bg-slate-800 disabled:cursor-not-allowed disabled:opacity-60"
101
+ >
102
+ <span className={`material-symbols-outlined text-lg ${isGeneratingAudio ? 'animate-spin' : ''}`}>
103
+ {isGeneratingAudio ? 'progress_activity' : 'play_arrow'}
104
+ </span>
105
+ {isGeneratingAudio ? 'Đang tạo audio...' : ttsAudioUrl ? 'Tạo lại bản audio' : 'Nghe bản tin'}
106
+ </button>
107
+ <button
108
+ type="button"
109
+ onClick={handleCopy}
110
+ className="flex items-center gap-3 border-2 border-black bg-white px-8 py-4 text-xs font-black uppercase tracking-[0.15em] text-black transition-all hover:bg-slate-50"
111
+ >
112
+ <span className="material-symbols-outlined text-lg">content_copy</span>
113
+ {copied ? 'Đã sao chép' : 'Sao chép tóm tắt'}
114
+ </button>
115
+ </div>
116
+
117
+ {ttsError ? (
118
+ <p className="mt-4 text-xs font-semibold text-red-600">{ttsError}</p>
119
+ ) : null}
120
+
121
+ {ttsAudioUrl ? (
122
+ <div className="mt-4">
123
+ <audio controls className="w-full" src={ttsAudioUrl} preload="none" />
124
+ </div>
125
+ ) : null}
126
+ </div>
127
+ )}
128
+ </div>
129
+ );
130
+ }
131
+
132
+ export default SummaryBox;
src/index.css ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ :root {
2
+ --background: #f6f7fb;
3
+ --on-background: #0b0b0b;
4
+ --surface: #ffffff;
5
+ --outline: rgba(15, 23, 42, 0.16);
6
+
7
+ font-family: 'Inter', sans-serif;
8
+ line-height: 1.5;
9
+ font-weight: 400;
10
+ font-synthesis: none;
11
+ text-rendering: optimizeLegibility;
12
+ -webkit-font-smoothing: antialiased;
13
+ -moz-osx-font-smoothing: grayscale;
14
+ }
15
+
16
+ * {
17
+ box-sizing: border-box;
18
+ }
19
+
20
+ body {
21
+ margin: 0;
22
+ width: 100%;
23
+ min-height: 100vh;
24
+ background:
25
+ radial-gradient(circle at 92% 4%, rgba(0, 0, 0, 0.05) 0, rgba(0, 0, 0, 0) 36%),
26
+ radial-gradient(circle at 4% 96%, rgba(15, 23, 42, 0.07) 0, rgba(15, 23, 42, 0) 34%),
27
+ var(--background);
28
+ color: var(--on-background);
29
+ }
30
+
31
+ #root {
32
+ width: 100%;
33
+ min-height: 100vh;
34
+ }
35
+
36
+ h1,
37
+ h2,
38
+ h3,
39
+ h4 {
40
+ font-family: 'Manrope', sans-serif;
41
+ }
src/main.jsx ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ import { StrictMode } from 'react'
2
+ import { createRoot } from 'react-dom/client'
3
+ import './index.css'
4
+ import App from './App.jsx'
5
+
6
+ createRoot(document.getElementById('root')).render(
7
+ <StrictMode>
8
+ <App />
9
+ </StrictMode>,
10
+ )
src/services/api.service.js ADDED
@@ -0,0 +1,93 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import axios from 'axios';
2
+
3
+ const rawApiUrl = import.meta.env.VITE_API_URL || 'http://localhost:5000';
4
+
5
+ const API_URL = (() => {
6
+ const trimmedUrl = rawApiUrl.replace(/\/+$/, '');
7
+
8
+ try {
9
+ const parsedUrl = new URL(trimmedUrl);
10
+
11
+ // If only an origin is provided, default to the backend API namespace.
12
+ if (!parsedUrl.pathname || parsedUrl.pathname === '/') {
13
+ parsedUrl.pathname = '/api';
14
+ return parsedUrl.toString().replace(/\/+$/, '');
15
+ }
16
+
17
+ return trimmedUrl;
18
+ } catch {
19
+ // Support relative API URLs while keeping explicit path configuration intact.
20
+ if (!trimmedUrl || trimmedUrl === '.') return '/api';
21
+ return trimmedUrl.startsWith('/') ? trimmedUrl : `/${trimmedUrl}`;
22
+ }
23
+ })();
24
+
25
+ class ApiService {
26
+ constructor() {
27
+ this.client = axios.create({
28
+ baseURL: API_URL,
29
+ headers: {
30
+ 'Content-Type': 'application/json'
31
+ }
32
+ });
33
+ }
34
+
35
+ async search(query, options = {}) {
36
+ const response = await this.client.post('/search', {
37
+ query,
38
+ language: options.language,
39
+ freshness: options.freshness
40
+ });
41
+ return response.data;
42
+ }
43
+
44
+ async searchAndSummarize(query, options = {}) {
45
+ const response = await this.client.post('/search-summarize', {
46
+ query,
47
+ language: options.language,
48
+ freshness: options.freshness
49
+ });
50
+ return response.data;
51
+ }
52
+
53
+ async scrapeAndSummarize(urls, query = '') {
54
+ const response = await this.client.post('/scrape-summarize', { urls, query });
55
+ return response.data;
56
+ }
57
+
58
+ async scrape(url) {
59
+ const response = await this.client.post('/scrape', { url });
60
+ return response.data;
61
+ }
62
+
63
+ async generateWithGemini(prompt) {
64
+ const response = await this.client.post('/gemini', { prompt });
65
+ return response.data;
66
+ }
67
+
68
+ async summarizeNews(content, title) {
69
+ const response = await this.client.post('/gemini/summarize', { content, title });
70
+ return response.data;
71
+ }
72
+
73
+ async healthCheck() {
74
+ const response = await this.client.get('/health');
75
+ return response.data;
76
+ }
77
+
78
+ async createTtsJob(text, options = {}) {
79
+ const response = await this.client.post('/tts/jobs', {
80
+ text,
81
+ language: options.language || 'vi',
82
+ speaker_audio: options.speakerAudio
83
+ });
84
+ return response.data;
85
+ }
86
+
87
+ async getTtsJob(key) {
88
+ const response = await this.client.get(`/tts/jobs/${encodeURIComponent(key)}`);
89
+ return response.data;
90
+ }
91
+ }
92
+
93
+ export default new ApiService();
tts_fastapi/Dockerfile ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ WORKDIR /app
4
+
5
+ RUN apt-get update && apt-get install -y \
6
+ ffmpeg \
7
+ && rm -rf /var/lib/apt/lists/*
8
+
9
+ # Copy requirements from root and subdirs as needed
10
+ COPY requirements.txt ./
11
+ COPY backend/tts_fastapi/requirements.txt ./backend/tts_fastapi/
12
+ COPY models/XTTSv2-Finetuning-for-New-Languages/requirements.txt ./models/XTTSv2-Finetuning-for-New-Languages/
13
+
14
+ RUN pip install --no-cache-dir -r requirements.txt
15
+
16
+ COPY . .
17
+
18
+ WORKDIR /app/backend/tts_fastapi
19
+ EXPOSE 8000
20
+
21
+ CMD ["python3", "-m", "uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000"]
tts_fastapi/README.md ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # vnTTS FastAPI (GPU)
2
+
3
+ This folder provides a FastAPI service that loads `anhnh2002/vnTTS` and exposes:
4
+
5
+ - `POST /v1/tts` for synchronous TTS inference.
6
+ - `POST /v1/tasks/tts` and `GET /v1/tasks/{task_id}` for async task dispatch.
7
+ - `GET /health` for runtime health checks.
8
+
9
+ ## 1) Prepare environment (Windows + GPU)
10
+
11
+ Use Python 3.11 and run from repository root:
12
+
13
+ ```powershell
14
+ py -3.11 -m venv .venv311
15
+ .\.venv311\Scripts\python.exe -m pip install --upgrade pip setuptools wheel
16
+ .\.venv311\Scripts\python.exe -m pip install torch torchaudio --index-url https://download.pytorch.org/whl/cu121
17
+ .\.venv311\Scripts\python.exe -m pip install -r .\models\XTTSv2-Finetuning-for-New-Languages\requirements.txt
18
+ .\.venv311\Scripts\python.exe -m pip install -r .\backend\tts_fastapi\requirements.txt
19
+ ```
20
+
21
+ ## 2) Download model weights
22
+
23
+ ```powershell
24
+ .\.venv311\Scripts\python.exe -c "from huggingface_hub import snapshot_download; snapshot_download(repo_id='anhnh2002/vnTTS', repo_type='model', local_dir='models/vntts-runtime-model')"
25
+ ```
26
+
27
+ ## 3) Run FastAPI service
28
+
29
+ ```powershell
30
+ $env:PYTHONPATH="e:/Users/Admin/Documents/GitHub/Project-LT-ML-23KHDL1-HCMUS/models/XTTSv2-Finetuning-for-New-Languages"
31
+ $env:VNTTS_MODEL_DIR="e:/Users/Admin/Documents/GitHub/Project-LT-ML-23KHDL1-HCMUS/models/vntts-runtime-model"
32
+ $env:VNTTS_DEVICE="cuda:0"
33
+ .\.venv311\Scripts\python.exe -m uvicorn backend.tts_fastapi.app:app --host 127.0.0.1 --port 8001
34
+ ```
35
+
36
+ ## 4) Smoke test
37
+
38
+ ```powershell
39
+ Invoke-RestMethod -Method Post -Uri http://127.0.0.1:8001/v1/tts -ContentType "application/json" -Body '{"text":"Xin chao ban, day la ban thu am tu vnTTS.","language":"vi"}'
40
+ ```
41
+
42
+ The API returns a JSON payload containing `audio_base64` and `sample_rate`.
tts_fastapi/__init__.py ADDED
File without changes