feat: Update nhiều thứ

This commit is contained in:
ngthanhvu
2026-07-28 21:55:39 -04:00
parent 72bc657d86
commit 1c92c0d50b
14 changed files with 173 additions and 129 deletions
+7 -14
View File
@@ -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 trải nghiệm xem mượt trên mọi thiết bị. Kho phim bộ, phim lẻ 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 trải nghiệm xem
mượt trên mọi thiết bị. Kho phim bộ, phim lẻ nội dung mới được cập nhật thường xuyên.
</p>
<p class="mt-7 text-sm text-slate-400">
+13 -9
View File
@@ -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 }}
+3 -14
View File
@@ -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)
+1 -1
View File
@@ -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) {
+59 -5
View File
@@ -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
View File
@@ -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>
+3 -14
View File
@@ -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
View File
@@ -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'
+4 -18
View File
@@ -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>
+5 -9
View File
@@ -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 -->
+25
View File
@@ -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
}