mirror of
https://github.com/ngthanhvu/kr-phim.git
synced 2026-08-10 12:27:47 +00:00
feat: Update nhiều thứ
This commit is contained in:
@@ -21,7 +21,7 @@ const linkGroups = [
|
||||
|
||||
<template>
|
||||
<footer class="border-t border-white/10 bg-[#07090f] text-white">
|
||||
<div class="mx-auto max-w-[1560px] px-4 py-10 sm:px-6 lg:px-8 xl:px-10">
|
||||
<div class="mx-auto max-w-390 px-4 py-10 sm:px-6 lg:px-8 xl:px-10">
|
||||
<div class="flex flex-col gap-8 lg:flex-row lg:items-center">
|
||||
<div class="flex shrink-0 items-center gap-7">
|
||||
<AppLogo />
|
||||
@@ -29,31 +29,24 @@ const linkGroups = [
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<button
|
||||
v-for="item in socialLinks"
|
||||
:key="item.label"
|
||||
type="button"
|
||||
<button v-for="item in socialLinks" :key="item.label" type="button"
|
||||
class="grid size-12 place-items-center rounded-full bg-yellow-400 text-slate-950 shadow-lg shadow-yellow-950/30 transition hover:-translate-y-0.5 hover:bg-white"
|
||||
:aria-label="item.label"
|
||||
>
|
||||
:aria-label="item.label">
|
||||
<AppIcon :name="item.icon" class="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-10 flex flex-wrap gap-x-8 gap-y-5 text-sm font-bold">
|
||||
<NuxtLink
|
||||
v-for="[label, path] in linkGroups"
|
||||
:key="label"
|
||||
:to="path"
|
||||
class="text-slate-100 transition hover:text-yellow-200"
|
||||
>
|
||||
<NuxtLink v-for="[label, path] in linkGroups" :key="label" :to="path"
|
||||
class="text-slate-100 transition hover:text-yellow-200">
|
||||
{{ label }}
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<p class="mt-10 max-w-4xl text-sm leading-7 text-slate-300">
|
||||
CineK - Trang xem phim Hàn Quốc online chất lượng cao với Vietsub, thuyết minh, lồng tiếng và trải nghiệm xem mượt trên mọi thiết bị. Kho phim bộ, phim lẻ và nội dung mới được cập nhật thường xuyên.
|
||||
CineK - Trang xem phim Hàn Quốc online chất lượng cao với Vietsub, thuyết minh, lồng tiếng và trải nghiệm xem
|
||||
mượt trên mọi thiết bị. Kho phim bộ, phim lẻ và nội dung mới được cập nhật thường xuyên.
|
||||
</p>
|
||||
|
||||
<p class="mt-7 text-sm text-slate-400">
|
||||
|
||||
@@ -99,7 +99,7 @@ watch(() => route.path, () => {
|
||||
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-yellow-950/20 sm:max-w-sm"
|
||||
@submit.prevent="submitSearch">
|
||||
<AppIcon name="search" class="mr-3 size-4 shrink-0 text-yellow-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..."
|
||||
class="w-full bg-transparent text-sm text-white outline-none placeholder:text-slate-400">
|
||||
</form>
|
||||
|
||||
@@ -108,7 +108,8 @@ watch(() => route.path, () => {
|
||||
class="inline-flex h-10 shrink-0 cursor-pointer items-center gap-2 rounded-full bg-[#F2F4F7] px-4 text-sm font-semibold text-[#1c1c1c] shadow-sm transition hover:bg-white hover:shadow-md"
|
||||
aria-label="Tài khoản thành viên" @click="handleMemberClick">
|
||||
<template v-if="user">
|
||||
<img v-if="avatarUrl" :src="avatarUrl" :alt="displayName" class="size-7 rounded-full object-cover ring-2 ring-[#FFD166]/20">
|
||||
<img v-if="avatarUrl" :src="avatarUrl" :alt="displayName"
|
||||
class="size-7 rounded-full object-cover ring-2 ring-[#FFD166]/20">
|
||||
<span v-else
|
||||
class="grid size-7 place-items-center rounded-full bg-[#FFD166]/20 text-xs font-bold text-[#FFD166]">
|
||||
{{ displayInitial }}
|
||||
@@ -117,7 +118,8 @@ watch(() => route.path, () => {
|
||||
</template>
|
||||
<template v-else>
|
||||
<svg class="size-5 shrink-0" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z" />
|
||||
<path
|
||||
d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z" />
|
||||
</svg>
|
||||
<span>Thành viên</span>
|
||||
</template>
|
||||
@@ -125,10 +127,11 @@ watch(() => route.path, () => {
|
||||
|
||||
<Transition name="member-menu">
|
||||
<div v-if="user && memberMenuOpen"
|
||||
class="absolute right-0 top-[calc(100%+0.5rem)] w-56 overflow-hidden rounded-2xl border border-white/[0.08] bg-[#12121a]/95 py-2 text-sm shadow-[0_20px_50px_rgba(0,0,0,0.5)] backdrop-blur-xl">
|
||||
<div class="border-b border-white/[0.06] px-4 py-3">
|
||||
class="absolute right-0 top-[calc(100%+0.5rem)] w-56 overflow-hidden rounded-2xl border border-white/8 bg-[#12121a]/95 py-2 text-sm shadow-[0_20px_50px_rgba(0,0,0,0.5)] backdrop-blur-xl">
|
||||
<div class="border-b border-white/6 px-4 py-3">
|
||||
<div class="flex items-center gap-2.5">
|
||||
<img v-if="avatarUrl" :src="avatarUrl" :alt="displayName" class="size-8 rounded-full object-cover ring-2 ring-[#FFD166]/20">
|
||||
<img v-if="avatarUrl" :src="avatarUrl" :alt="displayName"
|
||||
class="size-8 rounded-full object-cover ring-2 ring-[#FFD166]/20">
|
||||
<div v-else class="grid size-8 shrink-0 place-items-center rounded-full bg-[#FFD166]/10">
|
||||
<span class="text-xs font-bold text-[#FFD166]">{{ displayInitial }}</span>
|
||||
</div>
|
||||
@@ -139,11 +142,11 @@ watch(() => route.path, () => {
|
||||
</div>
|
||||
</div>
|
||||
<NuxtLink v-for="item in memberMenuItems" :key="item.label" :to="item.to"
|
||||
class="flex h-10 w-full cursor-pointer items-center gap-2.5 px-4 transition hover:bg-white/[0.04] hover:text-white">
|
||||
class="flex h-10 w-full cursor-pointer items-center gap-2.5 px-4 transition hover:bg-white/4 hover:text-white">
|
||||
<AppIcon :name="item.icon" class="size-4 shrink-0 text-white/50" />
|
||||
<span class="text-[13px] font-medium text-white/70">{{ item.label }}</span>
|
||||
</NuxtLink>
|
||||
<div class="my-1.5 border-t border-white/[0.06]" />
|
||||
<div class="my-1.5 border-t border-white/6" />
|
||||
<button type="button"
|
||||
class="flex h-10 w-full cursor-pointer items-center gap-2.5 px-4 text-[13px] font-medium text-red-400/70 transition hover:bg-red-400/8 hover:text-red-400"
|
||||
@click="handleLogout">
|
||||
@@ -176,7 +179,8 @@ watch(() => route.path, () => {
|
||||
</div>
|
||||
|
||||
<div v-if="user" class="flex items-center gap-3 border-b border-white/10 px-4 py-4">
|
||||
<img v-if="avatarUrl" :src="avatarUrl" :alt="displayName" class="size-9 rounded-full object-cover ring-2 ring-[#FFD166]/20">
|
||||
<img v-if="avatarUrl" :src="avatarUrl" :alt="displayName"
|
||||
class="size-9 rounded-full object-cover ring-2 ring-[#FFD166]/20">
|
||||
<span v-else
|
||||
class="grid size-9 place-items-center rounded-full bg-yellow-400/10 text-sm font-black text-yellow-400">
|
||||
{{ displayInitial }}
|
||||
|
||||
@@ -19,20 +19,9 @@ const props = defineProps<{
|
||||
}
|
||||
}>()
|
||||
|
||||
const episodeDisplay = computed(() => {
|
||||
const ep = props.movie.episode
|
||||
const total = props.movie.episodeTotal
|
||||
if (!ep) return undefined
|
||||
|
||||
const totalNum = total ? total.replace(/[^0-9]/g, '') : ''
|
||||
const epNum = ep.replace(/[^0-9]/g, '')
|
||||
|
||||
if (epNum && totalNum && epNum !== totalNum) {
|
||||
return `Tập ${epNum}/${totalNum}`
|
||||
}
|
||||
|
||||
return ep
|
||||
})
|
||||
const episodeDisplay = computed(() =>
|
||||
getEpisodeDisplay(props.movie.episode, props.movie.episodeTotal),
|
||||
)
|
||||
|
||||
const isPreviewVisible = ref(false)
|
||||
const isDescriptionExpanded = ref(false)
|
||||
|
||||
@@ -115,7 +115,7 @@ const apiActors = computed(() => {
|
||||
})
|
||||
|
||||
function addServer() {
|
||||
customServers.value.push({ name: '', episodes: [] })
|
||||
customServers.value.push({ name: `Server ${customServers.value.length + 1}`, episodes: [] })
|
||||
}
|
||||
|
||||
function removeServer(serverIndex: number) {
|
||||
|
||||
@@ -91,6 +91,53 @@ async function handleDeleteAll() {
|
||||
|
||||
const movies = computed(() => data.value?.items || [])
|
||||
const totalPages = computed(() => data.value?.totalPages || 1)
|
||||
|
||||
function extractEpisodeNumber(value?: string): number {
|
||||
if (!value) return 0
|
||||
const match = value.match(/(\d+)(?:\/\d+)?\s*$/)
|
||||
if (match) return Number(match[1]) || 0
|
||||
const anyNumber = value.match(/\d+/)
|
||||
return anyNumber ? Number(anyNumber[0]) : 0
|
||||
}
|
||||
|
||||
function formatEpisode(movie: any) {
|
||||
const current = extractEpisodeNumber(movie.episode)
|
||||
const total = extractEpisodeNumber(movie.episodeTotal)
|
||||
if (current > 0 && total > 0) {
|
||||
return `${current}/${total}`
|
||||
}
|
||||
if (current > 0) return String(current)
|
||||
if (movie.episode) return movie.episode
|
||||
return '-'
|
||||
}
|
||||
|
||||
function formatDate(value?: string | number | Date | null) {
|
||||
if (!value) return ''
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return ''
|
||||
return date.toLocaleString('vi-VN')
|
||||
}
|
||||
|
||||
function formatRelativeTime(dateValue?: string | number | Date | null) {
|
||||
if (!dateValue) return 'Không rõ'
|
||||
const date = new Date(dateValue)
|
||||
if (Number.isNaN(date.getTime())) return 'Không rõ'
|
||||
const now = new Date()
|
||||
const seconds = Math.floor((now.getTime() - date.getTime()) / 1000)
|
||||
const units = [
|
||||
{ label: 'năm', seconds: 365 * 24 * 60 * 60 },
|
||||
{ label: 'tháng', seconds: 30 * 24 * 60 * 60 },
|
||||
{ label: 'tuần', seconds: 7 * 24 * 60 * 60 },
|
||||
{ label: 'ngày', seconds: 24 * 60 * 60 },
|
||||
{ label: 'giờ', seconds: 60 * 60 },
|
||||
{ label: 'phút', seconds: 60 },
|
||||
]
|
||||
for (const unit of units) {
|
||||
const value = Math.floor(seconds / unit.seconds)
|
||||
if (value > 0) return `${value} ${unit.label} trước`
|
||||
}
|
||||
return 'Vừa xong'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -153,6 +200,7 @@ const totalPages = computed(() => data.value?.totalPages || 1)
|
||||
<th class="px-4 py-3 text-center">Nguồn trùng</th>
|
||||
<th class="px-4 py-3 text-center">Tập</th>
|
||||
<th class="px-4 py-3 text-center">Lượt xem</th>
|
||||
<th class="px-4 py-3 text-center">Cập nhật API</th>
|
||||
<th class="px-4 py-3 text-center">Trạng thái</th>
|
||||
<th class="px-4 py-3 text-center">Chỉnh sửa</th>
|
||||
<th class="px-4 py-3 text-center">Thao tác</th>
|
||||
@@ -175,14 +223,20 @@ const totalPages = computed(() => data.value?.totalPages || 1)
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-center text-sm text-slate-300">
|
||||
<template v-if="movie.episode && movie.episodeTotal && !movie.episode.includes('/')">
|
||||
<span class="font-semibold text-yellow-300">Tập {{ movie.episode.replace(/[^0-9]/g, '') }}/{{ movie.episodeTotal.replace(/[^0-9]/g, '') }}</span>
|
||||
</template>
|
||||
<span v-else>{{ movie.episode || '-' }}</span>
|
||||
<span
|
||||
class="font-semibold text-yellow-300"
|
||||
:title="(movie.sources || []).map((s: any) => `${s.source.toUpperCase()}: ${s.episode || s.episodeTotal || '?'}`).join('\n')">
|
||||
{{ formatEpisode(movie) }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-center text-sm text-slate-300">
|
||||
{{ (movie.views || 0).toLocaleString() }}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-center text-sm text-slate-300">
|
||||
<span :title="formatDate(movie.apiUpdatedAt || movie.syncedAt)">
|
||||
{{ formatRelativeTime(movie.apiUpdatedAt || movie.syncedAt) }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-center">
|
||||
<span class="rounded-full px-2.5 py-1 text-xs font-semibold"
|
||||
:class="movie.active ? 'bg-green-400/10 text-green-400' : 'bg-slate-400/10 text-slate-400'">
|
||||
@@ -218,7 +272,7 @@ const totalPages = computed(() => data.value?.totalPages || 1)
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!movies.length">
|
||||
<td colspan="8" class="px-4 py-12 text-center text-sm text-slate-400">
|
||||
<td colspan="9" class="px-4 py-12 text-center text-sm text-slate-400">
|
||||
{{ debouncedKeyword ? 'Không tìm thấy phim nào.' : 'Chưa có phim nào. Bấm "Đồng bộ từ API" để lấy phim.' }}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
+2
-16
@@ -149,20 +149,6 @@ function movieLink(movie: any) {
|
||||
}
|
||||
}
|
||||
|
||||
function getEpisodeDisplay(movie: any) {
|
||||
const ep = movie?.episode
|
||||
const total = movie?.episodeTotal
|
||||
if (!ep) return undefined
|
||||
|
||||
const totalNum = total ? total.replace(/[^0-9]/g, '') : ''
|
||||
const epNum = ep.replace(/[^0-9]/g, '')
|
||||
|
||||
if (epNum && totalNum && epNum !== totalNum) {
|
||||
return `Tập ${epNum}/${totalNum}`
|
||||
}
|
||||
|
||||
return ep
|
||||
}
|
||||
|
||||
function goToSlide(index: number) {
|
||||
heroSliderRef.value?.goTo(index)
|
||||
@@ -335,8 +321,8 @@ useHead({
|
||||
style="background-color:#ffd875;background-image:linear-gradient(220deg, #ffd875 0%, #ffe7a8 45%, #ffffff 100%)">
|
||||
{{ hero.quality }}
|
||||
</span>
|
||||
<span v-if="getEpisodeDisplay(hero)" class="px-2 py-0.75 rounded border border-white bg-black/40">
|
||||
{{ getEpisodeDisplay(hero) }}
|
||||
<span v-if="getEpisodeDisplay(hero.episode, hero.episodeTotal)" class="px-2 py-0.75 rounded border border-white bg-black/40">
|
||||
{{ getEpisodeDisplay(hero.episode, hero.episodeTotal) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -60,19 +60,8 @@ function formatWeekday(dateKey: string) {
|
||||
return weekdays[date.getDay()] || ''
|
||||
}
|
||||
|
||||
function getEpisodeDisplay(movie: any) {
|
||||
const ep = movie?.episode
|
||||
const total = movie?.episodeTotal
|
||||
if (!ep) return 'Mới cập nhật'
|
||||
|
||||
const totalNum = total ? total.replace(/[^0-9]/g, '') : ''
|
||||
const epNum = ep.replace(/[^0-9]/g, '')
|
||||
|
||||
if (epNum && totalNum && epNum !== totalNum) {
|
||||
return `Tập ${epNum}/${totalNum}`
|
||||
}
|
||||
|
||||
return ep
|
||||
function movieEpisodeDisplay(movie: any) {
|
||||
return getEpisodeDisplay(movie?.episode, movie?.episodeTotal) ?? 'Mới cập nhật'
|
||||
}
|
||||
|
||||
// Build map ngày -> phim
|
||||
@@ -210,7 +199,7 @@ useHead({
|
||||
{{ movie.name }}
|
||||
</h2>
|
||||
<p class="schedule-card-episode">
|
||||
{{ getEpisodeDisplay(movie) }}
|
||||
{{ movieEpisodeDisplay(movie) }}
|
||||
</p>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
|
||||
+10
-18
@@ -68,17 +68,9 @@ function episodeLink(index: number) {
|
||||
}
|
||||
}
|
||||
|
||||
function getEpisodeDisplay() {
|
||||
const ep = movie.value?.episode
|
||||
const total = movie.value?.episodeTotal
|
||||
if (!ep) return undefined
|
||||
const totalNum = total ? total.replace(/[^0-9]/g, '') : ''
|
||||
const epNum = ep.replace(/[^0-9]/g, '')
|
||||
if (epNum && totalNum && epNum !== totalNum) {
|
||||
return `Tập ${epNum}/${totalNum}`
|
||||
}
|
||||
return ep
|
||||
}
|
||||
const episodeDisplay = computed(() =>
|
||||
getEpisodeDisplay(movie.value?.episode, movie.value?.episodeTotal),
|
||||
)
|
||||
|
||||
function serverLabel(server: any, index: number) {
|
||||
const label = String(server?.name || '').replace(/^(ophim|nguonc|kkphim)\s*-\s*/i, '').trim()
|
||||
@@ -254,9 +246,9 @@ useHead(() => ({
|
||||
class="px-2 py-0.75 rounded border border-white bg-black/20 backdrop-blur-sm font-medium">
|
||||
{{ movie.lang }}
|
||||
</span>
|
||||
<span v-if="getEpisodeDisplay()"
|
||||
<span v-if="episodeDisplay"
|
||||
class="px-2 py-0.75 rounded border border-white bg-black/20 backdrop-blur-sm font-medium">
|
||||
{{ getEpisodeDisplay() }}
|
||||
{{ episodeDisplay }}
|
||||
</span>
|
||||
<span v-if="movie.rating"
|
||||
class="inline-flex items-center rounded overflow-hidden border border-solid border-[rgba(1,180,228,0.5)]">
|
||||
@@ -331,7 +323,7 @@ useHead(() => ({
|
||||
<div class="hidden md:grid grid-cols-1 sm:grid-cols-2 gap-2 text-sm mb-5">
|
||||
<div class="flex items-start gap-2">
|
||||
<span class="text-slate-500 shrink-0 w-20">Trạng thái:</span>
|
||||
<span class="text-slate-200">{{ getEpisodeDisplay() || movie.episode || 'Đang cập nhật' }}</span>
|
||||
<span class="text-slate-200">{{ episodeDisplay || movie.episode || 'Đang cập nhật' }}</span>
|
||||
</div>
|
||||
<div class="flex items-start gap-2">
|
||||
<span class="text-slate-500 shrink-0 w-20">Loại:</span>
|
||||
@@ -375,9 +367,9 @@ useHead(() => ({
|
||||
class="px-2 py-0.75 rounded border border-white bg-black/20 backdrop-blur-sm font-medium">
|
||||
{{ movie.lang }}
|
||||
</span>
|
||||
<span v-if="getEpisodeDisplay()"
|
||||
<span v-if="episodeDisplay"
|
||||
class="px-2 py-0.75 rounded border border-white bg-black/20 backdrop-blur-sm font-medium">
|
||||
{{ getEpisodeDisplay() }}
|
||||
{{ episodeDisplay }}
|
||||
</span>
|
||||
<span v-if="movie.rating"
|
||||
class="inline-flex items-center rounded overflow-hidden border border-solid border-[rgba(1,180,228,0.5)]">
|
||||
@@ -387,7 +379,7 @@ useHead(() => ({
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2"><span class="text-slate-500 shrink-0 w-20">Trạng thái:</span><span>{{
|
||||
getEpisodeDisplay() || movie.episode || 'Đang cập nhật' }}</span></div>
|
||||
episodeDisplay || movie.episode || 'Đang cập nhật' }}</span></div>
|
||||
<div class="flex gap-2"><span class="text-slate-500 shrink-0 w-20">Loại:</span><span>{{ movie.type ===
|
||||
'single' ? 'Phim lẻ' : movie.type === 'series' ? 'Phim bộ' : movie.type || 'Đang cập nhật' }}</span>
|
||||
</div>
|
||||
@@ -440,7 +432,7 @@ useHead(() => ({
|
||||
<AppIcon name="server" class="size-4" />
|
||||
Máy chủ:
|
||||
</div>
|
||||
<button v-for="(server, index) in servers" :key="server.name" type="button"
|
||||
<button v-for="(server, index) in servers" :key="index" type="button"
|
||||
class="group flex items-center gap-1.5 text-[13px] transition-colors rounded-md px-3.5 py-2 border font-medium"
|
||||
:class="selectedServer === index
|
||||
? 'border-white/30 text-white bg-white/5'
|
||||
|
||||
@@ -70,20 +70,6 @@ function movieLink(movie: any) {
|
||||
}
|
||||
}
|
||||
|
||||
function getEpisodeDisplay(movie: any) {
|
||||
const ep = movie.episode
|
||||
const total = movie.episodeTotal
|
||||
if (!ep) return undefined
|
||||
|
||||
const totalNum = total ? total.replace(/[^0-9]/g, '') : ''
|
||||
const epNum = ep.replace(/[^0-9]/g, '')
|
||||
|
||||
if (epNum && totalNum && epNum !== totalNum) {
|
||||
return `Tập ${epNum}/${totalNum}`
|
||||
}
|
||||
|
||||
return ep
|
||||
}
|
||||
|
||||
function nextPage() {
|
||||
page.value += 1
|
||||
@@ -135,7 +121,7 @@ useHead({
|
||||
<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-yellow-950/20">
|
||||
<AppIcon name="search" class="mr-3 size-4 shrink-0 text-yellow-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..."
|
||||
class="w-full bg-transparent text-sm text-white outline-none placeholder:text-slate-400">
|
||||
</div>
|
||||
|
||||
@@ -181,15 +167,15 @@ useHead({
|
||||
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">
|
||||
<p class="line-clamp-2 text-sm font-bold leading-snug text-white">{{ movie.name }}</p>
|
||||
<p class="mt-1 truncate text-xs text-yellow-100">{{ getEpisodeDisplay(movie) || movie.year || movie.quality }}</p>
|
||||
<p class="mt-1 truncate text-xs text-yellow-100">{{ getEpisodeDisplay(movie.episode, movie.episodeTotal) || movie.year || movie.quality }}</p>
|
||||
</div>
|
||||
<span v-if="movie.sources?.length > 1"
|
||||
class="absolute left-2 top-2 rounded bg-yellow-400 px-2 py-1 text-xs font-black text-slate-950">
|
||||
{{ movie.sources.length }} server
|
||||
</span>
|
||||
<span v-if="getEpisodeDisplay(movie)"
|
||||
<span v-if="getEpisodeDisplay(movie.episode, movie.episodeTotal)"
|
||||
class="absolute right-2 top-2 rounded bg-red-600/90 px-2 py-1 text-xs font-black text-white backdrop-blur-sm">
|
||||
{{ getEpisodeDisplay(movie) }}
|
||||
{{ getEpisodeDisplay(movie.episode, movie.episodeTotal) }}
|
||||
</span>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
|
||||
@@ -374,13 +374,9 @@ async function shareMovie() {
|
||||
catch { flashActionMessage('Chưa chia sẻ được.') }
|
||||
}
|
||||
|
||||
function getEpisodeDisplay() {
|
||||
const ep = movie.value?.episode, total = movie.value?.episodeTotal
|
||||
if (!ep) return undefined
|
||||
const totalNum = total ? total.replace(/[^0-9]/g, '') : '', epNum = ep.replace(/[^0-9]/g, '')
|
||||
if (epNum && totalNum && epNum !== totalNum) return `Tập ${epNum}/${totalNum}`
|
||||
return ep
|
||||
}
|
||||
const episodeDisplay = computed(() =>
|
||||
getEpisodeDisplay(movie.value?.episode, movie.value?.episodeTotal),
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
await refreshFavoriteState()
|
||||
@@ -635,9 +631,9 @@ useHead(() => ({
|
||||
<span v-if="movie.year"
|
||||
class="text-[11px] font-medium px-2 py-0.5 border border-white/20 rounded text-white/90">{{
|
||||
movie.year }}</span>
|
||||
<span v-if="getEpisodeDisplay()"
|
||||
<span v-if="episodeDisplay"
|
||||
class="text-[11px] font-medium px-2 py-0.5 border border-white/20 rounded text-white/90">{{
|
||||
getEpisodeDisplay() }}</span>
|
||||
episodeDisplay }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Genres -->
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
function extractEpisodeNumber(value?: string): number {
|
||||
if (!value) return 0
|
||||
const match = value.match(/(\d+)(?:\/\d+)?\s*$/)
|
||||
if (!match) {
|
||||
const anyNumber = value.match(/\d+/)
|
||||
return anyNumber ? Number(anyNumber[0]) : 0
|
||||
}
|
||||
return Number(match[1])
|
||||
}
|
||||
|
||||
export function getEpisodeDisplay(
|
||||
episode?: string,
|
||||
episodeTotal?: string,
|
||||
): string | undefined {
|
||||
if (!episode) return undefined
|
||||
|
||||
const totalNum = extractEpisodeNumber(episodeTotal)
|
||||
const epNum = extractEpisodeNumber(episode)
|
||||
|
||||
if (totalNum > 0 && epNum > 0 && epNum !== totalNum) {
|
||||
return `Tập ${epNum}/${totalNum}`
|
||||
}
|
||||
|
||||
return episode
|
||||
}
|
||||
@@ -45,13 +45,11 @@ export default defineEventHandler(async (event) => {
|
||||
|
||||
if ('customServers' in body) {
|
||||
updates.customServers = Array.isArray(body.customServers) && body.customServers.length
|
||||
? body.customServers
|
||||
.filter((server: any) => server.name?.trim())
|
||||
.map((server: any) => ({
|
||||
name: server.name.trim(),
|
||||
? body.customServers.map((server: any) => ({
|
||||
name: server.name?.trim() || `Server ${body.customServers.indexOf(server) + 1}`,
|
||||
episodes: Array.isArray(server.episodes)
|
||||
? server.episodes.filter((ep: any) => ep.name?.trim()).map((ep: any) => ({
|
||||
name: ep.name.trim(),
|
||||
? server.episodes.filter((ep: any) => ep.linkEmbed?.trim() || ep.linkM3u8?.trim() || ep.name?.trim()).map((ep: any) => ({
|
||||
name: ep.name?.trim() || '',
|
||||
linkEmbed: ep.linkEmbed?.trim() || null,
|
||||
linkM3u8: ep.linkM3u8?.trim() || null,
|
||||
}))
|
||||
|
||||
@@ -3,10 +3,13 @@ import { eq, and } from 'drizzle-orm'
|
||||
import type { MovieDetail, NormalizedServer } from '../../utils/movies'
|
||||
|
||||
function mapCustomServers(movie: any): NormalizedServer[] {
|
||||
const servers = movie.customServers || movie.custom_servers
|
||||
if (!Array.isArray(servers) || !servers.length) return []
|
||||
let raw = movie.customServers ?? movie.custom_servers
|
||||
if (typeof raw === 'string') {
|
||||
try { raw = JSON.parse(raw) } catch { raw = null }
|
||||
}
|
||||
if (!Array.isArray(raw) || !raw.length) return []
|
||||
|
||||
return servers.map((server: any) => ({
|
||||
return raw.map((server: any) => ({
|
||||
name: String(server.name || 'Server'),
|
||||
source: movie.source,
|
||||
sourceSlug: movie.slug,
|
||||
@@ -14,7 +17,7 @@ function mapCustomServers(movie: any): NormalizedServer[] {
|
||||
name: String(ep.name || ''),
|
||||
linkEmbed: ep.linkEmbed || undefined,
|
||||
linkM3u8: ep.linkM3u8 || undefined,
|
||||
})).filter((ep: any) => ep.name || ep.linkEmbed || ep.linkM3u8),
|
||||
})).filter((ep: any) => ep.linkEmbed || ep.linkM3u8 || ep.name),
|
||||
})).filter((server: any) => server.episodes.length)
|
||||
}
|
||||
|
||||
|
||||
+30
-1
@@ -27,6 +27,8 @@ export interface MovieSourceRef {
|
||||
source: Source
|
||||
slug: string
|
||||
name?: string
|
||||
episode?: string
|
||||
episodeTotal?: string
|
||||
}
|
||||
|
||||
export interface NormalizedEpisode {
|
||||
@@ -179,6 +181,8 @@ function movieSourceRef(movie: NormalizedMovie): MovieSourceRef {
|
||||
source: movie.source,
|
||||
slug: movie.slug,
|
||||
name: movie.name,
|
||||
episode: movie.episode,
|
||||
episodeTotal: movie.episodeTotal,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -200,6 +204,16 @@ function interleaveMovies(groups: NormalizedMovie[][]) {
|
||||
return interleaved
|
||||
}
|
||||
|
||||
function extractEpisodeNumber(value?: string): number {
|
||||
if (!value) return 0
|
||||
const match = value.match(/(\d+)(?:\/\d+)?\s*$/)
|
||||
if (!match) {
|
||||
const anyNumber = value.match(/\d+/)
|
||||
return anyNumber ? Number(anyNumber[0]) : 0
|
||||
}
|
||||
return Number(match[1])
|
||||
}
|
||||
|
||||
export function groupMovies(items: NormalizedMovie[]) {
|
||||
const groups = new Map<string, NormalizedMovie>()
|
||||
const exactAliases = new Map<string, string>()
|
||||
@@ -243,7 +257,11 @@ export function groupMovies(items: NormalizedMovie[]) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (!existing.sources?.some((source) => source.source === movie.source && source.slug === movie.slug)) {
|
||||
const existingRef = existing.sources?.find((source) => source.source === movie.source && source.slug === movie.slug)
|
||||
if (existingRef) {
|
||||
existingRef.episode = movie.episode
|
||||
existingRef.episodeTotal = movie.episodeTotal
|
||||
} else {
|
||||
existing.sources = [...(existing.sources || []), movieSourceRef(movie)]
|
||||
}
|
||||
|
||||
@@ -257,6 +275,17 @@ export function groupMovies(items: NormalizedMovie[]) {
|
||||
? (new Date(existing.updatedAt).getTime() > new Date(movie.updatedAt).getTime() ? existing.updatedAt : movie.updatedAt)
|
||||
: existing.updatedAt || movie.updatedAt
|
||||
|
||||
const existingCurrent = extractEpisodeNumber(existing.episode)
|
||||
const newCurrent = extractEpisodeNumber(movie.episode)
|
||||
if (newCurrent > existingCurrent) {
|
||||
existing.episode = movie.episode
|
||||
}
|
||||
const existingTotal = extractEpisodeNumber(existing.episodeTotal)
|
||||
const newTotal = extractEpisodeNumber(movie.episodeTotal)
|
||||
if (newTotal > existingTotal) {
|
||||
existing.episodeTotal = movie.episodeTotal
|
||||
}
|
||||
|
||||
for (const alias of aliases) {
|
||||
if (movie.year) exactAliases.set(`${alias}:${movie.year}`, groupKey)
|
||||
rememberLooseAlias(alias, groupKey)
|
||||
|
||||
Reference in New Issue
Block a user