import Link from "next/link" import { ArrowRight, Clock3, Flame, MessageSquare, Shuffle, Star, Trophy } from "lucide-react" import { formatViews } from "@/lib/utils" import connectToMongoDB from "@/lib/mongoose" import { Chapter } from "@/lib/models/chapter" import { HomeHotCarousel, type HotCarouselItem } from "@/components/home-hot-carousel" import { prisma } from "@/lib/prisma" export const dynamic = "force-dynamic" type HomeNovel = { id: string slug: string title: string authorName: string coverColor: string | null coverUrl: string | null rating: number views: number totalChapters: number status: string description: string bookmarkCount: number seriesId: string | null updatedAt: Date } type RankingEntry = { id: string seriesId: string | null novel: HomeNovel aggregatedViews: number } type RecentCommentItem = { id: string content: string createdAt: Date user: { name: string | null } novel: { slug: string title: string } } type LatestChapterInfo = { chapterNumber: number | null chapterTitle: string | null chapterCreatedAt: Date | null } const BASE_NOVEL_SELECT = { id: true, slug: true, title: true, authorName: true, coverColor: true, coverUrl: true, rating: true, views: true, totalChapters: true, status: true, description: true, bookmarkCount: true, seriesId: true, updatedAt: true, } as const function toUTCDateOnly(value: Date): Date { return new Date(Date.UTC(value.getUTCFullYear(), value.getUTCMonth(), value.getUTCDate())) } function shuffleRows(rows: T[]): T[] { const next = [...rows] for (let i = next.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)) const tmp = next[i] next[i] = next[j] next[j] = tmp } return next } function fillUniqueRows(primary: T[], fallback: T[], target: number): T[] { const picked = new Set() const output: T[] = [] for (const row of primary) { if (picked.has(row.id)) continue picked.add(row.id) output.push(row) if (output.length >= target) return output } for (const row of fallback) { if (picked.has(row.id)) continue picked.add(row.id) output.push(row) if (output.length >= target) return output } return output } function formatRelativeTime(value: Date | null | undefined): string { if (!value) return "Vừa cập nhật" const now = Date.now() const ts = value.getTime() const diff = Math.max(0, now - ts) const minute = 60 * 1000 const hour = 60 * minute const day = 24 * hour if (diff < minute) return "Vừa xong" if (diff < hour) return `${Math.floor(diff / minute)} phút trước` if (diff < day) return `${Math.floor(diff / hour)} giờ trước` if (diff < day * 30) return `${Math.floor(diff / day)} ngày trước` return value.toLocaleDateString("vi-VN") } function compactLine(text: string, max = 140): string { const normalized = text.replace(/\s+/g, " ").trim() if (normalized.length <= max) return normalized return `${normalized.slice(0, max).trim()}...` } function collapseSeriesRows(rows: T[]): T[] { const pickedSeries = new Set() const output: T[] = [] for (const row of rows) { if (!row.seriesId) { output.push(row) continue } if (pickedSeries.has(row.seriesId)) continue pickedSeries.add(row.seriesId) output.push(row) } return output } async function fetchRankingByDailyViews(options?: { since?: Date; take?: number }): Promise { const delegate = (prisma as any).novelViewDaily if (!delegate || typeof delegate.groupBy !== "function") { return [] } let grouped: Array<{ novelId: string; _sum: { views: number | null } }> = [] try { grouped = await delegate.groupBy({ by: ["novelId"], where: options?.since ? { day: { gte: toUTCDateOnly(options.since) } } : undefined, _sum: { views: true }, orderBy: { _sum: { views: "desc" } }, take: options?.take || 300, }) } catch (error) { console.warn("novelViewDaily aggregate unavailable, fallback to Novel.views", error) return [] } if (grouped.length === 0) return [] const ids = grouped.map((row) => row.novelId) const novels = await prisma.novel.findMany({ where: { id: { in: ids } }, select: BASE_NOVEL_SELECT, }) const novelMap = new Map(novels.map((novel) => [novel.id, novel as HomeNovel])) const entries: RankingEntry[] = [] for (const row of grouped) { const novel = novelMap.get(row.novelId) if (!novel) continue entries.push({ id: novel.id, seriesId: novel.seriesId, novel, aggregatedViews: row._sum.views || 0, }) } return entries } function toHotCarouselItems(rows: Array): HotCarouselItem[] { return rows.map((row) => ({ id: row.novel.id, slug: row.novel.slug, title: row.novel.title, authorName: row.novel.authorName, description: row.novel.description, coverUrl: row.novel.coverUrl, totalChapters: row.novel.totalChapters, rating: row.novel.rating, views: row.aggregatedViews, status: row.novel.status, hotSource: row.source, })) } function RankingBoard({ title, entries, emptyText, }: { title: string entries: RankingEntry[] emptyText: string }) { return (

{title}

{entries.length > 0 ? entries.map((entry, index) => ( {index + 1} {entry.novel.title}

{entry.novel.title}

{formatViews(entry.aggregatedViews)} lượt đọc

)) : (

{emptyText}

)}
) } export default async function HomePage() { let hotSlides: HotCarouselItem[] = [] let randomNovels: HomeNovel[] = [] let recommendedNovels: HomeNovel[] = [] let latestNovels: HomeNovel[] = [] let recentComments: RecentCommentItem[] = [] let weeklyRanking: RankingEntry[] = [] let monthlyRanking: RankingEntry[] = [] let allTimeRanking: RankingEntry[] = [] const latestChapterMap = new Map() try { const now = new Date() const weekStart = new Date(now) weekStart.setDate(now.getDate() - 7) const monthStart = new Date(now) monthStart.setDate(now.getDate() - 30) const [ weeklyResult, monthlyResult, allTimeResult, popularFallbackResult, randomPoolResult, recommendedPoolResult, latestPoolResult, commentsResult, ] = await Promise.allSettled([ fetchRankingByDailyViews({ since: weekStart, take: 600 }), fetchRankingByDailyViews({ since: monthStart, take: 600 }), fetchRankingByDailyViews({ take: 800 }), prisma.novel.findMany({ take: 400, select: BASE_NOVEL_SELECT, orderBy: [{ views: "desc" }, { updatedAt: "desc" }], }), prisma.novel.findMany({ take: 420, select: BASE_NOVEL_SELECT, orderBy: [{ updatedAt: "desc" }], }), prisma.novel.findMany({ take: 220, select: BASE_NOVEL_SELECT, orderBy: [{ rating: "desc" }, { bookmarkCount: "desc" }, { views: "desc" }], }), prisma.novel.findMany({ take: 180, select: BASE_NOVEL_SELECT, orderBy: [{ updatedAt: "desc" }], }), prisma.comment.findMany({ take: 10, orderBy: { createdAt: "desc" }, select: { id: true, content: true, createdAt: true, user: { select: { name: true } }, novel: { select: { slug: true, title: true } }, }, }), ]) if (weeklyResult.status === "rejected") console.warn("Homepage weekly ranking query failed", weeklyResult.reason) if (monthlyResult.status === "rejected") console.warn("Homepage monthly ranking query failed", monthlyResult.reason) if (allTimeResult.status === "rejected") console.warn("Homepage all-time ranking query failed", allTimeResult.reason) if (popularFallbackResult.status === "rejected") console.warn("Homepage popular fallback query failed", popularFallbackResult.reason) if (randomPoolResult.status === "rejected") console.warn("Homepage random pool query failed", randomPoolResult.reason) if (recommendedPoolResult.status === "rejected") console.warn("Homepage recommended pool query failed", recommendedPoolResult.reason) if (latestPoolResult.status === "rejected") console.warn("Homepage latest pool query failed", latestPoolResult.reason) if (commentsResult.status === "rejected") console.warn("Homepage comments query failed", commentsResult.reason) const weeklyRaw = weeklyResult.status === "fulfilled" ? weeklyResult.value : [] const monthlyRaw = monthlyResult.status === "fulfilled" ? monthlyResult.value : [] const allTimeRaw = allTimeResult.status === "fulfilled" ? allTimeResult.value : [] const popularFallbackRaw = popularFallbackResult.status === "fulfilled" ? popularFallbackResult.value : [] const randomPoolRaw = randomPoolResult.status === "fulfilled" ? randomPoolResult.value : [] const recommendedPoolRaw = recommendedPoolResult.status === "fulfilled" ? recommendedPoolResult.value : [] const latestPoolRaw = latestPoolResult.status === "fulfilled" ? latestPoolResult.value : [] const commentsPool = commentsResult.status === "fulfilled" ? commentsResult.value : [] const popularFallbackRows: RankingEntry[] = (popularFallbackRaw as HomeNovel[]).map((novel) => ({ id: novel.id, seriesId: novel.seriesId, novel, aggregatedViews: novel.views, })) weeklyRanking = fillUniqueRows(collapseSeriesRows(weeklyRaw), collapseSeriesRows(popularFallbackRows), 10) monthlyRanking = fillUniqueRows(collapseSeriesRows(monthlyRaw), collapseSeriesRows(popularFallbackRows), 10) allTimeRanking = fillUniqueRows(collapseSeriesRows(allTimeRaw), collapseSeriesRows(popularFallbackRows), 10) const hotWeekly = weeklyRanking.slice(0, 5).map((entry) => ({ ...entry, source: "week" as const })) const hotMonthly = monthlyRanking.slice(0, 5).map((entry) => ({ ...entry, source: "month" as const })) hotSlides = toHotCarouselItems([...hotWeekly, ...hotMonthly]).slice(0, 10) const usedHotIds = new Set(hotSlides.map((item) => item.id)) const randomPool = randomPoolRaw as HomeNovel[] const randomCandidates = collapseSeriesRows(shuffleRows(randomPool)).filter((item) => !usedHotIds.has(item.id)) randomNovels = fillUniqueRows(randomCandidates, shuffleRows(randomPool), 12) recommendedNovels = fillUniqueRows( collapseSeriesRows(recommendedPoolRaw as HomeNovel[]), collapseSeriesRows(popularFallbackRaw as HomeNovel[]), 10, ) latestNovels = fillUniqueRows( collapseSeriesRows(latestPoolRaw as HomeNovel[]), collapseSeriesRows(popularFallbackRaw as HomeNovel[]), 12, ) recentComments = commentsPool as RecentCommentItem[] const latestIds = latestNovels.map((item) => item.id) if (latestIds.length > 0) { await connectToMongoDB() const rows = await Chapter.aggregate([ { $match: { novelId: { $in: latestIds } } }, { $sort: { novelId: 1, createdAt: -1, number: -1 } }, { $group: { _id: "$novelId", chapterNumber: { $first: "$number" }, chapterTitle: { $first: "$title" }, chapterCreatedAt: { $first: "$createdAt" }, }, }, ]) for (const row of rows) { latestChapterMap.set(String(row._id), { chapterNumber: typeof row.chapterNumber === "number" ? row.chapterNumber : null, chapterTitle: typeof row.chapterTitle === "string" ? row.chapterTitle : null, chapterCreatedAt: row.chapterCreatedAt ? new Date(row.chapterCreatedAt) : null, }) } } } catch (error) { console.error("Failed to fetch data for homepage during build/runtime", error) } return (

Truyện hot hôm nay

Mỗi lần trượt hiển thị 1 truyện, dữ liệu lấy từ log đọc theo tuần và tháng.

Xem tất cả
{hotSlides.length > 0 ? ( ) : (

Chưa có dữ liệu hot để hiển thị.

)}

Truyện ngẫu nhiên

Luôn cố gắng lấp đầy đủ 2 hàng
{randomNovels.length > 0 ? randomNovels.map((novel) => (
{novel.title}

{novel.title}

{novel.authorName}

{formatViews(novel.views)} lượt đọc

)) : (

Không có truyện để hiển thị.

)}

Top truyện đề cử

{recommendedNovels.length > 0 ? recommendedNovels.map((novel, index) => ( {index + 1} {novel.title}

{novel.title}

{novel.authorName}

{formatViews(novel.bookmarkCount)} theo dõi

{novel.rating.toFixed(1)}
)) : (

Chưa có truyện đề cử.

)}

Truyện mới cập nhật

Xem tất cả
{latestNovels.length > 0 ? latestNovels.map((novel) => { const chapter = latestChapterMap.get(novel.id) const chapterLabel = chapter?.chapterNumber ? `Chương ${chapter.chapterNumber}` : "Chưa có chương" const chapterTitle = chapter?.chapterTitle ? compactLine(chapter.chapterTitle, 100) : "Đang cập nhật nội dung chương" const updatedTime = formatRelativeTime(chapter?.chapterCreatedAt || novel.updatedAt) return ( {novel.title}

{novel.title}

{novel.authorName}

{chapterLabel}

{chapterTitle}

{updatedTime}
) }) : (

Chưa có truyện mới cập nhật.

)}

Bảng xếp hạng độ hot

So sánh độ nóng theo tuần, tháng và toàn thời gian.

) }