mirror of
https://github.com/ngthanhvu/kr-phim.git
synced 2026-08-10 16:07:47 +00:00
feat: implement core Nuxt application with film browsing, search, and detail page functionality using OPhim and NguonC APIs.
This commit is contained in:
@@ -0,0 +1,50 @@
|
|||||||
|
# Dành cho nhà phát triển Website
|
||||||
|
|
||||||
|
## Danh sách phim
|
||||||
|
|
||||||
|
### Phim mới cập nhật
|
||||||
|
**GET** `https://phim.nguonc.com/api/films/phim-moi-cap-nhat?page=${page}`
|
||||||
|
|
||||||
|
Ví dụ: `https://phim.nguonc.com/api/films/phim-moi-cap-nhat?page=1`
|
||||||
|
|
||||||
|
### Phim theo danh mục
|
||||||
|
**GET** `https://phim.nguonc.com/api/films/danh-sach/${slug}?page=${page}`
|
||||||
|
|
||||||
|
Ví dụ: `https://phim.nguonc.com/api/films/danh-sach/phim-dang-chieu?page=1`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phim & Tập Phim
|
||||||
|
|
||||||
|
### Thông tin Phim & Danh sách tập phim
|
||||||
|
**GET** `https://phim.nguonc.com/api/film/${slug}`
|
||||||
|
|
||||||
|
Ví dụ: `https://phim.nguonc.com/api/film/hoa-thien-cot`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Thể loại & Quốc gia & Năm
|
||||||
|
|
||||||
|
### Phim theo thể loại
|
||||||
|
**GET** `https://phim.nguonc.com/api/films/the-loai/${slug}?page=${page}`
|
||||||
|
|
||||||
|
Ví dụ: `https://phim.nguonc.com/api/films/the-loai/hanh-dong?page=1`
|
||||||
|
|
||||||
|
### Phim theo quốc gia
|
||||||
|
**GET** `https://phim.nguonc.com/api/films/quoc-gia/${slug}?page=${page}`
|
||||||
|
|
||||||
|
Ví dụ: `https://phim.nguonc.com/api/films/quoc-gia/au-my?page=1`
|
||||||
|
|
||||||
|
### Phim theo năm
|
||||||
|
**GET** `https://phim.nguonc.com/api/films/nam-phat-hanh/${slug}?page=${page}`
|
||||||
|
|
||||||
|
Ví dụ: `https://phim.nguonc.com/api/films/nam-phat-hanh/2024?page=1`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tìm Phim
|
||||||
|
|
||||||
|
### Tìm kiếm phim
|
||||||
|
**GET** `https://phim.nguonc.com/api/films/search?keyword=${slug}`
|
||||||
|
|
||||||
|
Ví dụ: `https://phim.nguonc.com/api/films/search?keyword=Regeneration`
|
||||||
+992
@@ -0,0 +1,992 @@
|
|||||||
|
# OPhim API — tài liệu chi tiết cho AI Agent / Antigravity / Codex
|
||||||
|
|
||||||
|
> Nguồn tổng hợp từ trang API public của OPhim và các ví dụ endpoint public đang được cộng đồng sử dụng. Tài liệu này viết theo hướng để AI coding agent có thể đọc, hiểu nhanh và tự sinh code tích hợp.
|
||||||
|
|
||||||
|
## 1. Tổng quan
|
||||||
|
|
||||||
|
OPhim cung cấp API REST để lấy dữ liệu phim, danh sách phim, tìm kiếm, lọc theo thể loại/quốc gia/năm và lấy chi tiết phim.
|
||||||
|
|
||||||
|
### Thông tin chung
|
||||||
|
|
||||||
|
| Thuộc tính | Giá trị |
|
||||||
|
|---|---|
|
||||||
|
| Base URL chính | `https://ophim1.com` |
|
||||||
|
| Kiểu dữ liệu | `JSON` |
|
||||||
|
| Encoding | `UTF-8` |
|
||||||
|
| HTTP method | `GET` |
|
||||||
|
| Auth | Không thấy yêu cầu token ở docs public |
|
||||||
|
| Image CDN thường dùng | `https://img.ophim.live/uploads/movies/` |
|
||||||
|
|
||||||
|
### Quy ước quan trọng
|
||||||
|
|
||||||
|
- API trả về dữ liệu phim dạng JSON.
|
||||||
|
- Dữ liệu danh sách thường có `items` hoặc `data.items`, tùy endpoint cũ/mới.
|
||||||
|
- Ảnh poster/thumb thường chỉ trả về filename, ví dụ `one-piece-thumb.jpg`. Muốn hiển thị ảnh cần nối với `pathImage` hoặc CDN:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const imageUrl = `${pathImage}${movie.thumb_url}`;
|
||||||
|
// hoặc fallback
|
||||||
|
const imageUrl = `https://img.ophim.live/uploads/movies/${movie.thumb_url}`;
|
||||||
|
```
|
||||||
|
|
||||||
|
- Danh sách phim chỉ nên dùng để lấy metadata cơ bản và `slug`.
|
||||||
|
- Muốn lấy link xem phim / tập phim thì gọi chi tiết phim theo `slug`.
|
||||||
|
- Không nên crawl toàn bộ detail của hàng chục nghìn phim cùng lúc. Nên phân trang, cache, debounce search, và chỉ gọi chi tiết khi user mở trang phim.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Hai nhóm endpoint cần biết
|
||||||
|
|
||||||
|
Hiện có 2 kiểu endpoint thường gặp:
|
||||||
|
|
||||||
|
### 2.1. Endpoint legacy
|
||||||
|
|
||||||
|
Các endpoint dạng ngắn:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
GET https://ophim1.com/danh-sach/phim-moi-cap-nhat?page=1
|
||||||
|
GET https://ophim1.com/phim/{slug}
|
||||||
|
```
|
||||||
|
|
||||||
|
Endpoint legacy thường trả response dạng:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface LegacyListResponse {
|
||||||
|
status: boolean;
|
||||||
|
items: MovieListItem[];
|
||||||
|
pathImage: string;
|
||||||
|
pagination: Pagination;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2. Endpoint v1
|
||||||
|
|
||||||
|
Các endpoint dạng `/v1/api/...`:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
GET https://ophim1.com/v1/api/home
|
||||||
|
GET https://ophim1.com/v1/api/danh-sach/{type}?page=1
|
||||||
|
GET https://ophim1.com/v1/api/phim/{slug}
|
||||||
|
```
|
||||||
|
|
||||||
|
Endpoint v1 thường được cộng đồng dùng theo dạng:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface V1Response<T> {
|
||||||
|
status: boolean;
|
||||||
|
msg?: string;
|
||||||
|
data: T;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Trong code nên viết adapter để hỗ trợ cả 2 format nếu cần.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Kiểu dữ liệu TypeScript đề xuất
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export interface Pagination {
|
||||||
|
totalItems?: number;
|
||||||
|
totalItemsPerPage?: number;
|
||||||
|
currentPage?: number | null;
|
||||||
|
totalPages?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TmdbInfo {
|
||||||
|
type?: 'movie' | 'tv' | string;
|
||||||
|
id?: string | number;
|
||||||
|
season?: number | null;
|
||||||
|
vote_average?: number;
|
||||||
|
vote_count?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImdbInfo {
|
||||||
|
id?: string;
|
||||||
|
vote_average?: number;
|
||||||
|
vote_count?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MovieListItem {
|
||||||
|
_id?: string;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
origin_name?: string;
|
||||||
|
thumb_url?: string;
|
||||||
|
poster_url?: string;
|
||||||
|
year?: number;
|
||||||
|
modified?: {
|
||||||
|
time?: string;
|
||||||
|
};
|
||||||
|
tmdb?: TmdbInfo;
|
||||||
|
imdb?: ImdbInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CategoryItem {
|
||||||
|
id?: string;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CountryItem {
|
||||||
|
id?: string;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EpisodeItem {
|
||||||
|
name: string;
|
||||||
|
slug?: string;
|
||||||
|
filename?: string;
|
||||||
|
link_embed?: string;
|
||||||
|
link_m3u8?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EpisodeServer {
|
||||||
|
server_name: string;
|
||||||
|
server_data: EpisodeItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MovieDetail {
|
||||||
|
_id?: string;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
origin_name?: string;
|
||||||
|
content?: string;
|
||||||
|
type?: string;
|
||||||
|
status?: string;
|
||||||
|
thumb_url?: string;
|
||||||
|
poster_url?: string;
|
||||||
|
trailer_url?: string;
|
||||||
|
time?: string;
|
||||||
|
episode_current?: string;
|
||||||
|
episode_total?: string;
|
||||||
|
quality?: string;
|
||||||
|
lang?: string;
|
||||||
|
year?: number;
|
||||||
|
actor?: string[];
|
||||||
|
director?: string[];
|
||||||
|
category?: CategoryItem[];
|
||||||
|
country?: CountryItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MovieDetailResponse {
|
||||||
|
movie?: MovieDetail;
|
||||||
|
item?: MovieDetail;
|
||||||
|
episodes?: EpisodeServer[];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Endpoint chi tiết
|
||||||
|
|
||||||
|
## 4.1. Lấy phim mới cập nhật — legacy
|
||||||
|
|
||||||
|
### Mục đích
|
||||||
|
|
||||||
|
Dùng để lấy danh sách phim mới cập nhật theo trang.
|
||||||
|
|
||||||
|
### Request
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /danh-sach/phim-moi-cap-nhat?page={page}
|
||||||
|
```
|
||||||
|
|
||||||
|
### URL đầy đủ
|
||||||
|
|
||||||
|
```txt
|
||||||
|
https://ophim1.com/danh-sach/phim-moi-cap-nhat?page=1
|
||||||
|
```
|
||||||
|
|
||||||
|
### Params
|
||||||
|
|
||||||
|
| Param | Kiểu | Bắt buộc | Mô tả |
|
||||||
|
|---|---:|---:|---|
|
||||||
|
| `page` | number | Không | Trang cần lấy. Mặc định thường là `1`. |
|
||||||
|
|
||||||
|
### Response shape thực tế
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": true,
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"tmdb": {
|
||||||
|
"type": "tv",
|
||||||
|
"id": "37854",
|
||||||
|
"season": 1,
|
||||||
|
"vote_average": 8.7,
|
||||||
|
"vote_count": 5216
|
||||||
|
},
|
||||||
|
"imdb": {
|
||||||
|
"id": "tt0388629",
|
||||||
|
"vote_average": 9,
|
||||||
|
"vote_count": 341535
|
||||||
|
},
|
||||||
|
"modified": {
|
||||||
|
"time": "2026-04-27T11:45:40.000Z"
|
||||||
|
},
|
||||||
|
"_id": "6220e66e8481266c5b7f154f",
|
||||||
|
"name": "Đảo Hải Tặc",
|
||||||
|
"origin_name": "One Piece (Luffy)",
|
||||||
|
"thumb_url": "one-piece-thumb.jpg",
|
||||||
|
"slug": "one-piece",
|
||||||
|
"year": 1999,
|
||||||
|
"poster_url": "one-piece-poster.jpg"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"pathImage": "https://img.ophim.live/uploads/movies/",
|
||||||
|
"pagination": {
|
||||||
|
"totalItems": 35382,
|
||||||
|
"totalItemsPerPage": 24,
|
||||||
|
"currentPage": 1,
|
||||||
|
"totalPages": 1475
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Cách dùng
|
||||||
|
|
||||||
|
```ts
|
||||||
|
async function getLatestMovies(page = 1) {
|
||||||
|
const res = await fetch(`https://ophim1.com/danh-sach/phim-moi-cap-nhat?page=${page}`);
|
||||||
|
if (!res.ok) throw new Error(`OPhim error: ${res.status}`);
|
||||||
|
return res.json() as Promise<LegacyListResponse>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Ghi chú cho AI agent
|
||||||
|
|
||||||
|
- Dùng endpoint này để render trang “Mới cập nhật”.
|
||||||
|
- Mỗi item có `slug`, dùng `slug` để gọi chi tiết phim.
|
||||||
|
- Ảnh lấy bằng `pathImage + thumb_url` hoặc `pathImage + poster_url`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4.2. Lấy danh sách phim — v1
|
||||||
|
|
||||||
|
### Mục đích
|
||||||
|
|
||||||
|
Dùng để lấy phim theo loại danh sách: phim mới cập nhật, phim lẻ, phim bộ, phim đang chiếu, phim hoàn thành, phim vietsub, thuyết minh, lồng tiếng, sắp chiếu...
|
||||||
|
|
||||||
|
### Request
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /v1/api/danh-sach/{type}?page={page}
|
||||||
|
```
|
||||||
|
|
||||||
|
### URL đầy đủ
|
||||||
|
|
||||||
|
```txt
|
||||||
|
https://ophim1.com/v1/api/danh-sach/phim-moi-cap-nhat?page=1
|
||||||
|
```
|
||||||
|
|
||||||
|
### Path params
|
||||||
|
|
||||||
|
| Param | Kiểu | Bắt buộc | Ví dụ | Mô tả |
|
||||||
|
|---|---:|---:|---|---|
|
||||||
|
| `type` | string | Có | `phim-moi-cap-nhat` | Loại danh sách phim cần lấy. |
|
||||||
|
|
||||||
|
### Một số `type` thường dùng
|
||||||
|
|
||||||
|
```txt
|
||||||
|
phim-moi-cap-nhat
|
||||||
|
phim-le
|
||||||
|
phim-bo
|
||||||
|
phim-bo-dang-chieu
|
||||||
|
phim-bo-hoan-thanh
|
||||||
|
phim-sap-chieu
|
||||||
|
phim-vietsub
|
||||||
|
phim-thuyet-minh
|
||||||
|
phim-long-tieng
|
||||||
|
hoat-hinh
|
||||||
|
tv-shows
|
||||||
|
subteam
|
||||||
|
```
|
||||||
|
|
||||||
|
> Lưu ý: một số `type` có thể thay đổi theo server. Nếu 404 hoặc empty data thì cần test lại slug danh sách từ website/menu.
|
||||||
|
|
||||||
|
### Query params thường dùng
|
||||||
|
|
||||||
|
| Param | Kiểu | Bắt buộc | Ví dụ | Mô tả |
|
||||||
|
|---|---:|---:|---|---|
|
||||||
|
| `page` | number | Không | `1` | Phân trang. |
|
||||||
|
| `sort_field` | string | Không | `_id`, `modified.time`, `year` | Field sort. Tùy server hỗ trợ. |
|
||||||
|
| `sort_type` | string | Không | `desc`, `asc` | Kiểu sắp xếp. |
|
||||||
|
| `sort_lang` | string | Không | `vietsub`, `thuyet-minh`, `long-tieng` | Lọc theo ngôn ngữ/phụ đề nếu server hỗ trợ. |
|
||||||
|
| `category` | string | Không | `hanh-dong` | Lọc theo thể loại. |
|
||||||
|
| `genre` | string | Không | `hanh-dong` | Một số ví dụ cộng đồng dùng `genre`; nếu `category` không chạy có thể thử `genre`. |
|
||||||
|
| `country` | string | Không | `han-quoc` | Lọc theo quốc gia. |
|
||||||
|
| `year` | number/string | Không | `2024` | Lọc theo năm. |
|
||||||
|
| `limit` | number | Không | `24` | Số item/trang, nếu server hỗ trợ. |
|
||||||
|
|
||||||
|
### Response shape dự kiến
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface V1MovieListData {
|
||||||
|
seoOnPage?: unknown;
|
||||||
|
breadCrumb?: unknown[];
|
||||||
|
titlePage?: string;
|
||||||
|
items: MovieListItem[];
|
||||||
|
params?: unknown;
|
||||||
|
type_list?: string;
|
||||||
|
APP_DOMAIN_FRONTEND?: string;
|
||||||
|
APP_DOMAIN_CDN_IMAGE?: string;
|
||||||
|
pagination: Pagination;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface V1MovieListResponse {
|
||||||
|
status: boolean;
|
||||||
|
msg?: string;
|
||||||
|
data: V1MovieListData;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Cách dùng
|
||||||
|
|
||||||
|
```ts
|
||||||
|
async function getMoviesByList(type: string, page = 1) {
|
||||||
|
const url = new URL(`https://ophim1.com/v1/api/danh-sach/${type}`);
|
||||||
|
url.searchParams.set('page', String(page));
|
||||||
|
|
||||||
|
const res = await fetch(url.toString());
|
||||||
|
if (!res.ok) throw new Error(`OPhim error: ${res.status}`);
|
||||||
|
|
||||||
|
const json = await res.json();
|
||||||
|
return json.data?.items ?? [];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Ví dụ có filter
|
||||||
|
|
||||||
|
```ts
|
||||||
|
async function getFilteredMovies() {
|
||||||
|
const url = new URL('https://ophim1.com/v1/api/danh-sach/phim-moi-cap-nhat');
|
||||||
|
url.searchParams.set('page', '1');
|
||||||
|
url.searchParams.set('category', 'hanh-dong');
|
||||||
|
url.searchParams.set('country', 'han-quoc');
|
||||||
|
url.searchParams.set('year', '2024');
|
||||||
|
|
||||||
|
const res = await fetch(url.toString());
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4.3. Tìm kiếm phim
|
||||||
|
|
||||||
|
### Mục đích
|
||||||
|
|
||||||
|
Tìm phim theo từ khóa.
|
||||||
|
|
||||||
|
### Request
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /v1/api/tim-kiem?keyword={keyword}&page={page}
|
||||||
|
```
|
||||||
|
|
||||||
|
### URL đầy đủ
|
||||||
|
|
||||||
|
```txt
|
||||||
|
https://ophim1.com/v1/api/tim-kiem?keyword=one%20piece&page=1
|
||||||
|
```
|
||||||
|
|
||||||
|
### Params
|
||||||
|
|
||||||
|
| Param | Kiểu | Bắt buộc | Ví dụ | Mô tả |
|
||||||
|
|---|---:|---:|---|---|
|
||||||
|
| `keyword` | string | Có | `one piece` | Từ khóa tìm kiếm. Cần encode URL. |
|
||||||
|
| `page` | number | Không | `1` | Trang kết quả. |
|
||||||
|
|
||||||
|
### Cách dùng
|
||||||
|
|
||||||
|
```ts
|
||||||
|
async function searchMovies(keyword: string, page = 1) {
|
||||||
|
const url = new URL('https://ophim1.com/v1/api/tim-kiem');
|
||||||
|
url.searchParams.set('keyword', keyword);
|
||||||
|
url.searchParams.set('page', String(page));
|
||||||
|
|
||||||
|
const res = await fetch(url.toString());
|
||||||
|
if (!res.ok) throw new Error(`OPhim search error: ${res.status}`);
|
||||||
|
|
||||||
|
const json = await res.json();
|
||||||
|
return json.data?.items ?? [];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Ghi chú cho AI agent
|
||||||
|
|
||||||
|
- Luôn dùng `URLSearchParams`, không tự nối chuỗi keyword trực tiếp.
|
||||||
|
- Nên debounce search ở frontend khoảng 300–500ms.
|
||||||
|
- Không gọi search mỗi lần user gõ một ký tự nếu chưa debounce.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4.4. Lấy danh sách thể loại
|
||||||
|
|
||||||
|
### Mục đích
|
||||||
|
|
||||||
|
Lấy toàn bộ thể loại để render dropdown/menu filter.
|
||||||
|
|
||||||
|
### Request
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /v1/api/the-loai
|
||||||
|
```
|
||||||
|
|
||||||
|
### URL đầy đủ
|
||||||
|
|
||||||
|
```txt
|
||||||
|
https://ophim1.com/v1/api/the-loai
|
||||||
|
```
|
||||||
|
|
||||||
|
### Response shape dự kiến
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface CategoryListResponse {
|
||||||
|
status: boolean;
|
||||||
|
data: {
|
||||||
|
items: CategoryItem[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Item dự kiến
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "Hành Động",
|
||||||
|
"slug": "hanh-dong"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Cách dùng
|
||||||
|
|
||||||
|
```ts
|
||||||
|
async function getCategories() {
|
||||||
|
const res = await fetch('https://ophim1.com/v1/api/the-loai');
|
||||||
|
if (!res.ok) throw new Error(`OPhim category error: ${res.status}`);
|
||||||
|
|
||||||
|
const json = await res.json();
|
||||||
|
return Array.isArray(json.data?.items) ? json.data.items : [];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4.5. Lấy phim theo thể loại
|
||||||
|
|
||||||
|
### Mục đích
|
||||||
|
|
||||||
|
Lấy danh sách phim thuộc một thể loại cụ thể.
|
||||||
|
|
||||||
|
### Request
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /v1/api/the-loai/{slug}?page={page}
|
||||||
|
```
|
||||||
|
|
||||||
|
### URL đầy đủ
|
||||||
|
|
||||||
|
```txt
|
||||||
|
https://ophim1.com/v1/api/the-loai/hanh-dong?page=1
|
||||||
|
```
|
||||||
|
|
||||||
|
### Path params
|
||||||
|
|
||||||
|
| Param | Kiểu | Bắt buộc | Ví dụ | Mô tả |
|
||||||
|
|---|---:|---:|---|---|
|
||||||
|
| `slug` | string | Có | `hanh-dong` | Slug thể loại lấy từ endpoint `/v1/api/the-loai`. |
|
||||||
|
|
||||||
|
### Query params
|
||||||
|
|
||||||
|
| Param | Kiểu | Bắt buộc | Mô tả |
|
||||||
|
|---|---:|---:|---|
|
||||||
|
| `page` | number | Không | Phân trang. |
|
||||||
|
|
||||||
|
### Cách dùng
|
||||||
|
|
||||||
|
```ts
|
||||||
|
async function getMoviesByCategory(categorySlug: string, page = 1) {
|
||||||
|
const url = new URL(`https://ophim1.com/v1/api/the-loai/${categorySlug}`);
|
||||||
|
url.searchParams.set('page', String(page));
|
||||||
|
|
||||||
|
const res = await fetch(url.toString());
|
||||||
|
if (!res.ok) throw new Error(`OPhim category movies error: ${res.status}`);
|
||||||
|
|
||||||
|
const json = await res.json();
|
||||||
|
return json.data?.items ?? [];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4.6. Lấy danh sách quốc gia
|
||||||
|
|
||||||
|
### Mục đích
|
||||||
|
|
||||||
|
Lấy toàn bộ quốc gia để render dropdown/menu filter.
|
||||||
|
|
||||||
|
### Request
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /v1/api/quoc-gia
|
||||||
|
```
|
||||||
|
|
||||||
|
### URL đầy đủ
|
||||||
|
|
||||||
|
```txt
|
||||||
|
https://ophim1.com/v1/api/quoc-gia
|
||||||
|
```
|
||||||
|
|
||||||
|
### Response shape dự kiến
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface CountryListResponse {
|
||||||
|
status: boolean;
|
||||||
|
data: {
|
||||||
|
items: CountryItem[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Item dự kiến
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "Hàn Quốc",
|
||||||
|
"slug": "han-quoc"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Cách dùng
|
||||||
|
|
||||||
|
```ts
|
||||||
|
async function getCountries() {
|
||||||
|
const res = await fetch('https://ophim1.com/v1/api/quoc-gia');
|
||||||
|
if (!res.ok) throw new Error(`OPhim country error: ${res.status}`);
|
||||||
|
|
||||||
|
const json = await res.json();
|
||||||
|
return Array.isArray(json.data?.items) ? json.data.items : [];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4.7. Lấy phim theo quốc gia
|
||||||
|
|
||||||
|
### Mục đích
|
||||||
|
|
||||||
|
Lấy danh sách phim thuộc một quốc gia cụ thể.
|
||||||
|
|
||||||
|
### Request
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /v1/api/quoc-gia/{slug}?page={page}
|
||||||
|
```
|
||||||
|
|
||||||
|
### URL đầy đủ
|
||||||
|
|
||||||
|
```txt
|
||||||
|
https://ophim1.com/v1/api/quoc-gia/han-quoc?page=1
|
||||||
|
```
|
||||||
|
|
||||||
|
### Path params
|
||||||
|
|
||||||
|
| Param | Kiểu | Bắt buộc | Ví dụ | Mô tả |
|
||||||
|
|---|---:|---:|---|---|
|
||||||
|
| `slug` | string | Có | `han-quoc` | Slug quốc gia lấy từ endpoint `/v1/api/quoc-gia`. |
|
||||||
|
|
||||||
|
### Cách dùng
|
||||||
|
|
||||||
|
```ts
|
||||||
|
async function getMoviesByCountry(countrySlug: string, page = 1) {
|
||||||
|
const url = new URL(`https://ophim1.com/v1/api/quoc-gia/${countrySlug}`);
|
||||||
|
url.searchParams.set('page', String(page));
|
||||||
|
|
||||||
|
const res = await fetch(url.toString());
|
||||||
|
if (!res.ok) throw new Error(`OPhim country movies error: ${res.status}`);
|
||||||
|
|
||||||
|
const json = await res.json();
|
||||||
|
return json.data?.items ?? [];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4.8. Lấy phim theo năm phát hành
|
||||||
|
|
||||||
|
### Mục đích
|
||||||
|
|
||||||
|
Lấy danh sách phim theo năm phát hành.
|
||||||
|
|
||||||
|
### Request
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /v1/api/nam/{year}?page={page}
|
||||||
|
```
|
||||||
|
|
||||||
|
### URL đầy đủ
|
||||||
|
|
||||||
|
```txt
|
||||||
|
https://ophim1.com/v1/api/nam/2024?page=1
|
||||||
|
```
|
||||||
|
|
||||||
|
### Path params
|
||||||
|
|
||||||
|
| Param | Kiểu | Bắt buộc | Ví dụ | Mô tả |
|
||||||
|
|---|---:|---:|---|---|
|
||||||
|
| `year` | number/string | Có | `2024` | Năm phát hành. |
|
||||||
|
|
||||||
|
### Query params
|
||||||
|
|
||||||
|
| Param | Kiểu | Bắt buộc | Mô tả |
|
||||||
|
|---|---:|---:|---|
|
||||||
|
| `page` | number | Không | Phân trang. |
|
||||||
|
|
||||||
|
### Cách dùng
|
||||||
|
|
||||||
|
```ts
|
||||||
|
async function getMoviesByYear(year: number, page = 1) {
|
||||||
|
const url = new URL(`https://ophim1.com/v1/api/nam/${year}`);
|
||||||
|
url.searchParams.set('page', String(page));
|
||||||
|
|
||||||
|
const res = await fetch(url.toString());
|
||||||
|
if (!res.ok) throw new Error(`OPhim year movies error: ${res.status}`);
|
||||||
|
|
||||||
|
const json = await res.json();
|
||||||
|
return json.data?.items ?? [];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> Nếu endpoint `/v1/api/nam/{year}` không chạy, thử kiểm tra lại docs/API Postman hiện tại vì trang docs gọi nhóm này là “Năm Phát Hành”, nhưng HTML public không expose path chi tiết.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4.9. Lấy chi tiết phim — legacy
|
||||||
|
|
||||||
|
### Mục đích
|
||||||
|
|
||||||
|
Lấy thông tin đầy đủ của một phim, bao gồm mô tả, diễn viên, đạo diễn, thể loại, quốc gia và danh sách tập/link xem.
|
||||||
|
|
||||||
|
### Request
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /phim/{slug}
|
||||||
|
```
|
||||||
|
|
||||||
|
### URL đầy đủ
|
||||||
|
|
||||||
|
```txt
|
||||||
|
https://ophim1.com/phim/one-piece
|
||||||
|
```
|
||||||
|
|
||||||
|
### Path params
|
||||||
|
|
||||||
|
| Param | Kiểu | Bắt buộc | Ví dụ | Mô tả |
|
||||||
|
|---|---:|---:|---|---|
|
||||||
|
| `slug` | string | Có | `one-piece` | Slug lấy từ item danh sách hoặc kết quả search. |
|
||||||
|
|
||||||
|
### Response shape thường gặp
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface LegacyMovieDetailResponse {
|
||||||
|
status: boolean;
|
||||||
|
movie: MovieDetail;
|
||||||
|
episodes: EpisodeServer[];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Cách dùng
|
||||||
|
|
||||||
|
```ts
|
||||||
|
async function getMovieDetailLegacy(slug: string) {
|
||||||
|
const res = await fetch(`https://ophim1.com/phim/${slug}`);
|
||||||
|
if (!res.ok) throw new Error(`OPhim movie detail error: ${res.status}`);
|
||||||
|
|
||||||
|
const json = await res.json();
|
||||||
|
|
||||||
|
return {
|
||||||
|
movie: json.movie,
|
||||||
|
episodes: json.episodes ?? [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Cách lấy link xem phim
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const detail = await getMovieDetailLegacy('one-piece');
|
||||||
|
|
||||||
|
const firstServer = detail.episodes[0];
|
||||||
|
const firstEpisode = firstServer?.server_data?.[0];
|
||||||
|
|
||||||
|
console.log(firstEpisode?.link_embed);
|
||||||
|
console.log(firstEpisode?.link_m3u8);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Ghi chú cho AI agent
|
||||||
|
|
||||||
|
- `episodes` là mảng server.
|
||||||
|
- Mỗi server có `server_name` và `server_data`.
|
||||||
|
- `server_data` là danh sách tập.
|
||||||
|
- Mỗi tập có thể có `link_embed` và/hoặc `link_m3u8`.
|
||||||
|
- Khi render player, ưu tiên `link_embed` nếu dùng iframe, hoặc `link_m3u8` nếu dùng HLS player.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4.10. Lấy chi tiết phim — v1
|
||||||
|
|
||||||
|
### Mục đích
|
||||||
|
|
||||||
|
Tương tự legacy detail nhưng theo format v1.
|
||||||
|
|
||||||
|
### Request
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /v1/api/phim/{slug}
|
||||||
|
```
|
||||||
|
|
||||||
|
### URL đầy đủ
|
||||||
|
|
||||||
|
```txt
|
||||||
|
https://ophim1.com/v1/api/phim/one-piece
|
||||||
|
```
|
||||||
|
|
||||||
|
### Response shape dự kiến
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface V1MovieDetailData {
|
||||||
|
seoOnPage?: unknown;
|
||||||
|
breadCrumb?: unknown[];
|
||||||
|
params?: unknown;
|
||||||
|
item: MovieDetail;
|
||||||
|
episodes: EpisodeServer[];
|
||||||
|
APP_DOMAIN_CDN_IMAGE?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface V1MovieDetailResponse {
|
||||||
|
status: boolean;
|
||||||
|
msg?: string;
|
||||||
|
data: V1MovieDetailData;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Cách dùng
|
||||||
|
|
||||||
|
```ts
|
||||||
|
async function getMovieDetailV1(slug: string) {
|
||||||
|
const res = await fetch(`https://ophim1.com/v1/api/phim/${slug}`);
|
||||||
|
if (!res.ok) throw new Error(`OPhim v1 movie detail error: ${res.status}`);
|
||||||
|
|
||||||
|
const json = await res.json();
|
||||||
|
|
||||||
|
return {
|
||||||
|
movie: json.data?.item,
|
||||||
|
episodes: json.data?.episodes ?? [],
|
||||||
|
imageDomain: json.data?.APP_DOMAIN_CDN_IMAGE,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Adapter chuẩn hóa response
|
||||||
|
|
||||||
|
Nên viết adapter để code không phụ thuộc quá cứng vào legacy hoặc v1.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export function normalizeMovieListResponse(json: any) {
|
||||||
|
const items = json?.items ?? json?.data?.items ?? [];
|
||||||
|
const pagination = json?.pagination ?? json?.data?.pagination ?? null;
|
||||||
|
const pathImage =
|
||||||
|
json?.pathImage ??
|
||||||
|
json?.data?.APP_DOMAIN_CDN_IMAGE ??
|
||||||
|
'https://img.ophim.live/uploads/movies/';
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: Boolean(json?.status),
|
||||||
|
items,
|
||||||
|
pagination,
|
||||||
|
pathImage,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeMovieDetailResponse(json: any) {
|
||||||
|
const movie = json?.movie ?? json?.data?.item ?? json?.item ?? null;
|
||||||
|
const episodes = json?.episodes ?? json?.data?.episodes ?? [];
|
||||||
|
const pathImage =
|
||||||
|
json?.pathImage ??
|
||||||
|
json?.data?.APP_DOMAIN_CDN_IMAGE ??
|
||||||
|
'https://img.ophim.live/uploads/movies/';
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: Boolean(json?.status),
|
||||||
|
movie,
|
||||||
|
episodes,
|
||||||
|
pathImage,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Service TypeScript hoàn chỉnh
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const OPHIM_BASE_URL = 'https://ophim1.com';
|
||||||
|
const OPHIM_IMAGE_FALLBACK = 'https://img.ophim.live/uploads/movies/';
|
||||||
|
|
||||||
|
async function requestJson<T>(url: string): Promise<T> {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`OPhim API error ${res.status}: ${res.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json() as Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMovieImageUrl(pathImage: string | undefined, fileName?: string) {
|
||||||
|
if (!fileName) return '';
|
||||||
|
return `${pathImage || OPHIM_IMAGE_FALLBACK}${fileName}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getLatestMovies(page = 1) {
|
||||||
|
const url = `${OPHIM_BASE_URL}/danh-sach/phim-moi-cap-nhat?page=${page}`;
|
||||||
|
const json = await requestJson<any>(url);
|
||||||
|
return normalizeMovieListResponse(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getMovieDetail(slug: string) {
|
||||||
|
const url = `${OPHIM_BASE_URL}/phim/${encodeURIComponent(slug)}`;
|
||||||
|
const json = await requestJson<any>(url);
|
||||||
|
return normalizeMovieDetailResponse(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function searchMovies(keyword: string, page = 1) {
|
||||||
|
const url = new URL(`${OPHIM_BASE_URL}/v1/api/tim-kiem`);
|
||||||
|
url.searchParams.set('keyword', keyword);
|
||||||
|
url.searchParams.set('page', String(page));
|
||||||
|
|
||||||
|
const json = await requestJson<any>(url.toString());
|
||||||
|
return normalizeMovieListResponse(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCategories() {
|
||||||
|
const json = await requestJson<any>(`${OPHIM_BASE_URL}/v1/api/the-loai`);
|
||||||
|
return json?.data?.items ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCountries() {
|
||||||
|
const json = await requestJson<any>(`${OPHIM_BASE_URL}/v1/api/quoc-gia`);
|
||||||
|
return json?.data?.items ?? [];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Luồng tích hợp frontend đề xuất
|
||||||
|
|
||||||
|
### Trang danh sách phim mới
|
||||||
|
|
||||||
|
1. Gọi `getLatestMovies(page)`.
|
||||||
|
2. Render `items`.
|
||||||
|
3. Ảnh: `pathImage + item.thumb_url`.
|
||||||
|
4. Khi user click phim, navigate tới `/movie/{slug}`.
|
||||||
|
|
||||||
|
### Trang chi tiết phim
|
||||||
|
|
||||||
|
1. Lấy `slug` từ route.
|
||||||
|
2. Gọi `getMovieDetail(slug)`.
|
||||||
|
3. Render `movie.name`, `movie.content`, `movie.category`, `movie.country`, `movie.actor`, `movie.director`.
|
||||||
|
4. Render danh sách episode từ `episodes`.
|
||||||
|
5. Khi user chọn tập:
|
||||||
|
- Nếu dùng iframe: set `src = episode.link_embed`.
|
||||||
|
- Nếu dùng HLS: set player source = `episode.link_m3u8`.
|
||||||
|
|
||||||
|
### Trang tìm kiếm
|
||||||
|
|
||||||
|
1. User nhập keyword.
|
||||||
|
2. Debounce 300–500ms.
|
||||||
|
3. Gọi `searchMovies(keyword, page)`.
|
||||||
|
4. Render items giống trang list.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Prompt ngắn cho Antigravity / Codex
|
||||||
|
|
||||||
|
Có thể đưa đoạn này cho AI coding agent:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
Build a movie browsing integration using OPhim API.
|
||||||
|
|
||||||
|
Base URL: https://ophim1.com
|
||||||
|
Data format: JSON, UTF-8, method GET.
|
||||||
|
|
||||||
|
Use these endpoints:
|
||||||
|
- Latest movies: GET /danh-sach/phim-moi-cap-nhat?page={page}
|
||||||
|
- Movie detail: GET /phim/{slug}
|
||||||
|
- Search: GET /v1/api/tim-kiem?keyword={keyword}&page={page}
|
||||||
|
- Categories: GET /v1/api/the-loai
|
||||||
|
- Countries: GET /v1/api/quoc-gia
|
||||||
|
- List by type: GET /v1/api/danh-sach/{type}?page={page}
|
||||||
|
- Movies by category: GET /v1/api/the-loai/{slug}?page={page}
|
||||||
|
- Movies by country: GET /v1/api/quoc-gia/{slug}?page={page}
|
||||||
|
- Movies by year: GET /v1/api/nam/{year}?page={page}
|
||||||
|
|
||||||
|
Important response rules:
|
||||||
|
- Legacy list response has: status, items, pathImage, pagination.
|
||||||
|
- v1 response usually has: status, msg, data.
|
||||||
|
- v1 list items are usually in data.items.
|
||||||
|
- Movie detail usually has movie/item and episodes.
|
||||||
|
- Use movie.slug from list/search to call detail.
|
||||||
|
- To render images, concatenate pathImage or CDN image domain with thumb_url/poster_url.
|
||||||
|
- Image fallback CDN: https://img.ophim.live/uploads/movies/
|
||||||
|
- Episode links are usually in episodes[].server_data[].link_embed or link_m3u8.
|
||||||
|
|
||||||
|
Implementation requirements:
|
||||||
|
- Create an API service module.
|
||||||
|
- Add safe fetch wrapper with error handling.
|
||||||
|
- Normalize legacy and v1 responses.
|
||||||
|
- Do not crawl all detail pages at once.
|
||||||
|
- Add pagination support.
|
||||||
|
- Add debounce for search.
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Lưu ý production
|
||||||
|
|
||||||
|
- API public có thể thay đổi format/path, nên code cần defensive parsing.
|
||||||
|
- Cần xử lý lỗi mạng, response rỗng, item thiếu ảnh, item thiếu `slug`.
|
||||||
|
- Nên cache danh sách thể loại/quốc gia vì ít thay đổi.
|
||||||
|
- Nên cache detail phim sau khi user mở phim.
|
||||||
|
- Với website thật, không nên phụ thuộc 100% vào API public. Có thể lưu dữ liệu về database riêng để giảm rủi ro API đổi/chậm/lỗi.
|
||||||
|
- Nếu dùng `link_m3u8`, cần kiểm tra CORS/player support.
|
||||||
|
- Nếu dùng iframe `link_embed`, cần kiểm tra domain có cho embed không.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Checklist cho AI agent khi code
|
||||||
|
|
||||||
|
- [ ] Có file `ophim.service.ts` hoặc tương đương.
|
||||||
|
- [ ] Có `requestJson()` xử lý lỗi HTTP.
|
||||||
|
- [ ] Có `normalizeMovieListResponse()`.
|
||||||
|
- [ ] Có `normalizeMovieDetailResponse()`.
|
||||||
|
- [ ] Có hàm `getLatestMovies(page)`.
|
||||||
|
- [ ] Có hàm `getMovieDetail(slug)`.
|
||||||
|
- [ ] Có hàm `searchMovies(keyword, page)`.
|
||||||
|
- [ ] Có hàm `getCategories()`.
|
||||||
|
- [ ] Có hàm `getCountries()`.
|
||||||
|
- [ ] UI danh sách dùng `movie.slug` để route detail.
|
||||||
|
- [ ] UI ảnh dùng `pathImage + thumb_url` hoặc fallback CDN.
|
||||||
|
- [ ] UI episode đọc từ `episodes[].server_data[]`.
|
||||||
|
- [ ] Không fetch detail hàng loạt cho toàn bộ list.
|
||||||
+4
-4
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<NuxtRouteAnnouncer />
|
||||||
<NuxtRouteAnnouncer />
|
<NuxtPage />
|
||||||
<NuxtWelcome />
|
<AppFooter />
|
||||||
</div>
|
<ScrollToTop />
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
@import url("https://fonts.googleapis.com/css2?family=Be+Vietnam+Pro:wght@400;500;600;700;800;900&display=swap");
|
||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
--font-sans: "Be Vietnam Pro", ui-sans-serif, system-ui, sans-serif;
|
||||||
|
--color-skyflix-50: #effaff;
|
||||||
|
--color-skyflix-100: #dff5ff;
|
||||||
|
--color-skyflix-300: #6ed0ff;
|
||||||
|
--color-skyflix-500: #0ea5e9;
|
||||||
|
--color-skyflix-600: #0284c7;
|
||||||
|
--color-skyflix-950: #07111f;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
min-width: 320px;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 15% 0%, rgba(14, 165, 233, 0.28), transparent 34rem),
|
||||||
|
linear-gradient(135deg, #061321 0%, #081827 46%, #020617 100%);
|
||||||
|
color: #f8fbff;
|
||||||
|
}
|
||||||
|
|
||||||
|
body,
|
||||||
|
button,
|
||||||
|
input {
|
||||||
|
font-family: "Be Vietnam Pro", ui-sans-serif, system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
height: 0.7rem;
|
||||||
|
width: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(56, 189, 248, 0.55);
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: rgba(2, 6, 23, 0.72);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-fade-enter-active,
|
||||||
|
.hero-fade-leave-active {
|
||||||
|
transition: opacity 700ms ease, transform 900ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-fade-enter-from,
|
||||||
|
.hero-fade-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(1.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-scrollbar {
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-scrollbar::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { CirclePlay, Globe2, MessageCircle, Music2, Send } from 'lucide-vue-next'
|
||||||
|
|
||||||
|
const currentYear = new Date().getFullYear()
|
||||||
|
|
||||||
|
const socialLinks = [
|
||||||
|
{ label: 'Telegram', icon: Send },
|
||||||
|
{ label: 'Chat', icon: MessageCircle },
|
||||||
|
{ label: 'Website', icon: Globe2 },
|
||||||
|
{ label: 'Cộng đồng', icon: MessageCircle },
|
||||||
|
{ label: 'TikTok', icon: Music2 },
|
||||||
|
{ label: 'Video', icon: CirclePlay },
|
||||||
|
]
|
||||||
|
|
||||||
|
const linkGroups = [
|
||||||
|
['Domain dự phòng', 'Điều khoản sử dụng'],
|
||||||
|
['Hỏi-Đáp', 'Giới thiệu'],
|
||||||
|
['Chính sách bảo mật', 'Liên hệ'],
|
||||||
|
]
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<footer class="border-t border-white/10 bg-[#07090f] text-white">
|
||||||
|
<div class="mx-auto max-w-[1560px] px-4 py-10 sm:px-6 lg:px-8 xl:px-10">
|
||||||
|
<div class="flex flex-col gap-8 lg:flex-row lg:items-center">
|
||||||
|
<div class="flex shrink-0 items-center gap-7">
|
||||||
|
<AppLogo />
|
||||||
|
<span class="hidden h-14 w-px bg-white/10 sm:block" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-3">
|
||||||
|
<button
|
||||||
|
v-for="item in socialLinks"
|
||||||
|
:key="item.label"
|
||||||
|
type="button"
|
||||||
|
class="grid size-12 place-items-center rounded-full bg-sky-400 text-slate-950 shadow-lg shadow-sky-950/30 transition hover:-translate-y-0.5 hover:bg-white"
|
||||||
|
:aria-label="item.label"
|
||||||
|
>
|
||||||
|
<component :is="item.icon" class="size-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-10 grid gap-8 text-sm font-bold sm:grid-cols-3 lg:max-w-3xl">
|
||||||
|
<div v-for="(group, index) in linkGroups" :key="index" class="space-y-5">
|
||||||
|
<NuxtLink
|
||||||
|
v-for="link in group"
|
||||||
|
:key="link"
|
||||||
|
to="/phim"
|
||||||
|
class="block text-slate-100 transition hover:text-sky-200"
|
||||||
|
>
|
||||||
|
{{ link }}
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="mt-10 max-w-4xl text-sm leading-7 text-slate-300">
|
||||||
|
KRPHIM - Trang xem phim Hàn Quốc online chất lượng cao miễn phí Vietsub, thuyết minh, lồng tiếng full HD. Kho phim mới cập nhật liên tục từ OPhim và NguồnC với giao diện xanh da trời hiện đại.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p class="mt-7 text-sm text-slate-400">
|
||||||
|
© {{ currentYear }} KRPHIM
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Search } from 'lucide-vue-next'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const keyword = ref(typeof route.query.q === 'string' ? route.query.q : '')
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{ label: 'Trang chủ', to: '/' },
|
||||||
|
{ label: 'Duyệt phim', to: '/phim' },
|
||||||
|
{ label: 'Phim bộ', to: '/phim?type=series' },
|
||||||
|
{ label: 'Phim lẻ', to: '/phim?type=single' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function isActive(to: string) {
|
||||||
|
if (to === '/') return route.path === '/'
|
||||||
|
return route.fullPath === to || route.path === to.split('?')[0] && route.fullPath.includes(to.split('?')[1] || '')
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitSearch() {
|
||||||
|
const q = keyword.value.trim()
|
||||||
|
router.push({
|
||||||
|
path: '/phim',
|
||||||
|
query: q ? { q } : undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<header class="fixed inset-x-0 top-0 z-50 border-b border-white/10 bg-slate-950/78 backdrop-blur-xl">
|
||||||
|
<nav class="mx-auto grid max-w-[1560px] grid-cols-[auto_1fr] items-center gap-4 px-4 py-3 sm:px-6 lg:grid-cols-[auto_1fr_auto] lg:px-8 xl:px-10">
|
||||||
|
<AppLogo />
|
||||||
|
|
||||||
|
<div class="no-scrollbar order-3 col-span-2 flex items-center justify-center gap-4 overflow-x-auto text-sm font-semibold text-slate-200 lg:order-none lg:col-span-1 lg:gap-7">
|
||||||
|
<NuxtLink
|
||||||
|
v-for="item in navItems"
|
||||||
|
:key="item.to"
|
||||||
|
:to="item.to"
|
||||||
|
class="shrink-0 transition hover:text-sky-200"
|
||||||
|
:class="isActive(item.to) ? 'text-sky-200' : 'text-slate-200'"
|
||||||
|
>
|
||||||
|
{{ item.label }}
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form
|
||||||
|
class="ml-auto flex w-full max-w-xs items-center rounded-full border border-white/10 bg-white/8 px-4 py-2 shadow-2xl shadow-sky-950/20 sm:max-w-sm"
|
||||||
|
@submit.prevent="submitSearch"
|
||||||
|
>
|
||||||
|
<Search class="mr-3 size-4 shrink-0 text-sky-200" />
|
||||||
|
<input
|
||||||
|
v-model="keyword"
|
||||||
|
type="search"
|
||||||
|
placeholder="Tìm phim Hàn Quốc..."
|
||||||
|
class="w-full bg-transparent text-sm text-white outline-none placeholder:text-slate-400"
|
||||||
|
>
|
||||||
|
</form>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Play } from 'lucide-vue-next'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<NuxtLink
|
||||||
|
to="/"
|
||||||
|
class="group inline-flex h-10 min-w-[132px] shrink-0 flex-row flex-nowrap items-center gap-2.5 whitespace-nowrap"
|
||||||
|
aria-label="KR PHIM"
|
||||||
|
>
|
||||||
|
<span class="grid size-9 shrink-0 place-items-center rounded-lg border border-sky-200/80 bg-black/30 text-white shadow-lg shadow-black/20 transition group-hover:border-sky-300 group-hover:text-sky-200">
|
||||||
|
<Play class="ml-0.5 size-5 fill-current" />
|
||||||
|
</span>
|
||||||
|
<span class="inline-flex items-baseline text-2xl font-black uppercase leading-none tracking-tight">
|
||||||
|
<span class="text-sky-300">KR</span><span class="text-white">PHIM</span>
|
||||||
|
</span>
|
||||||
|
</NuxtLink>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Circle, Heart, Play } from 'lucide-vue-next'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
movie: {
|
||||||
|
source: string
|
||||||
|
slug: string
|
||||||
|
name: string
|
||||||
|
originName?: string
|
||||||
|
thumb?: string
|
||||||
|
poster?: string
|
||||||
|
year?: number
|
||||||
|
time?: string
|
||||||
|
episode?: string
|
||||||
|
quality?: string
|
||||||
|
lang?: string
|
||||||
|
categories?: string[]
|
||||||
|
countries?: string[]
|
||||||
|
}
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const isPreviewVisible = ref(false)
|
||||||
|
const previewStyle = ref<Record<string, string>>({})
|
||||||
|
let hideTimer: ReturnType<typeof setTimeout> | undefined
|
||||||
|
|
||||||
|
const movieLink = computed(() => ({
|
||||||
|
path: `/phim/${props.movie.slug}`,
|
||||||
|
query: { source: props.movie.source },
|
||||||
|
}))
|
||||||
|
|
||||||
|
function showPreview(event: MouseEvent) {
|
||||||
|
if (window.matchMedia('(max-width: 639px)').matches) return
|
||||||
|
if (hideTimer) clearTimeout(hideTimer)
|
||||||
|
|
||||||
|
const rect = (event.currentTarget as HTMLElement).getBoundingClientRect()
|
||||||
|
const width = 408
|
||||||
|
const height = 344
|
||||||
|
const gap = 14
|
||||||
|
const left = Math.min(Math.max(rect.left - 10, 16), window.innerWidth - width - 16)
|
||||||
|
const top = Math.min(Math.max(rect.top - gap, 86), window.innerHeight - height - 16)
|
||||||
|
|
||||||
|
previewStyle.value = {
|
||||||
|
left: `${left}px`,
|
||||||
|
top: `${top}px`,
|
||||||
|
width: `${width}px`,
|
||||||
|
}
|
||||||
|
isPreviewVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleHide() {
|
||||||
|
if (hideTimer) clearTimeout(hideTimer)
|
||||||
|
hideTimer = setTimeout(() => {
|
||||||
|
isPreviewVisible.value = false
|
||||||
|
}, 90)
|
||||||
|
}
|
||||||
|
|
||||||
|
function keepPreview() {
|
||||||
|
if (hideTimer) clearTimeout(hideTimer)
|
||||||
|
}
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (hideTimer) clearTimeout(hideTimer)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<NuxtLink
|
||||||
|
:to="movieLink"
|
||||||
|
class="group relative block h-[19.5rem] w-44 shrink-0 snap-start transition duration-300 hover:z-30 hover:-translate-y-1 sm:h-[20.5rem] sm:w-52 xl:w-56"
|
||||||
|
@mouseenter="showPreview"
|
||||||
|
@mouseleave="scheduleHide"
|
||||||
|
@focus="showPreview"
|
||||||
|
@blur="scheduleHide"
|
||||||
|
>
|
||||||
|
<div class="absolute inset-0 overflow-hidden rounded-md bg-slate-900 shadow-xl shadow-black/25 ring-1 ring-white/10 transition duration-300 group-hover:ring-sky-300/60">
|
||||||
|
<img :src="movie.thumb || movie.poster" :alt="movie.name" class="h-full w-full object-cover transition duration-500 group-hover:scale-105">
|
||||||
|
<div class="absolute inset-x-0 bottom-0 bg-gradient-to-t from-slate-950 via-slate-950/70 to-transparent p-3">
|
||||||
|
<p class="line-clamp-2 text-sm font-bold leading-snug text-white">{{ movie.name }}</p>
|
||||||
|
<p class="mt-1 truncate text-xs text-sky-100">{{ movie.episode || movie.year || movie.quality }}</p>
|
||||||
|
</div>
|
||||||
|
<span class="absolute left-2 top-2 rounded bg-sky-400 px-2 py-1 text-xs font-black text-slate-950">
|
||||||
|
{{ movie.source }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</NuxtLink>
|
||||||
|
|
||||||
|
<Teleport to="body">
|
||||||
|
<Transition
|
||||||
|
enter-active-class="transition duration-180 ease-out"
|
||||||
|
enter-from-class="scale-95 opacity-0"
|
||||||
|
enter-to-class="scale-100 opacity-100"
|
||||||
|
leave-active-class="transition duration-120 ease-in"
|
||||||
|
leave-from-class="scale-100 opacity-100"
|
||||||
|
leave-to-class="scale-95 opacity-0"
|
||||||
|
>
|
||||||
|
<NuxtLink
|
||||||
|
v-if="isPreviewVisible"
|
||||||
|
:to="movieLink"
|
||||||
|
class="fixed z-[90] hidden origin-top-left overflow-hidden rounded-xl bg-[#1a0b14] text-white shadow-2xl shadow-black/60 ring-1 ring-sky-300/45 sm:block"
|
||||||
|
:style="previewStyle"
|
||||||
|
@mouseenter="keepPreview"
|
||||||
|
@mouseleave="scheduleHide"
|
||||||
|
>
|
||||||
|
<div class="relative h-44 overflow-hidden">
|
||||||
|
<img :src="movie.poster || movie.thumb" :alt="movie.name" class="h-full w-full object-cover">
|
||||||
|
<div class="absolute inset-0 bg-gradient-to-t from-[#1a0b14] via-[#1a0b14]/30 to-transparent" />
|
||||||
|
<div class="absolute inset-x-0 bottom-0 p-4">
|
||||||
|
<h3 class="line-clamp-2 text-xl font-black leading-tight text-white">
|
||||||
|
{{ movie.name }}
|
||||||
|
</h3>
|
||||||
|
<p v-if="movie.originName" class="mt-1 truncate text-sm font-semibold text-slate-200">
|
||||||
|
{{ movie.originName }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-4 pt-3">
|
||||||
|
<div class="grid grid-cols-[1fr_6.25rem_6.25rem] gap-2">
|
||||||
|
<span class="inline-flex h-10 items-center justify-center gap-2 rounded-md bg-sky-400 px-4 text-sm font-black text-slate-950 transition hover:bg-white">
|
||||||
|
<Play class="size-4 fill-current" />
|
||||||
|
Xem ngay
|
||||||
|
</span>
|
||||||
|
<span class="grid h-10 place-items-center rounded-md border border-white/30 bg-white/8 text-white">
|
||||||
|
<Heart class="size-5 fill-current" />
|
||||||
|
</span>
|
||||||
|
<span class="grid h-10 place-items-center rounded-md border border-white/30 bg-white/8 text-white">
|
||||||
|
<Circle class="size-5 fill-current" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-3 flex flex-wrap gap-2 text-xs font-black">
|
||||||
|
<span v-if="movie.quality" class="rounded bg-black/28 px-2.5 py-1.5 text-white ring-1 ring-white/10">{{ movie.quality }}</span>
|
||||||
|
<span v-if="movie.lang" class="rounded bg-sky-300 px-2.5 py-1.5 text-slate-950">{{ movie.lang }}</span>
|
||||||
|
<span v-if="movie.time" class="rounded bg-white px-2.5 py-1.5 text-slate-950">{{ movie.time }}</span>
|
||||||
|
<span v-if="movie.episode" class="rounded bg-black/28 px-2.5 py-1.5 text-white ring-1 ring-white/10">{{ movie.episode }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="mt-3 line-clamp-2 text-xs font-bold leading-6 text-slate-200">
|
||||||
|
<template v-if="movie.countries?.length">{{ movie.countries.slice(0, 2).join(' • ') }}</template>
|
||||||
|
<template v-if="movie.countries?.length && movie.categories?.length"> • </template>
|
||||||
|
<template v-if="movie.categories?.length">{{ movie.categories.slice(0, 3).join(' • ') }}</template>
|
||||||
|
<template v-if="!movie.countries?.length && !movie.categories?.length">Phim Hàn Quốc Vietsub mới cập nhật</template>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</NuxtLink>
|
||||||
|
</Transition>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ArrowUp } from 'lucide-vue-next'
|
||||||
|
|
||||||
|
const isVisible = ref(false)
|
||||||
|
|
||||||
|
function updateVisibility() {
|
||||||
|
isVisible.value = window.scrollY > 420
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollToTop() {
|
||||||
|
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
updateVisibility()
|
||||||
|
window.addEventListener('scroll', updateVisibility, { passive: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
window.removeEventListener('scroll', updateVisibility)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Transition
|
||||||
|
enter-active-class="transition duration-200 ease-out"
|
||||||
|
enter-from-class="translate-y-3 opacity-0"
|
||||||
|
enter-to-class="translate-y-0 opacity-100"
|
||||||
|
leave-active-class="transition duration-150 ease-in"
|
||||||
|
leave-from-class="translate-y-0 opacity-100"
|
||||||
|
leave-to-class="translate-y-3 opacity-0"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
v-if="isVisible"
|
||||||
|
type="button"
|
||||||
|
class="fixed bottom-6 right-5 z-50 grid size-12 place-items-center rounded-full bg-sky-400 text-slate-950 shadow-2xl shadow-sky-950/40 ring-1 ring-white/20 transition hover:-translate-y-0.5 hover:bg-white sm:bottom-8 sm:right-8"
|
||||||
|
aria-label="Lên đầu trang"
|
||||||
|
@click="scrollToTop"
|
||||||
|
>
|
||||||
|
<ArrowUp class="size-6" />
|
||||||
|
</button>
|
||||||
|
</Transition>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { BookPlus, ChevronLeft, ChevronRight, Heart, Info, Play } from 'lucide-vue-next'
|
||||||
|
|
||||||
|
const { data, pending, error } = await useFetch('/api/movies', {
|
||||||
|
query: {
|
||||||
|
page: 1,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const movies = computed(() => data.value?.items ?? [])
|
||||||
|
const heroIndex = ref(0)
|
||||||
|
const heroSlides = computed(() => movies.value.slice(0, 6))
|
||||||
|
const hero = computed(() => heroSlides.value[heroIndex.value] ?? movies.value[0])
|
||||||
|
const sourceStatus = computed(() => data.value?.sources ?? [])
|
||||||
|
|
||||||
|
let heroTimer: ReturnType<typeof setInterval> | undefined
|
||||||
|
|
||||||
|
function selectHero(index: number) {
|
||||||
|
heroIndex.value = index
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextHero() {
|
||||||
|
if (!heroSlides.value.length) return
|
||||||
|
heroIndex.value = (heroIndex.value + 1) % heroSlides.value.length
|
||||||
|
}
|
||||||
|
|
||||||
|
function previousHero() {
|
||||||
|
if (!heroSlides.value.length) return
|
||||||
|
heroIndex.value = (heroIndex.value - 1 + heroSlides.value.length) % heroSlides.value.length
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(heroSlides, (slides) => {
|
||||||
|
if (heroIndex.value >= slides.length) {
|
||||||
|
heroIndex.value = 0
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
heroTimer = setInterval(nextHero, 6500)
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (heroTimer) clearInterval(heroTimer)
|
||||||
|
})
|
||||||
|
|
||||||
|
const rows = computed(() => [
|
||||||
|
{
|
||||||
|
title: 'Mới cập nhật',
|
||||||
|
to: '/phim',
|
||||||
|
items: movies.value.slice(0, 12),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Phim bộ Hàn Quốc',
|
||||||
|
to: '/phim?type=series',
|
||||||
|
items: movies.value.filter((movie: any) => movie.type !== 'single').slice(0, 12),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Phim lẻ & đặc biệt',
|
||||||
|
to: '/phim?type=single',
|
||||||
|
items: movies.value.filter((movie: any) => movie.type === 'single' || movie.episode === 'Full').slice(0, 12),
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
useHead({
|
||||||
|
title: 'KR Phim - Phim Hàn Quốc',
|
||||||
|
meta: [
|
||||||
|
{
|
||||||
|
name: 'description',
|
||||||
|
content: 'Xem phim Hàn Quốc Vietsub từ OPhim và NguồnC với giao diện xanh da trời kiểu Netflix.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<main class="min-h-screen overflow-hidden">
|
||||||
|
<AppHeader />
|
||||||
|
|
||||||
|
<section v-if="hero" class="relative h-[680px] min-h-[680px] overflow-hidden pt-28 sm:h-[720px] sm:min-h-[720px] lg:h-[760px] lg:min-h-[760px] lg:pt-16">
|
||||||
|
<TransitionGroup name="hero-fade">
|
||||||
|
<img
|
||||||
|
v-for="(slide, index) in heroSlides"
|
||||||
|
v-show="index === heroIndex"
|
||||||
|
:key="`${slide.source}-${slide.slug}`"
|
||||||
|
:src="slide.poster || slide.thumb"
|
||||||
|
:alt="slide.name"
|
||||||
|
class="absolute inset-0 h-full w-full object-cover"
|
||||||
|
>
|
||||||
|
</TransitionGroup>
|
||||||
|
<div class="absolute inset-0 bg-gradient-to-r from-slate-950 via-slate-950/76 to-slate-950/10" />
|
||||||
|
<div class="absolute inset-0 bg-gradient-to-t from-slate-950 via-slate-950/20 to-slate-950/30" />
|
||||||
|
<div class="absolute inset-y-0 right-0 hidden w-1/2 bg-gradient-to-l from-sky-950/18 to-transparent lg:block" />
|
||||||
|
|
||||||
|
<div class="relative mx-auto grid h-full max-w-[1560px] content-center px-4 pb-28 pt-16 sm:px-6 lg:px-8 xl:px-10">
|
||||||
|
<div class="max-w-5xl">
|
||||||
|
<p class="mb-3 text-sm font-semibold text-sky-200">
|
||||||
|
Chỉ phim Hàn Quốc
|
||||||
|
</p>
|
||||||
|
<h1 class="max-w-5xl text-3xl font-black leading-tight text-white drop-shadow-2xl sm:text-5xl lg:text-6xl">
|
||||||
|
{{ hero.name }}
|
||||||
|
</h1>
|
||||||
|
<p class="mt-3 max-w-2xl text-lg text-sky-100">
|
||||||
|
{{ hero.originName || 'Kho phim Hàn Vietsub mới cập nhật' }}
|
||||||
|
</p>
|
||||||
|
<div class="mt-5 flex flex-wrap gap-2 text-sm font-bold text-slate-100">
|
||||||
|
<span v-if="hero.rating" class="rounded bg-black/35 px-3 py-2 ring-1 ring-white/10">IMDb {{ hero.rating.toFixed(1) }}</span>
|
||||||
|
<span v-if="hero.quality" class="rounded bg-sky-300 px-3 py-2 text-slate-950">{{ hero.quality }}</span>
|
||||||
|
<span v-if="hero.lang" class="rounded bg-white px-3 py-2 text-slate-950">{{ hero.lang }}</span>
|
||||||
|
<span v-if="hero.year" class="rounded bg-black/35 px-3 py-2 ring-1 ring-white/10">{{ hero.year }}</span>
|
||||||
|
<span v-if="hero.episode" class="rounded bg-black/35 px-3 py-2 ring-1 ring-white/10">{{ hero.episode }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="hero.categories?.length" class="mt-4 flex flex-wrap gap-2 text-sm font-semibold text-white">
|
||||||
|
<span
|
||||||
|
v-for="category in hero.categories.slice(0, 4)"
|
||||||
|
:key="category"
|
||||||
|
class="rounded bg-white/12 px-3 py-1.5 backdrop-blur"
|
||||||
|
>
|
||||||
|
{{ category }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p class="mt-5 max-w-2xl text-sm leading-7 text-slate-100 sm:text-base">
|
||||||
|
{{ hero.originName ? `${hero.originName}. ` : '' }}{{ hero.time || 'Phim Hàn Quốc Vietsub mới cập nhật.' }}
|
||||||
|
</p>
|
||||||
|
<div class="mt-8 flex items-center gap-4">
|
||||||
|
<NuxtLink
|
||||||
|
:to="{ path: `/phim/${hero.slug}`, query: { source: hero.source } }"
|
||||||
|
class="grid size-16 shrink-0 place-items-center rounded-full bg-sky-300 text-slate-950 shadow-2xl shadow-sky-950/50 transition hover:scale-105 hover:bg-white"
|
||||||
|
aria-label="Xem ngay"
|
||||||
|
>
|
||||||
|
<Play class="size-7 fill-current" />
|
||||||
|
</NuxtLink>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="grid size-12 shrink-0 place-items-center rounded-full border border-white/20 bg-black/28 text-white backdrop-blur transition hover:bg-white/16"
|
||||||
|
aria-label="Yêu thích"
|
||||||
|
>
|
||||||
|
<Heart class="size-5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="grid size-12 shrink-0 place-items-center rounded-full border border-white/20 bg-black/28 text-white backdrop-blur transition hover:bg-white/16"
|
||||||
|
aria-label="Thêm vào thư viện"
|
||||||
|
>
|
||||||
|
<BookPlus class="size-5" />
|
||||||
|
</button>
|
||||||
|
<NuxtLink
|
||||||
|
:to="{ path: `/phim/${hero.slug}`, query: { source: hero.source } }"
|
||||||
|
class="grid size-12 shrink-0 place-items-center rounded-full border border-white/20 bg-black/28 text-white backdrop-blur transition hover:bg-white/16"
|
||||||
|
aria-label="Thông tin phim"
|
||||||
|
>
|
||||||
|
<Info class="size-5" />
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="absolute bottom-12 right-4 flex max-w-[calc(100%-2rem)] items-center gap-3 sm:right-6 lg:right-8">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="hidden size-10 shrink-0 place-items-center rounded-full border border-white/15 bg-black/35 text-white backdrop-blur transition hover:bg-white/15 sm:grid"
|
||||||
|
aria-label="Phim trước"
|
||||||
|
@click="previousHero"
|
||||||
|
>
|
||||||
|
<ChevronLeft class="size-5" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="no-scrollbar flex gap-3 overflow-x-auto pb-2">
|
||||||
|
<button
|
||||||
|
v-for="(slide, index) in heroSlides"
|
||||||
|
:key="`${slide.source}-${slide.slug}-thumb`"
|
||||||
|
type="button"
|
||||||
|
class="relative h-14 w-28 shrink-0 overflow-hidden rounded-md border bg-slate-900 shadow-lg shadow-black/30 transition sm:h-16 sm:w-32"
|
||||||
|
:class="index === heroIndex ? 'border-sky-300 ring-2 ring-sky-300/45' : 'border-white/15 opacity-70 hover:opacity-100'"
|
||||||
|
@click="selectHero(index)"
|
||||||
|
>
|
||||||
|
<img :src="slide.thumb || slide.poster" :alt="slide.name" class="h-full w-full object-cover">
|
||||||
|
<span class="absolute inset-x-0 bottom-0 truncate bg-black/55 px-1 py-0.5 text-[10px] font-semibold text-white">
|
||||||
|
{{ slide.name }}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="hidden size-10 shrink-0 place-items-center rounded-full border border-white/15 bg-black/35 text-white backdrop-blur transition hover:bg-white/15 sm:grid"
|
||||||
|
aria-label="Phim tiếp theo"
|
||||||
|
@click="nextHero"
|
||||||
|
>
|
||||||
|
<ChevronRight class="size-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-else class="mx-auto flex min-h-screen max-w-7xl items-center px-4 pt-36 sm:px-6 lg:px-8 lg:pt-24">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-4xl font-black text-white">KR Phim</h1>
|
||||||
|
<p class="mt-3 text-slate-300">Đang tải kho phim Hàn Quốc...</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="relative z-10 mx-auto -mt-10 max-w-[1560px] px-4 pb-16 sm:px-6 lg:px-8 xl:px-10">
|
||||||
|
<div v-if="sourceStatus.length" class="mb-8 flex flex-wrap gap-2 text-xs text-slate-300">
|
||||||
|
<span
|
||||||
|
v-for="source in sourceStatus"
|
||||||
|
:key="source.name"
|
||||||
|
class="rounded-full border border-white/10 bg-white/8 px-3 py-1"
|
||||||
|
>
|
||||||
|
{{ source.name }}: {{ source.ok ? 'sẵn sàng' : 'đang bị chặn' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="error" class="rounded-md border border-red-300/30 bg-red-500/12 p-4 text-red-100">
|
||||||
|
Không tải được dữ liệu phim. Vui lòng thử lại sau.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div v-if="pending" class="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-6">
|
||||||
|
<div v-for="item in 12" :key="item" class="aspect-[2/3] animate-pulse rounded-md bg-white/10" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section
|
||||||
|
v-for="(row, index) in rows"
|
||||||
|
v-show="row.items.length"
|
||||||
|
:id="index === 0 ? 'latest' : index === 1 ? 'series' : 'movies'"
|
||||||
|
:key="row.title"
|
||||||
|
class="mb-12"
|
||||||
|
>
|
||||||
|
<div class="mb-4 flex items-end justify-between gap-4">
|
||||||
|
<h2 class="text-2xl font-extrabold text-white">{{ row.title }}</h2>
|
||||||
|
<NuxtLink :to="row.to" class="text-sm font-semibold text-sky-200 hover:text-white">
|
||||||
|
Xem thêm
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="no-scrollbar -mx-4 flex snap-x gap-4 overflow-x-auto px-4 pb-5">
|
||||||
|
<HomeMovieCard
|
||||||
|
v-for="movie in row.items"
|
||||||
|
:key="`${movie.source}-${movie.slug}`"
|
||||||
|
:movie="movie"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
const route = useRoute()
|
||||||
|
const selectedServer = ref(0)
|
||||||
|
const selectedEpisode = ref(0)
|
||||||
|
|
||||||
|
const { data: movie, pending, error } = await useFetch(`/api/movies/${route.params.slug}`, {
|
||||||
|
query: {
|
||||||
|
source: route.query.source,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const servers = computed(() => movie.value?.servers ?? [])
|
||||||
|
const activeServer = computed(() => servers.value[selectedServer.value])
|
||||||
|
const activeEpisode = computed(() => activeServer.value?.episodes?.[selectedEpisode.value])
|
||||||
|
const playerUrl = computed(() => activeEpisode.value?.linkEmbed || activeEpisode.value?.linkM3u8 || '')
|
||||||
|
const episodeCount = computed(() => servers.value.reduce((total: number, server: any) => total + (server.episodes?.length || 0), 0))
|
||||||
|
|
||||||
|
watch(selectedServer, () => {
|
||||||
|
selectedEpisode.value = 0
|
||||||
|
})
|
||||||
|
|
||||||
|
useHead(() => ({
|
||||||
|
title: movie.value ? `${movie.value.name} - KR Phim` : 'Đang tải phim - KR Phim',
|
||||||
|
meta: [
|
||||||
|
{
|
||||||
|
name: 'description',
|
||||||
|
content: movie.value?.content || 'Xem phim Hàn Quốc Vietsub.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<main class="min-h-screen bg-slate-950 text-white">
|
||||||
|
<AppHeader />
|
||||||
|
|
||||||
|
<div v-if="pending" class="mx-auto grid min-h-screen max-w-7xl gap-8 px-4 pt-36 sm:px-6 lg:grid-cols-[minmax(0,1fr)_22rem] lg:px-8 lg:pt-28">
|
||||||
|
<div class="aspect-video animate-pulse rounded-md bg-white/10" />
|
||||||
|
<div class="h-96 animate-pulse rounded-md bg-white/10" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="error || !movie" class="mx-auto flex min-h-screen max-w-4xl items-center px-4 pt-36 lg:pt-24">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-3xl font-black">Không mở được phim</h1>
|
||||||
|
<p class="mt-3 text-slate-300">Nguồn phim có thể đang chặn request hoặc phim chưa có tập xem.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<section class="relative pt-32 lg:pt-16">
|
||||||
|
<img :src="movie.poster || movie.thumb" :alt="movie.name" class="absolute inset-0 h-full w-full object-cover opacity-25">
|
||||||
|
<div class="absolute inset-0 bg-gradient-to-b from-slate-950/70 via-slate-950 to-slate-950" />
|
||||||
|
|
||||||
|
<div class="relative mx-auto grid max-w-7xl gap-8 px-4 py-12 sm:px-6 lg:grid-cols-[minmax(0,1fr)_22rem] lg:px-8">
|
||||||
|
<div>
|
||||||
|
<div class="overflow-hidden rounded-md bg-black shadow-2xl shadow-sky-950/40 ring-1 ring-white/10">
|
||||||
|
<div class="aspect-video">
|
||||||
|
<iframe
|
||||||
|
v-if="playerUrl"
|
||||||
|
:src="playerUrl"
|
||||||
|
:title="`${movie.name} - ${activeEpisode?.name || 'Tập phim'}`"
|
||||||
|
class="h-full w-full"
|
||||||
|
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||||
|
allowfullscreen
|
||||||
|
/>
|
||||||
|
<div v-else class="flex h-full items-center justify-center bg-slate-900 px-6 text-center text-slate-300">
|
||||||
|
Phim này chưa có link xem khả dụng từ API.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-8">
|
||||||
|
<p class="mb-3 text-sm font-semibold uppercase tracking-wide text-sky-300">{{ movie.source }} · {{ movie.lang || 'Vietsub' }}</p>
|
||||||
|
<h1 class="text-3xl font-black leading-tight sm:text-5xl">{{ movie.name }}</h1>
|
||||||
|
<p v-if="movie.originName" class="mt-2 text-lg text-slate-300">{{ movie.originName }}</p>
|
||||||
|
|
||||||
|
<div class="mt-5 flex flex-wrap gap-2 text-sm text-slate-100">
|
||||||
|
<span v-if="movie.year" class="rounded-full bg-white/10 px-3 py-1">{{ movie.year }}</span>
|
||||||
|
<span v-if="movie.quality" class="rounded-full bg-white/10 px-3 py-1">{{ movie.quality }}</span>
|
||||||
|
<span v-if="movie.episode" class="rounded-full bg-sky-400/20 px-3 py-1 text-sky-100">{{ movie.episode }}</span>
|
||||||
|
<span v-if="movie.time" class="rounded-full bg-white/10 px-3 py-1">{{ movie.time }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="movie.content" class="mt-6 max-w-4xl text-base leading-8 text-slate-200">
|
||||||
|
{{ movie.content }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="mt-6 grid gap-3 text-sm text-slate-300 sm:grid-cols-2">
|
||||||
|
<p v-if="movie.categories?.length"><span class="font-bold text-white">Thể loại:</span> {{ movie.categories.join(', ') }}</p>
|
||||||
|
<p v-if="movie.countries?.length"><span class="font-bold text-white">Quốc gia:</span> {{ movie.countries.join(', ') }}</p>
|
||||||
|
<p v-if="movie.actors?.length"><span class="font-bold text-white">Diễn viên:</span> {{ movie.actors.slice(0, 8).join(', ') }}</p>
|
||||||
|
<p v-if="movie.directors?.length"><span class="font-bold text-white">Đạo diễn:</span> {{ movie.directors.join(', ') }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<aside class="lg:sticky lg:top-24 lg:self-start">
|
||||||
|
<div class="rounded-md border border-white/10 bg-white/8 p-4 shadow-2xl shadow-black/20 backdrop-blur-xl">
|
||||||
|
<div class="flex items-center justify-between gap-3">
|
||||||
|
<h2 class="text-xl font-extrabold">Danh sách tập</h2>
|
||||||
|
<span class="text-sm text-slate-300">{{ episodeCount }} tập</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="servers.length > 1" class="mt-4 flex gap-2 overflow-x-auto pb-2">
|
||||||
|
<button
|
||||||
|
v-for="(server, index) in servers"
|
||||||
|
:key="server.name"
|
||||||
|
type="button"
|
||||||
|
class="shrink-0 rounded-md px-3 py-2 text-sm font-semibold"
|
||||||
|
:class="selectedServer === index ? 'bg-sky-300 text-slate-950' : 'bg-white/10 text-white hover:bg-white/16'"
|
||||||
|
@click="selectedServer = index"
|
||||||
|
>
|
||||||
|
{{ server.name }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="activeServer?.episodes?.length" class="mt-4 grid max-h-[32rem] grid-cols-3 gap-2 overflow-y-auto pr-1 sm:grid-cols-4 lg:grid-cols-3">
|
||||||
|
<button
|
||||||
|
v-for="(episode, index) in activeServer.episodes"
|
||||||
|
:key="`${episode.name}-${index}`"
|
||||||
|
type="button"
|
||||||
|
class="min-h-11 rounded-md px-3 py-2 text-sm font-bold transition"
|
||||||
|
:class="selectedEpisode === index ? 'bg-sky-300 text-slate-950' : 'bg-slate-900/80 text-slate-100 hover:bg-sky-400/20'"
|
||||||
|
@click="selectedEpisode = index"
|
||||||
|
>
|
||||||
|
{{ episode.name }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-else class="mt-4 rounded-md border border-white/10 bg-slate-900/70 p-4 text-sm text-slate-300">
|
||||||
|
Chưa có tập xem từ nguồn này.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ChevronLeft, ChevronRight, Search } from 'lucide-vue-next'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const page = ref(Math.max(Number(route.query.page || 1), 1))
|
||||||
|
const keyword = ref(typeof route.query.q === 'string' ? route.query.q : '')
|
||||||
|
const debouncedKeyword = ref(keyword.value.trim())
|
||||||
|
const type = ref(typeof route.query.type === 'string' ? route.query.type : 'all')
|
||||||
|
|
||||||
|
let searchTimer: ReturnType<typeof setTimeout> | undefined
|
||||||
|
|
||||||
|
watch(keyword, (value) => {
|
||||||
|
if (searchTimer) clearTimeout(searchTimer)
|
||||||
|
searchTimer = setTimeout(() => {
|
||||||
|
debouncedKeyword.value = value.trim()
|
||||||
|
page.value = 1
|
||||||
|
}, 420)
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(type, () => {
|
||||||
|
page.value = 1
|
||||||
|
})
|
||||||
|
|
||||||
|
watch([page, debouncedKeyword, type], () => {
|
||||||
|
router.replace({
|
||||||
|
query: {
|
||||||
|
page: page.value > 1 ? page.value : undefined,
|
||||||
|
q: debouncedKeyword.value || undefined,
|
||||||
|
type: type.value !== 'all' ? type.value : undefined,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data, pending, error } = await useFetch('/api/movies', {
|
||||||
|
query: computed(() => ({
|
||||||
|
page: page.value,
|
||||||
|
keyword: debouncedKeyword.value || undefined,
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
|
||||||
|
const movies = computed(() => data.value?.items ?? [])
|
||||||
|
const filteredMovies = computed(() => {
|
||||||
|
if (type.value === 'series') {
|
||||||
|
return movies.value.filter((movie: any) => movie.type !== 'single')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type.value === 'single') {
|
||||||
|
return movies.value.filter((movie: any) => movie.type === 'single' || movie.episode === 'Full')
|
||||||
|
}
|
||||||
|
|
||||||
|
return movies.value
|
||||||
|
})
|
||||||
|
const totalPages = computed(() => Number(data.value?.pagination?.totalPages || data.value?.pagination?.total_pages || 0))
|
||||||
|
const sourceStatus = computed(() => data.value?.sources ?? [])
|
||||||
|
|
||||||
|
function nextPage() {
|
||||||
|
page.value += 1
|
||||||
|
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function previousPage() {
|
||||||
|
page.value = Math.max(1, page.value - 1)
|
||||||
|
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||||
|
}
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (searchTimer) clearTimeout(searchTimer)
|
||||||
|
})
|
||||||
|
|
||||||
|
useHead({
|
||||||
|
title: 'Duyệt phim Hàn Quốc - KR Phim',
|
||||||
|
meta: [
|
||||||
|
{
|
||||||
|
name: 'description',
|
||||||
|
content: 'Tìm kiếm và lọc phim Hàn Quốc Vietsub trên KR Phim.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<main class="min-h-screen bg-slate-950">
|
||||||
|
<AppHeader />
|
||||||
|
|
||||||
|
<section class="mx-auto max-w-[1560px] px-4 pb-16 pt-36 sm:px-6 lg:px-8 lg:pt-28 xl:px-10">
|
||||||
|
<div class="mb-8 flex flex-col gap-5 lg:flex-row lg:items-end lg:justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-semibold text-sky-200">Kho phim Hàn Quốc</p>
|
||||||
|
<h1 class="mt-2 text-3xl font-black text-white sm:text-5xl">
|
||||||
|
Duyệt phim
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex w-full flex-col gap-3 sm:flex-row lg:max-w-3xl">
|
||||||
|
<div class="flex min-w-0 flex-1 items-center rounded-full border border-white/10 bg-white/8 px-4 py-3 shadow-2xl shadow-sky-950/20">
|
||||||
|
<Search class="mr-3 size-4 shrink-0 text-sky-200" />
|
||||||
|
<input
|
||||||
|
v-model="keyword"
|
||||||
|
type="search"
|
||||||
|
placeholder="Tìm phim Hàn Quốc..."
|
||||||
|
class="w-full bg-transparent text-sm text-white outline-none placeholder:text-slate-400"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-3 rounded-full border border-white/10 bg-white/8 p-1 text-sm font-bold text-slate-200">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-full px-4 py-2 transition"
|
||||||
|
:class="type === 'all' ? 'bg-sky-300 text-slate-950' : 'hover:text-white'"
|
||||||
|
@click="type = 'all'"
|
||||||
|
>
|
||||||
|
Tất cả
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-full px-4 py-2 transition"
|
||||||
|
:class="type === 'series' ? 'bg-sky-300 text-slate-950' : 'hover:text-white'"
|
||||||
|
@click="type = 'series'"
|
||||||
|
>
|
||||||
|
Phim bộ
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-full px-4 py-2 transition"
|
||||||
|
:class="type === 'single' ? 'bg-sky-300 text-slate-950' : 'hover:text-white'"
|
||||||
|
@click="type = 'single'"
|
||||||
|
>
|
||||||
|
Phim lẻ
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="sourceStatus.length" class="mb-8 flex flex-wrap gap-2 text-xs text-slate-300">
|
||||||
|
<span
|
||||||
|
v-for="source in sourceStatus"
|
||||||
|
:key="source.name"
|
||||||
|
class="rounded-full border border-white/10 bg-white/8 px-3 py-1"
|
||||||
|
>
|
||||||
|
{{ source.name }}: {{ source.ok ? 'sẵn sàng' : 'đang bị chặn' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="error" class="rounded-md border border-red-300/30 bg-red-500/12 p-4 text-red-100">
|
||||||
|
Không tải được dữ liệu phim. Vui lòng thử lại sau.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div v-if="pending" class="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-5 xl:grid-cols-6">
|
||||||
|
<div v-for="item in 18" :key="item" class="aspect-[2/3] animate-pulse rounded-md bg-white/10" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-5 xl:grid-cols-6">
|
||||||
|
<NuxtLink
|
||||||
|
v-for="movie in filteredMovies"
|
||||||
|
:key="`${movie.source}-${movie.slug}`"
|
||||||
|
:to="{ path: `/phim/${movie.slug}`, query: { source: movie.source } }"
|
||||||
|
class="group"
|
||||||
|
>
|
||||||
|
<div class="relative aspect-[2/3] overflow-hidden rounded-md bg-slate-900 shadow-xl shadow-black/25 ring-1 ring-white/10 transition duration-300 group-hover:-translate-y-1 group-hover:ring-sky-300/60">
|
||||||
|
<img :src="movie.thumb || movie.poster" :alt="movie.name" class="h-full w-full object-cover transition duration-500 group-hover:scale-105">
|
||||||
|
<div class="absolute inset-x-0 bottom-0 bg-gradient-to-t from-slate-950 via-slate-950/70 to-transparent p-3">
|
||||||
|
<p class="line-clamp-2 text-sm font-bold leading-snug text-white">{{ movie.name }}</p>
|
||||||
|
<p class="mt-1 truncate text-xs text-sky-100">{{ movie.episode || movie.year || movie.quality }}</p>
|
||||||
|
</div>
|
||||||
|
<span class="absolute left-2 top-2 rounded bg-sky-400 px-2 py-1 text-xs font-black text-slate-950">
|
||||||
|
{{ movie.source }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="!pending && !filteredMovies.length" class="rounded-md border border-white/10 bg-white/8 p-8 text-center text-slate-300">
|
||||||
|
Không có phim phù hợp.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="movies.length" class="flex items-center justify-center gap-3 pt-10">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
:disabled="page === 1"
|
||||||
|
class="grid size-11 place-items-center rounded-full border border-white/15 bg-white/8 text-white disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
|
aria-label="Trang trước"
|
||||||
|
@click="previousPage"
|
||||||
|
>
|
||||||
|
<ChevronLeft class="size-5" />
|
||||||
|
</button>
|
||||||
|
<span class="text-sm text-slate-300">Trang {{ page }}<template v-if="totalPages"> / {{ totalPages }}</template></span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="grid size-11 place-items-center rounded-full bg-sky-300 text-slate-950 hover:bg-white"
|
||||||
|
aria-label="Trang sau"
|
||||||
|
@click="nextPage"
|
||||||
|
>
|
||||||
|
<ChevronRight class="size-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
+9
-1
@@ -1,5 +1,13 @@
|
|||||||
// https://nuxt.com/docs/api/configuration/nuxt-config
|
// https://nuxt.com/docs/api/configuration/nuxt-config
|
||||||
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
|
|
||||||
export default defineNuxtConfig({
|
export default defineNuxtConfig({
|
||||||
compatibilityDate: '2025-07-15',
|
compatibilityDate: '2025-07-15',
|
||||||
devtools: { enabled: true }
|
devtools: { enabled: true },
|
||||||
|
css: ['~/assets/css/main.css'],
|
||||||
|
vite: {
|
||||||
|
plugins: [
|
||||||
|
tailwindcss(),
|
||||||
|
],
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
Generated
+552
-140
@@ -7,7 +7,11 @@
|
|||||||
"name": "kr-phim",
|
"name": "kr-phim",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@oxc-parser/binding-win32-x64-msvc": "^0.128.0",
|
||||||
|
"@tailwindcss/vite": "^4.3.0",
|
||||||
|
"lucide-vue-next": "^1.0.0",
|
||||||
"nuxt": "^4.4.5",
|
"nuxt": "^4.4.5",
|
||||||
|
"tailwindcss": "^4.3.0",
|
||||||
"vue": "^3.5.34",
|
"vue": "^3.5.34",
|
||||||
"vue-router": "^5.0.6"
|
"vue-router": "^5.0.6"
|
||||||
}
|
}
|
||||||
@@ -1620,9 +1624,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1639,9 +1640,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1658,9 +1656,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1677,9 +1672,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1696,9 +1688,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1715,9 +1704,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1734,9 +1720,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1753,9 +1736,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1966,9 +1946,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1985,9 +1962,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2004,9 +1978,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2023,9 +1994,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2042,9 +2010,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2061,9 +2026,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2080,9 +2042,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2099,9 +2058,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2185,7 +2141,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
|
||||||
"os": [
|
"os": [
|
||||||
"win32"
|
"win32"
|
||||||
],
|
],
|
||||||
@@ -2321,9 +2276,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2340,9 +2292,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2359,9 +2308,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2378,9 +2324,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2397,9 +2340,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2416,9 +2356,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2435,9 +2372,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2454,9 +2388,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2670,9 +2601,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2693,9 +2621,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2716,9 +2641,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2739,9 +2661,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2762,9 +2681,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2785,9 +2701,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3194,9 +3107,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3210,9 +3120,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3226,9 +3133,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3242,9 +3146,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3258,9 +3159,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3274,9 +3172,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3290,9 +3185,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3306,9 +3198,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3322,9 +3211,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3338,9 +3224,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3354,9 +3237,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3370,9 +3250,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3386,9 +3263,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3518,6 +3392,263 @@
|
|||||||
"integrity": "sha512-BMq1K3DsElxDWawkX6eLg9+CKJrTVGCBAWVuHXVUV2u0s2711qiChLSId6ikYPfxhdYocLNt3wWwSvDiTvFabw==",
|
"integrity": "sha512-BMq1K3DsElxDWawkX6eLg9+CKJrTVGCBAWVuHXVUV2u0s2711qiChLSId6ikYPfxhdYocLNt3wWwSvDiTvFabw==",
|
||||||
"license": "CC0-1.0"
|
"license": "CC0-1.0"
|
||||||
},
|
},
|
||||||
|
"node_modules/@tailwindcss/node": {
|
||||||
|
"version": "4.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.0.tgz",
|
||||||
|
"integrity": "sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@jridgewell/remapping": "^2.3.5",
|
||||||
|
"enhanced-resolve": "^5.21.0",
|
||||||
|
"jiti": "^2.6.1",
|
||||||
|
"lightningcss": "1.32.0",
|
||||||
|
"magic-string": "^0.30.21",
|
||||||
|
"source-map-js": "^1.2.1",
|
||||||
|
"tailwindcss": "4.3.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide": {
|
||||||
|
"version": "4.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.0.tgz",
|
||||||
|
"integrity": "sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 20"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@tailwindcss/oxide-android-arm64": "4.3.0",
|
||||||
|
"@tailwindcss/oxide-darwin-arm64": "4.3.0",
|
||||||
|
"@tailwindcss/oxide-darwin-x64": "4.3.0",
|
||||||
|
"@tailwindcss/oxide-freebsd-x64": "4.3.0",
|
||||||
|
"@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.0",
|
||||||
|
"@tailwindcss/oxide-linux-arm64-gnu": "4.3.0",
|
||||||
|
"@tailwindcss/oxide-linux-arm64-musl": "4.3.0",
|
||||||
|
"@tailwindcss/oxide-linux-x64-gnu": "4.3.0",
|
||||||
|
"@tailwindcss/oxide-linux-x64-musl": "4.3.0",
|
||||||
|
"@tailwindcss/oxide-wasm32-wasi": "4.3.0",
|
||||||
|
"@tailwindcss/oxide-win32-arm64-msvc": "4.3.0",
|
||||||
|
"@tailwindcss/oxide-win32-x64-msvc": "4.3.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-android-arm64": {
|
||||||
|
"version": "4.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.0.tgz",
|
||||||
|
"integrity": "sha512-TJPiq67tKlLuObP6RkwvVGDoxCMBVtDgKkLfa/uyj7/FyxvQwHS+UOnVrXXgbEsfUaMgiVvC4KbJnRr26ho4Ng==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-darwin-arm64": {
|
||||||
|
"version": "4.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.0.tgz",
|
||||||
|
"integrity": "sha512-oMN/WZRb+SO37BmUElEgeEWuU8E/HXRkiODxJxLe1UTHVXLrdVSgfaJV7pSlhRGMSOiXLuxTIjfsF3wYvz8cgQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-darwin-x64": {
|
||||||
|
"version": "4.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.0.tgz",
|
||||||
|
"integrity": "sha512-N6CUmu4a6bKVADfw77p+iw6Yd9Q3OBhe0veaDX+QazfuVYlQsHfDgxBrsjQ/IW+zywL8mTrNd0SdJT/zgtvMdA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-freebsd-x64": {
|
||||||
|
"version": "4.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.0.tgz",
|
||||||
|
"integrity": "sha512-zDL5hBkQdH5C6MpqbK3gQAgP80tsMwSI26vjOzjJtNCMUo0lFgOItzHKBIupOZNQxt3ouPH7RPhvNhiTfCe5CQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
|
||||||
|
"version": "4.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.0.tgz",
|
||||||
|
"integrity": "sha512-R06HdNi7A7OEoMsf6d4tjZ71RCWnZQPHj2mnotSFURjNLdBC+cIgXQ7l81CqeoiQftjf6OOblxXMInMgN2VzMA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
|
||||||
|
"version": "4.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.0.tgz",
|
||||||
|
"integrity": "sha512-qTJHELX8jetjhRQHCLilkVLmybpzNQAtaI/gaoVoidn/ufbNDbAo8KlK2J+yPoc8wQxvDxCmh/5lr8nC1+lTbg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-linux-arm64-musl": {
|
||||||
|
"version": "4.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.0.tgz",
|
||||||
|
"integrity": "sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-linux-x64-gnu": {
|
||||||
|
"version": "4.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.0.tgz",
|
||||||
|
"integrity": "sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-linux-x64-musl": {
|
||||||
|
"version": "4.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.0.tgz",
|
||||||
|
"integrity": "sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-wasm32-wasi": {
|
||||||
|
"version": "4.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.0.tgz",
|
||||||
|
"integrity": "sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==",
|
||||||
|
"bundleDependencies": [
|
||||||
|
"@napi-rs/wasm-runtime",
|
||||||
|
"@emnapi/core",
|
||||||
|
"@emnapi/runtime",
|
||||||
|
"@tybys/wasm-util",
|
||||||
|
"@emnapi/wasi-threads",
|
||||||
|
"tslib"
|
||||||
|
],
|
||||||
|
"cpu": [
|
||||||
|
"wasm32"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@emnapi/core": "^1.10.0",
|
||||||
|
"@emnapi/runtime": "^1.10.0",
|
||||||
|
"@emnapi/wasi-threads": "^1.2.1",
|
||||||
|
"@napi-rs/wasm-runtime": "^1.1.4",
|
||||||
|
"@tybys/wasm-util": "^0.10.1",
|
||||||
|
"tslib": "^2.8.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
|
||||||
|
"version": "4.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.0.tgz",
|
||||||
|
"integrity": "sha512-Pe+RPVTi1T+qymuuRpcdvwSVZjnll/f7n8gBxMMh3xLTctMDKqpdfGimbMyioqtLhUYZxdJ9wGNhV7MKHvgZsQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-win32-x64-msvc": {
|
||||||
|
"version": "4.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.0.tgz",
|
||||||
|
"integrity": "sha512-Mvrf2kXW/yeW/OTezZlCGOirXRcUuLIBx/5Y12BaPM7wJoryG6dfS/NJL8aBPqtTEx/Vm4T4vKzFUcKDT+TKUA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/vite": {
|
||||||
|
"version": "4.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.0.tgz",
|
||||||
|
"integrity": "sha512-t6J3OrB5Fc0ExuhohouH0fWUGMYL6PTLhW+E7zIk/pdbnJARZDCwjBznFnkh5ynRnIRSI4YjtTH0t6USjJISrw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@tailwindcss/node": "4.3.0",
|
||||||
|
"@tailwindcss/oxide": "4.3.0",
|
||||||
|
"tailwindcss": "4.3.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"vite": "^5.2.0 || ^6 || ^7 || ^8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@tybys/wasm-util": {
|
"node_modules/@tybys/wasm-util": {
|
||||||
"version": "0.10.2",
|
"version": "0.10.2",
|
||||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
|
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
|
||||||
@@ -4587,16 +4718,6 @@
|
|||||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/commander": {
|
|
||||||
"version": "13.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz",
|
|
||||||
"integrity": "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==",
|
|
||||||
"extraneous": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/commondir": {
|
"node_modules/commondir": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz",
|
||||||
@@ -5225,6 +5346,19 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/enhanced-resolve": {
|
||||||
|
"version": "5.21.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.3.tgz",
|
||||||
|
"integrity": "sha512-QyL119InA+XXEkNLNTPCXPugSvOfhwv0JOlGNzvxs0hZaiHLNvXSpudUWsOlsXGWJh8G6ckCScEkVHfX3kw/2Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"graceful-fs": "^4.2.4",
|
||||||
|
"tapable": "^2.3.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.13.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/entities": {
|
"node_modules/entities": {
|
||||||
"version": "7.0.1",
|
"version": "7.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
|
||||||
@@ -6239,6 +6373,255 @@
|
|||||||
"safe-buffer": "~5.1.0"
|
"safe-buffer": "~5.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/lightningcss": {
|
||||||
|
"version": "1.32.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
||||||
|
"integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"detect-libc": "^2.0.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"lightningcss-android-arm64": "1.32.0",
|
||||||
|
"lightningcss-darwin-arm64": "1.32.0",
|
||||||
|
"lightningcss-darwin-x64": "1.32.0",
|
||||||
|
"lightningcss-freebsd-x64": "1.32.0",
|
||||||
|
"lightningcss-linux-arm-gnueabihf": "1.32.0",
|
||||||
|
"lightningcss-linux-arm64-gnu": "1.32.0",
|
||||||
|
"lightningcss-linux-arm64-musl": "1.32.0",
|
||||||
|
"lightningcss-linux-x64-gnu": "1.32.0",
|
||||||
|
"lightningcss-linux-x64-musl": "1.32.0",
|
||||||
|
"lightningcss-win32-arm64-msvc": "1.32.0",
|
||||||
|
"lightningcss-win32-x64-msvc": "1.32.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-android-arm64": {
|
||||||
|
"version": "1.32.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
|
||||||
|
"integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-darwin-arm64": {
|
||||||
|
"version": "1.32.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz",
|
||||||
|
"integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-darwin-x64": {
|
||||||
|
"version": "1.32.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz",
|
||||||
|
"integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-freebsd-x64": {
|
||||||
|
"version": "1.32.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz",
|
||||||
|
"integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-linux-arm-gnueabihf": {
|
||||||
|
"version": "1.32.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz",
|
||||||
|
"integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-linux-arm64-gnu": {
|
||||||
|
"version": "1.32.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz",
|
||||||
|
"integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-linux-arm64-musl": {
|
||||||
|
"version": "1.32.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz",
|
||||||
|
"integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-linux-x64-gnu": {
|
||||||
|
"version": "1.32.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz",
|
||||||
|
"integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-linux-x64-musl": {
|
||||||
|
"version": "1.32.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz",
|
||||||
|
"integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-win32-arm64-msvc": {
|
||||||
|
"version": "1.32.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz",
|
||||||
|
"integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-win32-x64-msvc": {
|
||||||
|
"version": "1.32.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz",
|
||||||
|
"integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/lilconfig": {
|
"node_modules/lilconfig": {
|
||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
|
||||||
@@ -6337,6 +6720,16 @@
|
|||||||
"yallist": "^3.0.2"
|
"yallist": "^3.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/lucide-vue-next": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lucide-vue-next/-/lucide-vue-next-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-V6SPvx1IHTj/UY+FrIYWV5faISsPSb8BnWSFDxAtezWKvWc9ZZ40PDrdu1/Qb5vg4lHWr1hs1BAMGVGm6V1Xdg==",
|
||||||
|
"deprecated": "Package deprecated. Please use @lucide/vue instead.",
|
||||||
|
"license": "ISC",
|
||||||
|
"peerDependencies": {
|
||||||
|
"vue": ">=3.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/magic-regexp": {
|
"node_modules/magic-regexp": {
|
||||||
"version": "0.10.0",
|
"version": "0.10.0",
|
||||||
"resolved": "https://registry.npmjs.org/magic-regexp/-/magic-regexp-0.10.0.tgz",
|
"resolved": "https://registry.npmjs.org/magic-regexp/-/magic-regexp-0.10.0.tgz",
|
||||||
@@ -8627,6 +9020,25 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tailwindcss": {
|
||||||
|
"version": "4.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.0.tgz",
|
||||||
|
"integrity": "sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/tapable": {
|
||||||
|
"version": "2.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz",
|
||||||
|
"integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/webpack"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/tar": {
|
"node_modules/tar": {
|
||||||
"version": "7.5.15",
|
"version": "7.5.15",
|
||||||
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.15.tgz",
|
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.15.tgz",
|
||||||
|
|||||||
@@ -10,7 +10,11 @@
|
|||||||
"postinstall": "nuxt prepare"
|
"postinstall": "nuxt prepare"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@oxc-parser/binding-win32-x64-msvc": "^0.128.0",
|
||||||
|
"@tailwindcss/vite": "^4.3.0",
|
||||||
|
"lucide-vue-next": "^1.0.0",
|
||||||
"nuxt": "^4.4.5",
|
"nuxt": "^4.4.5",
|
||||||
|
"tailwindcss": "^4.3.0",
|
||||||
"vue": "^3.5.34",
|
"vue": "^3.5.34",
|
||||||
"vue-router": "^5.0.6"
|
"vue-router": "^5.0.6"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { getKoreanMovies } from '../utils/movies'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const query = getQuery(event)
|
||||||
|
const page = Math.max(Number(query.page || 1), 1)
|
||||||
|
const keyword = typeof query.keyword === 'string' ? query.keyword.trim() : ''
|
||||||
|
|
||||||
|
return await getKoreanMovies(page, keyword)
|
||||||
|
})
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { getNguoncDetail, getOphimDetail } from '../../utils/movies'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const slug = getRouterParam(event, 'slug')
|
||||||
|
const source = getQuery(event).source
|
||||||
|
|
||||||
|
if (!slug) {
|
||||||
|
throw createError({ statusCode: 400, statusMessage: 'Thiếu slug phim' })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (source === 'nguonc') {
|
||||||
|
try {
|
||||||
|
return await getNguoncDetail(slug)
|
||||||
|
} catch {
|
||||||
|
return await getOphimDetail(slug)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await getOphimDetail(slug)
|
||||||
|
} catch (error) {
|
||||||
|
try {
|
||||||
|
return await getNguoncDetail(slug)
|
||||||
|
} catch {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,278 @@
|
|||||||
|
type Source = 'ophim' | 'nguonc'
|
||||||
|
|
||||||
|
export interface NormalizedMovie {
|
||||||
|
id: string
|
||||||
|
source: Source
|
||||||
|
name: string
|
||||||
|
originName: string
|
||||||
|
slug: string
|
||||||
|
thumb: string
|
||||||
|
poster: string
|
||||||
|
year?: number
|
||||||
|
time?: string
|
||||||
|
episode?: string
|
||||||
|
quality?: string
|
||||||
|
lang?: string
|
||||||
|
type?: string
|
||||||
|
rating?: number
|
||||||
|
categories: string[]
|
||||||
|
countries: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NormalizedEpisode {
|
||||||
|
name: string
|
||||||
|
slug?: string
|
||||||
|
linkEmbed?: string
|
||||||
|
linkM3u8?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NormalizedServer {
|
||||||
|
name: string
|
||||||
|
episodes: NormalizedEpisode[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MovieDetail extends NormalizedMovie {
|
||||||
|
content: string
|
||||||
|
actors: string[]
|
||||||
|
directors: string[]
|
||||||
|
trailer?: string
|
||||||
|
servers: NormalizedServer[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const OPHIM_BASE = 'https://ophim1.com'
|
||||||
|
const OPHIM_IMAGE = 'https://img.ophim.live/uploads/movies/'
|
||||||
|
const NGUONC_BASE = 'https://phim.nguonc.com'
|
||||||
|
|
||||||
|
function toArray<T>(value: T[] | T | undefined | null): T[] {
|
||||||
|
if (!value) return []
|
||||||
|
return Array.isArray(value) ? value : [value]
|
||||||
|
}
|
||||||
|
|
||||||
|
function text(value: unknown, fallback = '') {
|
||||||
|
return typeof value === 'string' ? value : fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
function joinImage(base: string, path?: string) {
|
||||||
|
if (!path) return ''
|
||||||
|
if (/^https?:\/\//i.test(path)) return path
|
||||||
|
const cleanBase = base.endsWith('/') ? base : `${base}/`
|
||||||
|
const cleanPath = path.startsWith('/') ? path.slice(1) : path
|
||||||
|
return `${cleanBase}${cleanPath}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function joinOphimImage(base: string, path?: string) {
|
||||||
|
if (!path) return ''
|
||||||
|
if (/^https?:\/\//i.test(path)) return path
|
||||||
|
|
||||||
|
const url = new URL(base)
|
||||||
|
const cleanPath = path.startsWith('/') ? path.slice(1) : path
|
||||||
|
const basePath = url.pathname.replace(/\/$/, '')
|
||||||
|
|
||||||
|
if (cleanPath.startsWith('uploads/')) {
|
||||||
|
url.pathname = cleanPath
|
||||||
|
} else if (basePath.includes('/uploads/movies')) {
|
||||||
|
url.pathname = `${basePath}/${cleanPath}`
|
||||||
|
} else {
|
||||||
|
url.pathname = `/uploads/movies/${cleanPath}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return url.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
function includesKorea(movie: any) {
|
||||||
|
const countries = toArray(movie?.country ?? movie?.countries ?? movie?.quoc_gia)
|
||||||
|
if (!countries.length) return true
|
||||||
|
|
||||||
|
return countries.some((country: any) => {
|
||||||
|
const slug = text(country?.slug ?? country).toLowerCase()
|
||||||
|
const name = text(country?.name ?? country).toLowerCase()
|
||||||
|
return slug === 'han-quoc' || name.includes('hàn quốc') || name.includes('han quoc')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripHtml(value?: string) {
|
||||||
|
return text(value)
|
||||||
|
.replace(/<[^>]+>/g, ' ')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeOphimMovie(movie: any, pathImage = OPHIM_IMAGE): NormalizedMovie {
|
||||||
|
return {
|
||||||
|
id: `ophim:${movie?._id ?? movie?.slug}`,
|
||||||
|
source: 'ophim',
|
||||||
|
name: text(movie?.name, 'Chưa có tên'),
|
||||||
|
originName: text(movie?.origin_name),
|
||||||
|
slug: text(movie?.slug),
|
||||||
|
thumb: joinOphimImage(pathImage, movie?.thumb_url),
|
||||||
|
poster: joinOphimImage(pathImage, movie?.poster_url || movie?.thumb_url),
|
||||||
|
year: Number(movie?.year) || undefined,
|
||||||
|
time: text(movie?.time),
|
||||||
|
episode: text(movie?.episode_current),
|
||||||
|
quality: text(movie?.quality),
|
||||||
|
lang: text(movie?.lang),
|
||||||
|
type: text(movie?.type),
|
||||||
|
rating: Number(movie?.tmdb?.vote_average || movie?.imdb?.vote_average) || undefined,
|
||||||
|
categories: toArray(movie?.category).map((item: any) => text(item?.name)).filter(Boolean),
|
||||||
|
countries: toArray(movie?.country).map((item: any) => text(item?.name)).filter(Boolean),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeNguoncMovie(movie: any): NormalizedMovie {
|
||||||
|
const image = movie?.thumb_url || movie?.poster_url || movie?.image || movie?.thumbnail
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: `nguonc:${movie?._id ?? movie?.slug}`,
|
||||||
|
source: 'nguonc',
|
||||||
|
name: text(movie?.name || movie?.title, 'Chưa có tên'),
|
||||||
|
originName: text(movie?.original_name || movie?.origin_name),
|
||||||
|
slug: text(movie?.slug),
|
||||||
|
thumb: joinImage('', image),
|
||||||
|
poster: joinImage('', movie?.poster_url || image),
|
||||||
|
year: Number(movie?.year || movie?.release_year) || undefined,
|
||||||
|
time: text(movie?.time || movie?.duration),
|
||||||
|
episode: text(movie?.episode_current || movie?.current_episode),
|
||||||
|
quality: text(movie?.quality),
|
||||||
|
lang: text(movie?.language || movie?.lang),
|
||||||
|
type: text(movie?.type),
|
||||||
|
rating: Number(movie?.rating) || undefined,
|
||||||
|
categories: toArray(movie?.category || movie?.categories).map((item: any) => text(item?.name ?? item)).filter(Boolean),
|
||||||
|
countries: toArray(movie?.country || movie?.countries).map((item: any) => text(item?.name ?? item)).filter(Boolean),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestJson<T>(url: string): Promise<T> {
|
||||||
|
return await $fetch<T>(url, {
|
||||||
|
headers: {
|
||||||
|
accept: 'application/json',
|
||||||
|
'user-agent': 'KR-Phim/1.0',
|
||||||
|
},
|
||||||
|
retry: 0,
|
||||||
|
timeout: 12000,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getOphimKoreanMovies(page: number, keyword = '') {
|
||||||
|
const url = keyword
|
||||||
|
? new URL('/v1/api/tim-kiem', OPHIM_BASE)
|
||||||
|
: new URL('/v1/api/quoc-gia/han-quoc', OPHIM_BASE)
|
||||||
|
|
||||||
|
url.searchParams.set('page', String(page))
|
||||||
|
if (keyword) {
|
||||||
|
url.searchParams.set('keyword', keyword)
|
||||||
|
url.searchParams.set('country', 'han-quoc')
|
||||||
|
}
|
||||||
|
|
||||||
|
const json: any = await requestJson(url.toString())
|
||||||
|
const data = json?.data ?? json
|
||||||
|
const imageBase = data?.APP_DOMAIN_CDN_IMAGE || data?.pathImage || OPHIM_IMAGE
|
||||||
|
const items = toArray(data?.items ?? json?.items)
|
||||||
|
.filter(includesKorea)
|
||||||
|
.map((item) => normalizeOphimMovie(item, imageBase))
|
||||||
|
.filter((movie) => movie.slug)
|
||||||
|
|
||||||
|
return {
|
||||||
|
items,
|
||||||
|
pagination: data?.params?.pagination ?? data?.pagination ?? json?.pagination ?? {},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getNguoncKoreanMovies(page: number, keyword = '') {
|
||||||
|
const url = keyword
|
||||||
|
? new URL('/api/films/search', NGUONC_BASE)
|
||||||
|
: new URL('/api/films/quoc-gia/han-quoc', NGUONC_BASE)
|
||||||
|
|
||||||
|
if (keyword) {
|
||||||
|
url.searchParams.set('keyword', keyword)
|
||||||
|
} else {
|
||||||
|
url.searchParams.set('page', String(page))
|
||||||
|
}
|
||||||
|
|
||||||
|
const json: any = await requestJson(url.toString())
|
||||||
|
const data = json?.data ?? json
|
||||||
|
const items = toArray(data?.items ?? data?.films ?? json?.items)
|
||||||
|
.filter(includesKorea)
|
||||||
|
.map(normalizeNguoncMovie)
|
||||||
|
.filter((movie) => movie.slug)
|
||||||
|
|
||||||
|
return {
|
||||||
|
items,
|
||||||
|
pagination: data?.pagination ?? json?.pagination ?? {},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getKoreanMovies(page: number, keyword = '') {
|
||||||
|
const results = await Promise.allSettled([
|
||||||
|
getOphimKoreanMovies(page, keyword),
|
||||||
|
getNguoncKoreanMovies(page, keyword),
|
||||||
|
])
|
||||||
|
|
||||||
|
const items = results.flatMap((result) => result.status === 'fulfilled' ? result.value.items : [])
|
||||||
|
const seen = new Set<string>()
|
||||||
|
const unique = items.filter((movie) => {
|
||||||
|
const key = movie.slug || `${movie.source}:${movie.name}`
|
||||||
|
if (seen.has(key)) return false
|
||||||
|
seen.add(key)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
const pagination = results.find((result) => result.status === 'fulfilled')?.value.pagination ?? {}
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: unique,
|
||||||
|
page,
|
||||||
|
pagination,
|
||||||
|
sources: results.map((result, index) => ({
|
||||||
|
name: index === 0 ? 'ophim' : 'nguonc',
|
||||||
|
ok: result.status === 'fulfilled',
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getOphimDetail(slug: string): Promise<MovieDetail> {
|
||||||
|
const json: any = await requestJson(`${OPHIM_BASE}/v1/api/phim/${slug}`)
|
||||||
|
const data = json?.data ?? json
|
||||||
|
const movie = data?.item ?? data?.movie ?? json?.movie ?? json?.item
|
||||||
|
const imageBase = data?.APP_DOMAIN_CDN_IMAGE || data?.pathImage || OPHIM_IMAGE
|
||||||
|
const normalized = normalizeOphimMovie(movie, imageBase)
|
||||||
|
|
||||||
|
return {
|
||||||
|
...normalized,
|
||||||
|
content: stripHtml(movie?.content),
|
||||||
|
actors: toArray(movie?.actor).map((item) => text(item)).filter(Boolean),
|
||||||
|
directors: toArray(movie?.director).map((item) => text(item)).filter(Boolean),
|
||||||
|
trailer: text(movie?.trailer_url),
|
||||||
|
servers: toArray(data?.episodes ?? json?.episodes ?? movie?.episodes).map((server: any) => ({
|
||||||
|
name: text(server?.server_name, 'Server'),
|
||||||
|
episodes: toArray(server?.server_data).map((episode: any) => ({
|
||||||
|
name: text(episode?.name, 'Tập phim'),
|
||||||
|
slug: text(episode?.slug),
|
||||||
|
linkEmbed: text(episode?.link_embed),
|
||||||
|
linkM3u8: text(episode?.link_m3u8),
|
||||||
|
})),
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getNguoncDetail(slug: string): Promise<MovieDetail> {
|
||||||
|
const json: any = await requestJson(`${NGUONC_BASE}/api/film/${slug}`)
|
||||||
|
const movie = json?.movie ?? json?.item ?? json?.data?.item ?? json?.data?.movie ?? json
|
||||||
|
const normalized = normalizeNguoncMovie(movie)
|
||||||
|
|
||||||
|
return {
|
||||||
|
...normalized,
|
||||||
|
content: stripHtml(movie?.content || movie?.description),
|
||||||
|
actors: toArray(movie?.actor || movie?.actors).map((item: any) => text(item?.name ?? item)).filter(Boolean),
|
||||||
|
directors: toArray(movie?.director || movie?.directors).map((item: any) => text(item?.name ?? item)).filter(Boolean),
|
||||||
|
trailer: text(movie?.trailer_url || movie?.trailer),
|
||||||
|
servers: toArray(movie?.episodes ?? json?.episodes ?? json?.data?.episodes).map((server: any) => ({
|
||||||
|
name: text(server?.server_name || server?.name, 'Server'),
|
||||||
|
episodes: toArray(server?.server_data ?? server?.items ?? server?.episodes).map((episode: any) => ({
|
||||||
|
name: text(episode?.name || episode?.title, 'Tập phim'),
|
||||||
|
slug: text(episode?.slug),
|
||||||
|
linkEmbed: text(episode?.link_embed || episode?.embed),
|
||||||
|
linkM3u8: text(episode?.link_m3u8 || episode?.m3u8),
|
||||||
|
})),
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user