feat: implement movie detail page with multi-source support, library features, and responsive layout

This commit is contained in:
thanhvudaynee
2026-05-21 23:07:59 +07:00
parent 4a91b03678
commit 9c52a7927c
18 changed files with 606 additions and 180 deletions
+146
View File
@@ -0,0 +1,146 @@
# API KKPhim - Dành cho nhà phát triển Website xem phim
## Danh sách phim
### Phim mới cập nhật
`GET https://phimapi.com/danh-sach/phim-moi-cap-nhat?page=${page}`
Ví dụ: `GET https://phimapi.com/danh-sach/phim-moi-cap-nhat?page=1`
Ví dụ V2: `GET https://phimapi.com/danh-sach/phim-moi-cap-nhat-v2?page=1`
Ví dụ V3: `GET https://phimapi.com/danh-sach/phim-moi-cap-nhat-v3?page=1`
**Thông số kỹ thuật:**
- Ở bản V2 + V3, đã được bổ sung các kết quả khác nhau phục vụ cho từng nhu cầu khác nhau. Bạn có thể truy xuất thử và áp dụng cho mình khi cần thiết.
## Phim & Tập Phim (có hỗ trợ TMDB ID)
### Thông tin Phim & Danh sách tập phim
`GET https://phimapi.com/phim/{slug}`
Ví dụ: `https://kkphim.com/phim/ngoi-truong-xac-song GET https://phimapi.com/phim/ngoi-truong-xac-song`
### Thông tin dựa theo TMDB ID
`GET https://phimapi.com/tmdb/{type}/{id}`
Ví dụ: `GET https://phimapi.com/tmdb/tv & movie/280945`
**Thông số kỹ thuật:**
- `type = tv` [dành cho các phim thuộc phim bộ, bao gồm hoạt hình + tv shows]. `movie` [tương tự type tv]
- Mục này chỉ dành cho những phim mà KKPhim có hỗ trợ TMDB ID.
## Danh sách tổng hợp
### Tổng hợp danh sách phim có thể sử dụng
`GET https://phimapi.com/v1/api/danh-sach/{type_list}?page={page}&sort_field={sort_field}&sort_type={sort_type}&sort_lang={sort_lang}&category={category}&country={country}&year={year}&limit={limit}`
Ví dụ: `GET https://phimapi.com/v1/api/danh-sach/phim-bo?page=1&sort_field=_id&sort_type=asc&sort_lang=long-tieng&category=hanh-dong&country=trung-quoc&year=2024&limit=10`
**Thông số kỹ thuật:**
- `type_list` = phim-bo, phim-le, tv-shows, hoat-hinh, phim-vietsub, phim-thuyet-minh, phim-long-tieng
- `page` = Số trang cần truy xuất, sử dụng [totalPages] để biết tổng trang khả dụng.
- `sort_field` = modified.time > tính theo thời gian cập nhật, _id > lấy theo ID của phim, year > lấy theo số năm phát hành của phim.
- `sort_type` = desc hoặc asc.
- `sort_lang` = vietsub > phim có Vietsub, thuyet-minh > phim có Thuyết Minh, long-tieng > phim có Lồng Tiếng.
- `category` = Thể loại phim cần lấy, sử dụng API phimapi.com/the-loai để lấy chi tiết slug.
- `country` = Quốc gia phim cần lấy, sử dụng API phimapi.com/quoc-gia để lấy chi tiết slug.
- `year` = Năm phát hành của phim (1970 - hiện tại).
- `limit` = Giới hạn kết quả (tối đa 64).
## Tìm kiếm
### Tìm kiếm phim
`GET https://phimapi.com/v1/api/tim-kiem?keyword={keyword}?page={page}&sort_field={sort_field}&sort_type={sort_type}&sort_lang={sort_lang}&category={category}&country={country}&year={year}&limit={limit}`
Ví dụ: `GET https://phimapi.com/v1/api/tim-kiem?keyword=Thước&page=1&sort_field=_id&sort_type=asc&sort_lang=long-tieng&category=hanh-dong&country=trung-quoc&year=2024&limit=10`
**Thông số kỹ thuật:**
- `keyword` = Từ khóa bạn cần tìm kiếm.
- `page` = Số trang cần truy xuất, sử dụng [totalPages] để biết tổng trang khả dụng.
- `sort_field` = modified.time > tính theo thời gian cập nhật, _id > lấy theo ID của phim, year > lấy theo số năm phát hành của phim.
- `sort_type` = desc hoặc asc.
- `sort_lang` = vietsub > phim có Vietsub, thuyet-minh > phim có Thuyết Minh, long-tieng > phim có Lồng Tiếng.
- `category` = Thể loại phim cần lấy, sử dụng API phimapi.com/the-loai để lấy chi tiết slug.
- `country` = Quốc gia phim cần lấy, sử dụng API phimapi.com/quoc-gia để lấy chi tiết slug.
- `year` = Năm phát hành của phim (1970 - hiện tại).
- `limit` = Giới hạn kết quả (tối đa 64).
## Thể loại - Thể loại chi tiết
### Thể loại phim
`GET https://phimapi.com/the-loai`
### Chi tiết của thể loại
`GET https://phimapi.com/v1/api/the-loai/{type_list}?page={page}&sort_field={sort_field}&sort_type={sort_type}&sort_lang={sort_lang}&country={country}&year={year}&limit={limit}`
Ví dụ: `GET https://phimapi.com/v1/api/the-loai/hanh-dong?page=1&sort_field=_id&sort_type=asc&sort_lang=long-tieng&country=trung-quoc&year=2024&limit=10`
**Thông số kỹ thuật:**
- `type_list` = Thể loại phim cần lấy, sử dụng API phimapi.com/the-loai để lấy chi tiết slug.
- `page` = Số trang cần truy xuất, sử dụng [totalPages] để biết tổng trang khả dụng.
- `sort_field` = modified.time > tính theo thời gian cập nhật, _id > lấy theo ID của phim, year > lấy theo số năm phát hành của phim.
- `sort_type` = desc hoặc asc.
- `sort_lang` = vietsub > phim có Vietsub, thuyet-minh > phim có Thuyết Minh, long-tieng > phim có Lồng Tiếng.
- `country` = Quốc gia phim cần lấy, sử dụng API phimapi.com/quoc-gia để lấy chi tiết slug.
- `year` = Năm phát hành của phim (1970 - hiện tại).
- `limit` = Giới hạn kết quả (tối đa 64).
## Quốc gia - Quốc gia chi tiết
### Quốc gia phim
`GET https://phimapi.com/quoc-gia`
### Chi tiết của quốc gia
`GET https://phimapi.com/v1/api/quoc-gia/{type_list}?page={page}&sort_field={sort_field}&sort_type={sort_type}&sort_lang={sort_lang}&category={category}&country={country}&year={year}&limit={limit}`
Ví dụ: `GET https://phimapi.com/v1/api/quoc-gia/trung-quoc?page=1&sort_field=_id&sort_type=asc&sort_lang=long-tieng&category=hanh-dong&year=2024&limit=10`
**Thông số kỹ thuật:**
- `type_list` = Quốc gia phim cần lấy, sử dụng API phimapi.com/quoc-gia để lấy chi tiết slug.
- `page` = Số trang cần truy xuất, sử dụng [totalPages] để biết tổng trang khả dụng.
- `sort_field` = modified.time > tính theo thời gian cập nhật, _id > lấy theo ID của phim, year > lấy theo số năm phát hành của phim.
- `sort_type` = desc hoặc asc.
- `sort_lang` = vietsub > phim có Vietsub, thuyet-minh > phim có Thuyết Minh, long-tieng > phim có Lồng Tiếng.
- `category` = Thể loại phim cần lấy, sử dụng API phimapi.com/the-loai để lấy chi tiết slug.
- `year` = Năm phát hành của phim (1970 - hiện tại).
- `limit` = Giới hạn kết quả (tối đa 64).
## Năm
### Năm
`GET https://phimapi.com/v1/api/nam/{type_list}?page={page}&sort_field={sort_field}&sort_type={sort_type}&sort_lang={sort_lang}&category={category}&country={country}&limit={limit}`
Ví dụ: `GET https://phimapi.com/v1/api/nam/2024?page=1&sort_field=_id&sort_type=asc&sort_lang=long-tieng&category=hanh-dong&country=trung-quoc&limit=10`
**Thông số kỹ thuật:**
- `type_list` = Năm phát hành của phim (1970 - hiện tại).
- `page` = Số trang cần truy xuất, sử dụng [totalPages] để biết tổng trang khả dụng.
- `sort_field` = modified.time > tính theo thời gian cập nhật, _id > lấy theo ID của phim, year > lấy theo số năm phát hành của phim.
- `sort_type` = desc hoặc asc.
- `sort_lang` = vietsub > phim có Vietsub, thuyet-minh > phim có Thuyết Minh, long-tieng > phim có Lồng Tiếng.
- `category` = Thể loại phim cần lấy, sử dụng API phimapi.com/the-loai để lấy chi tiết slug.
- `country` = Quốc gia phim cần lấy, sử dụng API phimapi.com/quoc-gia để lấy chi tiết slug.
- `limit` = Giới hạn kết quả (tối đa 64).
## Chuyển đổi ảnh
### Chuyển đổi ảnh sang WEBP
`GET https://phimapi.com/image.php?url={liên kết ảnh từ KKPhim}`
Ví dụ: `GET https://phimapi.com/image.php?url=https://phimimg.com/upload/vod/20250624-1/6295cf08845cf14b511410c7547d336a.jpg`
**Thông số kỹ thuật:**
- `url` = Chèn liên kết hình ảnh mặc định mà KKPhim trả về cho bạn.
- Với tính năng này, bạn sẽ được KKPhim trả lại ảnh có định dạng .webp thay vì .jpg, giúp website của bạn tăng tốc độ tải ảnh cũng như SEO. Hãy sử dụng nó khi bạn cần, mọi chuyện khác để KKPhim lo <3.
+6 -3
View File
@@ -1,10 +1,10 @@
# KR Phim # KR Phim
KR Phim is a Korean movie streaming web app built with Nuxt and Tailwind CSS. It aggregates Korean-only movie data from the OPhim and NguonC APIs, normalizes the responses on the server, and presents the content with a Netflix-inspired sky-blue interface. KR Phim is a Korean movie streaming web app built with Nuxt and Tailwind CSS. It aggregates Korean-only movie data from the OPhim, NguonC, and KKPhim APIs, normalizes the responses on the server, and presents the content with a Netflix-inspired Nuxt-green interface.
## Features ## Features
- Korean-only movie catalogue from OPhim and NguonC. - Korean-only movie catalogue from OPhim, NguonC, and KKPhim.
- Netflix-style home page with a large hero carousel. - Netflix-style home page with a large hero carousel.
- Floating movie preview cards on home page hover. - Floating movie preview cards on home page hover.
- Browse page with search, type filters, and pagination. - Browse page with search, type filters, and pagination.
@@ -37,6 +37,7 @@ server/
utils/movies.ts API adapters and normalization helpers utils/movies.ts API adapters and normalization helpers
API_nguonc.md NguonC API notes API_nguonc.md NguonC API notes
API_ophim.md OPhim API notes API_ophim.md OPhim API notes
API_kkphim.md KKPhim API notes
``` ```
## Setup ## Setup
@@ -114,6 +115,7 @@ The Compose service is named `kr-phim` and maps host port `3002` to container po
- `/phim?q=keyword` - Search Korean movies. - `/phim?q=keyword` - Search Korean movies.
- `/phim/[slug]?source=ophim` - Movie detail page from OPhim. - `/phim/[slug]?source=ophim` - Movie detail page from OPhim.
- `/phim/[slug]?source=nguonc` - Movie detail page from NguonC. - `/phim/[slug]?source=nguonc` - Movie detail page from NguonC.
- `/phim/[slug]?source=kkphim` - Movie detail page from KKPhim.
## API Notes ## API Notes
@@ -121,9 +123,10 @@ The app does not call external movie APIs directly from the browser. Instead, Nu
- OPhim list/detail data is handled in `server/utils/movies.ts`. - OPhim list/detail data is handled in `server/utils/movies.ts`.
- NguonC list/detail data is handled in `server/utils/movies.ts`. - NguonC list/detail data is handled in `server/utils/movies.ts`.
- KKPhim list/detail data is handled in `server/utils/movies.ts`.
- Only Korean movies are kept in the normalized result. - Only Korean movies are kept in the normalized result.
If one source is unavailable, the list endpoint still returns data from the other source when possible and includes source status metadata. If one source is unavailable, the list endpoint still returns data from the other sources when possible and includes source status metadata.
## Scripts ## Scripts
+9 -9
View File
@@ -3,12 +3,12 @@
@theme { @theme {
--font-sans: "Be Vietnam Pro", ui-sans-serif, system-ui, sans-serif; --font-sans: "Be Vietnam Pro", ui-sans-serif, system-ui, sans-serif;
--color-skyflix-50: #effaff; --color-nuxt-50: #ecfff7;
--color-skyflix-100: #dff5ff; --color-nuxt-100: #d3ffec;
--color-skyflix-300: #6ed0ff; --color-nuxt-300: #7dffc8;
--color-skyflix-500: #0ea5e9; --color-nuxt-400: #00dc82;
--color-skyflix-600: #0284c7; --color-nuxt-500: #00bd71;
--color-skyflix-950: #07111f; --color-nuxt-950: #061f17;
} }
html { html {
@@ -18,8 +18,8 @@ html {
body { body {
min-width: 320px; min-width: 320px;
background: background:
radial-gradient(circle at 15% 0%, rgba(14, 165, 233, 0.28), transparent 34rem), radial-gradient(circle at 15% 0%, rgba(0, 220, 130, 0.26), transparent 34rem),
linear-gradient(135deg, #061321 0%, #081827 46%, #020617 100%); linear-gradient(135deg, #061f17 0%, #09231b 46%, #020617 100%);
color: #f8fbff; color: #f8fbff;
} }
@@ -35,7 +35,7 @@ input {
} }
::-webkit-scrollbar-thumb { ::-webkit-scrollbar-thumb {
background: rgba(56, 189, 248, 0.55); background: rgba(0, 220, 130, 0.55);
border-radius: 999px; border-radius: 999px;
} }
+3 -3
View File
@@ -33,7 +33,7 @@ const linkGroups = [
v-for="item in socialLinks" v-for="item in socialLinks"
:key="item.label" :key="item.label"
type="button" 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" class="grid size-12 place-items-center rounded-full bg-emerald-400 text-slate-950 shadow-lg shadow-emerald-950/30 transition hover:-translate-y-0.5 hover:bg-white"
:aria-label="item.label" :aria-label="item.label"
> >
<component :is="item.icon" class="size-5" /> <component :is="item.icon" class="size-5" />
@@ -47,7 +47,7 @@ const linkGroups = [
v-for="link in group" v-for="link in group"
:key="link" :key="link"
to="/phim" to="/phim"
class="block text-slate-100 transition hover:text-sky-200" class="block text-slate-100 transition hover:text-emerald-200"
> >
{{ link }} {{ link }}
</NuxtLink> </NuxtLink>
@@ -55,7 +55,7 @@ const linkGroups = [
</div> </div>
<p class="mt-10 max-w-4xl text-sm leading-7 text-slate-300"> <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 NguồnC với giao diện xanh da trời hiện đại. 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, NguồnC và KKPhim với giao diện xanh Nuxt hiện đại.
</p> </p>
<p class="mt-7 text-sm text-slate-400"> <p class="mt-7 text-sm text-slate-400">
+25 -25
View File
@@ -182,38 +182,38 @@ watch(user, (currentUser) => {
<AppLogo /> <AppLogo />
<div class="hidden lg:flex lg:items-center lg:gap-7 lg:text-sm lg:font-semibold lg:text-slate-200"> <div class="hidden lg:flex lg:items-center lg:gap-7 lg:text-sm lg:font-semibold lg:text-slate-200">
<NuxtLink v-for="item in navItems" :key="item.to" :to="item.to" class="shrink-0 transition hover:text-sky-200" <NuxtLink v-for="item in navItems" :key="item.to" :to="item.to" class="shrink-0 transition hover:text-emerald-200"
:class="isActive(item.to) ? 'text-sky-200' : 'text-slate-200'"> :class="isActive(item.to) ? 'text-emerald-200' : 'text-slate-200'">
{{ item.label }} {{ item.label }}
</NuxtLink> </NuxtLink>
</div> </div>
<form <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" 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-emerald-950/20 sm:max-w-sm"
@submit.prevent="submitSearch"> @submit.prevent="submitSearch">
<Search class="mr-3 size-4 shrink-0 text-sky-200" /> <Search class="mr-3 size-4 shrink-0 text-emerald-200" />
<input v-model="keyword" type="search" placeholder="Tìm phim Hàn Quốc..." <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"> class="w-full bg-transparent text-sm text-white outline-none placeholder:text-slate-400">
</form> </form>
<div class="group/member relative hidden shrink-0 items-center gap-3 md:flex" @mouseenter="memberMenuOpen = true" <div class="group/member relative hidden shrink-0 items-center gap-2.5 md:flex" @mouseenter="memberMenuOpen = true"
@mouseleave="memberMenuOpen = false"> @mouseleave="memberMenuOpen = false">
<button v-if="user" type="button" <button v-if="user" type="button"
class="grid size-10 cursor-pointer place-items-center rounded-full bg-white/10 text-white transition hover:bg-white/16" class="grid size-9 cursor-pointer place-items-center rounded-full bg-white/10 text-white transition hover:bg-white/16"
aria-label="Thông báo"> aria-label="Thông báo">
<Bell class="size-4 fill-current" /> <Bell class="size-4 fill-current" />
</button> </button>
<button type="button" <button type="button"
class="inline-flex h-11 shrink-0 cursor-pointer items-center gap-2 rounded-full bg-white px-3 pl-1 text-sm font-black text-slate-950 shadow-xl shadow-black/20 transition hover:bg-sky-100" class="inline-flex h-9 shrink-0 cursor-pointer items-center gap-1.5 rounded-full bg-white px-2.5 pl-1 text-xs font-black text-slate-950 shadow-xl shadow-black/20 transition hover:bg-emerald-100"
:aria-label="user ? 'Tài khoản thành viên' : 'Đăng nhập thành viên'" @click="handleMemberClick"> :aria-label="user ? 'Tài khoản thành viên' : 'Đăng nhập thành viên'" @click="handleMemberClick">
<Loader2 v-if="authLoading" class="mx-1 size-4 animate-spin" /> <Loader2 v-if="authLoading" class="mx-1 size-4 animate-spin" />
<template v-else-if="user"> <template v-else-if="user">
<img :src="memberAvatar" :alt="memberName" <img :src="memberAvatar" :alt="memberName"
class="size-8 rounded-full object-cover ring-2 ring-sky-200/80"> class="size-7 rounded-full object-cover ring-2 ring-emerald-200/80">
</template> </template>
<User v-else class="ml-1 size-4 fill-current" /> <User v-else class="ml-1 size-4 fill-current" />
<span class="max-w-28 truncate">{{ user ? memberName : 'Thành viên' }}</span> <span class="max-w-24 truncate">{{ user ? memberName : 'Thành viên' }}</span>
</button> </button>
<Transition name="member-menu"> <Transition name="member-menu">
@@ -226,7 +226,7 @@ watch(user, (currentUser) => {
</NuxtLink> </NuxtLink>
<div class="my-2 border-t border-white/10" /> <div class="my-2 border-t border-white/10" />
<button type="button" <button type="button"
class="flex h-12 w-full cursor-pointer items-center gap-3 px-5 text-left font-black text-sky-300 transition hover:bg-white/8 hover:text-white" class="flex h-12 w-full cursor-pointer items-center gap-3 px-5 text-left font-black text-emerald-300 transition hover:bg-white/8 hover:text-white"
@click="handleSignOut"> @click="handleSignOut">
<LogOut class="size-4 shrink-0" /> <LogOut class="size-4 shrink-0" />
Đăng xuất Đăng xuất
@@ -257,14 +257,14 @@ watch(user, (currentUser) => {
</div> </div>
<nav class="flex flex-col gap-1 p-4"> <nav class="flex flex-col gap-1 p-4">
<NuxtLink v-for="item in navItems" :key="item.to" :to="item.to" <NuxtLink v-for="item in navItems" :key="item.to" :to="item.to"
class="rounded-lg px-4 py-3 text-base font-semibold text-slate-200 transition hover:bg-white/8 hover:text-sky-200" class="rounded-lg px-4 py-3 text-base font-semibold text-slate-200 transition hover:bg-white/8 hover:text-emerald-200"
:class="isActive(item.to) ? 'bg-white/8 text-sky-200' : ''" @click="closeMobileMenu"> :class="isActive(item.to) ? 'bg-white/8 text-emerald-200' : ''" @click="closeMobileMenu">
{{ item.label }} {{ item.label }}
</NuxtLink> </NuxtLink>
</nav> </nav>
<div class="border-t border-white/10 p-4"> <div class="border-t border-white/10 p-4">
<button type="button" <button type="button"
class="inline-flex h-11 w-full cursor-pointer items-center justify-center gap-2 rounded-full bg-white px-4 text-sm font-black text-slate-950 transition hover:bg-sky-100" class="inline-flex h-11 w-full cursor-pointer items-center justify-center gap-2 rounded-full bg-white px-4 text-sm font-black text-slate-950 transition hover:bg-emerald-100"
@click="handleMemberClick"> @click="handleMemberClick">
<img v-if="user" :src="memberAvatar" :alt="memberName" <img v-if="user" :src="memberAvatar" :alt="memberName"
class="size-7 rounded-full object-cover"> class="size-7 rounded-full object-cover">
@@ -279,7 +279,7 @@ watch(user, (currentUser) => {
</NuxtLink> </NuxtLink>
<div class="border-t border-white/10" /> <div class="border-t border-white/10" />
<button type="button" <button type="button"
class="flex h-11 w-full cursor-pointer items-center gap-3 px-4 text-left text-sm font-black text-sky-300 transition hover:bg-white/8 hover:text-white" class="flex h-11 w-full cursor-pointer items-center gap-3 px-4 text-left text-sm font-black text-emerald-300 transition hover:bg-white/8 hover:text-white"
@click="handleSignOut"> @click="handleSignOut">
<LogOut class="size-4 shrink-0" /> <LogOut class="size-4 shrink-0" />
Đăng xuất Đăng xuất
@@ -309,11 +309,11 @@ watch(user, (currentUser) => {
<div class="bg-slate-950 px-5 py-10 sm:px-10 md:px-16 md:py-16"> <div class="bg-slate-950 px-5 py-10 sm:px-10 md:px-16 md:py-16">
<div v-if="user"> <div v-if="user">
<p class="text-sm font-bold text-sky-300">Thành viên</p> <p class="text-sm font-bold text-emerald-300">Thành viên</p>
<h2 class="mt-2 text-2xl font-black">Tài khoản của bạn</h2> <h2 class="mt-2 text-2xl font-black">Tài khoản của bạn</h2>
<p class="mt-6 rounded-md bg-white/8 px-4 py-3 text-sm font-semibold text-slate-100">{{ user.email }}</p> <p class="mt-6 rounded-md bg-white/8 px-4 py-3 text-sm font-semibold text-slate-100">{{ user.email }}</p>
<button type="button" <button type="button"
class="mt-5 inline-flex h-12 w-full items-center justify-center gap-2 rounded-md bg-sky-300 text-sm font-black text-slate-950 transition hover:bg-white" class="mt-5 inline-flex h-12 w-full items-center justify-center gap-2 rounded-md bg-emerald-300 text-sm font-black text-slate-950 transition hover:bg-white"
@click="handleSignOut"> @click="handleSignOut">
<LogOut class="size-4" /> <LogOut class="size-4" />
Đăng xuất Đăng xuất
@@ -325,14 +325,14 @@ watch(user, (currentUser) => {
<p class="mt-5 text-sm text-slate-300"> <p class="mt-5 text-sm text-slate-300">
<template v-if="authMode === 'login'"> <template v-if="authMode === 'login'">
Nếu bạn chưa tài khoản, Nếu bạn chưa tài khoản,
<button type="button" class="font-black text-sky-300 hover:text-white" <button type="button" class="font-black text-emerald-300 hover:text-white"
@click="authMode = 'register'; authError = ''; authMessage = ''"> @click="authMode = 'register'; authError = ''; authMessage = ''">
đăng ngay đăng ngay
</button> </button>
</template> </template>
<template v-else> <template v-else>
Nếu bạn đã tài khoản, Nếu bạn đã tài khoản,
<button type="button" class="font-black text-sky-300 hover:text-white" <button type="button" class="font-black text-emerald-300 hover:text-white"
@click="authMode = 'login'; authError = ''; authMessage = ''"> @click="authMode = 'login'; authError = ''; authMessage = ''">
đăng nhập ngay đăng nhập ngay
</button> </button>
@@ -367,7 +367,7 @@ watch(user, (currentUser) => {
</div> </div>
<button type="submit" <button type="submit"
class="inline-flex h-12 w-full items-center justify-center gap-2 rounded-md bg-sky-300 text-sm font-black text-slate-950 transition hover:bg-white disabled:cursor-not-allowed disabled:opacity-70" class="inline-flex h-12 w-full items-center justify-center gap-2 rounded-md bg-emerald-300 text-sm font-black text-slate-950 transition hover:bg-white disabled:cursor-not-allowed disabled:opacity-70"
:disabled="submittingAuth"> :disabled="submittingAuth">
<Loader2 v-if="submittingAuth" class="size-4 animate-spin" /> <Loader2 v-if="submittingAuth" class="size-4 animate-spin" />
<User v-else class="size-4 fill-current" /> <User v-else class="size-4 fill-current" />
@@ -375,7 +375,7 @@ watch(user, (currentUser) => {
</button> </button>
<button v-if="authMode === 'login'" type="button" <button v-if="authMode === 'login'" type="button"
class="mt-7 block w-full text-center text-sm font-bold text-white hover:text-sky-300" class="mt-7 block w-full text-center text-sm font-bold text-white hover:text-emerald-300"
@click="handleResetPassword"> @click="handleResetPassword">
Quên mật khẩu? Quên mật khẩu?
</button> </button>
@@ -440,9 +440,9 @@ watch(user, (currentUser) => {
.auth-poster-panel { .auth-poster-panel {
background: background:
linear-gradient(180deg, rgba(2, 6, 23, 0.45), rgba(2, 6, 23, 0.92)), linear-gradient(180deg, rgba(2, 6, 23, 0.45), rgba(2, 6, 23, 0.92)),
linear-gradient(135deg, rgba(125, 211, 252, 0.12) 0 18%, transparent 18% 100%), linear-gradient(135deg, rgba(0, 220, 130, 0.12) 0 18%, transparent 18% 100%),
radial-gradient(circle at 22% 18%, rgba(56, 189, 248, 0.24), transparent 8rem), radial-gradient(circle at 22% 18%, rgba(0, 220, 130, 0.24), transparent 8rem),
linear-gradient(120deg, #07111f 0%, #0f172a 54%, #020617 100%); linear-gradient(120deg, #061f17 0%, #0f172a 54%, #020617 100%);
position: relative; position: relative;
} }
@@ -450,9 +450,9 @@ watch(user, (currentUser) => {
position: absolute; position: absolute;
inset: 0; inset: 0;
background-image: background-image:
linear-gradient(115deg, transparent 0 10%, rgba(125, 211, 252, 0.12) 10% 10.6%, transparent 10.6% 100%), linear-gradient(115deg, transparent 0 10%, rgba(0, 220, 130, 0.12) 10% 10.6%, transparent 10.6% 100%),
repeating-linear-gradient(105deg, rgba(255, 255, 255, 0.06) 0 7.5rem, transparent 7.5rem 8rem), repeating-linear-gradient(105deg, rgba(255, 255, 255, 0.06) 0 7.5rem, transparent 7.5rem 8rem),
repeating-linear-gradient(15deg, rgba(14, 165, 233, 0.08) 0 10rem, transparent 10rem 10.6rem); repeating-linear-gradient(15deg, rgba(0, 220, 130, 0.08) 0 10rem, transparent 10rem 10.6rem);
content: ""; content: "";
opacity: 0.75; opacity: 0.75;
} }
+2 -2
View File
@@ -8,11 +8,11 @@ import { Play } from 'lucide-vue-next'
class="group inline-flex h-8 min-w-[100px] shrink-0 flex-row flex-nowrap items-center gap-2 whitespace-nowrap sm:h-10 sm:min-w-[132px] sm:gap-2.5" class="group inline-flex h-8 min-w-[100px] shrink-0 flex-row flex-nowrap items-center gap-2 whitespace-nowrap sm:h-10 sm:min-w-[132px] sm:gap-2.5"
aria-label="KR PHIM" aria-label="KR PHIM"
> >
<span class="grid size-7 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 sm:size-9"> <span class="grid size-7 shrink-0 place-items-center rounded-lg border border-emerald-200/80 bg-black/30 text-white shadow-lg shadow-black/20 transition group-hover:border-emerald-300 group-hover:text-emerald-200 sm:size-9">
<Play class="ml-0.5 size-4 fill-current sm:size-5" /> <Play class="ml-0.5 size-4 fill-current sm:size-5" />
</span> </span>
<span class="inline-flex items-baseline text-xl font-black uppercase leading-none tracking-tight sm:text-2xl"> <span class="inline-flex items-baseline text-xl font-black uppercase leading-none tracking-tight sm:text-2xl">
<span class="text-sky-300">KR</span><span class="text-white">PHIM</span> <span class="text-emerald-300">KR</span><span class="text-white">PHIM</span>
</span> </span>
</NuxtLink> </NuxtLink>
</template> </template>
+7 -7
View File
@@ -68,14 +68,14 @@ onBeforeUnmount(() => {
class="group relative block h-78 w-44 shrink-0 snap-start transition duration-300 hover:z-30 hover:-translate-y-1 sm:h-82 sm:w-52 xl:w-56" class="group relative block h-78 w-44 shrink-0 snap-start transition duration-300 hover:z-30 hover:-translate-y-1 sm:h-82 sm:w-52 xl:w-56"
@mouseenter="showPreview" @mouseleave="scheduleHide" @focus="showPreview" @blur="scheduleHide"> @mouseenter="showPreview" @mouseleave="scheduleHide" @focus="showPreview" @blur="scheduleHide">
<div <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"> 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-emerald-300/60">
<img :src="movie.thumb || movie.poster" :alt="movie.name" <img :src="movie.thumb || movie.poster" :alt="movie.name"
class="h-full w-full object-cover transition duration-500 group-hover:scale-105"> class="h-full w-full object-cover transition duration-500 group-hover:scale-105">
<div class="absolute inset-x-0 bottom-0 bg-linear-to-t from-slate-950 via-slate-950/70 to-transparent p-3"> <div class="absolute inset-x-0 bottom-0 bg-linear-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="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> <p class="mt-1 truncate text-xs text-emerald-100">{{ movie.episode || movie.year || movie.quality }}</p>
</div> </div>
<span class="absolute left-2 top-2 rounded bg-sky-400 px-2 py-1 text-xs font-black text-slate-950"> <span class="absolute left-2 top-2 rounded bg-emerald-400 px-2 py-1 text-xs font-black text-slate-950">
{{ movie.source }} {{ movie.source }}
</span> </span>
</div> </div>
@@ -86,11 +86,11 @@ onBeforeUnmount(() => {
enter-to-class="scale-100 opacity-100" leave-active-class="transition duration-120 ease-in" 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"> leave-from-class="scale-100 opacity-100" leave-to-class="scale-95 opacity-0">
<NuxtLink v-if="isPreviewVisible" :to="movieLink" <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" class="fixed z-90 hidden origin-top-left overflow-hidden rounded-xl bg-[#071411] text-white shadow-2xl shadow-black/60 ring-1 ring-emerald-300/45 sm:block"
:style="previewStyle" @mouseenter="keepPreview" @mouseleave="scheduleHide"> :style="previewStyle" @mouseenter="keepPreview" @mouseleave="scheduleHide">
<div class="relative h-44 overflow-hidden"> <div class="relative h-44 overflow-hidden">
<img :src="movie.poster || movie.thumb" :alt="movie.name" class="h-full w-full object-cover"> <img :src="movie.poster || movie.thumb" :alt="movie.name" class="h-full w-full object-cover">
<div class="absolute inset-0 bg-linear-to-t from-[#1a0b14] via-[#1a0b14]/30 to-transparent" /> <div class="absolute inset-0 bg-linear-to-t from-[#071411] via-[#071411]/30 to-transparent" />
<div class="absolute inset-x-0 bottom-0 p-4"> <div class="absolute inset-x-0 bottom-0 p-4">
<h3 class="line-clamp-2 text-xl font-black leading-tight text-white"> <h3 class="line-clamp-2 text-xl font-black leading-tight text-white">
{{ movie.name }} {{ movie.name }}
@@ -104,7 +104,7 @@ onBeforeUnmount(() => {
<div class="p-4 pt-3"> <div class="p-4 pt-3">
<div class="grid grid-cols-[1fr_6.25rem_6.25rem] gap-2"> <div class="grid grid-cols-[1fr_6.25rem_6.25rem] gap-2">
<span <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"> class="inline-flex h-10 items-center justify-center gap-2 rounded-md bg-emerald-400 px-4 text-sm font-black text-slate-950 transition hover:bg-white">
<Play class="size-4 fill-current" /> <Play class="size-4 fill-current" />
Xem ngay Xem ngay
</span> </span>
@@ -119,7 +119,7 @@ onBeforeUnmount(() => {
<div class="mt-3 flex flex-wrap gap-2 text-xs font-black"> <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">{{ <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> 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.lang" class="rounded bg-emerald-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.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">{{ <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> movie.episode }}</span>
+1 -1
View File
@@ -33,7 +33,7 @@ onBeforeUnmount(() => {
<button <button
v-if="isVisible" v-if="isVisible"
type="button" 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" class="fixed bottom-6 right-5 z-50 grid size-12 place-items-center rounded-full bg-emerald-400 text-slate-950 shadow-2xl shadow-emerald-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" aria-label="Lên đầu trang"
@click="scrollToTop" @click="scrollToTop"
> >
+111 -16
View File
@@ -10,8 +10,9 @@ const { data, pending, error } = await useFetch('/api/movies', {
const movies = computed(() => data.value?.items ?? []) const movies = computed(() => data.value?.items ?? [])
const heroIndex = ref(0) const heroIndex = ref(0)
const heroSlides = computed(() => movies.value.slice(0, 6)) const heroSlides = computed(() => uniqueHeroMovies(movies.value).slice(0, 6))
const hero = computed(() => heroSlides.value[heroIndex.value] ?? movies.value[0]) const hero = computed(() => heroSlides.value[heroIndex.value] ?? movies.value[0])
const heroDetail = ref<any>(null)
const sourceStatus = computed(() => data.value?.sources ?? []) const sourceStatus = computed(() => data.value?.sources ?? [])
const watchHistory = ref<WatchHistoryItem[]>([]) const watchHistory = ref<WatchHistoryItem[]>([])
const { user, initAuth } = useSupabaseAuth() const { user, initAuth } = useSupabaseAuth()
@@ -21,6 +22,48 @@ const {
} = useWatchHistory() } = useWatchHistory()
let heroTimer: ReturnType<typeof setInterval> | undefined let heroTimer: ReturnType<typeof setInterval> | undefined
const heroDetailCache = new Map<string, any>()
const heroDescription = computed(() => {
const content = String(heroDetail.value?.content || '').trim()
if (content) return content
const fallback = [
hero.value?.originName,
hero.value?.time,
].filter(Boolean)
return fallback.length ? fallback.join('. ') : 'Phim Hàn Quốc Vietsub mới cập nhật.'
})
function comparableMovieText(value?: string) {
return String(value || '')
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
}
function uniqueHeroMovies(items: any[]) {
const seen = new Set<string>()
const uniqueItems: any[] = []
for (const item of items) {
const keys = [
comparableMovieText(item.slug),
comparableMovieText(item.originName),
comparableMovieText(item.name),
].filter(Boolean)
if (keys.some((key) => seen.has(key))) continue
keys.forEach((key) => seen.add(key))
uniqueItems.push(item)
}
return uniqueItems
}
function selectHero(index: number) { function selectHero(index: number) {
heroIndex.value = index heroIndex.value = index
@@ -42,6 +85,35 @@ watch(heroSlides, (slides) => {
} }
}) })
watch(hero, async (currentHero) => {
if (!currentHero?.slug) {
heroDetail.value = null
return
}
const cacheKey = `${currentHero.source}:${currentHero.slug}`
if (heroDetailCache.has(cacheKey)) {
heroDetail.value = heroDetailCache.get(cacheKey)
return
}
heroDetail.value = null
try {
const detail = await $fetch(`/api/movies/${currentHero.slug}`, {
query: {
source: currentHero.source,
},
})
heroDetailCache.set(cacheKey, detail)
if (hero.value?.slug === currentHero.slug && hero.value?.source === currentHero.source) {
heroDetail.value = detail
}
} catch {
heroDetail.value = null
}
}, { immediate: true })
onMounted(() => { onMounted(() => {
initAuth() initAuth()
heroTimer = setInterval(nextHero, 6500) heroTimer = setInterval(nextHero, 6500)
@@ -124,6 +196,29 @@ const rows = computed(() => [
}, },
]) ])
const apiRows = computed(() => [
{
title: 'Mới cập nhật từ tất cả nguồn',
to: '/phim',
items: movies.value.slice(0, 12),
},
{
title: 'OPhim',
to: '/phim?source=ophim',
items: movies.value.filter((movie: any) => movie.source === 'ophim').slice(0, 12),
},
{
title: 'NguonC',
to: '/phim?source=nguonc',
items: movies.value.filter((movie: any) => movie.source === 'nguonc').slice(0, 12),
},
{
title: 'KKPhim',
to: '/phim?source=kkphim',
items: movies.value.filter((movie: any) => movie.source === 'kkphim').slice(0, 12),
},
])
watch(user, () => { watch(user, () => {
loadWatchHistory() loadWatchHistory()
}) })
@@ -133,7 +228,7 @@ useHead({
meta: [ meta: [
{ {
name: 'description', name: 'description',
content: 'Xem phim Hàn Quốc Vietsub từ OPhim NguồnC với giao diện xanh da trời kiểu Netflix.', content: 'Xem phim Hàn Quốc Vietsub từ OPhim, NguồnC và KKPhim với giao diện xanh Nuxt hiện đại.',
}, },
], ],
}) })
@@ -151,24 +246,24 @@ useHead({
</TransitionGroup> </TransitionGroup>
<div class="absolute inset-0 bg-linear-to-r from-slate-950 via-slate-950/76 to-slate-950/10" /> <div class="absolute inset-0 bg-linear-to-r from-slate-950 via-slate-950/76 to-slate-950/10" />
<div class="absolute inset-0 bg-linear-to-t from-slate-950 via-slate-950/20 to-slate-950/30" /> <div class="absolute inset-0 bg-linear-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-linear-to-l from-sky-950/18 to-transparent lg:block" /> <div class="absolute inset-y-0 right-0 hidden w-1/2 bg-linear-to-l from-emerald-950/18 to-transparent lg:block" />
<div <div
class="relative mx-auto grid h-full max-w-390 content-center px-4 pb-20 pt-12 sm:px-6 sm:pb-24 sm:pt-16 lg:px-8 lg:pb-28 xl:px-10"> class="relative mx-auto grid h-full max-w-390 content-center px-4 pb-20 pt-12 sm:px-6 sm:pb-24 sm:pt-16 lg:px-8 lg:pb-28 xl:px-10">
<div class="max-w-5xl"> <div class="max-w-5xl">
<p class="mb-2 text-xs font-semibold text-sky-200 sm:mb-3 sm:text-sm"> <p class="mb-2 text-xs font-semibold text-emerald-200 sm:mb-3 sm:text-sm">
Chỉ phim Hàn Quốc Chỉ phim Hàn Quốc
</p> </p>
<h1 class="max-w-5xl text-2xl font-black leading-tight text-white drop-shadow-2xl sm:text-4xl lg:text-6xl"> <h1 class="max-w-5xl text-2xl font-black leading-tight text-white drop-shadow-2xl sm:text-4xl lg:text-6xl">
{{ hero.name }} {{ hero.name }}
</h1> </h1>
<p class="mt-2 max-w-2xl text-sm text-sky-100 sm:mt-3 sm:text-lg"> <p class="mt-2 max-w-2xl text-sm text-emerald-100 sm:mt-3 sm:text-lg">
{{ hero.originName || 'Kho phim Hàn Vietsub mới cập nhật' }} {{ hero.originName || 'Kho phim Hàn Vietsub mới cập nhật' }}
</p> </p>
<div class="mt-3 flex flex-wrap gap-1.5 text-xs font-bold text-slate-100 sm:mt-5 sm:gap-2 sm:text-sm"> <div class="mt-3 flex flex-wrap gap-1.5 text-xs font-bold text-slate-100 sm:mt-5 sm:gap-2 sm:text-sm">
<span v-if="hero.rating" class="rounded bg-black/35 px-2 py-1.5 ring-1 ring-white/10 sm:px-3 sm:py-2">IMDb <span v-if="hero.rating" class="rounded bg-black/35 px-2 py-1.5 ring-1 ring-white/10 sm:px-3 sm:py-2">IMDb
{{ hero.rating.toFixed(1) }}</span> {{ hero.rating.toFixed(1) }}</span>
<span v-if="hero.quality" class="rounded bg-sky-300 px-2 py-1.5 text-slate-950 sm:px-3 sm:py-2">{{ <span v-if="hero.quality" class="rounded bg-emerald-300 px-2 py-1.5 text-slate-950 sm:px-3 sm:py-2">{{
hero.quality }}</span> hero.quality }}</span>
<span v-if="hero.lang" class="rounded bg-white px-2 py-1.5 text-slate-950 sm:px-3 sm:py-2">{{ hero.lang <span v-if="hero.lang" class="rounded bg-white px-2 py-1.5 text-slate-950 sm:px-3 sm:py-2">{{ hero.lang
}}</span> }}</span>
@@ -184,12 +279,12 @@ useHead({
{{ category }} {{ category }}
</span> </span>
</div> </div>
<p class="mt-3 max-w-2xl text-xs leading-6 text-slate-100 sm:mt-5 sm:text-sm sm:leading-7"> <p class="mt-3 line-clamp-3 max-w-2xl text-xs leading-6 text-slate-100 sm:mt-5 sm:text-sm sm:leading-7">
{{ hero.originName ? `${hero.originName}. ` : '' }}{{ hero.time || 'Phim Hàn Quốc Vietsub mới cập nhật.' }} {{ heroDescription }}
</p> </p>
<div class="mt-6 flex items-center gap-3 sm:mt-8 sm:gap-4"> <div class="mt-6 flex items-center gap-3 sm:mt-8 sm:gap-4">
<NuxtLink :to="{ path: `/phim/${hero.slug}`, query: { source: hero.source } }" <NuxtLink :to="{ path: `/phim/${hero.slug}`, query: { source: hero.source } }"
class="grid size-12 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 sm:size-16" class="grid size-12 shrink-0 place-items-center rounded-full bg-emerald-300 text-slate-950 shadow-2xl shadow-emerald-950/50 transition hover:scale-105 hover:bg-white sm:size-16"
aria-label="Xem ngay"> aria-label="Xem ngay">
<Play class="size-6 fill-current sm:size-7" /> <Play class="size-6 fill-current sm:size-7" />
</NuxtLink> </NuxtLink>
@@ -222,7 +317,7 @@ useHead({
<div class="no-scrollbar flex gap-2 overflow-x-auto pb-2 sm:gap-3"> <div class="no-scrollbar flex gap-2 overflow-x-auto pb-2 sm:gap-3">
<button v-for="(slide, index) in heroSlides" :key="`${slide.source}-${slide.slug}-thumb`" type="button" <button v-for="(slide, index) in heroSlides" :key="`${slide.source}-${slide.slug}-thumb`" type="button"
class="relative h-12 w-20 shrink-0 overflow-hidden rounded-md border bg-slate-900 shadow-lg shadow-black/30 transition sm:h-16 sm:w-32" class="relative h-12 w-20 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'" :class="index === heroIndex ? 'border-emerald-300 ring-2 ring-emerald-300/45' : 'border-white/15 opacity-70 hover:opacity-100'"
@click="selectHero(index)"> @click="selectHero(index)">
<img :src="slide.thumb || slide.poster" :alt="slide.name" class="h-full w-full object-cover"> <img :src="slide.thumb || slide.poster" :alt="slide.name" class="h-full w-full object-cover">
<span <span
@@ -264,7 +359,7 @@ useHead({
<div class="mb-4 flex items-center justify-between gap-4"> <div class="mb-4 flex items-center justify-between gap-4">
<h2 class="text-2xl font-extrabold text-white">Tiếp Tục Xem</h2> <h2 class="text-2xl font-extrabold text-white">Tiếp Tục Xem</h2>
<button type="button" <button type="button"
class="grid size-10 shrink-0 place-items-center rounded-full border border-white/20 bg-white/8 text-white transition hover:border-sky-300/60 hover:bg-white/14" class="grid size-10 shrink-0 place-items-center rounded-full border border-white/20 bg-white/8 text-white transition hover:border-emerald-300/60 hover:bg-white/14"
aria-label="Xóa lịch sử xem" @click="clearWatchHistory"> aria-label="Xóa lịch sử xem" @click="clearWatchHistory">
<Trash2 class="size-4" /> <Trash2 class="size-4" />
</button> </button>
@@ -274,14 +369,14 @@ useHead({
<NuxtLink v-for="item in watchHistory" :key="`${item.source}-${item.slug}`" :to="watchHistoryLink(item)" <NuxtLink v-for="item in watchHistory" :key="`${item.source}-${item.slug}`" :to="watchHistoryLink(item)"
class="group block w-44 shrink-0 snap-start sm:w-52 xl:w-56"> class="group block w-44 shrink-0 snap-start sm:w-52 xl:w-56">
<div <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"> 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-emerald-300/60">
<img :src="item.thumb || item.poster" :alt="item.name" <img :src="item.thumb || item.poster" :alt="item.name"
class="h-full w-full object-cover transition duration-500 group-hover:scale-105"> class="h-full w-full object-cover transition duration-500 group-hover:scale-105">
<div class="absolute inset-x-0 bottom-0 h-1 bg-white/18"> <div class="absolute inset-x-0 bottom-0 h-1 bg-white/18">
<span class="block h-full bg-sky-300" :style="{ width: `${watchProgressPercent(item)}%` }" /> <span class="block h-full bg-emerald-300" :style="{ width: `${watchProgressPercent(item)}%` }" />
</div> </div>
<span <span
class="absolute left-2 top-2 rounded bg-sky-400 px-2 py-1 text-xs font-black text-slate-950"> class="absolute left-2 top-2 rounded bg-emerald-400 px-2 py-1 text-xs font-black text-slate-950">
{{ item.episodeName || `Tập ${(item.episodeIndex || 0) + 1}` }} {{ item.episodeName || `Tập ${(item.episodeIndex || 0) + 1}` }}
</span> </span>
<span <span
@@ -301,11 +396,11 @@ useHead({
<div v-for="item in 12" :key="item" class="aspect-2/3 animate-pulse rounded-md bg-white/10" /> <div v-for="item in 12" :key="item" class="aspect-2/3 animate-pulse rounded-md bg-white/10" />
</div> </div>
<section v-for="(row, index) in rows" v-show="row.items.length" <section v-for="(row, index) in apiRows" v-show="row.items.length"
:id="index === 0 ? 'latest' : index === 1 ? 'series' : 'movies'" :key="row.title" class="mb-12"> :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"> <div class="mb-4 flex items-end justify-between gap-4">
<h2 class="text-2xl font-extrabold text-white">{{ row.title }}</h2> <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"> <NuxtLink :to="row.to" class="text-sm font-semibold text-emerald-200 hover:text-white">
Xem thêm Xem thêm
</NuxtLink> </NuxtLink>
</div> </div>
+46 -12
View File
@@ -24,6 +24,36 @@ function watchHistoryLink(item: WatchHistoryItem) {
} }
} }
function formatWatchTime(seconds = 0) {
const totalSeconds = Math.max(Math.floor(seconds), 0)
const hours = Math.floor(totalSeconds / 3600)
const minutes = Math.floor((totalSeconds % 3600) / 60)
const remainingSeconds = totalSeconds % 60
if (hours) {
return `${hours}:${String(minutes).padStart(2, '0')}:${String(remainingSeconds).padStart(2, '0')}`
}
return `${minutes}:${String(remainingSeconds).padStart(2, '0')}`
}
function watchProgressPercent(item: WatchHistoryItem) {
if (!item.durationSeconds || !item.progressSeconds) return 6
return Math.min(Math.max((item.progressSeconds / item.durationSeconds) * 100, 6), 100)
}
function watchProgressLabel(item: WatchHistoryItem) {
if (item.progressSeconds && item.durationSeconds) {
return `${formatWatchTime(item.progressSeconds)} / ${formatWatchTime(item.durationSeconds)}`
}
if (item.progressSeconds) {
return `${formatWatchTime(item.progressSeconds)} đã xem`
}
return item.episodeName || `Tập ${(item.episodeIndex || 0) + 1}`
}
async function removeHistory() { async function removeHistory() {
await clearWatchHistory() await clearWatchHistory()
historyItems.value = [] historyItems.value = []
@@ -46,7 +76,7 @@ useHead({
<section class="mx-auto max-w-390 px-4 pb-16 pt-28 sm:px-6 lg:px-8 lg:pt-32 xl:px-10"> <section class="mx-auto max-w-390 px-4 pb-16 pt-28 sm:px-6 lg:px-8 lg:pt-32 xl:px-10">
<div class="mb-6 flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between"> <div class="mb-6 flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<div> <div>
<p class="text-sm font-black uppercase text-sky-300">Thành viên</p> <p class="text-sm font-black uppercase text-emerald-300">Thành viên</p>
<h1 class="mt-2 text-3xl font-black sm:text-4xl">Lịch sử xem</h1> <h1 class="mt-2 text-3xl font-black sm:text-4xl">Lịch sử xem</h1>
</div> </div>
<button v-if="historyItems.length" type="button" <button v-if="historyItems.length" type="button"
@@ -57,43 +87,47 @@ useHead({
</button> </button>
</div> </div>
<div v-if="loading" class="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-6"> <div v-if="loading" class="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-5 xl:grid-cols-6">
<div v-for="item in 12" :key="item" class="aspect-2/3 animate-pulse rounded-md bg-white/10" /> <div v-for="item in 12" :key="item" class="aspect-2/3 animate-pulse rounded-md bg-white/10" />
</div> </div>
<div v-else-if="historyItems.length" class="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-6"> <div v-else-if="historyItems.length" class="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-5 xl:grid-cols-6">
<NuxtLink v-for="item in historyItems" :key="`${item.source}-${item.slug}`" :to="watchHistoryLink(item)" <NuxtLink v-for="item in historyItems" :key="`${item.source}-${item.slug}`" :to="watchHistoryLink(item)"
class="group block min-w-0"> class="group block min-w-0">
<div <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"> 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-emerald-300/60">
<img v-if="item.thumb || item.poster" :src="item.thumb || item.poster" :alt="item.name" <img v-if="item.thumb || item.poster" :src="item.thumb || item.poster" :alt="item.name"
class="h-full w-full object-cover transition duration-500 group-hover:scale-105"> class="h-full w-full object-cover transition duration-500 group-hover:scale-105">
<div v-else class="grid h-full w-full place-items-center bg-white/8"> <div v-else class="grid h-full w-full place-items-center bg-white/8">
<Clock3 class="size-10 text-sky-300" /> <Clock3 class="size-10 text-emerald-300" />
</div> </div>
<div class="absolute inset-x-0 bottom-0 h-1 bg-white/18"> <div class="absolute inset-x-0 bottom-0 h-1 bg-white/18">
<span class="block h-full w-1/3 bg-sky-300" /> <span class="block h-full bg-emerald-300" :style="{ width: `${watchProgressPercent(item)}%` }" />
</div> </div>
<span class="absolute left-2 top-2 rounded bg-sky-400 px-2 py-1 text-xs font-black text-slate-950"> <span
class="absolute bottom-3 right-3 rounded bg-black/70 px-2 py-1 text-[11px] font-black text-white opacity-0 transition group-hover:opacity-100">
Xem tiếp
</span>
<span class="absolute left-2 top-2 rounded bg-emerald-400 px-2 py-1 text-xs font-black text-slate-950">
{{ item.episodeName || `Tập ${(item.episodeIndex || 0) + 1}` }} {{ item.episodeName || `Tập ${(item.episodeIndex || 0) + 1}` }}
</span> </span>
</div> </div>
<h2 class="mt-3 truncate text-sm font-black">{{ item.name }}</h2> <h2 class="mt-3 line-clamp-1 text-center text-sm font-black text-white">{{ item.name }}</h2>
<p class="mt-1 truncate text-xs font-semibold text-slate-400"> <p class="mt-1 truncate text-center text-xs font-semibold text-slate-400">
{{ item.episodeName || `Tập ${(item.episodeIndex || 0) + 1}` }} {{ watchProgressLabel(item) }}
</p> </p>
</NuxtLink> </NuxtLink>
</div> </div>
<div v-else <div v-else
class="flex min-h-80 flex-col items-center justify-center rounded-lg border border-white/10 bg-white/6 p-6 text-center"> class="flex min-h-80 flex-col items-center justify-center rounded-lg border border-white/10 bg-white/6 p-6 text-center">
<Clock3 class="size-12 text-sky-300" /> <Clock3 class="size-12 text-emerald-300" />
<h2 class="mt-4 text-xl font-black">Chưa lịch sử xem</h2> <h2 class="mt-4 text-xl font-black">Chưa lịch sử xem</h2>
<p class="mt-2 max-w-md text-sm leading-6 text-slate-300"> <p class="mt-2 max-w-md text-sm leading-6 text-slate-300">
Phim bạn đã xem sẽ xuất hiện đây để bạn thể xem tiếp nhanh hơn. Phim bạn đã xem sẽ xuất hiện đây để bạn thể xem tiếp nhanh hơn.
</p> </p>
<NuxtLink to="/phim" <NuxtLink to="/phim"
class="mt-5 inline-flex h-11 items-center justify-center rounded-md bg-sky-300 px-5 text-sm font-black text-slate-950 transition hover:bg-white"> class="mt-5 inline-flex h-11 items-center justify-center rounded-md bg-emerald-300 px-5 text-sm font-black text-slate-950 transition hover:bg-white">
Duyệt phim Duyệt phim
</NuxtLink> </NuxtLink>
</div> </div>
+19 -18
View File
@@ -19,6 +19,7 @@ const {
const sourceOptions = [ const sourceOptions = [
{ label: 'OPhim', value: 'ophim' }, { label: 'OPhim', value: 'ophim' },
{ label: 'NguonC', value: 'nguonc' }, { label: 'NguonC', value: 'nguonc' },
{ label: 'KKPhim', value: 'kkphim' },
] ]
const { data: movie, pending, error } = await useFetch(() => `/api/movies/${route.params.slug}`, { const { data: movie, pending, error } = await useFetch(() => `/api/movies/${route.params.slug}`, {
@@ -199,7 +200,7 @@ useHead(() => ({
class="mx-auto aspect-2/3 w-29 rounded-md object-cover shadow-xl shadow-black/40 sm:w-52 lg:w-full"> class="mx-auto aspect-2/3 w-29 rounded-md object-cover shadow-xl shadow-black/40 sm:w-52 lg:w-full">
<h1 class="mt-4 text-[1.35rem] font-black leading-tight sm:text-2xl lg:mt-5">{{ movie.name }}</h1> <h1 class="mt-4 text-[1.35rem] font-black leading-tight sm:text-2xl lg:mt-5">{{ movie.name }}</h1>
<p v-if="movie.originName" class="mt-1 text-sm font-bold text-slate-100 sm:text-sky-200">{{ <p v-if="movie.originName" class="mt-1 text-sm font-bold text-slate-100 sm:text-emerald-200">{{
movie.originName }}</p> movie.originName }}</p>
<div class="mt-3 hidden flex-wrap justify-center gap-2 text-xs font-black sm:flex lg:justify-start"> <div class="mt-3 hidden flex-wrap justify-center gap-2 text-xs font-black sm:flex lg:justify-start">
@@ -207,7 +208,7 @@ useHead(() => ({
<span v-if="movie.episode" class="rounded bg-white/12 px-2 py-1">{{ movie.episode }}</span> <span v-if="movie.episode" class="rounded bg-white/12 px-2 py-1">{{ movie.episode }}</span>
<span v-if="movie.time" class="rounded bg-white/12 px-2 py-1">{{ movie.time }}</span> <span v-if="movie.time" class="rounded bg-white/12 px-2 py-1">{{ movie.time }}</span>
<span v-if="movie.rating" class="inline-flex items-center gap-1 rounded bg-white/12 px-2 py-1"> <span v-if="movie.rating" class="inline-flex items-center gap-1 rounded bg-white/12 px-2 py-1">
<Star class="size-3 fill-sky-300 text-sky-300" /> <Star class="size-3 fill-emerald-300 text-emerald-300" />
{{ movie.rating.toFixed(1) }} {{ movie.rating.toFixed(1) }}
</span> </span>
</div> </div>
@@ -234,7 +235,7 @@ useHead(() => ({
<section <section
class="mt-0 rounded-none border-y border-white/10 bg-transparent px-0 pb-0 pt-3 shadow-none sm:mt-0 sm:rounded-md sm:border sm:bg-[#15161b]/95 sm:p-5 sm:shadow-2xl sm:shadow-black/40 lg:p-5"> class="mt-0 rounded-none border-y border-white/10 bg-transparent px-0 pb-0 pt-3 shadow-none sm:mt-0 sm:rounded-md sm:border sm:bg-[#15161b]/95 sm:p-5 sm:shadow-2xl sm:shadow-black/40 lg:p-5">
<button type="button" <button type="button"
class="mx-auto flex items-center gap-1 pb-3 text-sm font-bold text-sky-200 sm:hidden" class="mx-auto flex items-center gap-1 pb-3 text-sm font-bold text-emerald-200 sm:hidden"
:aria-expanded="movieInfoOpen" @click="movieInfoOpen = !movieInfoOpen"> :aria-expanded="movieInfoOpen" @click="movieInfoOpen = !movieInfoOpen">
<span>Thông tin phim</span> <span>Thông tin phim</span>
<ChevronDown class="size-4 transition" :class="movieInfoOpen ? 'rotate-180' : ''" /> <ChevronDown class="size-4 transition" :class="movieInfoOpen ? 'rotate-180' : ''" />
@@ -260,7 +261,7 @@ useHead(() => ({
<div class="flex gap-2 overflow-x-auto pb-1 sm:pb-0"> <div class="flex gap-2 overflow-x-auto pb-1 sm:pb-0">
<NuxtLink v-for="source in sourceOptions" :key="source.value" :to="sourceLink(source.value)" <NuxtLink v-for="source in sourceOptions" :key="source.value" :to="sourceLink(source.value)"
class="shrink-0 rounded-md px-4 py-2 text-sm font-black transition" class="shrink-0 rounded-md px-4 py-2 text-sm font-black transition"
:class="activeSource === source.value ? 'bg-sky-400 text-slate-950' : 'bg-white/10 text-white hover:bg-white/16'"> :class="activeSource === source.value ? 'bg-emerald-400 text-slate-950' : 'bg-white/10 text-white hover:bg-white/16'">
{{ source.label }} {{ source.label }}
</NuxtLink> </NuxtLink>
</div> </div>
@@ -270,7 +271,7 @@ useHead(() => ({
class="flex flex-col gap-4 border-b border-white/10 py-4 sm:flex-row sm:items-center sm:justify-between sm:py-5"> class="flex flex-col gap-4 border-b border-white/10 py-4 sm:flex-row sm:items-center sm:justify-between sm:py-5">
<div class="flex flex-wrap justify-center gap-3 sm:justify-start"> <div class="flex flex-wrap justify-center gap-3 sm:justify-start">
<NuxtLink :to="firstWatchLink" <NuxtLink :to="firstWatchLink"
class="inline-flex h-12 w-full items-center justify-center gap-2 rounded-full bg-linear-to-r from-sky-400 to-sky-300 px-7 text-sm font-black text-slate-950 transition hover:from-sky-300 hover:to-white sm:w-auto"> class="inline-flex h-12 w-full items-center justify-center gap-2 rounded-full bg-linear-to-r from-emerald-400 to-emerald-300 px-7 text-sm font-black text-slate-950 transition hover:from-emerald-300 hover:to-white sm:w-auto">
<Play class="size-4 fill-current" /> <Play class="size-4 fill-current" />
Xem Ngay Xem Ngay
</NuxtLink> </NuxtLink>
@@ -281,39 +282,39 @@ useHead(() => ({
<span class="text-xs font-black text-slate-300">Nguồn API</span> <span class="text-xs font-black text-slate-300">Nguồn API</span>
<NuxtLink v-for="source in sourceOptions" :key="source.value" :to="sourceLink(source.value)" <NuxtLink v-for="source in sourceOptions" :key="source.value" :to="sourceLink(source.value)"
class="shrink-0 rounded-md px-4 py-2 text-sm font-black transition" class="shrink-0 rounded-md px-4 py-2 text-sm font-black transition"
:class="activeSource === source.value ? 'bg-sky-400 text-slate-950' : 'bg-white/10 text-white hover:bg-white/16'"> :class="activeSource === source.value ? 'bg-emerald-400 text-slate-950' : 'bg-white/10 text-white hover:bg-white/16'">
{{ source.label }} {{ source.label }}
</NuxtLink> </NuxtLink>
</div> </div>
<div class="flex items-center justify-between gap-3 px-8 sm:justify-center sm:px-0"> <div class="flex items-center justify-between gap-3 px-8 sm:justify-center sm:px-0">
<button type="button" <button type="button"
class="flex cursor-pointer flex-col items-center gap-1 text-xs font-bold transition hover:text-sky-200 disabled:cursor-not-allowed disabled:opacity-70" class="flex cursor-pointer flex-col items-center gap-1 text-xs font-bold transition hover:text-emerald-200 disabled:cursor-not-allowed disabled:opacity-70"
:class="isFavoriteMovie ? 'text-sky-300' : 'text-white'" aria-label="Yêu thích" :class="isFavoriteMovie ? 'text-emerald-300' : 'text-white'" aria-label="Yêu thích"
:disabled="actionBusy" @click="toggleFavorite"> :disabled="actionBusy" @click="toggleFavorite">
<Heart class="size-5" :class="isFavoriteMovie ? 'fill-current' : ''" /> <Heart class="size-5" :class="isFavoriteMovie ? 'fill-current' : ''" />
<span>{{ isFavoriteMovie ? 'Đã thích' : 'Yêu thích' }}</span> <span>{{ isFavoriteMovie ? 'Đã thích' : 'Yêu thích' }}</span>
</button> </button>
<button type="button" <button type="button"
class="flex cursor-pointer flex-col items-center gap-1 text-xs font-bold text-white transition hover:text-sky-200 disabled:cursor-not-allowed disabled:opacity-70" class="flex cursor-pointer flex-col items-center gap-1 text-xs font-bold text-white transition hover:text-emerald-200 disabled:cursor-not-allowed disabled:opacity-70"
aria-label="Thêm vào" :disabled="actionBusy" @click="addToWatchLater"> aria-label="Thêm vào" :disabled="actionBusy" @click="addToWatchLater">
<Plus class="size-5" /> <Plus class="size-5" />
<span>Thêm vào</span> <span>Thêm vào</span>
</button> </button>
<button type="button" <button type="button"
class="flex cursor-pointer flex-col items-center gap-1 text-xs font-bold text-white transition hover:text-sky-200" class="flex cursor-pointer flex-col items-center gap-1 text-xs font-bold text-white transition hover:text-emerald-200"
aria-label="Chia sẻ" @click="shareMovie"> aria-label="Chia sẻ" @click="shareMovie">
<Share2 class="size-5" /> <Share2 class="size-5" />
<span>Chia sẻ</span> <span>Chia sẻ</span>
</button> </button>
<div v-if="movie.rating" <div v-if="movie.rating"
class="inline-flex items-center gap-1.5 rounded-full bg-sky-400 px-3 py-1.5 text-sm font-black text-slate-950"> class="inline-flex items-center gap-1.5 rounded-full bg-emerald-400 px-3 py-1.5 text-sm font-black text-slate-950">
<Star class="size-3.5 fill-current" /> <Star class="size-3.5 fill-current" />
{{ movie.rating.toFixed(1) }} {{ movie.rating.toFixed(1) }}
</div> </div>
</div> </div>
<p v-if="actionMessage" <p v-if="actionMessage"
class="mt-2 text-center text-xs font-bold text-sky-200 sm:text-right"> class="mt-2 text-center text-xs font-bold text-emerald-200 sm:text-right">
{{ actionMessage }} {{ actionMessage }}
</p> </p>
</div> </div>
@@ -322,10 +323,10 @@ useHead(() => ({
<div <div
class="mt-3 flex justify-between gap-6 border-b border-white/10 px-6 text-sm font-black sm:mt-0 sm:justify-center sm:px-0 sm:pt-5"> class="mt-3 flex justify-between gap-6 border-b border-white/10 px-6 text-sm font-black sm:mt-0 sm:justify-center sm:px-0 sm:pt-5">
<button type="button" class="px-5 pb-3 sm:px-0" <button type="button" class="px-5 pb-3 sm:px-0"
:class="activeTab === 'episodes' ? 'border-b-2 border-sky-300 text-sky-200' : 'text-slate-300'" :class="activeTab === 'episodes' ? 'border-b-2 border-emerald-300 text-emerald-200' : 'text-slate-300'"
@click="activeTab = 'episodes'">Tập phim</button> @click="activeTab = 'episodes'">Tập phim</button>
<button type="button" class="px-5 pb-3 sm:px-0" <button type="button" class="px-5 pb-3 sm:px-0"
:class="activeTab === 'actors' ? 'border-b-2 border-sky-300 text-sky-200' : 'text-slate-300'" :class="activeTab === 'actors' ? 'border-b-2 border-emerald-300 text-emerald-200' : 'text-slate-300'"
@click="activeTab = 'actors'">Diễn viên</button> @click="activeTab = 'actors'">Diễn viên</button>
</div> </div>
@@ -335,7 +336,7 @@ useHead(() => ({
<div v-if="servers.length > 1" class="mt-3 flex gap-2 overflow-x-auto pb-2"> <div v-if="servers.length > 1" class="mt-3 flex gap-2 overflow-x-auto pb-2">
<button v-for="(server, index) in servers" :key="server.name" type="button" <button v-for="(server, index) in servers" :key="server.name" type="button"
class="shrink-0 rounded px-4 py-2 text-sm font-black" class="shrink-0 rounded px-4 py-2 text-sm font-black"
:class="selectedServer === index ? 'bg-sky-400 text-slate-950' : 'bg-white/10 text-white hover:bg-white/16'" :class="selectedServer === index ? 'bg-emerald-400 text-slate-950' : 'bg-white/10 text-white hover:bg-white/16'"
@click="selectedServer = index"> @click="selectedServer = index">
{{ server.name }} {{ server.name }}
</button> </button>
@@ -345,7 +346,7 @@ useHead(() => ({
class="mt-3 grid grid-cols-3 gap-2 rounded-md border border-white/10 p-3 sm:grid-cols-3 lg:grid-cols-6"> class="mt-3 grid grid-cols-3 gap-2 rounded-md border border-white/10 p-3 sm:grid-cols-3 lg:grid-cols-6">
<NuxtLink v-for="(episode, index) in activeServer.episodes" :key="`${episode.name}-${index}`" <NuxtLink v-for="(episode, index) in activeServer.episodes" :key="`${episode.name}-${index}`"
:to="episodeLink(index)" :to="episodeLink(index)"
class="rounded-md bg-white/10 px-4 py-3 text-center text-sm font-black text-white transition first:bg-sky-400 first:text-slate-950 hover:bg-sky-400 hover:text-slate-950"> class="rounded-md bg-white/10 px-4 py-3 text-center text-sm font-black text-white transition first:bg-emerald-400 first:text-slate-950 hover:bg-emerald-400 hover:text-slate-950">
{{ formatEpisodeName(episode.name, index) }} {{ formatEpisodeName(episode.name, index) }}
</NuxtLink> </NuxtLink>
</div> </div>
@@ -364,13 +365,13 @@ useHead(() => ({
<img v-if="actor.avatar" :src="actor.avatar" :alt="actor.name" <img v-if="actor.avatar" :src="actor.avatar" :alt="actor.name"
class="size-14 shrink-0 rounded-md object-cover"> class="size-14 shrink-0 rounded-md object-cover">
<div v-else <div v-else
class="grid size-14 shrink-0 place-items-center rounded-md bg-sky-400/18 text-lg font-black text-sky-100 ring-1 ring-sky-300/20"> class="grid size-14 shrink-0 place-items-center rounded-md bg-emerald-400/18 text-lg font-black text-emerald-100 ring-1 ring-emerald-300/20">
{{ actorInitial(actor.name) }} {{ actorInitial(actor.name) }}
</div> </div>
<div class="min-w-0"> <div class="min-w-0">
<p class="truncate text-sm font-black text-white">{{ actor.name }}</p> <p class="truncate text-sm font-black text-white">{{ actor.name }}</p>
<p class="mt-1 truncate text-xs font-semibold text-sky-200"> <p class="mt-1 truncate text-xs font-semibold text-emerald-200">
{{ actor.originalName || 'Tên quốc tế đang cập nhật' }} {{ actor.originalName || 'Tên quốc tế đang cập nhật' }}
</p> </p>
<p class="mt-1 truncate text-xs font-semibold text-slate-400"> <p class="mt-1 truncate text-xs font-semibold text-slate-400">
+33 -11
View File
@@ -8,6 +8,13 @@ const page = ref(Math.max(Number(route.query.page || 1), 1))
const keyword = ref(typeof route.query.q === 'string' ? route.query.q : '') const keyword = ref(typeof route.query.q === 'string' ? route.query.q : '')
const debouncedKeyword = ref(keyword.value.trim()) const debouncedKeyword = ref(keyword.value.trim())
const type = ref(typeof route.query.type === 'string' ? route.query.type : 'all') const type = ref(typeof route.query.type === 'string' ? route.query.type : 'all')
const source = ref(typeof route.query.source === 'string' ? route.query.source : 'all')
const sourceOptions = [
{ label: 'Tất cả API', value: 'all' },
{ label: 'OPhim', value: 'ophim' },
{ label: 'NguonC', value: 'nguonc' },
{ label: 'KKPhim', value: 'kkphim' },
]
let searchTimer: ReturnType<typeof setTimeout> | undefined let searchTimer: ReturnType<typeof setTimeout> | undefined
@@ -23,12 +30,17 @@ watch(type, () => {
page.value = 1 page.value = 1
}) })
watch([page, debouncedKeyword, type], () => { watch(source, () => {
page.value = 1
})
watch([page, debouncedKeyword, type, source], () => {
router.replace({ router.replace({
query: { query: {
page: page.value > 1 ? page.value : undefined, page: page.value > 1 ? page.value : undefined,
q: debouncedKeyword.value || undefined, q: debouncedKeyword.value || undefined,
type: type.value !== 'all' ? type.value : undefined, type: type.value !== 'all' ? type.value : undefined,
source: source.value !== 'all' ? source.value : undefined,
}, },
}) })
}) })
@@ -37,6 +49,7 @@ const { data, pending, error } = await useFetch('/api/movies', {
query: computed(() => ({ query: computed(() => ({
page: page.value, page: page.value,
keyword: debouncedKeyword.value || undefined, keyword: debouncedKeyword.value || undefined,
source: source.value !== 'all' ? source.value : undefined,
})), })),
}) })
@@ -87,7 +100,7 @@ useHead({
<section class="mx-auto max-w-390 px-4 pb-16 pt-36 sm:px-6 lg:px-8 lg:pt-28 xl:px-10"> <section class="mx-auto max-w-390 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 class="mb-8 flex flex-col gap-5 lg:flex-row lg:items-end lg:justify-between">
<div> <div>
<p class="text-sm font-semibold text-sky-200">Kho phim Hàn Quốc</p> <p class="text-sm font-semibold text-emerald-200">Kho phim Hàn Quốc</p>
<h1 class="mt-2 text-3xl font-black text-white sm:text-5xl"> <h1 class="mt-2 text-3xl font-black text-white sm:text-5xl">
Duyệt phim Duyệt phim
</h1> </h1>
@@ -95,8 +108,8 @@ useHead({
<div class="flex w-full flex-col gap-3 sm:flex-row lg:max-w-3xl"> <div class="flex w-full flex-col gap-3 sm:flex-row lg:max-w-3xl">
<div <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"> 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-emerald-950/20">
<Search class="mr-3 size-4 shrink-0 text-sky-200" /> <Search class="mr-3 size-4 shrink-0 text-emerald-200" />
<input v-model="keyword" type="search" placeholder="Tìm phim Hàn Quốc..." <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"> class="w-full bg-transparent text-sm text-white outline-none placeholder:text-slate-400">
</div> </div>
@@ -104,21 +117,30 @@ useHead({
<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"> 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" <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'"> :class="type === 'all' ? 'bg-emerald-300 text-slate-950' : 'hover:text-white'" @click="type = 'all'">
Tất cả Tất cả
</button> </button>
<button type="button" class="rounded-full px-4 py-2 transition" <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'"> :class="type === 'series' ? 'bg-emerald-300 text-slate-950' : 'hover:text-white'" @click="type = 'series'">
Phim bộ Phim bộ
</button> </button>
<button type="button" class="rounded-full px-4 py-2 transition" <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'"> :class="type === 'single' ? 'bg-emerald-300 text-slate-950' : 'hover:text-white'" @click="type = 'single'">
Phim lẻ Phim lẻ
</button> </button>
</div> </div>
</div> </div>
</div> </div>
<div class="mb-6 flex flex-wrap gap-2 text-sm font-black text-slate-200">
<button v-for="option in sourceOptions" :key="option.value" type="button"
class="rounded-full border border-white/10 px-4 py-2 transition"
:class="source === option.value ? 'bg-emerald-300 text-slate-950' : 'bg-white/8 hover:bg-white/14 hover:text-white'"
@click="source = option.value">
{{ option.label }}
</button>
</div>
<div v-if="sourceStatus.length" class="mb-8 flex flex-wrap gap-2 text-xs text-slate-300"> <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" <span v-for="source in sourceStatus" :key="source.name"
class="rounded-full border border-white/10 bg-white/8 px-3 py-1"> class="rounded-full border border-white/10 bg-white/8 px-3 py-1">
@@ -138,14 +160,14 @@ useHead({
<NuxtLink v-for="movie in filteredMovies" :key="`${movie.source}-${movie.slug}`" <NuxtLink v-for="movie in filteredMovies" :key="`${movie.source}-${movie.slug}`"
:to="{ path: `/phim/${movie.slug}`, query: { source: movie.source } }" class="group"> :to="{ path: `/phim/${movie.slug}`, query: { source: movie.source } }" class="group">
<div <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"> 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-emerald-300/60">
<img :src="movie.thumb || movie.poster" :alt="movie.name" <img :src="movie.thumb || movie.poster" :alt="movie.name"
class="h-full w-full object-cover transition duration-500 group-hover:scale-105"> class="h-full w-full object-cover transition duration-500 group-hover:scale-105">
<div class="absolute inset-x-0 bottom-0 bg-linear-to-t from-slate-950 via-slate-950/70 to-transparent p-3"> <div class="absolute inset-x-0 bottom-0 bg-linear-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="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> <p class="mt-1 truncate text-xs text-emerald-100">{{ movie.episode || movie.year || movie.quality }}</p>
</div> </div>
<span class="absolute left-2 top-2 rounded bg-sky-400 px-2 py-1 text-xs font-black text-slate-950"> <span class="absolute left-2 top-2 rounded bg-emerald-400 px-2 py-1 text-xs font-black text-slate-950">
{{ movie.source }} {{ movie.source }}
</span> </span>
</div> </div>
@@ -166,7 +188,7 @@ useHead({
<span class="text-sm text-slate-300">Trang {{ page }}<template v-if="totalPages"> / {{ totalPages <span class="text-sm text-slate-300">Trang {{ page }}<template v-if="totalPages"> / {{ totalPages
}}</template></span> }}</template></span>
<button type="button" <button type="button"
class="grid size-11 place-items-center rounded-full bg-sky-300 text-slate-950 hover:bg-white" class="grid size-11 place-items-center rounded-full bg-emerald-300 text-slate-950 hover:bg-white"
aria-label="Trang sau" @click="nextPage"> aria-label="Trang sau" @click="nextPage">
<ChevronRight class="size-5" /> <ChevronRight class="size-5" />
</button> </button>
+6 -6
View File
@@ -31,7 +31,7 @@ useHead({
<section class="mx-auto max-w-390 px-4 pb-16 pt-28 sm:px-6 lg:px-8 lg:pt-32 xl:px-10"> <section class="mx-auto max-w-390 px-4 pb-16 pt-28 sm:px-6 lg:px-8 lg:pt-32 xl:px-10">
<div class="mb-6"> <div class="mb-6">
<p class="text-sm font-black uppercase text-sky-300">Thành viên</p> <p class="text-sm font-black uppercase text-emerald-300">Thành viên</p>
<h1 class="mt-2 text-3xl font-black sm:text-4xl">Trang nhân</h1> <h1 class="mt-2 text-3xl font-black sm:text-4xl">Trang nhân</h1>
</div> </div>
@@ -43,11 +43,11 @@ useHead({
<section class="rounded-lg border border-white/10 bg-white/6 p-5 sm:p-6"> <section class="rounded-lg border border-white/10 bg-white/6 p-5 sm:p-6">
<div class="flex flex-col gap-5 sm:flex-row sm:items-center"> <div class="flex flex-col gap-5 sm:flex-row sm:items-center">
<img :src="memberAvatar" :alt="memberName" <img :src="memberAvatar" :alt="memberName"
class="size-24 shrink-0 rounded-full object-cover ring-4 ring-sky-300/30"> class="size-24 shrink-0 rounded-full object-cover ring-4 ring-emerald-300/30">
<div class="min-w-0"> <div class="min-w-0">
<h2 class="truncate text-2xl font-black">{{ memberName }}</h2> <h2 class="truncate text-2xl font-black">{{ memberName }}</h2>
<p class="mt-2 inline-flex items-center gap-2 text-sm font-semibold text-slate-300"> <p class="mt-2 inline-flex items-center gap-2 text-sm font-semibold text-slate-300">
<Mail class="size-4 text-sky-300" /> <Mail class="size-4 text-emerald-300" />
{{ user.email }} {{ user.email }}
</p> </p>
<p class="mt-4 max-w-2xl text-sm leading-6 text-slate-400"> <p class="mt-4 max-w-2xl text-sm leading-6 text-slate-400">
@@ -60,7 +60,7 @@ useHead({
<aside class="grid gap-3 sm:grid-cols-2 lg:grid-cols-1"> <aside class="grid gap-3 sm:grid-cols-2 lg:grid-cols-1">
<NuxtLink to="/lich-su" <NuxtLink to="/lich-su"
class="flex items-center gap-3 rounded-lg border border-white/10 bg-white/6 p-4 transition hover:bg-white/10"> class="flex items-center gap-3 rounded-lg border border-white/10 bg-white/6 p-4 transition hover:bg-white/10">
<span class="grid size-10 place-items-center rounded-md bg-sky-300 text-slate-950"> <span class="grid size-10 place-items-center rounded-md bg-emerald-300 text-slate-950">
<Clock3 class="size-5" /> <Clock3 class="size-5" />
</span> </span>
<span> <span>
@@ -70,7 +70,7 @@ useHead({
</NuxtLink> </NuxtLink>
<NuxtLink to="/yeu-thich" <NuxtLink to="/yeu-thich"
class="flex items-center gap-3 rounded-lg border border-white/10 bg-white/6 p-4 transition hover:bg-white/10"> class="flex items-center gap-3 rounded-lg border border-white/10 bg-white/6 p-4 transition hover:bg-white/10">
<span class="grid size-10 place-items-center rounded-md bg-white/10 text-sky-300"> <span class="grid size-10 place-items-center rounded-md bg-white/10 text-emerald-300">
<Heart class="size-5 fill-current" /> <Heart class="size-5 fill-current" />
</span> </span>
<span> <span>
@@ -82,7 +82,7 @@ useHead({
</div> </div>
<div v-else class="rounded-lg border border-white/10 bg-white/6 p-6"> <div v-else class="rounded-lg border border-white/10 bg-white/6 p-6">
<UserRound class="size-10 text-sky-300" /> <UserRound class="size-10 text-emerald-300" />
<h2 class="mt-4 text-xl font-black">Bạn chưa đăng nhập</h2> <h2 class="mt-4 text-xl font-black">Bạn chưa đăng nhập</h2>
<p class="mt-2 text-sm leading-6 text-slate-300"> <p class="mt-2 text-sm leading-6 text-slate-300">
Bấm nút Thành viên trên header để đăng nhập hoặc đăng tài khoản. Bấm nút Thành viên trên header để đăng nhập hoặc đăng tài khoản.
+30 -30
View File
@@ -646,7 +646,7 @@ useHead(() => ({
<template v-else> <template v-else>
<section class="mx-auto max-w-390 px-3 pb-16 pt-24 sm:px-6 sm:pt-36 lg:px-8 lg:pt-28 xl:px-10"> <section class="mx-auto max-w-390 px-3 pb-16 pt-24 sm:px-6 sm:pt-36 lg:px-8 lg:pt-28 xl:px-10">
<NuxtLink :to="{ path: `/phim/${route.params.slug}`, query: { source: route.query.source } }" <NuxtLink :to="{ path: `/phim/${route.params.slug}`, query: { source: route.query.source } }"
class="text-sm font-bold text-slate-100 hover:text-sky-200"> class="text-sm font-bold text-slate-100 hover:text-emerald-200">
Chi tiết phim Chi tiết phim
</NuxtLink> </NuxtLink>
@@ -654,13 +654,13 @@ useHead(() => ({
<div class="grid grid-cols-2 gap-2 border-b border-white/10 bg-slate-950/92 p-2 sm:hidden"> <div class="grid grid-cols-2 gap-2 border-b border-white/10 bg-slate-950/92 p-2 sm:hidden">
<button type="button" <button type="button"
class="rounded-md px-3 py-2 text-sm font-black transition disabled:cursor-not-allowed disabled:opacity-40" class="rounded-md px-3 py-2 text-sm font-black transition disabled:cursor-not-allowed disabled:opacity-40"
:class="playerMode === 'embed' ? 'bg-sky-400 text-slate-950' : 'bg-white/10 text-slate-100'" :class="playerMode === 'embed' ? 'bg-emerald-400 text-slate-950' : 'bg-white/10 text-slate-100'"
:disabled="!canUseEmbed" @click="selectPlayerMode('embed')"> :disabled="!canUseEmbed" @click="selectPlayerMode('embed')">
Nhúng Nhúng
</button> </button>
<button type="button" <button type="button"
class="rounded-md px-3 py-2 text-sm font-black transition disabled:cursor-not-allowed disabled:opacity-40" class="rounded-md px-3 py-2 text-sm font-black transition disabled:cursor-not-allowed disabled:opacity-40"
:class="playerMode === 'hls' ? 'bg-sky-400 text-slate-950' : 'bg-white/10 text-slate-100'" :class="playerMode === 'hls' ? 'bg-emerald-400 text-slate-950' : 'bg-white/10 text-slate-100'"
:disabled="!canUseHls" @click="selectPlayerMode('hls')"> :disabled="!canUseHls" @click="selectPlayerMode('hls')">
HLS HLS
</button> </button>
@@ -670,12 +670,12 @@ useHead(() => ({
<div <div
class="absolute left-3 top-3 z-20 hidden rounded-md border border-white/10 bg-black/65 p-1 text-xs font-black text-white backdrop-blur sm:inline-flex"> class="absolute left-3 top-3 z-20 hidden rounded-md border border-white/10 bg-black/65 p-1 text-xs font-black text-white backdrop-blur sm:inline-flex">
<button type="button" class="rounded px-3 py-1.5 transition disabled:cursor-not-allowed disabled:opacity-40" <button type="button" class="rounded px-3 py-1.5 transition disabled:cursor-not-allowed disabled:opacity-40"
:class="playerMode === 'embed' ? 'bg-sky-400 text-slate-950' : 'text-slate-200 hover:bg-white/10'" :class="playerMode === 'embed' ? 'bg-emerald-400 text-slate-950' : 'text-slate-200 hover:bg-white/10'"
:disabled="!canUseEmbed" @click="selectPlayerMode('embed')"> :disabled="!canUseEmbed" @click="selectPlayerMode('embed')">
Nhúng Nhúng
</button> </button>
<button type="button" class="rounded px-3 py-1.5 transition disabled:cursor-not-allowed disabled:opacity-40" <button type="button" class="rounded px-3 py-1.5 transition disabled:cursor-not-allowed disabled:opacity-40"
:class="playerMode === 'hls' ? 'bg-sky-400 text-slate-950' : 'text-slate-200 hover:bg-white/10'" :class="playerMode === 'hls' ? 'bg-emerald-400 text-slate-950' : 'text-slate-200 hover:bg-white/10'"
:disabled="!canUseHls" @click="selectPlayerMode('hls')"> :disabled="!canUseHls" @click="selectPlayerMode('hls')">
HLS HLS
</button> </button>
@@ -698,7 +698,7 @@ useHead(() => ({
<div class="pointer-events-none absolute inset-0 bg-linear-to-t from-black/70 via-transparent to-black/35" /> <div class="pointer-events-none absolute inset-0 bg-linear-to-t from-black/70 via-transparent to-black/35" />
<div v-if="isVideoBuffering" <div v-if="isVideoBuffering"
class="pointer-events-none absolute inset-0 z-10 grid place-items-center bg-black/20 text-sky-200"> class="pointer-events-none absolute inset-0 z-10 grid place-items-center bg-black/20 text-emerald-200">
<Loader2 class="size-10 animate-spin" /> <Loader2 class="size-10 animate-spin" />
</div> </div>
@@ -708,13 +708,13 @@ useHead(() => ({
</div> </div>
<button v-if="videoCurrentTime < skipIntroSeconds && durationSeconds > 180" type="button" <button v-if="videoCurrentTime < skipIntroSeconds && durationSeconds > 180" type="button"
class="absolute bottom-24 right-4 z-20 rounded-md bg-sky-400 px-4 py-2 text-xs font-black text-slate-950 shadow-lg transition hover:bg-white" class="absolute bottom-24 right-4 z-20 rounded-md bg-emerald-400 px-4 py-2 text-xs font-black text-slate-950 shadow-lg transition hover:bg-white"
@click="skipIntro"> @click="skipIntro">
Bỏ intro Bỏ intro
</button> </button>
<button v-if="skipOutroSeconds && videoCurrentTime > skipOutroSeconds - 45 && hasNextEpisode" type="button" <button v-if="skipOutroSeconds && videoCurrentTime > skipOutroSeconds - 45 && hasNextEpisode" type="button"
class="absolute bottom-24 right-4 z-20 inline-flex items-center gap-2 rounded-md bg-sky-400 px-4 py-2 text-xs font-black text-slate-950 shadow-lg transition hover:bg-white" class="absolute bottom-24 right-4 z-20 inline-flex items-center gap-2 rounded-md bg-emerald-400 px-4 py-2 text-xs font-black text-slate-950 shadow-lg transition hover:bg-white"
@click="playNextEpisode(true)"> @click="playNextEpisode(true)">
<SkipForward class="size-4" /> Tập tiếp <SkipForward class="size-4" /> Tập tiếp
</button> </button>
@@ -724,7 +724,7 @@ useHead(() => ({
:class="isVideoPlaying && !controlsVisible ? 'opacity-0' : 'opacity-100'" :class="isVideoPlaying && !controlsVisible ? 'opacity-0' : 'opacity-100'"
:aria-label="isVideoPlaying ? 'Tạm dừng' : 'Phát phim'" @click="toggleHlsPlayback"> :aria-label="isVideoPlaying ? 'Tạm dừng' : 'Phát phim'" @click="toggleHlsPlayback">
<span <span
class="grid size-12 place-items-center rounded-full bg-sky-400 text-slate-950 shadow-xl shadow-sky-950/30 sm:size-16"> class="grid size-12 place-items-center rounded-full bg-emerald-400 text-slate-950 shadow-xl shadow-emerald-950/30 sm:size-16">
<Pause v-if="isVideoPlaying" class="size-5 fill-current sm:size-7" /> <Pause v-if="isVideoPlaying" class="size-5 fill-current sm:size-7" />
<Play v-else class="size-5 fill-current sm:size-7" /> <Play v-else class="size-5 fill-current sm:size-7" />
</span> </span>
@@ -737,23 +737,23 @@ useHead(() => ({
<div class="mt-2 flex items-center justify-between gap-2 sm:mt-3 sm:gap-3"> <div class="mt-2 flex items-center justify-between gap-2 sm:mt-3 sm:gap-3">
<div class="flex min-w-0 items-center gap-1.5 sm:gap-3"> <div class="flex min-w-0 items-center gap-1.5 sm:gap-3">
<button type="button" <button type="button"
class="grid size-8 cursor-pointer place-items-center rounded-full bg-white/12 text-white transition hover:bg-sky-400 hover:text-slate-950 sm:size-9" class="grid size-8 cursor-pointer place-items-center rounded-full bg-white/12 text-white transition hover:bg-emerald-400 hover:text-slate-950 sm:size-9"
:aria-label="isVideoPlaying ? 'Tạm dừng' : 'Phát phim'" @click="toggleHlsPlayback"> :aria-label="isVideoPlaying ? 'Tạm dừng' : 'Phát phim'" @click="toggleHlsPlayback">
<Pause v-if="isVideoPlaying" class="size-3.5 fill-current sm:size-4" /> <Pause v-if="isVideoPlaying" class="size-3.5 fill-current sm:size-4" />
<Play v-else class="size-3.5 fill-current sm:size-4" /> <Play v-else class="size-3.5 fill-current sm:size-4" />
</button> </button>
<button type="button" <button type="button"
class="grid size-8 cursor-pointer place-items-center rounded-full bg-white/12 text-white transition hover:bg-sky-400 hover:text-slate-950 sm:size-9" class="grid size-8 cursor-pointer place-items-center rounded-full bg-white/12 text-white transition hover:bg-emerald-400 hover:text-slate-950 sm:size-9"
aria-label="Lùi 10 giây" @click="seekBy(-10)"> aria-label="Lùi 10 giây" @click="seekBy(-10)">
<Rewind class="size-3.5 sm:size-4" /> <Rewind class="size-3.5 sm:size-4" />
</button> </button>
<button type="button" <button type="button"
class="grid size-8 cursor-pointer place-items-center rounded-full bg-white/12 text-white transition hover:bg-sky-400 hover:text-slate-950 sm:size-9" class="grid size-8 cursor-pointer place-items-center rounded-full bg-white/12 text-white transition hover:bg-emerald-400 hover:text-slate-950 sm:size-9"
aria-label="Tua 10 giây" @click="seekBy(10)"> aria-label="Tua 10 giây" @click="seekBy(10)">
<FastForward class="size-3.5 sm:size-4" /> <FastForward class="size-3.5 sm:size-4" />
</button> </button>
<button type="button" <button type="button"
class="grid size-8 cursor-pointer place-items-center rounded-full bg-white/12 text-white transition hover:bg-sky-400 hover:text-slate-950 sm:size-9" class="grid size-8 cursor-pointer place-items-center rounded-full bg-white/12 text-white transition hover:bg-emerald-400 hover:text-slate-950 sm:size-9"
:aria-label="isVideoMuted ? 'Bật âm' : 'Tắt âm'" @click="toggleMute"> :aria-label="isVideoMuted ? 'Bật âm' : 'Tắt âm'" @click="toggleMute">
<VolumeX v-if="isVideoMuted" class="size-3.5 sm:size-4" /> <VolumeX v-if="isVideoMuted" class="size-3.5 sm:size-4" />
<Volume2 v-else class="size-3.5 sm:size-4" /> <Volume2 v-else class="size-3.5 sm:size-4" />
@@ -768,8 +768,8 @@ useHead(() => ({
<div class="flex shrink-0 items-center gap-1.5 sm:gap-2"> <div class="flex shrink-0 items-center gap-1.5 sm:gap-2">
<div class="relative"> <div class="relative">
<button type="button" <button type="button"
class="grid size-8 cursor-pointer place-items-center rounded-full bg-white/12 text-white transition hover:bg-sky-400 hover:text-slate-950 sm:size-9" class="grid size-8 cursor-pointer place-items-center rounded-full bg-white/12 text-white transition hover:bg-emerald-400 hover:text-slate-950 sm:size-9"
:class="isSettingsOpen ? 'bg-sky-400 text-slate-950' : ''" aria-label="Cài đặt player" :class="isSettingsOpen ? 'bg-emerald-400 text-slate-950' : ''" aria-label="Cài đặt player"
@click="isSettingsOpen = !isSettingsOpen; controlsVisible = true"> @click="isSettingsOpen = !isSettingsOpen; controlsVisible = true">
<Settings class="size-3.5 sm:size-4" /> <Settings class="size-3.5 sm:size-4" />
</button> </button>
@@ -804,17 +804,17 @@ useHead(() => ({
</div> </div>
</div> </div>
<button type="button" <button type="button"
class="hidden size-9 cursor-pointer place-items-center rounded-full bg-white/12 text-white transition hover:bg-sky-400 hover:text-slate-950 sm:grid" class="hidden size-9 cursor-pointer place-items-center rounded-full bg-white/12 text-white transition hover:bg-emerald-400 hover:text-slate-950 sm:grid"
aria-label="Picture in Picture" @click="togglePictureInPicture"> aria-label="Picture in Picture" @click="togglePictureInPicture">
<PictureInPicture2 class="size-4" /> <PictureInPicture2 class="size-4" />
</button> </button>
<button v-if="hasNextEpisode" type="button" <button v-if="hasNextEpisode" type="button"
class="hidden size-9 cursor-pointer place-items-center rounded-full bg-white/12 text-white transition hover:bg-sky-400 hover:text-slate-950 sm:grid" class="hidden size-9 cursor-pointer place-items-center rounded-full bg-white/12 text-white transition hover:bg-emerald-400 hover:text-slate-950 sm:grid"
aria-label="Tập tiếp theo" @click="playNextEpisode(true)"> aria-label="Tập tiếp theo" @click="playNextEpisode(true)">
<SkipForward class="size-4" /> <SkipForward class="size-4" />
</button> </button>
<button type="button" <button type="button"
class="grid size-8 cursor-pointer place-items-center rounded-full bg-white/12 text-white transition hover:bg-sky-400 hover:text-slate-950 sm:size-9" class="grid size-8 cursor-pointer place-items-center rounded-full bg-white/12 text-white transition hover:bg-emerald-400 hover:text-slate-950 sm:size-9"
aria-label="Toàn màn hình" @click="toggleFullscreen"> aria-label="Toàn màn hình" @click="toggleFullscreen">
<Maximize class="size-3.5 sm:size-4" /> <Maximize class="size-3.5 sm:size-4" />
</button> </button>
@@ -828,7 +828,7 @@ useHead(() => ({
<div class="absolute inset-0 bg-black/45" /> <div class="absolute inset-0 bg-black/45" />
<div class="absolute inset-0 grid place-items-center"> <div class="absolute inset-0 grid place-items-center">
<span <span
class="grid size-16 place-items-center rounded-full bg-sky-400 text-slate-950 shadow-xl shadow-sky-950/30"> class="grid size-16 place-items-center rounded-full bg-emerald-400 text-slate-950 shadow-xl shadow-emerald-950/30">
<Play class="size-7 fill-current" /> <Play class="size-7 fill-current" />
</span> </span>
<span v-if="playerMode === 'hls' && !canUseHls" <span v-if="playerMode === 'hls' && !canUseHls"
@@ -842,25 +842,25 @@ useHead(() => ({
<div <div
class="flex items-center justify-center gap-7 border-t border-white/10 px-4 py-4 text-xs font-bold text-slate-100 sm:justify-start sm:gap-4 sm:py-3"> class="flex items-center justify-center gap-7 border-t border-white/10 px-4 py-4 text-xs font-bold text-slate-100 sm:justify-start sm:gap-4 sm:py-3">
<button type="button" <button type="button"
class="inline-flex cursor-pointer items-center gap-2 hover:text-sky-200 disabled:cursor-not-allowed disabled:opacity-70" class="inline-flex cursor-pointer items-center gap-2 hover:text-emerald-200 disabled:cursor-not-allowed disabled:opacity-70"
:class="isFavoriteMovie ? 'text-sky-300' : ''" :disabled="actionBusy" @click="toggleFavorite"> :class="isFavoriteMovie ? 'text-emerald-300' : ''" :disabled="actionBusy" @click="toggleFavorite">
<Heart class="size-4 sm:size-3" :class="isFavoriteMovie ? 'fill-current' : ''" /> <Heart class="size-4 sm:size-3" :class="isFavoriteMovie ? 'fill-current' : ''" />
{{ isFavoriteMovie ? 'Đã thích' : 'Yêu thích' }} {{ isFavoriteMovie ? 'Đã thích' : 'Yêu thích' }}
</button> </button>
<button type="button" <button type="button"
class="inline-flex cursor-pointer items-center gap-2 hover:text-sky-200 disabled:cursor-not-allowed disabled:opacity-70" class="inline-flex cursor-pointer items-center gap-2 hover:text-emerald-200 disabled:cursor-not-allowed disabled:opacity-70"
:disabled="actionBusy" @click="addToWatchLater"> :disabled="actionBusy" @click="addToWatchLater">
<Plus class="size-4 sm:size-3" /> Thêm vào <Plus class="size-4 sm:size-3" /> Thêm vào
</button> </button>
<button type="button" class="inline-flex cursor-pointer items-center gap-2 hover:text-sky-200" <button type="button" class="inline-flex cursor-pointer items-center gap-2 hover:text-emerald-200"
@click="shareMovie"> @click="shareMovie">
<Share2 class="size-4 sm:size-3" /> Chia sẻ <Share2 class="size-4 sm:size-3" /> Chia sẻ
</button> </button>
<span v-if="actionMessage" class="hidden text-xs font-bold text-sky-200 sm:inline"> <span v-if="actionMessage" class="hidden text-xs font-bold text-emerald-200 sm:inline">
{{ actionMessage }} {{ actionMessage }}
</span> </span>
</div> </div>
<p v-if="actionMessage" class="border-t border-white/10 px-4 pb-3 text-center text-xs font-bold text-sky-200 sm:hidden"> <p v-if="actionMessage" class="border-t border-white/10 px-4 pb-3 text-center text-xs font-bold text-emerald-200 sm:hidden">
{{ actionMessage }} {{ actionMessage }}
</p> </p>
</div> </div>
@@ -872,13 +872,13 @@ useHead(() => ({
class="hidden h-28 w-20 rounded-md object-cover sm:block"> class="hidden h-28 w-20 rounded-md object-cover sm:block">
<div> <div>
<h1 class="text-[1.35rem] font-black leading-tight sm:text-2xl">{{ movie.name }}</h1> <h1 class="text-[1.35rem] font-black leading-tight sm:text-2xl">{{ movie.name }}</h1>
<p v-if="movie.originName" class="mt-1 text-sm font-bold text-sky-200">{{ movie.originName }}</p> <p v-if="movie.originName" class="mt-1 text-sm font-bold text-emerald-200">{{ movie.originName }}</p>
<div class="mt-3 flex flex-wrap gap-2 text-xs font-black"> <div class="mt-3 flex flex-wrap gap-2 text-xs font-black">
<span v-if="movie.quality" class="rounded bg-white/12 px-2 py-1">{{ movie.quality }}</span> <span v-if="movie.quality" class="rounded bg-white/12 px-2 py-1">{{ movie.quality }}</span>
<span v-if="movie.episode" class="rounded bg-white/12 px-2 py-1">{{ movie.episode }}</span> <span v-if="movie.episode" class="rounded bg-white/12 px-2 py-1">{{ movie.episode }}</span>
<span v-if="movie.time" class="rounded bg-white/12 px-2 py-1">{{ movie.time }}</span> <span v-if="movie.time" class="rounded bg-white/12 px-2 py-1">{{ movie.time }}</span>
<span v-if="movie.rating" <span v-if="movie.rating"
class="inline-flex items-center gap-1 rounded bg-sky-400 px-2 py-1 text-slate-950"> class="inline-flex items-center gap-1 rounded bg-emerald-400 px-2 py-1 text-slate-950">
<Star class="size-3 fill-current" /> <Star class="size-3 fill-current" />
{{ movie.rating.toFixed(1) }} {{ movie.rating.toFixed(1) }}
</span> </span>
@@ -888,14 +888,14 @@ useHead(() => ({
<p v-if="movie.content" class="mt-4 text-sm leading-7 text-slate-200">{{ movie.content }}</p> <p v-if="movie.content" class="mt-4 text-sm leading-7 text-slate-200">{{ movie.content }}</p>
<div class="mt-5 rounded-md bg-sky-400 px-4 py-3 text-sm font-black text-slate-950"> <div class="mt-5 rounded-md bg-emerald-400 px-4 py-3 text-sm font-black text-slate-950">
Đang xem {{ activeEpisode?.name || 'Tập 1' }} Đang xem {{ activeEpisode?.name || 'Tập 1' }}
</div> </div>
<div v-if="servers.length > 1" class="mt-4 flex gap-2 overflow-x-auto pb-2"> <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" <button v-for="(server, index) in servers" :key="server.name" type="button"
class="shrink-0 rounded px-4 py-2 text-sm font-black" class="shrink-0 rounded px-4 py-2 text-sm font-black"
:class="selectedServer === index ? 'bg-sky-400 text-slate-950' : 'bg-white/10 text-white hover:bg-white/16'" :class="selectedServer === index ? 'bg-emerald-400 text-slate-950' : 'bg-white/10 text-white hover:bg-white/16'"
@click="selectServer(index)"> @click="selectServer(index)">
{{ server.name }} {{ server.name }}
</button> </button>
@@ -906,7 +906,7 @@ useHead(() => ({
<NuxtLink v-for="(episode, index) in activeServer.episodes" :key="`${episode.name}-${index}`" <NuxtLink v-for="(episode, index) in activeServer.episodes" :key="`${episode.name}-${index}`"
:to="episodeLink(index)" :to="episodeLink(index)"
class="inline-flex items-center justify-center gap-2 rounded-md px-3 py-3 text-center text-sm font-black transition" class="inline-flex items-center justify-center gap-2 rounded-md px-3 py-3 text-center text-sm font-black transition"
:class="selectedEpisode === index ? 'bg-sky-400 text-slate-950' : 'bg-white/10 text-white hover:bg-white/16'"> :class="selectedEpisode === index ? 'bg-emerald-400 text-slate-950' : 'bg-white/10 text-white hover:bg-white/16'">
<Play class="size-3 fill-current" /> <Play class="size-3 fill-current" />
{{ episode.name }} {{ episode.name }}
</NuxtLink> </NuxtLink>
+7 -7
View File
@@ -42,7 +42,7 @@ useHead({
<section class="mx-auto max-w-390 px-4 pb-16 pt-28 sm:px-6 lg:px-8 lg:pt-32 xl:px-10"> <section class="mx-auto max-w-390 px-4 pb-16 pt-28 sm:px-6 lg:px-8 lg:pt-32 xl:px-10">
<div class="mb-6"> <div class="mb-6">
<p class="text-sm font-black uppercase text-sky-300">Thư viện</p> <p class="text-sm font-black uppercase text-emerald-300">Thư viện</p>
<h1 class="mt-2 text-3xl font-black sm:text-4xl">Yêu thích</h1> <h1 class="mt-2 text-3xl font-black sm:text-4xl">Yêu thích</h1>
</div> </div>
@@ -54,13 +54,13 @@ useHead({
<NuxtLink v-for="item in favoriteItems" :key="`${item.source}-${item.slug}`" :to="movieLink(item)" <NuxtLink v-for="item in favoriteItems" :key="`${item.source}-${item.slug}`" :to="movieLink(item)"
class="group block min-w-0"> class="group block min-w-0">
<div <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"> 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-emerald-300/60">
<img v-if="item.thumb || item.poster" :src="item.thumb || item.poster" :alt="item.name" <img v-if="item.thumb || item.poster" :src="item.thumb || item.poster" :alt="item.name"
class="h-full w-full object-cover transition duration-500 group-hover:scale-105"> class="h-full w-full object-cover transition duration-500 group-hover:scale-105">
<div v-else class="grid h-full w-full place-items-center bg-white/8"> <div v-else class="grid h-full w-full place-items-center bg-white/8">
<Heart class="size-10 text-sky-300" /> <Heart class="size-10 text-emerald-300" />
</div> </div>
<span class="absolute left-2 top-2 rounded bg-sky-400 px-2 py-1 text-xs font-black text-slate-950"> <span class="absolute left-2 top-2 rounded bg-emerald-400 px-2 py-1 text-xs font-black text-slate-950">
Yêu thích Yêu thích
</span> </span>
</div> </div>
@@ -71,19 +71,19 @@ useHead({
<div v-else-if="user" <div v-else-if="user"
class="flex min-h-80 flex-col items-center justify-center rounded-lg border border-white/10 bg-white/6 p-6 text-center"> class="flex min-h-80 flex-col items-center justify-center rounded-lg border border-white/10 bg-white/6 p-6 text-center">
<Heart class="size-12 text-sky-300" /> <Heart class="size-12 text-emerald-300" />
<h2 class="mt-4 text-xl font-black">Chưa phim yêu thích</h2> <h2 class="mt-4 text-xl font-black">Chưa phim yêu thích</h2>
<p class="mt-2 max-w-md text-sm leading-6 text-slate-300"> <p class="mt-2 max-w-md text-sm leading-6 text-slate-300">
Bấm nút Yêu thích trang phim để lưu phim vào đây. Bấm nút Yêu thích trang phim để lưu phim vào đây.
</p> </p>
<NuxtLink to="/phim" <NuxtLink to="/phim"
class="mt-5 inline-flex h-11 items-center justify-center rounded-md bg-sky-300 px-5 text-sm font-black text-slate-950 transition hover:bg-white"> class="mt-5 inline-flex h-11 items-center justify-center rounded-md bg-emerald-300 px-5 text-sm font-black text-slate-950 transition hover:bg-white">
Duyệt phim Duyệt phim
</NuxtLink> </NuxtLink>
</div> </div>
<div v-else class="rounded-lg border border-white/10 bg-white/6 p-6"> <div v-else class="rounded-lg border border-white/10 bg-white/6 p-6">
<UserRound class="size-10 text-sky-300" /> <UserRound class="size-10 text-emerald-300" />
<h2 class="mt-4 text-xl font-black">Bạn chưa đăng nhập</h2> <h2 class="mt-4 text-xl font-black">Bạn chưa đăng nhập</h2>
<p class="mt-2 text-sm leading-6 text-slate-300"> <p class="mt-2 text-sm leading-6 text-slate-300">
Đăng nhập để đồng bộ danh sách yêu thích, hoặc vẫn thể lưu tạm trên thiết bị này. Đăng nhập để đồng bộ danh sách yêu thích, hoặc vẫn thể lưu tạm trên thiết bị này.
+4 -1
View File
@@ -4,6 +4,9 @@ export default defineEventHandler(async (event) => {
const query = getQuery(event) const query = getQuery(event)
const page = Math.max(Number(query.page || 1), 1) const page = Math.max(Number(query.page || 1), 1)
const keyword = typeof query.keyword === 'string' ? query.keyword.trim() : '' const keyword = typeof query.keyword === 'string' ? query.keyword.trim() : ''
const source = typeof query.source === 'string' && ['ophim', 'nguonc', 'kkphim'].includes(query.source)
? query.source as 'ophim' | 'nguonc' | 'kkphim'
: 'all'
return await getKoreanMovies(page, keyword) return await getKoreanMovies(page, keyword, source)
}) })
+19 -14
View File
@@ -1,4 +1,4 @@
import { getNguoncDetail, getOphimDetail } from '../../utils/movies' import { getKkphimDetail, getNguoncDetail, getOphimDetail } from '../../utils/movies'
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
const slug = getRouterParam(event, 'slug') const slug = getRouterParam(event, 'slug')
@@ -8,21 +8,26 @@ export default defineEventHandler(async (event) => {
throw createError({ statusCode: 400, statusMessage: 'Thiếu slug phim' }) throw createError({ statusCode: 400, statusMessage: 'Thiếu slug phim' })
} }
if (source === 'nguonc') { const detailBySource = {
ophim: getOphimDetail,
nguonc: getNguoncDetail,
kkphim: getKkphimDetail,
}
const requestedSource = typeof source === 'string' && source in detailBySource ? source as keyof typeof detailBySource : 'ophim'
const orderedSources = [
requestedSource,
...Object.keys(detailBySource).filter((item) => item !== requestedSource) as Array<keyof typeof detailBySource>,
]
let lastError: unknown
for (const sourceName of orderedSources) {
try { try {
return await getNguoncDetail(slug) return await detailBySource[sourceName](slug)
} catch { } catch (error) {
return await getOphimDetail(slug) lastError = error
} }
} }
try { throw lastError
return await getOphimDetail(slug)
} catch (error) {
try {
return await getNguoncDetail(slug)
} catch {
throw error
}
}
}) })
+132 -15
View File
@@ -1,4 +1,5 @@
type Source = 'ophim' | 'nguonc' type Source = 'ophim' | 'nguonc' | 'kkphim'
type SourceFilter = Source | 'all'
export interface NormalizedMovie { export interface NormalizedMovie {
id: string id: string
@@ -49,6 +50,9 @@ export interface MovieDetail extends NormalizedMovie {
const OPHIM_BASE = 'https://ophim1.com' const OPHIM_BASE = 'https://ophim1.com'
const OPHIM_IMAGE = 'https://img.ophim.live/uploads/movies/' const OPHIM_IMAGE = 'https://img.ophim.live/uploads/movies/'
const NGUONC_BASE = 'https://phim.nguonc.com' const NGUONC_BASE = 'https://phim.nguonc.com'
const KKPHIM_BASE = 'https://phimapi.com'
const KKPHIM_IMAGE = 'https://phimimg.com/'
const SOURCE_NAMES: Source[] = ['ophim', 'nguonc', 'kkphim']
function toArray<T>(value: T[] | T | undefined | null): T[] { function toArray<T>(value: T[] | T | undefined | null): T[] {
if (!value) return [] if (!value) return []
@@ -86,6 +90,18 @@ function joinOphimImage(base: string, path?: string) {
return url.toString() return url.toString()
} }
function joinKkphimImage(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(/\/$/, '')
url.pathname = basePath && basePath !== '/' ? `${basePath}/${cleanPath}` : cleanPath
return url.toString()
}
function includesKorea(movie: any) { function includesKorea(movie: any) {
const countries = toArray(movie?.country ?? movie?.countries ?? movie?.quoc_gia) const countries = toArray(movie?.country ?? movie?.countries ?? movie?.quoc_gia)
if (!countries.length) return true if (!countries.length) return true
@@ -104,6 +120,35 @@ function stripHtml(value?: string) {
.trim() .trim()
} }
function isSource(value: unknown): value is Source {
return typeof value === 'string' && SOURCE_NAMES.includes(value as Source)
}
function interleaveMovies(groups: NormalizedMovie[][]) {
const maxLength = Math.max(0, ...groups.map((items) => items.length))
const interleaved: NormalizedMovie[] = []
for (let index = 0; index < maxLength; index += 1) {
for (const items of groups) {
const movie = items[index]
if (movie) interleaved.push(movie)
}
}
return interleaved
}
function uniqueMovies(items: NormalizedMovie[]) {
const seen = new Set<string>()
return items.filter((movie) => {
const key = `${movie.source}:${movie.slug || movie.name}`
if (seen.has(key)) return false
seen.add(key)
return true
})
}
function normalizeActor(actor: any): NormalizedActor | undefined { function normalizeActor(actor: any): NormalizedActor | undefined {
if (typeof actor === 'string') { if (typeof actor === 'string') {
const name = actor.trim() const name = actor.trim()
@@ -142,6 +187,27 @@ export function normalizeOphimMovie(movie: any, pathImage = OPHIM_IMAGE): Normal
} }
} }
export function normalizeKkphimMovie(movie: any, pathImage = KKPHIM_IMAGE): NormalizedMovie {
return {
id: `kkphim:${movie?._id ?? movie?.slug}`,
source: 'kkphim',
name: text(movie?.name, 'Chưa có tên'),
originName: text(movie?.origin_name),
slug: text(movie?.slug),
thumb: joinKkphimImage(pathImage, movie?.thumb_url),
poster: joinKkphimImage(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 { function normalizeNguoncMovie(movie: any): NormalizedMovie {
const image = movie?.thumb_url || movie?.poster_url || movie?.image || movie?.thumbnail const image = movie?.thumb_url || movie?.poster_url || movie?.image || movie?.thumbnail
@@ -225,20 +291,45 @@ export async function getNguoncKoreanMovies(page: number, keyword = '') {
} }
} }
export async function getKoreanMovies(page: number, keyword = '') { export async function getKkphimKoreanMovies(page: number, keyword = '') {
const results = await Promise.allSettled([ const url = keyword
getOphimKoreanMovies(page, keyword), ? new URL('/v1/api/tim-kiem', KKPHIM_BASE)
getNguoncKoreanMovies(page, keyword), : new URL('/v1/api/quoc-gia/han-quoc', KKPHIM_BASE)
])
const items = results.flatMap((result) => result.status === 'fulfilled' ? result.value.items : []) url.searchParams.set('page', String(page))
const seen = new Set<string>() if (keyword) {
const unique = items.filter((movie) => { url.searchParams.set('keyword', keyword)
const key = movie.slug || `${movie.source}:${movie.name}` url.searchParams.set('country', 'han-quoc')
if (seen.has(key)) return false }
seen.add(key)
return true const json: any = await requestJson(url.toString())
}) const data = json?.data ?? json
const imageBase = data?.APP_DOMAIN_CDN_IMAGE || data?.pathImage || KKPHIM_IMAGE
const items = toArray(data?.items ?? json?.items)
.filter(includesKorea)
.map((item) => normalizeKkphimMovie(item, imageBase))
.filter((movie) => movie.slug)
return {
items,
pagination: data?.params?.pagination ?? data?.pagination ?? json?.pagination ?? {},
}
}
export async function getKoreanMovies(page: number, keyword = '', source: SourceFilter = 'all') {
const fetchers = {
ophim: getOphimKoreanMovies,
nguonc: getNguoncKoreanMovies,
kkphim: getKkphimKoreanMovies,
}
const selectedSources = isSource(source) ? [source] : SOURCE_NAMES
const results = await Promise.allSettled(
selectedSources.map((sourceName) => fetchers[sourceName](page, keyword)),
)
const resultGroups = results.map((result) => result.status === 'fulfilled' ? result.value.items : [])
const items = source === 'all' ? interleaveMovies(resultGroups) : resultGroups.flat()
const unique = uniqueMovies(items)
const pagination = results.find((result) => result.status === 'fulfilled')?.value.pagination ?? {} const pagination = results.find((result) => result.status === 'fulfilled')?.value.pagination ?? {}
@@ -247,9 +338,10 @@ export async function getKoreanMovies(page: number, keyword = '') {
page, page,
pagination, pagination,
sources: results.map((result, index) => ({ sources: results.map((result, index) => ({
name: index === 0 ? 'ophim' : 'nguonc', name: selectedSources[index],
ok: result.status === 'fulfilled', ok: result.status === 'fulfilled',
})), })),
source,
} }
} }
@@ -300,3 +392,28 @@ export async function getNguoncDetail(slug: string): Promise<MovieDetail> {
})), })),
} }
} }
export async function getKkphimDetail(slug: string): Promise<MovieDetail> {
const json: any = await requestJson(`${KKPHIM_BASE}/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 || KKPHIM_IMAGE
const normalized = normalizeKkphimMovie(movie, imageBase)
return {
...normalized,
content: stripHtml(movie?.content),
actors: toArray(movie?.actor).map(normalizeActor).filter(Boolean) as NormalizedActor[],
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),
})),
})),
}
}