TXAVLOG commited on
Commit
4bea261
·
0 Parent(s):

Deploy DPTXA to Hugging Face Spaces

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .env +6 -0
  2. .gitattributes +4 -0
  3. .gitignore +19 -0
  4. Dockerfile +19 -0
  5. astro.config.mjs +23 -0
  6. cloudflare-worker/README.md +49 -0
  7. cloudflare-worker/index.js +274 -0
  8. cloudflare-worker/wrangler.toml +9 -0
  9. package-lock.json +0 -0
  10. package.json +33 -0
  11. public/favicon.ico +3 -0
  12. public/favicon.png +3 -0
  13. public/favicon.svg +3 -0
  14. public/images/auth/bg.png +3 -0
  15. public/lib/txa/txacomment.js +493 -0
  16. public/lib/txa/txaformat.js +67 -0
  17. public/lib/txa/txamodal.js +138 -0
  18. public/lib/txa/txaplayer.js +0 -0
  19. public/lib/txa/txatoast.js +199 -0
  20. public/lib/txa/txatooltip.js +198 -0
  21. public/locations.json +0 -0
  22. public/logo-full.png +3 -0
  23. public/logo-icon.png +3 -0
  24. public/logo-square.png +3 -0
  25. public/logo.svg +3 -0
  26. public/robots.txt +6 -0
  27. scratch/test_fetch.js +18 -0
  28. scratch/test_variations.js +24 -0
  29. scripts/apply_subtitle_update.js +118 -0
  30. scripts/format-xml.js +51 -0
  31. src/components/CombinedRegionSection.jsx +132 -0
  32. src/components/DeferredHero.astro +87 -0
  33. src/components/DeferredSection.astro +24 -0
  34. src/components/DeferredTopTen.astro +66 -0
  35. src/components/EpisodeList.jsx +170 -0
  36. src/components/Footer.jsx +206 -0
  37. src/components/Header.astro +677 -0
  38. src/components/Header.jsx +100 -0
  39. src/components/HeroBanner.jsx +223 -0
  40. src/components/HomeSectionsLoader.jsx +147 -0
  41. src/components/MovieCard.jsx +127 -0
  42. src/components/MovieSection.jsx +92 -0
  43. src/components/MovieSlider.jsx +118 -0
  44. src/components/PaginatedGrid.jsx +104 -0
  45. src/components/Pagination.jsx +66 -0
  46. src/components/SplashScreen.jsx +55 -0
  47. src/components/TopTenCard.jsx +120 -0
  48. src/components/TopTenSection.jsx +119 -0
  49. src/env.d.ts +1 -0
  50. src/layouts/AdminLayout.astro +463 -0
.env ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ PUBLIC_SUPABASE_URL=https://jjmzipyewddbepnelawf.supabase.co
2
+ PUBLIC_SUPABASE_ANON_KEY=sb_publishable_p45xwwJNPMFAzp9K8YQlkA_2oc87I1u
3
+
4
+ # TPhimX Cloudflare Worker HLS Proxy URL
5
+ # Triển khai worker trong thư mục `cloudflare-worker/` trước rồi dán link vào đây
6
+ PUBLIC_CLOUDFLARE_PROXY_URL=https://tphimx-r2-proxy.txagt145.workers.dev
.gitattributes ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ *.png filter=lfs diff=lfs merge=lfs -text
2
+ *.jpg filter=lfs diff=lfs merge=lfs -text
3
+ *.ico filter=lfs diff=lfs merge=lfs -text
4
+ *.svg filter=lfs diff=lfs merge=lfs -text
.gitignore ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Dependencies
2
+ node_modules/
3
+
4
+ # Build outputs
5
+ dist/
6
+ .astro/
7
+ .vercel/
8
+
9
+ # Logs
10
+ npm-debug.log*
11
+ yarn-debug.log*
12
+ yarn-error.log*
13
+ .pnpm-debug.log*
14
+
15
+ # System Files
16
+ .DS_Store
17
+ Thumbs.db
18
+
19
+ .vercel
Dockerfile ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM node:18-alpine AS build
2
+ WORKDIR /app
3
+ COPY package*.json ./
4
+ RUN npm install --legacy-peer-deps
5
+ COPY . .
6
+ RUN npm run build
7
+
8
+ FROM node:18-alpine AS runtime
9
+ WORKDIR /app
10
+ COPY --from=build /app/dist ./dist
11
+ COPY --from=build /app/node_modules ./node_modules
12
+ COPY --from=build /app/package.json ./package.json
13
+ COPY --from=build /app/public ./public
14
+
15
+ ENV HOST=0.0.0.0
16
+ ENV PORT=7860
17
+ EXPOSE 7860
18
+
19
+ CMD ["node", "./dist/server/entry.mjs"]
astro.config.mjs ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { defineConfig } from 'astro/config';
2
+ import react from '@astrojs/react';
3
+ import tailwindcss from '@tailwindcss/vite';
4
+ import sitemap from '@astrojs/sitemap';
5
+ import node from '@astrojs/node';
6
+
7
+ export default defineConfig({
8
+ site: 'https://dongmephim.online',
9
+ output: 'server',
10
+ adapter: node({
11
+ mode: 'standalone'
12
+ }),
13
+ integrations: [react(), sitemap()],
14
+ vite: {
15
+ plugins: [tailwindcss()]
16
+ },
17
+ prefetch: true,
18
+ image: {
19
+ service: {
20
+ entrypoint: 'astro/assets/services/sharp'
21
+ }
22
+ }
23
+ });
cloudflare-worker/README.md ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # TPhimX - Cloudflare Worker R2 Auto-Cache CDN & Media Proxy
2
+
3
+ Dự án này chứa mã nguồn **Cloudflare Worker** chịu trách nhiệm chạy **Reverse Proxy & Tự động lưu trữ (Cache) luồng phim HLS** vào bộ nhớ Cloudflare R2 bucket của bạn.
4
+
5
+ ---
6
+
7
+ ## ⚡ Các Tính Năng Vượt Trội
8
+
9
+ 1. **Bỏ qua CORS (CORS Bypass):** Cho phép phát phim trực tiếp từ trình duyệt trên mọi tên miền (`localhost`, `vercel.app`, `Smart TV`, `Mobile App`).
10
+ 2. **Hỗ trợ tua phim (Range Requests):** Trình phát phim có thể tua (seeking/scrubbing) mượt mà không bị xoay tròn tải lại nhờ hỗ trợ mã phản hồi HTTP `206 Partial Content`.
11
+ 3. **Tự động Cache vào R2 (Cache Miss / Cache Hit):**
12
+ - Khi có người xem phim, Worker sẽ kiểm tra trong Cloudflare R2 bucket (`tphimx-movie`) trước.
13
+ - Nếu phim **đã có** (Cache HIT): Worker phát trực tiếp từ R2 cực nhanh và mượt mà, **không tốn băng thông và zero egress fee**.
14
+ - Nếu phim **chưa có** (Cache MISS): Worker tải phim từ link gốc (như KKPhim/NguonC), đồng thời lưu file đó vào R2 bucket của bạn ở chế độ nền (background task) và trả về cho người dùng cùng lúc. Lần sau người khác xem sẽ là Cache HIT!
15
+ 4. **M3U8 Playlist Rewriter:** Tự động phân tích file danh sách phát `.m3u8` và viết lại toàn bộ đường dẫn các phân đoạn `.ts` để chúng tự động đi qua Worker và lưu vào R2 bucket của bạn.
16
+
17
+ ---
18
+
19
+ ## 🚀 Hướng Dẫn Triển Khai (Chỉ 2 Phút)
20
+
21
+ Hãy chắc chắn rằng máy tính của bạn đã cài đặt Node.js. Chạy các lệnh sau trong Terminal tại thư mục này:
22
+
23
+ ### Bước 1: Đăng nhập vào Cloudflare
24
+ Bật Terminal và chạy lệnh sau để đăng nhập tài khoản Cloudflare của bạn:
25
+ ```bash
26
+ npx wrangler login
27
+ ```
28
+ *Trình duyệt sẽ tự động mở ra, bạn chỉ cần bấm **Allow/Xác nhận**.*
29
+
30
+ ### Bước 2: Triển khai Worker
31
+ Chạy lệnh sau để deploy trực tiếp lên hệ thống Cloudflare toàn cầu:
32
+ ```bash
33
+ npx wrangler deploy
34
+ ```
35
+ *Sau khi hoàn tất, hệ thống sẽ trả về một đường dẫn Worker của bạn, ví dụ:*
36
+ `https://tphimx-r2-proxy.tên-tài-khoản.workers.dev`
37
+
38
+ ---
39
+
40
+ ## ⚙️ Cấu Hình Tên Miền / Cập Nhật Ứng Dụng Web
41
+
42
+ Sau khi đã deploy xong và có link Worker của bạn:
43
+
44
+ 1. Mở file `.env` của web và cấu hình thêm biến môi trường sau để ứng dụng web tự động nhận diện:
45
+ ```env
46
+ PUBLIC_CLOUDFLARE_PROXY_URL=https://tphimx-r2-proxy.tên-tài-khoản.workers.dev
47
+ ```
48
+
49
+ 2. Các trang phát phim như [[slug].astro](file:///c:/Users/TXA3100/Desktop/SRC%20-%20ROPHIm/web/src/pages/xem/%5Bslug%5D.astro) sẽ tự động ưu tiên điều hướng qua Proxy R2 của bạn để phát phim nhanh chóng và tự động lưu trữ lại.
cloudflare-worker/index.js ADDED
@@ -0,0 +1,274 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * TPhimX - Cloudflare Worker HLS Media Proxy & R2 Auto-Cache CDN
3
+ *
4
+ * Features:
5
+ * 1. CORS Bypass: Attaches Access-Control-Allow-Origin: * to all requests.
6
+ * 2. Range Requests: Supports seeking/scrubbing in HLS players (206 Partial Content).
7
+ * 3. Dynamic R2 Caching: Checks R2 first. On cache miss, fetches original video segment,
8
+ * caches it into R2, and pipes it back to the client.
9
+ * 4. Playlist Rewriter: Rewrites .m3u8 relative and absolute URLs to route all subsequent
10
+ * .ts media segment requests through this Worker and cache them in R2 automatically.
11
+ */
12
+
13
+ export default {
14
+ async fetch(request, env, ctx) {
15
+ const urlObj = new URL(request.url);
16
+ const targetUrlStr = urlObj.searchParams.get('url');
17
+
18
+ if (!targetUrlStr) {
19
+ // Direct R2 Path-based Request: e.g. /fbi-part-one/360p/index.m3u8
20
+ const pathname = urlObj.pathname;
21
+ if (pathname && pathname !== '/') {
22
+ const r2Key = pathname.replace(/^\/+/, ''); // Convert leading slash to relative key
23
+ try {
24
+ const object = await env.tphimx_movie.get(r2Key);
25
+ if (object !== null) {
26
+ // Cache HIT - Serve directly from R2
27
+ const headers = new Headers();
28
+ object.writeHttpMetadata(headers);
29
+ headers.set('Access-Control-Allow-Origin', '*');
30
+ headers.set('X-TPhimX-Cache', 'HIT (Direct R2 Path)');
31
+
32
+ // Handle Range Requests for seekability
33
+ const rangeHeader = request.headers.get('Range');
34
+ if (rangeHeader && object.size > 0) {
35
+ const parts = rangeHeader.replace(/bytes=/, "").split("-");
36
+ const start = parseInt(parts[0], 10);
37
+ const end = parts[1] ? parseInt(parts[1], 10) : object.size - 1;
38
+
39
+ if (start >= 0 && end < object.size && start <= end) {
40
+ const slicedObject = await env.tphimx_movie.get(r2Key, {
41
+ range: { offset: start, length: end - start + 1 }
42
+ });
43
+
44
+ headers.set('Content-Range', `bytes ${start}-${end}/${object.size}`);
45
+ headers.set('Content-Length', String(end - start + 1));
46
+
47
+ return new Response(slicedObject.body, {
48
+ status: 206,
49
+ headers
50
+ });
51
+ }
52
+ }
53
+
54
+ return new Response(object.body, {
55
+ status: 200,
56
+ headers
57
+ });
58
+ } else {
59
+ return new Response('File not found in R2 bucket', {
60
+ status: 404,
61
+ headers: { 'Access-Control-Allow-Origin': '*' }
62
+ });
63
+ }
64
+ } catch (err) {
65
+ return new Response(`R2 Path Error: ${err.message}`, {
66
+ status: 500,
67
+ headers: { 'Access-Control-Allow-Origin': '*' }
68
+ });
69
+ }
70
+ }
71
+
72
+ return new Response(JSON.stringify({
73
+ error: 'Missing "url" search parameter.',
74
+ usage: `${urlObj.origin}/?url=https://example.com/stream.m3u8`
75
+ }), {
76
+ status: 400,
77
+ headers: {
78
+ 'Content-Type': 'application/json',
79
+ 'Access-Control-Allow-Origin': '*'
80
+ }
81
+ });
82
+ }
83
+
84
+ // Handle OPTIONS Preflight request
85
+ if (request.method === 'OPTIONS') {
86
+ return new Response(null, {
87
+ status: 204,
88
+ headers: {
89
+ 'Access-Control-Allow-Origin': '*',
90
+ 'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS',
91
+ 'Access-Control-Allow-Headers': '*',
92
+ 'Access-Control-Max-Age': '86400'
93
+ }
94
+ });
95
+ }
96
+
97
+ let targetUrl;
98
+ try {
99
+ targetUrl = new URL(targetUrlStr);
100
+ } catch (e) {
101
+ return new Response('Invalid target URL format', { status: 400, headers: { 'Access-Control-Allow-Origin': '*' } });
102
+ }
103
+
104
+ // Generate unique storage key for Cloudflare R2
105
+ // Scheme: cache/<hostname>/<clean_pathname>
106
+ const cleanPath = targetUrl.pathname.replace(/^\/+/, '');
107
+ const r2Key = `cache/${targetUrl.hostname}/${cleanPath}`;
108
+
109
+ // 1. Check if file already exists in Cloudflare R2 bucket
110
+ try {
111
+ const object = await env.tphimx_movie.get(r2Key);
112
+ if (object !== null) {
113
+ // Cache HIT - Serve directly from R2
114
+ const headers = new Headers();
115
+ object.writeHttpMetadata(headers);
116
+ headers.set('Access-Control-Allow-Origin', '*');
117
+ headers.set('X-TPhimX-Cache', 'HIT (Cloudflare R2)');
118
+
119
+ // Handle Range Requests for seekability
120
+ const rangeHeader = request.headers.get('Range');
121
+ if (rangeHeader && object.size > 0) {
122
+ const parts = rangeHeader.replace(/bytes=/, "").split("-");
123
+ const start = parseInt(parts[0], 10);
124
+ const end = parts[1] ? parseInt(parts[1], 10) : object.size - 1;
125
+
126
+ if (start >= 0 && end < object.size && start <= end) {
127
+ // Slice the R2 object
128
+ const slicedObject = await env.tphimx_movie.get(r2Key, {
129
+ range: { offset: start, length: end - start + 1 }
130
+ });
131
+
132
+ headers.set('Content-Range', `bytes ${start}-${end}/${object.size}`);
133
+ headers.set('Content-Length', String(end - start + 1));
134
+
135
+ return new Response(slicedObject.body, {
136
+ status: 206,
137
+ headers
138
+ });
139
+ }
140
+ }
141
+
142
+ return new Response(object.body, {
143
+ status: 200,
144
+ headers
145
+ });
146
+ }
147
+ } catch (err) {
148
+ // Log error but fallback to fetching from source to prevent playback interruption
149
+ console.error('R2 read error:', err.message);
150
+ }
151
+
152
+ // Cache MISS - Fetch from original media source server
153
+ try {
154
+ // Forward request headers like Range
155
+ const fetchHeaders = new Headers();
156
+
157
+ // Bypass anti-hotlinking/Referer checks of media providers (e.g. s2.phim1280.tv)
158
+ const targetOrigin = targetUrl.origin;
159
+ fetchHeaders.set('Referer', targetOrigin + '/');
160
+ fetchHeaders.set('Origin', targetOrigin);
161
+ fetchHeaders.set('User-Agent', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36');
162
+
163
+ const range = request.headers.get('Range');
164
+ if (range) fetchHeaders.set('Range', range);
165
+
166
+ const response = await fetch(targetUrlStr, {
167
+ headers: fetchHeaders
168
+ });
169
+
170
+ if (!response.ok && response.status !== 206) {
171
+ const responseText = await response.text().catch(() => '');
172
+ return new Response(JSON.stringify({
173
+ error: `Failed to fetch original media source (HTTP ${response.status})`,
174
+ targetUrl: targetUrlStr,
175
+ sentHeaders: Object.fromEntries(fetchHeaders.entries()),
176
+ responseHeaders: Object.fromEntries(response.headers.entries()),
177
+ responseBody: responseText.substring(0, 500)
178
+ }), {
179
+ status: response.status,
180
+ headers: {
181
+ 'Content-Type': 'application/json',
182
+ 'Access-Control-Allow-Origin': '*'
183
+ }
184
+ });
185
+ }
186
+
187
+ const contentType = response.headers.get('Content-Type') || '';
188
+ const isM3U8 = targetUrl.pathname.endsWith('.m3u8') ||
189
+ contentType.includes('mpegurl') ||
190
+ contentType.includes('application/x-mpegURL');
191
+
192
+ if (isM3U8) {
193
+ // Rewrite HLS playlist URLs to route through this Worker Proxy
194
+ let playlistText = await response.text();
195
+ const baseTargetUrl = targetUrl.origin + targetUrl.pathname.substring(0, targetUrl.pathname.lastIndexOf('/') + 1);
196
+
197
+ const lines = playlistText.split('\n');
198
+ const rewrittenLines = lines.map(line => {
199
+ const trimmed = line.trim();
200
+ if (trimmed.length === 0 || trimmed.startsWith('#')) {
201
+ return line;
202
+ }
203
+
204
+ // Resolve absolute URL
205
+ let absoluteUrl = trimmed;
206
+ if (!trimmed.startsWith('http://') && !trimmed.startsWith('https://')) {
207
+ if (trimmed.startsWith('/')) {
208
+ absoluteUrl = targetUrl.origin + trimmed;
209
+ } else {
210
+ absoluteUrl = baseTargetUrl + trimmed;
211
+ }
212
+ }
213
+
214
+ // Rewritten relative segment URL to pipe through the worker
215
+ return `${urlObj.origin}/?url=${encodeURIComponent(absoluteUrl)}`;
216
+ });
217
+
218
+ const rewrittenPlaylist = rewrittenLines.join('\n');
219
+ const playlistBuffer = new TextEncoder().encode(rewrittenPlaylist);
220
+
221
+ // Save the rewritten playlist into Cloudflare R2 bucket
222
+ ctx.waitUntil(
223
+ env.tphimx_movie.put(r2Key, playlistBuffer, {
224
+ httpMetadata: { contentType: 'application/x-mpegURL' }
225
+ }).catch(e => console.error('R2 Put Error:', e.message))
226
+ );
227
+
228
+ return new Response(rewrittenPlaylist, {
229
+ status: 200,
230
+ headers: {
231
+ 'Content-Type': 'application/x-mpegURL',
232
+ 'Access-Control-Allow-Origin': '*',
233
+ 'X-TPhimX-Cache': 'MISS (Rewritten & Cached)'
234
+ }
235
+ });
236
+ } else {
237
+ // Fetch raw binary segment file (.ts)
238
+ const segmentData = await response.arrayBuffer();
239
+
240
+ // Save the segment file into Cloudflare R2 bucket asynchronously
241
+ ctx.waitUntil(
242
+ env.tphimx_movie.put(r2Key, segmentData, {
243
+ httpMetadata: { contentType: contentType }
244
+ }).catch(e => console.error('R2 Put Error:', e.message))
245
+ );
246
+
247
+ // Build Response
248
+ const headers = new Headers();
249
+ headers.set('Content-Type', contentType);
250
+ headers.set('Access-Control-Allow-Origin', '*');
251
+ headers.set('X-TPhimX-Cache', 'MISS (Cached Segment)');
252
+
253
+ if (response.status === 206) {
254
+ const contentRange = response.headers.get('Content-Range');
255
+ if (contentRange) headers.set('Content-Range', contentRange);
256
+ return new Response(segmentData, {
257
+ status: 206,
258
+ headers
259
+ });
260
+ }
261
+
262
+ return new Response(segmentData, {
263
+ status: 200,
264
+ headers
265
+ });
266
+ }
267
+ } catch (fetchErr) {
268
+ return new Response(`Proxy error: ${fetchErr.message}`, {
269
+ status: 502,
270
+ headers: { 'Access-Control-Allow-Origin': '*' }
271
+ });
272
+ }
273
+ }
274
+ };
cloudflare-worker/wrangler.toml ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ name = "tphimx-r2-proxy"
2
+ main = "index.js"
3
+ compatibility_date = "2024-03-01"
4
+
5
+ # Cloudflare R2 Bucket Binding
6
+ [[r2_buckets]]
7
+ binding = "tphimx_movie"
8
+ bucket_name = "tphimx-movie"
9
+ preview_bucket_name = "tphimx-movie"
package-lock.json ADDED
The diff for this file is too large to render. See raw diff
 
package.json ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "tphimx",
3
+ "type": "module",
4
+ "version": "1.0.0",
5
+ "scripts": {
6
+ "dev": "astro dev",
7
+ "start": "astro dev",
8
+ "build": "astro build",
9
+ "postbuild": "node scripts/format-xml.js",
10
+ "preview": "astro preview",
11
+ "astro": "astro"
12
+ },
13
+ "dependencies": {
14
+ "@astrojs/node": "^10.1.1",
15
+ "@astrojs/react": "^5.0.5",
16
+ "@astrojs/sitemap": "^3.7.2",
17
+ "@astrojs/vercel": "^10.0.7",
18
+ "@supabase/supabase-js": "^2.105.4",
19
+ "@tailwindcss/vite": "^4.0.0",
20
+ "astro": "^6.3.3",
21
+ "lucide-react": "^0.460.0",
22
+ "nodemailer": "^8.0.7",
23
+ "react": "^19.0.0",
24
+ "react-dom": "^19.0.0",
25
+ "swiper": "^11.2.0",
26
+ "tailwindcss": "^4.0.0"
27
+ },
28
+ "devDependencies": {
29
+ "@types/react": "^19.0.0",
30
+ "@types/react-dom": "^19.0.0",
31
+ "typescript": "^5.7.0"
32
+ }
33
+ }
public/favicon.ico ADDED

Git LFS Details

  • SHA256: 81fe0c4ea68851716a27444871287c000144421182fcb2b3287ca361d4a39756
  • Pointer size: 130 Bytes
  • Size of remote file: 67.6 kB
public/favicon.png ADDED

Git LFS Details

  • SHA256: 9be9a2f7d99ee048339b466a999deb7ce453478fd6c10b543c0fedb373d88986
  • Pointer size: 130 Bytes
  • Size of remote file: 31.5 kB
public/favicon.svg ADDED

Git LFS Details

  • SHA256: f96d6e9cb308794525e75b2936fb35b49ae2915c717054967c11fa7e83b62ede
  • Pointer size: 128 Bytes
  • Size of remote file: 258 Bytes
public/images/auth/bg.png ADDED

Git LFS Details

  • SHA256: 238e980db35bf25a91885f8eb97e0f6af636aa9b60a13c0521277860bd703de5
  • Pointer size: 131 Bytes
  • Size of remote file: 774 kB
public/lib/txa/txacomment.js ADDED
@@ -0,0 +1,493 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * TXA Comment Widget - TPHIMX Cinematic Platform
3
+ * Tự động hóa khung bình luận cao cấp.
4
+ */
5
+
6
+ class TxaComment {
7
+ constructor(containerId) {
8
+ this.container = document.getElementById(containerId);
9
+ if (!this.container) return;
10
+
11
+ this.movieSlug = this.container.getAttribute('data-movie-slug');
12
+ this.movieType = this.container.getAttribute('data-movie-type') || null;
13
+ this.episodeName = this.container.getAttribute('data-episode-name') || null;
14
+ this.serverName = this.container.getAttribute('data-server-name') || null;
15
+
16
+ const userAttr = this.container.getAttribute('data-user');
17
+ this.user = userAttr ? JSON.parse(userAttr) : null;
18
+ this.comments = [];
19
+ this.bannedUsers = [];
20
+
21
+ this.init();
22
+ }
23
+
24
+ async init() {
25
+ this.renderSkeleton();
26
+ await this.fetchComments();
27
+ this.renderCommentsList();
28
+ this.setupListeners();
29
+ }
30
+
31
+ renderSkeleton() {
32
+ const isLogged = !!this.user;
33
+
34
+ this.container.innerHTML = `
35
+ <div class="space-y-6 text-left">
36
+ <!-- Heading -->
37
+ <div class="flex items-center gap-3">
38
+ <i class="fas fa-comment-dots text-primary text-xl"></i>
39
+ <h3 class="text-lg font-black text-white">Bình luận (<span id="txac-count">0</span>)</h3>
40
+ </div>
41
+
42
+ <!-- Input Area -->
43
+ <div class="glass-sub rounded-3xl p-5 border border-white/5 space-y-4">
44
+ ${isLogged ? `
45
+ <div class="relative">
46
+ <span class="absolute right-3 top-3 text-[10px] text-gray-500 font-mono" id="txac-char-counter">0 / 1000</span>
47
+ <textarea
48
+ id="txac-textarea"
49
+ maxlength="1000"
50
+ placeholder="Viết bình luận..."
51
+ class="w-full bg-[#12141d] border border-white/5 rounded-2xl px-5 py-4 pt-10 text-sm text-white focus:outline-none focus:border-primary/50 transition-all resize-none min-h-[100px]"
52
+ ></textarea>
53
+ </div>
54
+
55
+ <div class="flex items-center justify-between pt-2">
56
+ <!-- Spoiler Toggle -->
57
+ <div class="flex items-center gap-3">
58
+ <label class="relative inline-flex items-center cursor-pointer select-none">
59
+ <input type="checkbox" id="txac-spoiler" class="sr-only peer" />
60
+ <div class="w-9 h-5 bg-white/10 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-gray-300 after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-primary"></div>
61
+ </label>
62
+ <span class="text-xs text-gray-400 font-bold select-none">Tiết lộ?</span>
63
+ </div>
64
+
65
+ <!-- Send button -->
66
+ <button id="txac-submit-btn" class="flex items-center gap-2 rounded-xl bg-primary/10 hover:bg-primary text-primary hover:text-dark border border-primary/20 hover:border-primary px-5 py-2.5 text-xs font-black uppercase tracking-widest transition-all active:scale-95 cursor-pointer">
67
+ Gửi <i class="fas fa-paper-plane text-[10px]"></i>
68
+ </button>
69
+ </div>
70
+ ` : `
71
+ <div class="py-8 text-center text-xs text-gray-500">
72
+ Vui lòng <a href="/dang-nhap" class="text-primary font-bold hover:underline">đăng nhập</a> để tham gia bình luận.
73
+ </div>
74
+ `}
75
+ </div>
76
+
77
+ <!-- Comments list -->
78
+ <div id="txac-list" class="space-y-4 pt-4 divide-y divide-white/5">
79
+ <div class="text-center text-xs text-gray-500 py-6">Đang tải bình luận...</div>
80
+ </div>
81
+ </div>
82
+ `;
83
+ }
84
+
85
+ async fetchComments() {
86
+ try {
87
+ const res = await fetch(`/api/movie/comments?slug=${this.movieSlug}`);
88
+ if (res.ok) {
89
+ const data = await res.json();
90
+ this.comments = data.comments || [];
91
+ this.bannedUsers = data.bannedUsers || [];
92
+ this.userProfiles = data.userProfiles || {};
93
+ }
94
+ } catch (e) {
95
+ console.error('[TxaComment] Error fetching comments:', e);
96
+ }
97
+ }
98
+
99
+ renderCommentsList() {
100
+ const listContainer = document.getElementById('txac-list');
101
+ const countSpan = document.getElementById('txac-count');
102
+
103
+ if (countSpan) {
104
+ const totalCount = this.comments.length;
105
+ countSpan.textContent = window.txaformat ? window.txaformat.twoDigits(totalCount) : (totalCount < 10 ? '0' + totalCount : totalCount);
106
+ }
107
+
108
+ if (!listContainer) return;
109
+
110
+ if (this.comments.length === 0) {
111
+ listContainer.innerHTML = `
112
+ <div class="text-center text-xs text-gray-500 py-8">
113
+ Chưa có bình luận nào. Hãy là người đầu tiên chia sẻ cảm nghĩ!
114
+ </div>
115
+ `;
116
+ return;
117
+ }
118
+
119
+ listContainer.innerHTML = this.comments.map(c => {
120
+ const charCode = c.username ? c.username.charCodeAt(0) : 65;
121
+ const avatarColors = [
122
+ 'bg-primary text-dark', 'bg-emerald-500 text-white', 'bg-blue-500 text-white',
123
+ 'bg-pink-500 text-white', 'bg-yellow-500 text-dark', 'bg-purple-500 text-white'
124
+ ];
125
+ const avatarStyle = avatarColors[charCode % avatarColors.length];
126
+
127
+ // Spoiler Logic
128
+ const isSpoiler = c.is_spoiler;
129
+ const commentBody = isSpoiler
130
+ ? `<div class="relative bg-red-500/5 border border-red-500/10 rounded-xl p-3 text-xs text-gray-400 mt-1 cursor-pointer spoiler-blur" onclick="this.classList.remove('spoiler-blur')">
131
+ <span class="block text-[9px] font-black uppercase text-red-400 tracking-wider mb-1"><i class="fas fa-exclamation-triangle"></i> Bình luận có chứa tình tiết phim (Click để xem)</span>
132
+ <span>${c.content}</span>
133
+ </div>`
134
+ : `<p class="text-sm text-gray-300 leading-relaxed mt-1">${c.content}</p>`;
135
+
136
+ // Episode Tag (If commented from watch page)
137
+ const episodeTag = (c.episode_name && c.server_name)
138
+ ? `<span class="ml-2 bg-primary/10 text-primary border border-primary/20 text-[9px] font-black uppercase tracking-wider px-2 py-0.5 rounded-lg">${c.episode_name} - ${c.server_name}</span>`
139
+ : '';
140
+
141
+ // Likes/Dislikes Arrays
142
+ const likes = c.likes || [];
143
+ const dislikes = c.dislikes || [];
144
+ const hasLiked = this.user && likes.includes(this.user.username);
145
+ const hasDisliked = this.user && dislikes.includes(this.user.username);
146
+
147
+ // Actions Check (Delete comment)
148
+ const canDelete = this.user && (c.username === this.user.username || this.user.role === 'admin');
149
+ const deleteButton = canDelete
150
+ ? `<button class="txac-delete-btn text-gray-500 hover:text-red-400 transition-all flex items-center justify-center h-6 w-6 rounded-lg bg-white/5 hover:bg-red-500/10 cursor-pointer select-none ml-2" data-id="${c.id}" data-txatooltip="Xóa bình luận">
151
+ <i class="fas fa-trash-alt text-[10px]"></i>
152
+ </button>`
153
+ : '';
154
+
155
+ // Ban Button (For Admin ONLY)
156
+ const isBanned = this.bannedUsers.includes(c.username);
157
+ const showBanBtn = this.user && this.user.role === 'admin' && c.username !== this.user.username;
158
+ const banButton = showBanBtn
159
+ ? (isBanned
160
+ ? `<button class="txac-ban-btn text-emerald-400 hover:text-emerald-300 transition-all flex items-center justify-center h-6 w-6 rounded-lg bg-white/5 hover:bg-emerald-500/10 cursor-pointer select-none ml-auto animate-pulse" data-username="${c.username}" data-action="unban" data-txatooltip="Bỏ cấm bình luận @${c.username}">
161
+ <i class="fas fa-user-check text-[10px]"></i>
162
+ </button>`
163
+ : `<button class="txac-ban-btn text-red-500 hover:text-red-400 transition-all flex items-center justify-center h-6 w-6 rounded-lg bg-white/5 hover:bg-red-500/10 cursor-pointer select-none ml-auto" data-username="${c.username}" data-action="ban" data-txatooltip="Cấm bình luận @${c.username} (1 click)">
164
+ <i class="fas fa-user-slash text-[10px]"></i>
165
+ </button>`
166
+ )
167
+ : '';
168
+
169
+ // Lấy thông tin profile phục vụ cho hover popup
170
+ const profile = (this.userProfiles && this.userProfiles[c.username]) || {
171
+ commentsCount: 0,
172
+ likesCount: 0,
173
+ ward: '',
174
+ province: ''
175
+ };
176
+
177
+ let locationStr = 'Chưa cập nhật';
178
+ if (profile.ward || profile.province) {
179
+ const parts = [];
180
+ if (profile.ward) parts.push(`xã ${profile.ward}`);
181
+ if (profile.province) parts.push(`tỉnh ${profile.province}`);
182
+ locationStr = parts.join(', ');
183
+ locationStr = locationStr.charAt(0).toUpperCase() + locationStr.slice(1);
184
+ }
185
+
186
+ const formattedComments = window.txaformat ? window.txaformat.twoDigits(profile.commentsCount) : (profile.commentsCount < 10 ? '0' + profile.commentsCount : profile.commentsCount);
187
+ const formattedLikes = window.txaformat ? window.txaformat.twoDigits(profile.likesCount) : (profile.likesCount < 10 ? '0' + profile.likesCount : profile.likesCount);
188
+
189
+ // Check if user has custom avatar URL
190
+ const hasAvatar = profile && profile.avatar_url;
191
+ const avatarInner = hasAvatar
192
+ ? `<img src="${profile.avatar_url}" class="h-full w-full object-cover" />`
193
+ : (c.username ? c.username[0] : 'U');
194
+
195
+ return `
196
+ <div class="pt-5 flex gap-4 items-start group">
197
+ <!-- Avatar -->
198
+ <div class="h-9 w-9 rounded-full overflow-hidden flex-shrink-0 flex items-center justify-center font-black uppercase text-xs ${hasAvatar ? '' : avatarStyle}">
199
+ ${avatarInner}
200
+ </div>
201
+
202
+ <!-- Content -->
203
+ <div class="flex-1 min-w-0">
204
+ <div class="flex items-center gap-2">
205
+ <!-- Username với hover popup info -->
206
+ <div class="relative inline-block group/usercard">
207
+ <span class="text-xs font-bold text-white hover:text-primary transition-all cursor-pointer">@${c.username}</span>
208
+
209
+ <!-- Hover Card Popup -->
210
+ <div class="absolute bottom-full left-0 mb-3 w-64 p-4 rounded-2xl bg-[#090b11]/98 border border-white/10 shadow-2xl backdrop-blur-md opacity-0 pointer-events-none group-hover/usercard:opacity-100 group-hover/usercard:pointer-events-auto transition-all duration-200 z-50 transform translate-y-1 group-hover/usercard:translate-y-0 text-left">
211
+ <div class="absolute -inset-px rounded-2xl bg-gradient-to-r from-primary/10 to-blue-500/10 opacity-40 blur-sm pointer-events-none"></div>
212
+ <div class="relative space-y-3">
213
+ <div class="flex items-center gap-3">
214
+ <div class="h-8 w-8 rounded-full overflow-hidden flex items-center justify-center font-black uppercase text-xs ${hasAvatar ? '' : avatarStyle}">
215
+ ${avatarInner}
216
+ </div>
217
+ <div>
218
+ <div class="text-xs font-black text-white">@${c.username}</div>
219
+ <div class="text-[9px] text-gray-500 font-bold uppercase tracking-wider">Thành viên TPhimX</div>
220
+ </div>
221
+ </div>
222
+ <div class="border-t border-white/5 my-1"></div>
223
+ <div class="space-y-2 text-[11px] text-gray-400">
224
+ <div class="flex items-center gap-2">
225
+ <i class="fas fa-comment text-primary w-4 text-center"></i>
226
+ <span>Số bình luận: <strong class="text-white">${formattedComments}</strong></span>
227
+ </div>
228
+ <div class="flex items-center gap-2">
229
+ <i class="fas fa-heart text-red-500 w-4 text-center"></i>
230
+ <span>Đã thích: <strong class="text-white">${formattedLikes} phim</strong></span>
231
+ </div>
232
+ <div class="flex items-start gap-2">
233
+ <i class="fas fa-map-marker-alt text-emerald-400 w-4 text-center mt-0.5"></i>
234
+ <span class="leading-relaxed">Nơi ở: <strong class="text-white">${locationStr}</strong></span>
235
+ </div>
236
+ </div>
237
+ </div>
238
+ </div>
239
+ </div>
240
+ ${episodeTag}
241
+ <span class="text-[10px] text-gray-500 ml-1">${(window.txaformat && window.txaformat.relativeTime) ? window.txaformat.relativeTime(c.created_at) : this.formatRelativeTime(c.created_at)}</span>
242
+ ${banButton}
243
+ ${deleteButton}
244
+ </div>
245
+ ${commentBody}
246
+
247
+ <!-- Likes & Dislikes Row -->
248
+ <div class="flex items-center gap-4 mt-3 pt-1">
249
+ <!-- Like -->
250
+ <button class="txac-reaction-btn flex items-center gap-1.5 transition-all text-xs font-bold select-none cursor-pointer ${hasLiked ? 'text-primary' : 'text-gray-500 hover:text-primary'}" data-id="${c.id}" data-action="like" data-txatooltip="Thích bình luận">
251
+ <i class="${hasLiked ? 'fas fa-thumbs-up' : 'far fa-thumbs-up'} text-[11px]"></i>
252
+ <span class="font-mono text-[11px] font-bold">${likes.length}</span>
253
+ </button>
254
+ <!-- Dislike -->
255
+ <button class="txac-reaction-btn flex items-center gap-1.5 transition-all text-xs font-bold select-none cursor-pointer ${hasDisliked ? 'text-red-500' : 'text-gray-500 hover:text-red-500'}" data-id="${c.id}" data-action="dislike" data-txatooltip="Không thích bình luận">
256
+ <i class="${hasDisliked ? 'fas fa-thumbs-down' : 'far fa-thumbs-down'} text-[11px]"></i>
257
+ <span class="font-mono text-[11px] font-bold">${dislikes.length}</span>
258
+ </button>
259
+ </div>
260
+ </div>
261
+ </div>
262
+ `;
263
+ }).join('');
264
+
265
+ if (window.initTxaTooltips) {
266
+ setTimeout(() => window.initTxaTooltips(), 50);
267
+ }
268
+ }
269
+
270
+ setupListeners() {
271
+ const textarea = document.getElementById('txac-textarea');
272
+ const counter = document.getElementById('txac-char-counter');
273
+ const submitBtn = document.getElementById('txac-submit-btn');
274
+ const spoiler = document.getElementById('txac-spoiler');
275
+
276
+ // Char count update
277
+ if (textarea && counter) {
278
+ textarea.addEventListener('input', () => {
279
+ const len = textarea.value.length;
280
+ counter.textContent = `${len} / 1000`;
281
+ });
282
+ }
283
+
284
+ // Submit new comment
285
+ if (submitBtn && textarea) {
286
+ submitBtn.addEventListener('click', async (e) => {
287
+ e.preventDefault();
288
+ const content = textarea.value.trim();
289
+ if (!content) {
290
+ if (window.txatoast) window.txatoast.warning('Nội dung bình luận không được để trống!', 3000, 'bottom-right');
291
+ return;
292
+ }
293
+
294
+ submitBtn.disabled = true;
295
+
296
+ try {
297
+ const res = await fetch('/api/movie/comments', {
298
+ method: 'POST',
299
+ headers: { 'Content-Type': 'application/json' },
300
+ body: JSON.stringify({
301
+ movieSlug: this.movieSlug,
302
+ content: content,
303
+ isSpoiler: spoiler ? spoiler.checked : false,
304
+ episodeName: this.episodeName,
305
+ serverName: this.serverName,
306
+ movieType: this.movieType
307
+ })
308
+ });
309
+
310
+ const data = await res.json();
311
+ if (res.ok) {
312
+ if (window.txatoast) window.txatoast.success(data.message, 3000, 'bottom-right');
313
+ textarea.value = '';
314
+ if (counter) counter.textContent = '0 / 1000';
315
+ if (spoiler) spoiler.checked = false;
316
+
317
+ // Reload comments
318
+ await this.fetchComments();
319
+ this.renderCommentsList();
320
+ } else {
321
+ if (window.txatoast) window.txatoast.error(data.error, 3000, 'bottom-right');
322
+ }
323
+ } catch (err) {
324
+ if (window.txatoast) window.txatoast.error('Lỗi gửi bình luận!', 3000, 'bottom-right');
325
+ } finally {
326
+ submitBtn.disabled = false;
327
+ }
328
+ });
329
+ }
330
+
331
+ // Event delegation for Likes, Dislikes, Delete, Ban buttons
332
+ const listContainer = document.getElementById('txac-list');
333
+ if (listContainer) {
334
+ listContainer.addEventListener('click', async (e) => {
335
+ // Find reaction button (Like / Dislike)
336
+ const reactBtn = e.target.closest('.txac-reaction-btn');
337
+ if (reactBtn) {
338
+ e.preventDefault();
339
+ const commentId = reactBtn.getAttribute('data-id');
340
+ const action = reactBtn.getAttribute('data-action');
341
+ await this.handleReaction(commentId, action);
342
+ return;
343
+ }
344
+
345
+ // Find delete button
346
+ const deleteBtn = e.target.closest('.txac-delete-btn');
347
+ if (deleteBtn) {
348
+ e.preventDefault();
349
+ const commentId = deleteBtn.getAttribute('data-id');
350
+ this.handleDelete(commentId);
351
+ return;
352
+ }
353
+
354
+ // Find ban button
355
+ const banBtn = e.target.closest('.txac-ban-btn');
356
+ if (banBtn) {
357
+ e.preventDefault();
358
+ const targetUsername = banBtn.getAttribute('data-username');
359
+ const action = banBtn.getAttribute('data-action');
360
+ this.handleBan(targetUsername, action);
361
+ return;
362
+ }
363
+ });
364
+ }
365
+ }
366
+
367
+ async handleReaction(commentId, action) {
368
+ if (!this.user) {
369
+ if (window.txatoast) window.txatoast.warning('Vui lòng đăng nhập để bình chọn bình luận!', 3000, 'bottom-right');
370
+ return;
371
+ }
372
+
373
+ try {
374
+ const res = await fetch('/api/movie/comments', {
375
+ method: 'PUT',
376
+ headers: { 'Content-Type': 'application/json' },
377
+ body: JSON.stringify({ commentId, action })
378
+ });
379
+ const data = await res.json();
380
+ if (res.ok) {
381
+ // Cập nhật mảng trực tiếp cho comment hiện tại và re-render để hiển thị thay đổi lập tức
382
+ const comment = this.comments.find(c => c.id === commentId);
383
+ if (comment) {
384
+ comment.likes = data.likes || [];
385
+ comment.dislikes = data.dislikes || [];
386
+ this.renderCommentsList();
387
+ }
388
+ } else {
389
+ if (window.txatoast) window.txatoast.error(data.error, 3000, 'bottom-right');
390
+ }
391
+ } catch (err) {
392
+ console.error('[TxaComment] Lỗi tương tác:', err);
393
+ }
394
+ }
395
+
396
+ handleDelete(commentId) {
397
+ if (!window.txamodal) {
398
+ if (confirm('Bạn có chắc chắn muốn xóa bình luận này không?')) {
399
+ this.executeDelete(commentId);
400
+ }
401
+ return;
402
+ }
403
+
404
+ window.txamodal.show({
405
+ title: 'Xóa bình luận',
406
+ message: 'Bạn có chắc chắn muốn xóa vĩnh viễn bình luận này? Hành động này không thể hoàn tác.',
407
+ type: 'danger',
408
+ confirmText: 'Xóa ngay',
409
+ onConfirm: async () => {
410
+ await this.executeDelete(commentId);
411
+ return true;
412
+ }
413
+ });
414
+ }
415
+
416
+ async executeDelete(commentId) {
417
+ try {
418
+ const res = await fetch('/api/movie/comments', {
419
+ method: 'DELETE',
420
+ headers: { 'Content-Type': 'application/json' },
421
+ body: JSON.stringify({ commentId })
422
+ });
423
+ const data = await res.json();
424
+ if (res.ok) {
425
+ if (window.txatoast) window.txatoast.success(data.message, 3000, 'bottom-right');
426
+ this.comments = this.comments.filter(c => c.id !== commentId);
427
+ this.renderCommentsList();
428
+ } else {
429
+ if (window.txatoast) window.txatoast.error(data.error, 3000, 'bottom-right');
430
+ }
431
+ } catch (err) {
432
+ if (window.txatoast) window.txatoast.error('Lỗi khi xóa bình luận!', 3000, 'bottom-right');
433
+ }
434
+ }
435
+
436
+ handleBan(targetUsername, action) {
437
+ // Chặn 1-click siêu tốc không cần xác nhận
438
+ this.executeBan(targetUsername, action);
439
+ }
440
+
441
+ async executeBan(targetUsername, action) {
442
+ try {
443
+ const res = await fetch('/api/movie/comments', {
444
+ method: 'PUT',
445
+ headers: { 'Content-Type': 'application/json' },
446
+ body: JSON.stringify({ action, targetUsername })
447
+ });
448
+ const data = await res.json();
449
+ if (res.ok) {
450
+ if (window.txatoast) window.txatoast.success(data.message, 3000, 'bottom-right');
451
+ this.bannedUsers = data.bannedUsers || [];
452
+ this.renderCommentsList();
453
+ } else {
454
+ if (window.txatoast) window.txatoast.error(data.error, 3000, 'bottom-right');
455
+ }
456
+ } catch (err) {
457
+ if (window.txatoast) window.txatoast.error('Lỗi khi thực thi yêu cầu chặn!', 3000, 'bottom-right');
458
+ }
459
+ }
460
+
461
+ formatRelativeTime(isoString) {
462
+ if (!isoString) return '';
463
+ const date = new Date(isoString);
464
+ const now = new Date();
465
+ const diffMs = now.getTime() - date.getTime();
466
+
467
+ if (isNaN(date.getTime()) || diffMs < 0) return 'Vừa xong';
468
+
469
+ const diffSec = Math.floor(diffMs / 1000);
470
+ if (diffSec < 60) return 'Vừa xong';
471
+
472
+ const diffMin = Math.floor(diffSec / 60);
473
+ if (diffMin < 60) return `${diffMin} phút trước`;
474
+
475
+ const diffHour = Math.floor(diffMin / 60);
476
+ if (diffHour < 24) return `${diffHour} giờ trước`;
477
+
478
+ // Vượt quá 1 ngày (24 giờ): Chuyển sang định dạng ngày cụ thể "ngày/tháng/năm" chuẩn Việt Nam
479
+ const day = date.getDate();
480
+ const month = date.getMonth() + 1;
481
+ const year = date.getFullYear();
482
+ return `${day}/${month}/${year}`;
483
+ }
484
+ }
485
+
486
+ // Auto instantiate on load
487
+ function initTxaCommentWidget() {
488
+ if (document.getElementById('txa-comment-container')) {
489
+ window.txacomment = new TxaComment('txa-comment-container');
490
+ }
491
+ }
492
+ document.addEventListener('DOMContentLoaded', initTxaCommentWidget);
493
+ document.addEventListener('astro:page-load', initTxaCommentWidget);
public/lib/txa/txaformat.js ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * TXA Format Utility
3
+ * Version: 1.0 (Web Optimized)
4
+ */
5
+
6
+ const txaformat = {
7
+ twoDigits: (n) => (n < 10 ? '0' : '') + n,
8
+
9
+ shortNumber: (n) => {
10
+ if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M';
11
+ if (n >= 1000) return (n / 1000).toFixed(1) + 'K';
12
+ return n;
13
+ },
14
+
15
+ number: (n) => new Intl.NumberFormat('vi-VN').format(n),
16
+
17
+ duration: function (s) {
18
+ const h = Math.floor(s / 3600);
19
+ const m = Math.floor((s % 3600) / 60);
20
+ const sec = Math.floor(s % 60);
21
+ let str = '';
22
+ if (h > 0) str += this.twoDigits(h) + ':';
23
+ return str + this.twoDigits(m) + ':' + this.twoDigits(sec);
24
+ },
25
+
26
+ fileSize: (bytes) => {
27
+ if (bytes === 0) return '0 B';
28
+ const units = ['B', 'KB', 'MB', 'GB', 'TB'];
29
+ const i = Math.floor(Math.log(bytes) / Math.log(1024));
30
+ return parseFloat((bytes / Math.pow(1024, i)).toFixed(2)) + ' ' + units[i];
31
+ },
32
+
33
+ dateTime: (val) => {
34
+ if (!val) return '';
35
+ const d = new Date(val);
36
+ if (isNaN(d.getTime())) return val;
37
+ return d.toLocaleString('vi-VN');
38
+ },
39
+
40
+ relativeTime: (val) => {
41
+ if (!val) return '';
42
+ const date = new Date(val);
43
+ const now = new Date();
44
+ const diffMs = now.getTime() - date.getTime();
45
+
46
+ if (isNaN(date.getTime()) || diffMs < 0) return 'Vừa xong';
47
+
48
+ const diffSec = Math.floor(diffMs / 1000);
49
+ if (diffSec < 60) return 'Vừa xong';
50
+
51
+ const diffMin = Math.floor(diffSec / 60);
52
+ if (diffMin < 60) return `${diffMin} phút trước`;
53
+
54
+ const diffHour = Math.floor(diffMin / 60);
55
+ if (diffHour < 24) return `${diffHour} giờ trước`;
56
+
57
+ // Quá 1 ngày (24 giờ) sẽ hiện là ngày/tháng/năm
58
+ const day = date.getDate();
59
+ const month = date.getMonth() + 1;
60
+ const year = date.getFullYear();
61
+ return `${day}/${month}/${year}`;
62
+ }
63
+ };
64
+
65
+ if (typeof window !== 'undefined') {
66
+ window.txaformat = txaformat;
67
+ }
public/lib/txa/txamodal.js ADDED
@@ -0,0 +1,138 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * TxaModal - Premium Modal System
3
+ * Migration: TPHIMX V2 (Vite Compatible)
4
+ */
5
+
6
+ class TxaModal {
7
+ constructor() {
8
+ this.containerCreated = false;
9
+ this.container = null;
10
+ this.overlay = null;
11
+ this.modal = null;
12
+ }
13
+
14
+ createContainer() {
15
+ if (this.containerCreated && document.getElementById('txamodal-container')) {
16
+ this.container = document.getElementById('txamodal-container');
17
+ this.overlay = document.getElementById('txamodal-overlay');
18
+ this.modal = document.getElementById('txamodal-content');
19
+ return true;
20
+ }
21
+
22
+ const container = document.createElement('div');
23
+ container.id = 'txamodal-container';
24
+ container.style.cssText = `position: fixed; top: 0; left: 0; width: 100%; height: 100%; display: none; align-items: center; justify-content: center; z-index: 2147483640; padding: 20px;`;
25
+
26
+ const overlay = document.createElement('div');
27
+ overlay.id = 'txamodal-overlay';
28
+ overlay.style.cssText = `position: absolute; top: 0; left: 0; width: 100%; height: 100%; background: rgba(10, 15, 25, 0.7); backdrop-filter: blur(12px); opacity: 0; transition: opacity 0.3s ease;`;
29
+
30
+ const modal = document.createElement('div');
31
+ modal.id = 'txamodal-content';
32
+ modal.style.cssText = `position: relative; background: rgba(30, 41, 59, 0.7); backdrop-filter: blur(24px); width: 100%; max-width: 500px; border-radius: 28px; border: 1px solid rgba(255, 255, 255, 0.1); box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.8); transform: scale(0.9) translateY(30px); opacity: 0; transition: all 0.4s cubic-bezier(0.34, 1.56, 0.64, 1); overflow: hidden; display: flex; flex-direction: column;`;
33
+
34
+ container.appendChild(overlay);
35
+ container.appendChild(modal);
36
+ document.body.appendChild(container);
37
+
38
+ this.container = container;
39
+ this.overlay = overlay;
40
+ this.modal = modal;
41
+ this.containerCreated = true;
42
+ return true;
43
+ }
44
+
45
+ show({ title, message, type = 'info', confirmText = 'Xác nhận', cancelText = 'Hủy', showCancel = true, showConfirm = true, onConfirm = null, onCancel = null, input = null, inputPlaceholder = '', inputType = 'text' }) {
46
+ if (!this.createContainer()) return;
47
+
48
+ let icon = '<i class="fas fa-info-circle text-indigo-500"></i>';
49
+ let btnConfirmStyle = 'background: linear-gradient(135deg, #6366f1, #a855f7); color: #fff;';
50
+
51
+ if (type === 'danger') {
52
+ icon = '<i class="fas fa-exclamation-triangle text-red-500"></i>';
53
+ btnConfirmStyle = 'background: #ef4444; color: #fff;';
54
+ } else if (type === 'warning') {
55
+ icon = '<i class="fas fa-exclamation-circle text-amber-500"></i>';
56
+ btnConfirmStyle = 'background: linear-gradient(135deg, #f59e0b, #ea580c); color: #fff;';
57
+ } else if (type === 'success') {
58
+ icon = '<i class="fas fa-check-circle text-emerald-500"></i>';
59
+ btnConfirmStyle = 'background: #10b981; color: #fff;';
60
+ }
61
+
62
+ let inputHtml = '';
63
+ if (input) {
64
+ inputHtml = `
65
+ <div style="margin-top: 24px;">
66
+ <input id="txamodal-input" type="${inputType}" placeholder="${inputPlaceholder}"
67
+ style="width: 100%; background: rgba(0,0,0,0.4); border: 1px solid rgba(255,255,255,0.1); padding: 14px 20px; border-radius: 16px; color: #fff; outline: none; font-family: inherit;">
68
+ </div>
69
+ `;
70
+ }
71
+
72
+ let cancelBtnHtml = '';
73
+ if (showCancel && cancelText) {
74
+ cancelBtnHtml = `<button id="txamodal-cancel" style="flex: 1; padding: 14px; border-radius: 16px; border: 1px solid rgba(255, 255, 255, 0.1); background: rgba(255, 255, 255, 0.05); color: #94a3b8; font-weight: 600; cursor: pointer; transition: all 0.2s;">${cancelText}</button>`;
75
+ }
76
+
77
+ let confirmBtnHtml = '';
78
+ if (showConfirm) {
79
+ confirmBtnHtml = `<button id="txamodal-confirm" style="flex: 1; padding: 14px; border-radius: 16px; border: none; ${btnConfirmStyle} font-weight: 700; cursor: pointer; transition: all 0.2s; box-shadow: 0 4px 15px rgba(0,0,0,0.3);">${confirmText}</button>`;
80
+ }
81
+
82
+ this.modal.innerHTML = `
83
+ <div style="padding: 40px 30px; text-align: center;">
84
+ <div style="font-size: 64px; margin-bottom: 24px;">${icon}</div>
85
+ <h3 style="margin: 0 0 16px 0; font-size: 24px; font-weight: 800; color: #fff;">${title || 'Thông báo'}</h3>
86
+ <div style="color: #94a3b8; line-height: 1.6;">${message}</div>
87
+ ${inputHtml}
88
+ </div>
89
+ ${(showCancel || showConfirm) ? `
90
+ <div style="padding: 24px 30px; background: rgba(0, 0, 0, 0.3); display: flex; gap: 12px;">
91
+ ${cancelBtnHtml}
92
+ ${confirmBtnHtml}
93
+ </div>` : ''}
94
+ `;
95
+
96
+ this.container.style.display = 'flex';
97
+ setTimeout(() => {
98
+ this.overlay.style.opacity = '1';
99
+ this.modal.style.opacity = '1';
100
+ this.modal.style.transform = 'scale(1) translateY(0)';
101
+ if (input) document.getElementById('txamodal-input').focus();
102
+ }, 10);
103
+
104
+ const confirmBtn = document.getElementById('txamodal-confirm');
105
+ if (confirmBtn) {
106
+ confirmBtn.onclick = async () => {
107
+ let result = true;
108
+ if (input) {
109
+ const val = document.getElementById('txamodal-input').value;
110
+ if (onConfirm) result = await onConfirm(val);
111
+ } else {
112
+ if (onConfirm) result = await onConfirm();
113
+ }
114
+ if (result !== false) this.hide();
115
+ };
116
+ }
117
+
118
+ const cancelBtn = document.getElementById('txamodal-cancel');
119
+ if (cancelBtn) {
120
+ cancelBtn.onclick = () => {
121
+ if (typeof onCancel === 'function') {
122
+ try { onCancel(); } catch (_) { /* noop */ }
123
+ }
124
+ this.hide();
125
+ };
126
+ }
127
+ }
128
+
129
+ hide() {
130
+ if (!this.container) return;
131
+ this.overlay.style.opacity = '0';
132
+ this.modal.style.opacity = '0';
133
+ this.modal.style.transform = 'scale(0.95) translateY(20px)';
134
+ setTimeout(() => { this.container.style.display = 'none'; }, 400);
135
+ }
136
+ }
137
+
138
+ window.txamodal = new TxaModal();
public/lib/txa/txaplayer.js ADDED
The diff for this file is too large to render. See raw diff
 
public/lib/txa/txatoast.js ADDED
@@ -0,0 +1,199 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * TXAToast - Liquid Glass Toast Notification System
3
+ * Hỗ trợ đa vị trí, bypass CSP qua Trusted Types & SVG Inline
4
+ */
5
+
6
+ class TXAToastSystem {
7
+ constructor() {
8
+ this.containers = {};
9
+ this.policy = null;
10
+ this.initPolicy();
11
+ this.injectStyles();
12
+ }
13
+
14
+ initPolicy() {
15
+ if (typeof window !== 'undefined' && window.trustedTypes) {
16
+ try {
17
+ this.policy = window.trustedTypes.createPolicy('txatoast-policy', {
18
+ createHTML: (string) => string
19
+ });
20
+ } catch (e) {
21
+ this.policy = window.trustedTypes.defaultPolicy || { createHTML: (string) => string };
22
+ }
23
+ }
24
+ }
25
+
26
+ getContainer(position = 'top-right') {
27
+ if (typeof document === 'undefined') return null;
28
+
29
+ const id = `txatoast-container-${position}`;
30
+ let container = document.getElementById(id);
31
+
32
+ if (!container) {
33
+ container = document.createElement('div');
34
+ container.id = id;
35
+
36
+ const posStyles = {
37
+ 'top-right': 'top: 24px; right: 24px;',
38
+ 'top-left': 'top: 24px; left: 24px;',
39
+ 'bottom-right': 'bottom: 24px; right: 24px;',
40
+ 'bottom-left': 'bottom: 24px; left: 24px;',
41
+ 'top-center': 'top: 24px; left: 50%; transform: translateX(-50%); align-items: center;',
42
+ 'bottom-center': 'bottom: 24px; left: 50%; transform: translateX(-50%); align-items: center;'
43
+ };
44
+
45
+ container.style.cssText = `
46
+ position: fixed;
47
+ z-index: 2147483647;
48
+ display: flex;
49
+ flex-direction: column;
50
+ gap: 14px;
51
+ pointer-events: none;
52
+ max-width: 380px;
53
+ width: 100%;
54
+ ${posStyles[position] || posStyles['top-right']}
55
+ `;
56
+ document.body.appendChild(container);
57
+ }
58
+ return container;
59
+ }
60
+
61
+ injectStyles() {
62
+ if (typeof document === 'undefined' || document.getElementById('txatoast-styles')) return;
63
+
64
+ const style = document.createElement('style');
65
+ style.id = 'txatoast-styles';
66
+ style.textContent = `
67
+ .txa-toast {
68
+ pointer-events: auto;
69
+ position: relative;
70
+ padding: 16px 22px;
71
+ border-radius: 24px;
72
+ color: #ffffff;
73
+ font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
74
+ font-size: 14px;
75
+ font-weight: 600;
76
+ letter-spacing: -0.01em;
77
+ display: flex;
78
+ align-items: center;
79
+ justify-content: space-between;
80
+ gap: 14px;
81
+ overflow: hidden;
82
+ background: rgba(255, 255, 255, 0.02);
83
+ backdrop-filter: blur(30px);
84
+ -webkit-backdrop-filter: blur(30px);
85
+ border: 1px solid rgba(255, 255, 255, 0.12);
86
+ box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5), inset 0 1px 2px rgba(255, 255, 255, 0.2);
87
+ opacity: 0;
88
+ transition: all 0.5s cubic-bezier(0.175, 0.885, 0.32, 1.275);
89
+ }
90
+
91
+ /* Position-based Animations */
92
+ [id*="-right"] .txa-toast { transform: translateX(120%) scale(0.9); }
93
+ [id*="-left"] .txa-toast { transform: translateX(-120%) scale(0.9); }
94
+ [id*="-center"] .txa-toast { transform: translateY(50px) scale(0.9); }
95
+ [id*="top-center"] .txa-toast { transform: translateY(-50px) scale(0.9); }
96
+
97
+ .txa-toast.txa-show { transform: translate(0) scale(1); opacity: 1; }
98
+ .txa-toast.txa-hide { opacity: 0; scale: 0.85; }
99
+
100
+ .txa-toast::before {
101
+ content: ''; position: absolute; top: -50%; left: -50%; width: 200%; height: 200%;
102
+ background: radial-gradient(circle, rgba(255,255,255,0.12) 0%, transparent 60%); pointer-events: none; mix-blend-mode: overlay;
103
+ }
104
+
105
+ .txa-toast-success { border-color: rgba(16, 185, 129, 0.4); box-shadow: 0 12px 35px rgba(16, 185, 129, 0.2); }
106
+ .txa-toast-success .txa-icon { color: #10b981; }
107
+
108
+ .txa-toast-error { border-color: rgba(239, 68, 68, 0.4); box-shadow: 0 12px 35px rgba(239, 68, 68, 0.2); }
109
+ .txa-toast-error .txa-icon { color: #ef4444; }
110
+
111
+ .txa-toast-info { border-color: rgba(59, 130, 246, 0.4); box-shadow: 0 12px 35px rgba(59, 130, 246, 0.2); }
112
+ .txa-toast-info .txa-icon { color: #3b82f6; }
113
+
114
+ .txa-toast-warning { border-color: rgba(245, 158, 11, 0.4); box-shadow: 0 12px 35px rgba(245, 158, 11, 0.2); }
115
+ .txa-toast-warning .txa-icon { color: #f59e0b; }
116
+
117
+ .txa-content-wrapper { display: flex; flex-direction: row; align-items: center; gap: 12px; flex-grow: 1; }
118
+ .txa-icon { display: flex; align-items: center; justify-content: center; flex-shrink: 0; width: 24px; }
119
+ .txa-message { line-height: 1.4; text-shadow: 0 1px 2px rgba(0,0,0,0.6); flex-grow: 1; }
120
+
121
+ .txa-close {
122
+ background: transparent; border: none; color: rgba(255, 255, 255, 0.4);
123
+ cursor: pointer; padding: 6px; display: flex; align-items: center;
124
+ justify-content: center; transition: all 0.2s; border-radius: 50%;
125
+ }
126
+ .txa-close:hover { color: #ffffff; background: rgba(255,255,255,0.1); transform: rotate(90deg); }
127
+
128
+ .txa-progress-bar {
129
+ position: absolute; bottom: 0; left: 0; height: 3px; width: 100%;
130
+ background: linear-gradient(90deg, transparent, rgba(255,255,255,0.5), transparent);
131
+ transform-origin: left; transform: scaleX(1);
132
+ }
133
+ `;
134
+ document.head.appendChild(style);
135
+ }
136
+
137
+ show(message, type = 'info', duration = 3500, position = 'top-right') {
138
+ const container = this.getContainer(position);
139
+ if (!container) return;
140
+
141
+ const svgIcons = {
142
+ success: `<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"></path><polyline points="22 4 12 14.01 9 11.01"></polyline></svg>`,
143
+ error: `<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"></circle><line x1="15" y1="9" x2="9" y2="15"></line><line x1="9" y1="9" x2="15" y2="15"></line></svg>`,
144
+ info: `<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="16" x2="12" y2="12"></line><line x1="12" y1="8" x2="12.01" y2="8"></line></svg>`,
145
+ warning: `<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"></path><line x1="12" y1="9" x2="12" y2="13"></line><line x1="12" y1="17" x2="12.01" y2="17"></line></svg>`
146
+ };
147
+
148
+ const toast = document.createElement('div');
149
+ toast.className = `txa-toast txa-toast-${type}`;
150
+
151
+ const rawHTML = `
152
+ <div class="txa-content-wrapper">
153
+ <div class="txa-icon">${svgIcons[type] || svgIcons.info}</div>
154
+ <div class="txa-message">${message}</div>
155
+ </div>
156
+ <button class="txa-close" aria-label="Close">
157
+ <svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>
158
+ </button>
159
+ <div class="txa-progress-bar"></div>
160
+ `;
161
+
162
+ if (this.policy) {
163
+ toast.innerHTML = this.policy.createHTML(rawHTML);
164
+ } else {
165
+ toast.innerHTML = rawHTML;
166
+ }
167
+
168
+ container.appendChild(toast);
169
+ setTimeout(() => { toast.classList.add('txa-show'); }, 10);
170
+
171
+ const progressBar = toast.querySelector('.txa-progress-bar');
172
+ if (progressBar) {
173
+ progressBar.style.transition = `transform ${duration}ms linear`;
174
+ progressBar.style.transform = 'scaleX(0)';
175
+ }
176
+
177
+ const closeToast = () => {
178
+ toast.classList.remove('txa-show');
179
+ toast.classList.add('txa-hide');
180
+ setTimeout(() => { toast.remove(); }, 500);
181
+ };
182
+
183
+ toast.querySelector('.txa-close').onclick = closeToast;
184
+ setTimeout(closeToast, duration);
185
+ }
186
+
187
+ success(msg, duration, pos) { this.show(msg, 'success', duration, pos); }
188
+ error(msg, duration, pos) { this.show(msg, 'error', duration, pos); }
189
+ info(msg, duration, pos) { this.show(msg, 'info', duration, pos); }
190
+ warning(msg, duration, pos) { this.show(msg, 'warning', duration, pos); }
191
+ }
192
+
193
+ const txatoastInstance = new TXAToastSystem();
194
+ if (typeof window !== 'undefined') {
195
+ window.txatoast = txatoastInstance;
196
+ document.addEventListener('astro:page-load', () => {
197
+ txatoastInstance.injectStyles();
198
+ });
199
+ }
public/lib/txa/txatooltip.js ADDED
@@ -0,0 +1,198 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Ultimate Liquid Tooltip System (TXA Tooltip V2)
3
+ * High performance, mouse-following, glassmorphism design
4
+ * Supports data-txa-tooltip, data-txatooltip, and .txatooltip class
5
+ */
6
+
7
+ (function () {
8
+ 'use strict';
9
+
10
+ if (window.isTxaTooltipInit) return;
11
+ window.isTxaTooltipInit = true;
12
+
13
+ const tooltip = document.getElementById('txa-tooltip') || document.createElement('div');
14
+ if (!tooltip.id) {
15
+ tooltip.id = 'txa-tooltip';
16
+ tooltip.style.cssText = `
17
+ position: fixed;
18
+ pointer-events: none;
19
+ z-index: 2147483647;
20
+ padding: 12px 18px;
21
+ background: rgba(15, 23, 42, 0.96);
22
+ backdrop-filter: blur(20px) saturate(180%);
23
+ -webkit-backdrop-filter: blur(20px) saturate(180%);
24
+ border: 1px solid rgba(255, 255, 255, 0.2);
25
+ border-radius: 16px;
26
+ color: #fff;
27
+ font-size: 14px;
28
+ font-weight: 500;
29
+ box-shadow: 0 20px 50px rgba(0,0,0,0.6), 0 0 20px rgba(99, 102, 241, 0.2);
30
+ opacity: 0;
31
+ transform: scale(0.95) translateY(10px);
32
+ transition: opacity 0.3s cubic-bezier(0.16, 1, 0.3, 1), transform 0.3s cubic-bezier(0.16, 1, 0.3, 1), left 0.08s linear, top 0.08s linear;
33
+ white-space: normal;
34
+ max-width: 320px;
35
+ line-height: 1.6;
36
+ text-align: left;
37
+ word-break: break-word;
38
+ font-family: 'Outfit', sans-serif;
39
+ `;
40
+ }
41
+
42
+ function initTooltip() {
43
+ if (!document.body) {
44
+ window.addEventListener('DOMContentLoaded', initTooltip);
45
+ return;
46
+ }
47
+ if (!document.getElementById('txa-tooltip')) {
48
+ document.body.appendChild(tooltip);
49
+ }
50
+ }
51
+
52
+ window.initTxaTooltips = initTooltip;
53
+ initTooltip();
54
+ document.addEventListener('astro:page-load', initTooltip);
55
+
56
+ let currentTarget = null;
57
+ let hideTimeout = null;
58
+ let isMouseOnTooltip = false;
59
+
60
+ tooltip.addEventListener('mouseenter', () => {
61
+ isMouseOnTooltip = true;
62
+ clearTimeout(hideTimeout);
63
+ });
64
+
65
+ tooltip.addEventListener('mouseleave', () => {
66
+ isMouseOnTooltip = false;
67
+ if (!currentTarget) hideTooltip();
68
+ });
69
+
70
+ function showTooltip(text, target, e = null) {
71
+ if (!text) return;
72
+ clearTimeout(hideTimeout);
73
+
74
+ const link = target.getAttribute('data-tooltip-link');
75
+ const linkTitle = target.getAttribute('data-tooltip-link-title') || 'Xem chi tiết';
76
+
77
+ let content = `<div class="txa-tooltip-content">${text}</div>`;
78
+ if (link) {
79
+ content += `
80
+ <div class="txa-tooltip-action" style="margin-top: 12px; border-top: 1px solid rgba(255,255,255,0.1); padding-top: 10px;">
81
+ <a href="${link}" class="txa-tooltip-btn" style="display: block; width: 100%; padding: 8px 12px; background: linear-gradient(90deg, #6366f1, #a855f7); color: #fff; border-radius: 8px; text-align: center; font-weight: 700; font-size: 12px; transition: all 0.2s; text-decoration: none;">
82
+ <i class="fas fa-external-link-alt me-1"></i> ${linkTitle}
83
+ </a>
84
+ </div>
85
+ `;
86
+ tooltip.style.pointerEvents = 'auto';
87
+ } else {
88
+ tooltip.style.pointerEvents = 'none';
89
+ }
90
+
91
+ tooltip.innerHTML = content;
92
+
93
+ // Dynamic alignment
94
+ tooltip.style.textAlign = text.length > 40 ? 'left' : 'center';
95
+
96
+ tooltip.style.opacity = '1';
97
+ tooltip.style.transform = 'scale(1) translateY(0)';
98
+ currentTarget = target;
99
+
100
+ if (e) {
101
+ updatePosition(e);
102
+ }
103
+ }
104
+
105
+ function hideTooltip() {
106
+ if (isMouseOnTooltip) return;
107
+
108
+ hideTimeout = setTimeout(() => {
109
+ if (isMouseOnTooltip) return;
110
+ tooltip.style.opacity = '0';
111
+ tooltip.style.transform = 'scale(0.95) translateY(10px)';
112
+ currentTarget = null;
113
+ }, 100);
114
+ }
115
+
116
+ function updatePosition(e) {
117
+ if (tooltip.style.opacity === '0' || isMouseOnTooltip) return;
118
+
119
+ const offset = 20;
120
+ const rect = tooltip.getBoundingClientRect();
121
+
122
+ let x = e.clientX - (rect.width / 2);
123
+ let y = e.clientY - rect.height - offset;
124
+
125
+ const padding = 15;
126
+
127
+ if (x < padding) x = padding;
128
+ if (x + rect.width + padding > window.innerWidth) {
129
+ x = window.innerWidth - rect.width - padding;
130
+ }
131
+
132
+ if (y < padding) {
133
+ y = e.clientY + offset;
134
+ }
135
+
136
+ tooltip.style.left = x + 'px';
137
+ tooltip.style.top = y + 'px';
138
+ }
139
+
140
+ document.addEventListener('mouseover', (e) => {
141
+ const target = e.target.closest('[data-txa-tooltip], [data-txatooltip], .txatooltip, .txaxtooltip');
142
+ if (target) {
143
+ // Logic for sidebar menu items (collapsed state)
144
+ if (target.classList.contains('menu-item') || target.classList.contains('sidebar-link')) {
145
+ const isSidebarCollapsed = document.body.classList.contains('sidebar-collapsed');
146
+ if (!isSidebarCollapsed && window.innerWidth > 992) return;
147
+ }
148
+
149
+ const tooltipText = target.getAttribute('data-txa-tooltip') || target.getAttribute('data-txatooltip') || target.title;
150
+ if (tooltipText) {
151
+ // Remove title to prevent browser default tooltip
152
+ if (target.title) {
153
+ target.setAttribute('data-original-title', target.title);
154
+ target.title = '';
155
+ }
156
+ showTooltip(tooltipText, target, e);
157
+ }
158
+ }
159
+ });
160
+
161
+ document.addEventListener('mouseout', (e) => {
162
+ const target = e.target.closest('[data-txa-tooltip], [data-txatooltip], .txatooltip, .txaxtooltip');
163
+ if (target) {
164
+ // Restore title if needed
165
+ const originalTitle = target.getAttribute('data-original-title');
166
+ if (originalTitle) {
167
+ target.title = originalTitle;
168
+ target.removeAttribute('data-original-title');
169
+ }
170
+ hideTooltip();
171
+ }
172
+ });
173
+
174
+ document.addEventListener('mousemove', (e) => {
175
+ if (currentTarget) updatePosition(e);
176
+ });
177
+
178
+ document.addEventListener('click', (e) => {
179
+ if (!tooltip.contains(e.target)) hideTooltip();
180
+ });
181
+
182
+ // Fullscreen support
183
+ function handleFullscreenChange() {
184
+ const fsElem = document.fullscreenElement || document.webkitFullscreenElement;
185
+ if (fsElem) {
186
+ fsElem.appendChild(tooltip);
187
+ tooltip.style.zIndex = '2147483647';
188
+ } else {
189
+ if (document.body && !document.body.contains(tooltip)) {
190
+ document.body.appendChild(tooltip);
191
+ }
192
+ }
193
+ }
194
+
195
+ document.addEventListener('fullscreenchange', handleFullscreenChange);
196
+ document.addEventListener('webkitfullscreenchange', handleFullscreenChange);
197
+
198
+ })();
public/locations.json ADDED
The diff for this file is too large to render. See raw diff
 
public/logo-full.png ADDED

Git LFS Details

  • SHA256: 7e60ad1633e88233b72b5688b885d730bf5a6f85d003fa625231d762fd0d76e3
  • Pointer size: 131 Bytes
  • Size of remote file: 628 kB
public/logo-icon.png ADDED

Git LFS Details

  • SHA256: 1fbbcbe9389558c17c6b664312e8610e6ec7c348477d5b8d83461ffc2ee44852
  • Pointer size: 131 Bytes
  • Size of remote file: 619 kB
public/logo-square.png ADDED

Git LFS Details

  • SHA256: 1bb3f3a4384282db9cb67ba7b4ad604c7ba5700f56119842e6333642f04d883f
  • Pointer size: 130 Bytes
  • Size of remote file: 95.3 kB
public/logo.svg ADDED

Git LFS Details

  • SHA256: 16d93aa13607471fe2fb992d0e60e63b5abd346e3563da697feee3745da60fc1
  • Pointer size: 128 Bytes
  • Size of remote file: 208 Bytes
public/robots.txt ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ User-agent: *
2
+ Disallow: /admin/
3
+ Disallow: /admin/*
4
+ Allow: /
5
+
6
+ Sitemap: https://tphimx.vercel.app/sitemap-index.xml
scratch/test_fetch.js ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ async function test() {
3
+ const targetUrl = 'https://v7.kkphimplayer7.com/20260514/LGQKiMAz/preview.jpg';
4
+ try {
5
+ const response = await fetch(targetUrl, {
6
+ headers: {
7
+ 'Referer': 'https://v7.kkphimplayer7.com/',
8
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
9
+ }
10
+ });
11
+ console.log('Status:', response.status);
12
+ console.log('Headers:', JSON.stringify(Object.fromEntries(response.headers.entries()), null, 2));
13
+ } catch (err) {
14
+ console.error('Error:', err);
15
+ }
16
+ }
17
+
18
+ test();
scratch/test_variations.js ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ async function test() {
3
+ const variations = [
4
+ 'https://v7.kkphimplayer7.com/20260514/LGQKiMAz/preview.jpg',
5
+ 'https://v7.kkphimplayer7.com/20260514/LGQKiMAz/thumb.jpg',
6
+ 'https://v7.kkphimplayer7.com/20260514/LGQKiMAz/index.jpg',
7
+ 'https://v7.kkphimplayer7.com/20260514/LGQKiMAz/preview.png'
8
+ ];
9
+ for (const url of variations) {
10
+ try {
11
+ const response = await fetch(url, {
12
+ headers: {
13
+ 'Referer': 'https://v7.kkphimplayer7.com/',
14
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
15
+ }
16
+ });
17
+ console.log(`URL: ${url} -> Status: ${response.status}`);
18
+ } catch (err) {
19
+ console.error(`URL: ${url} -> Error: ${err.message}`);
20
+ }
21
+ }
22
+ }
23
+
24
+ test();
scripts/apply_subtitle_update.js ADDED
@@ -0,0 +1,118 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import { createClient } from '@supabase/supabase-js';
4
+
5
+ // Parse .env manually
6
+ const envPath = path.resolve('.env');
7
+ if (!fs.existsSync(envPath)) {
8
+ console.error("Error: .env file not found at", envPath);
9
+ process.exit(1);
10
+ }
11
+
12
+ const envContent = fs.readFileSync(envPath, 'utf-8');
13
+ const env = {};
14
+ envContent.split('\n').forEach(line => {
15
+ const trimmed = line.trim();
16
+ if (trimmed.startsWith('#') || !trimmed) return;
17
+ const match = trimmed.match(/^([^=]+)=(.*)$/);
18
+ if (match) {
19
+ env[match[1].trim()] = match[2].trim();
20
+ }
21
+ });
22
+
23
+ const supabaseUrl = env.PUBLIC_SUPABASE_URL;
24
+ const supabaseAnonKey = env.PUBLIC_SUPABASE_ANON_KEY;
25
+
26
+ if (!supabaseUrl || !supabaseAnonKey) {
27
+ console.error("Error: Supabase URL or Anon Key is missing in .env!");
28
+ process.exit(1);
29
+ }
30
+
31
+ const supabase = createClient(supabaseUrl, supabaseAnonKey);
32
+
33
+ async function run() {
34
+ try {
35
+ const srtPath = "C:\\Users\\TXA3100\\Desktop\\VIETSUB\\sut_vi.srt";
36
+ if (!fs.existsSync(srtPath)) {
37
+ console.error("Error: SRT file not found at", srtPath);
38
+ process.exit(1);
39
+ }
40
+ const srtContent = fs.readFileSync(srtPath, 'utf-8');
41
+
42
+ console.log("Read SRT subtitle successfully. Length:", srtContent.length);
43
+
44
+ const movie_json = {
45
+ "_id": "fbi-part-one-id",
46
+ "imdb": { "id": null },
47
+ "lang": "Vietsub",
48
+ "name": "FBI: Part One",
49
+ "slug": "fbi-part-one",
50
+ "time": "120 phút",
51
+ "tmdb": { "id": null, "type": "movie", "season": 1, "vote_count": 0, "vote_average": 0 },
52
+ "type": "single",
53
+ "view": 0,
54
+ "year": 2026,
55
+ "actor": ["FBI Agents"],
56
+ "notify": "",
57
+ "status": "completed",
58
+ "content": "Bộ phim hành động FBI Part One cực kỳ kịch tính và hấp dẫn, đi kèm hệ thống Storyboard dynamic youtube-style mượt mà và chất lượng đa luồng chất lượng cao.",
59
+ "country": [{ "id": "usa", "name": "Mỹ", "slug": "my" }],
60
+ "created": { "time": "2026-05-18T14:40:00.000Z" },
61
+ "quality": "HD",
62
+ "category": [{ "id": "action", "name": "Hành Động", "slug": "hanh-dong" }],
63
+ "chieurap": false,
64
+ "director": ["FBI Directors"],
65
+ "modified": { "time": "2026-05-18T14:40:00.000Z" },
66
+ "showtimes": "",
67
+ "thumb_url": "https://phimimg.com/upload/vod/20260307-1/81572d43eddf1dc784f3f53f6323f46d.jpg",
68
+ "poster_url": "https://phimimg.com/upload/vod/20260307-1/7e5b11b1d5ce3514f6d62787d164b816.jpg",
69
+ "origin_name": "FBI Part One",
70
+ "trailer_url": "",
71
+ "is_copyright": false,
72
+ "sub_docquyen": false,
73
+ "episode_total": "1",
74
+ "episode_current": "Full"
75
+ };
76
+
77
+ const episodes_json = [
78
+ {
79
+ "server_name": "Cloudflare R2 Premium",
80
+ "server_data": [
81
+ {
82
+ "name": "Full Movie",
83
+ "slug": "full-movie",
84
+ "filename": "FBI Part One - Full HD - Vietsub",
85
+ "link_m3u8": "https://tphimx-r2-proxy.txagt145.workers.dev/fbi-part-one/master.m3u8",
86
+ "link_embed": "https://tphimx-r2-proxy.txagt145.workers.dev/fbi-part-one/master.m3u8",
87
+ "subtitles_srt": srtContent
88
+ }
89
+ ]
90
+ }
91
+ ];
92
+
93
+ console.log("Upserting movie record to public.movies for slug 'fbi-part-one'...");
94
+
95
+ const { data, error } = await supabase
96
+ .from('movies')
97
+ .upsert({
98
+ slug: 'fbi-part-one',
99
+ movie: movie_json,
100
+ episodes: episodes_json,
101
+ updated_at: new Date().toISOString()
102
+ })
103
+ .select();
104
+
105
+ if (error) {
106
+ throw error;
107
+ }
108
+
109
+ console.log("Success! Movie 'fbi-part-one' has been successfully registered/updated with SRT subtitles.");
110
+ console.log("Result rows:", data ? data.length : 0);
111
+
112
+ } catch (err) {
113
+ console.error("Error executing database upsert:", err);
114
+ process.exit(1);
115
+ }
116
+ }
117
+
118
+ run();
scripts/format-xml.js ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+
4
+ function formatXml(xml) {
5
+ let formatted = '';
6
+ let pad = 0;
7
+ xml = xml.replace(/(>)(<)(\/*)/g, '$1\r\n$2$3');
8
+ xml.split('\r\n').forEach(node => {
9
+ let indent = 0;
10
+ if (node.match(/.+<\/\w[^>]*>$/)) {
11
+ indent = 0;
12
+ } else if (node.match(/^<\/\w/)) {
13
+ if (pad !== 0) { pad -= 1; }
14
+ } else if (node.match(/^<\w[^>]*[^\/]>.*$/)) {
15
+ indent = 1;
16
+ } else {
17
+ indent = 0;
18
+ }
19
+
20
+ formatted += ' '.repeat(pad) + node + '\r\n';
21
+ pad += indent;
22
+ });
23
+ return formatted.trim();
24
+ }
25
+
26
+ const filesToFormat = [
27
+ 'dist/client/sitemap-index.xml',
28
+ 'dist/client/sitemap-0.xml',
29
+ 'dist/client/sitemap-1.xml',
30
+ 'dist/client/sitemap-2.xml',
31
+ 'dist/client/sitemap-3.xml',
32
+ 'dist/client/sitemap-4.xml',
33
+ 'dist/client/sitemap-5.xml',
34
+ '.vercel/output/static/sitemap-index.xml',
35
+ '.vercel/output/static/sitemap-0.xml',
36
+ '.vercel/output/static/sitemap-1.xml',
37
+ '.vercel/output/static/sitemap-2.xml',
38
+ '.vercel/output/static/sitemap-3.xml',
39
+ '.vercel/output/static/sitemap-4.xml',
40
+ '.vercel/output/static/sitemap-5.xml'
41
+ ];
42
+
43
+ filesToFormat.forEach(filePath => {
44
+ const fullPath = path.resolve(process.cwd(), filePath);
45
+ if (fs.existsSync(fullPath)) {
46
+ const xmlContent = fs.readFileSync(fullPath, 'utf8');
47
+ const prettyXml = formatXml(xmlContent);
48
+ fs.writeFileSync(fullPath, prettyXml, 'utf8');
49
+ console.log(`Formatted XML: ${filePath}`);
50
+ }
51
+ });
src/components/CombinedRegionSection.jsx ADDED
@@ -0,0 +1,132 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { formatImageUrl } from '../lib/image';
2
+
3
+ export default function CombinedRegionSection({ sections = [] }) {
4
+ if (!sections || sections.length === 0) return null;
5
+
6
+ return (
7
+ <section className="mx-auto max-w-7xl px-4 py-10 md:px-8 relative overflow-hidden">
8
+ {/* Master Premium Glass Container */}
9
+ <div className="relative overflow-hidden rounded-[32px] border border-white/5 bg-gradient-to-br from-white/[0.02] to-white/[0.003] p-6 shadow-2xl backdrop-blur-2xl md:p-10">
10
+
11
+ {/* Abstract Floating Glows */}
12
+ <div className="absolute -left-20 -top-20 -z-10 h-80 w-80 rounded-full bg-primary/5 blur-[100px] pointer-events-none" />
13
+ <div className="absolute -right-20 -bottom-20 -z-10 h-80 w-80 rounded-full bg-amber-500/5 blur-[100px] pointer-events-none" />
14
+
15
+ {/* Section Header */}
16
+ <div className="relative z-10 mb-10 flex items-center justify-between border-b border-white/5 pb-5">
17
+ <div className="flex items-center gap-3">
18
+ <span className="h-8 w-2 rounded-full bg-gradient-to-b from-primary to-amber-500 shadow-[0_0_20px_rgba(124,58,237,0.4)]" />
19
+ <h2 className="text-xl font-black uppercase tracking-tight text-white md:text-2xl">
20
+ Đường Đua Điện Ảnh Quốc Tế
21
+ </h2>
22
+ </div>
23
+ <span className="hidden sm:inline-block rounded-full bg-white/5 border border-white/10 px-3 py-1 text-[8px] font-black uppercase tracking-widest text-gray-400">
24
+ Cập nhật hàng ngày
25
+ </span>
26
+ </div>
27
+
28
+ {/* Rows wrapper */}
29
+ <div className="relative z-10 space-y-12">
30
+ {sections.map((sec, idx) => {
31
+ // Authentic sponsor configuration for each region
32
+ const sponsors = {
33
+ 'trung-quoc': { name: 'VIC88 TÀI TRỢ', bg: 'from-amber-600 to-red-600 shadow-red-600/30' },
34
+ 'han-quoc': { name: 'F88 SPORT', bg: 'from-emerald-600 to-teal-600 shadow-emerald-600/30' },
35
+ 'au-my': { name: '86BET LIVE', bg: 'from-blue-600 to-indigo-600 shadow-blue-600/30' }
36
+ };
37
+
38
+ const sponsor = sponsors[sec.country] || { name: 'ROPHIM VIP', bg: 'from-primary to-accent shadow-primary/30' };
39
+
40
+ return (
41
+ <div key={sec.country} className="space-y-5">
42
+ {/* Row Header */}
43
+ <div className="flex items-center justify-between">
44
+ <h3 className="flex items-center gap-2.5 text-xs font-black uppercase tracking-widest text-gray-300">
45
+ <span className={`h-2.5 w-2.5 rounded-full bg-gradient-to-br ${sponsor.bg}`} />
46
+ {sec.title}
47
+ </h3>
48
+ <a
49
+ href={sec.href}
50
+ className="group/all flex items-center gap-1 text-[9px] font-black uppercase tracking-widest text-primary hover:text-white transition-all duration-300 cursor-pointer"
51
+ >
52
+ Xem tất cả
53
+ <i className="fas fa-chevron-right text-[7px] transition-transform duration-300 group-hover/all:translate-x-1" />
54
+ </a>
55
+ </div>
56
+
57
+ {/* Landscape 16/9 Card Scroll list */}
58
+ <div className="scrollbar-hide flex overflow-x-auto gap-5 pb-4 px-1 -mx-1 snap-x scroll-smooth">
59
+ {sec.movies.slice(0, 10).map((m, mIdx) => {
60
+ const poster = formatImageUrl(m.poster_url || m.thumb_url);
61
+ const year = m.year || '';
62
+ const quality = m.quality || 'FHD';
63
+ const lang = m.lang || 'Vietsub';
64
+
65
+ return (
66
+ <div
67
+ key={m._id || m.slug}
68
+ className="w-[200px] sm:w-[240px] md:w-[280px] flex-shrink-0 snap-start group/land relative"
69
+ >
70
+ {/* 16:9 Image wrapper */}
71
+ <div className="relative aspect-[16/9] w-full overflow-hidden rounded-2xl border border-white/5 bg-black/40 transition-all duration-500 shadow-lg group-hover/land:scale-[1.03] group-hover/land:shadow-[0_12px_30px_rgba(0,0,0,0.6)] group-hover/land:border-white/15">
72
+ <img
73
+ src={poster}
74
+ alt={m.name}
75
+ className="h-full w-full object-cover transition-transform duration-700 group-hover/land:scale-105"
76
+ />
77
+ <div className="absolute inset-0 bg-gradient-to-t from-black/95 via-black/30 to-transparent" />
78
+
79
+ {/* Authentic Sponsor Badge Overlay (on FIRST card of row) */}
80
+ {mIdx === 0 && (
81
+ <div className={`absolute top-3 left-3 z-30 bg-gradient-to-r ${sponsor.bg} text-[7px] font-black uppercase text-white px-2.5 py-0.5 rounded shadow-[0_4px_12px_rgba(0,0,0,0.5)] tracking-widest`}>
82
+ {sponsor.name}
83
+ </div>
84
+ )}
85
+
86
+ {/* Quality/Lang tags in top-right */}
87
+ <div className="absolute top-3 right-3 z-20 flex gap-1">
88
+ <span className="rounded bg-black/70 border border-white/10 px-1.5 py-[2px] text-[7px] font-black uppercase text-gray-300 backdrop-blur-sm shadow-md">
89
+ {quality}
90
+ </span>
91
+ <span className="rounded bg-primary/90 px-1.5 py-[2px] text-[7px] font-black uppercase text-white backdrop-blur-sm shadow-md">
92
+ {lang}
93
+ </span>
94
+ </div>
95
+
96
+ {/* Glow Play Button overlay */}
97
+ <div className="absolute inset-0 flex items-center justify-center opacity-0 group-hover/land:opacity-100 transition-opacity duration-300 bg-black/40 backdrop-blur-[1px] z-10">
98
+ <div className="h-11 w-11 rounded-full bg-primary flex items-center justify-center text-white shadow-[0_0_20px_rgba(229,9,20,0.5)] transform scale-75 group-hover/land:scale-100 transition-transform duration-300">
99
+ <i className="fas fa-play text-xs ml-0.5" />
100
+ </div>
101
+ </div>
102
+
103
+ {/* Movie title overlay */}
104
+ <div className="absolute bottom-3 left-4 right-4 z-20">
105
+ <h4 className="line-clamp-1 text-[11px] sm:text-xs font-black uppercase tracking-tight text-white group-hover/land:text-primary transition-colors duration-300">
106
+ {m.name}
107
+ </h4>
108
+ <div className="mt-0.5 flex items-center gap-1.5 text-[8px] font-bold text-gray-400 uppercase tracking-widest">
109
+ <span className="truncate max-w-[120px]">{m.origin_name}</span>
110
+ {year && (
111
+ <>
112
+ <span className="h-1 w-1 rounded-full bg-gray-600" />
113
+ <span>{year}</span>
114
+ </>
115
+ )}
116
+ </div>
117
+ </div>
118
+
119
+ <a href={`/phim/${m.slug}`} className="absolute inset-0 z-30" />
120
+ </div>
121
+ </div>
122
+ );
123
+ })}
124
+ </div>
125
+ </div>
126
+ );
127
+ })}
128
+ </div>
129
+ </div>
130
+ </section>
131
+ );
132
+ }
src/components/DeferredHero.astro ADDED
@@ -0,0 +1,87 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ import HeroBanner from './HeroBanner.jsx';
3
+ import { getNewestMovies } from '../lib/api.js';
4
+ import { txasupabase } from '../lib/txasupabase.js';
5
+
6
+ // 1. Lấy tất cả phim cục bộ để tìm phim được ghim lên slider
7
+ const localMoviesObj = await txasupabase.getLocalMovies();
8
+ const deletedSlugs = await txasupabase.getDeletedMovies();
9
+
10
+ const allLocalMovies = Object.values(localMoviesObj)
11
+ .filter(m => !deletedSlugs.includes(m.movie.slug));
12
+
13
+ // Lọc các phim được chọn ghim lên slider
14
+ const pinnedMovies = allLocalMovies
15
+ .filter(m => m.movie.is_slider === true || m.movie.is_slider === 'true')
16
+ .map(m => ({
17
+ _id: m.movie.id || m.movie.slug,
18
+ name: m.movie.name,
19
+ slug: m.movie.slug,
20
+ origin_name: m.movie.origin_name,
21
+ thumb_url: m.movie.thumb_url,
22
+ poster_url: m.movie.poster_url,
23
+ year: m.movie.year,
24
+ quality: m.movie.quality || 'FHD',
25
+ lang: m.movie.lang || 'Vietsub',
26
+ episode_current: m.movie.episode_current || 'Tập 1',
27
+ type: m.movie.type || 'single',
28
+ isLocal: true
29
+ }));
30
+
31
+ // Sắp xếp phim được ghim theo thời gian cập nhật mới nhất
32
+ pinnedMovies.sort((a, b) => {
33
+ const timeA = new Date(localMoviesObj[a.slug]?.movie.modified?.time || 0).getTime();
34
+ const timeB = new Date(localMoviesObj[b.slug]?.movie.modified?.time || 0).getTime();
35
+ return timeB - timeA;
36
+ });
37
+
38
+ // Cần tối đa 10 phim cho slider
39
+ let heroMovies = [...pinnedMovies];
40
+
41
+ if (heroMovies.length === 0) {
42
+ // Lấy danh sách phim mới nhất để bù đắp các vị trí trống
43
+ const newest = await getNewestMovies(1);
44
+ const newestMovies = newest?.items || [];
45
+
46
+ let extraMovies = [...newestMovies];
47
+ if (extraMovies.length < 4) {
48
+ try {
49
+ const res = await fetch('https://phimapi.com/danh-sach/phim-moi-cap-nhat?page=1');
50
+ if (res.ok) {
51
+ const data = await res.json();
52
+ const items = data.items || [];
53
+ const mapped = items.map(item => ({
54
+ _id: item._id,
55
+ name: item.name,
56
+ slug: item.slug,
57
+ origin_name: item.origin_name,
58
+ thumb_url: item.thumb_url,
59
+ poster_url: item.poster_url,
60
+ year: item.year,
61
+ quality: item.quality || 'FHD',
62
+ lang: item.lang || 'Vietsub',
63
+ episode_current: item.episode_current || 'Tập 1',
64
+ type: item.type || 'single',
65
+ isLocal: false
66
+ }));
67
+ extraMovies = [...extraMovies, ...mapped.filter(m => !extraMovies.some(x => x.slug === m.slug))];
68
+ }
69
+ } catch (e) {
70
+ console.error('Hero fallback error:', e);
71
+ }
72
+ }
73
+
74
+ // Thêm phim mới vào slider và loại trừ các phim đã được ghim/đã thêm để tránh trùng lặp
75
+ for (const movie of extraMovies) {
76
+ if (heroMovies.length >= 10) break;
77
+ if (!heroMovies.some(m => m.slug === movie.slug)) {
78
+ heroMovies.push(movie);
79
+ }
80
+ }
81
+ }
82
+
83
+ // Giới hạn đúng tối đa 10 phim
84
+ heroMovies = heroMovies.slice(0, 10);
85
+ ---
86
+
87
+ <HeroBanner movies={heroMovies} client:load />
src/components/DeferredSection.astro ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ import MovieSection from './MovieSection.jsx';
3
+ import { getNewestMovies, getSeriesMovies, getSingleMovies, getCartoonMovies } from '../lib/api.js';
4
+
5
+ interface Props {
6
+ type: 'newest' | 'series' | 'single' | 'cartoon';
7
+ title: string;
8
+ href: string;
9
+ }
10
+
11
+ const { type, title, href } = Astro.props;
12
+
13
+ let movies = [];
14
+ try {
15
+ if (type === 'newest') movies = (await getNewestMovies(1))?.items || [];
16
+ if (type === 'series') movies = (await getSeriesMovies(1))?.items || [];
17
+ if (type === 'single') movies = (await getSingleMovies(1))?.items || [];
18
+ if (type === 'cartoon') movies = (await getCartoonMovies(1))?.items || [];
19
+ } catch (e) {
20
+ console.error(e);
21
+ }
22
+ ---
23
+
24
+ <MovieSection title={title} movies={movies} href={href} client:load />
src/components/DeferredTopTen.astro ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ import TopTenSection from './TopTenSection.jsx';
3
+ import { getSeriesMovies } from '../lib/api.js';
4
+ import { txasupabase } from '../lib/txasupabase.js';
5
+
6
+ const data = await getSeriesMovies(1);
7
+ let rawMovies = data?.items || [];
8
+
9
+ if (rawMovies.length < 4) {
10
+ try {
11
+ const res = await fetch('https://phimapi.com/v1/api/danh-sach/phim-bo?page=1');
12
+ if (res.ok) {
13
+ const d = await res.json();
14
+ const items = d.data?.items || [];
15
+ const mapped = items.map(item => ({
16
+ _id: item._id,
17
+ name: item.name,
18
+ slug: item.slug,
19
+ origin_name: item.origin_name,
20
+ thumb_url: item.thumb_url,
21
+ poster_url: item.poster_url,
22
+ year: item.year,
23
+ quality: item.quality || 'FHD',
24
+ lang: item.lang || 'Vietsub',
25
+ episode_current: item.episode_current || 'Tập 1',
26
+ type: item.type || 'series',
27
+ isLocal: false
28
+ }));
29
+ rawMovies = [...rawMovies, ...mapped.filter(m => !rawMovies.some(x => x.slug === m.slug))];
30
+ }
31
+ } catch (e) {
32
+ console.error('TopTen fallback error:', e);
33
+ }
34
+ }
35
+
36
+ // Smart Deduplicate by base name (to avoid multiple seasons of the same show)
37
+ const uniqueMovies = [];
38
+ const seenBaseNames = new Set();
39
+
40
+ for (const m of rawMovies) {
41
+ const baseName = m.name.split(' (Phần')[0].trim().toLowerCase();
42
+
43
+ if (!seenBaseNames.has(baseName)) {
44
+ seenBaseNames.add(baseName);
45
+ uniqueMovies.push(m);
46
+ }
47
+ }
48
+
49
+ const seriesMovies = uniqueMovies.slice(0, 10);
50
+
51
+ // Fetch Top Rated & Most Favorited
52
+ const topRated = await txasupabase.getTopRatedMovies(10).catch(() => []);
53
+ const mostFavorited = await txasupabase.getMostFavoritedMovies(10).catch(() => []);
54
+
55
+ // Intelligent Fallbacks
56
+ const finalSeries = seriesMovies;
57
+ const finalRated = topRated.length >= 3 ? topRated : seriesMovies;
58
+ const finalFavorited = mostFavorited.length >= 3 ? mostFavorited : seriesMovies;
59
+ ---
60
+
61
+ <TopTenSection
62
+ series={finalSeries}
63
+ rated={finalRated}
64
+ favorited={finalFavorited}
65
+ client:only="react"
66
+ />
src/components/EpisodeList.jsx ADDED
@@ -0,0 +1,170 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Play, CheckCircle2 } from 'lucide-react';
2
+ import { useState, useEffect } from 'react';
3
+
4
+ export default function EpisodeList({ episodes, currentTap, movieSlug }) {
5
+ const [serverIndex, setServerIndex] = useState(0);
6
+ const [selectedChunkIndex, setSelectedChunkIndex] = useState(0);
7
+ const [watched, setWatched] = useState([]);
8
+
9
+ // Tự động đồng bộ server và tab (chunk) chứa tập đang phát (currentTap)
10
+ useEffect(() => {
11
+ if (currentTap && episodes?.length) {
12
+ const foundServerIdx = episodes.findIndex(server =>
13
+ server.server_data?.some(ep => ep.name === currentTap)
14
+ );
15
+ if (foundServerIdx !== -1) {
16
+ setServerIndex(foundServerIdx);
17
+
18
+ const targetServer = episodes[foundServerIdx];
19
+ const targetEpisodes = targetServer?.server_data || [];
20
+ const epIdx = targetEpisodes.findIndex(ep => ep.name === currentTap);
21
+ if (epIdx !== -1) {
22
+ const targetChunkIdx = Math.floor(epIdx / 25);
23
+ setSelectedChunkIndex(targetChunkIdx);
24
+ }
25
+ }
26
+ }
27
+ }, [currentTap, episodes]);
28
+
29
+ // Tải lịch sử xem phim
30
+ useEffect(() => {
31
+ try {
32
+ const history = JSON.parse(localStorage.getItem('txa_watch_history') || '{}');
33
+ if (history[movieSlug]?.watched) {
34
+ setWatched(history[movieSlug].watched);
35
+ }
36
+ } catch (e) {
37
+ console.error('Failed to load watch history');
38
+ }
39
+ }, [movieSlug]);
40
+
41
+ if (!episodes?.length) return null;
42
+
43
+ const currentServer = episodes[serverIndex] || episodes[0];
44
+ const allEpisodes = currentServer?.server_data || [];
45
+
46
+ // Chia danh sách tập phim thành các nhóm tối đa 25 tập
47
+ const chunkSize = 25;
48
+ const chunks = [];
49
+ for (let i = 0; i < allEpisodes.length; i += chunkSize) {
50
+ chunks.push(allEpisodes.slice(i, i + chunkSize));
51
+ }
52
+
53
+ // Đảm bảo selectedChunkIndex không vượt quá phạm vi mới sau khi đổi server
54
+ const activeChunkIndex = selectedChunkIndex < chunks.length ? selectedChunkIndex : 0;
55
+ const visibleEpisodes = chunks[activeChunkIndex] || [];
56
+
57
+ return (
58
+ <div suppressHydrationWarning className="glass rounded-[32px] p-6 shadow-2xl backdrop-blur-2xl border border-white/5 space-y-6">
59
+
60
+ {/* Tiêu đề & Chọn Server / Chọn Tab 25 tập */}
61
+ <div suppressHydrationWarning className="flex flex-col gap-4 border-b border-white/5 pb-6">
62
+
63
+ <div suppressHydrationWarning className="flex flex-col justify-between gap-4 sm:flex-row sm:items-center">
64
+ <div suppressHydrationWarning className="flex items-center gap-3">
65
+ <span className="h-8 w-2 rounded-full bg-primary shadow-[0_0_15px_rgba(229,9,20,0.5)]" />
66
+ <h3 className="text-xl font-black uppercase tracking-tighter text-white">
67
+ Danh sách tập
68
+ </h3>
69
+ </div>
70
+
71
+ {/* Các Server phát */}
72
+ {episodes.length > 1 && (
73
+ <div suppressHydrationWarning className="flex flex-wrap gap-2">
74
+ {episodes.map((server, idx) => (
75
+ <button
76
+ key={idx}
77
+ onClick={() => {
78
+ setServerIndex(idx);
79
+ setSelectedChunkIndex(0); // Reset về tab đầu tiên khi đổi server
80
+ }}
81
+ className={`rounded-2xl px-5 py-2.5 text-xs font-black uppercase tracking-widest transition-all cursor-pointer ${
82
+ serverIndex === idx
83
+ ? 'bg-primary text-white shadow-[0_0_20px_rgba(229,9,20,0.4)] scale-105'
84
+ : 'glass text-gray-400 hover:text-white hover:bg-white/5'
85
+ }`}
86
+ >
87
+ {server.server_name}
88
+ </button>
89
+ ))}
90
+ </div>
91
+ )}
92
+ </div>
93
+
94
+ {/* Các Tab phân trang 25 tập (chỉ hiện khi có trên 25 tập) */}
95
+ {chunks.length > 1 && (
96
+ <div suppressHydrationWarning className="flex flex-wrap gap-2 mt-2 pt-4 border-t border-white/5">
97
+ {chunks.map((chunk, idx) => {
98
+ const startIdx = idx * chunkSize + 1;
99
+ const endIdx = Math.min((idx + 1) * chunkSize, allEpisodes.length);
100
+
101
+ // Tạo nhãn tab trực quan theo tên tập hoặc index số
102
+ const getLabel = () => {
103
+ const sName = chunk[0]?.name;
104
+ const eName = chunk[chunk.length - 1]?.name;
105
+ const sNum = parseInt(sName);
106
+ const eNum = parseInt(eName);
107
+ if (!isNaN(sNum) && !isNaN(eNum)) {
108
+ return `Tập ${sNum} - ${eNum}`;
109
+ }
110
+ return `${sName} - ${eName}`;
111
+ };
112
+
113
+ const isActive = activeChunkIndex === idx;
114
+
115
+ return (
116
+ <button
117
+ key={idx}
118
+ onClick={() => setSelectedChunkIndex(idx)}
119
+ className={`rounded-xl px-4 py-2 text-xs font-black transition-all cursor-pointer ${
120
+ isActive
121
+ ? 'bg-yellow-500 text-dark shadow-[0_0_15px_rgba(234,179,8,0.3)] scale-105'
122
+ : 'bg-white/5 text-gray-400 hover:text-white hover:bg-white/10'
123
+ }`}
124
+ >
125
+ {getLabel()}
126
+ </button>
127
+ );
128
+ })}
129
+ </div>
130
+ )}
131
+ </div>
132
+
133
+ {/* Grid danh sách tập */}
134
+ <div suppressHydrationWarning className="grid grid-cols-3 gap-3 sm:grid-cols-5 md:grid-cols-6 lg:grid-cols-8 xl:grid-cols-10">
135
+ {visibleEpisodes.map((ep) => {
136
+ const isWatched = watched.includes(ep.name);
137
+ const isActive = ep.name === currentTap;
138
+
139
+ return (
140
+ <a
141
+ key={ep.name}
142
+ href={`/xem/${movieSlug}?tap=${ep.name}`}
143
+ className={`group relative flex flex-col items-center justify-center rounded-2xl py-4 transition-all duration-300 ${
144
+ isActive
145
+ ? 'bg-primary shadow-[0_0_25px_rgba(229,9,20,0.3)] ring-2 ring-primary scale-105 z-10'
146
+ : 'glass hover:bg-white/10 hover:translate-y-[-2px]'
147
+ }`}
148
+ >
149
+ <span className={`text-sm font-black tracking-tight ${isActive ? 'text-white' : 'text-gray-400 group-hover:text-white'}`}>
150
+ {ep.name.toLowerCase().includes('tập') ? ep.name : `Tập ${ep.name}`}
151
+ </span>
152
+
153
+ {isWatched && !isActive && (
154
+ <div className="absolute -right-1 -top-1 rounded-full bg-green-500 p-0.5 text-white shadow-lg ring-2 ring-[#0f0f0f]">
155
+ <CheckCircle2 size={10} fill="currentColor" className="text-white" />
156
+ </div>
157
+ )}
158
+
159
+ {isActive && (
160
+ <div className="mt-1">
161
+ <Play size={10} fill="white" className="text-white animate-pulse" />
162
+ </div>
163
+ )}
164
+ </a>
165
+ );
166
+ })}
167
+ </div>
168
+ </div>
169
+ );
170
+ }
src/components/Footer.jsx ADDED
@@ -0,0 +1,206 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Film, Heart } from 'lucide-react';
2
+
3
+ export default function Footer({ settings = {} }) {
4
+ const currentYear = new Date().getFullYear();
5
+ const copyrightYear = currentYear > 2026 ? `2026 - ${currentYear}` : '2026';
6
+
7
+ const socials = [
8
+ {
9
+ key: 'social_fb',
10
+ label: 'Theo dõi DPTXA trên Facebook',
11
+ hoverColor: '#1877F2',
12
+ trackingName: 'facebook',
13
+ svg: (
14
+ <svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
15
+ <path d="M22 12c0-5.52-4.48-10-10-10S2 6.48 2 12c0 4.84 3.44 8.87 8 9.8V15H8v-3h2V9.5C10 7.57 11.57 6 13.5 6H16v3h-2c-.55 0-1 .45-1 1v2h3v3h-3v6.95c4.56-.93 8-4.96 8-9.75z"/>
16
+ </svg>
17
+ )
18
+ },
19
+ {
20
+ key: 'social_fb_group',
21
+ label: 'Tham gia Cộng đồng Facebook',
22
+ hoverColor: '#1877F2',
23
+ trackingName: 'facebook_group',
24
+ svg: (
25
+ <svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
26
+ <path d="M16 11c1.66 0 2.99-1.34 2.99-3S17.66 5 16 5c-1.66 0-3 1.34-3 3s1.34 3 3 3zm-8 0c1.66 0 2.99-1.34 2.99-3S9.66 5 8 5C6.34 5 5 6.34 5 8s1.34 3 3 3zm0 2c-2.33 0-7 1.17-7 3.5V19h14v-2.5c0-2.33-4.67-3.5-7-3.5zm8 0c-.29 0-.62.02-.97.05 1.16.84 1.97 1.97 1.97 3.45V19h6v-2.5c0-2.33-4.67-3.5-7-3.5z"/>
27
+ </svg>
28
+ )
29
+ },
30
+ {
31
+ key: 'social_zalo_group',
32
+ label: 'Tham gia Nhóm Zalo hỗ trợ',
33
+ hoverColor: '#0068FF',
34
+ trackingName: 'zalo',
35
+ svg: (
36
+ <svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
37
+ <path d="M12 2C6.48 2 2 5.58 2 10c0 1.95.86 3.73 2.3 5.09L3.5 19.5c-.13.34.19.68.52.54l4.58-1.99c1.17.34 2.27.45 3.4.45 5.52 0 10-3.58 10-8s-4.48-8-10-8zm2.5 11h-4.24l2.94-3.53H10.5V8.5h4.24L11.8 12.03h2.7v1.47z"/>
38
+ </svg>
39
+ )
40
+ },
41
+ {
42
+ key: 'social_telegram',
43
+ label: 'Kênh Telegram DPTXA',
44
+ hoverColor: '#24A1DE',
45
+ trackingName: 'telegram',
46
+ svg: (
47
+ <svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
48
+ <path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm4.64 6.8c-.15 1.58-.8 5.42-1.13 7.19-.14.75-.42 1-.68 1.03-.58.05-1.02-.38-1.58-.75-.88-.58-1.38-.94-2.23-1.5-1-.65-.35-1 .22-1.58.15-.15 2.72-2.5 2.77-2.71.01-.03.01-.13-.05-.18-.06-.05-.14-.03-.21-.02-.09.02-1.49.95-4.22 2.79-.4.27-.76.41-1.08.4-.36-.01-1.04-.2-1.55-.37-.63-.2-1.12-.31-1.08-.66.02-.18.27-.36.74-.55 2.92-1.27 4.86-2.11 5.83-2.51 2.78-1.16 3.35-1.36 3.73-1.36.08 0 .27.02.39.12.1.08.13.19.14.27-.01.06.01.24 0 .24z"/>
49
+ </svg>
50
+ )
51
+ },
52
+ {
53
+ key: 'social_tiktok',
54
+ label: 'Theo dõi DPTXA trên TikTok',
55
+ hoverColor: '#FE2C55',
56
+ trackingName: 'tiktok',
57
+ svg: (
58
+ <svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
59
+ <path d="M12.525.02c1.31-.02 2.61-.01 3.91-.02.08 1.53.63 3.09 1.75 4.17 1.12 1.11 2.7 1.62 4.24 1.79v4.03c-1.44-.17-2.86-.74-3.95-1.72-.1-.09-.17-.13-.27-.24v6.07c.07 2.13-.53 4.36-2.02 5.92-1.49 1.62-3.8 2.47-5.97 2.4-2.18-.04-4.42-.9-5.74-2.65-1.43-1.84-1.75-4.52-1.01-6.73.68-2.11 2.58-3.79 4.77-4.19 1.12-.22 2.3-.1 3.38.31.02-2.31.01-4.63.01-6.95.03-.45.02-.91.03-1.36-.08-2.34-.05-4.69-.07-7.04z" />
60
+ </svg>
61
+ )
62
+ },
63
+ {
64
+ key: 'social_instagram',
65
+ label: 'Kênh Instagram DPTXA',
66
+ hoverColor: '#E1306C',
67
+ trackingName: 'instagram',
68
+ svg: (
69
+ <svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
70
+ <path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zM12 0C8.741 0 8.333.014 7.053.072 2.695.272.273 2.69.073 7.051.014 8.333 0 8.741 0 12c0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98 1.281.058 1.689.072 4.948.072 3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98C15.668.014 15.259 0 12 0zm0 5.838a6.162 6.162 0 100 12.324 6.162 6.162 0 000-12.324zM12 16a4 4 0 110-8 4 4 0 010 8zm6.406-11.845a1.44 1.44 0 100 2.881 1.44 1.44 0 000-2.881z"/>
71
+ </svg>
72
+ )
73
+ }
74
+ ];
75
+
76
+ return (
77
+ <footer className="relative z-10 border-t border-white/10 bg-[#0f0f0f] mt-auto">
78
+ {/* Dynamic Stagger Fade-Up styles for Premium Aesthetics & Accessibility */}
79
+ <style dangerouslySetInnerHTML={{__html: `
80
+ @keyframes footerSocialFadeUp {
81
+ from {
82
+ opacity: 0;
83
+ transform: translateY(12px);
84
+ }
85
+ to {
86
+ opacity: 1;
87
+ transform: translateY(0);
88
+ }
89
+ }
90
+ .social-link-item {
91
+ opacity: 0;
92
+ animation: footerSocialFadeUp 0.6s cubic-bezier(0.16, 1, 0.3, 1) forwards;
93
+ }
94
+ @media (prefers-reduced-motion: reduce) {
95
+ .social-link-item {
96
+ animation: none !important;
97
+ opacity: 1 !important;
98
+ transform: none !important;
99
+ transition: none !important;
100
+ }
101
+ }
102
+ `}} />
103
+
104
+ <div className="mx-auto max-w-7xl px-4 py-10">
105
+ <div className="grid gap-8 md:grid-cols-4">
106
+ <div>
107
+ <div className="mb-4">
108
+ <a href="/" className="flex items-center shrink-0 transition-transform hover:scale-[1.02] active:scale-95 group">
109
+ <svg className="h-8 w-8" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
110
+ <circle cx="50" cy="50" r="44" stroke="url(#footer-purple-grad)" strokeWidth="5" fill="black" fillOpacity="0.3"/>
111
+ <circle cx="50" cy="50" r="36" stroke="url(#footer-purple-grad)" strokeWidth="1.5" strokeDasharray="3 3" opacity="0.8"/>
112
+ <path d="M44 32.5L68 50L44 67.5V32.5Z" fill="url(#footer-purple-grad)" stroke="url(#footer-purple-grad)" strokeWidth="1" strokeLinejoin="round"/>
113
+ <defs>
114
+ <linearGradient id="footer-purple-grad" x1="0%" y1="0%" x2="100%" y2="100%">
115
+ <stop offset="0%" stopColor="#a78bfa"/>
116
+ <stop offset="50%" stopColor="#7c3aed"/>
117
+ <stop offset="100%" stopColor="#4c1d95"/>
118
+ </linearGradient>
119
+ </defs>
120
+ </svg>
121
+ <div className="flex flex-col ml-2 justify-center leading-none">
122
+ <span className="text-lg font-black tracking-tight text-white flex items-center leading-none">
123
+ D<span className="text-primary">P</span>TXA
124
+ </span>
125
+ <span className="text-[8px] font-bold text-gray-400 mt-1 tracking-wider whitespace-nowrap leading-none">Động Phim Đỉnh Cao</span>
126
+ </div>
127
+ </a>
128
+ </div>
129
+ <p className="text-[10px] font-black uppercase tracking-[0.4em] text-primary mb-3">Động Phim Đỉnh Cao</p>
130
+ <p className="text-sm leading-relaxed text-gray-400">
131
+ Nền tảng xem phim trực tuyến miễn phí với kho phim khổng lồ, cập nhật liên tục mỗi ngày. Trải nghiệm đường truyền siêu tốc, chất lượng Full HD.
132
+ </p>
133
+ </div>
134
+
135
+ <div>
136
+ <h3 className="mb-4 text-xs font-black uppercase tracking-widest text-white">Thể loại</h3>
137
+ <ul className="space-y-3 text-sm text-gray-400">
138
+ <li><a href="/danh-sach/phim-le" className="hover:text-primary transition-colors">Phim lẻ</a></li>
139
+ <li><a href="/danh-sach/phim-bo" className="hover:text-primary transition-colors">Phim bộ</a></li>
140
+ <li><a href="/danh-sach/hoat-hinh" className="hover:text-primary transition-colors">Hoạt hình</a></li>
141
+ </ul>
142
+ </div>
143
+
144
+ <div>
145
+ <h3 className="mb-4 text-xs font-black uppercase tracking-widest text-white">Chính sách</h3>
146
+ <ul className="space-y-3 text-sm text-gray-400">
147
+ <li><a href="/dieu-khoan" className="hover:text-primary transition-colors">Điều khoản sử dụng</a></li>
148
+ <li><a href="/chinh-sach" className="hover:text-primary transition-colors">Chính sách bảo mật</a></li>
149
+ <li><a href="#" className="hover:text-primary transition-colors">DMCA</a></li>
150
+ </ul>
151
+ </div>
152
+
153
+ <div>
154
+ <h3 className="mb-4 text-xs font-black uppercase tracking-widest text-white">Liên hệ</h3>
155
+ <p className="mb-2 text-sm text-gray-400 font-medium">Email: contact@tphimx.com</p>
156
+ <div className="flex flex-wrap gap-4 mt-4">
157
+ {socials
158
+ .filter(social => settings[`${social.key}_enable`] && settings[`${social.key}_url`])
159
+ .map((social, index) => {
160
+ const url = settings[`${social.key}_url`];
161
+ return (
162
+ <a
163
+ key={social.key}
164
+ href={url}
165
+ target="_blank"
166
+ rel="noopener noreferrer"
167
+ className="social-link-item text-gray-400 transition-all duration-300 hover:scale-115 active:scale-90 flex items-center justify-center p-2 rounded-xl bg-white/[0.02] border border-white/5"
168
+ style={{
169
+ '--hover-color': social.hoverColor,
170
+ animationDelay: `${index * 80}ms`
171
+ }}
172
+ onMouseEnter={(e) => {
173
+ e.currentTarget.style.color = social.hoverColor;
174
+ e.currentTarget.style.borderColor = `${social.hoverColor}33`;
175
+ e.currentTarget.style.backgroundColor = `${social.hoverColor}10`;
176
+ e.currentTarget.style.boxShadow = `0 0 15px ${social.hoverColor}22`;
177
+ }}
178
+ onMouseLeave={(e) => {
179
+ e.currentTarget.style.color = '';
180
+ e.currentTarget.style.borderColor = '';
181
+ e.currentTarget.style.backgroundColor = '';
182
+ e.currentTarget.style.boxShadow = '';
183
+ }}
184
+ data-txatooltip={social.label}
185
+ data-social={social.trackingName}
186
+ >
187
+ {social.svg}
188
+ </a>
189
+ );
190
+ })}
191
+ </div>
192
+ </div>
193
+ </div>
194
+
195
+ <div className="mt-12 flex flex-col items-center justify-between gap-4 border-t border-white/5 pt-8 text-[10px] font-black uppercase tracking-widest text-gray-600 md:flex-row">
196
+ <p className="flex items-center gap-2">
197
+ <Film size={14} /> &copy; {copyrightYear} DPTXA - Động Phim Đỉnh Cao
198
+ </p>
199
+ <p className="flex items-center gap-2">
200
+ Made with <Heart size={12} className="text-primary animate-pulse" /> by DPTXA Team
201
+ </p>
202
+ </div>
203
+ </div>
204
+ </footer>
205
+ );
206
+ }
src/components/Header.astro ADDED
@@ -0,0 +1,677 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ import { txasupabase } from '../lib/txasupabase.js';
3
+
4
+ const genres = await txasupabase.getGenres().catch(() => []);
5
+ const countries = await txasupabase.getCountries().catch(() => []);
6
+
7
+ const navLinks = [
8
+ { href: '/', label: 'Trang chủ' },
9
+ { href: '/danh-sach/phim-le', label: 'Phim Lẻ' },
10
+ { href: '/danh-sach/phim-bo', label: 'Phim Bộ' },
11
+ ];
12
+ ---
13
+
14
+ <header
15
+ id="main-header"
16
+ class="fixed top-0 left-0 right-0 z-[100] transition-all duration-500 bg-gradient-to-b from-black/35 via-transparent to-transparent"
17
+ >
18
+ <div id="header-inner" class="w-full flex items-center justify-between px-4 md:px-6 xl:px-10 py-4 md:py-6 transition-all duration-300">
19
+
20
+ <!-- Left: Logo & Search -->
21
+ <div class="flex items-center gap-2 md:gap-4 lg:gap-6 flex-1 min-w-0">
22
+ <button id="menu-drawer-trigger" class="xl:hidden text-white hover:text-primary transition-all text-lg p-2.5 rounded-xl hover:bg-white/5 cursor-pointer shrink-0 flex items-center justify-center" aria-label="Mở menu">
23
+ <i class="fas fa-bars"></i>
24
+ </button>
25
+ <a href="/" class="flex items-center shrink-0 transition-transform hover:scale-[1.02] active:scale-95 group">
26
+ <!-- SVG Play Circle Logo (Matching TPHIMX Premium Theme) -->
27
+ <svg class="h-9 md:h-10 w-auto animate-pulse" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg" style="height: 42px; width: 42px;">
28
+ <circle cx="50" cy="50" r="44" stroke="url(#purple-grad)" stroke-width="5" fill="black" fill-opacity="0.3"/>
29
+ <circle cx="50" cy="50" r="36" stroke="url(#purple-grad)" stroke-width="1.5" stroke-dasharray="3 3" opacity="0.8"/>
30
+ <path d="M44 32.5L68 50L44 67.5V32.5Z" fill="url(#purple-grad)" stroke="url(#purple-grad)" stroke-width="1" stroke-linejoin="round"/>
31
+ <defs>
32
+ <linearGradient id="purple-grad" x1="0%" y1="0%" x2="100%" y2="100%">
33
+ <stop offset="0%" stop-color="#a78bfa"/>
34
+ <stop offset="50%" stop-color="#7c3aed"/>
35
+ <stop offset="100%" stop-color="#4c1d95"/>
36
+ </linearGradient>
37
+ </defs>
38
+ </svg>
39
+ <div class="flex flex-col ml-2.5 justify-center leading-none">
40
+ <span class="text-lg md:text-[22px] font-black tracking-tight text-white flex items-center leading-none">
41
+ D<span class="text-primary">P</span>TXA
42
+ </span>
43
+ <span class="hidden sm:inline-block text-[8px] font-black text-gray-400 mt-2 tracking-widest uppercase leading-none">Động Phim Đỉnh Cao</span>
44
+ </div>
45
+ </a>
46
+
47
+ <!-- Search Bar (Pill-shaped, next to Logo) -->
48
+ <div class="relative hidden xl:block w-40 focus-within:w-52 xl:w-56 xl:focus-within:w-72 transition-all duration-300 ease-out group shrink-0">
49
+ <form id="header-search-form" class="flex items-center h-9 rounded-full bg-white/[0.06] border border-white/[0.08] px-3.5 focus-within:bg-white/10 focus-within:border-white/15 transition-all cursor-pointer w-full">
50
+ <i class="fas fa-search text-[11px] text-gray-400 shrink-0 select-none"></i>
51
+ <input
52
+ type="text"
53
+ id="header-search-input"
54
+ placeholder="Tìm phim, diễn viên..."
55
+ class="flex-1 bg-transparent text-[11px] font-bold text-white outline-none placeholder:text-gray-400/40 min-w-0 ml-2 transition-all duration-300"
56
+ autocomplete="off"
57
+ />
58
+ <span id="search-shortcut-badge" class="hidden sm:inline-flex items-center justify-center text-[8px] font-black text-gray-400 bg-white/10 border border-white/5 px-2 py-0.5 rounded ml-2 shrink-0 leading-none group-focus-within:hidden select-none">/</span>
59
+ </form>
60
+ <div id="search-suggestions" class="absolute left-0 right-0 top-full mt-2 max-h-[400px] overflow-y-auto hidden rounded-2xl border border-white/10 p-2 shadow-2xl glass-dropdown z-[120] scrollbar-thin">
61
+ <div id="suggestions-content" class="flex flex-col gap-1"></div>
62
+ </div>
63
+ </div>
64
+ </div>
65
+
66
+ <div class="flex items-center gap-4 xl:gap-6 shrink-0">
67
+ <!-- Desktop Navigation Menu (Inline in Header!) -->
68
+ <nav class="hidden xl:flex items-center gap-1 xl:gap-2 2xl:gap-3 text-[11px] xl:text-[13px] 2xl:text-[14px] font-black uppercase tracking-wider text-gray-300">
69
+ <a href="/chu-de" data-astro-prefetch class="px-2 xl:px-3 py-1.5 xl:py-2 hover:text-white rounded-xl hover:bg-white/[0.06] transition-all whitespace-nowrap">Chủ đề</a>
70
+
71
+ <div class="relative group">
72
+ <button class="flex items-center gap-1 px-2 xl:px-3 py-1.5 xl:py-2 hover:text-white rounded-xl hover:bg-white/[0.06] transition-all cursor-pointer whitespace-nowrap">
73
+ Thể loại <i class="fas fa-caret-down text-[10px] opacity-50 ml-0.5"></i>
74
+ </button>
75
+ <div class="absolute left-1/2 -translate-x-1/2 top-[80%] pt-4 w-[560px] opacity-0 translate-y-1 pointer-events-none group-hover:opacity-100 group-hover:translate-y-0 group-hover:pointer-events-auto transition-all duration-300 z-[120]">
76
+ <div class="glass-dropdown rounded-3xl p-5 border border-white/10 shadow-2xl grid grid-cols-4 gap-1.5 max-h-[380px] overflow-y-auto scrollbar-thin">
77
+ {genres.length > 0 ? genres.map((g: any) => (
78
+ <a href={`/the-loai/${g.slug}`} class="px-3.5 py-2.5 rounded-xl text-[12px] font-bold text-gray-400 hover:bg-white/5 hover:text-white transition-all truncate">{g.name}</a>
79
+ )) : (
80
+ <span class="col-span-4 text-center text-gray-600 text-[12px] py-4">Chưa có thể loại</span>
81
+ )}
82
+ </div>
83
+ </div>
84
+ </div>
85
+
86
+ <a href="/danh-sach/phim-le" data-astro-prefetch class="px-2 xl:px-3 py-1.5 xl:py-2 hover:text-white rounded-xl hover:bg-white/[0.06] transition-all whitespace-nowrap">Phim Lẻ</a>
87
+ <a href="/danh-sach/phim-bo" data-astro-prefetch class="px-2 xl:px-3 py-1.5 xl:py-2 hover:text-white rounded-xl hover:bg-white/[0.06] transition-all whitespace-nowrap">Phim Bộ</a>
88
+
89
+ <a href="#" id="xem-chung-btn" class="flex items-center gap-1 xl:gap-1.5 px-2 xl:px-3 py-1.5 xl:py-2 hover:text-white rounded-xl hover:bg-white/[0.06] transition-all whitespace-nowrap group" data-txatooltip="Xem phim cùng bạn bè thời gian thực - Sắp ra mắt!">
90
+ Xem Chung
91
+ <span class="bg-gradient-to-r from-violet-500 to-fuchsia-500 text-white text-[8px] font-black px-1.5 py-0.5 rounded shadow-[0_0_12px_rgba(168,85,247,0.5)] tracking-wide uppercase leading-none scale-90 origin-left">Sắp ra mắt</span>
92
+ </a>
93
+ <div class="relative group">
94
+ <button class="flex items-center gap-1 px-2 xl:px-3 py-1.5 xl:py-2 hover:text-white rounded-xl hover:bg-white/[0.06] transition-all cursor-pointer whitespace-nowrap">
95
+ Quốc gia <i class="fas fa-caret-down text-[10px] opacity-50 ml-0.5"></i>
96
+ </button>
97
+ <div class="absolute left-1/2 -translate-x-1/2 top-[80%] pt-4 w-[460px] opacity-0 translate-y-1 pointer-events-none group-hover:opacity-100 group-hover:translate-y-0 group-hover:pointer-events-auto transition-all duration-300 z-[120]">
98
+ <div class="glass-dropdown rounded-3xl p-5 border border-white/10 shadow-2xl grid grid-cols-3 gap-1.5 max-h-[380px] overflow-y-auto scrollbar-thin">
99
+ {countries.length > 0 ? countries.map((c: any) => (
100
+ <a href={`/quoc-gia/${c.slug}`} class="px-3.5 py-2.5 rounded-xl text-[12px] font-bold text-gray-400 hover:bg-white/5 hover:text-white transition-all truncate">{c.name}</a>
101
+ )) : (
102
+ <span class="col-span-3 text-center text-gray-600 text-[12px] py-4">Chưa có quốc gia</span>
103
+ )}
104
+ </div>
105
+ </div>
106
+ </div>
107
+
108
+ <a href="/dien-vien" class="px-2 xl:px-3 py-1.5 xl:py-2 hover:text-white rounded-xl hover:bg-white/[0.06] transition-all whitespace-nowrap">Diễn viên</a>
109
+ <a href="/lich-chieu" class="px-2 xl:px-3 py-1.5 xl:py-2 hover:text-white rounded-xl hover:bg-white/[0.06] transition-all whitespace-nowrap">Lịch chiếu</a>
110
+ </nav>
111
+
112
+ <!-- Auth + Mobile Buttons -->
113
+ <div class="flex items-center gap-3 shrink-0">
114
+ <div id="auth-container" class="relative group">
115
+ <div id="auth-loading" class="flex items-center gap-3">
116
+ <div class="h-8 w-24 animate-pulse rounded-lg bg-white/5"></div>
117
+ </div>
118
+ <div id="auth-guest" class="hidden">
119
+ <a href="/dang-nhap" data-astro-prefetch class="flex items-center justify-center gap-2 rounded-full bg-white/[0.08] border border-white/[0.08] px-5 py-2 text-xs font-black text-white hover:bg-white hover:text-black hover:border-white transition-all">
120
+ <i class="fas fa-user text-xs"></i>
121
+ Thành viên
122
+ </a>
123
+ </div>
124
+ <div id="auth-user" class="hidden flex items-center gap-3 cursor-pointer group">
125
+ <div class="hidden sm:flex flex-col items-end leading-none">
126
+ <span class="text-[9.5px] font-black uppercase tracking-widest text-gray-500 mb-1">Thành viên</span>
127
+ <span id="user-display-name" class="text-[13px] font-bold text-white group-hover:text-primary transition-colors">Admin</span>
128
+ </div>
129
+ <div class="relative">
130
+ <div id="user-avatar" class="h-9 w-9 rounded-xl bg-gradient-to-br from-primary to-primary-dark flex items-center justify-center text-white font-black shadow-lg ring-2 ring-white/10 overflow-hidden">
131
+ <img id="user-avatar-img" src="" class="h-full w-full object-cover hidden" />
132
+ <span id="user-avatar-text" class="text-xs">A</span>
133
+ </div>
134
+ <div class="absolute right-0 top-full pt-3 w-64 translate-y-2 opacity-0 pointer-events-none group-hover:translate-y-0 group-hover:opacity-100 group-hover:pointer-events-auto transition-all duration-300 z-[110]">
135
+ <div class="glass-dropdown rounded-2xl p-2 border border-white/10 shadow-2xl overflow-hidden">
136
+ <div class="px-4 py-3.5 border-b border-white/5 mb-1.5">
137
+ <div id="user-email" class="text-xs font-semibold text-gray-500 truncate">user@example.com</div>
138
+ </div>
139
+ <a href="/ca-nhan/yeu-thich" class="flex items-center gap-3 px-4 py-3 rounded-xl text-xs font-bold text-gray-400 hover:bg-white/5 hover:text-white transition-all"><i class="fas fa-heart text-[15px] text-primary/60"></i> Phim yêu thích</a>
140
+ <a href="/ca-nhan/lich-su" class="flex items-center gap-3 px-4 py-3 rounded-xl text-xs font-bold text-gray-400 hover:bg-white/5 hover:text-white transition-all"><i class="fas fa-history text-[15px] text-primary/60"></i> Lịch sử xem</a>
141
+ <div id="admin-link-container"></div>
142
+ <button id="logout-btn" class="w-full flex items-center gap-3 px-4 py-3 rounded-xl text-xs font-bold text-gray-400 hover:bg-red-500/10 hover:text-red-500 transition-all"><i class="fas fa-sign-out-alt text-[15px]"></i> Đăng xuất</button>
143
+ </div>
144
+ </div>
145
+ </div>
146
+ </div>
147
+ </div>
148
+
149
+ <!-- Mobile triggers -->
150
+ <div class="flex items-center gap-2 xl:hidden">
151
+ <button id="mobile-search-trigger" class="text-gray-400 hover:text-white h-9 w-9 flex items-center justify-center rounded-lg bg-white/5"><i class="fas fa-search text-xs"></i></button>
152
+ </div>
153
+ </div>
154
+ </div>
155
+ </div>
156
+
157
+ <!-- Mobile Search Overlay -->
158
+ <div id="mobile-search-overlay" class="fixed inset-0 z-[110] hidden bg-black/95 backdrop-blur-3xl animate-in fade-in duration-300">
159
+ <div class="p-6">
160
+ <div class="flex items-center justify-between mb-8">
161
+ <div class="text-2xl font-black tracking-tighter text-white">D<span class="text-primary">P</span>TXA</div>
162
+ <button id="mobile-search-close" class="h-12 w-12 flex items-center justify-center rounded-full bg-white/5 text-white">
163
+ <i class="fas fa-times text-xl"></i>
164
+ </button>
165
+ </div>
166
+ <form id="mobile-search-form" class="relative">
167
+ <input
168
+ type="text"
169
+ id="mobile-search-input"
170
+ placeholder="Nhập tên phim bạn muốn tìm..."
171
+ class="w-full bg-white/5 border border-white/10 rounded-2xl px-6 py-5 text-lg font-bold text-white outline-none focus:border-primary/50 focus:bg-white/10 transition-all"
172
+ autocomplete="off"
173
+ />
174
+ <button type="submit" class="absolute right-4 top-1/2 -translate-y-1/2 h-12 w-12 flex items-center justify-center rounded-xl bg-primary text-white shadow-[0_0_20px_var(--primary)]">
175
+ <i class="fas fa-search"></i>
176
+ </button>
177
+ </form>
178
+ <!-- Mobile Search Suggestions -->
179
+ <div id="mobile-search-suggestions" class="mt-6 max-h-[calc(100vh-220px)] overflow-y-auto hidden rounded-3xl border border-white/10 p-2 glass-dropdown">
180
+ <div id="mobile-suggestions-content" class="flex flex-col gap-1"></div>
181
+ </div>
182
+ </div>
183
+ </div>
184
+
185
+ <!-- Slide-out Navigation Drawer (Glassmorphic) -->
186
+ <div
187
+ id="menu-drawer"
188
+ class="fixed inset-y-0 left-0 w-80 max-w-[85vw] z-[120] -translate-x-full hidden flex-col h-full max-h-screen overflow-y-auto scrollbar-hide bg-black/95 backdrop-blur-3xl border-r border-white/10 p-6 transition-transform duration-300 ease-out overscroll-contain"
189
+ style="-webkit-overflow-scrolling: touch;"
190
+ >
191
+ <!-- Close button & Logo -->
192
+ <div class="flex items-center justify-between mb-6 pb-4 border-b border-white/5 shrink-0">
193
+ <div class="text-[17px] font-black tracking-tight text-white flex items-center">
194
+ D<span class="text-primary mr-0.5">P</span>TXA
195
+ </div>
196
+ <button id="menu-drawer-close" class="h-8 w-8 flex items-center justify-center rounded-xl bg-white/5 text-white hover:bg-white/10 transition-all cursor-pointer">
197
+ <i class="fas fa-times text-sm"></i>
198
+ </button>
199
+ </div>
200
+
201
+ <!-- Navigation Links inside Drawer -->
202
+ <div class="space-y-1.5 pr-1">
203
+ <a href="/" class="flex items-center gap-3 rounded-xl hover:bg-white/5 px-4 py-2.5 text-xs font-bold uppercase tracking-wider text-gray-300 hover:text-white transition-all">
204
+ <i class="fas fa-home text-sm text-primary/60 w-5"></i> Trang chủ
205
+ </a>
206
+ <a href="/chu-de" class="flex items-center gap-3 rounded-xl hover:bg-white/5 px-4 py-2.5 text-xs font-bold uppercase tracking-wider text-gray-300 hover:text-white transition-all">
207
+ <i class="fas fa-compass text-sm text-primary/60 w-5"></i> Chủ đề
208
+ </a>
209
+ <a href="/danh-sach/phim-le" class="flex items-center gap-3 rounded-xl hover:bg-white/5 px-4 py-2.5 text-xs font-bold uppercase tracking-wider text-gray-300 hover:text-white transition-all">
210
+ <i class="fas fa-film text-sm text-primary/60 w-5"></i> Phim Lẻ
211
+ </a>
212
+ <a href="/danh-sach/phim-bo" class="flex items-center gap-3 rounded-xl hover:bg-white/5 px-4 py-2.5 text-xs font-bold uppercase tracking-wider text-gray-300 hover:text-white transition-all">
213
+ <i class="fas fa-tv text-sm text-primary/60 w-5"></i> Phim Bộ
214
+ </a>
215
+ <a href="#" id="drawer-xem-chung-btn" class="flex items-center justify-between rounded-xl hover:bg-white/5 px-4 py-2.5 text-xs font-bold uppercase tracking-wider text-gray-300 hover:text-white transition-all group">
216
+ <span class="flex items-center gap-3">
217
+ <i class="fas fa-users text-sm text-primary/60 w-5"></i> Xem Chung
218
+ </span>
219
+ <span class="bg-gradient-to-r from-violet-500 to-fuchsia-500 text-white text-[8px] font-black px-1.5 py-0.5 rounded leading-none">Sắp ra mắt</span>
220
+ </a>
221
+ <a href="/dien-vien" class="flex items-center gap-3 rounded-xl hover:bg-white/5 px-4 py-2.5 text-xs font-bold uppercase tracking-wider text-gray-300 hover:text-white transition-all">
222
+ <i class="fas fa-user-friends text-sm text-primary/60 w-5"></i> Diễn viên
223
+ </a>
224
+ <a href="/lich-chieu" class="flex items-center gap-3 rounded-xl hover:bg-white/5 px-4 py-2.5 text-xs font-bold uppercase tracking-wider text-gray-300 hover:text-white transition-all">
225
+ <i class="fas fa-calendar-alt text-sm text-primary/60 w-5"></i> Lịch chiếu
226
+ </a>
227
+
228
+ <!-- Dividers for lists -->
229
+ <div class="h-px bg-white/5 my-4"></div>
230
+
231
+ <!-- Countries list -->
232
+ <div class="px-4 py-2 text-[10px] font-black tracking-widest text-gray-500 uppercase">Quốc gia</div>
233
+ <div class="grid grid-cols-2 gap-1.5 px-2">
234
+ {countries.length > 0 ? countries.map((c: any) => (
235
+ <a href={`/quoc-gia/${c.slug}`} class="px-2.5 py-2 rounded-lg text-[11px] font-bold text-gray-400 hover:bg-white/5 hover:text-white transition-all truncate">{c.name}</a>
236
+ )) : (
237
+ <span class="col-span-2 text-center text-gray-600 text-[11px] py-2">Chưa có quốc gia</span>
238
+ )}
239
+ </div>
240
+
241
+ <!-- Genres list -->
242
+ <div class="h-px bg-white/5 my-4"></div>
243
+ <div class="px-4 py-2 text-[10px] font-black tracking-widest text-gray-500 uppercase">Thể loại</div>
244
+ <div class="grid grid-cols-2 gap-1.5 px-2 pb-6">
245
+ {genres.length > 0 ? genres.map((g: any) => (
246
+ <a href={`/the-loai/${g.slug}`} class="px-2.5 py-2 rounded-lg text-[11px] font-bold text-gray-400 hover:bg-white/5 hover:text-white transition-all truncate">{g.name}</a>
247
+ )) : (
248
+ <span class="col-span-2 text-center text-gray-600 text-[11px] py-2">Chưa có thể loại</span>
249
+ )}
250
+ </div>
251
+ </div>
252
+ </div>
253
+
254
+ <!-- Drawer Backdrop Overlay -->
255
+ <div
256
+ id="drawer-backdrop"
257
+ class="fixed inset-0 z-[115] bg-black/60 backdrop-blur-sm hidden opacity-0 transition-opacity duration-300"
258
+ ></div>
259
+ </header>
260
+
261
+ <script>
262
+ function initHeader() {
263
+ const header = document.getElementById('main-header');
264
+ if (!header || header.dataset.initialized) return;
265
+ header.dataset.initialized = 'true';
266
+
267
+ const drawerTrigger = document.getElementById('menu-drawer-trigger');
268
+ const drawer = document.getElementById('menu-drawer');
269
+ const backdrop = document.getElementById('drawer-backdrop');
270
+ const drawerClose = document.getElementById('menu-drawer-close');
271
+ const searchForm = document.getElementById('header-search-form');
272
+ const searchInput = document.getElementById('header-search-input');
273
+
274
+ // Shortcut badge label detection
275
+ const shortcutBadge = document.getElementById('search-shortcut-badge');
276
+ const isMac = navigator.userAgent.toLowerCase().includes('mac');
277
+ if (shortcutBadge) {
278
+ shortcutBadge.textContent = isMac ? '⌘K' : '/';
279
+ }
280
+
281
+ // Global Keydown search shortcut focus
282
+ if (!window._hasSearchShortcut) {
283
+ window._hasSearchShortcut = true;
284
+ window.addEventListener('keydown', (e) => {
285
+ const activeEl = document.activeElement;
286
+ if (activeEl && (activeEl.tagName === 'INPUT' || activeEl.tagName === 'TEXTAREA' || activeEl.hasAttribute('contenteditable'))) {
287
+ return;
288
+ }
289
+
290
+ const isMacUser = navigator.userAgent.toLowerCase().includes('mac');
291
+ const sInput = document.getElementById('header-search-input');
292
+
293
+ if ((isMacUser && e.metaKey && e.key.toLowerCase() === 'k') || (!isMacUser && e.key === '/')) {
294
+ e.preventDefault();
295
+ sInput?.focus();
296
+ sInput?.select();
297
+ }
298
+ });
299
+ }
300
+
301
+ if (!header) return;
302
+
303
+ // Scroll Effect
304
+ const headerInner = document.getElementById('header-inner');
305
+ const handleScroll = () => {
306
+ if (window.scrollY > 30) {
307
+ header.classList.add('glass-scrolled', 'shadow-2xl');
308
+ header.classList.remove('bg-gradient-to-b', 'from-black/35', 'via-transparent', 'to-transparent');
309
+ headerInner?.classList.add('py-3');
310
+ headerInner?.classList.remove('py-4', 'md:py-6');
311
+ } else {
312
+ header.classList.remove('glass-scrolled', 'shadow-2xl');
313
+ header.classList.add('bg-gradient-to-b', 'from-black/35', 'via-transparent', 'to-transparent');
314
+ headerInner?.classList.add('py-4', 'md:py-6');
315
+ headerInner?.classList.remove('py-3');
316
+ }
317
+ };
318
+
319
+ window.addEventListener('scroll', handleScroll);
320
+ handleScroll(); // Init state
321
+
322
+ // Drawer Xem Chung click handling with txatoast
323
+ const drawerXemChungBtn = document.getElementById('drawer-xem-chung-btn');
324
+ drawerXemChungBtn?.addEventListener('click', (e) => {
325
+ e.preventDefault();
326
+ const { txatoast } = window;
327
+ if (txatoast) {
328
+ txatoast.info('Tính năng Xem Chung phim trực tuyến đang được phát triển và sẽ sớm ra mắt!');
329
+ } else {
330
+ alert('Tính năng Xem Chung phim trực tuyến sắp ra mắt!');
331
+ }
332
+ });
333
+
334
+ // Auto-focus search input when search form is clicked
335
+ searchForm?.addEventListener('click', () => {
336
+ searchInput?.focus();
337
+ });
338
+
339
+ // Drawer Open & Close Animation Logic
340
+ const openDrawer = () => {
341
+ drawer?.classList.remove('hidden');
342
+ backdrop?.classList.remove('hidden');
343
+ setTimeout(() => {
344
+ drawer?.classList.remove('-translate-x-full');
345
+ backdrop?.classList.add('opacity-100');
346
+ }, 10);
347
+ document.body.style.overflow = 'hidden';
348
+ };
349
+
350
+ const closeDrawer = () => {
351
+ drawer?.classList.add('-translate-x-full');
352
+ backdrop?.classList.remove('opacity-100');
353
+ setTimeout(() => {
354
+ drawer?.classList.add('hidden');
355
+ backdrop?.classList.add('hidden');
356
+ }, 300);
357
+ document.body.style.overflow = '';
358
+ };
359
+
360
+ drawerTrigger?.addEventListener('click', openDrawer);
361
+ drawerClose?.addEventListener('click', closeDrawer);
362
+ backdrop?.addEventListener('click', closeDrawer);
363
+
364
+ // Mobile Search Overlay Logic
365
+ const mobileSearchTrigger = document.getElementById('mobile-search-trigger');
366
+ const mobileSearchOverlay = document.getElementById('mobile-search-overlay');
367
+ const mobileSearchClose = document.getElementById('mobile-search-close');
368
+ const mobileSearchForm = document.getElementById('mobile-search-form');
369
+ const mobileSearchInput = document.getElementById('mobile-search-input');
370
+
371
+ mobileSearchTrigger?.addEventListener('click', () => {
372
+ mobileSearchOverlay?.classList.remove('hidden');
373
+ document.body.style.overflow = 'hidden';
374
+ setTimeout(() => mobileSearchInput?.focus(), 300);
375
+ });
376
+
377
+ mobileSearchClose?.addEventListener('click', () => {
378
+ mobileSearchOverlay?.classList.add('hidden');
379
+ if (drawer?.classList.contains('hidden')) {
380
+ document.body.style.overflow = '';
381
+ }
382
+ });
383
+
384
+ mobileSearchForm?.addEventListener('submit', (e) => {
385
+ e.preventDefault();
386
+ const q = mobileSearchInput?.value.trim();
387
+ if (q) window.location.href = `/tim-kiem?q=${encodeURIComponent(q)}`;
388
+ });
389
+
390
+ // Search Suggestions and Logic
391
+ const searchSuggestions = document.getElementById('search-suggestions');
392
+ const suggestionsContent = document.getElementById('suggestions-content');
393
+ const mobileSearchSuggestions = document.getElementById('mobile-search-suggestions');
394
+ const mobileSuggestionsContent = document.getElementById('mobile-suggestions-content');
395
+
396
+ function debounce(func, wait) {
397
+ let timeout;
398
+ return function(...args) {
399
+ clearTimeout(timeout);
400
+ timeout = setTimeout(() => func.apply(this, args), wait);
401
+ };
402
+ }
403
+
404
+ const formatImageUrl = (url) => {
405
+ if (!url) return '';
406
+ if (url.startsWith('http')) return url;
407
+ const cleanUrl = url.startsWith('/') ? url : `/${url}`;
408
+ return `https://phimimg.com${cleanUrl}`;
409
+ };
410
+
411
+ function highlightMatch(text, query) {
412
+ if (!query || !text) return text;
413
+
414
+ const normalize = (str) => {
415
+ return str
416
+ .normalize("NFD")
417
+ .replace(/[\u0300-\u036f]/g, "")
418
+ .toLowerCase()
419
+ .replace(/đ/g, "d");
420
+ };
421
+
422
+ const normalizedText = normalize(text);
423
+ const normalizedQuery = normalize(query);
424
+
425
+ let result = "";
426
+ let lastIdx = 0;
427
+ let idx = normalizedText.indexOf(normalizedQuery);
428
+
429
+ if (idx === -1) return text;
430
+
431
+ while (idx !== -1) {
432
+ result += text.substring(lastIdx, idx);
433
+ result += `<span class="text-primary font-black">${text.substring(idx, idx + query.length)}</span>`;
434
+ lastIdx = idx + query.length;
435
+ idx = normalizedText.indexOf(normalizedQuery, lastIdx);
436
+ }
437
+
438
+ result += text.substring(lastIdx);
439
+ return result;
440
+ }
441
+
442
+ const performSearch = async (query, container, dropdown) => {
443
+ const q = query.trim();
444
+ if (!q) {
445
+ dropdown?.classList.add('hidden');
446
+ if (container) container.innerHTML = '';
447
+ return;
448
+ }
449
+
450
+ try {
451
+ const res = await fetch(`/api/movie/search?q=${encodeURIComponent(q)}`);
452
+ const { items } = await res.json();
453
+
454
+ if (!items || items.length === 0) {
455
+ if (container) {
456
+ container.innerHTML = `
457
+ <div class="p-4 text-center text-xs font-black uppercase tracking-widest text-gray-500">
458
+ Không tìm thấy phim nào
459
+ </div>
460
+ `;
461
+ }
462
+ dropdown?.classList.remove('hidden');
463
+ return;
464
+ }
465
+
466
+ if (container) {
467
+ container.innerHTML = items.slice(0, 5).map((movie: any) => {
468
+ const poster = formatImageUrl(movie.poster_url || movie.thumb_url);
469
+ const titleHtml = highlightMatch(movie.name, q);
470
+ const originHtml = highlightMatch(movie.origin_name, q);
471
+ const status = movie.episode_current || 'HD';
472
+ const year = movie.year || '';
473
+ const type = movie.type === 'single' ? 'Phim Lẻ' : 'Phim Bộ';
474
+
475
+ return `
476
+ <a href="/phim/${movie.slug}" class="flex items-center gap-3 p-2 rounded-2xl hover:bg-white/5 transition-all group">
477
+ <div class="h-14 w-10 overflow-hidden rounded-xl border border-white/10 shrink-0">
478
+ <img src="${poster}" class="h-full w-full object-cover group-hover:scale-105 transition-transform" />
479
+ </div>
480
+ <div class="min-w-0 flex-1">
481
+ <div class="text-[11px] font-black uppercase text-white truncate group-hover:text-primary transition-colors">${titleHtml}</div>
482
+ <div class="text-[9px] font-bold text-gray-500 truncate">${originHtml}</div>
483
+ <div class="flex items-center gap-2 mt-1">
484
+ <span class="text-[8px] font-black uppercase text-primary px-1.5 py-0.5 rounded bg-primary/10 border border-primary/20">${status}</span>
485
+ ${year ? `<span class="text-[8px] font-bold text-gray-400">${year}</span>` : ''}
486
+ <span class="text-[8px] font-bold text-gray-500">${type}</span>
487
+ </div>
488
+ </div>
489
+ <i class="fas fa-chevron-right text-[8px] text-gray-600 group-hover:text-primary transition-colors mr-2"></i>
490
+ </a>
491
+ `;
492
+ }).join('');
493
+ }
494
+ dropdown?.classList.remove('hidden');
495
+ } catch (err) {
496
+ console.error('Search suggestion error:', err);
497
+ }
498
+ };
499
+
500
+ const debouncedDesktopSearch = debounce((q: string) => {
501
+ performSearch(q, suggestionsContent, searchSuggestions);
502
+ }, 150);
503
+
504
+ const debouncedMobileSearch = debounce((q) => {
505
+ performSearch(q, mobileSuggestionsContent, mobileSearchSuggestions);
506
+ }, 150);
507
+
508
+ searchInput?.addEventListener('input', (e) => {
509
+ debouncedDesktopSearch(e.target.value);
510
+ });
511
+
512
+ mobileSearchInput?.addEventListener('input', (e) => {
513
+ debouncedMobileSearch(e.target.value);
514
+ });
515
+
516
+ // Hide dropdown when clicking outside
517
+ document.addEventListener('click', (e) => {
518
+ if (!searchForm?.contains(e.target) && !searchSuggestions?.contains(e.target)) {
519
+ searchSuggestions?.classList.add('hidden');
520
+ }
521
+ });
522
+
523
+ // Focus triggers again if there's value
524
+ searchInput?.addEventListener('focus', () => {
525
+ if (searchInput.value.trim()) {
526
+ searchSuggestions?.classList.remove('hidden');
527
+ }
528
+ });
529
+
530
+ searchForm?.addEventListener('submit', (e) => {
531
+ e.preventDefault();
532
+ const query = searchInput?.value.trim();
533
+ if (query) {
534
+ window.location.href = `/tim-kiem?q=${encodeURIComponent(query)}`;
535
+ }
536
+ });
537
+
538
+ // Close menu on link click inside drawer
539
+ drawer?.querySelectorAll('a').forEach(link => {
540
+ link.addEventListener('click', closeDrawer);
541
+ });
542
+ }
543
+
544
+ async function checkAuth() {
545
+ const authContainer = document.getElementById('auth-container');
546
+ if (!authContainer || authContainer.dataset.initialized) return;
547
+ authContainer.dataset.initialized = 'true';
548
+
549
+ const loading = document.getElementById('auth-loading');
550
+ const guest = document.getElementById('auth-guest');
551
+ const userEl = document.getElementById('auth-user');
552
+ const mobileContainer = document.getElementById('mobile-auth-menu');
553
+
554
+ try {
555
+ const res = await fetch('/api/auth/me');
556
+ const { user } = await res.json();
557
+
558
+ if (loading) loading.classList.add('hidden');
559
+
560
+ if (user) {
561
+ // Desktop UI
562
+ if (userEl) {
563
+ userEl.classList.remove('hidden');
564
+ const nameEl = document.getElementById('user-display-name');
565
+ const emailEl = document.getElementById('user-email');
566
+ const avatarText = document.getElementById('user-avatar-text');
567
+ const adminContainer = document.getElementById('admin-link-container');
568
+
569
+ if (nameEl) nameEl.textContent = user.username;
570
+ if (emailEl) emailEl.textContent = user.email || `${user.username}@tphimx.com`;
571
+
572
+ const avatarImg = document.getElementById('user-avatar-img');
573
+ if (user.avatar_url && avatarImg) {
574
+ avatarImg.src = user.avatar_url;
575
+ avatarImg.classList.remove('hidden');
576
+ avatarText?.classList.add('hidden');
577
+ } else if (avatarText) {
578
+ avatarText.textContent = user.username[0].toUpperCase();
579
+ avatarText.classList.remove('hidden');
580
+ avatarImg?.classList.add('hidden');
581
+ }
582
+
583
+ if (user.role === 'admin' && adminContainer) {
584
+ adminContainer.innerHTML = `
585
+ <a href="/admin" class="flex items-center gap-3 px-4 py-3 rounded-xl text-xs font-bold text-primary hover:bg-primary/10 transition-all">
586
+ <i class="fas fa-user-shield text-[15px]"></i>
587
+ Bảng quản trị
588
+ </a>
589
+ `;
590
+ }
591
+
592
+ document.getElementById('logout-btn')?.addEventListener('click', async () => {
593
+ await fetch('/api/auth/logout', { method: 'POST' });
594
+ window.location.reload();
595
+ });
596
+ }
597
+
598
+ // Mobile UI
599
+ if (mobileContainer) {
600
+ mobileContainer.innerHTML = `
601
+ <div class="w-full flex flex-col gap-4">
602
+ <div class="flex items-center justify-between rounded-3xl bg-white/5 p-6 border border-white/10">
603
+ <div class="flex items-center gap-4">
604
+ ${user.avatar_url ? `
605
+ <div class="h-14 w-14 rounded-2xl overflow-hidden shadow-[0_0_20px_var(--primary)] ring-2 ring-white/10 shrink-0">
606
+ <img src="${user.avatar_url}" class="h-full w-full object-cover" />
607
+ </div>
608
+ ` : `
609
+ <div class="h-14 w-14 rounded-2xl bg-primary flex items-center justify-center text-2xl font-black text-white shadow-[0_0_20px_var(--primary)] shrink-0">${user.username[0].toUpperCase()}</div>
610
+ `}
611
+ <div class="flex flex-col">
612
+ <span class="text-[10px] font-black uppercase tracking-widest text-gray-500">Thành viên TPHIMX</span>
613
+ <span class="text-lg font-black uppercase text-white">${user.username}</span>
614
+ </div>
615
+ </div>
616
+ </div>
617
+ <div class="grid grid-cols-2 gap-3">
618
+ <a href="/ca-nhan/yeu-thich" class="flex flex-col items-center gap-3 rounded-3xl bg-white/5 p-6 border border-white/10 text-[10px] font-black uppercase tracking-widest text-gray-400">
619
+ <i class="fas fa-heart text-2xl text-primary"></i>
620
+ Yêu thích
621
+ </a>
622
+ <a href="/ca-nhan/lich-su" class="flex flex-col items-center gap-3 rounded-3xl bg-white/5 p-6 border border-white/10 text-[10px] font-black uppercase tracking-widest text-gray-400">
623
+ <i class="fas fa-history text-2xl text-primary"></i>
624
+ Lịch sử
625
+ </a>
626
+ </div>
627
+ ${user.role === 'admin' ? `
628
+ <a href="/admin" class="flex items-center justify-center gap-3 rounded-3xl bg-primary/20 border border-primary/30 p-5 text-[10px] font-black uppercase tracking-widest text-primary">
629
+ <i class="fas fa-user-shield"></i>
630
+ Quản trị hệ thống
631
+ </a>
632
+ ` : ''}
633
+ <button id="mobile-logout-btn" class="flex items-center justify-center gap-3 rounded-3xl bg-red-500/10 border border-red-500/20 p-5 text-[10px] font-black uppercase tracking-widest text-red-500">
634
+ <i class="fas fa-sign-out-alt"></i>
635
+ Đăng xuất tài khoản
636
+ </button>
637
+ </div>
638
+ `;
639
+ document.getElementById('mobile-logout-btn')?.addEventListener('click', async () => {
640
+ await fetch('/api/auth/logout', { method: 'POST' });
641
+ window.location.reload();
642
+ });
643
+ }
644
+ } else {
645
+ if (guest) guest.classList.remove('hidden');
646
+ }
647
+
648
+ if (window.initTxaTooltips) window.initTxaTooltips();
649
+ } catch (err) {
650
+ console.error('Auth check failed:', err);
651
+ if (guest) guest.classList.remove('hidden');
652
+ }
653
+ }
654
+
655
+ // Run immediately since DOM is ready when script executes
656
+ initHeader();
657
+ checkAuth();
658
+
659
+ // Support Astro View Transitions
660
+ document.addEventListener('astro:page-load', () => {
661
+ initHeader();
662
+ checkAuth();
663
+ });
664
+ </script>
665
+
666
+ <style>
667
+ .glass-scrolled {
668
+ backdrop-filter: blur(20px) saturate(180%);
669
+ background: rgba(10, 10, 10, 0.85);
670
+ border-bottom: 1px solid rgba(255, 255, 255, 0.05);
671
+ }
672
+ .glass-dropdown {
673
+ background: rgba(15, 15, 15, 0.92);
674
+ backdrop-filter: blur(30px) saturate(180%);
675
+ border: 1px solid rgba(255, 255, 255, 0.08);
676
+ }
677
+ </style>
src/components/Header.jsx ADDED
@@ -0,0 +1,100 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useEffect } from 'react';
2
+ import { Search, Menu, X } from 'lucide-react';
3
+
4
+ const navLinks = [
5
+ { href: '/', label: 'Trang chủ' },
6
+ { href: '/danh-sach/phim-le', label: 'Phim Lẻ' },
7
+ { href: '/danh-sach/phim-bo', label: 'Phim Bộ' },
8
+ { href: '/danh-sach/hoat-hinh', label: 'Hoạt hình' },
9
+ ];
10
+
11
+ export default function Header() {
12
+ const [scrolled, setScrolled] = useState(false);
13
+ const [menuOpen, setMenuOpen] = useState(false);
14
+ const [query, setQuery] = useState('');
15
+ const [mounted, setMounted] = useState(false);
16
+
17
+ useEffect(() => {
18
+ setMounted(true);
19
+ const onScroll = () => setScrolled(window.scrollY > 20);
20
+ window.addEventListener('scroll', onScroll);
21
+ return () => window.removeEventListener('scroll', onScroll);
22
+ }, []);
23
+
24
+ const handleSearch = (e) => {
25
+ e.preventDefault();
26
+ if (query.trim()) {
27
+ window.location.href = `/tim-kiem?q=${encodeURIComponent(query.trim())}`;
28
+ }
29
+ };
30
+
31
+ if (!mounted) {
32
+ return (
33
+ <header className="fixed top-0 left-0 right-0 z-50 bg-black/90 opacity-0">
34
+ <div className="mx-auto max-w-[1400px] px-4 py-2.5">
35
+ <div className="text-2xl font-black text-white">T<span className="text-primary">PHIM</span>X</div>
36
+ </div>
37
+ </header>
38
+ );
39
+ }
40
+
41
+ return (
42
+ <header
43
+ suppressHydrationWarning
44
+ className={`fixed top-0 left-0 right-0 z-50 transition-all duration-500 ${scrolled ? 'glass bg-black/70 shadow-2xl' : 'bg-gradient-to-b from-black/95 to-transparent'}`}
45
+ >
46
+ {/* Row 1: Logo + Search + Auth */}
47
+ <div suppressHydrationWarning className="mx-auto flex max-w-[1400px] items-center justify-between px-4 lg:px-6 py-2.5">
48
+ <a href="/" className="flex items-center shrink-0 transition-transform hover:scale-[1.02] active:scale-95">
49
+ <div className="h-9 md:h-10 w-auto overflow-hidden">
50
+ <img src="/logo-full.png" className="h-full w-full object-contain" alt="TPHIMX" />
51
+ </div>
52
+ </a>
53
+
54
+ <form onSubmit={handleSearch} className="hidden md:flex items-center flex-1 max-w-md mx-6 rounded-lg bg-white/[0.06] border border-white/[0.06] px-4 py-2 focus-within:bg-white/10 focus-within:border-primary/30 transition-all">
55
+ <Search size={12} className="text-gray-500 mr-3 shrink-0" />
56
+ <input
57
+ type="text"
58
+ placeholder="Tìm kiếm phim, diễn viên..."
59
+ value={query}
60
+ onChange={(e) => setQuery(e.target.value)}
61
+ className="flex-1 bg-transparent text-xs font-medium text-white outline-none placeholder:text-gray-500"
62
+ />
63
+ </form>
64
+
65
+ <div suppressHydrationWarning className="flex items-center gap-2 shrink-0">
66
+ <button onClick={() => setMenuOpen(!menuOpen)} className="flex h-10 w-10 items-center justify-center rounded-lg bg-white/5 text-white transition-all hover:bg-primary hover:text-dark md:hidden">
67
+ {menuOpen ? <X size={18} /> : <Menu size={18} />}
68
+ </button>
69
+ </div>
70
+ </div>
71
+
72
+ {/* Row 2: Nav Bar */}
73
+ <nav suppressHydrationWarning className={`hidden md:block border-t border-white/[0.04] ${scrolled ? 'bg-transparent' : 'bg-black/20'}`}>
74
+ <div className="mx-auto max-w-[1400px] flex items-center gap-0.5 px-4 lg:px-6 py-0 text-[11px] font-bold">
75
+ {navLinks.map((link) => (
76
+ <a key={link.href} href={link.href} className="px-4 py-2.5 text-gray-400 hover:text-white hover:bg-white/5 rounded-lg transition-all whitespace-nowrap">
77
+ {link.label}
78
+ </a>
79
+ ))}
80
+ <a href="/dien-vien" className="px-4 py-2.5 text-gray-400 hover:text-white hover:bg-white/5 rounded-lg transition-all whitespace-nowrap">Diễn viên</a>
81
+ <a href="/lich-chieu" className="px-4 py-2.5 text-gray-400 hover:text-white hover:bg-white/5 rounded-lg transition-all whitespace-nowrap">Lịch chiếu</a>
82
+ </div>
83
+ </nav>
84
+
85
+ {/* Mobile Menu */}
86
+ {menuOpen && (
87
+ <div suppressHydrationWarning className="glass flex flex-col border-t border-white/5 bg-black/95 p-4 md:hidden">
88
+ {navLinks.map((link) => (
89
+ <a key={link.href} href={link.href} className="flex items-center justify-between rounded-xl px-5 py-4 text-xs font-bold text-gray-300 transition-all active:scale-95 active:bg-primary active:text-dark" onClick={() => setMenuOpen(false)}>
90
+ {link.label}
91
+ <i className="fas fa-chevron-right text-[9px] opacity-30"></i>
92
+ </a>
93
+ ))}
94
+ <a href="/dien-vien" className="flex items-center justify-between rounded-xl px-5 py-4 text-xs font-bold text-gray-300 transition-all" onClick={() => setMenuOpen(false)}>Diễn viên</a>
95
+ <a href="/lich-chieu" className="flex items-center justify-between rounded-xl px-5 py-4 text-xs font-bold text-gray-300 transition-all" onClick={() => setMenuOpen(false)}>Lịch chiếu</a>
96
+ </div>
97
+ )}
98
+ </header>
99
+ );
100
+ }
src/components/HeroBanner.jsx ADDED
@@ -0,0 +1,223 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useEffect, useCallback } from 'react';
2
+ import { Play, Info, ChevronLeft, ChevronRight } from 'lucide-react';
3
+ import { formatImageUrl } from '../lib/image';
4
+
5
+ export default function HeroBanner({ movies = [] }) {
6
+ const [active, setActive] = useState(0);
7
+ const [progress, setProgress] = useState(0);
8
+ const [favorites, setFavorites] = useState([]);
9
+
10
+ useEffect(() => {
11
+ fetch('/api/user/favorite')
12
+ .then(res => res.json())
13
+ .then(data => {
14
+ if (data && data.favorites) {
15
+ setFavorites(data.favorites.map(f => f.slug));
16
+ }
17
+ })
18
+ .catch(() => {});
19
+ }, []);
20
+
21
+ const handleFavoriteClick = async (m) => {
22
+ try {
23
+ const res = await fetch('/api/user/favorite', {
24
+ method: 'POST',
25
+ headers: { 'Content-Type': 'application/json' },
26
+ body: JSON.stringify({
27
+ movieSlug: m.slug,
28
+ movieName: m.name,
29
+ movieThumb: m.thumb_url || m.poster_url
30
+ })
31
+ });
32
+ const data = await res.json();
33
+ if (res.ok) {
34
+ if (window.txatoast) {
35
+ window.txatoast.success(data.message);
36
+ }
37
+ if (data.action === 'added') {
38
+ setFavorites(prev => [...prev, m.slug]);
39
+ } else {
40
+ setFavorites(prev => prev.filter(slug => slug !== m.slug));
41
+ }
42
+ } else {
43
+ if (window.txatoast) {
44
+ window.txatoast.error(data.error || 'Vui lòng đăng nhập!');
45
+ }
46
+ }
47
+ } catch (e) {
48
+ if (window.txatoast) {
49
+ window.txatoast.error('Không thể thực hiện lúc này!');
50
+ }
51
+ }
52
+ };
53
+
54
+ const heroMovies = movies.slice(0, 5);
55
+
56
+ const next = useCallback(() => {
57
+ setActive((prev) => (prev + 1) % heroMovies.length);
58
+ setProgress(0);
59
+ }, [heroMovies.length]);
60
+
61
+ const prev = useCallback(() => {
62
+ setActive((prev) => (prev - 1 + heroMovies.length) % heroMovies.length);
63
+ setProgress(0);
64
+ }, [heroMovies.length]);
65
+
66
+ useEffect(() => {
67
+ if (heroMovies.length <= 1) return;
68
+ const interval = 100; // 0.1s
69
+ const step = 100 / (6000 / interval); // 6s per slide
70
+
71
+ const timer = setInterval(() => {
72
+ setProgress((p) => {
73
+ if (p >= 100) {
74
+ next();
75
+ return 0;
76
+ }
77
+ return p + step;
78
+ });
79
+ }, interval);
80
+
81
+ return () => clearInterval(timer);
82
+ }, [heroMovies.length, next]);
83
+
84
+ if (!heroMovies.length) return null;
85
+ const movie = heroMovies[active];
86
+ const bg = formatImageUrl(movie.poster_url || movie.thumb_url);
87
+ const year = movie.year || (movie.modified?.time ? new Date(movie.modified.time).getFullYear() : '');
88
+
89
+ return (
90
+ <div className="relative h-[600px] w-full overflow-hidden md:h-[750px] lg:h-[850px]">
91
+ {/* Background Slides with Stylized Text */}
92
+ {heroMovies.map((m, i) => (
93
+ <div
94
+ key={m._id}
95
+ className={`absolute inset-0 transition-all duration-[2000ms] ease-out ${
96
+ i === active ? 'opacity-100 scale-100' : 'opacity-0 scale-110'
97
+ }`}
98
+ >
99
+ {/* Main Background Image */}
100
+ <div
101
+ className="absolute inset-0 bg-cover bg-center md:bg-[center_right_20%]"
102
+ style={{ backgroundImage: `url(${formatImageUrl(m.poster_url || m.thumb_url)})` }}
103
+ />
104
+
105
+ {/* Stylized Background Text Layer (The "Thu Hút Mãnh Liệt" style in bg) */}
106
+ <div className="absolute inset-0 flex items-center justify-center opacity-10 select-none pointer-events-none overflow-hidden">
107
+ <h1 className="text-[20vw] font-black uppercase whitespace-nowrap rotate-[-10deg] text-white">
108
+ {m.name}
109
+ </h1>
110
+ </div>
111
+ </div>
112
+ ))}
113
+
114
+ {/* Gradients for cinematic feel */}
115
+ <div className="absolute inset-0 bg-gradient-to-r from-[#0f0f0f] via-[#0f0f0f]/80 to-transparent z-10" />
116
+ <div className="absolute inset-0 bg-gradient-to-t from-[#0f0f0f] via-transparent to-transparent z-10" />
117
+
118
+ <div className="relative mx-auto flex h-full max-w-7xl flex-col justify-center px-6 z-20">
119
+ <div className="max-w-2xl transition-all duration-700 transform opacity-100 translate-y-0">
120
+ <h1 className="text-4xl font-black text-white md:text-6xl lg:text-7xl drop-shadow-[0_0_30px_rgba(255,255,255,0.2)]">
121
+ <a href={`/phim/${movie.slug}`} className="hover:text-primary transition-colors cursor-pointer">
122
+ {movie.name}
123
+ </a>
124
+ </h1>
125
+
126
+ {movie.origin_name && (
127
+ <p className="mt-2 text-sm font-bold text-gray-400 md:text-base uppercase tracking-wider opacity-60 italic">{movie.origin_name}</p>
128
+ )}
129
+
130
+ {/* Badges Row */}
131
+ <div className="mt-6 flex flex-wrap items-center gap-3">
132
+ <div className="flex items-center gap-1.5 rounded-lg bg-yellow-500/20 px-2.5 py-1 text-[10px] font-black text-yellow-500 ring-1 ring-yellow-500/30">
133
+ <i className="fas fa-star"></i> {movie.tmdb?.vote_average || (8.5 + (movie.slug.length % 15) / 10).toFixed(1)}
134
+ </div>
135
+ <span className="glass rounded-lg px-2.5 py-1 text-[10px] font-black text-gray-300 ring-1 ring-white/10 uppercase">
136
+ {movie.quality || 'Full HD'}
137
+ </span>
138
+ <span className="glass rounded-lg px-2.5 py-1 text-[10px] font-black text-gray-300 ring-1 ring-white/10">
139
+ {year}
140
+ </span>
141
+ {movie.tmdb?.season && (
142
+ <span className="glass rounded-lg px-2.5 py-1 text-[10px] font-black text-gray-300 ring-1 ring-white/10">
143
+ Phần {movie.tmdb.season}
144
+ </span>
145
+ )}
146
+ <span className="glass rounded-lg px-2.5 py-1 text-[10px] font-black text-gray-300 ring-1 ring-white/10">
147
+ {movie.episode_current?.toLowerCase().includes('hoàn tất') ? 'Hoàn tất' : `Tập ${movie.episode_current?.match(/\d+/)?.[0] || '??'}`}
148
+ {movie.episode_total && movie.episode_total !== '1' && ` / ${movie.episode_total}`}
149
+ </span>
150
+ </div>
151
+
152
+ {/* Categories Row */}
153
+ <div className="mt-4 flex flex-wrap gap-2">
154
+ {movie.category?.slice(0, 4).map(cat => (
155
+ <span key={cat.id} className="text-[11px] font-bold text-gray-500 hover:text-white transition-colors cursor-pointer">
156
+ {cat.name}
157
+ </span>
158
+ ))}
159
+ </div>
160
+
161
+ <p className="mt-8 line-clamp-3 max-w-xl text-sm leading-relaxed text-gray-400 md:text-base drop-shadow-md">
162
+ {movie.content?.replace(/<[^>]*>/g, '') || movie.description || ''}
163
+ </p>
164
+
165
+ <div className="mt-10 flex items-center gap-4">
166
+ <a
167
+ href={`/xem/${movie.slug}`}
168
+ className="group flex h-14 w-14 items-center justify-center rounded-full bg-yellow-500 text-black shadow-[0_0_30px_rgba(234,179,8,0.4)] transition-all hover:scale-110 active:scale-95"
169
+ data-txatooltip="Xem phim ngay"
170
+ >
171
+ <i className="fas fa-play text-xl ml-1"></i>
172
+ </a>
173
+
174
+ <button
175
+ className={`glass flex h-14 w-14 items-center justify-center rounded-full transition-all hover:bg-white/10 hover:ring-2 hover:ring-white/20 ${favorites.includes(movie.slug) ? 'text-primary' : 'text-white'}`}
176
+ onClick={() => handleFavoriteClick(movie)}
177
+ data-txatooltip={favorites.includes(movie.slug) ? "Bỏ yêu thích" : "Thêm vào yêu thích"}
178
+ >
179
+ <i className={`${favorites.includes(movie.slug) ? 'fas' : 'far'} fa-heart text-xl`}></i>
180
+ </button>
181
+
182
+ <a
183
+ href={`/phim/${movie.slug}`}
184
+ className="glass flex h-14 w-14 items-center justify-center rounded-full text-white transition-all hover:bg-white/10 hover:ring-2 hover:ring-white/20"
185
+ data-txatooltip="Thông tin chi tiết"
186
+ >
187
+ <i className="fas fa-info text-xl"></i>
188
+ </a>
189
+ </div>
190
+ </div>
191
+
192
+ {/* Thumbnails Navigation (Right side row) */}
193
+ <div className="absolute bottom-16 right-6 hidden lg:flex items-center gap-3">
194
+ {heroMovies.map((m, i) => (
195
+ <div
196
+ key={m._id}
197
+ onClick={() => { setActive(i); setProgress(0); }}
198
+ className={`group relative h-16 w-24 cursor-pointer overflow-hidden rounded-xl border-2 transition-all duration-500 ${
199
+ i === active ? 'border-primary scale-110 shadow-[0_0_20px_rgba(229,9,20,0.5)]' : 'border-white/10 opacity-50 hover:opacity-100'
200
+ }`}
201
+ >
202
+ <img src={formatImageUrl(m.thumb_url || m.poster_url)} className="h-full w-full object-cover" alt="nav" />
203
+ {i === active && (
204
+ <div className="absolute bottom-0 left-0 h-1 bg-primary transition-all duration-100" style={{ width: `${progress}%` }} />
205
+ )}
206
+ </div>
207
+ ))}
208
+ </div>
209
+
210
+ {/* Mobile Navigation */}
211
+ <div className="absolute bottom-8 left-6 flex lg:hidden gap-2">
212
+ {heroMovies.map((_, i) => (
213
+ <div
214
+ key={i}
215
+ onClick={() => { setActive(i); setProgress(0); }}
216
+ className={`h-1.5 rounded-full transition-all duration-500 ${i === active ? 'w-8 bg-primary' : 'w-2 bg-white/20'}`}
217
+ />
218
+ ))}
219
+ </div>
220
+ </div>
221
+ </div>
222
+ );
223
+ }
src/components/HomeSectionsLoader.jsx ADDED
@@ -0,0 +1,147 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useState, useRef } from 'react';
2
+ import MovieSection from './MovieSection.jsx';
3
+ import CombinedRegionSection from './CombinedRegionSection.jsx';
4
+
5
+ const SECTION_CONFIGS = [
6
+ { type: 'newest', title: 'Phim mới cập nhật', href: '/danh-sach/phim-moi-cap-nhat' },
7
+ { type: 'series', title: 'Phim bộ đặc sắc', href: '/danh-sach/phim-bo' },
8
+ { type: 'single', title: 'Phim Điện Ảnh Mới Coóng', href: '/danh-sach/phim-le' },
9
+ { category: 'phim-chieu-rap', title: 'Mãn Nhãn với Phim Chiếu Rạp', href: '/the-loai/phim-chieu-rap' },
10
+ { type: 'cartoon', title: 'Thế giới hoạt hình', href: '/danh-sach/hoat-hinh' },
11
+ { country: 'trung-quoc', title: 'Phim Trung Quốc mới', href: '/quoc-gia/trung-quoc' },
12
+ { country: 'han-quoc', title: 'Phim Hàn Quốc mới', href: '/quoc-gia/han-quoc' },
13
+ { country: 'au-my', title: 'Phim Âu-Mỹ mới', href: '/quoc-gia/au-my' }
14
+ ];
15
+
16
+ export default function HomeSectionsLoader() {
17
+ const [loadedSections, setLoadedSections] = useState([]);
18
+ const [loading, setLoading] = useState(false);
19
+ const [currentIndex, setCurrentIndex] = useState(0);
20
+ const loaderRef = useRef(null);
21
+
22
+ const fetchSectionData = async (config) => {
23
+ try {
24
+ const params = new URLSearchParams();
25
+ if (config.type) params.append('type', config.type);
26
+ if (config.category) params.append('category', config.category);
27
+ if (config.country) params.append('country', config.country);
28
+
29
+ const res = await fetch(`/api/movies?${params.toString()}`);
30
+ if (!res.ok) throw new Error('Failed to fetch movies');
31
+ const data = await res.json();
32
+ return data.items || [];
33
+ } catch (e) {
34
+ console.error(`[HomeSectionsLoader] Error loading ${config.title}:`, e);
35
+ return [];
36
+ }
37
+ };
38
+
39
+ // Tải danh mục tiếp theo khi cuộn xuống
40
+ const loadNextSection = async () => {
41
+ if (loading || currentIndex >= SECTION_CONFIGS.length) return;
42
+
43
+ setLoading(true);
44
+ const config = SECTION_CONFIGS[currentIndex];
45
+ const movies = await fetchSectionData(config);
46
+
47
+ if (movies.length > 0) {
48
+ setLoadedSections(prev => {
49
+ if (prev.some(s => s.title === config.title)) return prev;
50
+ return [...prev, { ...config, movies }];
51
+ });
52
+ }
53
+
54
+ setCurrentIndex(prev => prev + 1);
55
+ setLoading(false);
56
+ };
57
+
58
+ // Lần đầu tải 2 Section đầu tiên để lấp đầy khung nhìn ngay lập tức
59
+ useEffect(() => {
60
+ let active = true;
61
+
62
+ const initLoad = async () => {
63
+ setLoading(true);
64
+
65
+ // Load Section 1
66
+ const config1 = SECTION_CONFIGS[0];
67
+ const movies1 = await fetchSectionData(config1);
68
+ if (active && movies1.length > 0) {
69
+ setLoadedSections([{ ...config1, movies: movies1 }]);
70
+ }
71
+
72
+ // Load Section 2
73
+ const config2 = SECTION_CONFIGS[1];
74
+ const movies2 = await fetchSectionData(config2);
75
+ if (active && movies2.length > 0) {
76
+ setLoadedSections(prev => [...prev, { ...config2, movies: movies2 }]);
77
+ }
78
+
79
+ if (active) {
80
+ setCurrentIndex(2);
81
+ setLoading(false);
82
+ }
83
+ };
84
+
85
+ initLoad();
86
+
87
+ return () => {
88
+ active = false;
89
+ };
90
+ }, []);
91
+
92
+ // Lắng nghe Intersection Observer để kích hoạt tải Section tiếp theo khi cuộn xuống gần cuối
93
+ useEffect(() => {
94
+ if (!loaderRef.current || currentIndex >= SECTION_CONFIGS.length || loading) return;
95
+
96
+ const observer = new IntersectionObserver((entries) => {
97
+ const target = entries[0];
98
+ if (target.isIntersecting && !loading) {
99
+ loadNextSection();
100
+ }
101
+ }, {
102
+ rootMargin: '350px', // Bắt đầu tải ngầm trước khi cuộn tới 350px để tạo trải nghiệm bất tận mượt mà
103
+ threshold: 0.1
104
+ });
105
+
106
+ observer.observe(loaderRef.current);
107
+ return () => observer.disconnect();
108
+ }, [currentIndex, loading]);
109
+
110
+ const normalSections = loadedSections.filter(sec => !['trung-quoc', 'han-quoc', 'au-my'].includes(sec.country));
111
+ const regionSections = loadedSections.filter(sec => ['trung-quoc', 'han-quoc', 'au-my'].includes(sec.country));
112
+
113
+ return (
114
+ <div className="space-y-4">
115
+ {normalSections.map((sec, i) => (
116
+ <MovieSection
117
+ key={`${sec.type || sec.category || sec.country}-${i}`}
118
+ title={sec.title}
119
+ movies={sec.movies}
120
+ href={sec.href}
121
+ />
122
+ ))}
123
+
124
+ {regionSections.length > 0 && (
125
+ <CombinedRegionSection sections={regionSections} />
126
+ )}
127
+
128
+ {/* Điểm kích hoạt cuộn tải Section */}
129
+ {currentIndex < SECTION_CONFIGS.length && (
130
+ <div ref={loaderRef} className="flex flex-col items-center justify-center py-12">
131
+ {loading && (
132
+ <>
133
+ <div className="relative flex h-16 w-16 items-center justify-center">
134
+ {/* Vòng quay hiệu ứng Neon cao cấp */}
135
+ <div className="absolute inset-0 animate-spin rounded-full border-4 border-t-primary border-r-accent border-b-transparent border-l-transparent"></div>
136
+ <div className="h-6 w-6 animate-pulse rounded-full bg-primary/80 blur-[2px]"></div>
137
+ </div>
138
+ <span className="mt-4 text-xs font-black uppercase tracking-widest text-gray-500 animate-pulse">
139
+ Đang tải thêm danh mục phim...
140
+ </span>
141
+ </>
142
+ )}
143
+ </div>
144
+ )}
145
+ </div>
146
+ );
147
+ }
src/components/MovieCard.jsx ADDED
@@ -0,0 +1,127 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Play, Star, Clock, Calendar } from 'lucide-react';
2
+ const IMAGE_BASE_URL = 'https://phimimg.com';
3
+
4
+ const formatImageUrl = (url) => {
5
+ if (!url) return '';
6
+ if (url.startsWith('http')) return url;
7
+ const cleanUrl = url.startsWith('/') ? url : `/${url}`;
8
+ return `${IMAGE_BASE_URL}${cleanUrl}`;
9
+ };
10
+
11
+ export default function MovieCard({ movie }) {
12
+ if (!movie) return null;
13
+
14
+ const poster = formatImageUrl(movie.poster_url);
15
+ const thumb = formatImageUrl(movie.thumb_url || movie.poster_url);
16
+ const year = movie.year || (movie.modified?.time ? new Date(movie.modified.time).getFullYear() : '');
17
+ const quality = movie.quality || 'HD';
18
+ const lang = movie.lang?.toLowerCase() || 'vietsub';
19
+
20
+ const isVietsub = lang.includes('vietsub') || lang.includes('pđ') || lang.includes('phụ đề') || lang === 'vietsub';
21
+ const isThuyetMinh = lang.includes('thuyết minh') || lang.includes('tm');
22
+
23
+ const isSingle = movie.type === 'single' || movie.type === 'movie';
24
+ const epNum = movie.episode_current?.match(/\d+/)?.[0];
25
+ const isFull = movie.episode_current?.toLowerCase().includes('hoàn tất') ||
26
+ (movie.episode_current === movie.episode_total && movie.episode_total !== '1' && movie.episode_total !== undefined);
27
+
28
+ const statusLabel = isFull ? 'Hoàn tất' : (epNum ? `Tập ${epNum}` : movie.episode_current);
29
+
30
+ // Deterministic rating based on slug to avoid hydration mismatch
31
+ const getRating = (slug) => {
32
+ if (!slug) return "8.5";
33
+ let hash = 0;
34
+ for (let i = 0; i < slug.length; i++) hash = slug.charCodeAt(i) + ((hash << 5) - hash);
35
+ return (8.5 + (Math.abs(hash) % 15) / 10).toFixed(1);
36
+ };
37
+ const rating = movie.tmdb?.vote_average || getRating(movie.slug);
38
+
39
+ return (
40
+ <a
41
+ href={`/phim/${movie.slug}`}
42
+ className="group relative block overflow-hidden rounded-[24px] bg-[#1a1a1a] transition-all duration-500 hover:ring-2 hover:ring-primary/50 shadow-xl"
43
+ data-txatooltip={`<div class='p-2'><div class='text-primary font-black mb-1'>${movie.name}</div><div class='text-xs text-gray-400 line-clamp-3'>${movie.content?.replace(/<[^>]*>/g, '')}</div></div>`}
44
+ >
45
+ <div className="relative aspect-[2/3] overflow-hidden">
46
+ {/* Main Background Image */}
47
+ <img
48
+ src={poster}
49
+ alt={movie.name}
50
+ loading="lazy"
51
+ decoding="async"
52
+ className="h-full w-full object-cover transition-transform duration-700 group-hover:scale-110"
53
+ />
54
+
55
+ {/* Overlay Gradients */}
56
+ <div className="absolute inset-0 bg-gradient-to-t from-black via-transparent to-transparent opacity-90" />
57
+ <div className="absolute inset-0 bg-gradient-to-b from-black/60 via-transparent to-transparent opacity-40" />
58
+
59
+ {/* Quality Badge (Top Left) */}
60
+ <div className="absolute left-3 top-3 z-20">
61
+ <span className="glass rounded-lg px-2.5 py-1 text-xs font-black uppercase text-white ring-1 ring-white/20 shadow-2xl">
62
+ {quality}
63
+ </span>
64
+ </div>
65
+
66
+ {/* Rating Badge (Top Right) */}
67
+ <div className="absolute right-3 top-3 z-20 flex flex-col items-end gap-1.5">
68
+ <div className="glass flex items-center gap-1.5 rounded-lg px-3 py-1.5 ring-1 ring-yellow-500/30">
69
+ <i className="fas fa-star text-xs text-yellow-500"></i>
70
+ <span className="text-xs font-black text-white">{rating}</span>
71
+ </div>
72
+ {year && (
73
+ <span className="glass rounded-lg px-3 py-1.5 text-xs font-black tracking-widest text-gray-300 ring-1 ring-white/10">
74
+ {year}
75
+ </span>
76
+ )}
77
+ </div>
78
+
79
+ {/* Small Poster Thumbnail Overlay (Bottom Left) */}
80
+ <div className="absolute bottom-4 left-3 z-10 h-16 w-11 overflow-hidden rounded-xl border border-white/20 shadow-2xl transition-all duration-500 group-hover:scale-110 group-hover:-translate-y-1">
81
+ <img src={thumb} className="h-full w-full object-cover" alt="mini" />
82
+ </div>
83
+
84
+ {/* Bottom Status Badges (Vietsub | Thuyết Minh | Status) */}
85
+ <div className="absolute bottom-4 right-3 flex flex-col items-end gap-1.5">
86
+ <div className="flex flex-wrap gap-1 bg-black/85 p-1 rounded-xl border border-white/10 backdrop-blur-md shadow-2xl">
87
+ {isVietsub && (
88
+ <span className="rounded-lg bg-indigo-600/80 px-2 py-1 text-[9px] font-black uppercase text-white shadow-md">
89
+ Vietsub
90
+ </span>
91
+ )}
92
+ {isThuyetMinh && (
93
+ <span className="rounded-lg bg-emerald-600/80 px-2 py-1 text-[9px] font-black uppercase text-white shadow-md">
94
+ Thuyết Minh
95
+ </span>
96
+ )}
97
+ {isSingle ? (
98
+ <span className="rounded-lg bg-blue-600/85 px-2 py-1 text-[9px] font-black uppercase text-white shadow-md">
99
+ FULL
100
+ </span>
101
+ ) : (
102
+ statusLabel && (
103
+ <span className="rounded-lg bg-amber-500/85 px-2 py-1 text-[9px] font-black uppercase text-dark shadow-md">
104
+ {statusLabel}
105
+ </span>
106
+ )
107
+ )}
108
+ </div>
109
+ </div>
110
+ </div>
111
+
112
+ <div className="p-4 pt-3.5 space-y-1">
113
+ <h3 className="line-clamp-1 text-base font-black uppercase tracking-tight text-white transition-colors group-hover:text-primary">
114
+ {movie.name}
115
+ </h3>
116
+ <div className="flex items-center justify-between gap-2">
117
+ <p className="line-clamp-1 text-sm font-bold text-gray-400">{movie.origin_name}</p>
118
+ {statusLabel && (
119
+ <span className="text-xs font-black uppercase tracking-wider text-amber-400 bg-amber-400/10 px-2 py-0.5 rounded border border-amber-400/25 shrink-0">
120
+ {statusLabel}
121
+ </span>
122
+ )}
123
+ </div>
124
+ </div>
125
+ </a>
126
+ );
127
+ }
src/components/MovieSection.jsx ADDED
@@ -0,0 +1,92 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useRef, useState } from 'react';
2
+ import { ChevronLeft, ChevronRight } from 'lucide-react';
3
+ import MovieCard from './MovieCard';
4
+
5
+ export default function MovieSection({ title, movies = [], href = '#' }) {
6
+ const [visible, setVisible] = useState(true);
7
+ const sectionRef = useRef(null);
8
+ const scrollRef = useRef(null);
9
+ const [showLeft, setShowLeft] = useState(false);
10
+ const [showRight, setShowRight] = useState(true);
11
+
12
+ useEffect(() => {
13
+ const observer = new IntersectionObserver(
14
+ ([entry]) => {
15
+ if (entry.isIntersecting) {
16
+ setVisible(true);
17
+ observer.unobserve(entry.target);
18
+ }
19
+ },
20
+ { threshold: 0.1 }
21
+ );
22
+ if (sectionRef.current) observer.observe(sectionRef.current);
23
+ return () => observer.disconnect();
24
+ }, []);
25
+
26
+ const handleScroll = () => {
27
+ if (!scrollRef.current) return;
28
+ const { scrollLeft, scrollWidth, clientWidth } = scrollRef.current;
29
+ setShowLeft(scrollLeft > 10);
30
+ setShowRight(scrollLeft < scrollWidth - clientWidth - 10);
31
+ };
32
+
33
+ const scroll = (dir) => {
34
+ if (!scrollRef.current) return;
35
+ const amount = scrollRef.current.clientWidth * 0.8;
36
+ scrollRef.current.scrollBy({ left: dir * amount, behavior: 'smooth' });
37
+ };
38
+
39
+ if (!movies?.length) return null;
40
+
41
+ return (
42
+ <section
43
+ ref={sectionRef}
44
+ className={`mx-auto max-w-7xl px-4 py-10 transition-all duration-1000 ${visible ? 'translate-y-0 opacity-100' : 'translate-y-10 opacity-0'}`}
45
+ >
46
+ <div className="mb-8 flex items-center justify-between">
47
+ <h2 className="flex items-center gap-3 text-2xl font-black uppercase tracking-tighter text-white md:text-3xl">
48
+ <span className="h-8 w-2 rounded-full bg-primary shadow-[0_0_15px_rgba(229,9,20,0.5)]" />
49
+ {title}
50
+ </h2>
51
+ {href !== '#' && (
52
+ <a href={href} className="group flex items-center gap-2 text-[10px] font-black uppercase tracking-widest text-gray-500 transition-colors hover:text-white">
53
+ Xem tất cả
54
+ <ChevronRight size={14} className="transition-transform group-hover:translate-x-1" />
55
+ </a>
56
+ )}
57
+ </div>
58
+
59
+ <div className="group relative">
60
+ {showLeft && (
61
+ <button
62
+ onClick={() => scroll(-1)}
63
+ className="absolute -left-4 top-1/2 z-30 flex h-12 w-12 -translate-y-1/2 items-center justify-center rounded-full bg-black/60 text-white backdrop-blur-md opacity-0 transition-all group-hover:opacity-100 hover:bg-primary"
64
+ >
65
+ <ChevronLeft size={24} />
66
+ </button>
67
+ )}
68
+
69
+ {showRight && (
70
+ <button
71
+ onClick={() => scroll(1)}
72
+ className="absolute -right-4 top-1/2 z-30 flex h-12 w-12 -translate-y-1/2 items-center justify-center rounded-full bg-black/60 text-white backdrop-blur-md opacity-0 transition-all group-hover:opacity-100 hover:bg-primary"
73
+ >
74
+ <ChevronRight size={24} />
75
+ </button>
76
+ )}
77
+
78
+ <div
79
+ ref={scrollRef}
80
+ onScroll={handleScroll}
81
+ className="scrollbar-hide flex gap-5 overflow-x-auto pb-4"
82
+ >
83
+ {movies.map((movie) => (
84
+ <div key={movie._id || movie.slug} className="w-[180px] flex-shrink-0 md:w-[220px]">
85
+ <MovieCard movie={movie} />
86
+ </div>
87
+ ))}
88
+ </div>
89
+ </div>
90
+ </section>
91
+ );
92
+ }
src/components/MovieSlider.jsx ADDED
@@ -0,0 +1,118 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useRef, useState } from 'react';
2
+ import { ChevronLeft, ChevronRight, Play, Star, Calendar, Clock } from 'lucide-react';
3
+
4
+ export default function MovieSlider({ movies = [] }) {
5
+ const containerRef = useRef(null);
6
+ const [canScrollLeft, setCanScrollLeft] = useState(false);
7
+ const [canScrollRight, setCanScrollRight] = useState(true);
8
+
9
+ const checkScroll = () => {
10
+ const el = containerRef.current;
11
+ if (!el) return;
12
+ setCanScrollLeft(el.scrollLeft > 0);
13
+ setCanScrollRight(el.scrollLeft + el.clientWidth < el.scrollWidth - 10);
14
+ };
15
+
16
+ useEffect(() => {
17
+ checkScroll();
18
+ const el = containerRef.current;
19
+ if (el) {
20
+ el.addEventListener('scroll', checkScroll);
21
+ window.addEventListener('resize', checkScroll);
22
+ return () => {
23
+ el.removeEventListener('scroll', checkScroll);
24
+ window.removeEventListener('resize', checkScroll);
25
+ };
26
+ }
27
+ }, [movies]);
28
+
29
+ const scroll = (dir) => {
30
+ const el = containerRef.current;
31
+ if (!el) return;
32
+ el.scrollBy({ left: dir * el.clientWidth * 0.8, behavior: 'smooth' });
33
+ };
34
+
35
+ if (!movies?.length) return null;
36
+
37
+ return (
38
+ <div className="relative mx-auto max-w-7xl px-4 py-4">
39
+ <div className="mb-3 flex items-center gap-2 text-lg font-bold text-white md:text-xl">
40
+ <span className="h-5 w-1 rounded bg-[#e50914]" />
41
+ Đề xuất hot
42
+ </div>
43
+ <div className="relative">
44
+ <div
45
+ ref={containerRef}
46
+ className="flex snap-x snap-mandatory gap-4 overflow-x-auto pb-4 scrollbar-hide"
47
+ style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
48
+ >
49
+ {movies.map((movie) => {
50
+ const thumb = movie.thumb_url || movie.poster_url || '';
51
+ const year = movie.year || (movie.modified?.time ? new Date(movie.modified.time).getFullYear() : '');
52
+ return (
53
+ <a
54
+ key={movie._id || movie.slug}
55
+ href={`/phim/${movie.slug}`}
56
+ className="group relative w-[260px] flex-shrink-0 snap-start overflow-hidden rounded-xl sm:w-[300px] md:w-[340px]"
57
+ >
58
+ <div className="relative aspect-[16/10] overflow-hidden">
59
+ <img
60
+ src={thumb}
61
+ alt={movie.name}
62
+ loading="lazy"
63
+ className="h-full w-full object-cover transition-transform duration-500 group-hover:scale-105"
64
+ onError={(e) => { e.currentTarget.src = 'https://via.placeholder.com/640x360/1a1a1a/888?text=No+Image'; }}
65
+ />
66
+ <div className="absolute inset-0 bg-gradient-to-t from-black via-black/30 to-transparent" />
67
+ <div className="absolute inset-0 flex items-center justify-center opacity-0 transition-opacity duration-300 group-hover:opacity-100">
68
+ <div className="flex h-14 w-14 items-center justify-center rounded-full bg-[#e50914]/90 shadow-xl backdrop-blur-sm">
69
+ <Play size={24} className="ml-1 text-white" fill="white" />
70
+ </div>
71
+ </div>
72
+ <div className="absolute left-3 top-3">
73
+ {movie.quality && (
74
+ <span className="rounded bg-[#e50914] px-2 py-0.5 text-[10px] font-bold uppercase text-white">
75
+ {movie.quality}
76
+ </span>
77
+ )}
78
+ </div>
79
+ </div>
80
+ <div className="p-3">
81
+ <h3 className="line-clamp-1 text-sm font-bold text-white group-hover:text-[#e50914]">
82
+ {movie.name}
83
+ </h3>
84
+ <div className="mt-1 flex flex-wrap items-center gap-3 text-[11px] text-gray-400">
85
+ {year && <span className="flex items-center gap-1"><Calendar size={10} /> {year}</span>}
86
+ {movie.time && <span className="flex items-center gap-1"><Clock size={10} /> {movie.time}</span>}
87
+ {movie.tmdb?.vote_average && (
88
+ <span className="flex items-center gap-1 text-yellow-400">
89
+ <Star size={10} fill="currentColor" /> {movie.tmdb.vote_average.toFixed(1)}
90
+ </span>
91
+ )}
92
+ </div>
93
+ </div>
94
+ </a>
95
+ );
96
+ })}
97
+ </div>
98
+
99
+ {canScrollLeft && (
100
+ <button
101
+ onClick={() => scroll(-1)}
102
+ className="absolute left-0 top-1/2 -translate-y-1/2 z-10 flex h-9 w-9 items-center justify-center rounded-full bg-black/60 text-white backdrop-blur-sm hover:bg-[#e50914] transition-colors"
103
+ >
104
+ <ChevronLeft size={20} />
105
+ </button>
106
+ )}
107
+ {canScrollRight && (
108
+ <button
109
+ onClick={() => scroll(1)}
110
+ className="absolute right-0 top-1/2 -translate-y-1/2 z-10 flex h-9 w-9 items-center justify-center rounded-full bg-black/60 text-white backdrop-blur-sm hover:bg-[#e50914] transition-colors"
111
+ >
112
+ <ChevronRight size={20} />
113
+ </button>
114
+ )}
115
+ </div>
116
+ </div>
117
+ );
118
+ }
src/components/PaginatedGrid.jsx ADDED
@@ -0,0 +1,104 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useRef } from 'react';
2
+ import MovieCard from './MovieCard';
3
+ import Pagination from './Pagination';
4
+
5
+ export default function PaginatedGrid({ initialData, category, title, query, topic, country, type }) {
6
+ const [data, setData] = useState(initialData);
7
+ const [loading, setLoading] = useState(false);
8
+ const containerRef = useRef(null);
9
+
10
+ const moviesList = data?.items || [];
11
+ const currentPage = data?.pagination?.currentPage || 1;
12
+ const totalPages = data?.pagination?.totalPages || 1;
13
+
14
+ // Xây dựng đường dẫn cơ sở basePath động chuẩn chỉnh cho chuyển trang
15
+ let basePath = '';
16
+ if (typeof window !== 'undefined') {
17
+ const url = new URL(window.location.href);
18
+ url.searchParams.delete('page');
19
+ // Giữ nguyên các tham số tìm kiếm khác (như q) nếu có
20
+ basePath = url.pathname + url.search;
21
+ } else {
22
+ // Giải pháp dự phòng khi Render trên máy chủ (SSR Fallback)
23
+ if (query) {
24
+ basePath = `/tim-kiem?q=${encodeURIComponent(query)}`;
25
+ } else if (topic) {
26
+ basePath = `/chu-de/${topic}`;
27
+ } else {
28
+ basePath = category ? `/the-loai/${category}` : '';
29
+ }
30
+ }
31
+
32
+ const handlePageChange = async (pageNum) => {
33
+ if (pageNum === currentPage || loading) return;
34
+ setLoading(true);
35
+
36
+ // Cuộn mượt mà lên đầu danh sách/bảng phim với offset hợp lý
37
+ if (containerRef.current) {
38
+ containerRef.current.scrollIntoView({ behavior: 'smooth', block: 'start' });
39
+ }
40
+
41
+ try {
42
+ let apiUrl = `/api/movies?page=${pageNum}`;
43
+ if (category) apiUrl += `&category=${encodeURIComponent(category)}`;
44
+ if (topic) apiUrl += `&topic=${encodeURIComponent(topic)}`;
45
+ if (country) apiUrl += `&country=${encodeURIComponent(country)}`;
46
+ if (query) apiUrl += `&q=${encodeURIComponent(query)}`;
47
+ if (type) apiUrl += `&type=${encodeURIComponent(type)}`;
48
+
49
+ const res = await fetch(apiUrl);
50
+ if (!res.ok) throw new Error(`HTTP error ${res.status}`);
51
+ const json = await res.json();
52
+
53
+ // Giãn cách một chút (300ms) để hiệu ứng chuyển trang trông premium, mượt mà và trực quan
54
+ await new Promise((resolve) => setTimeout(resolve, 300));
55
+ setData(json);
56
+
57
+ // Cập nhật địa chỉ URL trình duyệt mà không gây tải lại toàn bộ trang
58
+ const newUrl = new URL(window.location.href);
59
+ newUrl.searchParams.set('page', String(pageNum));
60
+ window.history.pushState({}, '', newUrl.toString());
61
+ } catch (err) {
62
+ console.error('Lỗi khi tải trang mới:', err);
63
+ } finally {
64
+ setLoading(false);
65
+ }
66
+ };
67
+
68
+ return (
69
+ <div ref={containerRef} className="space-y-12 relative scroll-mt-28">
70
+ {/* Hiệu ứng phủ kính Glassmorphism Loading Siêu Sang Trọng */}
71
+ {loading && (
72
+ <div className="absolute inset-0 z-50 bg-black/40 backdrop-blur-[2px] rounded-3xl flex items-center justify-center transition-all duration-300 animate-fade-in">
73
+ <div className="flex flex-col items-center gap-3 bg-[#12141d]/85 border border-white/10 rounded-2xl p-6 shadow-2xl">
74
+ <div className="h-10 w-10 border-4 border-primary/20 border-t-primary rounded-full animate-spin"></div>
75
+ <span className="text-xs font-black uppercase tracking-widest text-primary animate-pulse">Đang cập nhật...</span>
76
+ </div>
77
+ </div>
78
+ )}
79
+
80
+ {moviesList.length === 0 ? (
81
+ <div className="py-20 text-center text-gray-500 font-bold uppercase tracking-widest text-xs">
82
+ Không tìm thấy phim nào
83
+ </div>
84
+ ) : (
85
+ <>
86
+ {/* Grid hiển thị danh sách phim hiện tại */}
87
+ <div className={`grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 transition-all duration-300 ${loading ? 'opacity-30 blur-[1px]' : 'opacity-100'}`}>
88
+ {moviesList.map((movie) => (
89
+ <MovieCard key={movie._id || movie.slug} movie={movie} />
90
+ ))}
91
+ </div>
92
+
93
+ {/* Phân trang tiêu chuẩn cao cấp bằng AJAX */}
94
+ <Pagination
95
+ currentPage={currentPage}
96
+ totalPages={totalPages}
97
+ basePath={basePath}
98
+ onPageChange={handlePageChange}
99
+ />
100
+ </>
101
+ )}
102
+ </div>
103
+ );
104
+ }
src/components/Pagination.jsx ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { ChevronLeft, ChevronRight } from 'lucide-react';
2
+
3
+ export default function Pagination({ currentPage, totalPages, basePath, onPageChange }) {
4
+ if (totalPages <= 1) return null;
5
+
6
+ const getPages = () => {
7
+ const pages = [];
8
+ const maxVisible = 5;
9
+ let start = Math.max(1, currentPage - Math.floor(maxVisible / 2));
10
+ let end = Math.min(totalPages, start + maxVisible - 1);
11
+ if (end - start < maxVisible - 1) start = Math.max(1, end - maxVisible + 1);
12
+ for (let i = start; i <= end; i++) pages.push(i);
13
+ return pages;
14
+ };
15
+
16
+ const buildUrl = (page) => {
17
+ const sep = basePath.includes('?') ? '&' : '?';
18
+ return `${basePath}${sep}page=${page}`;
19
+ };
20
+
21
+ const handlePageClick = (e, page) => {
22
+ if (onPageChange) {
23
+ e.preventDefault();
24
+ onPageChange(page);
25
+ }
26
+ };
27
+
28
+ return (
29
+ <div className="flex items-center justify-center gap-2 py-8">
30
+ {currentPage > 1 && (
31
+ <a
32
+ href={buildUrl(currentPage - 1)}
33
+ onClick={(e) => handlePageClick(e, currentPage - 1)}
34
+ className="flex h-9 w-9 items-center justify-center rounded-md bg-[#1a1a1a] text-gray-300 hover:bg-[#e50914] hover:text-white transition-colors"
35
+ >
36
+ <ChevronLeft size={16} />
37
+ </a>
38
+ )}
39
+
40
+ {getPages().map((page) => (
41
+ <a
42
+ key={page}
43
+ href={buildUrl(page)}
44
+ onClick={(e) => handlePageClick(e, page)}
45
+ className={`flex h-9 min-w-[36px] items-center justify-center rounded-md px-2 text-sm font-medium transition-colors ${
46
+ page === currentPage
47
+ ? 'bg-[#e50914] text-white'
48
+ : 'bg-[#1a1a1a] text-gray-300 hover:bg-[#2a2a2a] hover:text-white'
49
+ }`}
50
+ >
51
+ {page}
52
+ </a>
53
+ ))}
54
+
55
+ {currentPage < totalPages && (
56
+ <a
57
+ href={buildUrl(currentPage + 1)}
58
+ onClick={(e) => handlePageClick(e, currentPage + 1)}
59
+ className="flex h-9 w-9 items-center justify-center rounded-md bg-[#1a1a1a] text-gray-300 hover:bg-[#e50914] hover:text-white transition-colors"
60
+ >
61
+ <ChevronRight size={16} />
62
+ </a>
63
+ )}
64
+ </div>
65
+ );
66
+ }
src/components/SplashScreen.jsx ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useState } from 'react';
2
+
3
+ export default function SplashScreen() {
4
+ const [visible, setVisible] = useState(true);
5
+ const [fadeOut, setFadeOut] = useState(false);
6
+
7
+ useEffect(() => {
8
+ const show = () => {
9
+ setVisible(true);
10
+ setFadeOut(false);
11
+ };
12
+ const hide = () => {
13
+ setFadeOut(true);
14
+ setTimeout(() => setVisible(false), 600);
15
+ };
16
+
17
+ document.addEventListener('astro:before-preparation', show);
18
+ document.addEventListener('astro:after-preparation', hide);
19
+
20
+ // Initial hide for first page load
21
+ const timer = setTimeout(hide, 1000);
22
+
23
+ return () => {
24
+ document.removeEventListener('astro:before-preparation', show);
25
+ document.removeEventListener('astro:after-preparation', hide);
26
+ clearTimeout(timer);
27
+ };
28
+ }, []);
29
+
30
+ if (!visible) return null;
31
+
32
+ return (
33
+ <div
34
+ className={`fixed inset-0 z-[9999] flex flex-col items-center justify-center bg-[#0f0f0f] transition-opacity duration-500 ${fadeOut ? 'opacity-0 pointer-events-none' : 'opacity-100'}`}
35
+ >
36
+ <div className="relative mb-8 flex flex-col items-center">
37
+ <div className="relative w-80 h-32 overflow-hidden animate-pulse">
38
+ <img src="/logo-full.png" className="h-full w-full object-contain" alt="TPHIMX Full Logo" />
39
+ </div>
40
+ <div className="mt-4 text-[12px] font-black uppercase tracking-[0.6em] text-primary">Tuyệt mỹ từng khung hình</div>
41
+ <div className="mt-6 h-[2px] w-64 bg-gradient-to-r from-transparent via-primary to-transparent" />
42
+ </div>
43
+ <div className="flex gap-1.5">
44
+ {[0, 1, 2].map((i) => (
45
+ <div
46
+ key={i}
47
+ className="h-2.5 w-2.5 rounded-full bg-primary animate-bounce shadow-[0_0_8px_var(--primary)]"
48
+ style={{ animationDelay: `${i * 0.15}s` }}
49
+ />
50
+ ))}
51
+ </div>
52
+ <p className="mt-4 text-sm tracking-widest text-gray-500 uppercase">Đang tải...</p>
53
+ </div>
54
+ );
55
+ }
src/components/TopTenCard.jsx ADDED
@@ -0,0 +1,120 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Play, Info } from 'lucide-react';
2
+ import { useState, useRef } from 'react';
3
+ import { formatImageUrl } from '../lib/image';
4
+
5
+ export default function TopTenCard({ movie, index }) {
6
+ const [showPreview, setShowPreview] = useState(false);
7
+ const hoverTimer = useRef(null);
8
+
9
+ const handleMouseEnter = () => {
10
+ hoverTimer.current = setTimeout(() => {
11
+ setShowPreview(true);
12
+ }, 400); // Mượt mà hơn với 400ms phản hồi
13
+ };
14
+
15
+ const handleMouseLeave = () => {
16
+ if (hoverTimer.current) clearTimeout(hoverTimer.current);
17
+ setShowPreview(false);
18
+ };
19
+
20
+ const year = movie.year || (movie.modified?.time ? new Date(movie.modified.time).getFullYear() : '');
21
+ const epCount = movie.episode_current?.match(/\d+/)?.[0] || '??';
22
+
23
+ // horizontal backdrop for preview
24
+ const backdrop = formatImageUrl(movie.poster_url || movie.thumb_url);
25
+ const poster = formatImageUrl(movie.thumb_url || movie.poster_url);
26
+
27
+ return (
28
+ <div
29
+ className="relative flex flex-col group/card h-[240px] md:h-[340px] w-full"
30
+ onMouseEnter={handleMouseEnter}
31
+ onMouseLeave={handleMouseLeave}
32
+ >
33
+ {/* Giant Netflix Outlined Number */}
34
+ <div
35
+ className="pointer-events-none absolute select-none leading-none font-black transition-all duration-700 group-hover/card:scale-105"
36
+ style={{
37
+ left: '-12px',
38
+ bottom: '40px',
39
+ fontSize: '180px',
40
+ fontWeight: 950,
41
+ fontStyle: 'italic',
42
+ fontFamily: '"Outfit", "Impact", "Arial Black", sans-serif',
43
+ color: '#0c0c0c',
44
+ WebkitTextStroke: '4px rgba(255, 255, 255, 0.45)',
45
+ textShadow: '0 10px 30px rgba(0,0,0,0.9), 10px 10px 20px rgba(0,0,0,0.7)',
46
+ zIndex: 10,
47
+ }}
48
+ >
49
+ {index + 1}
50
+ </div>
51
+
52
+ {/* Overlapping Poster Card */}
53
+ <div className="relative ml-[35%] w-[65%] h-[80%] z-20 transition-all duration-500 group-hover/card:translate-x-2 group-hover/card:scale-[1.03]">
54
+ <div
55
+ className="absolute inset-0 overflow-hidden rounded-2xl shadow-[0_15px_30px_rgba(0,0,0,0.8)] ring-1 ring-white/10 group-hover/card:shadow-[0_20px_40px_rgba(229,9,20,0.45)] group-hover/card:ring-primary/50"
56
+ >
57
+ <img src={poster} alt={movie.name} className="h-full w-full object-cover" />
58
+ <div className="absolute inset-0 bg-gradient-to-t from-black/90 via-transparent to-transparent" />
59
+ </div>
60
+
61
+ {/* Episode Badge */}
62
+ <div className="absolute bottom-2 left-2 z-20 flex flex-wrap gap-1">
63
+ <span className="rounded px-2 py-[3px] text-[9px] md:text-[10px] font-black uppercase tracking-wide text-white bg-black/85 border border-white/10 backdrop-blur-sm shadow-md">
64
+ TẬP {epCount}
65
+ </span>
66
+ </div>
67
+
68
+ <a href={`/phim/${movie.slug}`} className="absolute inset-0 z-40 rounded-2xl" />
69
+ </div>
70
+
71
+ {/* Info below aligned with poster */}
72
+ <div className="ml-[35%] pl-3 pr-1 pt-3 h-[20%] flex flex-col justify-center min-w-0">
73
+ <h3 className="line-clamp-1 text-[13px] md:text-[15px] font-black uppercase tracking-tight text-white transition-colors duration-300 group-hover/card:text-primary">
74
+ {movie.name}
75
+ </h3>
76
+ <p className="mt-1 line-clamp-1 text-[9px] md:text-[11px] font-bold uppercase tracking-widest text-gray-400">
77
+ {movie.origin_name}
78
+ </p>
79
+ </div>
80
+
81
+ {/* Premium Hover Preview Popup */}
82
+ {showPreview && (
83
+ <div className="absolute left-[65%] top-[10%] z-[100] w-[280px] -translate-x-1/2 -translate-y-[20%] animate-in fade-in zoom-in duration-300 pointer-events-auto">
84
+ <div className="overflow-hidden rounded-2xl bg-[#141414] shadow-[0_25px_60px_rgba(0,0,0,0.95)] ring-1 ring-white/10 border border-white/5">
85
+ <div className="relative aspect-video w-full overflow-hidden">
86
+ <img src={backdrop} className="h-full w-full object-cover" alt="preview" />
87
+ <div className="absolute inset-0 bg-gradient-to-t from-[#141414] via-transparent to-transparent" />
88
+ <div className="absolute bottom-3 left-3 right-3 flex items-end justify-between">
89
+ <div className="flex flex-col min-w-0">
90
+ <span className="text-[10px] font-black text-white shadow-black drop-shadow-lg truncate">{movie.name}</span>
91
+ <span className="text-[7px] font-bold text-gray-400 uppercase tracking-widest truncate">{movie.origin_name}</span>
92
+ </div>
93
+ <span className="rounded bg-primary px-1.5 py-0.5 text-[7px] font-black text-white uppercase shrink-0 shadow-lg">{movie.quality || 'FHD'}</span>
94
+ </div>
95
+ </div>
96
+
97
+ <div className="p-4 bg-[#141414]">
98
+ <div className="flex items-center gap-3 mb-3.5">
99
+ <a href={`/xem/${movie.slug}`} className="flex flex-1 items-center justify-center gap-2 rounded-xl bg-white py-2.5 text-[9px] font-black uppercase tracking-widest text-black transition-all hover:bg-white/90 hover:scale-[1.02] active:scale-95 shadow-md">
100
+ <Play size={10} fill="black" /> Xem ngay
101
+ </a>
102
+ <a href={`/phim/${movie.slug}`} className="flex h-9 w-9 items-center justify-center rounded-xl bg-white/10 text-white transition-all hover:bg-white/20 hover:scale-[1.02] border border-white/5 shadow-md">
103
+ <Info size={14} />
104
+ </a>
105
+ </div>
106
+ <p className="line-clamp-2 text-[9px] leading-relaxed text-gray-400 font-medium">
107
+ {movie.content?.replace(/<[^>]*>/g, '') || 'Đang cập nhật nội dung cốt truyện...'}
108
+ </p>
109
+ <div className="mt-3.5 pt-3 border-t border-white/5 flex items-center gap-2 text-[8px] font-bold text-gray-500 uppercase tracking-wider">
110
+ <span>{year}</span>
111
+ <span className="h-1 w-1 rounded-full bg-gray-700" />
112
+ <span>{movie.episode_current}</span>
113
+ </div>
114
+ </div>
115
+ </div>
116
+ </div>
117
+ )}
118
+ </div>
119
+ );
120
+ }
src/components/TopTenSection.jsx ADDED
@@ -0,0 +1,119 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useRef, useState, useEffect } from 'react';
2
+ import { ChevronLeft, ChevronRight } from 'lucide-react';
3
+ import TopTenCard from './TopTenCard';
4
+
5
+ export default function TopTenSection({ series = [], rated = [], favorited = [] }) {
6
+ const [activeTab, setActiveTab] = useState('series');
7
+ const scrollRef = useRef(null);
8
+ const [showLeft, setShowLeft] = useState(false);
9
+ const [showRight, setShowRight] = useState(true);
10
+
11
+ const getMoviesForTab = () => {
12
+ switch (activeTab) {
13
+ case 'rated': return rated;
14
+ case 'favorited': return favorited;
15
+ case 'series':
16
+ default: return series;
17
+ }
18
+ };
19
+
20
+ const movies = getMoviesForTab();
21
+
22
+ const handleScroll = () => {
23
+ if (!scrollRef.current) return;
24
+ const { scrollLeft, scrollWidth, clientWidth } = scrollRef.current;
25
+ setShowLeft(scrollLeft > 10);
26
+ setShowRight(scrollLeft < scrollWidth - clientWidth - 10);
27
+ };
28
+
29
+ const scroll = (dir) => {
30
+ if (!scrollRef.current) return;
31
+ const amount = scrollRef.current.clientWidth * 0.8;
32
+ scrollRef.current.scrollBy({ left: dir * amount, behavior: 'smooth' });
33
+ };
34
+
35
+ // Reset scroll position on tab change
36
+ useEffect(() => {
37
+ if (scrollRef.current) {
38
+ scrollRef.current.scrollLeft = 0;
39
+ handleScroll();
40
+ }
41
+ }, [activeTab]);
42
+
43
+ if (!movies?.length) return null;
44
+
45
+ return (
46
+ <section className="mx-auto max-w-7xl px-4 py-10 md:px-8 overflow-hidden">
47
+ {/* Header Tabs */}
48
+ <div className="mb-10 flex flex-col md:flex-row md:items-center justify-between gap-6 border-b border-white/5 pb-5">
49
+ <div className="flex items-center gap-3">
50
+ <span className="h-8 w-2 rounded-full bg-primary shadow-[0_0_20px_rgba(229,9,20,0.6)]" />
51
+ <h2 className="text-2xl font-black uppercase tracking-tight text-white">
52
+ Bảng Xếp Hạng
53
+ </h2>
54
+ </div>
55
+
56
+ {/* Premium Tab Buttons */}
57
+ <div className="flex flex-wrap gap-2 bg-white/5 p-1 rounded-2xl border border-white/5 backdrop-blur-md self-start md:self-auto">
58
+ {[
59
+ { id: 'series', name: 'Top Phim Bộ', icon: 'fa-fire' },
60
+ { id: 'rated', name: 'Đánh Giá Cao', icon: 'fa-star' },
61
+ { id: 'favorited', name: 'Yêu Thích Nhất', icon: 'fa-heart' }
62
+ ].map(tab => (
63
+ <button
64
+ key={tab.id}
65
+ onClick={() => setActiveTab(tab.id)}
66
+ className={`flex items-center gap-2 px-5 py-2.5 rounded-xl text-[10px] font-black uppercase tracking-widest transition-all duration-300 cursor-pointer ${
67
+ activeTab === tab.id
68
+ ? 'bg-primary text-white shadow-[0_0_20px_rgba(229,9,20,0.3)] scale-[1.02]'
69
+ : 'text-gray-400 hover:text-white hover:bg-white/5'
70
+ }`}
71
+ >
72
+ <i className={`fas ${tab.icon} text-[9px] ${activeTab === tab.id ? 'text-white' : 'text-gray-500'}`}></i>
73
+ {tab.name}
74
+ </button>
75
+ ))}
76
+ </div>
77
+ </div>
78
+
79
+ {/* Row có scroll ngang */}
80
+ <div className="relative group/section">
81
+ {/* Nút trái */}
82
+ {showLeft && (
83
+ <button
84
+ onClick={() => scroll(-1)}
85
+ className="absolute left-1 top-[42%] z-40 -translate-y-1/2 flex h-10 w-10 items-center justify-center rounded-full bg-black/80 text-white backdrop-blur-sm opacity-0 transition-all duration-300 group-hover/section:opacity-100 hover:bg-primary border border-white/10 shadow-lg cursor-pointer"
86
+ >
87
+ <ChevronLeft size={20} />
88
+ </button>
89
+ )}
90
+
91
+ {/* Nút phải */}
92
+ {showRight && (
93
+ <button
94
+ onClick={() => scroll(1)}
95
+ className="absolute right-1 top-[42%] z-40 -translate-y-1/2 flex h-10 w-10 items-center justify-center rounded-full bg-black/80 text-white backdrop-blur-sm opacity-0 transition-all duration-300 group-hover/section:opacity-100 hover:bg-primary border border-white/10 shadow-lg cursor-pointer"
96
+ >
97
+ <ChevronRight size={20} />
98
+ </button>
99
+ )}
100
+
101
+ {/* Track */}
102
+ <div
103
+ ref={scrollRef}
104
+ onScroll={handleScroll}
105
+ className="scrollbar-hide flex overflow-x-auto pb-16 pt-2 px-4 md:px-8 gap-6 md:gap-8"
106
+ >
107
+ {movies.slice(0, 10).map((movie, idx) => (
108
+ <div
109
+ key={`${movie._id || movie.slug}-${activeTab}-${idx}`}
110
+ className="w-[180px] md:w-[240px] flex-shrink-0"
111
+ >
112
+ <TopTenCard movie={movie} index={idx} />
113
+ </div>
114
+ ))}
115
+ </div>
116
+ </div>
117
+ </section>
118
+ );
119
+ }
src/env.d.ts ADDED
@@ -0,0 +1 @@
 
 
1
+ /// <reference types="astro/client" />
src/layouts/AdminLayout.astro ADDED
@@ -0,0 +1,463 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ import '../styles/global.css';
3
+ import { ClientRouter } from 'astro:transitions';
4
+
5
+ interface Props {
6
+ title: string;
7
+ }
8
+
9
+ const { title } = Astro.props;
10
+
11
+ // Mock admin check - in real app, check role from cookie/session
12
+ const token = Astro.cookies.get('auth_token')?.value;
13
+ let user = null;
14
+ if (token) {
15
+ try {
16
+ user = JSON.parse(Buffer.from(token, 'base64').toString('utf-8'));
17
+ } catch(e) {}
18
+ }
19
+
20
+ let forceRedirect = false;
21
+ if (!user || user.role !== 'admin') {
22
+ forceRedirect = true;
23
+ }
24
+
25
+ const sidebarLinks = [
26
+ { href: '/admin', label: 'Tổng quan', icon: 'fas fa-chart-line' },
27
+ { href: '/admin/duyet-zalo', label: 'Duyệt Zalo', icon: 'fas fa-user-check', badgeId: 'nav-badge-zalo' },
28
+ { href: '/admin/bao-cao-loi', label: 'Báo cáo lỗi', icon: 'fas fa-exclamation-triangle', badgeId: 'nav-badge-loi' },
29
+ { href: '/admin/phim', label: 'Quản lý phim', icon: 'fas fa-film' },
30
+ { href: '/admin/crawl', label: 'Thu thập phim', icon: 'fas fa-spider' },
31
+ { href: '/admin/crawl-nang-cao', label: 'Cào nâng cao', icon: 'fas fa-search-plus' },
32
+ { href: '/admin/server', label: 'Quản lý Server', icon: 'fas fa-server' },
33
+ { href: '/admin/the-loai', label: 'Thể loại', icon: 'fas fa-tags' },
34
+ { href: '/admin/quoc-gia', label: 'Quốc gia', icon: 'fas fa-globe' },
35
+ { href: '/admin/dien-vien', label: 'Diễn viên', icon: 'fas fa-user-friends' },
36
+ { href: '/admin/nguoi-dung', label: 'Người dùng', icon: 'fas fa-users' },
37
+ { href: '/admin/binh-luan', label: 'Bình luận', icon: 'fas fa-comments', badgeId: 'nav-badge-comment' },
38
+ { href: '/admin/email', label: 'Lịch sử Email', icon: 'fas fa-envelope-open-text' },
39
+ { href: '/admin/cai-dat', label: 'Cài đặt', icon: 'fas fa-cog' },
40
+ ];
41
+ ---
42
+
43
+ <!doctype html>
44
+ <html lang="vi">
45
+ <head>
46
+ <meta charset="UTF-8" />
47
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
48
+ {forceRedirect && <meta http-equiv="refresh" content="0;url=/dang-nhap" />}
49
+ <title>{title} - TPHIMX Admin</title>
50
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" />
51
+
52
+ <!-- TXA Core Libraries -->
53
+ <script is:inline src="/lib/txa/txaformat.js" defer></script>
54
+ <script is:inline src="/lib/txa/txatooltip.js" defer></script>
55
+ <script is:inline src="/lib/txa/txatoast.js" defer></script>
56
+ <script is:inline src="/lib/txa/txamodal.js" type="module" defer></script>
57
+
58
+ <ClientRouter />
59
+ </head>
60
+ <body class="bg-[#0b0c10] text-white selection:bg-primary selection:text-dark overflow-x-hidden w-full max-w-full">
61
+ {forceRedirect ? (
62
+ <div class="fixed inset-0 z-[10000] flex flex-col items-center justify-center bg-[#0b0c10]">
63
+ <div class="w-10 h-10 rounded-full border-4 border-white/5 border-t-primary animate-spin shadow-[0_0_30px_rgba(124,58,237,0.4)] mb-4"></div>
64
+ <p class="text-xs font-black uppercase tracking-widest text-primary animate-pulse">Vui lòng đăng nhập...</p>
65
+ </div>
66
+ ) : (
67
+ <>
68
+ <!-- Premium Cinematic Splash Loading Screen -->
69
+ <div id="txa-splash-loader" class="fixed inset-0 z-[9999] flex flex-col items-center justify-center bg-[#0b0c10] transition-all duration-700">
70
+ <!-- Glowing aura behind logo -->
71
+ <div class="absolute w-[400px] h-[400px] bg-primary/10 rounded-full blur-[120px] animate-pulse"></div>
72
+
73
+ <div class="relative flex flex-col items-center gap-6 scale-95 animate-fade-in">
74
+ <!-- Spinning Outer Ring -->
75
+ <div class="relative flex items-center justify-center">
76
+ <div class="w-24 h-24 rounded-full border-4 border-white/5 border-t-primary animate-spin shadow-[0_0_30px_rgba(124,58,237,0.4)]"></div>
77
+ <!-- Logo Icon inside -->
78
+ <div class="absolute w-14 h-14 overflow-hidden rounded-2xl shadow-lg">
79
+ <img src="/logo-icon.png" class="h-full w-full object-cover" />
80
+ </div>
81
+ </div>
82
+
83
+ <!-- Branding & Subtext -->
84
+ <div class="flex flex-col items-center">
85
+ <div class="text-3xl font-black tracking-tighter text-white animate-pulse">
86
+ T<span class="text-primary">PHIM</span>X
87
+ </div>
88
+ <div class="mt-2 text-[10px] font-black uppercase tracking-[0.2em] text-gray-500">
89
+ Hệ thống Quản trị Cao cấp
90
+ </div>
91
+ </div>
92
+
93
+ <!-- Premium Horizontal Progress Bar -->
94
+ <div class="w-48 h-1 bg-white/5 rounded-full overflow-hidden border border-white/5">
95
+ <div class="h-full bg-gradient-to-r from-primary to-violet-400 w-1/3 rounded-full animate-progress-loading"></div>
96
+ </div>
97
+ </div>
98
+ </div>
99
+
100
+ <div class="flex min-h-screen w-full max-w-full overflow-x-hidden">
101
+ <!-- Desktop Sidebar -->
102
+ <aside class="fixed left-0 top-0 hidden h-screen w-72 border-r border-white/5 bg-dark-light lg:block z-[100]">
103
+ <div class="flex h-full flex-col p-8">
104
+ <div class="mb-12 flex items-center gap-4">
105
+ <div class="h-10 w-10 overflow-hidden rounded-xl shadow-[0_0_20px_var(--primary)]">
106
+ <img src="/logo-icon.png" class="h-full w-full object-cover" />
107
+ </div>
108
+ <div class="text-2xl font-black tracking-tighter">
109
+ T<span class="text-primary">PHIM</span>X
110
+ </div>
111
+ </div>
112
+
113
+ <nav class="flex-1 space-y-1.5 overflow-y-auto pr-2 scrollbar-thin">
114
+ {sidebarLinks.map(link => {
115
+ const isActive = Astro.url.pathname === link.href || (link.href !== '/admin' && Astro.url.pathname.startsWith(link.href + '/'));
116
+ return (
117
+ <a
118
+ href={link.href}
119
+ class={`flex items-center gap-4 rounded-2xl px-6 py-3.5 text-xs font-black uppercase tracking-widest transition-all group ${
120
+ isActive
121
+ ? 'bg-primary/15 text-primary border-l-4 border-primary shadow-[0_0_15px_rgba(124,58,237,0.1)]'
122
+ : 'text-gray-500 hover:bg-white/5 hover:text-white active:bg-primary/10'
123
+ }`}
124
+ >
125
+ <i class={`${link.icon} text-base ${isActive ? 'text-primary animate-pulse' : 'group-hover:text-primary transition-colors'}`}></i>
126
+ <span>{link.label}</span>
127
+ {link.badgeId && (
128
+ <span
129
+ id={link.badgeId}
130
+ class={`hidden ml-auto px-2 py-0.5 rounded-full text-[9px] font-black text-white min-w-[18px] text-center animate-pulse ${
131
+ link.badgeId === 'nav-badge-loi' ? 'bg-red-500 shadow-[0_0_10px_rgba(239,68,68,0.4)]' :
132
+ link.badgeId === 'nav-badge-comment' ? 'bg-amber-500 shadow-[0_0_10px_rgba(245,158,11,0.4)]' :
133
+ 'bg-primary shadow-[0_0_10px_rgba(124,58,237,0.4)]'
134
+ }`}
135
+ >
136
+ 0
137
+ </span>
138
+ )}
139
+ </a>
140
+ );
141
+ })}
142
+ </nav>
143
+
144
+ <div class="mt-auto pt-6 border-t border-white/5">
145
+ <div class="flex items-center gap-4">
146
+ {user?.avatar_url ? (
147
+ <div class="h-10 w-10 overflow-hidden rounded-full shrink-0 shadow-lg">
148
+ <img src={user.avatar_url} class="h-full w-full object-cover" />
149
+ </div>
150
+ ) : (
151
+ <div class="h-10 w-10 rounded-full bg-primary flex items-center justify-center text-dark font-black shrink-0">
152
+ {user?.username?.[0]?.toUpperCase() || 'A'}
153
+ </div>
154
+ )}
155
+ <div class="flex flex-col">
156
+ <span class="text-sm font-bold text-white">{user?.username || 'Admin'}</span>
157
+ <span class="text-[10px] font-black uppercase tracking-widest text-gray-500">Quản trị viên</span>
158
+ </div>
159
+ <a href="/" class="ml-auto text-gray-500 hover:text-red-500 transition-colors">
160
+ <i class="fas fa-sign-out-alt"></i>
161
+ </a>
162
+ </div>
163
+ </div>
164
+ </div>
165
+ </aside>
166
+
167
+ <!-- Mobile Top Header -->
168
+ <header class="fixed top-0 left-0 right-0 z-[90] flex h-16 items-center justify-between border-b border-white/5 bg-[#12141d]/80 px-6 backdrop-blur-xl lg:hidden">
169
+ <div class="flex items-center gap-3">
170
+ <div class="h-8 w-8 overflow-hidden rounded-lg shadow-[0_0_15px_rgba(124,58,237,0.5)]">
171
+ <img src="/logo-icon.png" class="h-full w-full object-cover" />
172
+ </div>
173
+ <span class="text-lg font-black tracking-tighter text-white">T<span class="text-primary">PHIM</span>X</span>
174
+ </div>
175
+ <button id="mobile-menu-trigger" class="h-10 w-10 rounded-xl bg-white/5 flex items-center justify-center text-gray-400 hover:text-white border border-white/5 active:scale-95 transition-all">
176
+ <i class="fas fa-bars"></i>
177
+ </button>
178
+ </header>
179
+
180
+ <!-- Mobile Slide-out Drawer -->
181
+ <div id="mobile-drawer" class="fixed inset-0 z-[200] hidden bg-black/60 opacity-0 backdrop-blur-md transition-all duration-300 pointer-events-none">
182
+ <div class="absolute inset-y-0 right-0 w-[280px] bg-[#12141d] border-l border-white/5 p-8 flex flex-col justify-between transform translate-x-full transition-transform duration-300 pointer-events-auto overflow-y-auto scrollbar-thin">
183
+ <div>
184
+ <div class="flex items-center justify-between mb-12">
185
+ <div class="flex items-center gap-3">
186
+ <div class="h-8 w-8 overflow-hidden rounded-lg shadow-[0_0_15px_rgba(124,58,237,0.5)]">
187
+ <img src="/logo-icon.png" class="h-full w-full object-cover" />
188
+ </div>
189
+ <span class="text-lg font-black tracking-tighter text-white">T<span class="text-primary">PHIM</span>X</span>
190
+ </div>
191
+ <button id="mobile-menu-close" class="h-8 w-8 rounded-lg bg-white/5 flex items-center justify-center text-gray-400 hover:text-white border border-white/5">
192
+ <i class="fas fa-times text-sm"></i>
193
+ </button>
194
+ </div>
195
+
196
+ <nav class="space-y-1.5 pr-1">
197
+ {sidebarLinks.map(link => {
198
+ const isActive = Astro.url.pathname === link.href || (link.href !== '/admin' && Astro.url.pathname.startsWith(link.href + '/'));
199
+ return (
200
+ <a
201
+ href={link.href}
202
+ class={`flex items-center gap-4 rounded-xl px-5 py-3.5 text-xs font-black uppercase tracking-widest transition-all group ${
203
+ isActive
204
+ ? 'bg-primary/15 text-primary border-l-4 border-primary shadow-[0_0_15px_rgba(124,58,237,0.1)]'
205
+ : 'text-gray-500 hover:bg-white/5 hover:text-white active:bg-primary/10'
206
+ }`}
207
+ >
208
+ <i class={`${link.icon} text-base ${isActive ? 'text-primary' : 'group-hover:text-primary transition-colors'}`}></i>
209
+ <span>{link.label}</span>
210
+ {link.badgeId && (
211
+ <span
212
+ id={`${link.badgeId}-mobile`}
213
+ class={`hidden ml-auto px-2 py-0.5 rounded-full text-[9px] font-black text-white min-w-[18px] text-center animate-pulse ${
214
+ link.badgeId === 'nav-badge-loi' ? 'bg-red-500 shadow-[0_0_10px_rgba(239,68,68,0.4)]' :
215
+ link.badgeId === 'nav-badge-comment' ? 'bg-amber-500 shadow-[0_0_10px_rgba(245,158,11,0.4)]' :
216
+ 'bg-primary shadow-[0_0_10px_rgba(124,58,237,0.4)]'
217
+ }`}
218
+ >
219
+ 0
220
+ </span>
221
+ )}
222
+ </a>
223
+ );
224
+ })}
225
+ </nav>
226
+ </div>
227
+
228
+ <div class="pt-8 border-t border-white/5">
229
+ <div class="flex items-center gap-4">
230
+ {user?.avatar_url ? (
231
+ <div class="h-10 w-10 overflow-hidden rounded-full shrink-0 shadow-lg">
232
+ <img src={user.avatar_url} class="h-full w-full object-cover" />
233
+ </div>
234
+ ) : (
235
+ <div class="h-10 w-10 rounded-full bg-primary flex items-center justify-center text-dark font-black shrink-0">
236
+ {user?.username?.[0]?.toUpperCase() || 'A'}
237
+ </div>
238
+ )}
239
+ <div class="flex flex-col">
240
+ <span class="text-sm font-bold text-white">{user?.username || 'Admin'}</span>
241
+ <span class="text-[10px] font-black uppercase tracking-widest text-gray-500">Quản trị viên</span>
242
+ </div>
243
+ <a href="/" class="ml-auto text-gray-500 hover:text-red-500 transition-colors">
244
+ <i class="fas fa-sign-out-alt"></i>
245
+ </a>
246
+ </div>
247
+ </div>
248
+ </div>
249
+ </div>
250
+
251
+ <!-- Main Content -->
252
+ <main class="flex-1 lg:ml-72 p-4 sm:p-6 pt-24 pb-24 lg:p-12 lg:pt-12 overflow-x-hidden max-w-full">
253
+ <slot />
254
+ </main>
255
+ </div>
256
+ </>
257
+ )}
258
+ </body>
259
+ </html>
260
+
261
+ <script>
262
+ // Hide splash loader on initial load with a smooth buffer delay
263
+ const hideInitialSplash = () => {
264
+ const splash = document.getElementById('txa-splash-loader');
265
+ if (splash) {
266
+ setTimeout(() => {
267
+ splash.style.opacity = '0';
268
+ splash.style.pointerEvents = 'none';
269
+ setTimeout(() => {
270
+ splash.style.display = 'none';
271
+ }, 700);
272
+ }, 450); // 450ms buffer delay to let admin panels mount seamlessly
273
+ }
274
+ };
275
+
276
+ if (document.readyState === 'complete') {
277
+ hideInitialSplash();
278
+ } else {
279
+ window.addEventListener('load', () => {
280
+ setTimeout(hideInitialSplash, 150);
281
+ });
282
+ }
283
+
284
+ // Handle transition page load
285
+ document.addEventListener('astro:before-preparation', () => {
286
+ const splash = document.getElementById('txa-splash-loader');
287
+ if (splash) {
288
+ splash.style.display = 'flex';
289
+ void splash.offsetWidth;
290
+ splash.style.opacity = '1';
291
+ splash.style.pointerEvents = 'auto';
292
+ }
293
+ });
294
+
295
+ document.addEventListener('astro:page-load', () => {
296
+ // Hide splash loader on navigation complete
297
+ hideInitialSplash();
298
+
299
+ if ((window as any).initTxaTooltips) (window as any).initTxaTooltips();
300
+
301
+ // Mobile Drawer Logic
302
+ const trigger = document.getElementById('mobile-menu-trigger');
303
+ const closeBtn = document.getElementById('mobile-menu-close');
304
+ const drawer = document.getElementById('mobile-drawer');
305
+ const panel = drawer?.querySelector('.pointer-events-auto');
306
+
307
+ const openDrawer = () => {
308
+ if (drawer && panel) {
309
+ drawer.classList.remove('hidden');
310
+ // Force reflow
311
+ void drawer.offsetWidth;
312
+ drawer.classList.add('opacity-100');
313
+ drawer.classList.remove('pointer-events-none');
314
+ panel.classList.remove('translate-x-full');
315
+ }
316
+ };
317
+
318
+ const closeDrawer = () => {
319
+ if (drawer && panel) {
320
+ drawer.classList.remove('opacity-100');
321
+ drawer.classList.add('pointer-events-none');
322
+ panel.classList.add('translate-x-full');
323
+ setTimeout(() => {
324
+ drawer.classList.add('hidden');
325
+ }, 300);
326
+ }
327
+ };
328
+
329
+ trigger?.addEventListener('click', openDrawer);
330
+ closeBtn?.addEventListener('click', closeDrawer);
331
+ drawer?.addEventListener('click', (e) => {
332
+ if (e.target === drawer) closeDrawer();
333
+ });
334
+
335
+ // Real-time notifications logic (8 seconds polling)
336
+ const pollNotifications = async () => {
337
+ try {
338
+ const res = await fetch('/api/admin/notifications');
339
+ if (!res.ok) return;
340
+ const data = await res.json();
341
+ if (!data.success) return;
342
+
343
+ // 1. Zalo badge
344
+ const zaloCount = data.pendingZalo;
345
+ ['nav-badge-zalo', 'nav-badge-zalo-mobile'].forEach(id => {
346
+ const badge = document.getElementById(id);
347
+ if (badge) {
348
+ if (zaloCount > 0) {
349
+ badge.textContent = zaloCount;
350
+ badge.classList.remove('hidden');
351
+ } else {
352
+ badge.classList.add('hidden');
353
+ }
354
+ }
355
+ });
356
+
357
+ // 2. Report badge
358
+ const reportCount = data.pendingReports;
359
+ ['nav-badge-loi', 'nav-badge-loi-mobile'].forEach(id => {
360
+ const badge = document.getElementById(id);
361
+ if (badge) {
362
+ if (reportCount > 0) {
363
+ badge.textContent = reportCount;
364
+ badge.classList.remove('hidden');
365
+ } else {
366
+ badge.classList.add('hidden');
367
+ }
368
+ }
369
+ });
370
+
371
+ // 3. Comments badge
372
+ if (window.location.pathname.startsWith('/admin/binh-luan')) {
373
+ localStorage.setItem('admin_last_viewed_comments', new Date().toISOString());
374
+ }
375
+
376
+ const lastViewedStr = localStorage.getItem('admin_last_viewed_comments');
377
+ let newCommentsCount = 0;
378
+ if (lastViewedStr && data.comments) {
379
+ const lastViewedTime = new Date(lastViewedStr).getTime();
380
+ newCommentsCount = data.comments.filter(c => new Date(c.created_at).getTime() > lastViewedTime).length;
381
+ } else if (data.comments) {
382
+ const last24h = Date.now() - 24 * 60 * 60 * 1000;
383
+ newCommentsCount = data.comments.filter(c => new Date(c.created_at).getTime() > last24h).length;
384
+ }
385
+
386
+ ['nav-badge-comment', 'nav-badge-comment-mobile'].forEach(id => {
387
+ const badge = document.getElementById(id);
388
+ if (badge) {
389
+ if (newCommentsCount > 0) {
390
+ badge.textContent = newCommentsCount;
391
+ badge.classList.remove('hidden');
392
+ } else {
393
+ badge.classList.add('hidden');
394
+ }
395
+ }
396
+ });
397
+
398
+ // 4. Comparison and Toast alerts
399
+ const prevZalo = parseInt(sessionStorage.getItem('prev_pending_zalo') || '0', 10);
400
+ const prevReports = parseInt(sessionStorage.getItem('prev_pending_reports') || '0', 10);
401
+ const prevComments = parseInt(sessionStorage.getItem('prev_new_comments') || '0', 10);
402
+
403
+ // Make sure toast objects are present
404
+ if ((window as any).txatoast) {
405
+ if (zaloCount > prevZalo) {
406
+ (window as any).txatoast.info(`🔔 Có yêu cầu duyệt Zalo mới đang chờ phê duyệt!`);
407
+ }
408
+ if (reportCount > prevReports) {
409
+ (window as any).txatoast.warning(`⚠️ Có báo cáo lỗi phim mới chưa được xử lý!`);
410
+ }
411
+ if (newCommentsCount > prevComments) {
412
+ (window as any).txatoast.success(`💬 Có bình luận mới của người xem trên trang phim!`);
413
+ }
414
+ }
415
+
416
+ // Cache counts
417
+ sessionStorage.setItem('prev_pending_zalo', zaloCount.toString());
418
+ sessionStorage.setItem('prev_pending_reports', reportCount.toString());
419
+ sessionStorage.setItem('prev_new_comments', newCommentsCount.toString());
420
+
421
+ } catch (err) {
422
+ console.error('Failed to fetch notifications:', err);
423
+ }
424
+ };
425
+
426
+ // Expose notification fetcher globally so admin sub-pages can refresh counts immediately after any actions
427
+ (window as any).txaPollNotifications = pollNotifications;
428
+
429
+ // Initial fetch
430
+ pollNotifications();
431
+
432
+ // Clear previous interval if any
433
+ if ((window as any).txaNotificationInterval) {
434
+ clearInterval((window as any).txaNotificationInterval);
435
+ }
436
+
437
+ // Set polling interval
438
+ (window as any).txaNotificationInterval = setInterval(pollNotifications, 8000);
439
+ });
440
+ </script>
441
+
442
+ <style>
443
+ @theme {
444
+ --color-primary: #7c3aed;
445
+ --color-dark: #0b0c10;
446
+ --color-dark-light: #12141d;
447
+ }
448
+ @keyframes progress-loading {
449
+ 0% { transform: translateX(-100%); }
450
+ 50% { transform: translateX(100%); }
451
+ 100% { transform: translateX(-100%); }
452
+ }
453
+ .animate-progress-loading {
454
+ animation: progress-loading 2s infinite linear;
455
+ }
456
+ @keyframes fade-in {
457
+ from { opacity: 0; transform: scale(0.9); }
458
+ to { opacity: 1; transform: scale(1); }
459
+ }
460
+ .animate-fade-in {
461
+ animation: fade-in 0.6s cubic-bezier(0.16, 1, 0.3, 1) forwards;
462
+ }
463
+ </style>