mirror of
https://github.com/ngthanhvu/kr-phim.git
synced 2026-08-10 14:27:47 +00:00
feat: implement sorting functionality for movie management and add sections API for recent comments and trending movies
This commit is contained in:
@@ -13,6 +13,8 @@ const statusFilter = ref('')
|
|||||||
const sourceFilter = ref('')
|
const sourceFilter = ref('')
|
||||||
const typeFilter = ref('')
|
const typeFilter = ref('')
|
||||||
const currentPage = ref(1)
|
const currentPage = ref(1)
|
||||||
|
const sortBy = ref('')
|
||||||
|
const sortOrder = ref<'asc' | 'desc'>('desc')
|
||||||
const syncing = ref(false)
|
const syncing = ref(false)
|
||||||
const syncOpen = ref(false)
|
const syncOpen = ref(false)
|
||||||
const deleting = ref(false)
|
const deleting = ref(false)
|
||||||
@@ -37,6 +39,8 @@ const { data, refresh } = await useFetch('/api/admin/movies', {
|
|||||||
status: statusFilter.value,
|
status: statusFilter.value,
|
||||||
source: sourceFilter.value,
|
source: sourceFilter.value,
|
||||||
type: typeFilter.value,
|
type: typeFilter.value,
|
||||||
|
sortBy: sortBy.value,
|
||||||
|
sortOrder: sortOrder.value,
|
||||||
limit: 20,
|
limit: 20,
|
||||||
})),
|
})),
|
||||||
})
|
})
|
||||||
@@ -45,6 +49,24 @@ watch([statusFilter, sourceFilter, typeFilter], () => {
|
|||||||
currentPage.value = 1
|
currentPage.value = 1
|
||||||
})
|
})
|
||||||
|
|
||||||
|
watch([sortBy, sortOrder], () => {
|
||||||
|
currentPage.value = 1
|
||||||
|
})
|
||||||
|
|
||||||
|
function getDefaultSortOrder(column: string): 'asc' | 'desc' {
|
||||||
|
if (column === 'name') return 'asc'
|
||||||
|
return 'desc'
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSort(column: string) {
|
||||||
|
if (sortBy.value === column) {
|
||||||
|
sortOrder.value = sortOrder.value === 'asc' ? 'desc' : 'asc'
|
||||||
|
} else {
|
||||||
|
sortBy.value = column
|
||||||
|
sortOrder.value = getDefaultSortOrder(column)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function toggleActive(movie: any) {
|
async function toggleActive(movie: any) {
|
||||||
await $fetch(`/api/admin/movies/${movie.id}`, {
|
await $fetch(`/api/admin/movies/${movie.id}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
@@ -92,6 +114,38 @@ async function handleDeleteAll() {
|
|||||||
const movies = computed(() => data.value?.items || [])
|
const movies = computed(() => data.value?.items || [])
|
||||||
const totalPages = computed(() => data.value?.totalPages || 1)
|
const totalPages = computed(() => data.value?.totalPages || 1)
|
||||||
|
|
||||||
|
const visiblePages = computed(() => {
|
||||||
|
const total = totalPages.value
|
||||||
|
const current = currentPage.value
|
||||||
|
const pages: (number | 'ellipsis')[] = []
|
||||||
|
|
||||||
|
if (total <= 7) {
|
||||||
|
for (let i = 1; i <= total; i++) pages.push(i)
|
||||||
|
return pages
|
||||||
|
}
|
||||||
|
|
||||||
|
pages.push(1)
|
||||||
|
|
||||||
|
if (current > 4) {
|
||||||
|
pages.push('ellipsis')
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = Math.max(2, Math.min(current - 1, total - 4))
|
||||||
|
const end = Math.min(total - 1, Math.max(current + 1, 5))
|
||||||
|
|
||||||
|
for (let i = start; i <= end; i++) {
|
||||||
|
pages.push(i)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (end < total - 1) {
|
||||||
|
pages.push('ellipsis')
|
||||||
|
}
|
||||||
|
|
||||||
|
pages.push(total)
|
||||||
|
|
||||||
|
return pages
|
||||||
|
})
|
||||||
|
|
||||||
function extractEpisodeNumber(value?: string): number {
|
function extractEpisodeNumber(value?: string): number {
|
||||||
if (!value) return 0
|
if (!value) return 0
|
||||||
const match = value.match(/(\d+)(?:\/\d+)?\s*$/)
|
const match = value.match(/(\d+)(?:\/\d+)?\s*$/)
|
||||||
@@ -196,12 +250,32 @@ function formatRelativeTime(dateValue?: string | number | Date | null) {
|
|||||||
<thead>
|
<thead>
|
||||||
<tr class="border-b border-white/10 text-center text-xs font-semibold uppercase text-slate-400">
|
<tr class="border-b border-white/10 text-center text-xs font-semibold uppercase text-slate-400">
|
||||||
<th class="w-12 px-4 py-3 text-center">STT</th>
|
<th class="w-12 px-4 py-3 text-center">STT</th>
|
||||||
<th class="px-4 py-3 text-center">Phim</th>
|
<th class="cursor-pointer select-none px-4 py-3 text-center transition hover:text-white" @click="toggleSort('name')">
|
||||||
|
<div class="flex items-center justify-center gap-1">
|
||||||
|
Phim
|
||||||
|
<AppIcon v-if="sortBy === 'name'" :name="sortOrder === 'asc' ? 'chevron-up' : 'chevron-down'" class="size-3" />
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
<th class="px-4 py-3 text-center">Nguồn trùng</th>
|
<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">Tập</th>
|
||||||
<th class="px-4 py-3 text-center">Lượt xem</th>
|
<th class="cursor-pointer select-none px-4 py-3 text-center transition hover:text-white" @click="toggleSort('views')">
|
||||||
<th class="px-4 py-3 text-center">Cập nhật API</th>
|
<div class="flex items-center justify-center gap-1">
|
||||||
<th class="px-4 py-3 text-center">Trạng thái</th>
|
Lượt xem
|
||||||
|
<AppIcon v-if="sortBy === 'views'" :name="sortOrder === 'asc' ? 'chevron-up' : 'chevron-down'" class="size-3" />
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
<th class="cursor-pointer select-none px-4 py-3 text-center transition hover:text-white" @click="toggleSort('apiUpdatedAt')">
|
||||||
|
<div class="flex items-center justify-center gap-1">
|
||||||
|
Cập nhật API
|
||||||
|
<AppIcon v-if="sortBy === 'apiUpdatedAt'" :name="sortOrder === 'asc' ? 'chevron-up' : 'chevron-down'" class="size-3" />
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
<th class="cursor-pointer select-none px-4 py-3 text-center transition hover:text-white" @click="toggleSort('active')">
|
||||||
|
<div class="flex items-center justify-center gap-1">
|
||||||
|
Trạng thái
|
||||||
|
<AppIcon v-if="sortBy === 'active'" :name="sortOrder === 'asc' ? 'chevron-up' : 'chevron-down'" class="size-3" />
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
<th class="px-4 py-3 text-center">Chỉnh sửa</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>
|
<th class="px-4 py-3 text-center">Thao tác</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -288,6 +362,20 @@ function formatRelativeTime(dateValue?: string | number | Date | null) {
|
|||||||
:disabled="currentPage <= 1" @click="currentPage--">
|
:disabled="currentPage <= 1" @click="currentPage--">
|
||||||
‹
|
‹
|
||||||
</button>
|
</button>
|
||||||
|
<template v-for="(page, index) in visiblePages" :key="index">
|
||||||
|
<span v-if="page === 'ellipsis'"
|
||||||
|
class="grid size-8 place-items-center text-sm text-slate-400">
|
||||||
|
...
|
||||||
|
</span>
|
||||||
|
<button v-else type="button"
|
||||||
|
class="grid size-8 place-items-center rounded-lg border text-sm transition"
|
||||||
|
:class="page === currentPage
|
||||||
|
? 'border-yellow-400 bg-yellow-400 text-slate-950'
|
||||||
|
: 'border-white/10 text-slate-400 hover:bg-white/10 hover:text-white'"
|
||||||
|
@click="currentPage = page">
|
||||||
|
{{ page }}
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
<button type="button"
|
<button type="button"
|
||||||
class="grid size-8 place-items-center rounded-lg border border-white/10 text-slate-400 transition hover:bg-white/10 hover:text-white disabled:opacity-30"
|
class="grid size-8 place-items-center rounded-lg border border-white/10 text-slate-400 transition hover:bg-white/10 hover:text-white disabled:opacity-30"
|
||||||
:disabled="currentPage >= totalPages" @click="currentPage++">
|
:disabled="currentPage >= totalPages" @click="currentPage++">
|
||||||
|
|||||||
+267
-4
@@ -23,6 +23,16 @@ const { data: additionalData } = useFetch('/api/movies', {
|
|||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const { data: sectionsData } = useFetch('/api/home/sections', {
|
||||||
|
lazy: true,
|
||||||
|
default: () => ({
|
||||||
|
recentComments: [],
|
||||||
|
trending: [],
|
||||||
|
mostRated: [],
|
||||||
|
hotGenres: [],
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
const movies = computed(() => data.value?.items ?? [])
|
const movies = computed(() => data.value?.items ?? [])
|
||||||
const additionalMovies = computed(() => additionalData.value?.items ?? [])
|
const additionalMovies = computed(() => additionalData.value?.items ?? [])
|
||||||
const homeCatalog = computed(() => uniqueHeroMovies([...movies.value, ...additionalMovies.value]))
|
const homeCatalog = computed(() => uniqueHeroMovies([...movies.value, ...additionalMovies.value]))
|
||||||
@@ -132,6 +142,74 @@ const rows = computed(() => [
|
|||||||
},
|
},
|
||||||
])
|
])
|
||||||
|
|
||||||
|
const recentComments = computed(() => sectionsData.value?.recentComments || [])
|
||||||
|
const trendingMovies = computed(() => sectionsData.value?.trending || [])
|
||||||
|
const mostRatedMovies = computed(() => sectionsData.value?.mostRated || [])
|
||||||
|
const hotGenres = computed(() => sectionsData.value?.hotGenres || [])
|
||||||
|
const commentsCarouselRef = ref<HTMLDivElement | null>(null)
|
||||||
|
|
||||||
|
function scrollComments(direction: 'left' | 'right') {
|
||||||
|
const container = commentsCarouselRef.value
|
||||||
|
if (!container) return
|
||||||
|
const firstCard = container.querySelector('a') as HTMLElement | null
|
||||||
|
if (!firstCard) return
|
||||||
|
const cardWidth = firstCard.offsetWidth
|
||||||
|
const gap = 16
|
||||||
|
const scrollAmount = cardWidth + gap
|
||||||
|
container.scrollBy({
|
||||||
|
left: direction === 'left' ? -scrollAmount : scrollAmount,
|
||||||
|
behavior: 'smooth',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function timeAgo(dateStr: string): string {
|
||||||
|
if (!dateStr) return ''
|
||||||
|
const date = new Date(dateStr)
|
||||||
|
if (isNaN(date.getTime())) return ''
|
||||||
|
const now = new Date()
|
||||||
|
const seconds = Math.floor((now.getTime() - date.getTime()) / 1000)
|
||||||
|
if (seconds < 60) return 'Vừa xong'
|
||||||
|
const minutes = Math.floor(seconds / 60)
|
||||||
|
if (minutes < 60) return `${minutes} phút trước`
|
||||||
|
const hours = Math.floor(minutes / 60)
|
||||||
|
if (hours < 24) return `${hours} giờ trước`
|
||||||
|
const days = Math.floor(hours / 24)
|
||||||
|
if (days < 30) return `${days} ngày trước`
|
||||||
|
return date.toLocaleDateString('vi-VN')
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCommentContent(content: string, maxLength = 120) {
|
||||||
|
if (!content) return ''
|
||||||
|
if (/!\[GIF\]\(/i.test(content)) return 'Đã gửi một GIF'
|
||||||
|
if (content.length <= maxLength) return content
|
||||||
|
return `${content.slice(0, maxLength).trim()}...`
|
||||||
|
}
|
||||||
|
|
||||||
|
function movieLinkFromSection(movie: any) {
|
||||||
|
return {
|
||||||
|
path: `/phim/${movie?.slug}`,
|
||||||
|
query: {
|
||||||
|
source: movie?.source,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function genreTagColor(index: number) {
|
||||||
|
const colors = [
|
||||||
|
'bg-yellow-500',
|
||||||
|
'bg-green-500',
|
||||||
|
'bg-indigo-500',
|
||||||
|
'bg-cyan-500',
|
||||||
|
'bg-orange-500',
|
||||||
|
'bg-pink-500',
|
||||||
|
'bg-red-500',
|
||||||
|
'bg-lime-500',
|
||||||
|
'bg-teal-500',
|
||||||
|
'bg-violet-500',
|
||||||
|
]
|
||||||
|
return colors[index % colors.length]
|
||||||
|
}
|
||||||
|
|
||||||
function movieSourcesQuery(movie: any) {
|
function movieSourcesQuery(movie: any) {
|
||||||
return (movie?.sources || [{ source: movie?.source, slug: movie?.slug }])
|
return (movie?.sources || [{ source: movie?.source, slug: movie?.slug }])
|
||||||
.filter((source: any) => source.source && source.slug)
|
.filter((source: any) => source.source && source.slug)
|
||||||
@@ -275,18 +353,21 @@ useHead({
|
|||||||
<AppHeader />
|
<AppHeader />
|
||||||
|
|
||||||
<div v-if="hero" class="relative bg-black mt-16 md:mt-0">
|
<div v-if="hero" class="relative bg-black mt-16 md:mt-0">
|
||||||
<section class="relative w-full aspect-video md:h-screen overflow-hidden bg-black after:content-[''] after:absolute after:bottom-0 after:left-0 after:right-0 after:h-1.5 after:bg-black after:z-10">
|
<section
|
||||||
|
class="relative w-full aspect-video md:h-screen overflow-hidden bg-black after:content-[''] after:absolute after:bottom-0 after:left-0 after:right-0 after:h-1.5 after:bg-black after:z-10">
|
||||||
<ClientOnly>
|
<ClientOnly>
|
||||||
<HeroSlider v-if="heroSlides.length" ref="heroSliderRef" :slides="heroSlides"
|
<HeroSlider v-if="heroSlides.length" ref="heroSliderRef" :slides="heroSlides"
|
||||||
@update:modelValue="heroIndex = $event" />
|
@update:modelValue="heroIndex = $event" />
|
||||||
</ClientOnly>
|
</ClientOnly>
|
||||||
|
|
||||||
<!-- Mobile gradient overlay (bottom only) -->
|
<!-- Mobile gradient overlay (bottom only) -->
|
||||||
<div class="absolute inset-0 z-10 pointer-events-none md:hidden" style="background:linear-gradient(to top, #000000 0%, rgba(0,0,0,0.92) 8%, rgba(0,0,0,0.75) 22%, rgba(0,0,0,0.35) 50%, transparent 78%)">
|
<div class="absolute inset-0 z-10 pointer-events-none md:hidden"
|
||||||
|
style="background:linear-gradient(to top, #000000 0%, rgba(0,0,0,0.92) 8%, rgba(0,0,0,0.75) 22%, rgba(0,0,0,0.35) 50%, transparent 78%)">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Desktop gradient overlay (bottom only) -->
|
<!-- Desktop gradient overlay (bottom only) -->
|
||||||
<div class="absolute inset-0 z-10 pointer-events-none hidden md:block" style="background:linear-gradient(to top, rgba(0,0,0,0.95) 0%, rgba(0,0,0,0.75) 20%, rgba(0,0,0,0.35) 45%, transparent 70%)">
|
<div class="absolute inset-0 z-10 pointer-events-none hidden md:block"
|
||||||
|
style="background:linear-gradient(to top, rgba(0,0,0,0.95) 0%, rgba(0,0,0,0.75) 20%, rgba(0,0,0,0.35) 45%, transparent 70%)">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Content overlay -->
|
<!-- Content overlay -->
|
||||||
@@ -321,7 +402,8 @@ useHead({
|
|||||||
style="background-color:#ffd875;background-image:linear-gradient(220deg, #ffd875 0%, #ffe7a8 45%, #ffffff 100%)">
|
style="background-color:#ffd875;background-image:linear-gradient(220deg, #ffd875 0%, #ffe7a8 45%, #ffffff 100%)">
|
||||||
{{ hero.quality }}
|
{{ hero.quality }}
|
||||||
</span>
|
</span>
|
||||||
<span v-if="getEpisodeDisplay(hero.episode, hero.episodeTotal)" class="px-2 py-0.75 rounded border border-white bg-black/40">
|
<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) }}
|
{{ getEpisodeDisplay(hero.episode, hero.episodeTotal) }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -530,6 +612,187 @@ useHead({
|
|||||||
<HomeMovieCard v-for="movie in row.items" :key="`${movie.source}-${movie.slug}`" :movie="movie" />
|
<HomeMovieCard v-for="movie in row.items" :key="`${movie.source}-${movie.slug}`" :movie="movie" />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- Community sections -->
|
||||||
|
<section class="rounded-2xl border border-white/20 bg-black p-5">
|
||||||
|
<!-- Bình luận mới -->
|
||||||
|
<div v-if="recentComments.length" class="mb-8">
|
||||||
|
<div class="mb-5 flex items-center gap-3">
|
||||||
|
<AppIcon name="message-circle" class="size-6 text-yellow-400" />
|
||||||
|
<h2 class="text-2xl font-black uppercase text-white">Bình luận mới</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="relative">
|
||||||
|
<div ref="commentsCarouselRef"
|
||||||
|
class="no-scrollbar flex snap-x snap-mandatory gap-4 overflow-x-auto px-4 pb-5">
|
||||||
|
<NuxtLink v-for="comment in recentComments" :key="comment.id" :to="movieLinkFromSection(comment.movie)"
|
||||||
|
class="group relative h-50 snap-start overflow-hidden rounded-2xl bg-[#181a22]
|
||||||
|
transition-all sm:min-w-60 md:min-w-65 lg:min-w-67.5
|
||||||
|
xl:min-w-72.5 2xl:min-w-77.5">
|
||||||
|
<!-- Blurred background -->
|
||||||
|
<div v-if="comment.movie?.poster" class="absolute inset-0" :style="{
|
||||||
|
backgroundImage: `url(${comment.movie.poster})`,
|
||||||
|
backgroundSize: 'cover',
|
||||||
|
backgroundPosition: 'center',
|
||||||
|
filter: 'blur(8px) saturate(1.2)',
|
||||||
|
transform: 'scale(1.2)'
|
||||||
|
}" />
|
||||||
|
<!-- Dark gradient overlay -->
|
||||||
|
<div v-if="comment.movie?.poster" class="absolute inset-0 bg-linear-to-t
|
||||||
|
from-black via-black/80 to-black/40" />
|
||||||
|
<!-- Content -->
|
||||||
|
<div class="relative z-10 flex h-full flex-col p-4">
|
||||||
|
<!-- Top: Avatar + Poster -->
|
||||||
|
<div class="flex items-end justify-between">
|
||||||
|
<!-- Avatar -->
|
||||||
|
<img v-if="comment.user?.avatar" :src="comment.user.avatar" :alt="comment.user?.name" class="size-12 shrink-0 rounded-full object-cover
|
||||||
|
ring-2 ring-white/20 shadow-md mt-auto">
|
||||||
|
<!-- Fallback Avatar -->
|
||||||
|
<div v-else class="grid size-12 shrink-0 place-items-center rounded-full
|
||||||
|
bg-linear-to-br from-yellow-400 to-orange-500
|
||||||
|
text-lg font-black text-slate-950
|
||||||
|
ring-2 ring-white/20 shadow-md mt-auto">
|
||||||
|
{{ (comment.user?.name || 'A').charAt(0).toUpperCase() }}
|
||||||
|
</div>
|
||||||
|
<!-- Movie Poster -->
|
||||||
|
<div class="h-19 w-13 shrink-0 overflow-hidden
|
||||||
|
rounded-lg shadow-md ring-1 ring-white/10">
|
||||||
|
<img v-if="comment.movie?.poster" :src="comment.movie.poster" :alt="comment.movie?.name"
|
||||||
|
class="h-full w-full object-cover">
|
||||||
|
<img v-else-if="comment.movie?.thumb" :src="comment.movie.thumb" :alt="comment.movie?.name"
|
||||||
|
class="h-full w-full object-cover">
|
||||||
|
<div v-else class="h-full w-full bg-slate-700" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- User Name + Gender -->
|
||||||
|
<div class="mt-2 flex min-w-0 items-center gap-1.5">
|
||||||
|
<span class="truncate text-sm font-bold text-white">
|
||||||
|
{{ comment.user?.name || 'Ẩn danh' }}
|
||||||
|
</span>
|
||||||
|
<AppIcon v-if="comment.user?.gender === 'female'" name="venus"
|
||||||
|
class="size-3.5 shrink-0 text-pink-400" />
|
||||||
|
<AppIcon v-if="comment.user?.gender === 'male'" name="mars"
|
||||||
|
class="size-3.5 shrink-0 text-blue-400" />
|
||||||
|
<AppIcon v-if="comment.user?.gender === 'other'" name="minus"
|
||||||
|
class="size-3.5 shrink-0 text-purple-400" />
|
||||||
|
</div>
|
||||||
|
<!-- Comment -->
|
||||||
|
<p class="mt-1 line-clamp-2 text-[13px] leading-relaxed text-white/60">
|
||||||
|
{{ formatCommentContent(comment.content, 180) }}
|
||||||
|
</p>
|
||||||
|
<!-- Stats -->
|
||||||
|
<div class="mt-auto flex items-center gap-5 text-xs text-white/50">
|
||||||
|
<!-- Like -->
|
||||||
|
<span class="flex items-center gap-1">
|
||||||
|
<AppIcon name="thumbs-up" class="size-3.5" />
|
||||||
|
{{ comment.likeCount ?? 0 }}
|
||||||
|
</span>
|
||||||
|
<!-- Dislike -->
|
||||||
|
<span class="flex items-center gap-1">
|
||||||
|
<AppIcon name="thumbs-down" class="size-3.5" />
|
||||||
|
{{ comment.dislikeCount ?? 0 }}
|
||||||
|
</span>
|
||||||
|
<!-- Replies -->
|
||||||
|
<span class="flex items-center gap-1">
|
||||||
|
<AppIcon name="message-square" class="size-3.5" />
|
||||||
|
{{ comment.replyCount ?? 0 }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="button"
|
||||||
|
class="absolute -left-5 top-1/2 z-10 grid size-10 -translate-y-1/2 place-items-center rounded-full border border-white/20 bg-black text-white backdrop-blur-sm transition hover:bg-slate-900"
|
||||||
|
@click="scrollComments('left')">
|
||||||
|
<AppIcon name="chevron-left" class="size-5" />
|
||||||
|
</button>
|
||||||
|
<button type="button"
|
||||||
|
class="absolute -right-5 top-1/2 z-10 grid size-10 -translate-y-1/2 place-items-center rounded-full border border-white/20 bg-black text-white backdrop-blur-sm transition hover:bg-slate-900"
|
||||||
|
@click="scrollComments('right')">
|
||||||
|
<AppIcon name="chevron-right" class="size-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Top sections -->
|
||||||
|
<div class="grid gap-4 lg:grid-cols-3">
|
||||||
|
<!-- Sôi nổi nhất -->
|
||||||
|
<div class="rounded-xl border border-white/20 p-4">
|
||||||
|
<div class="mb-5 flex items-center gap-3">
|
||||||
|
<AppIcon name="zap" class="size-5 text-orange-500" />
|
||||||
|
<h2 class="text-lg font-black uppercase text-white">Sôi nổi nhất</h2>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<NuxtLink v-for="(movie, index) in trendingMovies" :key="`${movie.source}-${movie.slug}-trending`"
|
||||||
|
:to="movieLinkFromSection(movie)" class="group flex items-center gap-3 transition hover:opacity-80">
|
||||||
|
<span class="w-6 text-center text-lg font-black text-white">{{ index + 1 }}.</span>
|
||||||
|
<AppIcon :name="index < 2 ? 'chevron-up' : index === 2 ? 'minus' : 'chevron-down'"
|
||||||
|
:class="index < 2 ? 'text-green-400' : index === 2 ? 'text-slate-400' : 'text-red-400'"
|
||||||
|
class="size-4 shrink-0" />
|
||||||
|
<img :src="movie.thumb || movie.poster" :alt="movie.name"
|
||||||
|
class="h-16 w-11 shrink-0 rounded-md object-cover ring-1 ring-white/10">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<p class="truncate text-sm font-bold text-white group-hover:text-yellow-300">{{ movie.name }}</p>
|
||||||
|
<p class="truncate text-xs text-slate-400">{{ movie.originName || movie.source }}</p>
|
||||||
|
</div>
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
<NuxtLink to="/phim" class="mt-5 inline-block text-sm font-semibold text-slate-400 hover:text-white">
|
||||||
|
Xem thêm
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Yêu thích nhất -->
|
||||||
|
<div class="rounded-xl border border-white/20 p-4">
|
||||||
|
<div class="mb-5 flex items-center gap-3">
|
||||||
|
<AppIcon name="heart" class="size-5 text-pink-500" />
|
||||||
|
<h2 class="text-lg font-black uppercase text-white">Yêu thích nhất</h2>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<NuxtLink v-for="(movie, index) in mostRatedMovies" :key="`${movie.source}-${movie.slug}-rated`"
|
||||||
|
:to="movieLinkFromSection(movie)" class="group flex items-center gap-3 transition hover:opacity-80">
|
||||||
|
<span class="w-6 text-center text-lg font-black text-white">{{ index + 1 }}.</span>
|
||||||
|
<AppIcon :name="index < 2 ? 'chevron-up' : index === 2 ? 'minus' : 'chevron-down'"
|
||||||
|
:class="index < 2 ? 'text-green-400' : index === 2 ? 'text-slate-400' : 'text-red-400'"
|
||||||
|
class="size-4 shrink-0" />
|
||||||
|
<img :src="movie.thumb || movie.poster" :alt="movie.name"
|
||||||
|
class="h-16 w-11 shrink-0 rounded-md object-cover ring-1 ring-white/10">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<p class="truncate text-sm font-bold text-white group-hover:text-yellow-300">{{ movie.name }}</p>
|
||||||
|
<p class="truncate text-xs text-slate-400">{{ movie.originName || movie.source }}</p>
|
||||||
|
</div>
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
<NuxtLink to="/phim" class="mt-5 inline-block text-sm font-semibold text-slate-400 hover:text-white">
|
||||||
|
Xem thêm
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Thể loại hot -->
|
||||||
|
<div class="rounded-xl border border-white/20 p-4">
|
||||||
|
<div class="mb-5 flex items-center gap-3">
|
||||||
|
<AppIcon name="layers" class="size-5 text-yellow-500" />
|
||||||
|
<h2 class="text-lg font-black uppercase text-white">Thể loại hot</h2>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<NuxtLink v-for="(genre, index) in hotGenres" :key="genre.name" to="/phim"
|
||||||
|
class="group flex items-center gap-3 transition hover:opacity-80">
|
||||||
|
<span class="w-6 text-center text-lg font-black text-white">{{ index + 1 }}.</span>
|
||||||
|
<AppIcon :name="index < 2 ? 'chevron-up' : index === 2 ? 'minus' : 'chevron-down'"
|
||||||
|
:class="index < 2 ? 'text-green-400' : index === 2 ? 'text-slate-400' : 'text-red-400'"
|
||||||
|
class="size-4 shrink-0" />
|
||||||
|
<span class="rounded-lg px-4 py-1.5 text-sm font-black text-slate-950" :class="genreTagColor(index)">
|
||||||
|
{{ genre.name }}
|
||||||
|
</span>
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
<NuxtLink to="/phim" class="mt-5 inline-block text-sm font-semibold text-slate-400 hover:text-white">
|
||||||
|
Xem thêm
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { movies } from '../../database/schema'
|
import { movies } from '../../database/schema'
|
||||||
import { desc, eq, like, and, sql } from 'drizzle-orm'
|
import { desc, eq, like, and, sql, asc } from 'drizzle-orm'
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
const db = useDb()
|
const db = useDb()
|
||||||
@@ -11,6 +11,8 @@ export default defineEventHandler(async (event) => {
|
|||||||
const status = typeof query.status === 'string' ? query.status : ''
|
const status = typeof query.status === 'string' ? query.status : ''
|
||||||
const source = typeof query.source === 'string' ? query.source : ''
|
const source = typeof query.source === 'string' ? query.source : ''
|
||||||
const type = typeof query.type === 'string' ? query.type : ''
|
const type = typeof query.type === 'string' ? query.type : ''
|
||||||
|
const sortBy = typeof query.sortBy === 'string' ? query.sortBy : ''
|
||||||
|
const sortOrder = typeof query.sortOrder === 'string' ? query.sortOrder : 'desc'
|
||||||
|
|
||||||
const conditions: any[] = []
|
const conditions: any[] = []
|
||||||
|
|
||||||
@@ -22,12 +24,27 @@ export default defineEventHandler(async (event) => {
|
|||||||
|
|
||||||
const whereClause = conditions.length > 0 ? and(...conditions) : undefined
|
const whereClause = conditions.length > 0 ? and(...conditions) : undefined
|
||||||
|
|
||||||
|
const allowedSortColumns: Record<string, any> = {
|
||||||
|
name: movies.name,
|
||||||
|
views: movies.views,
|
||||||
|
apiUpdatedAt: movies.apiUpdatedAt,
|
||||||
|
active: movies.active,
|
||||||
|
}
|
||||||
|
|
||||||
|
const orderBy: any[] = []
|
||||||
|
if (sortBy && allowedSortColumns[sortBy]) {
|
||||||
|
const column = allowedSortColumns[sortBy]
|
||||||
|
orderBy.push(sortOrder === 'asc' ? asc(column) : desc(column))
|
||||||
|
} else {
|
||||||
|
orderBy.push(desc(movies.apiUpdatedAt), desc(movies.year), desc(movies.syncedAt))
|
||||||
|
}
|
||||||
|
|
||||||
const [items, countResult] = await Promise.all([
|
const [items, countResult] = await Promise.all([
|
||||||
db
|
db
|
||||||
.select()
|
.select()
|
||||||
.from(movies)
|
.from(movies)
|
||||||
.where(whereClause)
|
.where(whereClause)
|
||||||
.orderBy(desc(movies.apiUpdatedAt), desc(movies.year), desc(movies.syncedAt))
|
.orderBy(...orderBy)
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
.offset(offset),
|
.offset(offset),
|
||||||
db
|
db
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import { eq, and, isNull, desc, inArray, count } from 'drizzle-orm'
|
||||||
|
import { comments, users, movies } from '../../database/schema'
|
||||||
|
|
||||||
|
export default defineEventHandler(async () => {
|
||||||
|
const db = useDb()
|
||||||
|
|
||||||
|
const [recentComments, trending, mostRated, allCategories] = await Promise.all([
|
||||||
|
// Recent comments (top-level only) with user and movie info
|
||||||
|
db
|
||||||
|
.select({
|
||||||
|
id: comments.id,
|
||||||
|
content: comments.content,
|
||||||
|
likeCount: comments.likeCount,
|
||||||
|
dislikeCount: comments.dislikeCount,
|
||||||
|
createdAt: comments.createdAt,
|
||||||
|
userId: comments.userId,
|
||||||
|
userName: users.name,
|
||||||
|
userAvatar: users.avatar,
|
||||||
|
userRole: users.role,
|
||||||
|
userGender: users.gender,
|
||||||
|
source: comments.source,
|
||||||
|
slug: comments.slug,
|
||||||
|
movieName: comments.movieName,
|
||||||
|
movieThumb: movies.thumb,
|
||||||
|
moviePoster: movies.poster,
|
||||||
|
})
|
||||||
|
.from(comments)
|
||||||
|
.leftJoin(users, eq(comments.userId, users.id))
|
||||||
|
.leftJoin(movies, and(eq(comments.slug, movies.slug), eq(comments.source, movies.source)))
|
||||||
|
.where(isNull(comments.parentId))
|
||||||
|
.orderBy(desc(comments.createdAt))
|
||||||
|
.limit(10),
|
||||||
|
|
||||||
|
// Trending by views
|
||||||
|
db
|
||||||
|
.select({
|
||||||
|
source: movies.source,
|
||||||
|
slug: movies.slug,
|
||||||
|
name: movies.name,
|
||||||
|
originName: movies.originName,
|
||||||
|
thumb: movies.thumb,
|
||||||
|
poster: movies.poster,
|
||||||
|
views: movies.views,
|
||||||
|
rating: movies.rating,
|
||||||
|
})
|
||||||
|
.from(movies)
|
||||||
|
.where(eq(movies.active, true))
|
||||||
|
.orderBy(desc(movies.views), desc(movies.rating))
|
||||||
|
.limit(5),
|
||||||
|
|
||||||
|
// Most rated / favorite
|
||||||
|
db
|
||||||
|
.select({
|
||||||
|
source: movies.source,
|
||||||
|
slug: movies.slug,
|
||||||
|
name: movies.name,
|
||||||
|
originName: movies.originName,
|
||||||
|
thumb: movies.thumb,
|
||||||
|
poster: movies.poster,
|
||||||
|
views: movies.views,
|
||||||
|
rating: movies.rating,
|
||||||
|
})
|
||||||
|
.from(movies)
|
||||||
|
.where(eq(movies.active, true))
|
||||||
|
.orderBy(desc(movies.rating), desc(movies.views))
|
||||||
|
.limit(5),
|
||||||
|
|
||||||
|
// All categories for hot genres calculation
|
||||||
|
db
|
||||||
|
.select({ categories: movies.categories })
|
||||||
|
.from(movies)
|
||||||
|
.where(eq(movies.active, true)),
|
||||||
|
])
|
||||||
|
|
||||||
|
// Count replies for each recent comment
|
||||||
|
const commentIds = recentComments.map(c => c.id)
|
||||||
|
let replyCounts: Record<number, number> = {}
|
||||||
|
if (commentIds.length > 0) {
|
||||||
|
const replyRows = await db
|
||||||
|
.select({
|
||||||
|
parentId: comments.parentId,
|
||||||
|
count: count(),
|
||||||
|
})
|
||||||
|
.from(comments)
|
||||||
|
.where(inArray(comments.parentId, commentIds))
|
||||||
|
.groupBy(comments.parentId)
|
||||||
|
replyCounts = Object.fromEntries(replyRows.map(r => [r.parentId!, Number(r.count)]))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aggregate hot genres
|
||||||
|
const genreMap = new Map<string, number>()
|
||||||
|
for (const row of allCategories) {
|
||||||
|
for (const category of (row.categories || [])) {
|
||||||
|
genreMap.set(category, (genreMap.get(category) || 0) + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const hotGenres = [...genreMap.entries()]
|
||||||
|
.sort((a, b) => b[1] - a[1])
|
||||||
|
.slice(0, 10)
|
||||||
|
.map(([name, count]) => ({ name, count }))
|
||||||
|
|
||||||
|
return {
|
||||||
|
recentComments: recentComments.map(c => ({
|
||||||
|
id: c.id,
|
||||||
|
content: c.content,
|
||||||
|
likeCount: c.likeCount,
|
||||||
|
dislikeCount: c.dislikeCount,
|
||||||
|
replyCount: replyCounts[c.id] || 0,
|
||||||
|
createdAt: c.createdAt,
|
||||||
|
user: {
|
||||||
|
id: c.userId,
|
||||||
|
name: c.userName || 'Ẩn danh',
|
||||||
|
avatar: c.userAvatar,
|
||||||
|
gender: c.userGender,
|
||||||
|
role: c.userRole,
|
||||||
|
},
|
||||||
|
movie: c.slug && c.source
|
||||||
|
? {
|
||||||
|
source: c.source,
|
||||||
|
slug: c.slug,
|
||||||
|
name: c.movieName || '',
|
||||||
|
thumb: c.movieThumb,
|
||||||
|
poster: c.moviePoster,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
})),
|
||||||
|
trending: trending.map(m => ({
|
||||||
|
source: m.source,
|
||||||
|
slug: m.slug,
|
||||||
|
name: m.name,
|
||||||
|
originName: m.originName,
|
||||||
|
thumb: m.thumb,
|
||||||
|
poster: m.poster,
|
||||||
|
views: m.views,
|
||||||
|
rating: m.rating,
|
||||||
|
})),
|
||||||
|
mostRated: mostRated.map(m => ({
|
||||||
|
source: m.source,
|
||||||
|
slug: m.slug,
|
||||||
|
name: m.name,
|
||||||
|
originName: m.originName,
|
||||||
|
thumb: m.thumb,
|
||||||
|
poster: m.poster,
|
||||||
|
views: m.views,
|
||||||
|
rating: m.rating,
|
||||||
|
})),
|
||||||
|
hotGenres,
|
||||||
|
}
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user