diff --git a/app/api/mod/[...path]/route.ts b/app/api/mod/[...path]/route.ts new file mode 100644 index 0000000..f0c6486 --- /dev/null +++ b/app/api/mod/[...path]/route.ts @@ -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) +} \ No newline at end of file diff --git a/app/mod/page.tsx b/app/mod/page.tsx index dfb0808..e1fdc22 100644 --- a/app/mod/page.tsx +++ b/app/mod/page.tsx @@ -1,46 +1,35 @@ import { getServerSession } from "next-auth" import { authOptions } from "@/lib/auth" -import { prisma } from "@/lib/prisma" import Link from "next/link" 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() { const session = await getServerSession(authOptions) - const novelWhere = session?.user.role === "ADMIN" - ? {} - : { - OR: [ - { uploaderId: session?.user.id }, - { uploaderId: null }, - ], + let novelCount = 0 + let totalViews = 0 + let commentCount = 0 + let seriesCount = 0 + + 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) } - - 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 + } catch (error) { + console.error("Failed to fetch mod overview", error) + } return (
diff --git a/app/page.tsx b/app/page.tsx index 0d459c8..c4b1b25 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -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 + recentComments: RecentCommentItem[] } -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 { +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(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, @@ -402,7 +163,7 @@ export default async function HomePage() { let randomNovels: HomeNovel[] = [] let recommendedByCountItems: RecommendedByCountItem[] = [] let editorRecommendedItems: EditorRecommendedItem[] = [] - let latestNovels: HomeNovel[] = [] + let latestNovels: Array = [] let recentComments: RecentCommentItem[] = [] let weeklyRanking: RankingEntry[] = [] let monthlyRanking: RankingEntry[] = [] @@ -410,158 +171,23 @@ export default async function HomePage() { 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 homeData = await readerApiFetch("/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>, - "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) + 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) diff --git a/app/the-loai/[slug]/page.tsx b/app/the-loai/[slug]/page.tsx index 87be0e3..6397d80 100644 --- a/app/the-loai/[slug]/page.tsx +++ b/app/the-loai/[slug]/page.tsx @@ -1,11 +1,36 @@ import Link from "next/link" import { ChevronLeft } from "lucide-react" -import { prisma } from "@/lib/prisma" import { NovelCard } from "@/components/novel-card" import { notFound } from "next/navigation" +import { readerApiFetch, readerApiFetchNullable } from "@/lib/server-api" 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(rows: T[]): T[] { const pickedSeries = new Set() const output: T[] = [] @@ -27,42 +52,16 @@ function collapseSeriesRows( export default async function GenreDetailPage({ params }: { params: Promise<{ slug: string }> }) { const { slug } = await params - const genre = await prisma.genre.findUnique({ - where: { slug } - }) + const genres = await readerApiFetch("/api/genres") + const genre = genres.find((item) => item.slug === slug) || null if (!genre) { notFound() } - const allNovelsRaw = await prisma.novel.findMany({ - 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 browse = await readerApiFetch(`/api/novels/browse?genre=${encodeURIComponent(slug)}&sort=latest&page=1&limit=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. return ( diff --git a/app/the-loai/page.tsx b/app/the-loai/page.tsx index 25cc2b2..43aa056 100644 --- a/app/the-loai/page.tsx +++ b/app/the-loai/page.tsx @@ -1,6 +1,6 @@ import Link from "next/link" 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 = { Sparkles: , @@ -17,17 +17,20 @@ const iconMap: Record = { 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() { - let genres: any[] = [] + let genres: GenreItem[] = [] try { - genres = await prisma.genre.findMany({ - include: { - _count: { - select: { novels: true } - } - } - }) + genres = await readerApiFetch("/api/genres") } catch (error) { console.error("Failed to fetch genres during build/runtime", error) } @@ -37,7 +40,7 @@ export default async function GenresPage() {

Thể Loại Truyện

{genres.map((genre) => { - const novelCount = genre._count.novels + const novelCount = genre.novelCount return ( (rows: T[]): T[] { const pickedSeries = new Set() const output: T[] = [] @@ -26,6 +52,11 @@ function collapseSeriesRows( return output } +function buildBrowsePath(params: Record) { + const search = new URLSearchParams(params) + return `/api/novels/browse?${search.toString()}` +} + export default async function SearchPage({ searchParams, }: { @@ -38,99 +69,47 @@ export default async function SearchPage({ const statusFilter = resolvedParams.statusFilter || "all" const requestedPage = Math.max(1, Number(resolvedParams.page || "1") || 1) - // Build where clause - let where: any = {} + const genres = await readerApiFetch("/api/genres") - if (q) { - 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 filteredNovels: BrowseNovel[] = [] let totalResults = 0 let totalPages = 1 let currentPage = requestedPage if (q) { - totalResults = await prisma.novel.count({ where }) - totalPages = Math.max(1, Math.ceil(totalResults / PAGE_SIZE)) - currentPage = Math.min(currentPage, totalPages) + const browse = await readerApiFetch( + buildBrowsePath({ + q, + sort: sortBy, + genre: genreFilter === "all" ? "" : genreFilter, + status: statusFilter === "all" ? "" : statusFilter, + page: String(requestedPage), + limit: String(PAGE_SIZE), + }) + ) - filteredNovels = await prisma.novel.findMany({ - where, - orderBy, - include: { - 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 { - const filteredNovelsRaw = await prisma.novel.findMany({ - where, - orderBy, - include: { - series: { - select: { - id: true, - name: true, - slug: true, - }, - }, - }, - take: 500, - }) + const browse = await readerApiFetch( + buildBrowsePath({ + sort: sortBy, + genre: genreFilter === "all" ? "" : genreFilter, + status: statusFilter === "all" ? "" : statusFilter, + page: "1", + limit: "500", + }) + ) - const collapsed = collapseSeriesRows(filteredNovelsRaw) + const collapsed = collapseSeriesRows(browse.items) totalResults = collapsed.length 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) } - const genres = await prisma.genre.findMany({ orderBy: { name: "asc" } }) - const pageRangeStart = Math.max(1, currentPage - 2) const pageRangeEnd = Math.min(totalPages, currentPage + 2) const pageNumbers = Array.from( @@ -154,7 +133,6 @@ export default async function SearchPage({

Tìm Kiếm Truyện

- {/* Search and Filters - This requires a client component wrapper ideally, but for now we can rely on standard form submissions to update searchParams */}
@@ -189,11 +167,10 @@ export default async function SearchPage({ - +
- {/* Results */}

{totalResults} kết quả {totalResults > 0 && `(Trang ${currentPage}/${totalPages})`}

diff --git a/app/truyen/[slug]/[chapterId]/page.tsx b/app/truyen/[slug]/[chapterId]/page.tsx index b431293..ae0ec6b 100644 --- a/app/truyen/[slug]/[chapterId]/page.tsx +++ b/app/truyen/[slug]/[chapterId]/page.tsx @@ -5,12 +5,41 @@ import { Button } from "@/components/ui/button" import { CommentSection } from "@/components/comment-section" import { ReaderFAB } from "@/components/reader-fab" import { ChapterReaderProgress } from "./chapter-reader-progress" -import { prisma } from "@/lib/prisma" -import connectToMongoDB from "@/lib/mongoose" -import { Chapter as ChapterModel } from "@/lib/models/chapter" +import { readerApiFetch, readerApiFetchNullable } from "@/lib/server-api" 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 }> }) { const { slug, chapterId } = await params const chapterNumber = parseInt(chapterId, 10) @@ -19,57 +48,41 @@ export default async function ChapterReaderPage({ params }: { params: Promise<{ notFound() } - const novel = await prisma.novel.findUnique({ - where: { slug } - }) - + const novel = await readerApiFetchNullable(`/api/novels/${encodeURIComponent(slug)}`) if (!novel) { notFound() } - await connectToMongoDB() - const chapter = await ChapterModel.findOne({ novelId: novel.id, number: chapterNumber }).lean() - + const chapter = await readerApiFetchNullable(`/api/truyen/${encodeURIComponent(novel.id)}/chapters/by-number/${chapterNumber}`) if (!chapter) { notFound() } - const maxChapter = await ChapterModel.countDocuments({ novelId: novel.id }) + const commentsData = await readerApiFetch( + `/api/truyen/${encodeURIComponent(novel.id)}/comments?chapterId=${encodeURIComponent(chapter.id)}&page=1&limit=50` + ) - const commentsData = await prisma.comment.findMany({ - where: { novelId: novel.id, chapterId: chapter._id.toString() }, - include: { user: true }, - orderBy: { createdAt: "desc" } - }) - - const comments = commentsData.map(c => ({ - id: c.id, - userId: c.user.id, - 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] + const comments = commentsData.comments.map((comment) => ({ + id: comment.id, + userId: comment.userId, + username: comment.username || "User", + avatarColor: "bg-primary", + novelId: novel.id, + chapterId: comment.chapterId || undefined, + content: comment.content, + createdAt: comment.createdAt ? comment.createdAt.split("T")[0] : "", })) - // Increment chapter views quietly (fire and forget to not block render) - ChapterModel.updateOne({ _id: chapter._id }, { $inc: { views: 1 } }) - .catch(e => console.error("Error updating chapter views:", e)) - - const hasPrev = chapterNumber > 1 - const hasNext = chapterNumber < maxChapter - - // Extract paragraphs for TTS + const hasPrev = Boolean(chapter.prevChapterNumber) + const hasNext = Boolean(chapter.nextChapterNumber) 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 volumeLabel = (chapter as any).volumeTitle || ((chapter as any).volumeNumber ? `Quyển ${(chapter as any).volumeNumber}` : null) + const chapterLabel = chapter.volumeChapterNumber ? `Chương ${chapter.volumeChapterNumber}` : `Chương ${chapter.number}` + const volumeLabel = chapter.volumeTitle || (chapter.volumeNumber ? `Quyển ${chapter.volumeNumber}` : null) return (
- {/* Top navigation */}
- + {novel.title} @@ -80,11 +93,10 @@ export default async function ChapterReaderPage({ params }: { params: Promise<{
- {/* Chapter navigation top */}
- {/* Chapter content */}
{paragraphs.map((text: string, idx: number) => (

@@ -128,11 +139,10 @@ export default async function ChapterReaderPage({ params }: { params: Promise<{ ))}

- {/* Chapter navigation bottom */}
- {/* Save reading progress */} - + - {/* Comments */}
- +
- {/* Floating Reader Actions & TTS Player */}
diff --git a/app/truyen/[slug]/page.tsx b/app/truyen/[slug]/page.tsx index a8a434c..033bc3c 100644 --- a/app/truyen/[slug]/page.tsx +++ b/app/truyen/[slug]/page.tsx @@ -7,13 +7,76 @@ import { StarRating } from "@/components/star-rating" import { ChapterList } from "@/components/chapter-list" import { CommentSection } from "@/components/comment-section" 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 { readerApiFetch, readerApiFetchNullable } from "@/lib/server-api" 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({ params, searchParams @@ -24,17 +87,10 @@ export default async function NovelDetailPage({ const { slug } = await params const { page } = await searchParams - const currentPage = parseInt(page || "1") + const currentPage = Math.max(1, parseInt(page || "1", 10) || 1) const limit = 20 - const novel = await prisma.novel.findUnique({ - where: { slug }, - include: { - genres: { - include: { genre: true } - } - } - }) + const novel = await readerApiFetchNullable(`/api/novels/${encodeURIComponent(slug)}`) if (!novel) { notFound() @@ -51,97 +107,58 @@ export default async function NovelDetailPage({ status: string totalChapters: number coverUrl: string | null - updatedAt: Date }> = [] + const [firstChapterData, commentsData, chapterCommentsData, chaptersData] = await Promise.all([ + readerApiFetch(`/api/truyen/${encodeURIComponent(novel.id)}/chapters?page=1&limit=1`), + readerApiFetch(`/api/truyen/${encodeURIComponent(novel.id)}/comments?page=1&limit=50`), + readerApiFetch(`/api/truyen/${encodeURIComponent(novel.id)}/comments?scope=chapter&page=1&limit=50`), + novel.seriesId + ? Promise.resolve(null) + : readerApiFetch(`/api/truyen/${encodeURIComponent(novel.id)}/chapters?page=${currentPage}&limit=${limit}`), + ]) - await connectToMongoDB() + firstChapterNumber = firstChapterData.chapters[0]?.number if (novel.seriesId) { - const [firstChapter, volumes] = await Promise.all([ - Chapter.findOne({ novelId: novel.id }).sort({ number: 1 }).select("number").lean(), - prisma.novel.findMany({ - where: { seriesId: novel.seriesId }, - select: { - id: true, - slug: true, - title: true, - status: true, - totalChapters: true, - coverUrl: true, - updatedAt: true, - }, - orderBy: { createdAt: "asc" }, - }), - ]) - - 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: "" + seriesVolumes = novel.series?.novels || [] + } else if (chaptersData) { + totalChapters = chaptersData.totalChapters + totalPages = Math.max(1, chaptersData.totalPages || 1) + formattedChapters = chaptersData.chapters.map((chapter) => ({ + id: chapter.id, + novelId: novel.id, + number: chapter.number, + volumeNumber: chapter.volumeNumber ?? null, + volumeTitle: chapter.volumeTitle ?? null, + volumeChapterNumber: chapter.volumeChapterNumber ?? null, + title: chapter.title, + createdAt: chapter.createdAt ? chapter.createdAt.split("T")[0] : "", + views: chapter.views || 0, + content: "", })) } - const commentsData = await prisma.comment.findMany({ - where: { novelId: novel.id, chapterId: null }, - include: { user: true }, - orderBy: { createdAt: "desc" } - }) - - // Format explicitly as the CommentProp type - const comments = commentsData.map(c => ({ - 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 comments = commentsData.comments.map((comment) => ({ + id: comment.id, + userId: comment.userId, + username: comment.username || "User", + avatarColor: "bg-primary", + novelId: novel.id, + content: comment.content, + createdAt: comment.createdAt ? comment.createdAt.split("T")[0] : "", })) - const chapterCommentsData = await prisma.comment.findMany({ - where: { novelId: novel.id, chapterId: { not: null } }, - include: { user: true }, - orderBy: { createdAt: "desc" } - }) - - // Format explicitly as the CommentProp type - const chapterComments = chapterCommentsData.map(c => ({ - 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 chapterComments = chapterCommentsData.comments.map((comment) => ({ + id: comment.id, + userId: comment.userId, + username: comment.username || "User", + avatarColor: "bg-primary", + novelId: novel.id, + content: comment.content, + createdAt: comment.createdAt ? comment.createdAt.split("T")[0] : "", })) - const novelGenres = novel.genres.map(ng => ng.genre) || [] + const novelGenres = novel.genres || [] return (
@@ -222,7 +239,7 @@ export default async function NovelDetailPage({ {/* Description */}

Giới Thiệu

-
{novel.description}
+
{novel.description || ""}
{/* Chapter list or series volumes */} diff --git a/lib/auth.ts b/lib/auth.ts index 38c8c5b..0784231 100644 --- a/lib/auth.ts +++ b/lib/auth.ts @@ -1,10 +1,20 @@ import { NextAuthOptions } from "next-auth" 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 = { - adapter: PrismaAdapter(prisma) as any, // ép kiểu vì type mismatch nhỏ providers: [ GoogleProvider({ clientId: process.env.GOOGLE_CLIENT_ID || "demo-id", @@ -12,24 +22,46 @@ export const authOptions: NextAuthOptions = { }), ], 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: { - async session({ session, user }) { - if (session.user) { - // Lấy role từ DB gán vào session - const dbUser = await prisma.user.findUnique({ - where: { email: session.user.email as string }, - select: { role: true, id: true }, - }) + async jwt({ token, account }) { + if (account?.provider === "google" && account.id_token) { + try { + const response = await fetch(`${readerApiOrigin}/api/auth/mobile-login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ googleIdToken: account.id_token }), + }) - session.user.id = dbUser?.id || user.id - session.user.role = dbUser?.role || "USER" + if (response.ok) { + 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 }, }, - // Tuân thủ bảo mật NextAuth secret: process.env.NEXTAUTH_SECRET, } diff --git a/lib/server-api.ts b/lib/server-api.ts new file mode 100644 index 0000000..2bc9eac --- /dev/null +++ b/lib/server-api.ts @@ -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(path: string, init?: RequestInit): Promise { + 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 +} + +export async function readerApiFetchNullable(path: string, init?: RequestInit): Promise { + try { + return await readerApiFetch(path, init) + } catch (error) { + if (error instanceof ReaderApiError && error.status === 404) { + return null + } + throw error + } +} \ No newline at end of file diff --git a/next.config.mjs b/next.config.mjs index 18665fa..d0d8a02 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -40,10 +40,6 @@ const nextConfig = { source: "/api/health", destination: `${readerApiOrigin}/api/health`, }, - { - source: "/api/mod/:path*", - destination: `${readerApiOrigin}/api/mod/:path*`, - }, { source: "/api/dev/:path*", destination: `${readerApiOrigin}/api/dev/:path*`, diff --git a/types/next-auth.d.ts b/types/next-auth.d.ts index 5254655..a99a82a 100644 --- a/types/next-auth.d.ts +++ b/types/next-auth.d.ts @@ -1,4 +1,5 @@ import NextAuth from "next-auth" +import { JWT } from "next-auth/jwt" declare module "next-auth" { interface Session { @@ -9,9 +10,19 @@ declare module "next-auth" { image?: string | null role: string } + accessToken?: string | null } interface User { role: string } } + +declare module "next-auth/jwt" { + interface JWT { + id?: string + role?: string + accessToken?: string | null + picture?: string | null + } +}