Spaces:
Paused
Paused
Upload 57 files
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .dockerignore +8 -0
- .env.example +2 -0
- .gitignore +24 -0
- Dockerfile +13 -0
- README.md +16 -10
- config/app.config.js +5 -0
- config/brave.config.js +4 -0
- config/gemini.config.js +4 -0
- config/puppeteer.config.js +6 -0
- config/supabase.config.js +5 -0
- config/tts.config.js +4 -0
- controllers/gemini.controller.js +42 -0
- controllers/scrape.controller.js +24 -0
- controllers/search.controller.js +261 -0
- controllers/tts.controller.js +142 -0
- eslint.config.js +29 -0
- index.html +37 -0
- package-lock.json +0 -0
- package.json +29 -0
- public/vite.svg +1 -0
- routes/gemini.routes.js +9 -0
- routes/index.js +20 -0
- routes/scrape.routes.js +8 -0
- routes/search.routes.js +10 -0
- routes/tts.routes.js +13 -0
- server.js +22 -0
- services/audioTranscode.service.js +71 -0
- services/brave.service.js +50 -0
- services/gemini.service.js +135 -0
- services/scrape.service.js +256 -0
- services/tts.service.js +33 -0
- services/ttsJob.service.js +143 -0
- src/App.css +66 -0
- src/App.jsx +1288 -0
- src/AppHome.jsx +73 -0
- src/assets/react.svg +1 -0
- src/components/FilterBar.jsx +57 -0
- src/components/Hero.jsx +10 -0
- src/components/HomeNewsGrid.jsx +233 -0
- src/components/HomePage.jsx +195 -0
- src/components/Navbar.jsx +17 -0
- src/components/NewsArticle.jsx +167 -0
- src/components/SearchBox.jsx +38 -0
- src/components/SummaryBox.jsx +132 -0
- src/index.css +41 -0
- src/main.jsx +10 -0
- src/services/api.service.js +93 -0
- tts_fastapi/Dockerfile +21 -0
- tts_fastapi/README.md +42 -0
- tts_fastapi/__init__.py +0 -0
.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 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
---
|
| 9 |
-
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|