Initial reader-api backend extracted from reader

This commit is contained in:
2026-03-24 13:55:10 +07:00
parent 56f8f5ccfc
commit 24f070d14e
69 changed files with 12167 additions and 1 deletions
+38
View File
@@ -0,0 +1,38 @@
"use client"
import { SessionProvider, useSession, signIn, signOut } from "next-auth/react"
import { useMemo, type ReactNode } from "react"
import type { User } from "./types"
export function AuthProvider({ children }: { children: ReactNode }) {
return <SessionProvider>{children}</SessionProvider>
}
// Giữ nguyên custom hook `useAuth` để tương thích ngược với UI components hiện tại
export function useAuth() {
const { data: session, status } = useSession()
const isLoading = status === "loading"
// Chuyển đổi session user thành format User của project
const sessionUser = session?.user
const user: User | null = useMemo(() => {
if (!sessionUser) return null
return {
id: (sessionUser as any).id || "",
username: (sessionUser as any).name || "Người dùng",
email: (sessionUser as any).email || "",
avatarUrl: (sessionUser as any).image || "",
avatarColor: "bg-blue-500", // Mặc định
role: (sessionUser as any).role || "USER",
createdAt: new Date().toISOString().split("T")[0],
}
}, [sessionUser])
const loginWithGoogle = () => signIn("google", { callbackUrl: "/" })
const logout = () => signOut({ callbackUrl: "/" })
return { user, isLoading, loginWithGoogle, logout }
}
+35
View File
@@ -0,0 +1,35 @@
import { NextAuthOptions } from "next-auth"
import GoogleProvider from "next-auth/providers/google"
import { PrismaAdapter } from "@auth/prisma-adapter"
import { prisma } from "./prisma"
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",
clientSecret: process.env.GOOGLE_CLIENT_SECRET || "demo-secret",
}),
],
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",
},
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 },
})
session.user.id = dbUser?.id || user.id
session.user.role = dbUser?.role || "USER"
}
return session
},
},
// Tuân thủ bảo mật NextAuth
secret: process.env.NEXTAUTH_SECRET,
}
+121
View File
@@ -0,0 +1,121 @@
"use client"
import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from "react"
import type { Bookmark } from "./types"
import { useAuth } from "./auth-context"
interface BookmarkContextType {
bookmarks: Bookmark[]
isBookmarked: (novelId: string) => boolean
toggleBookmark: (novelId: string) => Promise<void>
updateProgress: (novelId: string, chapterId: string, chapterNumber: number) => Promise<void>
getProgress: (novelId: string) => Bookmark | undefined
}
const BookmarkContext = createContext<BookmarkContextType | undefined>(undefined)
export function BookmarkProvider({ children }: { children: ReactNode }) {
const { user } = useAuth()
const [bookmarks, setBookmarks] = useState<Bookmark[]>([])
const fetchBookmarks = useCallback(async () => {
if (!user) {
setBookmarks([])
return
}
try {
const res = await fetch("/api/user/bookmarks")
if (!res.ok) return
const data = await res.json()
setBookmarks(Array.isArray(data) ? data : [])
} catch (e) {
console.error("Failed to fetch bookmarks", e)
}
}, [user])
useEffect(() => {
fetchBookmarks()
}, [fetchBookmarks])
const toggleBookmark = useCallback(async (novelId: string) => {
if (!user) return
// Optimistic update
setBookmarks((prev) => {
const exists = prev.find((b) => b.novelId === novelId)
if (exists) {
return prev.filter((b) => b.novelId !== novelId)
}
return [...prev, { novelId, addedAt: new Date().toISOString() } as any]
})
try {
const res = await fetch("/api/user/bookmarks", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "toggle", novelId })
})
if (!res.ok) {
throw new Error("Không thể cập nhật đánh dấu")
}
await fetchBookmarks()
} catch (e) {
console.error(e)
await fetchBookmarks()
}
}, [fetchBookmarks, user])
const updateProgress = useCallback(async (novelId: string, chapterId: string, chapterNumber: number) => {
if (!user) return
// Optimistic update
setBookmarks((prev) => {
const exists = prev.find((b) => b.novelId === novelId)
if (exists) {
return prev.map(b => b.novelId === novelId ? { ...b, lastChapterId: chapterId, lastChapterNumber: chapterNumber } : b)
}
return [...prev, { novelId, lastChapterId: chapterId, lastChapterNumber: chapterNumber, addedAt: new Date().toISOString() } as any]
})
try {
const res = await fetch("/api/user/bookmarks", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "updateProgress", novelId, lastChapterId: chapterId, lastChapterNumber: chapterNumber })
})
if (!res.ok) {
throw new Error("Không thể cập nhật tiến độ")
}
await fetchBookmarks()
} catch (e) {
console.error(e)
await fetchBookmarks()
}
}, [fetchBookmarks, user])
const getProgress = useCallback((novelId: string) => {
return bookmarks.find((b) => b.novelId === novelId)
}, [bookmarks])
const isBookmarked = useCallback((novelId: string) => {
return bookmarks.some((b) => b.novelId === novelId)
}, [bookmarks])
return (
<BookmarkContext.Provider value={{ bookmarks, isBookmarked, toggleBookmark, updateProgress, getProgress }}>
{children}
</BookmarkContext.Provider>
)
}
export function useBookmarks() {
const context = useContext(BookmarkContext)
if (!context) throw new Error("useBookmarks must be used within BookmarkProvider")
return context
}
+25
View File
@@ -0,0 +1,25 @@
export const MOD_AI_PREFILL_STORAGE_KEY = "mod:ai-tool:novel-prefill"
export const MOD_AI_MODEL_STORAGE_KEY = "mod:ai-tool:model"
export const MOD_AI_WEB_DEFAULT_MODEL = "gpt-4o-mini-search-preview"
export const MOD_AI_WEB_MODEL_OPTIONS = [
{
value: "gpt-4o-mini-search-preview",
label: "gpt-4o-mini-search-preview (nhanh)",
},
{
value: "gpt-4o-search-preview",
label: "gpt-4o-search-preview (chat luong cao)",
},
] as const
export type AINovelPrefillPayload = {
title?: string
originalTitle?: string
authorName?: string
originalAuthorName?: string
description?: string
coverUrl?: string
status?: "Đang ra" | "Hoàn thành" | "Tạm ngưng"
genresSuggested?: string[]
}
+30
View File
@@ -0,0 +1,30 @@
import mongoose, { Schema, Document } from "mongoose"
export interface IChapter extends Document {
novelId: string // Trỏ tới ID trong PostgreSQL
number: number
volumeNumber?: number
volumeTitle?: string
volumeChapterNumber?: number
title: string
content: string
views: number
createdAt: Date
}
const ChapterSchema: Schema = new Schema({
novelId: { type: String, required: true, index: true },
number: { type: Number, required: true },
volumeNumber: { type: Number, default: null },
volumeTitle: { type: String, default: null },
volumeChapterNumber: { type: Number, default: null },
title: { type: String, required: true },
content: { type: String, required: true },
views: { type: Number, default: 0 },
createdAt: { type: Date, default: Date.now },
})
ChapterSchema.index({ novelId: 1, number: 1 }, { unique: true })
ChapterSchema.index({ createdAt: -1, novelId: 1 })
export const Chapter = mongoose.models.Chapter || mongoose.model<IChapter>("Chapter", ChapterSchema)
+25
View File
@@ -0,0 +1,25 @@
import mongoose, { Schema, Document } from "mongoose"
export interface IEditorRecommendation extends Document {
novelId: string
editorId: string
createdAt: Date
updatedAt: Date
}
const EditorRecommendationSchema: Schema = new Schema(
{
novelId: { type: String, required: true, index: true },
editorId: { type: String, required: true, index: true },
},
{
timestamps: true,
}
)
EditorRecommendationSchema.index({ novelId: 1, editorId: 1 }, { unique: true })
EditorRecommendationSchema.index({ createdAt: -1 })
export const EditorRecommendation =
mongoose.models.EditorRecommendation ||
mongoose.model<IEditorRecommendation>("EditorRecommendation", EditorRecommendationSchema)
+25
View File
@@ -0,0 +1,25 @@
import mongoose, { Document, Schema } from "mongoose"
export interface IUserRecommendation extends Document {
userId: string
novelId: string
createdAt: Date
updatedAt: Date
}
const UserRecommendationSchema: Schema = new Schema(
{
userId: { type: String, required: true, index: true },
novelId: { type: String, required: true, index: true },
},
{
timestamps: true,
}
)
UserRecommendationSchema.index({ userId: 1, novelId: 1 }, { unique: true })
UserRecommendationSchema.index({ createdAt: -1 })
export const UserRecommendation =
mongoose.models.UserRecommendation ||
mongoose.model<IUserRecommendation>("UserRecommendation", UserRecommendationSchema)
+39
View File
@@ -0,0 +1,39 @@
import mongoose from "mongoose"
let cached = (global as any).mongoose
if (!cached) {
cached = (global as any).mongoose = { conn: null, promise: null }
}
async function connectToMongoDB() {
const mongodbUri = process.env.MONGODB_URI
if (!mongodbUri) {
throw new Error("Please define the MONGODB_URI environment variable")
}
if (cached.conn) {
return cached.conn
}
if (!cached.promise) {
const opts = {
bufferCommands: false,
}
cached.promise = mongoose.connect(mongodbUri, opts).then((mongoose) => {
return mongoose
})
}
try {
cached.conn = await cached.promise
} catch (e) {
cached.promise = null
throw e
}
return cached.conn
}
export default connectToMongoDB
+17
View File
@@ -0,0 +1,17 @@
export function getNovelStatusBadgeClass(status: string): string {
const normalized = status.trim().toLowerCase()
if (normalized.includes("hoàn")) {
return "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300"
}
if (normalized.includes("tạm")) {
return "bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300"
}
if (normalized.includes("drop") || normalized.includes("hủy") || normalized.includes("cancel")) {
return "bg-rose-100 text-rose-700 dark:bg-rose-900/30 dark:text-rose-300"
}
return "bg-sky-100 text-sky-700 dark:bg-sky-900/30 dark:text-sky-300"
}
+11
View File
@@ -0,0 +1,11 @@
import { PrismaClient } from "@prisma/client"
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient }
export const prisma =
globalForPrisma.prisma ||
new PrismaClient({
// log: ["query"], // uncomment during debug
})
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma
+140
View File
@@ -0,0 +1,140 @@
import { DeleteObjectCommand, PutObjectCommand, S3Client } from "@aws-sdk/client-s3"
import path from "path"
type UploadToR2Options = {
buffer: Buffer
contentType?: string | null
keyPrefix?: string
fileNameHint?: string
}
let cachedClient: S3Client | null = null
function requiredEnv(name: string): string {
const value = process.env[name]
if (!value) {
throw new Error(`Missing required environment variable: ${name}`)
}
return value
}
function optionalEnv(name: string): string | null {
const value = process.env[name]
return value ? value : null
}
function getR2Config() {
const accountId = requiredEnv("R2_ACCOUNT_ID")
const accessKeyId = requiredEnv("R2_ACCESS_KEY_ID")
const secretAccessKey = requiredEnv("R2_SECRET_ACCESS_KEY")
const bucket = requiredEnv("R2_BUCKET_NAME")
const publicBaseUrl = requiredEnv("R2_PUBLIC_BASE_URL")
return {
accountId,
accessKeyId,
secretAccessKey,
bucket,
publicBaseUrl: publicBaseUrl.replace(/\/+$/, ""),
}
}
function getR2Client(): S3Client {
if (cachedClient) return cachedClient
const { accountId, accessKeyId, secretAccessKey } = getR2Config()
cachedClient = new S3Client({
region: "auto",
endpoint: `https://${accountId}.r2.cloudflarestorage.com`,
credentials: {
accessKeyId,
secretAccessKey,
},
})
return cachedClient
}
function extensionFromMimeType(mimeType: string | null | undefined): string {
if (!mimeType) return ".jpg"
const normalized = mimeType.toLowerCase()
if (normalized.includes("png")) return ".png"
if (normalized.includes("webp")) return ".webp"
if (normalized.includes("gif")) return ".gif"
if (normalized.includes("avif")) return ".avif"
if (normalized.includes("jpeg") || normalized.includes("jpg")) return ".jpg"
return ".jpg"
}
function extensionFromHint(fileNameHint?: string): string {
if (!fileNameHint) return ""
const ext = path.extname(fileNameHint).toLowerCase()
if (!ext) return ""
if (!/^\.[a-z0-9]{1,8}$/.test(ext)) return ""
return ext
}
export async function uploadBufferToR2(options: UploadToR2Options): Promise<string> {
const client = getR2Client()
const { bucket, publicBaseUrl } = getR2Config()
const keyPrefix = (options.keyPrefix || "covers").replace(/^\/+|\/+$/g, "")
const ext = extensionFromHint(options.fileNameHint) || extensionFromMimeType(options.contentType)
const key = `${keyPrefix}/${Date.now()}-${Math.round(Math.random() * 1e9)}${ext}`
await client.send(
new PutObjectCommand({
Bucket: bucket,
Key: key,
Body: options.buffer,
ContentType: options.contentType || "application/octet-stream",
})
)
return `${publicBaseUrl}/${key}`
}
export function getR2ObjectKeyFromUrl(url: string | null | undefined): string | null {
if (!url) return null
const publicBaseUrl = optionalEnv("R2_PUBLIC_BASE_URL")?.replace(/\/+$/, "")
if (!publicBaseUrl) return null
let baseUrl: URL
let fileUrl: URL
try {
baseUrl = new URL(publicBaseUrl)
fileUrl = new URL(url)
} catch {
return null
}
if (baseUrl.origin !== fileUrl.origin) return null
const basePath = baseUrl.pathname.replace(/\/+$/, "")
if (!fileUrl.pathname.startsWith(basePath)) return null
const relativePath = fileUrl.pathname.slice(basePath.length).replace(/^\/+/, "")
if (!relativePath) return null
return decodeURIComponent(relativePath)
}
export async function deleteR2ObjectByUrl(url: string | null | undefined): Promise<boolean> {
const key = getR2ObjectKeyFromUrl(url)
if (!key) return false
const client = getR2Client()
const { bucket } = getR2Config()
await client.send(
new DeleteObjectCommand({
Bucket: bucket,
Key: key,
})
)
return true
}
+125
View File
@@ -0,0 +1,125 @@
"use client"
import { createContext, ReactNode, useCallback, useContext, useEffect, useState } from "react"
import { useAuth } from "@/lib/auth-context"
type UserRecommendedNovel = {
id: string
title: string
slug: string
authorName: string
coverUrl: string | null
status: string
totalChapters: number
}
type UserRecommendationItem = {
id: string
novelId: string
createdAt: string | null
novel: UserRecommendedNovel
}
type RecommendationContextType = {
recommendations: UserRecommendationItem[]
isRecommended: (novelId: string) => boolean
toggleRecommendation: (novelId: string) => Promise<{ status: "added" | "removed" | "exists" }>
}
const RecommendationContext = createContext<RecommendationContextType | undefined>(undefined)
export function RecommendationProvider({ children }: { children: ReactNode }) {
const { user } = useAuth()
const [recommendations, setRecommendations] = useState<UserRecommendationItem[]>([])
const fetchRecommendations = useCallback(async () => {
if (!user) {
setRecommendations([])
return
}
try {
const res = await fetch("/api/user/recommendations")
if (!res.ok) {
setRecommendations([])
return
}
const data = await res.json()
setRecommendations(Array.isArray(data) ? data : [])
} catch (error) {
console.error("Failed to fetch recommendations", error)
}
}, [user])
useEffect(() => {
fetchRecommendations()
}, [fetchRecommendations])
const isRecommended = useCallback(
(novelId: string) => recommendations.some((item) => item.novelId === novelId),
[recommendations]
)
const toggleRecommendation = useCallback(
async (novelId: string) => {
if (!user) throw new Error("Unauthorized")
if (!novelId) throw new Error("Missing novel id")
const existed = recommendations.some((item) => item.novelId === novelId)
if (existed) {
const res = await fetch(`/api/user/recommendations?novelId=${encodeURIComponent(novelId)}`, {
method: "DELETE",
})
const data = (await res.json()) as { error?: string }
if (!res.ok) {
throw new Error(data.error || "Không thể bỏ đề cử")
}
await fetchRecommendations()
return { status: "removed" as const }
}
const res = await fetch("/api/user/recommendations", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ novelId }),
})
const data = (await res.json()) as { error?: string }
if (!res.ok) {
if (res.status === 409) {
await fetchRecommendations()
return { status: "exists" as const }
}
throw new Error(data.error || "Không thể đề cử truyện")
}
await fetchRecommendations()
return { status: "added" as const }
},
[fetchRecommendations, recommendations, user]
)
return (
<RecommendationContext.Provider
value={{
recommendations,
isRecommended,
toggleRecommendation,
}}
>
{children}
</RecommendationContext.Provider>
)
}
export function useRecommendations() {
const context = useContext(RecommendationContext)
if (!context) throw new Error("useRecommendations must be used within RecommendationProvider")
return context
}
+72
View File
@@ -0,0 +1,72 @@
export interface Genre {
id: string
name: string
slug: string
description: string
icon: string
}
export interface Novel {
id: string
title: string
slug: string
authorName: string
series?: {
id: string
name: string
slug: string
} | null
coverColor: string
description: string
genres: string[]
status: "Đang ra" | "Hoàn thành" | "Tạm ngưng"
totalChapters: number
views: number
rating: number
ratingCount: number
bookmarkCount: number
lastUpdated: string
createdAt: string
}
export interface Chapter {
id: string
novelId: string
number: number
volumeNumber?: number
volumeTitle?: string
volumeChapterNumber?: number
title: string
content: string
views: number
createdAt: string
}
export interface User {
id: string
username: string
email: string
avatarColor: string
avatarUrl?: string
role?: "USER" | "MOD" | "ADMIN"
createdAt: string
}
export interface Comment {
id: string
userId: string
username: string
avatarColor: string
novelId: string
chapterId?: string
content: string
createdAt: string
}
export interface Bookmark {
novelId: string
lastChapterId?: string
lastChapterNumber?: number
addedAt: string
novel?: any
}
+27
View File
@@ -0,0 +1,27 @@
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
export function slugify(str: string) {
if (!str) return ""
return str
.toLowerCase()
.replace(/a|á|à|ả|ã|ạ|ă|ắ|ằ|ẳ|ẵ|ặ|â|ấ|ầ|ẩ|ẫ|ậ/gi, 'a')
.replace(/e|é|è|ẻ|ẽ|ẹ|ê|ế|ề|ể|ễ|ệ/gi, 'e')
.replace(/i|í|ì|ỉ|ĩ|ị/gi, 'i')
.replace(/o|ó|ò|ỏ|õ|ọ|ô|ố|ồ|ổ|ỗ|ộ|ơ|ớ|ờ|ở|ỡ|ợ/gi, 'o')
.replace(/u|ú|ù|ủ|ũ|ụ|ư|ứ|ừ|ử|ữ|ự/gi, 'u')
.replace(/y|ý|ỳ|ỷ|ỹ|ỵ/gi, 'y')
.replace(/đ/gi, 'd')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
}
export function formatViews(views: number): string {
if (views >= 1000000) return (views / 1000000).toFixed(1) + "M"
if (views >= 1000) return (views / 1000).toFixed(1) + "K"
return views.toString()
}