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:
@@ -0,0 +1,61 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server"
|
||||||
|
import { getToken } from "next-auth/jwt"
|
||||||
|
|
||||||
|
export const runtime = "nodejs"
|
||||||
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
|
const readerApiOrigin = (process.env.READER_API_ORIGIN || "http://localhost:8000").replace(/\/+$/, "")
|
||||||
|
|
||||||
|
async function proxyToReaderApi(req: NextRequest, path: string[]) {
|
||||||
|
const token = await getToken({ req, secret: process.env.NEXTAUTH_SECRET })
|
||||||
|
const accessToken = typeof (token as any)?.accessToken === "string" ? (token as any).accessToken : null
|
||||||
|
|
||||||
|
const url = new URL(req.url)
|
||||||
|
const query = url.search || ""
|
||||||
|
const targetUrl = `${readerApiOrigin}/api/mod/${path.join("/")}${query}`
|
||||||
|
|
||||||
|
const headers = new Headers(req.headers)
|
||||||
|
headers.delete("host")
|
||||||
|
if (accessToken) {
|
||||||
|
headers.set("authorization", `Bearer ${accessToken}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const isBodyMethod = req.method !== "GET" && req.method !== "HEAD"
|
||||||
|
const upstream = await fetch(targetUrl, {
|
||||||
|
method: req.method,
|
||||||
|
headers,
|
||||||
|
body: isBodyMethod ? req.body : undefined,
|
||||||
|
cache: "no-store",
|
||||||
|
duplex: "half",
|
||||||
|
} as any)
|
||||||
|
|
||||||
|
return new NextResponse(upstream.body, {
|
||||||
|
status: upstream.status,
|
||||||
|
headers: upstream.headers,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) {
|
||||||
|
const { path } = await ctx.params
|
||||||
|
return proxyToReaderApi(req, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) {
|
||||||
|
const { path } = await ctx.params
|
||||||
|
return proxyToReaderApi(req, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) {
|
||||||
|
const { path } = await ctx.params
|
||||||
|
return proxyToReaderApi(req, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PATCH(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) {
|
||||||
|
const { path } = await ctx.params
|
||||||
|
return proxyToReaderApi(req, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) {
|
||||||
|
const { path } = await ctx.params
|
||||||
|
return proxyToReaderApi(req, path)
|
||||||
|
}
|
||||||
+23
-34
@@ -1,46 +1,35 @@
|
|||||||
import { getServerSession } from "next-auth"
|
import { getServerSession } from "next-auth"
|
||||||
import { authOptions } from "@/lib/auth"
|
import { authOptions } from "@/lib/auth"
|
||||||
import { prisma } from "@/lib/prisma"
|
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { Sparkles } from "lucide-react"
|
import { Sparkles } from "lucide-react"
|
||||||
|
import { cookies } from "next/headers"
|
||||||
|
|
||||||
|
const readerApiOrigin = (process.env.READER_API_ORIGIN || "http://localhost:8000").replace(/\/+$/, "")
|
||||||
|
|
||||||
export default async function ModDashboardPage() {
|
export default async function ModDashboardPage() {
|
||||||
const session = await getServerSession(authOptions)
|
const session = await getServerSession(authOptions)
|
||||||
|
|
||||||
const novelWhere = session?.user.role === "ADMIN"
|
let novelCount = 0
|
||||||
? {}
|
let totalViews = 0
|
||||||
: {
|
let commentCount = 0
|
||||||
OR: [
|
let seriesCount = 0
|
||||||
{ uploaderId: session?.user.id },
|
|
||||||
{ uploaderId: null },
|
try {
|
||||||
],
|
const cookieHeader = (await cookies()).toString()
|
||||||
|
const res = await fetch(`${readerApiOrigin}/api/mod/overview`, {
|
||||||
|
cache: "no-store",
|
||||||
|
headers: cookieHeader ? { cookie: cookieHeader } : undefined,
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
novelCount = Number(data?.novelCount || 0)
|
||||||
|
totalViews = Number(data?.totalViews || 0)
|
||||||
|
commentCount = Number(data?.commentCount || 0)
|
||||||
|
seriesCount = Number(data?.seriesCount || 0)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch mod overview", error)
|
||||||
}
|
}
|
||||||
|
|
||||||
const [novelCount, novelViewsAgg, commentCount, seriesCount] = await Promise.all([
|
|
||||||
prisma.novel.count({ where: novelWhere }),
|
|
||||||
prisma.novel.aggregate({
|
|
||||||
where: novelWhere,
|
|
||||||
_sum: { views: true },
|
|
||||||
}),
|
|
||||||
prisma.comment.count({
|
|
||||||
where: {
|
|
||||||
novel: novelWhere,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
prisma.series.count({
|
|
||||||
where: session?.user.role === "ADMIN"
|
|
||||||
? {}
|
|
||||||
: {
|
|
||||||
OR: [
|
|
||||||
{ novels: { some: { uploaderId: session?.user.id } } },
|
|
||||||
{ novels: { some: { uploaderId: null } } },
|
|
||||||
{ novels: { none: {} } },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
])
|
|
||||||
|
|
||||||
const totalViews = novelViewsAgg._sum.views || 0
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
+52
-426
@@ -1,14 +1,9 @@
|
|||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { ArrowRight, Clock3, Flame, MessageSquare, Shuffle, Trophy } from "lucide-react"
|
import { ArrowRight, Clock3, Flame, MessageSquare, Shuffle, Trophy } from "lucide-react"
|
||||||
import { formatViews } from "@/lib/utils"
|
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 { HomeHotCarousel, type HotCarouselItem } from "@/components/home-hot-carousel"
|
||||||
import { HomeRecommendationBoards } from "@/components/home-recommendation-boards"
|
import { HomeRecommendationBoards } from "@/components/home-recommendation-boards"
|
||||||
|
import { readerApiFetch } from "@/lib/server-api"
|
||||||
import { prisma } from "@/lib/prisma"
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic"
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
@@ -26,21 +21,31 @@ type HomeNovel = {
|
|||||||
description: string
|
description: string
|
||||||
bookmarkCount: number
|
bookmarkCount: number
|
||||||
seriesId: string | null
|
seriesId: string | null
|
||||||
updatedAt: Date
|
updatedAt: string | null
|
||||||
uploader?: {
|
|
||||||
name: string | null
|
|
||||||
role: "USER" | "MOD" | "ADMIN"
|
|
||||||
} | null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type EditorRecommendedItem = {
|
type EditorRecommendedItem = {
|
||||||
novel: HomeNovel
|
novel: {
|
||||||
|
id: string
|
||||||
|
slug: string
|
||||||
|
title: string
|
||||||
|
authorName: string
|
||||||
|
coverUrl: string | null
|
||||||
|
rating: number
|
||||||
|
}
|
||||||
editorName: string
|
editorName: string
|
||||||
recommendCount: number
|
recommendCount: number
|
||||||
}
|
}
|
||||||
|
|
||||||
type RecommendedByCountItem = {
|
type RecommendedByCountItem = {
|
||||||
novel: HomeNovel
|
novel: {
|
||||||
|
id: string
|
||||||
|
slug: string
|
||||||
|
title: string
|
||||||
|
authorName: string
|
||||||
|
coverUrl: string | null
|
||||||
|
rating: number
|
||||||
|
}
|
||||||
recommendCount: number
|
recommendCount: number
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,7 +59,7 @@ type RankingEntry = {
|
|||||||
type RecentCommentItem = {
|
type RecentCommentItem = {
|
||||||
id: string
|
id: string
|
||||||
content: string
|
content: string
|
||||||
createdAt: Date
|
createdAt: string | null
|
||||||
user: {
|
user: {
|
||||||
name: string | null
|
name: string | null
|
||||||
}
|
}
|
||||||
@@ -67,73 +72,29 @@ type RecentCommentItem = {
|
|||||||
type LatestChapterInfo = {
|
type LatestChapterInfo = {
|
||||||
chapterNumber: number | null
|
chapterNumber: number | null
|
||||||
chapterTitle: string | null
|
chapterTitle: string | null
|
||||||
chapterCreatedAt: Date | null
|
chapterCreatedAt: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
const BASE_NOVEL_SELECT = {
|
type HomeApiResponse = {
|
||||||
id: true,
|
hotSlides: HotCarouselItem[]
|
||||||
slug: true,
|
randomNovels: HomeNovel[]
|
||||||
title: true,
|
recommendedByCountItems: RecommendedByCountItem[]
|
||||||
authorName: true,
|
editorRecommendedItems: EditorRecommendedItem[]
|
||||||
coverColor: true,
|
weeklyRanking: RankingEntry[]
|
||||||
coverUrl: true,
|
monthlyRanking: RankingEntry[]
|
||||||
rating: true,
|
allTimeRanking: RankingEntry[]
|
||||||
views: true,
|
latestNovels: Array<HomeNovel & { latestChapter?: { number?: number | null; title?: string | null; createdAt?: string | null } | null }>
|
||||||
totalChapters: true,
|
recentComments: RecentCommentItem[]
|
||||||
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<T>(rows: T[]): T[] {
|
function formatRelativeTime(value: string | Date | null | undefined): string {
|
||||||
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 {
|
|
||||||
if (!value) return "Vừa cập nhật"
|
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 now = Date.now()
|
||||||
const ts = value.getTime()
|
const ts = parsed.getTime()
|
||||||
const diff = Math.max(0, now - ts)
|
const diff = Math.max(0, now - ts)
|
||||||
const minute = 60 * 1000
|
const minute = 60 * 1000
|
||||||
const hour = 60 * minute
|
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) return `${Math.floor(diff / hour)} giờ trước`
|
||||||
if (diff < day * 30) return `${Math.floor(diff / day)} ngày 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 {
|
function compactLine(text: string, max = 140): string {
|
||||||
@@ -153,206 +114,6 @@ function compactLine(text: string, max = 140): string {
|
|||||||
return `${normalized.slice(0, max).trim()}...`
|
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({
|
function RankingBoard({
|
||||||
title,
|
title,
|
||||||
entries,
|
entries,
|
||||||
@@ -402,7 +163,7 @@ export default async function HomePage() {
|
|||||||
let randomNovels: HomeNovel[] = []
|
let randomNovels: HomeNovel[] = []
|
||||||
let recommendedByCountItems: RecommendedByCountItem[] = []
|
let recommendedByCountItems: RecommendedByCountItem[] = []
|
||||||
let editorRecommendedItems: EditorRecommendedItem[] = []
|
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 recentComments: RecentCommentItem[] = []
|
||||||
let weeklyRanking: RankingEntry[] = []
|
let weeklyRanking: RankingEntry[] = []
|
||||||
let monthlyRanking: RankingEntry[] = []
|
let monthlyRanking: RankingEntry[] = []
|
||||||
@@ -410,158 +171,23 @@ export default async function HomePage() {
|
|||||||
const latestChapterMap = new Map<string, LatestChapterInfo>()
|
const latestChapterMap = new Map<string, LatestChapterInfo>()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const now = new Date()
|
const homeData = await readerApiFetch<HomeApiResponse>("/api/home")
|
||||||
const weekStart = new Date(now)
|
hotSlides = homeData.hotSlides
|
||||||
weekStart.setDate(now.getDate() - 7)
|
randomNovels = homeData.randomNovels
|
||||||
const monthStart = new Date(now)
|
recommendedByCountItems = homeData.recommendedByCountItems
|
||||||
monthStart.setDate(now.getDate() - 30)
|
editorRecommendedItems = homeData.editorRecommendedItems
|
||||||
|
latestNovels = homeData.latestNovels
|
||||||
|
recentComments = homeData.recentComments
|
||||||
|
weeklyRanking = homeData.weeklyRanking
|
||||||
|
monthlyRanking = homeData.monthlyRanking
|
||||||
|
allTimeRanking = homeData.allTimeRanking
|
||||||
|
|
||||||
const [
|
for (const novel of latestNovels) {
|
||||||
weeklyResult,
|
latestChapterMap.set(novel.id, {
|
||||||
monthlyResult,
|
chapterNumber: novel.latestChapter?.number ?? null,
|
||||||
allTimeResult,
|
chapterTitle: novel.latestChapter?.title ?? null,
|
||||||
popularFallbackResult,
|
chapterCreatedAt: novel.latestChapter?.createdAt ?? null,
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to fetch data for homepage during build/runtime", error)
|
console.error("Failed to fetch data for homepage during build/runtime", error)
|
||||||
|
|||||||
@@ -1,11 +1,36 @@
|
|||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { ChevronLeft } from "lucide-react"
|
import { ChevronLeft } from "lucide-react"
|
||||||
import { prisma } from "@/lib/prisma"
|
|
||||||
import { NovelCard } from "@/components/novel-card"
|
import { NovelCard } from "@/components/novel-card"
|
||||||
import { notFound } from "next/navigation"
|
import { notFound } from "next/navigation"
|
||||||
|
import { readerApiFetch, readerApiFetchNullable } from "@/lib/server-api"
|
||||||
|
|
||||||
export const dynamic = "force-dynamic"
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
|
type GenreItem = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
slug: string
|
||||||
|
description: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
type BrowseNovel = {
|
||||||
|
id: string
|
||||||
|
slug: string
|
||||||
|
title: string
|
||||||
|
authorName: string
|
||||||
|
coverColor: string | null
|
||||||
|
coverUrl: string | null
|
||||||
|
rating: number
|
||||||
|
views: number
|
||||||
|
totalChapters: number
|
||||||
|
status: string
|
||||||
|
seriesId?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
type BrowseResponse = {
|
||||||
|
items: BrowseNovel[]
|
||||||
|
}
|
||||||
|
|
||||||
function collapseSeriesRows<T extends { id: string; seriesId?: string | null }>(rows: T[]): T[] {
|
function collapseSeriesRows<T extends { id: string; seriesId?: string | null }>(rows: T[]): T[] {
|
||||||
const pickedSeries = new Set<string>()
|
const pickedSeries = new Set<string>()
|
||||||
const output: T[] = []
|
const output: T[] = []
|
||||||
@@ -27,42 +52,16 @@ function collapseSeriesRows<T extends { id: string; seriesId?: string | null }>(
|
|||||||
export default async function GenreDetailPage({ params }: { params: Promise<{ slug: string }> }) {
|
export default async function GenreDetailPage({ params }: { params: Promise<{ slug: string }> }) {
|
||||||
const { slug } = await params
|
const { slug } = await params
|
||||||
|
|
||||||
const genre = await prisma.genre.findUnique({
|
const genres = await readerApiFetch<GenreItem[]>("/api/genres")
|
||||||
where: { slug }
|
const genre = genres.find((item) => item.slug === slug) || null
|
||||||
})
|
|
||||||
|
|
||||||
if (!genre) {
|
if (!genre) {
|
||||||
notFound()
|
notFound()
|
||||||
}
|
}
|
||||||
|
|
||||||
const allNovelsRaw = await prisma.novel.findMany({
|
const browse = await readerApiFetch<BrowseResponse>(`/api/novels/browse?genre=${encodeURIComponent(slug)}&sort=latest&page=1&limit=80`)
|
||||||
where: {
|
|
||||||
genres: {
|
|
||||||
some: {
|
|
||||||
genreId: genre.id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
slug: true,
|
|
||||||
title: true,
|
|
||||||
authorName: true,
|
|
||||||
coverColor: true,
|
|
||||||
coverUrl: true,
|
|
||||||
rating: true,
|
|
||||||
views: true,
|
|
||||||
totalChapters: true,
|
|
||||||
status: true,
|
|
||||||
seriesId: true,
|
|
||||||
},
|
|
||||||
orderBy: {
|
|
||||||
updatedAt: "desc"
|
|
||||||
},
|
|
||||||
take: 80
|
|
||||||
})
|
|
||||||
|
|
||||||
const allNovels = collapseSeriesRows(allNovelsRaw).slice(0, 20)
|
const allNovels = collapseSeriesRows(browse.items).slice(0, 20)
|
||||||
|
|
||||||
// Basic layout without sort for purely server side representation without search params. Optional searchParams can be added later if needed.
|
// Basic layout without sort for purely server side representation without search params. Optional searchParams can be added later if needed.
|
||||||
return (
|
return (
|
||||||
|
|||||||
+13
-10
@@ -1,6 +1,6 @@
|
|||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { BookOpen, Sparkles, Flame, Heart, Swords, Building2, Rocket, Crown, Laugh, Search, Shield } from "lucide-react"
|
import { BookOpen, Sparkles, Flame, Heart, Swords, Building2, Rocket, Crown, Laugh, Search, Shield } from "lucide-react"
|
||||||
import { prisma } from "@/lib/prisma"
|
import { readerApiFetch } from "@/lib/server-api"
|
||||||
|
|
||||||
const iconMap: Record<string, React.ReactNode> = {
|
const iconMap: Record<string, React.ReactNode> = {
|
||||||
Sparkles: <Sparkles className="h-6 w-6" />,
|
Sparkles: <Sparkles className="h-6 w-6" />,
|
||||||
@@ -17,17 +17,20 @@ const iconMap: Record<string, React.ReactNode> = {
|
|||||||
|
|
||||||
export const dynamic = "force-dynamic"
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
|
type GenreItem = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
slug: string
|
||||||
|
description: string | null
|
||||||
|
icon: string | null
|
||||||
|
novelCount: number
|
||||||
|
}
|
||||||
|
|
||||||
export default async function GenresPage() {
|
export default async function GenresPage() {
|
||||||
let genres: any[] = []
|
let genres: GenreItem[] = []
|
||||||
|
|
||||||
try {
|
try {
|
||||||
genres = await prisma.genre.findMany({
|
genres = await readerApiFetch<GenreItem[]>("/api/genres")
|
||||||
include: {
|
|
||||||
_count: {
|
|
||||||
select: { novels: true }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to fetch genres during build/runtime", error)
|
console.error("Failed to fetch genres during build/runtime", error)
|
||||||
}
|
}
|
||||||
@@ -37,7 +40,7 @@ export default async function GenresPage() {
|
|||||||
<h1 className="mb-6 text-2xl font-bold text-foreground">Thể Loại Truyện</h1>
|
<h1 className="mb-6 text-2xl font-bold text-foreground">Thể Loại Truyện</h1>
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{genres.map((genre) => {
|
{genres.map((genre) => {
|
||||||
const novelCount = genre._count.novels
|
const novelCount = genre.novelCount
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
key={genre.id}
|
key={genre.id}
|
||||||
|
|||||||
+60
-83
@@ -1,13 +1,39 @@
|
|||||||
// Server component instead of client component
|
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { Search } from "lucide-react"
|
import { Search } from "lucide-react"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { NovelCard } from "@/components/novel-card"
|
import { NovelCard } from "@/components/novel-card"
|
||||||
import { prisma } from "@/lib/prisma"
|
import { readerApiFetch } from "@/lib/server-api"
|
||||||
|
|
||||||
export const dynamic = "force-dynamic"
|
export const dynamic = "force-dynamic"
|
||||||
const PAGE_SIZE = 20
|
const PAGE_SIZE = 20
|
||||||
|
|
||||||
|
type BrowseNovel = {
|
||||||
|
id: string
|
||||||
|
slug: string
|
||||||
|
title: string
|
||||||
|
authorName: string
|
||||||
|
coverColor: string | null
|
||||||
|
coverUrl: string | null
|
||||||
|
rating: number
|
||||||
|
views: number
|
||||||
|
totalChapters: number
|
||||||
|
status: string
|
||||||
|
seriesId?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
type BrowseResponse = {
|
||||||
|
items: BrowseNovel[]
|
||||||
|
totalCount: number
|
||||||
|
totalPages: number
|
||||||
|
currentPage: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type GenreItem = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
slug: string
|
||||||
|
}
|
||||||
|
|
||||||
function collapseSeriesRows<T extends { id: string; seriesId?: string | null }>(rows: T[]): T[] {
|
function collapseSeriesRows<T extends { id: string; seriesId?: string | null }>(rows: T[]): T[] {
|
||||||
const pickedSeries = new Set<string>()
|
const pickedSeries = new Set<string>()
|
||||||
const output: T[] = []
|
const output: T[] = []
|
||||||
@@ -26,6 +52,11 @@ function collapseSeriesRows<T extends { id: string; seriesId?: string | null }>(
|
|||||||
return output
|
return output
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildBrowsePath(params: Record<string, string>) {
|
||||||
|
const search = new URLSearchParams(params)
|
||||||
|
return `/api/novels/browse?${search.toString()}`
|
||||||
|
}
|
||||||
|
|
||||||
export default async function SearchPage({
|
export default async function SearchPage({
|
||||||
searchParams,
|
searchParams,
|
||||||
}: {
|
}: {
|
||||||
@@ -38,99 +69,47 @@ export default async function SearchPage({
|
|||||||
const statusFilter = resolvedParams.statusFilter || "all"
|
const statusFilter = resolvedParams.statusFilter || "all"
|
||||||
const requestedPage = Math.max(1, Number(resolvedParams.page || "1") || 1)
|
const requestedPage = Math.max(1, Number(resolvedParams.page || "1") || 1)
|
||||||
|
|
||||||
// Build where clause
|
const genres = await readerApiFetch<GenreItem[]>("/api/genres")
|
||||||
let where: any = {}
|
|
||||||
|
|
||||||
if (q) {
|
let filteredNovels: BrowseNovel[] = []
|
||||||
where.OR = [
|
|
||||||
{ title: { contains: q, mode: "insensitive" } },
|
|
||||||
{ authorName: { contains: q, mode: "insensitive" } },
|
|
||||||
{ originalAuthorName: { contains: q, mode: "insensitive" } },
|
|
||||||
{ series: { name: { contains: q, mode: "insensitive" } } },
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
if (genreFilter !== "all") {
|
|
||||||
where.genres = {
|
|
||||||
some: {
|
|
||||||
genre: {
|
|
||||||
slug: genreFilter
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (statusFilter !== "all") {
|
|
||||||
where.status = statusFilter
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build order clause
|
|
||||||
let orderBy: any = {}
|
|
||||||
switch (sortBy) {
|
|
||||||
case "popular":
|
|
||||||
orderBy = { views: "desc" }
|
|
||||||
break
|
|
||||||
case "rating":
|
|
||||||
orderBy = { rating: "desc" }
|
|
||||||
break
|
|
||||||
case "name":
|
|
||||||
orderBy = { title: "asc" }
|
|
||||||
break
|
|
||||||
case "latest":
|
|
||||||
default:
|
|
||||||
orderBy = { updatedAt: "desc" }
|
|
||||||
}
|
|
||||||
|
|
||||||
let filteredNovels: any[] = []
|
|
||||||
let totalResults = 0
|
let totalResults = 0
|
||||||
let totalPages = 1
|
let totalPages = 1
|
||||||
let currentPage = requestedPage
|
let currentPage = requestedPage
|
||||||
|
|
||||||
if (q) {
|
if (q) {
|
||||||
totalResults = await prisma.novel.count({ where })
|
const browse = await readerApiFetch<BrowseResponse>(
|
||||||
totalPages = Math.max(1, Math.ceil(totalResults / PAGE_SIZE))
|
buildBrowsePath({
|
||||||
currentPage = Math.min(currentPage, totalPages)
|
q,
|
||||||
|
sort: sortBy,
|
||||||
filteredNovels = await prisma.novel.findMany({
|
genre: genreFilter === "all" ? "" : genreFilter,
|
||||||
where,
|
status: statusFilter === "all" ? "" : statusFilter,
|
||||||
orderBy,
|
page: String(requestedPage),
|
||||||
include: {
|
limit: String(PAGE_SIZE),
|
||||||
series: {
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
name: true,
|
|
||||||
slug: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
skip: (currentPage - 1) * PAGE_SIZE,
|
|
||||||
take: PAGE_SIZE,
|
|
||||||
})
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
filteredNovels = browse.items
|
||||||
|
totalResults = browse.totalCount
|
||||||
|
totalPages = Math.max(1, browse.totalPages || 1)
|
||||||
|
currentPage = Math.min(requestedPage, totalPages)
|
||||||
} else {
|
} else {
|
||||||
const filteredNovelsRaw = await prisma.novel.findMany({
|
const browse = await readerApiFetch<BrowseResponse>(
|
||||||
where,
|
buildBrowsePath({
|
||||||
orderBy,
|
sort: sortBy,
|
||||||
include: {
|
genre: genreFilter === "all" ? "" : genreFilter,
|
||||||
series: {
|
status: statusFilter === "all" ? "" : statusFilter,
|
||||||
select: {
|
page: "1",
|
||||||
id: true,
|
limit: "500",
|
||||||
name: true,
|
|
||||||
slug: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
take: 500,
|
|
||||||
})
|
})
|
||||||
|
)
|
||||||
|
|
||||||
const collapsed = collapseSeriesRows(filteredNovelsRaw)
|
const collapsed = collapseSeriesRows(browse.items)
|
||||||
totalResults = collapsed.length
|
totalResults = collapsed.length
|
||||||
totalPages = Math.max(1, Math.ceil(totalResults / PAGE_SIZE))
|
totalPages = Math.max(1, Math.ceil(totalResults / PAGE_SIZE))
|
||||||
currentPage = Math.min(currentPage, totalPages)
|
currentPage = Math.min(requestedPage, totalPages)
|
||||||
filteredNovels = collapsed.slice((currentPage - 1) * PAGE_SIZE, currentPage * PAGE_SIZE)
|
filteredNovels = collapsed.slice((currentPage - 1) * PAGE_SIZE, currentPage * PAGE_SIZE)
|
||||||
}
|
}
|
||||||
|
|
||||||
const genres = await prisma.genre.findMany({ orderBy: { name: "asc" } })
|
|
||||||
|
|
||||||
const pageRangeStart = Math.max(1, currentPage - 2)
|
const pageRangeStart = Math.max(1, currentPage - 2)
|
||||||
const pageRangeEnd = Math.min(totalPages, currentPage + 2)
|
const pageRangeEnd = Math.min(totalPages, currentPage + 2)
|
||||||
const pageNumbers = Array.from(
|
const pageNumbers = Array.from(
|
||||||
@@ -154,7 +133,6 @@ export default async function SearchPage({
|
|||||||
<div className="mx-auto max-w-6xl px-4 py-6">
|
<div className="mx-auto max-w-6xl px-4 py-6">
|
||||||
<h1 className="mb-6 text-2xl font-bold text-foreground">Tìm Kiếm Truyện</h1>
|
<h1 className="mb-6 text-2xl font-bold text-foreground">Tìm Kiếm Truyện</h1>
|
||||||
|
|
||||||
{/* Search and Filters - This requires a client component wrapper ideally, but for now we can rely on standard form submissions to update searchParams */}
|
|
||||||
<form method="GET" action="/tim-kiem">
|
<form method="GET" action="/tim-kiem">
|
||||||
<div className="relative mb-4">
|
<div className="relative mb-4">
|
||||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
@@ -189,11 +167,10 @@ export default async function SearchPage({
|
|||||||
<option value="name">Theo tên</option>
|
<option value="name">Theo tên</option>
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<button type="submit" className="h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium">Lọc</button>
|
<button type="submit" className="h-9 rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground">Lọc</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{/* Results */}
|
|
||||||
<p className="mb-4 text-sm text-muted-foreground">
|
<p className="mb-4 text-sm text-muted-foreground">
|
||||||
{totalResults} kết quả {totalResults > 0 && `(Trang ${currentPage}/${totalPages})`}
|
{totalResults} kết quả {totalResults > 0 && `(Trang ${currentPage}/${totalPages})`}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -5,12 +5,41 @@ import { Button } from "@/components/ui/button"
|
|||||||
import { CommentSection } from "@/components/comment-section"
|
import { CommentSection } from "@/components/comment-section"
|
||||||
import { ReaderFAB } from "@/components/reader-fab"
|
import { ReaderFAB } from "@/components/reader-fab"
|
||||||
import { ChapterReaderProgress } from "./chapter-reader-progress"
|
import { ChapterReaderProgress } from "./chapter-reader-progress"
|
||||||
import { prisma } from "@/lib/prisma"
|
import { readerApiFetch, readerApiFetchNullable } from "@/lib/server-api"
|
||||||
import connectToMongoDB from "@/lib/mongoose"
|
|
||||||
import { Chapter as ChapterModel } from "@/lib/models/chapter"
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic"
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
|
type NovelDetail = {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
slug: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type ChapterDetail = {
|
||||||
|
id: string
|
||||||
|
novelId: string
|
||||||
|
number: number
|
||||||
|
title: string
|
||||||
|
content: string
|
||||||
|
volumeNumber?: number | null
|
||||||
|
volumeTitle?: string | null
|
||||||
|
volumeChapterNumber?: number | null
|
||||||
|
prevChapterNumber?: number | null
|
||||||
|
nextChapterNumber?: number | null
|
||||||
|
maxChapter: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type CommentsResponse = {
|
||||||
|
comments: Array<{
|
||||||
|
id: string
|
||||||
|
userId: string
|
||||||
|
username: string
|
||||||
|
content: string
|
||||||
|
chapterId?: string | null
|
||||||
|
createdAt: string | null
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
export default async function ChapterReaderPage({ params }: { params: Promise<{ slug: string; chapterId: string }> }) {
|
export default async function ChapterReaderPage({ params }: { params: Promise<{ slug: string; chapterId: string }> }) {
|
||||||
const { slug, chapterId } = await params
|
const { slug, chapterId } = await params
|
||||||
const chapterNumber = parseInt(chapterId, 10)
|
const chapterNumber = parseInt(chapterId, 10)
|
||||||
@@ -19,57 +48,41 @@ export default async function ChapterReaderPage({ params }: { params: Promise<{
|
|||||||
notFound()
|
notFound()
|
||||||
}
|
}
|
||||||
|
|
||||||
const novel = await prisma.novel.findUnique({
|
const novel = await readerApiFetchNullable<NovelDetail>(`/api/novels/${encodeURIComponent(slug)}`)
|
||||||
where: { slug }
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!novel) {
|
if (!novel) {
|
||||||
notFound()
|
notFound()
|
||||||
}
|
}
|
||||||
|
|
||||||
await connectToMongoDB()
|
const chapter = await readerApiFetchNullable<ChapterDetail>(`/api/truyen/${encodeURIComponent(novel.id)}/chapters/by-number/${chapterNumber}`)
|
||||||
const chapter = await ChapterModel.findOne({ novelId: novel.id, number: chapterNumber }).lean()
|
|
||||||
|
|
||||||
if (!chapter) {
|
if (!chapter) {
|
||||||
notFound()
|
notFound()
|
||||||
}
|
}
|
||||||
|
|
||||||
const maxChapter = await ChapterModel.countDocuments({ novelId: novel.id })
|
const commentsData = await readerApiFetch<CommentsResponse>(
|
||||||
|
`/api/truyen/${encodeURIComponent(novel.id)}/comments?chapterId=${encodeURIComponent(chapter.id)}&page=1&limit=50`
|
||||||
|
)
|
||||||
|
|
||||||
const commentsData = await prisma.comment.findMany({
|
const comments = commentsData.comments.map((comment) => ({
|
||||||
where: { novelId: novel.id, chapterId: chapter._id.toString() },
|
id: comment.id,
|
||||||
include: { user: true },
|
userId: comment.userId,
|
||||||
orderBy: { createdAt: "desc" }
|
username: comment.username || "User",
|
||||||
})
|
avatarColor: "bg-primary",
|
||||||
|
novelId: novel.id,
|
||||||
const comments = commentsData.map(c => ({
|
chapterId: comment.chapterId || undefined,
|
||||||
id: c.id,
|
content: comment.content,
|
||||||
userId: c.user.id,
|
createdAt: comment.createdAt ? comment.createdAt.split("T")[0] : "",
|
||||||
username: c.user.name || "User",
|
|
||||||
avatarColor: c.user.image || "bg-primary",
|
|
||||||
novelId: c.novelId,
|
|
||||||
chapterId: c.chapterId || undefined,
|
|
||||||
content: c.content,
|
|
||||||
createdAt: c.createdAt.toISOString().split("T")[0]
|
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// Increment chapter views quietly (fire and forget to not block render)
|
const hasPrev = Boolean(chapter.prevChapterNumber)
|
||||||
ChapterModel.updateOne({ _id: chapter._id }, { $inc: { views: 1 } })
|
const hasNext = Boolean(chapter.nextChapterNumber)
|
||||||
.catch(e => console.error("Error updating chapter views:", e))
|
|
||||||
|
|
||||||
const hasPrev = chapterNumber > 1
|
|
||||||
const hasNext = chapterNumber < maxChapter
|
|
||||||
|
|
||||||
// Extract paragraphs for TTS
|
|
||||||
const paragraphs = chapter.content.split("\n").map((p: string) => p.trim()).filter(Boolean)
|
const paragraphs = chapter.content.split("\n").map((p: string) => p.trim()).filter(Boolean)
|
||||||
const chapterLabel = (chapter as any).volumeChapterNumber ? `Chương ${(chapter as any).volumeChapterNumber}` : `Chương ${chapter.number}`
|
const chapterLabel = chapter.volumeChapterNumber ? `Chương ${chapter.volumeChapterNumber}` : `Chương ${chapter.number}`
|
||||||
const volumeLabel = (chapter as any).volumeTitle || ((chapter as any).volumeNumber ? `Quyển ${(chapter as any).volumeNumber}` : null)
|
const volumeLabel = chapter.volumeTitle || (chapter.volumeNumber ? `Quyển ${chapter.volumeNumber}` : null)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-4xl px-3 py-4 md:px-8 md:py-6 lg:max-w-screen-lg">
|
<div className="mx-auto max-w-4xl px-3 py-4 md:px-8 md:py-6 lg:max-w-screen-lg">
|
||||||
{/* Top navigation */}
|
|
||||||
<div className="mb-6 flex flex-col gap-3">
|
<div className="mb-6 flex flex-col gap-3">
|
||||||
<Link href={`/truyen/${slug}`} className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors">
|
<Link href={`/truyen/${slug}`} className="inline-flex items-center gap-1 text-sm text-muted-foreground transition-colors hover:text-foreground">
|
||||||
<ChevronLeft className="h-4 w-4" /> {novel.title}
|
<ChevronLeft className="h-4 w-4" /> {novel.title}
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
@@ -80,11 +93,10 @@ export default async function ChapterReaderPage({ params }: { params: Promise<{
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Chapter navigation top */}
|
|
||||||
<div className="mb-6 flex items-center justify-between gap-2">
|
<div className="mb-6 flex items-center justify-between gap-2">
|
||||||
<Button variant="outline" size="sm" disabled={!hasPrev} asChild={hasPrev}>
|
<Button variant="outline" size="sm" disabled={!hasPrev} asChild={hasPrev}>
|
||||||
{hasPrev ? (
|
{hasPrev ? (
|
||||||
<Link href={`/truyen/${slug}/${chapterNumber - 1}`}>
|
<Link href={`/truyen/${slug}/${chapter.prevChapterNumber}`}>
|
||||||
<ChevronLeft className="mr-1 h-4 w-4" />
|
<ChevronLeft className="mr-1 h-4 w-4" />
|
||||||
<span className="hidden sm:inline">Ch. trước</span>
|
<span className="hidden sm:inline">Ch. trước</span>
|
||||||
<span className="sm:hidden">Trước</span>
|
<span className="sm:hidden">Trước</span>
|
||||||
@@ -104,7 +116,7 @@ export default async function ChapterReaderPage({ params }: { params: Promise<{
|
|||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" size="sm" disabled={!hasNext} asChild={hasNext}>
|
<Button variant="outline" size="sm" disabled={!hasNext} asChild={hasNext}>
|
||||||
{hasNext ? (
|
{hasNext ? (
|
||||||
<Link href={`/truyen/${slug}/${chapterNumber + 1}`}>
|
<Link href={`/truyen/${slug}/${chapter.nextChapterNumber}`}>
|
||||||
<span className="hidden sm:inline">Ch. sau</span>
|
<span className="hidden sm:inline">Ch. sau</span>
|
||||||
<span className="sm:hidden">Sau</span>
|
<span className="sm:hidden">Sau</span>
|
||||||
<ChevronRight className="ml-1 h-4 w-4" />
|
<ChevronRight className="ml-1 h-4 w-4" />
|
||||||
@@ -119,7 +131,6 @@ export default async function ChapterReaderPage({ params }: { params: Promise<{
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Chapter content */}
|
|
||||||
<article className="chapter-content mb-8 rounded-lg border border-border bg-card p-4 font-serif text-foreground/90 text-justify md:p-8 lg:p-12">
|
<article className="chapter-content mb-8 rounded-lg border border-border bg-card p-4 font-serif text-foreground/90 text-justify md:p-8 lg:p-12">
|
||||||
{paragraphs.map((text: string, idx: number) => (
|
{paragraphs.map((text: string, idx: number) => (
|
||||||
<p key={idx} data-p-index={idx} className="mb-4 last:mb-0">
|
<p key={idx} data-p-index={idx} className="mb-4 last:mb-0">
|
||||||
@@ -128,11 +139,10 @@ export default async function ChapterReaderPage({ params }: { params: Promise<{
|
|||||||
))}
|
))}
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
{/* Chapter navigation bottom */}
|
|
||||||
<div className="mb-8 flex items-center justify-between gap-2">
|
<div className="mb-8 flex items-center justify-between gap-2">
|
||||||
<Button variant="outline" size="sm" disabled={!hasPrev} asChild={hasPrev}>
|
<Button variant="outline" size="sm" disabled={!hasPrev} asChild={hasPrev}>
|
||||||
{hasPrev ? (
|
{hasPrev ? (
|
||||||
<Link href={`/truyen/${slug}/${chapterNumber - 1}`}>
|
<Link href={`/truyen/${slug}/${chapter.prevChapterNumber}`}>
|
||||||
<ChevronLeft className="mr-1 h-4 w-4" />
|
<ChevronLeft className="mr-1 h-4 w-4" />
|
||||||
<span className="hidden sm:inline">Chương trước</span>
|
<span className="hidden sm:inline">Chương trước</span>
|
||||||
<span className="sm:hidden">Trước</span>
|
<span className="sm:hidden">Trước</span>
|
||||||
@@ -147,7 +157,7 @@ export default async function ChapterReaderPage({ params }: { params: Promise<{
|
|||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" size="sm" disabled={!hasNext} asChild={hasNext}>
|
<Button variant="outline" size="sm" disabled={!hasNext} asChild={hasNext}>
|
||||||
{hasNext ? (
|
{hasNext ? (
|
||||||
<Link href={`/truyen/${slug}/${chapterNumber + 1}`}>
|
<Link href={`/truyen/${slug}/${chapter.nextChapterNumber}`}>
|
||||||
<span className="hidden sm:inline">Chương sau</span>
|
<span className="hidden sm:inline">Chương sau</span>
|
||||||
<span className="sm:hidden">Sau</span>
|
<span className="sm:hidden">Sau</span>
|
||||||
<ChevronRight className="ml-1 h-4 w-4" />
|
<ChevronRight className="ml-1 h-4 w-4" />
|
||||||
@@ -162,21 +172,18 @@ export default async function ChapterReaderPage({ params }: { params: Promise<{
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Save reading progress */}
|
<ChapterReaderProgress novelId={novel.id} chapterId={chapter.id} chapterNumber={chapter.number} />
|
||||||
<ChapterReaderProgress novelId={novel.id} chapterId={chapter._id.toString()} chapterNumber={chapter.number} />
|
|
||||||
|
|
||||||
{/* Comments */}
|
|
||||||
<section className="border-t border-border pt-8">
|
<section className="border-t border-border pt-8">
|
||||||
<CommentSection comments={comments} novelId={novel.id} chapterId={chapter._id.toString()} />
|
<CommentSection comments={comments} novelId={novel.id} chapterId={chapter.id} />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Floating Reader Actions & TTS Player */}
|
|
||||||
<ReaderFAB
|
<ReaderFAB
|
||||||
novelId={novel.id}
|
novelId={novel.id}
|
||||||
novelSlug={slug}
|
novelSlug={slug}
|
||||||
paragraphs={paragraphs}
|
paragraphs={paragraphs}
|
||||||
currentChapter={chapterNumber}
|
currentChapter={chapterNumber}
|
||||||
maxChapter={maxChapter}
|
maxChapter={chapter.maxChapter}
|
||||||
chapterTitle={`${volumeLabel ? `${volumeLabel} - ` : ""}${chapterLabel}: ${chapter.title}`}
|
chapterTitle={`${volumeLabel ? `${volumeLabel} - ` : ""}${chapterLabel}: ${chapter.title}`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+110
-93
@@ -7,13 +7,76 @@ import { StarRating } from "@/components/star-rating"
|
|||||||
import { ChapterList } from "@/components/chapter-list"
|
import { ChapterList } from "@/components/chapter-list"
|
||||||
import { CommentSection } from "@/components/comment-section"
|
import { CommentSection } from "@/components/comment-section"
|
||||||
import { NovelDetailActions } from "./novel-detail-actions"
|
import { NovelDetailActions } from "./novel-detail-actions"
|
||||||
import { prisma } from "@/lib/prisma"
|
|
||||||
import connectToMongoDB from "@/lib/mongoose"
|
|
||||||
import { Chapter } from "@/lib/models/chapter"
|
|
||||||
import { getNovelStatusBadgeClass } from "@/lib/novel-status"
|
import { getNovelStatusBadgeClass } from "@/lib/novel-status"
|
||||||
|
import { readerApiFetch, readerApiFetchNullable } from "@/lib/server-api"
|
||||||
|
|
||||||
export const dynamic = "force-dynamic"
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
|
type NovelGenre = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
slug: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type SeriesNovel = {
|
||||||
|
id: string
|
||||||
|
slug: string
|
||||||
|
title: string
|
||||||
|
status: string
|
||||||
|
totalChapters: number
|
||||||
|
coverUrl: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
type NovelDetail = {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
slug: string
|
||||||
|
originalTitle: string | null
|
||||||
|
authorName: string
|
||||||
|
originalAuthorName: string | null
|
||||||
|
description: string | null
|
||||||
|
coverUrl: string | null
|
||||||
|
status: string
|
||||||
|
totalChapters: number
|
||||||
|
views: number
|
||||||
|
ratingCount: number
|
||||||
|
bookmarkCount: number
|
||||||
|
seriesId: string | null
|
||||||
|
genres: NovelGenre[]
|
||||||
|
series: {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
slug: string
|
||||||
|
novels: SeriesNovel[]
|
||||||
|
} | null
|
||||||
|
}
|
||||||
|
|
||||||
|
type ChaptersResponse = {
|
||||||
|
chapters: Array<{
|
||||||
|
id: string
|
||||||
|
number: number
|
||||||
|
title: string
|
||||||
|
views: number
|
||||||
|
createdAt: string | null
|
||||||
|
volumeNumber?: number | null
|
||||||
|
volumeTitle?: string | null
|
||||||
|
volumeChapterNumber?: number | null
|
||||||
|
}>
|
||||||
|
totalChapters: number
|
||||||
|
totalPages: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type CommentsResponse = {
|
||||||
|
comments: Array<{
|
||||||
|
id: string
|
||||||
|
userId: string
|
||||||
|
username: string
|
||||||
|
content: string
|
||||||
|
chapterId?: string | null
|
||||||
|
createdAt: string | null
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
export default async function NovelDetailPage({
|
export default async function NovelDetailPage({
|
||||||
params,
|
params,
|
||||||
searchParams
|
searchParams
|
||||||
@@ -24,17 +87,10 @@ export default async function NovelDetailPage({
|
|||||||
const { slug } = await params
|
const { slug } = await params
|
||||||
const { page } = await searchParams
|
const { page } = await searchParams
|
||||||
|
|
||||||
const currentPage = parseInt(page || "1")
|
const currentPage = Math.max(1, parseInt(page || "1", 10) || 1)
|
||||||
const limit = 20
|
const limit = 20
|
||||||
|
|
||||||
const novel = await prisma.novel.findUnique({
|
const novel = await readerApiFetchNullable<NovelDetail>(`/api/novels/${encodeURIComponent(slug)}`)
|
||||||
where: { slug },
|
|
||||||
include: {
|
|
||||||
genres: {
|
|
||||||
include: { genre: true }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!novel) {
|
if (!novel) {
|
||||||
notFound()
|
notFound()
|
||||||
@@ -51,97 +107,58 @@ export default async function NovelDetailPage({
|
|||||||
status: string
|
status: string
|
||||||
totalChapters: number
|
totalChapters: number
|
||||||
coverUrl: string | null
|
coverUrl: string | null
|
||||||
updatedAt: Date
|
|
||||||
}> = []
|
}> = []
|
||||||
|
const [firstChapterData, commentsData, chapterCommentsData, chaptersData] = await Promise.all([
|
||||||
|
readerApiFetch<ChaptersResponse>(`/api/truyen/${encodeURIComponent(novel.id)}/chapters?page=1&limit=1`),
|
||||||
|
readerApiFetch<CommentsResponse>(`/api/truyen/${encodeURIComponent(novel.id)}/comments?page=1&limit=50`),
|
||||||
|
readerApiFetch<CommentsResponse>(`/api/truyen/${encodeURIComponent(novel.id)}/comments?scope=chapter&page=1&limit=50`),
|
||||||
|
novel.seriesId
|
||||||
|
? Promise.resolve(null)
|
||||||
|
: readerApiFetch<ChaptersResponse>(`/api/truyen/${encodeURIComponent(novel.id)}/chapters?page=${currentPage}&limit=${limit}`),
|
||||||
|
])
|
||||||
|
|
||||||
await connectToMongoDB()
|
firstChapterNumber = firstChapterData.chapters[0]?.number
|
||||||
|
|
||||||
if (novel.seriesId) {
|
if (novel.seriesId) {
|
||||||
const [firstChapter, volumes] = await Promise.all([
|
seriesVolumes = novel.series?.novels || []
|
||||||
Chapter.findOne({ novelId: novel.id }).sort({ number: 1 }).select("number").lean(),
|
} else if (chaptersData) {
|
||||||
prisma.novel.findMany({
|
totalChapters = chaptersData.totalChapters
|
||||||
where: { seriesId: novel.seriesId },
|
totalPages = Math.max(1, chaptersData.totalPages || 1)
|
||||||
select: {
|
formattedChapters = chaptersData.chapters.map((chapter) => ({
|
||||||
id: true,
|
id: chapter.id,
|
||||||
slug: true,
|
novelId: novel.id,
|
||||||
title: true,
|
number: chapter.number,
|
||||||
status: true,
|
volumeNumber: chapter.volumeNumber ?? null,
|
||||||
totalChapters: true,
|
volumeTitle: chapter.volumeTitle ?? null,
|
||||||
coverUrl: true,
|
volumeChapterNumber: chapter.volumeChapterNumber ?? null,
|
||||||
updatedAt: true,
|
title: chapter.title,
|
||||||
},
|
createdAt: chapter.createdAt ? chapter.createdAt.split("T")[0] : "",
|
||||||
orderBy: { createdAt: "asc" },
|
views: chapter.views || 0,
|
||||||
}),
|
content: "",
|
||||||
])
|
|
||||||
|
|
||||||
firstChapterNumber = (firstChapter as any)?.number
|
|
||||||
seriesVolumes = volumes
|
|
||||||
} else {
|
|
||||||
const skip = (currentPage - 1) * limit
|
|
||||||
const [chapters, chaptersCount, firstChapter] = await Promise.all([
|
|
||||||
Chapter.find({ novelId: novel.id })
|
|
||||||
.sort({ number: 1 })
|
|
||||||
.skip(skip)
|
|
||||||
.limit(limit)
|
|
||||||
.select("id novelId number title createdAt views volumeNumber volumeTitle volumeChapterNumber")
|
|
||||||
.lean(),
|
|
||||||
Chapter.countDocuments({ novelId: novel.id }),
|
|
||||||
Chapter.findOne({ novelId: novel.id }).sort({ number: 1 }).select("number").lean(),
|
|
||||||
])
|
|
||||||
|
|
||||||
totalChapters = chaptersCount
|
|
||||||
totalPages = Math.ceil(totalChapters / limit)
|
|
||||||
firstChapterNumber = (firstChapter as any)?.number
|
|
||||||
|
|
||||||
formattedChapters = chapters.map(c => ({
|
|
||||||
id: c._id.toString(),
|
|
||||||
novelId: c.novelId,
|
|
||||||
number: c.number,
|
|
||||||
volumeNumber: (c as any).volumeNumber ?? null,
|
|
||||||
volumeTitle: (c as any).volumeTitle ?? null,
|
|
||||||
volumeChapterNumber: (c as any).volumeChapterNumber ?? null,
|
|
||||||
title: c.title,
|
|
||||||
createdAt: c.createdAt ? (c.createdAt as Date).toISOString() : new Date().toISOString(),
|
|
||||||
views: c.views || 0,
|
|
||||||
content: ""
|
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
const commentsData = await prisma.comment.findMany({
|
const comments = commentsData.comments.map((comment) => ({
|
||||||
where: { novelId: novel.id, chapterId: null },
|
id: comment.id,
|
||||||
include: { user: true },
|
userId: comment.userId,
|
||||||
orderBy: { createdAt: "desc" }
|
username: comment.username || "User",
|
||||||
})
|
avatarColor: "bg-primary",
|
||||||
|
novelId: novel.id,
|
||||||
// Format explicitly as the CommentProp type
|
content: comment.content,
|
||||||
const comments = commentsData.map(c => ({
|
createdAt: comment.createdAt ? comment.createdAt.split("T")[0] : "",
|
||||||
id: c.id,
|
|
||||||
userId: c.user.id,
|
|
||||||
username: c.user.name || "User",
|
|
||||||
avatarColor: c.user.image || "bg-primary",
|
|
||||||
novelId: c.novelId,
|
|
||||||
content: c.content,
|
|
||||||
createdAt: c.createdAt.toISOString().split("T")[0]
|
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const chapterCommentsData = await prisma.comment.findMany({
|
const chapterComments = chapterCommentsData.comments.map((comment) => ({
|
||||||
where: { novelId: novel.id, chapterId: { not: null } },
|
id: comment.id,
|
||||||
include: { user: true },
|
userId: comment.userId,
|
||||||
orderBy: { createdAt: "desc" }
|
username: comment.username || "User",
|
||||||
})
|
avatarColor: "bg-primary",
|
||||||
|
novelId: novel.id,
|
||||||
// Format explicitly as the CommentProp type
|
content: comment.content,
|
||||||
const chapterComments = chapterCommentsData.map(c => ({
|
createdAt: comment.createdAt ? comment.createdAt.split("T")[0] : "",
|
||||||
id: c.id,
|
|
||||||
userId: c.user.id,
|
|
||||||
username: c.user.name || "User",
|
|
||||||
avatarColor: c.user.image || "bg-primary",
|
|
||||||
novelId: c.novelId,
|
|
||||||
content: c.content,
|
|
||||||
createdAt: c.createdAt.toISOString().split("T")[0]
|
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const novelGenres = novel.genres.map(ng => ng.genre) || []
|
const novelGenres = novel.genres || []
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-6xl px-4 py-6">
|
<div className="mx-auto max-w-6xl px-4 py-6">
|
||||||
@@ -222,7 +239,7 @@ export default async function NovelDetailPage({
|
|||||||
{/* Description */}
|
{/* Description */}
|
||||||
<section className="mt-8">
|
<section className="mt-8">
|
||||||
<h2 className="mb-3 text-lg font-bold text-foreground">Giới Thiệu</h2>
|
<h2 className="mb-3 text-lg font-bold text-foreground">Giới Thiệu</h2>
|
||||||
<div className="text-sm leading-relaxed text-foreground/80 whitespace-pre-wrap">{novel.description}</div>
|
<div className="text-sm leading-relaxed text-foreground/80 whitespace-pre-wrap">{novel.description || ""}</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Chapter list or series volumes */}
|
{/* Chapter list or series volumes */}
|
||||||
|
|||||||
+46
-14
@@ -1,10 +1,20 @@
|
|||||||
import { NextAuthOptions } from "next-auth"
|
import { NextAuthOptions } from "next-auth"
|
||||||
import GoogleProvider from "next-auth/providers/google"
|
import GoogleProvider from "next-auth/providers/google"
|
||||||
import { PrismaAdapter } from "@auth/prisma-adapter"
|
|
||||||
import { prisma } from "./prisma"
|
const readerApiOrigin = (process.env.READER_API_ORIGIN || "http://localhost:8000").replace(/\/+$/, "")
|
||||||
|
|
||||||
|
type MobileLoginResponse = {
|
||||||
|
accessToken: string
|
||||||
|
user: {
|
||||||
|
id: string
|
||||||
|
email?: string | null
|
||||||
|
name?: string | null
|
||||||
|
image?: string | null
|
||||||
|
role?: string | null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const authOptions: NextAuthOptions = {
|
export const authOptions: NextAuthOptions = {
|
||||||
adapter: PrismaAdapter(prisma) as any, // ép kiểu vì type mismatch nhỏ
|
|
||||||
providers: [
|
providers: [
|
||||||
GoogleProvider({
|
GoogleProvider({
|
||||||
clientId: process.env.GOOGLE_CLIENT_ID || "demo-id",
|
clientId: process.env.GOOGLE_CLIENT_ID || "demo-id",
|
||||||
@@ -12,24 +22,46 @@ export const authOptions: NextAuthOptions = {
|
|||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
session: {
|
session: {
|
||||||
// Để giữ NextAuth dùng JWT thay vì lưu phiên vào DB nếu thích, nhưng khi dùng PrismaAdapter, mặc định nó dùng DB strategy.
|
strategy: "jwt",
|
||||||
// strategy: "jwt",
|
|
||||||
},
|
},
|
||||||
callbacks: {
|
callbacks: {
|
||||||
async session({ session, user }) {
|
async jwt({ token, account }) {
|
||||||
if (session.user) {
|
if (account?.provider === "google" && account.id_token) {
|
||||||
// Lấy role từ DB gán vào session
|
try {
|
||||||
const dbUser = await prisma.user.findUnique({
|
const response = await fetch(`${readerApiOrigin}/api/auth/mobile-login`, {
|
||||||
where: { email: session.user.email as string },
|
method: "POST",
|
||||||
select: { role: true, id: true },
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ googleIdToken: account.id_token }),
|
||||||
})
|
})
|
||||||
|
|
||||||
session.user.id = dbUser?.id || user.id
|
if (response.ok) {
|
||||||
session.user.role = dbUser?.role || "USER"
|
const data = (await response.json()) as MobileLoginResponse
|
||||||
|
token.sub = data.user.id
|
||||||
|
;(token as any).id = data.user.id
|
||||||
|
;(token as any).role = data.user.role || "USER"
|
||||||
|
;(token as any).name = data.user.name || token.name || null
|
||||||
|
;(token as any).email = data.user.email || token.email || null
|
||||||
|
;(token as any).picture = data.user.image || (token as any).picture || null
|
||||||
|
;(token as any).accessToken = data.accessToken
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to sync Google login with reader-api", error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return token
|
||||||
|
},
|
||||||
|
async session({ session, token }) {
|
||||||
|
if (session.user) {
|
||||||
|
session.user.id = String((token as any).id || token.sub || "")
|
||||||
|
session.user.role = String((token as any).role || "USER")
|
||||||
|
session.user.name = ((token as any).name ?? token.name ?? session.user.name ?? null) as string | null
|
||||||
|
session.user.email = ((token as any).email ?? token.email ?? session.user.email ?? null) as string | null
|
||||||
|
session.user.image = ((token as any).picture ?? (token as any).image ?? session.user.image ?? null) as string | null
|
||||||
|
;(session as any).accessToken = (token as any).accessToken || null
|
||||||
}
|
}
|
||||||
return session
|
return session
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
// Tuân thủ bảo mật NextAuth
|
|
||||||
secret: process.env.NEXTAUTH_SECRET,
|
secret: process.env.NEXTAUTH_SECRET,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
const apiBaseUrl = (process.env.READER_API_ORIGIN || "http://localhost:8000").replace(/\/+$/, "")
|
||||||
|
|
||||||
|
export class ReaderApiError extends Error {
|
||||||
|
status: number
|
||||||
|
|
||||||
|
constructor(status: number, message: string) {
|
||||||
|
super(message)
|
||||||
|
this.status = status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildApiUrl(path: string) {
|
||||||
|
return `${apiBaseUrl}${path.startsWith("/") ? path : `/${path}`}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readerApiFetch<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
|
const response = await fetch(buildApiUrl(path), {
|
||||||
|
...init,
|
||||||
|
cache: init?.cache ?? "no-store",
|
||||||
|
headers: {
|
||||||
|
Accept: "application/json",
|
||||||
|
...(init?.headers || {}),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
let detail = response.statusText
|
||||||
|
try {
|
||||||
|
const data = await response.json()
|
||||||
|
detail = data?.detail || data?.error || detail
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
throw new ReaderApiError(response.status, `Reader API error ${response.status}: ${detail}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json() as Promise<T>
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readerApiFetchNullable<T>(path: string, init?: RequestInit): Promise<T | null> {
|
||||||
|
try {
|
||||||
|
return await readerApiFetch<T>(path, init)
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ReaderApiError && error.status === 404) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -39,10 +39,6 @@ const nextConfig = {
|
|||||||
{
|
{
|
||||||
source: "/api/health",
|
source: "/api/health",
|
||||||
destination: `${readerApiOrigin}/api/health`,
|
destination: `${readerApiOrigin}/api/health`,
|
||||||
},
|
|
||||||
{
|
|
||||||
source: "/api/mod/:path*",
|
|
||||||
destination: `${readerApiOrigin}/api/mod/:path*`,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
source: "/api/dev/:path*",
|
source: "/api/dev/:path*",
|
||||||
|
|||||||
Vendored
+11
@@ -1,4 +1,5 @@
|
|||||||
import NextAuth from "next-auth"
|
import NextAuth from "next-auth"
|
||||||
|
import { JWT } from "next-auth/jwt"
|
||||||
|
|
||||||
declare module "next-auth" {
|
declare module "next-auth" {
|
||||||
interface Session {
|
interface Session {
|
||||||
@@ -9,9 +10,19 @@ declare module "next-auth" {
|
|||||||
image?: string | null
|
image?: string | null
|
||||||
role: string
|
role: string
|
||||||
}
|
}
|
||||||
|
accessToken?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
interface User {
|
interface User {
|
||||||
role: string
|
role: string
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
declare module "next-auth/jwt" {
|
||||||
|
interface JWT {
|
||||||
|
id?: string
|
||||||
|
role?: string
|
||||||
|
accessToken?: string | null
|
||||||
|
picture?: string | null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user