diff --git a/app/(root)/page.tsx b/app/(root)/page.tsx index 6b484a3..ff0c27b 100644 --- a/app/(root)/page.tsx +++ b/app/(root)/page.tsx @@ -1,14 +1,17 @@ import BookCard from "@/components/BookCard"; import HeroSection from "@/components/HeroSection"; -import { sampleBooks } from "@/lib/constants"; +import { getAllBooks } from "@/lib/actions/book.actions"; + +export default async function Page() { + const bookResult = await getAllBooks(); + const books = bookResult.success ? bookResult.data ?? [] : []; -export default function Page() { return ( -
+
- {sampleBooks.map((book) => ( + {books.map((book) => ( ))}
-
+ ); } diff --git a/app/api/upload/route.ts b/app/api/upload/route.ts new file mode 100644 index 0000000..bcbfb14 --- /dev/null +++ b/app/api/upload/route.ts @@ -0,0 +1,52 @@ +import { MAX_FILE_SIZE } from "@/lib/constants"; +import { auth } from "@clerk/nextjs/server"; +import { handleUpload, HandleUploadBody } from "@vercel/blob/client"; +import { NextRequest, NextResponse } from "next/server"; + +export async function POST(request: NextRequest): Promise { + const body = (await request.json()) as HandleUploadBody; + + try { + // 处理上传请求 + const jsonResponse = await handleUpload({ + token: process.env.BLOB_READ_WRITE_TOKEN, + body, + request, + // 在生成上传令牌之前执行的函数 + onBeforeGenerateToken: async () => { + // 检查用户是否登录 + const { userId } = await auth(); + if (!userId) { + throw new Error("Unauthorized: 用户未登录"); + } + + return { + allowedContentTypes: [ + "application/pdf", + "image/jpeg", + "image/png", + "image/webp", + ], + addRandomSuffix: true, + maximumSizeInBytes: MAX_FILE_SIZE, + tokenPayload: JSON.stringify({ userId }), + }; + }, + // 上传完成后的回调 + onUploadCompleted: async ({ blob, tokenPayload }) => { + console.log("文件已上传至 Blob", blob.url); + + const payload = tokenPayload ? JSON.parse(tokenPayload) : null; + const userId = payload?.userId; + + // TODO: 上报PostHog + }, + }); + + return NextResponse.json(jsonResponse); + } catch (e) { + const message = e instanceof Error ? e.message : "未知错误"; + const status = message.includes("Unauthorized") ? 401 : 500; + return NextResponse.json({ error: message }, { status }); + } +} diff --git a/app/layout.tsx b/app/layout.tsx index bc83904..b8b4682 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -4,7 +4,7 @@ import { ClerkProvider } from "@clerk/nextjs"; import "./globals.css"; import NavBar from "@/components/NavBar"; import { CLERK_AUTH_APPEARANCE_OVERRIDE } from "@/lib/constants"; - +import { Toaster } from "@/components/ui/sonner" const ibmPlexSerif = IBM_Plex_Serif({ variable: "--font-ibm-plex-serif", @@ -40,6 +40,7 @@ export default function RootLayout({ {children} + diff --git a/components/UploadForm.tsx b/components/UploadForm.tsx index 0554dc6..fb953f8 100644 --- a/components/UploadForm.tsx +++ b/components/UploadForm.tsx @@ -5,8 +5,13 @@ import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import * as z from "zod"; import { Upload, Image as ImageIcon, X } from "lucide-react"; -import { cn } from "@/lib/utils"; - +import { cn, parsePDFFile } from "@/lib/utils"; +import { + MAX_FILE_SIZE, + ACCEPTED_PDF_TYPES, + MAX_IMAGE_SIZE, + ACCEPTED_IMAGE_TYPES, +} from "@/lib/constants"; import { Form, FormControl, @@ -16,6 +21,11 @@ import { FormMessage, } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; +import { useAuth } from "@clerk/nextjs"; +import { toast } from "sonner"; +import { useRouter } from "next/navigation"; +import { checkBookExists, createBook, saveBookSegments } from "@/lib/actions/book.actions"; +import { upload } from "@vercel/blob/client"; const VOICES = { male: [ @@ -42,17 +52,29 @@ const VOICES = { }; const formSchema = z.object({ - pdfFile: z.any().refine((file) => file, "请选择一本 PDF 文件"), - coverImage: z.any().optional(), - title: z.string().min(1, "标题是必填项"), - author: z.string().min(1, "作者是必填项"), + title: z.string().min(1, "标题是必填项").max(100, "标题不能超过 100 个字符"), + author: z.string().min(1, "作者是必填项").max(100, "作者名不能超过 100 个字符"), voice: z.string().min(1, "请选择一个语音"), + pdfFile: z + .instanceof(File, { message: "请选择一本 PDF 文件" }) + .refine((file) => file.size <= MAX_FILE_SIZE, "文件大小不能超过 50MB") + .refine((file) => ACCEPTED_PDF_TYPES.includes(file.type), "仅支持 PDF 格式的文件"), + coverImage: z + .instanceof(File) + .optional() + .refine((file) => !file || file.size <= MAX_IMAGE_SIZE, "图片大小不能超过 10MB") + .refine( + (file) => !file || ACCEPTED_IMAGE_TYPES.includes(file.type), + "仅支持 .jpg、.jpeg、.png 和 .webp 格式的图片", + ), }); export default function UploadForm() { const [isLoading, setIsLoading] = useState(false); const [pdfFilename, setPdfFilename] = useState(null); const [coverFilename, setCoverFilename] = useState(null); + const { userId } = useAuth(); + const router = useRouter(); const form = useForm>({ resolver: zodResolver(formSchema), @@ -60,16 +82,125 @@ export default function UploadForm() { title: "", author: "", voice: "", + pdfFile: undefined, + coverImage: undefined, }, }); - function onSubmit(values: z.infer) { + async function onSubmit(data: z.infer) { + // 检查用户是否登录 + if (!userId) { + return toast.error("上传图书前请先登录!"); + } + setIsLoading(true); - // Simulate API call - setTimeout(() => { + + // PostHog -> Track Book Uploads ... + + try { + // 检查图书是否已存在 + const existsCheck = await checkBookExists(data.title); + if (existsCheck.exists && existsCheck.book) { + toast.info("同名的图书已存在!"); + // 清空表单 + form.reset(); + // 已存在就跳转到该图书的详情页面 + router.push(`/books/${existsCheck.book.slug}`); + return; + } + + // 格式化文件名 + const fileTitle = data.title.replace(/\s+/g, "-").toLowerCase(); + // 解析 PDF 文件 + const pdfFile = data.pdfFile; + const parsedPdf = await parsePDFFile(pdfFile); + if (parsedPdf.content.length === 0) { + toast.error("无法解析 PDF 文件,请上传有效的 PDF 文件!"); + return; + } + + // 上传pdf + const uploadedPdfBlob = await upload(fileTitle, pdfFile, { + access: "public", + handleUploadUrl: "/api/upload", + contentType: "application/pdf", + }) + + // 上传封面 + let coverUrl: string; + + if (data.coverImage) { + const coverFile = data.coverImage; + const uploadedCoverBlob = await upload(`${fileTitle}_cover.png`, coverFile, { + access: "public", + handleUploadUrl: "/api/upload", + contentType: coverFile.type, + }) + coverUrl = uploadedCoverBlob.url; + } else { + const response = await fetch(parsedPdf.cover); + const blob = await response.blob(); + + const uploadedCoverBlob = await upload(`${fileTitle}_cover.png`, blob, { + access: "public", + handleUploadUrl: "/api/upload", + contentType: "image/png", + }) + coverUrl = uploadedCoverBlob.url; + } + + // 创建图书 + const book = await createBook({ + clerkId: userId, + title: data.title, + author: data.author, + persona: data.voice, + fileURL: uploadedPdfBlob.url, + fileBlobKey: uploadedPdfBlob.pathname, + coverURL: coverUrl, + fileSize: pdfFile.size, + }) + + // 创建失败 + if (!book.success) { + throw new Error("创建图书失败"); + } + + // 创建的图书已存在 + if (book.alreadyExists) { + toast.info("同名的图书已存在!"); + // 清空表单 + form.reset(); + // 已存在就跳转到该图书的详情页面 + router.push(`/books/${book.data.slug}`); + return; + } + + // 创建成功 + // 保存图书片段 + const segments = await saveBookSegments( + book.data._id, + userId, + parsedPdf.content, + ) + + if (!segments.success) { + toast.error("保存图书片段失败"); + throw new Error("保存图书片段失败"); + } + + toast.success("图书创建成功!"); + // 清空表单 + form.reset(); + // 创建新书成功跳转到首页 + router.push(`/`); + + } catch (e) { + console.error(e); + toast.error("上传图书失败,请稍后重试!"); + } finally { setIsLoading(false); - console.log("Form Submitted:", values); - }, 2000); + } } return ( diff --git a/components/ui/sonner.tsx b/components/ui/sonner.tsx new file mode 100644 index 0000000..9280ee5 --- /dev/null +++ b/components/ui/sonner.tsx @@ -0,0 +1,49 @@ +"use client" + +import { useTheme } from "next-themes" +import { Toaster as Sonner, type ToasterProps } from "sonner" +import { CircleCheckIcon, InfoIcon, TriangleAlertIcon, OctagonXIcon, Loader2Icon } from "lucide-react" + +const Toaster = ({ ...props }: ToasterProps) => { + const { theme = "system" } = useTheme() + + return ( + + ), + info: ( + + ), + warning: ( + + ), + error: ( + + ), + loading: ( + + ), + }} + style={ + { + "--normal-bg": "var(--popover)", + "--normal-text": "var(--popover-foreground)", + "--normal-border": "var(--border)", + "--border-radius": "var(--radius)", + } as React.CSSProperties + } + toastOptions={{ + classNames: { + toast: "cn-toast", + }, + }} + {...props} + /> + ) +} + +export { Toaster } diff --git a/database/models/book-segment.model.ts b/database/models/book-segment.model.ts new file mode 100644 index 0000000..7b445be --- /dev/null +++ b/database/models/book-segment.model.ts @@ -0,0 +1,23 @@ +import { IBookSegment } from "@/type"; +import { Schema, models, model } from "mongoose"; + +// 创建 BookSegmentSchema +const BookSegmentSchema = new Schema({ + clerkId: {type: String, required: true}, + bookId: {type: Schema.Types.ObjectId, ref: "Book", required: true, index: true}, + content: {type: String, required: true}, + segmentIndex: {type: Number, required: true, index: true}, + pageNumber: {type: Number, index: true}, + wordCount: {type: Number, required: true}, +}, {timestamps: true}) + +// 为常见查询场景创建复合字段索引,上面定义的"index: true"是单字段索引 +// 当 Vapi 朗读一本书时,它会按顺序获取段落。而这个复合索引可以让查找即时完成,而不是扫描数据集中的每个数据段。 +BookSegmentSchema.index({bookId: 1, segmentIndex: 1}, {unique: true}); +BookSegmentSchema.index({bookId: 1, pageNumber: 1}); +BookSegmentSchema.index({bookId: 1, content: 'text'}); + +// 防止每次 Next.js 开发环境热更时重复创建模型 +const BookSegment = models.BookSegment || model("BookSegment", BookSegmentSchema); + +export default BookSegment; diff --git a/database/models/book.model.ts b/database/models/book.model.ts new file mode 100644 index 0000000..d903b74 --- /dev/null +++ b/database/models/book.model.ts @@ -0,0 +1,22 @@ +import { IBook } from "@/type"; +import { Schema, models, model } from "mongoose"; + +// 创建 BookSchema +const BookSchema = new Schema({ + clerkId: {type: String, required: true}, + title: {type: String, required: true}, + slug: {type: String, required: true, unique: true, lowercase: true, trim: true}, + author: {type: String, required: true}, + persona: {type: String}, + fileURL: {type: String, required: true}, + fileBlobKey: {type: String, required: true}, + coverURL: {type: String}, + coverBlobKey: {type: String}, + fileSize: {type: Number, required: true}, + totalSegments: {type: Number, default: 0}, +}, {timestamps: true}) + +// 防止每次 Next.js 开发环境热更时重复创建模型 +const Book = models.Book || model("Book", BookSchema); + +export default Book; diff --git a/database/models/voice-session.model.ts b/database/models/voice-session.model.ts new file mode 100644 index 0000000..a158909 --- /dev/null +++ b/database/models/voice-session.model.ts @@ -0,0 +1,20 @@ +import { IVoiceSession } from "@/type"; +import { Schema, models, model } from "mongoose"; + +// 创建 VoiceSessionSchema +const VoiceSessionSchema = new Schema({ + clerkId: {type: String, required: true, index: true}, + bookId: {type: Schema.Types.ObjectId, ref: "Book", required: true}, + startedAt: {type: Date, required: true, default: Date.now}, + endedAt: {type: Date}, + durationSeconds: {type: Number, required: true, default: 0}, + billingPeriodStart: {type: Date, required: true, index: true}, +}, {timestamps: true}) + +// 按用户和计费周期查询创建复合索引,对订阅用户收费 +VoiceSessionSchema.index({clerkId: 1, billingPeriodStart: 1}); + +// 防止每次 Next.js 开发环境热更时重复创建模型 +const VoiceSession = models.VoiceSession || model("VoiceSession", VoiceSessionSchema); + +export default VoiceSession; diff --git a/database/mongoose.ts b/database/mongoose.ts new file mode 100644 index 0000000..8101bbd --- /dev/null +++ b/database/mongoose.ts @@ -0,0 +1,43 @@ +import mongoose from "mongoose"; + +const MONGODB_URI = process.env.MONGODB_URI; + +if (!MONGODB_URI) throw new Error("请配置MONGODB_URI 环境变量"); + +// 声明全局变量 mongooseCache +declare global { + var mongooseCache: { + conn: typeof mongoose | null; + promise: Promise | null; + }; +} + +// 获取全局 mongoose 缓存 +let cached = + global.mongooseCache || + (global.mongooseCache = { conn: null, promise: null }); + +// 连接数据库 +export const connectToDatabase = async () => { + // 如果已经连接,直接返回 + if (cached.conn) return cached.conn; + + // 如果没有连接,创建连接 + if (!cached.promise) { + cached.promise = mongoose.connect(MONGODB_URI, { + bufferCommands: false, + serverSelectionTimeoutMS: 30000, // 服务器选择超时 30 秒 + connectTimeoutMS: 30000, // 连接超时 30 秒 + }); + } + + try { + cached.conn = await cached.promise; + } catch (e) { + cached.promise = null; + console.error("数据库连接失败", e); + throw e; + } + console.info("数据库连接成功"); + return cached.conn; +}; diff --git a/lib/actions/book.actions.ts b/lib/actions/book.actions.ts new file mode 100644 index 0000000..2c77f7d --- /dev/null +++ b/lib/actions/book.actions.ts @@ -0,0 +1,142 @@ +"use server"; + +import { connectToDatabase } from "@/database/mongoose"; +import { generateSlug, serializeData } from "../utils"; +import { CreateBook, TextSegment } from "@/type"; +import Book from "@/database/models/book.model"; +import BookSegment from "@/database/models/book-segment.model"; + +export const getAllBooks = async () => { + try { + await connectToDatabase(); + + const books = await Book.find().sort({createdAt: -1}).lean(); + + return { + success: true, + data: serializeData(books), + }; + } catch (e) { + console.error("获取所有图书失败", e); + return { + success: false, + error: e, + }; + } +}; + +// 检查图书是否已存在 +export const checkBookExists = async (title: string) => { + try { + await connectToDatabase(); + + const slug = generateSlug(title); + + const existingBook = await Book.findOne({ slug }).lean(); + + if (existingBook) { + return { + exists: true, + book: serializeData(existingBook), + }; + } + + return { + exists: false, + }; + } catch (e) { + console.error("图书已存在", e); + return { + exists: false, + error: e, + }; + } +}; + +// 创建图书 +export const createBook = async (data: CreateBook) => { + try { + await connectToDatabase(); + + const slug = generateSlug(data.title); + + // 创建图书之前,先检查是否有相同标题的图书已经存在 + const existingBook = await Book.findOne({ slug }).lean(); + if (existingBook) { + return { + success: true, + // 从服务器操作中传递大型或复杂对象时,必须将数据序列化为纯 JSON 对象 + // data: JSON.parse(JSON.stringify(existingBook)), + data: serializeData(existingBook), // 序列化为纯 JSON 对象 + alreadyExists: true, + }; + } + + // Todo: 创建图书前先检查订阅限制 + + const book = await Book.create({ ...data, slug, totalSegments: 0 }); + + return { + success: true, + data: serializeData(book), + }; + } catch (e) { + console.error("创建图书失败", e); + return { + success: false, + error: e, + }; + } +}; + +// 保存图书段落 +export const saveBookSegments = async ( + bookId: string, + clerkId: string, + segments: TextSegment[], +) => { + try { + await connectToDatabase(); + + console.log("正在保存图书段落..."); + + // 准备要插入的段落数据 + const segmentsToInsert = segments.map( + ({ text, segmentIndex, pageNumber, wordCount }) => ({ + clerkId, + bookId, + content: text, + segmentIndex, + pageNumber, + wordCount, + }), + ); + + // 批量插入段落 + await BookSegment.insertMany(segmentsToInsert); + + // 更新图书的总段落数 + await Book.findByIdAndUpdate(bookId, { totalSegments: segments.length }); + + console.log("图书段落保存成功"); + + return { + success: true, + data: { + segmentsCreated: segments.length, + }, + }; + } catch (e) { + console.error("保存图书段落失败", e); + // 保存失败前可能已经存储了一些图书段落,需要删除 + await BookSegment.deleteMany({ bookId }); + // 删除该图书 + await Book.findByIdAndDelete({_id: bookId}); + console.log("由于保存段落失败,已删除该图书和所有图书段落"); + + return { + success: false, + error: e, + }; + } +}; diff --git a/lib/constants.ts b/lib/constants.ts index 27093c5..c1c81da 100644 --- a/lib/constants.ts +++ b/lib/constants.ts @@ -1,8 +1,8 @@ -// Brand color - used in JS files where CSS variables aren't available -export const BRAND_COLOR = '#212a3b'; // Dark blue-gray -export const BRAND_COLOR_HOVER = '#3d485e'; // Medium blue-gray +// 品牌色 - 用于 JS 文件中无法使用 CSS 变量的场景 +export const BRAND_COLOR = '#212a3b'; // 深蓝灰色 +export const BRAND_COLOR_HOVER = '#3d485e'; // 中蓝灰色 -// Sample books for the homepage (using Open Library covers) +// 首页示例书籍(使用 Open Library 封面) export const sampleBooks = [ { _id: '1', @@ -86,54 +86,54 @@ export const sampleBooks = [ }, ]; -// File validation helpers +// 文件校验辅助常量 export const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB export const ACCEPTED_PDF_TYPES = ['application/pdf']; export const MAX_IMAGE_SIZE = 10 * 1024 * 1024; // 10MB export const ACCEPTED_IMAGE_TYPES = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp']; -// Pre-configured VAPI assistant ID (hardcoded for this app) -const assistantId = process.env.NEXT_PUBLIC_ASSISTANT_ID; -if (!assistantId || assistantId.trim() === '') { - throw new Error('NEXT_PUBLIC_ASSISTANT_ID environment variable is required but not set. Please configure it in your .env file.'); -} -export const ASSISTANT_ID = assistantId; +// 预配置的 VAPI 助手 ID(在此应用中硬编码) +// const assistantId = process.env.NEXT_PUBLIC_ASSISTANT_ID; +// if (!assistantId || assistantId.trim() === '') { +// throw new Error('NEXT_PUBLIC_ASSISTANT_ID 环境变量是必需的但未设置。请在 .env 文件中配置。'); +// } +// export const ASSISTANT_ID = assistantId; -// 11Labs Voice IDs - Optimized for conversational AI -// Voices selected for natural, engaging book conversations +// 11Labs 语音 ID - 针对对话式 AI 优化 +// 精选适合自然、引人入胜的书籍对话的语音 export const voiceOptions = { - // Male voices - dave: { id: 'CYw3kZ02Hs0563khs1Fj', name: 'Dave', description: 'Young male, British-Essex, casual & conversational' }, - daniel: { id: 'onwK4e9ZLuTAKqWW03F9', name: 'Daniel', description: 'Middle-aged male, British, authoritative but warm' }, - chris: { id: 'iP95p4xoKVk53GoZ742B', name: 'Chris', description: 'Male, casual & easy-going' }, - // Female voices - rachel: { id: '21m00Tcm4TlvDq8ikWAM', name: 'Rachel', description: 'Young female, American, calm & clear' }, - sarah: { id: 'EXAVITQu4vr4xnSDxMaL', name: 'Sarah', description: 'Young female, American, soft & approachable' }, + // 男声 + dave: { id: 'CYw3kZ02Hs0563khs1Fj', name: 'Dave', description: '年轻男性,英式埃塞克斯口音,休闲对话风格' }, + daniel: { id: 'onwK4e9ZLuTAKqWW03F9', name: 'Daniel', description: '中年男性,英式口音,权威而温暖' }, + chris: { id: 'iP95p4xoKVk53GoZ742B', name: 'Chris', description: '男性,休闲随和风格' }, + // 女声 + rachel: { id: '21m00Tcm4TlvDq8ikWAM', name: 'Rachel', description: '年轻女性,美式口音,沉稳清晰' }, + sarah: { id: 'EXAVITQu4vr4xnSDxMaL', name: 'Sarah', description: '年轻女性,美式口音,柔和亲切' }, }; -// Voice categories for the selector UI +// 语音选择器 UI 的分类 export const voiceCategories = { male: ['dave', 'daniel', 'chris'], female: ['rachel', 'sarah'], }; -// Default voice +// 默认语音 export const DEFAULT_VOICE = 'rachel'; -// ElevenLabs voice settings optimized for conversational AI +// ElevenLabs 语音设置,针对对话式 AI 优化 export const VOICE_SETTINGS = { - stability: 0.45, // Lower for more emotional, dynamic delivery (0.30-0.50 is natural) - similarityBoost: 0.75, // Enhances clarity without distortion - style: 0, // Keep at 0 for conversational AI (higher = more latency, less stable) - useSpeakerBoost: true, // Improves voice quality - speed: 1.0, // Natural conversation speed + stability: 0.45, // 较低值使语音更富情感和动态(0.30-0.50 较自然) + similarityBoost: 0.75, // 增强清晰度且不失真 + style: 0, // 对话式 AI 保持为 0(越高延迟越大,越不稳定) + useSpeakerBoost: true, // 提升语音质量 + speed: 1.0, // 自然对话速度 }; -// VAPI configuration for natural conversation -// NOTE: These settings should be configured in the VAPI Dashboard for the assistant -// They are kept here for reference and documentation purposes +// VAPI 自然对话配置 +// 注意:这些设置应在 VAPI 控制台中为助手进行配置 +// 此处保留仅供参考和文档记录 export const VAPI_DASHBOARD_CONFIG = { - // Turn-taking settings + // 轮流发言设置 startSpeakingPlan: { smartEndpointingEnabled: true, waitSeconds: 0.4, @@ -143,18 +143,18 @@ export const VAPI_DASHBOARD_CONFIG = { voiceSeconds: 0.2, backoffSeconds: 1.0, }, - // Timing settings + // 时间设置 silenceTimeoutSeconds: 30, responseDelaySeconds: 0.4, llmRequestDelaySeconds: 0.1, - // Conversation features + // 对话功能 backgroundDenoisingEnabled: true, backchannelingEnabled: true, fillerInjectionEnabled: false, }; -// Clerk appearance overrides - Warm Literary Style -// Note: Tailwind requires static class names at build time, so we hardcode color values here +// Clerk 外观覆盖 - 温暖文艺风格 +// 注意:Tailwind 在构建时需要静态类名,因此这里硬编码颜色值 export const CLERK_AUTH_APPEARANCE_OVERRIDE = { rootBox: 'mx-auto', card: 'shadow-none border-none rounded-xl bg-transparent', diff --git a/lib/utils.ts b/lib/utils.ts index bd0c391..0d1eb9d 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -1,6 +1,202 @@ -import { clsx, type ClassValue } from "clsx" -import { twMerge } from "tailwind-merge" +import { TextSegment } from '@/type'; +import { clsx, type ClassValue } from 'clsx'; +import { twMerge } from 'tailwind-merge'; +import { DEFAULT_VOICE, voiceOptions } from './constants'; + export function cn(...inputs: ClassValue[]) { - return twMerge(clsx(inputs)) + return twMerge(clsx(inputs)); +} + +// 将 Mongoose 文档序列化为纯 JSON 对象(去除 ObjectId、Date 等特殊类型) +export const serializeData = (data: T): T => JSON.parse(JSON.stringify(data)); + +// 自动生成书籍文件名称的 slug 标识 +// 比如 generateSlug("The Three-Body Problem.pdf") → "the-three-body-problem" +// 支持中文/日文/韩文等 Unicode 字符 +export function generateSlug(text: string): string { + return text + .replace(/\.[^/.]+$/, '') // 移除文件扩展名(.pdf、.txt 等) + .toLowerCase() // 转换为小写 + .trim() // 去除首尾空白 + .replace(/[^\p{L}\p{N}\s-]/gu, '') // 移除特殊字符(保留 Unicode 字母、数字、空格和连字符) + .replace(/[\s_]+/g, '-') // 将空格和下划线替换为连字符 + .replace(/^-+|-+$/g, ''); // 去除首尾多余的连字符 +} + +// 转义正则表达式特殊字符,防止 ReDoS 攻击 +export const escapeRegex = (str: string): string => { + return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +}; + +// CJK 统一表意文字的 Unicode 范围正则,用于检测和拆分中日韩文本 +const CJK_RANGE = + /[\u2E80-\u2FFF\u3040-\u309F\u30A0-\u30FF\u3400-\u4DBF\u4E00-\u9FFF\uF900-\uFAFF\u{20000}-\u{2A6DF}\u{2A700}-\u{2B73F}\u{2B740}-\u{2B81F}]/u; + +/** + * 将文本分词为 token 数组。 + * - 对于空格分隔的语言(英文等),按空白符拆分。 + * - 对于 CJK 文本,将每个 CJK 字符视为独立 token, + * 连续的非 CJK 文本(如英文单词、数字)作为整体 token 保留。 + */ +function tokenize(text: string): string[] { + // 如果文本中不包含 CJK 字符,直接按空白符拆分(兼容原逻辑) + if (!CJK_RANGE.test(text)) { + return text.split(/\s+/).filter((w) => w.length > 0); + } + + // 匹配:单个 CJK 字符 | 连续非 CJK 非空白字符 | (跳过空白) + const tokenRegex = + /([\u2E80-\u2FFF\u3040-\u309F\u30A0-\u30FF\u3400-\u4DBF\u4E00-\u9FFF\uF900-\uFAFF\u{20000}-\u{2A6DF}\u{2A700}-\u{2B73F}\u{2B740}-\u{2B81F}])|([^\s\u2E80-\u2FFF\u3040-\u309F\u30A0-\u30FF\u3400-\u4DBF\u4E00-\u9FFF\uF900-\uFAFF\u{20000}-\u{2A6DF}\u{2A700}-\u{2B73F}\u{2B740}-\u{2B81F}]+)/gu; + + const tokens: string[] = []; + let match: RegExpExecArray | null; + while ((match = tokenRegex.exec(text)) !== null) { + tokens.push(match[0]); + } + return tokens; } + +// 将文本内容分割为段落,用于 MongoDB 存储和搜索 +// 支持 CJK 语言:每个 CJK 字符计为一个 token +export const splitIntoSegments = ( + text: string, + segmentSize: number = 500, // 每个段落的最大 token 数 + overlapSize: number = 50, // 段落之间重叠的 token 数,用于保持上下文连贯 +): TextSegment[] => { + // 校验参数,防止死循环 + if (segmentSize <= 0) { + throw new Error('segmentSize must be greater than 0'); + } + if (overlapSize < 0 || overlapSize >= segmentSize) { + throw new Error('overlapSize must be >= 0 and < segmentSize'); + } + + const tokens = tokenize(text); + const segments: TextSegment[] = []; + + let segmentIndex = 0; + let startIndex = 0; + + while (startIndex < tokens.length) { + const endIndex = Math.min(startIndex + segmentSize, tokens.length); + const segmentTokens = tokens.slice(startIndex, endIndex); + // CJK token 之间不加空格,非 CJK token 之间用空格连接 + const segmentText = segmentTokens.reduce((acc, token, i) => { + if (i === 0) return token; + const prevIsCJK = CJK_RANGE.test(segmentTokens[i - 1]); + const currIsCJK = CJK_RANGE.test(token); + // 两个 CJK 字符之间、CJK 字符与非 CJK 之间不加空格 + return acc + (prevIsCJK || currIsCJK ? '' : ' ') + token; + }, ''); + + segments.push({ + text: segmentText, + segmentIndex, + wordCount: segmentTokens.length, + }); + + segmentIndex++; + + if (endIndex >= tokens.length) break; + startIndex = endIndex - overlapSize; + } + + return segments; +}; + +// 根据角色标识或语音 ID 获取语音数据 +export const getVoice = (persona?: string) => { + if (!persona) return voiceOptions[DEFAULT_VOICE]; + + // 按语音 ID 查找 + const voiceEntry = Object.values(voiceOptions).find((v) => v.id === persona); + if (voiceEntry) return voiceEntry; + + // 按 key 查找 + const voiceByKey = voiceOptions[persona as keyof typeof voiceOptions]; + if (voiceByKey) return voiceByKey; + + // 默认回退 + return voiceOptions[DEFAULT_VOICE]; +}; + +// 将秒数格式化为 MM:SS 格式 +export const formatDuration = (seconds: number): string => { + const mins = Math.floor(seconds / 60); + const secs = seconds % 60; + return `${mins}:${secs.toString().padStart(2, '0')}`; +}; + +export async function parsePDFFile(file: File) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- pdfjs-dist 动态导入,类型推导复杂 + let pdfDocument: any = null; + + try { + const pdfjsLib = await import('pdfjs-dist'); + + if (typeof window !== 'undefined') { + pdfjsLib.GlobalWorkerOptions.workerSrc = new URL( + 'pdfjs-dist/build/pdf.worker.min.mjs', + import.meta.url, + ).toString(); + } + + // 将文件读取为 ArrayBuffer + const arrayBuffer = await file.arrayBuffer(); + + // 加载 PDF 文档 + const loadingTask = pdfjsLib.getDocument({ data: arrayBuffer }); + pdfDocument = await loadingTask.promise; + + // 渲染第一页作为封面图片 + const firstPage = await pdfDocument.getPage(1); + const viewport = firstPage.getViewport({ scale: 2 }); // 2 倍缩放以获得更高画质 + + const canvas = document.createElement('canvas'); + canvas.width = viewport.width; + canvas.height = viewport.height; + const context = canvas.getContext('2d'); + + if (!context) { + throw new Error('Could not get canvas context'); + } + + await firstPage.render({ + canvasContext: context, + viewport: viewport, + }).promise; + + // 将 Canvas 转换为 Data URL + const coverDataURL = canvas.toDataURL('image/png'); + + // 从所有页面提取文本 + let fullText = ''; + + for (let pageNum = 1; pageNum <= pdfDocument.numPages; pageNum++) { + const page = await pdfDocument.getPage(pageNum); + const textContent = await page.getTextContent(); + const pageText = textContent.items + .filter((item: any) => 'str' in item) + .map((item: { str: string; }) => (item as { str: string }).str) + .join(' '); + fullText += pageText + '\n'; + } + + // 将文本分割为段落,用于搜索 + const segments = splitIntoSegments(fullText); + + return { + content: segments, + cover: coverDataURL, + }; + } catch (error) { + console.error('PDF 解析失败:', error); + throw new Error(`PDF 文件解析失败: ${error instanceof Error ? error.message : String(error)}`); + } finally { + // 无论成功或失败,始终清理 PDF 文档资源,防止内存泄漏 + if (pdfDocument) { + await pdfDocument.destroy(); + } + } +} \ No newline at end of file diff --git a/next.config.ts b/next.config.ts index ce7ab18..033493f 100644 --- a/next.config.ts +++ b/next.config.ts @@ -8,6 +8,10 @@ const nextConfig: NextConfig = { protocol: "https", hostname: "covers.openlibrary.org", }, + { + protocol: "https", + hostname: "mgjacvezc1sbmenp.public.blob.vercel-storage.com", + }, ], }, }; diff --git a/package-lock.json b/package-lock.json index a19a1e5..edddb5f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,15 +13,21 @@ "@hookform/resolvers": "^5.2.2", "@radix-ui/react-label": "^2.1.8", "@radix-ui/react-slot": "^1.2.4", + "@vercel/blob": "^2.3.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^0.577.0", + "mongodb": "^7.1.1", + "mongoose": "^9.3.3", "next": "16.2.1", + "next-themes": "^0.4.6", + "pdfjs-dist": "^5.6.205", "radix-ui": "^1.4.3", "react": "19.2.4", "react-dom": "19.2.4", "react-hook-form": "^7.72.0", "shadcn": "^4.1.0", + "sonner": "^2.0.7", "tailwind-merge": "^3.5.0", "tw-animate-css": "^1.4.0", "zod": "^4.3.6" @@ -1742,6 +1748,15 @@ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "license": "MIT" }, + "node_modules/@mongodb-js/saslprep": { + "version": "1.4.6", + "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.4.6.tgz", + "integrity": "sha512-y+x3H1xBZd38n10NZF/rEBlvDOOMQ6LKUTHqr8R9VkJ+mmQOYtJFxIlkkK8fZrtOiL6VixbOBWMbZGBdal3Z1g==", + "license": "MIT", + "dependencies": { + "sparse-bitfield": "^3.0.3" + } + }, "node_modules/@mswjs/interceptors": { "version": "0.41.3", "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.41.3.tgz", @@ -1759,6 +1774,256 @@ "node": ">=18" } }, + "node_modules/@napi-rs/canvas": { + "version": "0.1.97", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.97.tgz", + "integrity": "sha512-8cFniXvrIEnVwuNSRCW9wirRZbHvrD3JVujdS2P5n5xiJZNZMOZcfOvJ1pb66c7jXMKHHglJEDVJGbm8XWFcXQ==", + "license": "MIT", + "optional": true, + "workspaces": [ + "e2e/*" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "optionalDependencies": { + "@napi-rs/canvas-android-arm64": "0.1.97", + "@napi-rs/canvas-darwin-arm64": "0.1.97", + "@napi-rs/canvas-darwin-x64": "0.1.97", + "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.97", + "@napi-rs/canvas-linux-arm64-gnu": "0.1.97", + "@napi-rs/canvas-linux-arm64-musl": "0.1.97", + "@napi-rs/canvas-linux-riscv64-gnu": "0.1.97", + "@napi-rs/canvas-linux-x64-gnu": "0.1.97", + "@napi-rs/canvas-linux-x64-musl": "0.1.97", + "@napi-rs/canvas-win32-arm64-msvc": "0.1.97", + "@napi-rs/canvas-win32-x64-msvc": "0.1.97" + } + }, + "node_modules/@napi-rs/canvas-android-arm64": { + "version": "0.1.97", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.97.tgz", + "integrity": "sha512-V1c/WVw+NzH8vk7ZK/O8/nyBSCQimU8sfMsB/9qeSvdkGKNU7+mxy/bIF0gTgeBFmHpj30S4E9WHMSrxXGQuVQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-darwin-arm64": { + "version": "0.1.97", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.97.tgz", + "integrity": "sha512-ok+SCEF4YejcxuJ9Rm+WWunHHpf2HmiPxfz6z1a/NFQECGXtsY7A4B8XocK1LmT1D7P174MzwPF9Wy3AUAwEPw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-darwin-x64": { + "version": "0.1.97", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.97.tgz", + "integrity": "sha512-PUP6e6/UGlclUvAQNnuXCcnkpdUou6VYZfQOQxExLp86epOylmiwLkqXIvpFmjoTEDmPmXrI+coL/9EFU1gKPA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm-gnueabihf": { + "version": "0.1.97", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.97.tgz", + "integrity": "sha512-XyXH2L/cic8eTNtbrXCcvqHtMX/nEOxN18+7rMrAM2XtLYC/EB5s0wnO1FsLMWmK+04ZSLN9FBGipo7kpIkcOw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-gnu": { + "version": "0.1.97", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.97.tgz", + "integrity": "sha512-Kuq/M3djq0K8ktgz6nPlK7Ne5d4uWeDxPpyKWOjWDK2RIOhHVtLtyLiJw2fuldw7Vn4mhw05EZXCEr4Q76rs9w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-musl": { + "version": "0.1.97", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.97.tgz", + "integrity": "sha512-kKmSkQVnWeqg7qdsiXvYxKhAFuHz3tkBjW/zyQv5YKUPhotpaVhpBGv5LqCngzyuRV85SXoe+OFj+Tv0a0QXkQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-riscv64-gnu": { + "version": "0.1.97", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.97.tgz", + "integrity": "sha512-Jc7I3A51jnEOIAXeLsN/M/+Z28LUeakcsXs07FLq9prXc0eYOtVwsDEv913Gr+06IRo34gJJVgT0TXvmz+N2VA==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-gnu": { + "version": "0.1.97", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.97.tgz", + "integrity": "sha512-iDUBe7AilfuBSRbSa8/IGX38Mf+iCSBqoVKLSQ5XaY2JLOaqz1TVyPFEyIck7wT6mRQhQt5sN6ogfjIDfi74tg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-musl": { + "version": "0.1.97", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.97.tgz", + "integrity": "sha512-AKLFd/v0Z5fvgqBDqhvqtAdx+fHMJ5t9JcUNKq4FIZ5WH+iegGm8HPdj00NFlCSnm83Fp3Ln8I2f7uq1aIiWaA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-win32-arm64-msvc": { + "version": "0.1.97", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-0.1.97.tgz", + "integrity": "sha512-u883Yr6A6fO7Vpsy9YE4FVCIxzzo5sO+7pIUjjoDLjS3vQaNMkVzx5bdIpEL+ob+gU88WDK4VcxYMZ6nmnoX9A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-win32-x64-msvc": { + "version": "0.1.97", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.97.tgz", + "integrity": "sha512-sWtD2EE3fV0IzN+iiQUqr/Q1SwqWhs2O1FKItFlxtdDkikpEj5g7DKQpY3x55H/MAOnL8iomnlk3mcEeGiUMoQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "0.2.12", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", @@ -4186,6 +4451,21 @@ "integrity": "sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw==", "license": "MIT" }, + "node_modules/@types/webidl-conversions": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz", + "integrity": "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==", + "license": "MIT" + }, + "node_modules/@types/whatwg-url": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-13.0.0.tgz", + "integrity": "sha512-N8WXpbE6Wgri7KUSvrmQcqrMllKZ9uxkYWMt+mCSGwNc0Hsw9VQTW7ApqI4XNrx6/SaM2QQJCzMPDEXE058s+Q==", + "license": "MIT", + "dependencies": { + "@types/webidl-conversions": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.57.1", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.57.1.tgz", @@ -4750,6 +5030,22 @@ "win32" ] }, + "node_modules/@vercel/blob": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/@vercel/blob/-/blob-2.3.3.tgz", + "integrity": "sha512-MtD7VLo6hU07eHR7bmk5SIMD290q574UaNYTe46qeyRT+hWrCy26CoAqfd7PnIefVXvRehRZBzukxuTO9iGTVg==", + "license": "Apache-2.0", + "dependencies": { + "async-retry": "^1.3.3", + "is-buffer": "^2.0.5", + "is-node-process": "^1.2.0", + "throttleit": "^2.1.0", + "undici": "^6.23.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -5095,6 +5391,15 @@ "node": ">= 0.4" } }, + "node_modules/async-retry": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/async-retry/-/async-retry-1.3.3.tgz", + "integrity": "sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw==", + "license": "MIT", + "dependencies": { + "retry": "0.13.1" + } + }, "node_modules/available-typed-arrays": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", @@ -5230,6 +5535,15 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/bson": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/bson/-/bson-7.2.0.tgz", + "integrity": "sha512-YCEo7KjMlbNlyHhz7zAZNDpIpQbd+wOEHJYezv0nMYTn4x31eIUM2yomNNubclAt63dObUzKHWsBLJ9QcZNSnQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/bundle-name": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", @@ -7623,6 +7937,29 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-buffer": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-2.0.5.tgz", + "integrity": "sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/is-bun-module": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/is-bun-module/-/is-bun-module-2.0.0.tgz", @@ -8279,6 +8616,15 @@ "node": ">=4.0" } }, + "node_modules/kareem": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/kareem/-/kareem-3.2.0.tgz", + "integrity": "sha512-VS8MWZz/cT+SqBCpVfNN4zoVz5VskR3N4+sTmUXme55e9avQHntpwpNq0yjnosISXqwJ3AQVjlbI4Dyzv//JtA==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -8721,6 +9067,12 @@ "node": ">= 0.8" } }, + "node_modules/memory-pager": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz", + "integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==", + "license": "MIT" + }, "node_modules/merge-descriptors": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", @@ -8829,6 +9181,104 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/mongodb": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-7.1.1.tgz", + "integrity": "sha512-067DXiMjcpYQl6bGjWQoTUEE9UoRViTtKFcoqX7z08I+iDZv/emH1g8XEFiO3qiDfXAheT5ozl1VffDTKhIW/w==", + "license": "Apache-2.0", + "dependencies": { + "@mongodb-js/saslprep": "^1.3.0", + "bson": "^7.1.1", + "mongodb-connection-string-url": "^7.0.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@aws-sdk/credential-providers": "^3.806.0", + "@mongodb-js/zstd": "^7.0.0", + "gcp-metadata": "^7.0.1", + "kerberos": "^7.0.0", + "mongodb-client-encryption": ">=7.0.0 <7.1.0", + "snappy": "^7.3.2", + "socks": "^2.8.6" + }, + "peerDependenciesMeta": { + "@aws-sdk/credential-providers": { + "optional": true + }, + "@mongodb-js/zstd": { + "optional": true + }, + "gcp-metadata": { + "optional": true + }, + "kerberos": { + "optional": true + }, + "mongodb-client-encryption": { + "optional": true + }, + "snappy": { + "optional": true + }, + "socks": { + "optional": true + } + } + }, + "node_modules/mongodb-connection-string-url": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-7.0.1.tgz", + "integrity": "sha512-h0AZ9A7IDVwwHyMxmdMXKy+9oNlF0zFoahHiX3vQ8e3KFcSP3VmsmfvtRSuLPxmyv2vjIDxqty8smTgie/SNRQ==", + "license": "Apache-2.0", + "dependencies": { + "@types/whatwg-url": "^13.0.0", + "whatwg-url": "^14.1.0" + }, + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/mongoose": { + "version": "9.3.3", + "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-9.3.3.tgz", + "integrity": "sha512-sfv5LOIPWeN5o/281kp4Rx9ZnuXb0g8CtvBTi7trYQs2PYYx8LWXegXxG3ar7VEns1o+d4h9LI/Dtc7dTTyYmA==", + "license": "MIT", + "dependencies": { + "kareem": "3.2.0", + "mongodb": "~7.1", + "mpath": "0.9.0", + "mquery": "6.0.0", + "ms": "2.1.3", + "sift": "17.1.3" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mongoose" + } + }, + "node_modules/mpath": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/mpath/-/mpath-0.9.0.tgz", + "integrity": "sha512-ikJRQTk8hw5DEoFVxHG1Gn9T/xcjtdnOKIU1JTmGjZZlg9LST2mBLmcX3/ICIbgJydT2GOc15RnNy5mHmzfSew==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/mquery": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/mquery/-/mquery-6.0.0.tgz", + "integrity": "sha512-b2KQNsmgtkscfeDgkYMcWGn9vZI9YoXh802VDEwE6qc50zxBFQ0Oo8ROkawbPAsXCY1/Z1yp0MagqsZStPWJjw==", + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -9004,6 +9454,16 @@ } } }, + "node_modules/next-themes": { + "version": "0.4.6", + "resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.6.tgz", + "integrity": "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" + } + }, "node_modules/next/node_modules/postcss": { "version": "8.4.31", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", @@ -9089,6 +9549,13 @@ "url": "https://opencollective.com/node-fetch" } }, + "node_modules/node-readable-to-web-readable-stream": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/node-readable-to-web-readable-stream/-/node-readable-to-web-readable-stream-0.4.2.tgz", + "integrity": "sha512-/cMZNI34v//jUTrI+UIo4ieHAB5EZRY/+7OmXZgBxaWBMcW2tGdceIw06RFxWxrKZ5Jp3sI2i5TsRo+CBhtVLQ==", + "license": "MIT", + "optional": true + }, "node_modules/node-releases": { "version": "2.0.36", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", @@ -9507,6 +9974,19 @@ "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", "license": "MIT" }, + "node_modules/pdfjs-dist": { + "version": "5.6.205", + "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.6.205.tgz", + "integrity": "sha512-tlUj+2IDa7G1SbvBNN74UHRLJybZDWYom+k6p5KIZl7huBvsA4APi6mKL+zCxd3tLjN5hOOEE9Tv7VdzO88pfg==", + "license": "Apache-2.0", + "engines": { + "node": ">=20.19.0 || >=22.13.0 || >=24" + }, + "optionalDependencies": { + "@napi-rs/canvas": "^0.1.96", + "node-readable-to-web-readable-stream": "^0.4.2" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -9673,7 +10153,6 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -10109,6 +10588,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/rettime": { "version": "0.10.1", "resolved": "https://registry.npmjs.org/rettime/-/rettime-0.10.1.tgz", @@ -10615,6 +11103,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/sift": { + "version": "17.1.3", + "resolved": "https://registry.npmjs.org/sift/-/sift-17.1.3.tgz", + "integrity": "sha512-Rtlj66/b0ICeFzYTuNvX/EF1igRbbnGSvEyT79McoZa/DeGhMyC5pWKOEsZKnpkqtSeovd5FL/bjHWC3CIIvCQ==", + "license": "MIT" + }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -10633,6 +11127,16 @@ "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", "license": "MIT" }, + "node_modules/sonner": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz", + "integrity": "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==", + "license": "MIT", + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" + } + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -10651,6 +11155,15 @@ "node": ">=0.10.0" } }, + "node_modules/sparse-bitfield": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz", + "integrity": "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==", + "license": "MIT", + "dependencies": { + "memory-pager": "^1.0.2" + } + }, "node_modules/stable-hash": { "version": "0.0.5", "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz", @@ -11015,6 +11528,18 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/throttleit": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/throttleit/-/throttleit-2.1.0.tgz", + "integrity": "sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/tiny-invariant": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", @@ -11120,6 +11645,18 @@ "node": ">=16" } }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/ts-api-utils": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", @@ -11361,6 +11898,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/undici": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.24.1.tgz", + "integrity": "sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA==", + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", @@ -11567,6 +12113,28 @@ "node": ">= 8" } }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index 38a7dc8..4d64c15 100644 --- a/package.json +++ b/package.json @@ -14,15 +14,21 @@ "@hookform/resolvers": "^5.2.2", "@radix-ui/react-label": "^2.1.8", "@radix-ui/react-slot": "^1.2.4", + "@vercel/blob": "^2.3.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^0.577.0", + "mongodb": "^7.1.1", + "mongoose": "^9.3.3", "next": "16.2.1", + "next-themes": "^0.4.6", + "pdfjs-dist": "^5.6.205", "radix-ui": "^1.4.3", "react": "19.2.4", "react-dom": "19.2.4", "react-hook-form": "^7.72.0", "shadcn": "^4.1.0", + "sonner": "^2.0.7", "tailwind-merge": "^3.5.0", "tw-animate-css": "^1.4.0", "zod": "^4.3.6" @@ -37,4 +43,4 @@ "tailwindcss": "^4", "typescript": "^5" } -} \ No newline at end of file +}