Spaces:
Sleeping
Sleeping
TXAVLOG commited on
Commit ·
4bea261
0
Parent(s):
Deploy DPTXA to Hugging Face Spaces
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .env +6 -0
- .gitattributes +4 -0
- .gitignore +19 -0
- Dockerfile +19 -0
- astro.config.mjs +23 -0
- cloudflare-worker/README.md +49 -0
- cloudflare-worker/index.js +274 -0
- cloudflare-worker/wrangler.toml +9 -0
- package-lock.json +0 -0
- package.json +33 -0
- public/favicon.ico +3 -0
- public/favicon.png +3 -0
- public/favicon.svg +3 -0
- public/images/auth/bg.png +3 -0
- public/lib/txa/txacomment.js +493 -0
- public/lib/txa/txaformat.js +67 -0
- public/lib/txa/txamodal.js +138 -0
- public/lib/txa/txaplayer.js +0 -0
- public/lib/txa/txatoast.js +199 -0
- public/lib/txa/txatooltip.js +198 -0
- public/locations.json +0 -0
- public/logo-full.png +3 -0
- public/logo-icon.png +3 -0
- public/logo-square.png +3 -0
- public/logo.svg +3 -0
- public/robots.txt +6 -0
- scratch/test_fetch.js +18 -0
- scratch/test_variations.js +24 -0
- scripts/apply_subtitle_update.js +118 -0
- scripts/format-xml.js +51 -0
- src/components/CombinedRegionSection.jsx +132 -0
- src/components/DeferredHero.astro +87 -0
- src/components/DeferredSection.astro +24 -0
- src/components/DeferredTopTen.astro +66 -0
- src/components/EpisodeList.jsx +170 -0
- src/components/Footer.jsx +206 -0
- src/components/Header.astro +677 -0
- src/components/Header.jsx +100 -0
- src/components/HeroBanner.jsx +223 -0
- src/components/HomeSectionsLoader.jsx +147 -0
- src/components/MovieCard.jsx +127 -0
- src/components/MovieSection.jsx +92 -0
- src/components/MovieSlider.jsx +118 -0
- src/components/PaginatedGrid.jsx +104 -0
- src/components/Pagination.jsx +66 -0
- src/components/SplashScreen.jsx +55 -0
- src/components/TopTenCard.jsx +120 -0
- src/components/TopTenSection.jsx +119 -0
- src/env.d.ts +1 -0
- 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
|
public/favicon.png
ADDED
|
|
Git LFS Details
|
public/favicon.svg
ADDED
|
|
Git LFS Details
|
public/images/auth/bg.png
ADDED
|
Git LFS Details
|
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
|
public/logo-icon.png
ADDED
|
|
Git LFS Details
|
public/logo-square.png
ADDED
|
Git LFS Details
|
public/logo.svg
ADDED
|
|
Git LFS Details
|
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} /> © {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>
|