import Link from "next/link" import { ArrowRight, Clock3, Flame, MessageSquare, Shuffle, Trophy } from "lucide-react" import { formatViews } from "@/lib/utils" import connectToMongoDB from "@/lib/mongoose" import { Chapter } from "@/lib/models/chapter" import { EditorRecommendation } from "@/lib/models/editor-recommendation" import { UserRecommendation } from "@/lib/models/user-recommendation" import { HomeHotCarousel, type HotCarouselItem } from "@/components/home-hot-carousel" import { HomeRecommendationBoards } from "@/components/home-recommendation-boards" 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 uploader?: { name: string | null role: "USER" | "MOD" | "ADMIN" } | null } type EditorRecommendedItem = { novel: HomeNovel editorName: string recommendCount: number } type RecommendedByCountItem = { novel: HomeNovel recommendCount: number } 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, uploader: { select: { name: true, role: 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 } function withTimeout(promise: Promise, label: string, timeoutMs = 4000): Promise { return new Promise((resolve, reject) => { const timer = setTimeout(() => { reject(new Error(`${label} timed out after ${timeoutMs}ms`)) }, timeoutMs) promise.then( (value) => { clearTimeout(timer) resolve(value) }, (error) => { clearTimeout(timer) reject(error) } ) }) } 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, })) } async function fetchManualRecommendationData(): Promise<{ recommendedByCountItems: RecommendedByCountItem[] editorRecommendedItems: EditorRecommendedItem[] }> { try { await connectToMongoDB() const editorDocs = (await EditorRecommendation.find({}).sort({ createdAt: -1 }).limit(2000).lean()) as Array<{ novelId: string editorId: string createdAt?: Date }> const userDocs = (await UserRecommendation.find({}).sort({ createdAt: -1 }).limit(5000).lean()) as Array<{ novelId: string createdAt?: Date }> if (editorDocs.length === 0 && userDocs.length === 0) { return { recommendedByCountItems: [], editorRecommendedItems: [], } } const novelIds = Array.from( new Set( [...editorDocs.map((doc) => doc.novelId), ...userDocs.map((doc) => doc.novelId)].filter(Boolean) ) ) const editorIds = Array.from(new Set(editorDocs.map((doc) => doc.editorId).filter(Boolean))) const [novels, editors] = await Promise.all([ prisma.novel.findMany({ where: { id: { in: novelIds } }, select: BASE_NOVEL_SELECT, }), prisma.user.findMany({ where: { id: { in: editorIds } }, select: { id: true, name: true }, }), ]) const novelMap = new Map(novels.map((novel) => [novel.id, novel as HomeNovel])) const editorMap = new Map(editors.map((editor) => [editor.id, editor])) const recommendCountMap = new Map() for (const doc of editorDocs) { recommendCountMap.set(doc.novelId, (recommendCountMap.get(doc.novelId) || 0) + 1) } for (const doc of userDocs) { recommendCountMap.set(doc.novelId, (recommendCountMap.get(doc.novelId) || 0) + 1) } const recommendedByCountItems = Array.from(recommendCountMap.entries()) .map(([novelId, recommendCount]) => ({ novel: novelMap.get(novelId), recommendCount, })) .filter((row): row is { novel: HomeNovel; recommendCount: number } => Boolean(row.novel)) .sort((a, b) => { if (b.recommendCount !== a.recommendCount) return b.recommendCount - a.recommendCount if (b.novel.rating !== a.novel.rating) return b.novel.rating - a.novel.rating return b.novel.views - a.novel.views }) const editorRecommendedItems = editorDocs .map((doc) => { const novel = novelMap.get(doc.novelId) if (!novel) return null return { novel, editorName: editorMap.get(doc.editorId)?.name || "Biên tập viên", recommendCount: recommendCountMap.get(doc.novelId) || 0, createdAt: doc.createdAt ? new Date(doc.createdAt).getTime() : 0, } }) .filter((item): item is NonNullable => Boolean(item)) .sort((a, b) => { if (b.recommendCount !== a.recommendCount) return b.recommendCount - a.recommendCount return b.createdAt - a.createdAt }) .map((item) => ({ novel: item.novel, editorName: item.editorName, recommendCount: item.recommendCount, })) return { recommendedByCountItems, editorRecommendedItems, } } catch (error) { console.warn("Homepage manual recommendation query failed", error) return { recommendedByCountItems: [], editorRecommendedItems: [], } } } 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 recommendedByCountItems: RecommendedByCountItem[] = [] let editorRecommendedItems: EditorRecommendedItem[] = [] 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, recommendationResult, commentsResult, ] = await Promise.allSettled([ withTimeout(fetchRankingByDailyViews({ since: weekStart, take: 600 }), "Homepage weekly ranking"), withTimeout(fetchRankingByDailyViews({ since: monthStart, take: 600 }), "Homepage monthly ranking"), withTimeout(fetchRankingByDailyViews({ take: 800 }), "Homepage all-time ranking"), withTimeout(prisma.novel.findMany({ take: 400, select: BASE_NOVEL_SELECT, orderBy: [{ views: "desc" }, { updatedAt: "desc" }], }), "Homepage popular fallback"), withTimeout(prisma.novel.findMany({ take: 420, select: BASE_NOVEL_SELECT, orderBy: [{ updatedAt: "desc" }], }), "Homepage random pool"), withTimeout(fetchManualRecommendationData(), "Homepage recommendations"), withTimeout(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 } }, }, }), "Homepage comments"), ]) 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 (recommendationResult.status === "rejected") console.warn("Homepage recommendation query failed", recommendationResult.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 recommendationData = recommendationResult.status === "fulfilled" ? recommendationResult.value : { recommendedByCountItems: [], editorRecommendedItems: [] } 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), 5) monthlyRanking = fillUniqueRows(collapseSeriesRows(monthlyRaw), collapseSeriesRows(popularFallbackRows), 5) allTimeRanking = fillUniqueRows(collapseSeriesRows(allTimeRaw), collapseSeriesRows(popularFallbackRows), 5) 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 })) const hotAllTime = allTimeRanking.slice(0, 8).map((entry) => ({ ...entry, source: "all" as const })) hotSlides = fillUniqueRows( toHotCarouselItems([...hotWeekly, ...hotMonthly]), toHotCarouselItems(hotAllTime), 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) recommendedByCountItems = recommendationData.recommendedByCountItems editorRecommendedItems = recommendationData.editorRecommendedItems recentComments = commentsPool as RecentCommentItem[] try { // Latest-updated list is based only on newly created chapters, not on novel metadata edits. // Sampling recent inserts by Mongo _id keeps this query on the default index and avoids scanning the whole collection. await withTimeout(connectToMongoDB(), "Homepage chapter Mongo connect") const recentChapterRows = await withTimeout( Chapter.find( {}, { novelId: 1, number: 1, title: 1, createdAt: 1, } ) .sort({ _id: -1 }) .limit(400) .lean() as Promise>, "Homepage recent chapters" ) const latestNovelIdsByChapter: string[] = [] const latestSeenNovelIds = new Set() for (const row of recentChapterRows) { const novelId = String(row.novelId || "").trim() if (!novelId || latestSeenNovelIds.has(novelId)) continue latestSeenNovelIds.add(novelId) latestNovelIdsByChapter.push(novelId) latestChapterMap.set(novelId, { chapterNumber: typeof row.number === "number" ? row.number : null, chapterTitle: typeof row.title === "string" ? row.title : null, chapterCreatedAt: row.createdAt ? new Date(row.createdAt) : null, }) if (latestNovelIdsByChapter.length >= 500) break } if (latestNovelIdsByChapter.length > 0) { const latestNovelPool = await withTimeout( prisma.novel.findMany({ where: { id: { in: latestNovelIdsByChapter } }, select: BASE_NOVEL_SELECT, }), "Homepage latest novel pool" ) const latestNovelMap = new Map(latestNovelPool.map((novel) => [novel.id, novel as HomeNovel])) const orderedLatestByChapter = latestNovelIdsByChapter .map((id) => latestNovelMap.get(id)) .filter((row): row is HomeNovel => Boolean(row)) latestNovels = collapseSeriesRows(orderedLatestByChapter).slice(0, 5) } } catch (error) { console.warn("Homepage latest chapter section skipped", error) } } 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ị.

)}

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

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

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.

)}
) }