wokogaming commited on
Commit
27c4dd4
·
verified ·
1 Parent(s): fb016c6

Upload 31 files

Browse files
frontend/.env.example ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ # Backend API URL
2
+ VITE_API_URL=http://localhost:5000
frontend/.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?
frontend/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;"]
frontend/README.md ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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.
frontend/dist/assets/index-BrA7KMgp.css ADDED
@@ -0,0 +1 @@
 
 
1
+ :root{--background: #f6f7fb;--on-background: #0b0b0b;--surface: #ffffff;--outline: rgba(15, 23, 42, .16);font-family:Inter,sans-serif;line-height:1.5;font-weight:400;font-synthesis:none;text-rendering:optimizeLegibility;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}*{box-sizing:border-box}body{margin:0;width:100%;min-height:100vh;background:radial-gradient(circle at 92% 4%,#0000000d 0,#0000 36%),radial-gradient(circle at 4% 96%,#0f172a12 0,#0f172a00 34%),var(--background);color:var(--on-background)}#root{width:100%;min-height:100vh}h1,h2,h3,h4{font-family:Manrope,sans-serif}.material-symbols-outlined{font-variation-settings:"FILL" 0,"wght" 450,"GRAD" 0,"opsz" 24}@keyframes summary-spin{0%{transform:rotate(0)}to{transform:rotate(360deg)}}@keyframes summary-shimmer{0%{background-position:-220px 0}to{background-position:calc(220px + 100%) 0}}.summary-panel{-webkit-backdrop-filter:blur(2px);backdrop-filter:blur(2px)}.summary-spin{animation:summary-spin 1.2s linear infinite}.summary-shimmer{background:linear-gradient(90deg,#e5e7eb,#f8fafc,#e5e7eb 80px);background-size:220px 100%;animation:summary-shimmer 1.3s linear infinite}@keyframes fade-in-up{0%{opacity:0;transform:translateY(12px)}to{opacity:1;transform:translateY(0)}}main section,main header{animation:fade-in-up .45s ease-out both}main section:nth-of-type(2){animation-delay:80ms}main section:nth-of-type(3){animation-delay:.16s}@media(max-width:1023px){main section,main header{animation-duration:.35s}}
frontend/dist/assets/index-Crl0tegD.js ADDED
The diff for this file is too large to render. See raw diff
 
frontend/dist/index.html ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ <script type="module" crossorigin src="/assets/index-Crl0tegD.js"></script>
33
+ <link rel="stylesheet" crossorigin href="/assets/index-BrA7KMgp.css">
34
+ </head>
35
+ <body>
36
+ <div id="root"></div>
37
+ </body>
38
+ </html>
frontend/dist/vite.svg ADDED
frontend/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
+ ])
frontend/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>
frontend/new ui/home.html ADDED
@@ -0,0 +1,297 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+
3
+ <html class="light" lang="vi"><head>
4
+ <meta charset="utf-8"/>
5
+ <meta content="width=device-width, initial-scale=1.0" name="viewport"/>
6
+ <link href="https://fonts.googleapis.com/css2?family=Manrope:wght@200;400;500;600;700;800&amp;family=Inter:wght@300;400;500;600;700&amp;display=swap" rel="stylesheet"/>
7
+ <link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&amp;display=swap" rel="stylesheet"/>
8
+ <link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&amp;display=swap" rel="stylesheet"/>
9
+ <script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
10
+ <style>
11
+ .material-symbols-outlined {
12
+ font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
13
+ vertical-align: middle;
14
+ }
15
+ body {
16
+ font-family: 'Inter', sans-serif;
17
+ background-color: #f7f9fb;
18
+ }
19
+ h1, h2, h3, .headline-lg {
20
+ font-family: 'Manrope', sans-serif;
21
+ }
22
+ .no-scrollbar::-webkit-scrollbar {
23
+ display: none;
24
+ }
25
+ .no-scrollbar {
26
+ -ms-overflow-style: none;
27
+ scrollbar-width: none;
28
+ }
29
+ </style>
30
+ <script id="tailwind-config">
31
+ tailwind.config = {
32
+ darkMode: "class",
33
+ theme: {
34
+ extend: {
35
+ "colors": {
36
+ "surface": "#f7f9fb",
37
+ "on-error-container": "#93000a",
38
+ "error": "#ba1a1a",
39
+ "outline-variant": "#c6c6cd",
40
+ "inverse-primary": "#b4c5ff",
41
+ "surface-container-lowest": "#ffffff",
42
+ "on-secondary-fixed": "#0d1c2e",
43
+ "surface-dim": "#d8dadc",
44
+ "on-tertiary": "#ffffff",
45
+ "on-surface": "#191c1e",
46
+ "secondary-fixed": "#d5e3fc",
47
+ "surface-variant": "#e0e3e5",
48
+ "on-secondary": "#ffffff",
49
+ "on-primary-container": "#497cff",
50
+ "primary-fixed": "#dbe1ff",
51
+ "on-tertiary-container": "#7c839b",
52
+ "on-surface-variant": "#45474c",
53
+ "primary-fixed-dim": "#b4c5ff",
54
+ "tertiary": "#000000",
55
+ "surface-container": "#eceef0",
56
+ "inverse-on-surface": "#eff1f3",
57
+ "secondary-fixed-dim": "#b9c7df",
58
+ "background": "#f7f9fb",
59
+ "surface-tint": "#0053db",
60
+ "primary": "#000000",
61
+ "surface-container-highest": "#e0e3e5",
62
+ "primary-container": "#00174b",
63
+ "secondary-container": "#d5e3fc",
64
+ "on-primary-fixed": "#00174b",
65
+ "error-container": "#ffdad6",
66
+ "tertiary-container": "#131b2e",
67
+ "secondary": "#515f74",
68
+ "on-secondary-container": "#57657a",
69
+ "on-tertiary-fixed": "#131b2e",
70
+ "tertiary-fixed-dim": "#bec6e0",
71
+ "surface-container-high": "#e6e8ea",
72
+ "on-primary-fixed-variant": "#003ea8",
73
+ "on-error": "#ffffff",
74
+ "on-background": "#191c1e",
75
+ "surface-bright": "#f7f9fb",
76
+ "on-primary": "#ffffff",
77
+ "surface-container-low": "#f2f4f6",
78
+ "outline": "#76777d",
79
+ "on-tertiary-fixed-variant": "#3f465c",
80
+ "inverse-surface": "#2d3133",
81
+ "on-secondary-fixed-variant": "#3a485b",
82
+ "tertiary-fixed": "#dae2fd"
83
+ },
84
+ "borderRadius": {
85
+ "DEFAULT": "0.125rem",
86
+ "lg": "0.25rem",
87
+ "xl": "0.5rem",
88
+ "full": "0.75rem"
89
+ },
90
+ "fontFamily": {
91
+ "headline": ["Manrope"],
92
+ "body": ["Inter"],
93
+ "label": ["Inter"]
94
+ }
95
+ },
96
+ }
97
+ }
98
+ </script>
99
+ </head>
100
+ <body class="bg-surface text-on-surface selection:bg-primary selection:text-white">
101
+ <!-- TopNavBar Cluster -->
102
+ <header class="flex justify-between items-center px-8 py-4 w-full max-w-full sticky top-0 z-50 bg-[#f7f9fb] dark:bg-[#0a0a0a] tonal-shift-bg border-none">
103
+ <div class="flex items-center gap-8">
104
+ <span class="text-2xl font-black tracking-tight text-black dark:text-white">NewsAI</span>
105
+ <nav class="hidden md:flex items-center gap-6">
106
+ <a class="text-black dark:text-white font-bold border-b-2 border-black dark:border-white pb-1 transition-colors" href="#">Discover</a>
107
+ <a class="text-slate-500 dark:text-slate-400 font-medium hover:text-blue-600 dark:hover:text-blue-400 transition-colors" href="#">Briefings</a>
108
+ <a class="text-slate-500 dark:text-slate-400 font-medium hover:text-blue-600 dark:hover:text-blue-400 transition-colors" href="#">Channels</a>
109
+ </nav>
110
+ </div>
111
+ <div class="flex items-center gap-4">
112
+ <div class="flex items-center gap-3 mr-4">
113
+ <button class="material-symbols-outlined text-on-surface-variant hover:text-primary transition-colors">notifications</button>
114
+ <button class="material-symbols-outlined text-on-surface-variant hover:text-primary transition-colors">settings</button>
115
+ </div>
116
+ <button class="bg-primary text-on-primary px-5 py-2 rounded-lg font-bold tracking-tight scale-95 active:duration-100 transition-all hover:opacity-90">Summarize All</button>
117
+ <div class="w-10 h-10 rounded-full overflow-hidden bg-surface-container">
118
+ <img alt="User profile" class="w-full h-full object-cover" data-alt="close-up portrait of a professional man with a neutral expression against a clean studio background" src="https://lh3.googleusercontent.com/aida-public/AB6AXuDoQmHS1Tv7ZwRrKKn4NKLeQASbjyXWGh6C_W6cPSwKX8kd8oLAZ5isTajGhYQ4FFuk_NsYeS3ZX4IZwym99L0x0hpeqTNmLFIajgNzF8TjFDvlh3X_icICFgbWY3vcUNS8bz5d0nyzldcODOK_MZzpEqTtYKDEK1TSaYiyO6MKfiMFKea4uLMnGLw6JkYeTx6mWZ0Oo5IMOXaNw35H4JXJFoo8oPeIgh3_3XSNIRkTqyetarLQZ-ICYjFkUq04v60gmv9eTmAbwR8"/>
119
+ </div>
120
+ </div>
121
+ </header>
122
+ <div class="flex min-h-screen">
123
+ <!-- SideNavBar Cluster -->
124
+ <aside class="flex flex-col h-screen fixed left-0 top-0 pt-20 pb-8 px-4 z-40 bg-white/70 dark:bg-black/70 backdrop-blur-xl h-full w-72 border-r-0 shadow-[0px_12px_32px_rgba(25,28,30,0.06)] tonal-layering bg-slate-100 dark:bg-slate-900">
125
+ <div class="px-4 mb-8">
126
+ <div class="flex items-center gap-3 mb-2">
127
+ <div class="w-8 h-8 rounded-lg bg-primary flex items-center justify-center">
128
+ <span class="material-symbols-outlined text-white text-sm" style="font-variation-settings: 'FILL' 1;">history</span>
129
+ </div>
130
+ <div>
131
+ <h3 class="text-lg font-bold text-black dark:text-white leading-tight">Recent History</h3>
132
+ <p class="text-[10px] uppercase tracking-widest text-slate-500 font-bold">AI Synopses</p>
133
+ </div>
134
+ </div>
135
+ </div>
136
+ <div class="flex-1 overflow-y-auto no-scrollbar space-y-1 px-2">
137
+ <button class="w-full flex items-center gap-3 px-3 py-3 bg-slate-200/50 dark:bg-slate-800/50 text-black dark:text-white rounded-xl font-semibold translate-x-1 duration-200 group">
138
+ <span class="material-symbols-outlined text-blue-600 dark:text-blue-500">history</span>
139
+ <span class="font-inter text-sm tracking-normal">Global Markets Recap</span>
140
+ </button>
141
+ <button class="w-full flex items-center gap-3 px-3 py-3 text-slate-500 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800 rounded-xl transition-all group">
142
+ <span class="material-symbols-outlined">bookmark</span>
143
+ <span class="font-inter text-sm tracking-normal">Quantum Computing</span>
144
+ </button>
145
+ <button class="w-full flex items-center gap-3 px-3 py-3 text-slate-500 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800 rounded-xl transition-all group">
146
+ <span class="material-symbols-outlined">source</span>
147
+ <span class="font-inter text-sm tracking-normal">Tech Policy 2024</span>
148
+ </button>
149
+ <button class="w-full flex items-center gap-3 px-3 py-3 text-slate-500 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800 rounded-xl transition-all group">
150
+ <span class="material-symbols-outlined">edit_note</span>
151
+ <span class="font-inter text-sm tracking-normal">Drafts: AI Safety</span>
152
+ </button>
153
+ <button class="w-full flex items-center gap-3 px-3 py-3 text-slate-500 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800 rounded-xl transition-all group">
154
+ <span class="material-symbols-outlined">inventory_2</span>
155
+ <span class="font-inter text-sm tracking-normal">Archived Briefs</span>
156
+ </button>
157
+ </div>
158
+ <div class="mt-auto px-2 space-y-4">
159
+ <button class="w-full py-4 bg-primary text-white rounded-xl font-bold text-sm tracking-tight flex items-center justify-center gap-2 hover:opacity-90 transition-opacity">
160
+ <span class="material-symbols-outlined text-sm">add</span>
161
+ New Research
162
+ </button>
163
+ <div class="pt-4 border-t border-slate-200 dark:border-slate-800">
164
+ <button class="w-full flex items-center gap-3 px-3 py-2 text-slate-500 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800 rounded-xl transition-all">
165
+ <span class="material-symbols-outlined">help</span>
166
+ <span class="font-inter text-sm tracking-normal">Support</span>
167
+ </button>
168
+ <button class="w-full flex items-center gap-3 px-3 py-2 text-slate-500 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800 rounded-xl transition-all">
169
+ <span class="material-symbols-outlined">person</span>
170
+ <span class="font-inter text-sm tracking-normal">Account</span>
171
+ </button>
172
+ </div>
173
+ </div>
174
+ </aside>
175
+ <!-- Main Content Area -->
176
+ <main class="flex-1 ml-72 p-8 lg:p-12">
177
+ <!-- Header Section -->
178
+ <section class="max-w-5xl mx-auto mb-16 text-center">
179
+ <h1 class="text-5xl lg:text-6xl font-black tracking-tighter text-primary mb-12 leading-tight">
180
+ Chào buổi sáng,<br/>Khám phá tin tức AI hôm nay
181
+ </h1>
182
+ <div class="relative max-w-2xl mx-auto mb-8">
183
+ <div class="absolute inset-y-0 left-6 flex items-center pointer-events-none">
184
+ <span class="material-symbols-outlined text-slate-400">search</span>
185
+ </div>
186
+ <input class="w-full h-16 pl-14 pr-6 bg-surface-container-lowest border-none rounded-full text-lg font-medium shadow-[0px_12px_32px_rgba(25,28,30,0.06)] focus:ring-2 focus:ring-primary/20 placeholder:text-slate-400" placeholder="Tìm kiếm tin tức hoặc chủ đề AI..." type="text"/>
187
+ </div>
188
+ <!-- Action Bar -->
189
+ <div class="flex items-center justify-center gap-4">
190
+ <button class="px-6 py-2.5 rounded-full border border-slate-200 text-secondary font-semibold text-sm hover:bg-slate-50 transition-colors flex items-center gap-2">
191
+ <span class="material-symbols-outlined text-lg">check_circle</span>
192
+ Chọn tất cả
193
+ </button>
194
+ <button class="px-8 py-2.5 rounded-full bg-primary text-white font-bold text-sm hover:scale-[1.02] active:scale-95 transition-all flex items-center gap-2">
195
+ <span class="material-symbols-outlined text-lg" style="font-variation-settings: 'FILL' 1;">bolt</span>
196
+ Tóm tắt ngay
197
+ </button>
198
+ </div>
199
+ </section>
200
+ <!-- News Grid (Bento/Editorial) -->
201
+ <div class="grid grid-cols-1 md:grid-cols-12 gap-6 max-w-7xl mx-auto">
202
+ <!-- Large Featured Card -->
203
+ <div class="md:col-span-8 group relative bg-surface-container-lowest rounded-xl overflow-hidden shadow-none hover:shadow-xl transition-all duration-500 border border-transparent hover:border-slate-100">
204
+ <div class="aspect-[16/9] w-full relative">
205
+ <img class="w-full h-full object-cover" data-alt="abstract high-tech visualization of artificial intelligence neural networks with glowing blue lines and digital nodes in a dark void" src="https://lh3.googleusercontent.com/aida-public/AB6AXuBiXLdXBZzpQxCDx6bt4QA3FCup9Awh4yCjnglOZ-pnjDufcAlW_sKxzQ9YhVT_LyaR3Rczg338mPAfq12i58G5YX0ed2EletyKCtWee8DzRm6XzrPTLhvGzmArJE3w4EUk8a8UiUfdsuEE_aMGkXGtaNkxmaVIYhDIl4ldjDj2bOHidzpKRY01HnsXos3HlY_2LtiAIgP5xVRzDPGvhsNBDVx74TfG4CKFXEpkDhUgKcW9XxoUK3brZpEifwaFWhNLtVS_bhhTXeM"/>
206
+ <div class="absolute inset-0 bg-gradient-to-t from-black/80 via-black/20 to-transparent"></div>
207
+ <div class="absolute top-6 left-6 flex items-center gap-2">
208
+ <input class="w-5 h-5 rounded border-white/30 bg-white/10 text-primary focus:ring-0" type="checkbox"/>
209
+ <span class="bg-primary text-white text-[10px] font-black uppercase tracking-widest px-3 py-1 rounded-sm">Featured</span>
210
+ </div>
211
+ <div class="absolute bottom-6 left-6 right-6">
212
+ <h2 class="text-3xl font-black text-white tracking-tight mb-3 leading-tight">OpenAI giới thiệu mô hình GPT-5: Bước nhảy vọt về khả năng suy luận đa phương thức</h2>
213
+ <div class="flex items-center justify-between">
214
+ <p class="text-white/70 text-sm font-medium">Bởi NewsAI Research • 5 phút đọc</p>
215
+ <button class="w-10 h-10 rounded-full bg-white text-primary flex items-center justify-center hover:bg-primary hover:text-white transition-all shadow-lg">
216
+ <span class="material-symbols-outlined" style="font-variation-settings: 'FILL' 1;">auto_awesome</span>
217
+ </button>
218
+ </div>
219
+ </div>
220
+ </div>
221
+ </div>
222
+ <!-- Side Card 1 -->
223
+ <div class="md:col-span-4 bg-surface-container-lowest rounded-xl p-6 border border-transparent hover:border-slate-200 transition-all">
224
+ <div class="flex items-center justify-between mb-4">
225
+ <span class="text-[10px] font-bold text-blue-600 uppercase tracking-widest">Đầu tư</span>
226
+ <input class="w-5 h-5 rounded border-slate-300 text-primary focus:ring-0" type="checkbox"/>
227
+ </div>
228
+ <div class="aspect-video w-full rounded-lg overflow-hidden mb-4">
229
+ <img class="w-full h-full object-cover" data-alt="futuristic architectural concept of a server farm with neon accents and sleek metallic surfaces in soft focus" src="https://lh3.googleusercontent.com/aida-public/AB6AXuAUifZ5fH3G6cA8bf48nJLr2BZ_VX9clQP_FEjEOHo7c5_Zl62lcEpbOMpO9rRPnjkG8qXwcYUmfCN6swI0cIUJuCIH7jlqaHSUhVIAFtlraHSp1IkMAVy1Ma-TzteBoY8KbfvJGvWNkyTd9rNZyIUk4NWDUvsBFbY9HhPl2UN4eBPqQUb95hb5OY0pxtuhKBMBMVJKoH8h4Hw7FhT9eJ-yz4LlXo5V8EEa7lo_2XkH3OHaZYci9pQbyOiUKgPwDxuiKi6EgsKO83M"/>
230
+ </div>
231
+ <h3 class="text-xl font-bold text-primary tracking-tight mb-4 leading-snug">Vốn đầu tư mạo hiểm đổ vào chip AI đạt mức kỷ lục 12 tỷ USD trong Q1</h3>
232
+ <div class="flex items-center justify-between mt-auto">
233
+ <span class="text-xs text-slate-500 font-medium">1 giờ trước</span>
234
+ <button class="p-2 text-slate-400 hover:text-primary transition-colors">
235
+ <span class="material-symbols-outlined">auto_awesome</span>
236
+ </button>
237
+ </div>
238
+ </div>
239
+ <!-- Small Editorial Cards -->
240
+ <div class="md:col-span-4 bg-surface-container-lowest rounded-xl overflow-hidden border border-transparent hover:border-slate-200 transition-all flex flex-col">
241
+ <div class="h-48 w-full relative">
242
+ <img class="w-full h-full object-cover" data-alt="close-up of complex circuit board with green glowing lights and metallic paths representing cybersecurity" src="https://lh3.googleusercontent.com/aida-public/AB6AXuBYSRSQb1tj4CIH6VXB2TICaTpfkwxl2qxex1_cC0oEOH-VMC8KKcF9thC2UWE0bz1iy6mZrNEI8oXVQ_jUNhir-LlC6sZYgX87w23K3TAqKwrhjmK2u_KLIWm-V1vPRx6AYCWTnsocTB1QGBgths9qUi__kOf0ruq1TwY28hcp94LIYh9RmXPvLVv2A6FHYtXUA1dzSNdFLooG683kJXAqildkUy2mYl1GgAR60jTHguwyAtAl4awnglu2HPCCDCtgQI-kFo8qGaQ"/>
243
+ <div class="absolute top-4 left-4">
244
+ <input class="w-5 h-5 rounded border-white/50 bg-white/20 text-primary focus:ring-0" type="checkbox"/>
245
+ </div>
246
+ </div>
247
+ <div class="p-6 flex-1 flex flex-col">
248
+ <span class="text-[10px] font-bold text-red-600 uppercase tracking-widest mb-2">Bảo mật</span>
249
+ <h3 class="text-lg font-bold text-primary tracking-tight mb-4 leading-tight">Phát hiện lỗ hổng zero-day mới trong các LLM mã nguồn mở</h3>
250
+ <div class="flex items-center justify-between mt-auto">
251
+ <span class="text-xs text-slate-500">45 phút trước</span>
252
+ <button class="p-2 text-slate-400 hover:text-primary transition-colors">
253
+ <span class="material-symbols-outlined">auto_awesome</span>
254
+ </button>
255
+ </div>
256
+ </div>
257
+ </div>
258
+ <div class="md:col-span-4 bg-surface-container-lowest rounded-xl overflow-hidden border border-transparent hover:border-slate-200 transition-all flex flex-col">
259
+ <div class="h-48 w-full relative">
260
+ <img class="w-full h-full object-cover" data-alt="minimalist white robot hand reaching out against a clean studio background with soft directional lighting" src="https://lh3.googleusercontent.com/aida-public/AB6AXuAYGStWQ--EprWMucpeo8YkscYgjMY-Sm6D5TJsunCAdaQXNt2-js-BV7C8Db27TYhxxjc382h1Xull_8vsioZ332VWLlMup7RNi4haPxiOfpCuCategOtkpRpN4a3A0wpPTyW3bPrGs-JSDExpn95YelPsUmfS_OdkvVfjeZr2ibBQvZ2ONCPLUIgdaWKC5vmJlV08jaCGEu9m07ZkVc9PfDb6rPMXzr-N6xZvKCy_UNdfXmBBfpOUvoFQecil6gh_QxueMOZKzu8"/>
261
+ <div class="absolute top-4 left-4">
262
+ <input class="w-5 h-5 rounded border-white/50 bg-white/20 text-primary focus:ring-0" type="checkbox"/>
263
+ </div>
264
+ </div>
265
+ <div class="p-6 flex-1 flex flex-col">
266
+ <span class="text-[10px] font-bold text-green-600 uppercase tracking-widest mb-2">Y tế</span>
267
+ <h3 class="text-lg font-bold text-primary tracking-tight mb-4 leading-tight">AI hỗ trợ bác sĩ chẩn đoán ung thư giai đoạn sớm với độ chính xác 99%</h3>
268
+ <div class="flex items-center justify-between mt-auto">
269
+ <span class="text-xs text-slate-500">3 giờ trước</span>
270
+ <button class="p-2 text-slate-400 hover:text-primary transition-colors">
271
+ <span class="material-symbols-outlined">auto_awesome</span>
272
+ </button>
273
+ </div>
274
+ </div>
275
+ </div>
276
+ <div class="md:col-span-4 bg-surface-container-lowest rounded-xl overflow-hidden border border-transparent hover:border-slate-200 transition-all flex flex-col">
277
+ <div class="h-48 w-full relative">
278
+ <img class="w-full h-full object-cover" data-alt="dynamic close-up of a human eye with digital data overlays and binary code reflections" src="https://lh3.googleusercontent.com/aida-public/AB6AXuC1CDzpXU19AtQqte6N9lEv_fzgISdyiMKSAFRm5ygYrvwOFg1buhfW5Kb-DdNT1xrDYOe4tQCcCoBPr0NtEF3hnn1BI-z_tcMkrdepwX6cbE13oLew9fr2TLez8GmSEGvpniJz-1dNIEjuULiedLwbnBW23TBth8FCsOt_V2ntEfeWf8x_UfdZidzmqUxyEGCYmNN_PBqCu1oEwwc8L6B_iyqb5TGmVglmuf83Eyfmo193D-cgltRqD_D3BFImm0O2gYTmxRLYfDM"/>
279
+ <div class="absolute top-4 left-4">
280
+ <input class="w-5 h-5 rounded border-white/50 bg-white/20 text-primary focus:ring-0" type="checkbox"/>
281
+ </div>
282
+ </div>
283
+ <div class="p-6 flex-1 flex flex-col">
284
+ <span class="text-[10px] font-bold text-purple-600 uppercase tracking-widest mb-2">Đạo đức</span>
285
+ <h3 class="text-lg font-bold text-primary tracking-tight mb-4 leading-tight">EU thông qua bộ luật AI toàn diện nhất thế giới nhằm bảo vệ quyền riêng tư</h3>
286
+ <div class="flex items-center justify-between mt-auto">
287
+ <span class="text-xs text-slate-500">6 giờ trước</span>
288
+ <button class="p-2 text-slate-400 hover:text-primary transition-colors">
289
+ <span class="material-symbols-outlined">auto_awesome</span>
290
+ </button>
291
+ </div>
292
+ </div>
293
+ </div>
294
+ </div>
295
+ </main>
296
+ </div>
297
+ </body></html>
frontend/new ui/new.html ADDED
@@ -0,0 +1,296 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+
3
+ <html class="light" lang="vi"><head>
4
+ <meta charset="utf-8"/>
5
+ <meta content="width=device-width, initial-scale=1.0" name="viewport"/>
6
+ <script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
7
+ <link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&amp;family=Inter:wght@400;500;600;700&amp;display=swap" rel="stylesheet"/>
8
+ <link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&amp;display=swap" rel="stylesheet"/>
9
+ <script id="tailwind-config">
10
+ tailwind.config = {
11
+ darkMode: "class",
12
+ theme: {
13
+ extend: {
14
+ "colors": {
15
+ "on-background": "#000000",
16
+ "inverse-surface": "#1a1c1e",
17
+ "on-tertiary-fixed-variant": "#2d3243",
18
+ "inverse-on-surface": "#ffffff",
19
+ "on-secondary-container": "#1e293b",
20
+ "error-container": "#ffdad6",
21
+ "on-primary": "#ffffff",
22
+ "background": "#ffffff",
23
+ "surface-variant": "#f1f5f9",
24
+ "surface-container-low": "#ffffff",
25
+ "surface-container-lowest": "#ffffff",
26
+ "error": "#ba1a1a",
27
+ "surface-bright": "#ffffff",
28
+ "surface-container-highest": "#e2e8f0",
29
+ "on-surface": "#000000",
30
+ "on-surface-variant": "#334155",
31
+ "primary-container": "#000000",
32
+ "on-error-container": "#93000a",
33
+ "surface-tint": "#000000",
34
+ "on-tertiary-fixed": "#0f172a",
35
+ "outline-variant": "#e2e8f0",
36
+ "on-primary-container": "#000000",
37
+ "primary-fixed": "#f1f5f9",
38
+ "on-primary-fixed-variant": "#000000",
39
+ "secondary-fixed-dim": "#94a3b8",
40
+ "on-secondary-fixed": "#0f172a",
41
+ "tertiary-fixed-dim": "#94a3b8",
42
+ "surface": "#ffffff",
43
+ "secondary-fixed": "#f1f5f9",
44
+ "surface-container-high": "#f8fafc",
45
+ "surface-container": "#f1f5f9",
46
+ "tertiary-fixed": "#f1f5f9",
47
+ "on-tertiary-container": "#334155",
48
+ "on-secondary": "#ffffff",
49
+ "surface-dim": "#f1f5f9",
50
+ "secondary-container": "#f1f5f9",
51
+ "secondary": "#475569",
52
+ "tertiary": "#000000",
53
+ "on-error": "#ffffff",
54
+ "on-tertiary": "#ffffff",
55
+ "outline": "#000000",
56
+ "on-secondary-fixed-variant": "#1e293b",
57
+ "tertiary-container": "#000000",
58
+ "primary": "#000000",
59
+ "primary-fixed-dim": "#cbd5e1",
60
+ "on-primary-fixed": "#000000",
61
+ "inverse-primary": "#ffffff"
62
+ },
63
+ "borderRadius": {
64
+ "DEFAULT": "0rem",
65
+ "lg": "0.25rem",
66
+ "xl": "0.5rem",
67
+ "full": "9999px"
68
+ },
69
+ "fontFamily": {
70
+ "headline": ["Manrope"],
71
+ "body": ["Inter"],
72
+ "label": ["Inter"]
73
+ }
74
+ },
75
+ },
76
+ }
77
+ </script>
78
+ <style>
79
+ body { font-family: 'Inter', sans-serif; }
80
+ h1, h2, h3 { font-family: 'Manrope', sans-serif; }
81
+ .material-symbols-outlined {
82
+ font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
83
+ }
84
+ .sidebar-border { border-right: 1.5px solid #000000; }
85
+ .search-shadow { shadow: 0 4px 20px -2px rgba(0,0,0,0.1); }
86
+ </style>
87
+ </head>
88
+ <body class="bg-white text-black flex min-h-screen">
89
+ <!-- SideNavBar: Modified to only show History -->
90
+ <aside class="fixed left-0 top-0 h-full flex flex-col p-8 bg-white w-72 border-r border-black/10 z-50">
91
+ <div class="flex items-center gap-3 mb-12">
92
+ <div class="w-10 h-10 bg-black flex items-center justify-center">
93
+ <span class="material-symbols-outlined text-white text-2xl" style="font-variation-settings: 'FILL' 1;">neurology</span>
94
+ </div>
95
+ <div>
96
+ <h1 class="text-xl font-extrabold tracking-tighter text-black uppercase leading-none">NewsAI</h1>
97
+ <p class="text-[10px] text-slate-500 font-bold tracking-[0.2em] uppercase mt-1">Digital Curator</p>
98
+ </div>
99
+ </div>
100
+ <div class="flex flex-col flex-1">
101
+ <div class="mb-6">
102
+ <h3 class="text-[12px] font-black text-black uppercase tracking-[0.15em] mb-6 flex items-center gap-2">
103
+ <span class="material-symbols-outlined text-lg">history</span>
104
+ Lịch sử tìm kiếm
105
+ </h3>
106
+ <div class="flex flex-col gap-1">
107
+ <div class="flex items-center gap-3 px-4 py-3 rounded-lg hover:bg-slate-50 group cursor-pointer transition-all">
108
+ <span class="material-symbols-outlined text-lg opacity-40 group-hover:opacity-100 group-hover:text-black">history</span>
109
+ <span class="text-sm font-medium text-slate-600 group-hover:text-black truncate">Trí tuệ nhân tạo 2024...</span>
110
+ </div>
111
+ <div class="flex items-center gap-3 px-4 py-3 rounded-lg hover:bg-slate-50 group cursor-pointer transition-all">
112
+ <span class="material-symbols-outlined text-lg opacity-40 group-hover:opacity-100 group-hover:text-black">history</span>
113
+ <span class="text-sm font-medium text-slate-600 group-hover:text-black truncate">Công nghệ bán dẫn...</span>
114
+ </div>
115
+ <div class="flex items-center gap-3 px-4 py-3 rounded-lg hover:bg-slate-50 group cursor-pointer transition-all">
116
+ <span class="material-symbols-outlined text-lg opacity-40 group-hover:opacity-100 group-hover:text-black">history</span>
117
+ <span class="text-sm font-medium text-slate-600 group-hover:text-black truncate">Xe điện và AI...</span>
118
+ </div>
119
+ <div class="flex items-center gap-3 px-4 py-3 rounded-lg hover:bg-slate-50 group cursor-pointer transition-all">
120
+ <span class="material-symbols-outlined text-lg opacity-40 group-hover:opacity-100 group-hover:text-black">history</span>
121
+ <span class="text-sm font-medium text-slate-600 group-hover:text-black truncate">Thị trường chip toàn cầu</span>
122
+ </div>
123
+ <div class="flex items-center gap-3 px-4 py-3 rounded-lg hover:bg-slate-50 group cursor-pointer transition-all">
124
+ <span class="material-symbols-outlined text-lg opacity-40 group-hover:opacity-100 group-hover:text-black">history</span>
125
+ <span class="text-sm font-medium text-slate-600 group-hover:text-black truncate">Mô hình ngôn ngữ lớn...</span>
126
+ </div>
127
+ </div>
128
+ </div>
129
+ </div>
130
+ <div class="mt-auto border-t border-slate-100 pt-6">
131
+ <a class="flex items-center gap-3 px-4 py-3 text-slate-500 hover:text-black hover:bg-slate-50 rounded-lg transition-all" href="#">
132
+ <span class="material-symbols-outlined">settings</span>
133
+ <span class="text-sm font-bold">Cài đặt</span>
134
+ </a>
135
+ </div>
136
+ </aside>
137
+ <!-- Main Content -->
138
+ <main class="flex-1 ml-72 p-12 max-w-7xl">
139
+ <!-- Header / Search -->
140
+ <header class="mb-16">
141
+ <div class="max-w-3xl mx-auto">
142
+ <div class="relative group">
143
+ <div class="absolute inset-y-0 left-6 flex items-center pointer-events-none">
144
+ <span class="material-symbols-outlined text-black font-bold">search</span>
145
+ </div>
146
+ <input class="w-full pl-16 pr-32 py-5 bg-white rounded-full border-2 border-black focus:ring-0 focus:border-black text-lg font-medium placeholder:text-slate-300 transition-all shadow-[8px_8px_0px_0px_rgba(0,0,0,0.05)]" placeholder="Khám phá tin tức AI thế giới..." type="text"/>
147
+ <div class="absolute inset-y-0 right-3 flex items-center">
148
+ <button class="bg-black text-white px-8 py-3 rounded-full font-bold text-sm hover:bg-slate-800 transition-colors uppercase tracking-wider">Tìm kiếm</button>
149
+ </div>
150
+ </div>
151
+ <!-- Filters & Voice Selection -->
152
+ <div class="flex items-center justify-center gap-4 mt-8">
153
+ <!-- Refined Voice Selection UI -->
154
+ <div class="relative group/voice">
155
+ <button class="flex items-center gap-3 px-5 py-2.5 bg-white border border-black/10 hover:border-black rounded-full text-xs font-bold text-black uppercase tracking-widest transition-all">
156
+ <span class="material-symbols-outlined text-sm">record_voice_over</span>
157
+ <span>Giọng đọc: Nam (Bắc)</span>
158
+ <span class="material-symbols-outlined text-sm">expand_more</span>
159
+ </button>
160
+ <!-- Tooltip-style accessible dropdown indicator (visual only) -->
161
+ <div class="hidden group-hover/voice:block absolute top-full mt-2 left-0 w-48 bg-white border border-black p-2 z-20 shadow-xl">
162
+ <div class="text-[10px] font-black text-slate-400 p-2 border-b border-slate-100 mb-1">CHỌN GIỌNG NÓI</div>
163
+ <div class="p-2 hover:bg-slate-50 text-xs font-bold cursor-pointer">Nam (Miền Bắc)</div>
164
+ <div class="p-2 hover:bg-slate-50 text-xs font-bold cursor-pointer">Nữ (Miền Bắc)</div>
165
+ <div class="p-2 hover:bg-slate-50 text-xs font-bold cursor-pointer">Nam (Miền Nam)</div>
166
+ <div class="p-2 hover:bg-slate-50 text-xs font-bold cursor-pointer">Nữ (Miền Nam)</div>
167
+ </div>
168
+ </div>
169
+ <button class="flex items-center gap-2 px-5 py-2.5 bg-white border border-black/10 hover:border-black rounded-full text-xs font-bold text-black uppercase tracking-widest transition-all">
170
+ <span>Thời gian</span>
171
+ <span class="material-symbols-outlined text-sm">expand_more</span>
172
+ </button>
173
+ <button class="flex items-center gap-2 px-5 py-2.5 bg-white border border-black/10 hover:border-black rounded-full text-xs font-bold text-black uppercase tracking-widest transition-all">
174
+ <span>Nguồn tin</span>
175
+ <span class="material-symbols-outlined text-sm">expand_more</span>
176
+ </button>
177
+ <div class="h-4 w-px bg-black/10 mx-2"></div>
178
+ <button class="p-2.5 text-black hover:bg-black hover:text-white rounded-full transition-all">
179
+ <span class="material-symbols-outlined text-lg">tune</span>
180
+ </button>
181
+ </div>
182
+ </div>
183
+ </header>
184
+ <!-- AI Summary Card -->
185
+ <section class="mb-20">
186
+ <div class="relative p-10 border-2 border-black bg-white overflow-hidden shadow-[12px_12px_0px_0px_rgba(0,0,0,0.03)]">
187
+ <div class="relative z-10 flex flex-col md:flex-row gap-12 items-start">
188
+ <div class="flex-1">
189
+ <div class="flex items-center gap-3 mb-6">
190
+ <span class="w-8 h-[2px] bg-black"></span>
191
+ <h2 class="text-xs font-black text-black tracking-[0.3em] uppercase">Bản tin tổng hợp thông minh</h2>
192
+ </div>
193
+ <h3 class="text-4xl font-black tracking-tighter mb-6 text-black leading-[1.1]">Xu hướng Trí tuệ Nhân tạo nổi bật trong tuần qua</h3>
194
+ <p class="text-slate-600 leading-relaxed mb-10 text-lg max-w-2xl font-medium">
195
+ Trong tuần này, thế giới AI chứng kiến sự bùng nổ của các mô hình đa phương thức mới. Nổi bật là việc tích hợp sâu rộng AI vào các hệ điều hành, giúp tối ưu hóa hiệu suất làm việc. Các chuyên gia dự báo một làn sóng ứng dụng AI trong y tế và giáo dục sẽ sớm định hình lại thị trường lao động toàn cầu.
196
+ </p>
197
+ <div class="flex items-center gap-6">
198
+ <button class="flex items-center gap-3 px-8 py-4 bg-black text-white font-black text-xs uppercase tracking-widest hover:bg-slate-800 transition-all">
199
+ <span class="material-symbols-outlined text-lg">play_arrow</span>
200
+ <span>Nghe bản tin</span>
201
+ </button>
202
+ <button class="flex items-center gap-3 px-8 py-4 bg-white border-2 border-black text-black font-black text-xs uppercase tracking-widest hover:bg-slate-50 transition-all">
203
+ <span class="material-symbols-outlined text-lg">content_copy</span>
204
+ <span>Sao chép tóm tắt</span>
205
+ </button>
206
+ </div>
207
+ </div>
208
+ <div class="hidden lg:block w-64 h-64 border-2 border-black p-2 bg-white">
209
+ <img alt="AI Synthesis" class="w-full h-full object-cover" data-alt="Abstract visualization of flowing blue digital data particles and neural networks in a sleek modern style" src="https://lh3.googleusercontent.com/aida-public/AB6AXuAPtG9V8FkdCJxYYB-g3a27zd2Q7qGIX7PG1WlAwbscyhXpUznOkB_GA2ordqiEKnf5OdZwRePEGbsLPDJ36SXAv4C5ZakI3x2jfn3dzD13Jxpm4Cmbv5WcVmwIyOyxLLsAr2lpERlnbG0DclD_21vnn7IUnoiPEV1uIU46cNeDmznaAg6tCyDFw8W-6Pu7q0pHXVVUvUm7OgwyNZHdeET3j8FA0hsdhev4S6KOSQpQdDFPYPjcVoQCUDUFxReeJhXelUKNgFFqVEc"/>
210
+ </div>
211
+ </div>
212
+ </div>
213
+ </section>
214
+ <!-- News Feed -->
215
+ <section>
216
+ <div class="flex items-center justify-between mb-10 border-b border-black pb-4">
217
+ <h2 class="text-2xl font-black tracking-tight uppercase">Kết quả tìm kiếm</h2>
218
+ <div class="flex items-center gap-2 text-[10px] font-black text-black tracking-[0.2em] uppercase">
219
+ <span>Sắp xếp: Mới nhất</span>
220
+ <span class="material-symbols-outlined text-sm">swap_vert</span>
221
+ </div>
222
+ </div>
223
+ <div class="space-y-12">
224
+ <!-- News Card 1 -->
225
+ <article class="group flex gap-10">
226
+ <div class="w-60 h-40 flex-shrink-0 border border-black overflow-hidden bg-slate-100">
227
+ <img alt="Technology News" class="w-full h-full object-cover grayscale group-hover:grayscale-0 transition-all duration-700 group-hover:scale-105" data-alt="High-tech server room with glowing blue led lights on hardware equipment, futuristic data center aesthetic" src="https://lh3.googleusercontent.com/aida-public/AB6AXuB9HFilry0fIyC3DscauYjLBcnhk2gl5Odd_uLVbvtY90cpTN2aXlsOKS7yEkjCUtrpNzRXiVbjbyVXvJGS4ahkxe037N8aEsr6wK4wKW5LXD2bZ0_K-T4tvCwwESnm9l-A3JdhJxnTxQZD8kTAFS9yfel-cyYFaA7EvGIq8mwtIvWU5tUPjXpne00x5VlwmLld5NL6tiLACi-ShbTTQlpeyJweUfxOG1QBLq4KFHPmYcq3e118hnCfC0YncPcPtL3McTRLlVlolxk"/>
228
+ </div>
229
+ <div class="flex-1 flex flex-col">
230
+ <div class="flex items-center gap-3 mb-4">
231
+ <span class="text-[10px] font-black px-2 py-0.5 border border-black bg-black text-white tracking-widest uppercase">Tin tức</span>
232
+ <span class="text-xs font-bold text-black">VnExpress</span>
233
+ <span class="w-1 h-1 bg-black rounded-full"></span>
234
+ <span class="text-xs font-bold text-slate-400">2 giờ trước</span>
235
+ </div>
236
+ <h3 class="text-2xl font-black tracking-tight mb-3 group-hover:underline underline-offset-4 decoration-2">OpenAI giới thiệu mô hình ngôn ngữ mới với khả năng suy luận vượt bậc</h3>
237
+ <p class="text-slate-500 text-sm line-clamp-2 leading-relaxed font-medium mb-6">
238
+ Mô hình mới tập trung vào việc giải quyết các bài toán logic phức tạp và khả năng lập trình, hứa hẹn sẽ làm thay đổi cách thức các kỹ sư tương tác với trí tuệ nhân tạo.
239
+ </p>
240
+ <div class="mt-auto flex items-center justify-between">
241
+ <div class="flex items-center gap-8">
242
+ <button class="text-[11px] font-black uppercase tracking-widest text-black hover:opacity-60 flex items-center gap-2">
243
+ <span class="material-symbols-outlined text-lg">article</span>
244
+ Đọc bài gốc
245
+ </button>
246
+ <button class="text-[11px] font-black uppercase tracking-widest text-slate-400 hover:text-black flex items-center gap-2 transition-colors">
247
+ <span class="material-symbols-outlined text-lg">volume_up</span>
248
+ Nghe tóm tắt
249
+ </button>
250
+ </div>
251
+ <button class="p-2 text-black hover:bg-slate-50 rounded-full">
252
+ <span class="material-symbols-outlined">share</span>
253
+ </button>
254
+ </div>
255
+ </div>
256
+ </article>
257
+ <!-- News Card 2 -->
258
+ <article class="group flex gap-10">
259
+ <div class="w-60 h-40 flex-shrink-0 border border-black overflow-hidden bg-slate-100">
260
+ <img alt="AI Hardware" class="w-full h-full object-cover grayscale group-hover:grayscale-0 transition-all duration-700 group-hover:scale-105" data-alt="Futuristic humanoid robot hand interacting with a digital interface glowing in a dark room" src="https://lh3.googleusercontent.com/aida-public/AB6AXuAjjSXaNjdLwChMYhTZkgB7BuzocVKjtPv7XYW6p89zn_HfvHY7tkkSf6tKNjgKAYfqavBPy77KhvgPVSEqHzRmj4Gz-fKlP8GPA9UnA7sASg8TNZ_LeS3l-lWxK_QHncQCJMV96tQ_U9igGUQ_8ErxBrXKp5pFmZ_Vckw996viWOvYgidpJrxTsQgRKfhgqe-u-EciSu4d7MTfMK8F_pZATC9xMNCjRgrMS53u8EiDTKLTI0Kyayo9fRjotulO0V2zzFadmeZWw50"/>
261
+ </div>
262
+ <div class="flex-1 flex flex-col">
263
+ <div class="flex items-center gap-3 mb-4">
264
+ <span class="text-[10px] font-black px-2 py-0.5 border border-black bg-black text-white tracking-widest uppercase">Công nghệ</span>
265
+ <span class="text-xs font-bold text-black">TechCrunch</span>
266
+ <span class="w-1 h-1 bg-black rounded-full"></span>
267
+ <span class="text-xs font-bold text-slate-400">5 giờ trước</span>
268
+ </div>
269
+ <h3 class="text-2xl font-black tracking-tight mb-3 group-hover:underline underline-offset-4 decoration-2">Cuộc đua chip AI: NVIDIA tiếp tục thống trị thị trường toàn cầu</h3>
270
+ <p class="text-slate-500 text-sm line-clamp-2 leading-relaxed font-medium mb-6">
271
+ Báo cáo tài chính mới nhất cho thấy doanh thu từ mảng trung tâm dữ liệu của NVIDIA tăng trưởng kỷ lục nhờ nhu cầu đào tạo mô hình AI tăng vọt.
272
+ </p>
273
+ <div class="mt-auto flex items-center justify-between">
274
+ <div class="flex items-center gap-8">
275
+ <button class="text-[11px] font-black uppercase tracking-widest text-black hover:opacity-60 flex items-center gap-2">
276
+ <span class="material-symbols-outlined text-lg">article</span>
277
+ Đọc bài gốc
278
+ </button>
279
+ <button class="text-[11px] font-black uppercase tracking-widest text-slate-400 hover:text-black flex items-center gap-2 transition-colors">
280
+ <span class="material-symbols-outlined text-lg">volume_up</span>
281
+ Nghe tóm tắt
282
+ </button>
283
+ </div>
284
+ <button class="p-2 text-black hover:bg-slate-50 rounded-full">
285
+ <span class="material-symbols-outlined">share</span>
286
+ </button>
287
+ </div>
288
+ </div>
289
+ </article>
290
+ </div>
291
+ <div class="mt-20 text-center">
292
+ <button class="px-12 py-5 border-2 border-black bg-white text-black font-black text-xs uppercase tracking-[0.2em] hover:bg-black hover:text-white transition-all">Tải thêm tin mới</button>
293
+ </div>
294
+ </section>
295
+ </main>
296
+ </body></html>
frontend/package-lock.json ADDED
The diff for this file is too large to render. See raw diff
 
frontend/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
+ }
frontend/public/vite.svg ADDED
frontend/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
+ }
frontend/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
frontend/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
frontend/src/assets/react.svg ADDED
frontend/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;
frontend/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;
frontend/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;
frontend/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;
frontend/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;
frontend/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;
frontend/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;
frontend/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;
frontend/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
+ }
frontend/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
+ )
frontend/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();
frontend/vite.config.js ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ import { defineConfig } from 'vite'
2
+ import react from '@vitejs/plugin-react'
3
+
4
+ // https://vite.dev/config/
5
+ export default defineConfig({
6
+ plugins: [react()],
7
+ })