Refactor API integration and data fetching for genre, novel, and chapter pages

- Replace Prisma database calls with API fetches from the reader API in GenreDetailPage, GenresPage, SearchPage, ChapterReaderPage, and NovelDetailPage.
- Introduce new utility functions for API requests in server-api.ts, including error handling.
- Update authentication flow in auth.ts to sync Google login with the reader API.
- Modify NextAuth session and JWT types to include additional user information.
- Clean up unused imports and code related to Prisma and MongoDB connections.
- Adjust the configuration in next.config.mjs to remove unnecessary API routes.
This commit is contained in:
2026-03-30 13:54:51 +07:00
parent f9bb247ff1
commit 41aca718c9
12 changed files with 515 additions and 749 deletions
+53 -427
View File
@@ -1,14 +1,9 @@
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"
import { readerApiFetch } from "@/lib/server-api"
export const dynamic = "force-dynamic"
@@ -26,21 +21,31 @@ type HomeNovel = {
description: string
bookmarkCount: number
seriesId: string | null
updatedAt: Date
uploader?: {
name: string | null
role: "USER" | "MOD" | "ADMIN"
} | null
updatedAt: string | null
}
type EditorRecommendedItem = {
novel: HomeNovel
novel: {
id: string
slug: string
title: string
authorName: string
coverUrl: string | null
rating: number
}
editorName: string
recommendCount: number
}
type RecommendedByCountItem = {
novel: HomeNovel
novel: {
id: string
slug: string
title: string
authorName: string
coverUrl: string | null
rating: number
}
recommendCount: number
}
@@ -54,7 +59,7 @@ type RankingEntry = {
type RecentCommentItem = {
id: string
content: string
createdAt: Date
createdAt: string | null
user: {
name: string | null
}
@@ -67,73 +72,29 @@ type RecentCommentItem = {
type LatestChapterInfo = {
chapterNumber: number | null
chapterTitle: string | null
chapterCreatedAt: Date | null
chapterCreatedAt: string | 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()))
type HomeApiResponse = {
hotSlides: HotCarouselItem[]
randomNovels: HomeNovel[]
recommendedByCountItems: RecommendedByCountItem[]
editorRecommendedItems: EditorRecommendedItem[]
weeklyRanking: RankingEntry[]
monthlyRanking: RankingEntry[]
allTimeRanking: RankingEntry[]
latestNovels: Array<HomeNovel & { latestChapter?: { number?: number | null; title?: string | null; createdAt?: string | null } | null }>
recentComments: RecentCommentItem[]
}
function shuffleRows<T>(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<T extends { id: string }>(primary: T[], fallback: T[], target: number): T[] {
const picked = new Set<string>()
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 {
function formatRelativeTime(value: string | Date | null | undefined): string {
if (!value) return "Vừa cập nhật"
const parsed = value instanceof Date ? value : new Date(value)
if (Number.isNaN(parsed.getTime())) return "Vừa cập nhật"
const now = Date.now()
const ts = value.getTime()
const ts = parsed.getTime()
const diff = Math.max(0, now - ts)
const minute = 60 * 1000
const hour = 60 * minute
@@ -144,7 +105,7 @@ function formatRelativeTime(value: Date | null | undefined): string {
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")
return parsed.toLocaleDateString("vi-VN")
}
function compactLine(text: string, max = 140): string {
@@ -153,206 +114,6 @@ function compactLine(text: string, max = 140): string {
return `${normalized.slice(0, max).trim()}...`
}
function collapseSeriesRows<T extends { id: string; seriesId?: string | null }>(rows: T[]): T[] {
const pickedSeries = new Set<string>()
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<T>(promise: Promise<T>, label: string, timeoutMs = 4000): Promise<T> {
return new Promise<T>((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<RankingEntry[]> {
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<RankingEntry & { source: HotCarouselItem["hotSource"] }>): 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<string, number>()
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<typeof item> => 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,
@@ -402,7 +163,7 @@ export default async function HomePage() {
let randomNovels: HomeNovel[] = []
let recommendedByCountItems: RecommendedByCountItem[] = []
let editorRecommendedItems: EditorRecommendedItem[] = []
let latestNovels: HomeNovel[] = []
let latestNovels: Array<HomeNovel & { latestChapter?: { number?: number | null; title?: string | null; createdAt?: string | null } | null }> = []
let recentComments: RecentCommentItem[] = []
let weeklyRanking: RankingEntry[] = []
let monthlyRanking: RankingEntry[] = []
@@ -410,158 +171,23 @@ export default async function HomePage() {
const latestChapterMap = new Map<string, LatestChapterInfo>()
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 homeData = await readerApiFetch<HomeApiResponse>("/api/home")
hotSlides = homeData.hotSlides
randomNovels = homeData.randomNovels
recommendedByCountItems = homeData.recommendedByCountItems
editorRecommendedItems = homeData.editorRecommendedItems
latestNovels = homeData.latestNovels
recentComments = homeData.recentComments
weeklyRanking = homeData.weeklyRanking
monthlyRanking = homeData.monthlyRanking
allTimeRanking = homeData.allTimeRanking
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<Array<{
novelId?: string
number?: number
title?: string
createdAt?: Date
}>>,
"Homepage recent chapters"
)
const latestNovelIdsByChapter: string[] = []
const latestSeenNovelIds = new Set<string>()
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)
for (const novel of latestNovels) {
latestChapterMap.set(novel.id, {
chapterNumber: novel.latestChapter?.number ?? null,
chapterTitle: novel.latestChapter?.title ?? null,
chapterCreatedAt: novel.latestChapter?.createdAt ?? null,
})
}
} catch (error) {
console.error("Failed to fetch data for homepage during build/runtime", error)