Initial reader-api backend extracted from reader
This commit is contained in:
@@ -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
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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[]
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user