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 */}
- {/* 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 */}
{hasPrev ? (
-
+
Ch. trước
Trước
@@ -104,7 +116,7 @@ export default async function ChapterReaderPage({ params }: { params: Promise<{
{hasNext ? (
-
+
Ch. sau
Sau
@@ -119,7 +131,6 @@ export default async function ChapterReaderPage({ params }: { params: Promise<{
- {/* Chapter content */}
{paragraphs.map((text: string, idx: number) => (
@@ -128,11 +139,10 @@ export default async function ChapterReaderPage({ params }: { params: Promise<{
))}
- {/* Chapter navigation bottom */}
{hasPrev ? (
-
+
Chương trước
Trước
@@ -147,7 +157,7 @@ export default async function ChapterReaderPage({ params }: { params: Promise<{
{hasNext ? (
-
+
Chương sau
Sau
@@ -162,21 +172,18 @@ export default async function ChapterReaderPage({ params }: { params: Promise<{
- {/* 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
+ }
+}