├── .env.example ├── .eslintrc.json ├── .gitignore ├── ReadMe.md ├── ai ├── baidu.ts ├── openai.ts └── vectorstore.ts ├── app ├── (routes) │ └── dashboard │ │ ├── [categoryId] │ │ └── page.tsx │ │ ├── _components │ │ ├── category-creator.tsx │ │ ├── default-table-columns.tsx │ │ ├── item-actions.tsx │ │ ├── items-browser.tsx │ │ ├── items-table.tsx │ │ ├── note-creator.tsx │ │ ├── paper-table-columns.tsx │ │ ├── paper-uploader.tsx │ │ ├── recent-browser.tsx │ │ ├── recent-item.tsx │ │ └── webs-browser.tsx │ │ └── layout.tsx ├── api │ ├── category │ │ ├── [categoryId] │ │ │ └── route.ts │ │ └── route.ts │ ├── chat │ │ └── route.ts │ ├── image │ │ └── route.ts │ ├── message │ │ └── route.ts │ ├── note │ │ ├── [noteId] │ │ │ └── route.ts │ │ └── route.ts │ ├── paper │ │ ├── [paperId] │ │ │ └── route.ts │ │ └── route.ts │ └── translate │ │ └── route.ts ├── favicon.ico ├── globals.css ├── layout.tsx ├── notes │ └── [noteId] │ │ └── page.tsx ├── page.tsx └── papers │ └── [paperId] │ ├── _components │ ├── chat │ │ ├── chat-client.tsx │ │ ├── chat-form.tsx │ │ ├── chat-message.tsx │ │ └── chat-messages.tsx │ ├── menu-bar.tsx │ ├── note-navbar.tsx │ ├── note-sidebar.tsx │ ├── paper-chat.tsx │ ├── paper-info.tsx │ ├── paper-note.tsx │ ├── paper-notes.tsx │ ├── paper-reader.tsx │ ├── pdf-viewer.tsx │ ├── style-setter.tsx │ └── tool-button.tsx │ └── page.tsx ├── components.json ├── components ├── category-list.tsx ├── default-categories.tsx ├── editors │ ├── excalidraw-editor.tsx │ ├── icon-picker.tsx │ └── markdown-editor.tsx ├── heading.tsx ├── heros.tsx ├── logo.tsx ├── navbar.tsx ├── search-bar.tsx ├── sidebar-item.tsx ├── sidebar.tsx └── ui │ ├── alert-dialog.tsx │ ├── avatar.tsx │ ├── button.tsx │ ├── dialog.tsx │ ├── dropdown-menu.tsx │ ├── form.tsx │ ├── input.tsx │ ├── label.tsx │ ├── popover.tsx │ ├── progress.tsx │ ├── select.tsx │ ├── separator.tsx │ ├── skeleton.tsx │ ├── slider.tsx │ ├── table.tsx │ └── tooltip.tsx ├── constants.ts ├── data ├── category.ts ├── message.ts ├── note.ts └── paper.ts ├── hooks └── use-annotations.tsx ├── lib ├── prismadb.ts └── utils.ts ├── next.config.mjs ├── package-lock.json ├── package.json ├── postcss.config.js ├── prisma ├── researcher.db └── schema.prisma ├── public ├── bot.svg ├── dashboard.png ├── documents-dark.png ├── documents.png ├── empty-dark.png ├── empty.png ├── error.png ├── files.png ├── men-dark.svg ├── men.svg ├── paper-reading.png ├── papers │ └── 1718771923339.pdf ├── reading-dark.png ├── reading.png └── user.svg ├── tailwind.config.ts ├── tsconfig.json └── types └── types.tsx /.env.example: -------------------------------------------------------------------------------- 1 | # Environment variables declared in this file are automatically made available to Prisma. 2 | # See the documentation for more detail: https://pris.ly/d/prisma-schema#accessing-environment-variables-from-the-schema 3 | 4 | # Prisma supports the native connection string format for PostgreSQL, MySQL, SQLite, SQL Server, MongoDB and CockroachDB. 5 | # See the documentation for all the connection string options: https://pris.ly/d/connection-strings 6 | 7 | DATABASE_URL="file:./researcher.db" 8 | 9 | BAIDU_TRANSLATION_APPID= 10 | BAIDU_TRANSLATION_SECRET_KEY= 11 | 12 | OPENAI_API_KEY= 13 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # public files 24 | /public/images 25 | /public/papers 26 | 27 | /prisma/researcher.db 28 | 29 | # debug 30 | npm-debug.log* 31 | yarn-debug.log* 32 | yarn-error.log* 33 | 34 | # local env files 35 | .env*.local 36 | .env 37 | 38 | logs 39 | 40 | # vercel 41 | .vercel 42 | 43 | # typescript 44 | *.tsbuildinfo 45 | next-env.d.ts 46 | 47 | nohup.out -------------------------------------------------------------------------------- /ReadMe.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | # Knowtate 4 | 5 |
6 | 7 |

8 | Empowering Research Excellence
9 | Knowtate is a scientific research assistant app offering a suite of features for literature reading, management, and intelligent QA with AI. Embrace the power of centralized document control, seamless reading, and intelligent QA with AI of your document—all within a single, streamlined application. 10 |

11 | 12 |

13 | Knowtate Landing Page 14 |

15 | 16 | > [!NOTE] 17 | > You can try Knowtate online at: https://knowtate.online or deploy it on your own local enviroment by following the instructions of [Quick Start Part](#quick-start). 18 | 19 | ## Features 20 | 21 | ### Literature Management and Reading 22 | Upload and manage your research papers with a central repository backed by object storage service. This service can be self-hostable or various cloud storage solutions like AWS S3. Enjoy an online PDF reader that enhances your literature reading experience. 23 | 24 |

25 | Document Management 26 | Paper reading 27 |

28 | 29 | ### Markdown Editor: 30 | For the markdown aficionados, the Markdown Editor provides a straightforward, text-focused interface. This allows for efficient writing and formatting with the simplicity and power of markdown syntax—perfect for those who prefer keyboard-centric controls and clean, exportable content. 31 | 32 | ### Intelligent Paper Q&A: 33 | Powered by a RAG-based intelligent Q&A system, Knowtate allows you to inquire about any aspect of your papers. Customize your dialogue models, vectorization models, and vector databases to suit your needs. Currently supporting OpenAI ChatGPT and OpenAI Embeddings for insightful interactions. 34 | 35 | ### More Features on the Horizon: 36 | Development is ongoing for additional features to enhance your research experience even further. 37 | 38 | ## Quick Start 39 | 40 | ### Run Locally 41 | 42 | #### 1. Clone the Repository Locally 43 | ```bash 44 | # Navigate to your desired path 45 | cd /your/path/ 46 | git clone https://github.com/tsmotlp/knowtate.git 47 | ``` 48 | 49 | #### 2. Configure Environment Variables 50 | ```bash 51 | cd /your/path/knowtate 52 | cp .env.example .env 53 | # Change `.env.example` to `.env` and update the following variables in `.env`: 54 | 55 | # Deployment used by `npx convex dev` 56 | DATABASE_URL="file:./researcher.db" 57 | 58 | BAIDU_TRANSLATION_APPID= 59 | BAIDU_TRANSLATION_SECRET_KEY= 60 | 61 | OPENAI_API_KEY= 62 | PROXY_URL= 63 | 64 | ``` 65 | 66 | > For Windows users, find your network proxy address in Settings -> Network and Internet -> Manual proxy setup -> Edit. Enter the server information in the format http://localhost:10077 as your PROXY_URL. 67 | 68 | > For Linux users, determine your network proxy address with: 69 | 70 | ```bash 71 | echo $HTTP_PROXY 72 | ``` 73 | 74 | #### 4. Install Depedencies: 75 | ```bash 76 | > cd path/to/knowtate 77 | > npm install # or pnpm install or yarn 78 | ``` 79 | 80 | #### 5. Run Knowtate: 81 | ```bash 82 | > cd path/to/knowtate 83 | > npm run dev # or pnpm run dev or yarn dev 84 | ``` 85 | Access Knowtate at http://localhost:3000 and delve into your personalized academic research experience. 86 | 87 | ## Copyright and License 88 | The contents of this repository are licensed under the [MIT License](https://github.com/tsmotlp/knowtate/blob/master/LICENSE). We respect and adhere to the copyright and usage permissions of each paper and resource. 89 | 90 | ## Contact Information 91 | For any questions or suggestions, please contact us through the following: 92 | 93 | - [Submit an Issue](https://github.com/tsmotlp/knowtate/issues) 94 | - Email: yqhuang2912@gmail.com -------------------------------------------------------------------------------- /ai/baidu.ts: -------------------------------------------------------------------------------- 1 | import OpenAI from "openai"; 2 | import { FaissStore } from "@langchain/community/vectorstores/faiss"; 3 | import { OpenAIEmbeddings } from "@langchain/openai"; 4 | import { OpenAIStream, StreamingTextResponse } from "ai"; 5 | import { DATA_DIR, HISTORY_MESSAGE_N, VECTOR_SEARCH_K } from "@/constants"; 6 | import { HttpsProxyAgent } from "https-proxy-agent"; 7 | import { ChatMessageProps } from "@/app/papers/[paperId]/_components/chat/chat-message"; 8 | import axios from "axios"; 9 | 10 | 11 | export async function BaiduChat(paperId: string, prompt: string, prevMessages: ChatMessageProps[] | undefined) { 12 | try { 13 | // // 1. vectorize use message 14 | // const embeddings = new OpenAIEmbeddings({ 15 | // openAIApiKey: process.env.OPENAI_API_KEY, 16 | // configuration: { 17 | // httpAgent: proxyAgent, 18 | // }, 19 | // }); 20 | 21 | // const vectors_path = `${DATA_DIR}/faiss/${paperId}`; 22 | 23 | // const vectorstore = await FaissStore.load(vectors_path, embeddings); 24 | 25 | // const results = await vectorstore.similaritySearch(prompt, VECTOR_SEARCH_K); 26 | 27 | const formattedPrevMessages = prevMessages ? prevMessages.map((msg) => ({ 28 | role: msg.role, 29 | content: msg.content, 30 | })) : []; 31 | 32 | 33 | const response = await axios.post(`https://aip.baidubce.com/rpc/2.0/ai_custom/v1/wenxinworkshop/chat/completions_pro?access_token=${process.env.BAIDU_ACCESS_TOKEN}`, { 34 | messages: [ 35 | // { 36 | // role: "system", 37 | // content: 38 | // "Use the following pieces of context (or previous conversaton if needed) to answer the users question in markdown format.", 39 | // }, 40 | { 41 | role: "user", 42 | content: `Use the following pieces of context (or previous conversaton if needed) to answer the users question in markdown format. \nIf you don't know the answer, just say that you don't know, don't try to make up an answer. 43 | 44 | \n----------------\n 45 | 46 | PREVIOUS CONVERSATION: 47 | ${formattedPrevMessages.map((message) => { 48 | if (message.role === "user") return `User: ${message.content}\n`; 49 | return `Assistant: ${message.content}\n`; 50 | })} 51 | 52 | \n----------------\n 53 | 54 | USER INPUT: ${prompt}`, 55 | }, 56 | ], 57 | stream: true 58 | }) 59 | console.log("response", response.data) 60 | 61 | const stream = OpenAIStream(response.data); 62 | return stream 63 | } catch (error) { 64 | console.log("[OPENAI_CHAT_COMPLETION_ERROR]", error) 65 | } 66 | } -------------------------------------------------------------------------------- /ai/openai.ts: -------------------------------------------------------------------------------- 1 | import OpenAI from "openai"; 2 | import { FaissStore } from "@langchain/community/vectorstores/faiss"; 3 | import { OpenAIEmbeddings } from "@langchain/openai"; 4 | import { OpenAIStream, StreamingTextResponse } from "ai"; 5 | import { DATA_DIR, HISTORY_MESSAGE_N, VECTOR_SEARCH_K } from "@/constants"; 6 | import { HttpsProxyAgent } from "https-proxy-agent"; 7 | import { ChatMessageProps } from "@/app/papers/[paperId]/_components/chat/chat-message"; 8 | 9 | const openai = new OpenAI({ 10 | apiKey: process.env.OPENAI_API_KEY, 11 | baseURL: "https://api.nextapi.fun/v1" 12 | }); 13 | 14 | export async function OpenAIChat(paperId: string, prompt: string, prevMessages: ChatMessageProps[] | undefined) { 15 | try { 16 | // let proxy_url = "" 17 | // if (process.env.PROXY_URL) { 18 | // proxy_url = process.env.PROXY_URL 19 | // } 20 | // const proxyAgent = new HttpsProxyAgent(proxy_url); 21 | 22 | // // 1. vectorize use message 23 | // const embeddings = new OpenAIEmbeddings({ 24 | // openAIApiKey: process.env.OPENAI_API_KEY, 25 | // configuration: { 26 | // httpAgent: proxyAgent, 27 | // }, 28 | // }); 29 | 30 | // const vectors_path = `${DATA_DIR}/faiss/${paperId}`; 31 | 32 | // const vectorstore = await FaissStore.load(vectors_path, embeddings); 33 | 34 | // const results = await vectorstore.similaritySearch(prompt, VECTOR_SEARCH_K); 35 | 36 | const formattedPrevMessages = prevMessages ? prevMessages.map((msg) => ({ 37 | role: msg.role, 38 | content: msg.content, 39 | })) : []; 40 | 41 | 42 | const response = await openai.chat.completions.create( 43 | { 44 | model: "gpt-3.5-turbo", 45 | temperature: 0, 46 | stream: true, 47 | messages: [ 48 | { 49 | role: "system", 50 | content: 51 | "Use the following pieces of context (or previous conversaton if needed) to answer the users question in markdown format.", 52 | }, 53 | { 54 | role: "user", 55 | content: `Use the following pieces of context (or previous conversaton if needed) to answer the users question in markdown format. \nIf you don't know the answer, just say that you don't know, don't try to make up an answer. 56 | 57 | \n----------------\n 58 | 59 | PREVIOUS CONVERSATION: 60 | ${formattedPrevMessages.map((message) => { 61 | if (message.role === "user") return `User: ${message.content}\n`; 62 | return `Assistant: ${message.content}\n`; 63 | })} 64 | 65 | \n----------------\n 66 | 67 | 68 | 69 | USER INPUT: ${prompt}`, 70 | }, 71 | ], 72 | }, 73 | // { 74 | // httpAgent: proxyAgent, 75 | // timeout: 30000, 76 | // }, 77 | ); 78 | 79 | const stream = OpenAIStream(response); 80 | return stream 81 | } catch (error) { 82 | console.log("[OPENAI_CHAT_COMPLETION_ERROR]", error) 83 | } 84 | } -------------------------------------------------------------------------------- /ai/vectorstore.ts: -------------------------------------------------------------------------------- 1 | import { PDFLoader } from "langchain/document_loaders/fs/pdf"; 2 | import { FaissStore } from "@langchain/community/vectorstores/faiss"; 3 | import { OpenAIEmbeddings } from "@langchain/openai"; 4 | import { RecursiveCharacterTextSplitter } from "langchain/text_splitter"; 5 | import { HttpsProxyAgent } from "https-proxy-agent"; 6 | import { DATA_DIR } from "@/constants"; 7 | import * as fs from 'fs'; 8 | import path from "path"; 9 | 10 | export const saveFileToVectorstore = async (paperId: string, paperUrl: string) => { 11 | try { 12 | const save_path = `${DATA_DIR}/faiss/${paperId}`; 13 | if (fs.existsSync(save_path)) { 14 | return 15 | } 16 | const paperPath = path.join(process.cwd(), "public", paperUrl) 17 | const loader = new PDFLoader(paperPath); 18 | const documents = await loader.load(); 19 | const splitter = new RecursiveCharacterTextSplitter({ 20 | chunkSize: 1000, 21 | chunkOverlap: 100, 22 | }); 23 | 24 | const chunks = await splitter.splitDocuments(documents); 25 | 26 | const proxyAgent = new HttpsProxyAgent(process.env.PROXY_URL!); 27 | 28 | const embeddings = new OpenAIEmbeddings({ 29 | openAIApiKey: process.env.OPENAP_API_KEY, 30 | configuration: { 31 | httpAgent: proxyAgent, 32 | }, 33 | }); 34 | 35 | const vectors = await FaissStore.fromDocuments(chunks, embeddings); 36 | 37 | await vectors.save(save_path); 38 | } catch (error) { 39 | throw new Error(`Embedding file to faiss got error: ${error}`); 40 | } 41 | }; 42 | -------------------------------------------------------------------------------- /app/(routes)/dashboard/[categoryId]/page.tsx: -------------------------------------------------------------------------------- 1 | import { getArchivedCategories, getCategoriesByParent, getCategory, getFavoritedCategories } from "@/data/category" 2 | import { getArchivedNotesWithPaper, getFavoritedNotesWithPaper, getNotesWithPaper } from "@/data/note" 3 | import { getArchivedPapers, getFavoritedPapers, getPapers, getRecentPapers } from "@/data/paper" 4 | import { ItemsBrowser } from "../_components/items-browser" 5 | import { DashboardItem, DashboardItemType, NoteWithPaper } from "@/types/types" 6 | import { Category, Paper } from "@prisma/client" 7 | import { RecentBrowser } from "../_components/recent-browser" 8 | import { WebsBrowser } from "../_components/webs-browser" 9 | 10 | interface CategoryIdPageProps { 11 | params: { 12 | categoryId: string 13 | } 14 | } 15 | 16 | const CategoryIdPage = async ({ params }: CategoryIdPageProps) => { 17 | const category = await getCategory(params.categoryId) 18 | let subCategories: Category[] | undefined = [] 19 | let papers: Paper[] | undefined = [] 20 | let notes: NoteWithPaper[] | undefined = [] 21 | if (params.categoryId === "recents") { 22 | papers = await getRecentPapers() 23 | } else if (params.categoryId === "trash") { 24 | subCategories = await getArchivedCategories() 25 | notes = await getArchivedNotesWithPaper() 26 | papers = await getArchivedPapers() 27 | } else if (params.categoryId === "favorites") { 28 | subCategories = await getFavoritedCategories() 29 | notes = await getFavoritedNotesWithPaper() 30 | papers = await getFavoritedPapers() 31 | } else { 32 | subCategories = await getCategoriesByParent(params.categoryId) 33 | notes = await getNotesWithPaper(params.categoryId) 34 | papers = await getPapers(params.categoryId) 35 | } 36 | 37 | const items: DashboardItem[] = [] 38 | if (subCategories) { 39 | subCategories.map((c) => (items.push({ 40 | id: c.id, 41 | label: c.name, 42 | type: DashboardItemType.Category, 43 | archived: c.archived, 44 | favorited: c.favorited, 45 | url: null, 46 | authors: null, 47 | publication: null, 48 | publicationDate: null, 49 | paperTile: null, 50 | paperId: null, 51 | lastEdit: c.updatedAt, 52 | }))) 53 | } 54 | 55 | if (papers) { 56 | papers.map((p) => (items.push({ 57 | id: p.id, 58 | label: p.title, 59 | type: DashboardItemType.Paper, 60 | archived: p.archived, 61 | favorited: p.favorited, 62 | url: p.url, 63 | authors: p.authors, 64 | publication: p.publication, 65 | publicationDate: p.publicateDate, 66 | paperTile: null, 67 | paperId: null, 68 | lastEdit: p.updatedAt, 69 | }))) 70 | } 71 | 72 | if (notes) { 73 | notes.map((n) => (items.push({ 74 | id: n.id, 75 | label: n.title, 76 | type: n.type as DashboardItemType, 77 | archived: n.archived, 78 | favorited: n.favorited, 79 | url: null, 80 | authors: null, 81 | publication: null, 82 | publicationDate: null, 83 | paperTile: n.paper ? n.paper.title : null, 84 | paperId: n.paperId, 85 | lastEdit: n.updatedAt, 86 | }))) 87 | } 88 | 89 | 90 | return ( 91 |
92 | {params.categoryId === "webs" ? ( 93 | 94 | ) : params.categoryId === "recents" ? ( 95 | 100 | ) : ( 101 | 106 | )} 107 |
108 | ); 109 | 110 | } 111 | 112 | 113 | export default CategoryIdPage -------------------------------------------------------------------------------- /app/(routes)/dashboard/_components/category-creator.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { Button } from "@/components/ui/button" 4 | import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog" 5 | import { Input } from "@/components/ui/input" 6 | import { CategoryType, DashboardItem, DashboardItemType } from "@/types/types" 7 | import { Category } from "@prisma/client" 8 | import axios from "axios" 9 | import { FolderPlus } from "lucide-react" 10 | import { useRouter } from "next/navigation" 11 | import { Dispatch, SetStateAction, useState } from "react" 12 | import { toast } from "sonner" 13 | 14 | interface CategoryCreatorProps { 15 | type: CategoryType 16 | parentId: string 17 | setItems: Dispatch> 18 | } 19 | 20 | export const CategoryCreator = ({ 21 | type, 22 | parentId, 23 | setItems, 24 | }: CategoryCreatorProps) => { 25 | const router = useRouter() 26 | const [categoryName, setCategoryName] = useState("") 27 | const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false) 28 | const handleInputChange = (e: React.ChangeEvent) => { 29 | setCategoryName(e.target.value) 30 | } 31 | const handleCreateCategory = async (name: string) => { 32 | try { 33 | const response = await axios.post("/api/category", JSON.stringify({ 34 | name: name, 35 | type: type, 36 | parentId: parentId 37 | })) 38 | if (response.status === 200) { 39 | const createdCategory: Category = response.data 40 | setItems((current) => [...current, { 41 | id: createdCategory.id, 42 | label: createdCategory.name, 43 | type: DashboardItemType.Category, 44 | archived: createdCategory.archived, 45 | favorited: createdCategory.favorited, 46 | url: null, 47 | authors: null, 48 | publication: null, 49 | publicationDate: null, 50 | paperTile: null, 51 | paperId: null, 52 | lastEdit: createdCategory.updatedAt, 53 | }]) 54 | toast.success("Category created!"); 55 | router.refresh() 56 | } 57 | } catch (error) { 58 | console.log("Failed to create category", error); 59 | toast.error("Failed to create category!"); 60 | } 61 | }; 62 | 63 | return ( 64 | { 67 | setIsCreateDialogOpen(isOpen) 68 | }} 69 | > 70 | 71 | 78 | 79 | 80 | 81 | 创建新分类 82 | 83 |
84 | 88 |
89 | 90 | 102 | 112 | 113 |
114 |
115 | ) 116 | } -------------------------------------------------------------------------------- /app/(routes)/dashboard/_components/default-table-columns.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { Paper } from "@prisma/client"; 4 | import { ColumnDef } from "@tanstack/react-table"; 5 | import { format } from "date-fns"; 6 | // import { FileActions } from "./file-actions"; 7 | import Link from "next/link"; 8 | import { BsFiletypePdf, BsMarkdown } from "react-icons/bs" 9 | // import { PaperActions } from "./paper-actions"; 10 | import { Folder, PenTool } from "lucide-react"; 11 | // import { CategoryActions } from "../../../../../components/category/category-actions"; 12 | import { DashboardItem, DashboardItemType } from "@/types/types"; 13 | import { ItemActions } from "./item-actions"; 14 | 15 | 16 | export const DefaultTableColumns: ColumnDef[] = [ 17 | { 18 | header: "标题", 19 | cell: ({ row }) => { 20 | return ( 21 | 26 |
27 | {row.original.type === DashboardItemType.Paper && ( 28 | 29 | )} 30 | {row.original.type === DashboardItemType.Category && ( 31 | 32 | )} 33 | {row.original.type === DashboardItemType.Markdown && ( 34 | 35 | )} 36 | {row.original.type === DashboardItemType.Whiteboard && ( 37 | 38 | )} 39 | {row.original.label} 40 |
41 | 42 | ) 43 | } 44 | }, 45 | { 46 | header: "所属论文", 47 | cell: ({ row }) => { 48 | return ( 49 | <> 50 | {row.original.paperId && ( 51 | 55 | 56 | {row.original.paperTile} 57 | 58 | )} 59 | 60 | ) 61 | } 62 | }, 63 | { 64 | header: "最后修改", 65 | cell: ({ row }) => { 66 | return ( 67 |
68 | {format(new Date(row.original.lastEdit), "yyyy/MM/dd")} 69 |
70 | ); 71 | }, 72 | }, 73 | { 74 | header: "管理", 75 | cell: ({ row }) => { 76 | return ( 77 |
78 | 81 |
82 | ); 83 | }, 84 | }, 85 | ] -------------------------------------------------------------------------------- /app/(routes)/dashboard/_components/items-browser.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { defaultCategories } from "@/components/default-categories" 4 | import { SearchBar } from "@/components/search-bar" 5 | import { CategoryType, DashboardItem, DashboardItemType } from "@/types/types" 6 | import { Category } from "@prisma/client" 7 | import { useEffect, useState } from "react" 8 | import { CategoryCreator } from "./category-creator" 9 | import { PaperUploader } from "./paper-uploader" 10 | import { Separator } from "@/components/ui/separator" 11 | import { NoteCreator } from "./note-creator" 12 | import { ItemsTable } from "./items-table" 13 | 14 | interface ItemsBrowserProps { 15 | category: Category | undefined | null, 16 | parentId: string, 17 | initItems: DashboardItem[] 18 | } 19 | 20 | // 这里的item包含Category, Paper和Note 21 | export const ItemsBrowser = ({ 22 | category, 23 | parentId, 24 | initItems, 25 | }: ItemsBrowserProps) => { 26 | if (!category) { 27 | category = defaultCategories.find((c) => c.id === parentId) 28 | } 29 | const [query, setQuery] = useState(""); 30 | const [items, setItems] = useState(initItems) 31 | 32 | 33 | useEffect(() => { 34 | const filterByQuery = (item: DashboardItem) => item.label.toLowerCase().includes(query.toLowerCase()); 35 | setItems(items.filter(filterByQuery)); 36 | }, [query]) 37 | 38 | return ( 39 | <> 40 | {category ? ( 41 |
42 |
43 |
44 |
45 | {category.name} 46 |
47 | {category.type === CategoryType.Papers && ( 48 |
49 | 50 | 51 |
52 | )} 53 | {category.type === CategoryType.Markdowns && ( 54 |
55 | 56 | 57 |
58 | )} 59 | {category.type === CategoryType.Whiteboards && ( 60 |
61 | 62 | 63 |
64 | )} 65 |
66 |
67 | 71 |
72 |
73 | 74 |
75 | 76 |
77 |
78 | ) : ( 79 |
80 | 出错啦 81 |
82 | )} 83 | 84 | ) 85 | } -------------------------------------------------------------------------------- /app/(routes)/dashboard/_components/items-table.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { 4 | flexRender, 5 | getCoreRowModel, 6 | Table as ReactTable, 7 | useReactTable, 8 | } from "@tanstack/react-table"; 9 | 10 | import { 11 | Table, 12 | TableBody, 13 | TableCell, 14 | TableHead, 15 | TableHeader, 16 | TableRow, 17 | } from "@/components/ui/table"; 18 | import { DefaultTableColumns } from "./default-table-columns"; 19 | import Image from "next/image"; 20 | import { CategoryType, DashboardItem, DashboardItemType } from "@/types/types"; 21 | import { PaperTableColumns } from "./paper-table-columns"; 22 | 23 | interface ItemsTableProps { 24 | type: CategoryType, 25 | items: DashboardItem[], 26 | } 27 | 28 | export const ItemsTable = ({ 29 | type, 30 | items 31 | }: ItemsTableProps) => { 32 | const columns = type === CategoryType.Papers ? PaperTableColumns : DefaultTableColumns; 33 | 34 | const table = useReactTable({ 35 | columns: columns, 36 | data: items, 37 | getCoreRowModel: getCoreRowModel() 38 | }); 39 | 40 | return ( 41 | <> 42 | {items.length > 0 ? ( 43 |
44 | 45 | 46 | {table.getHeaderGroups().map((headerGroup) => ( 47 | 48 | {headerGroup.headers.map((header) => { 49 | return ( 50 | 53 | {header.isPlaceholder ? null : flexRender( 54 | header.column.columnDef.header, 55 | header.getContext() 56 | )} 57 | 58 | ) 59 | })} 60 | 61 | ))} 62 | 63 | 64 | {table.getRowModel().rows?.length > 0 && ( 65 | table.getRowModel().rows 66 | .sort( 67 | (a, b) => { 68 | // 首先比较url,空字符串视为较小 69 | if (a.original.type === DashboardItemType.Category && b.original.type !== DashboardItemType.Category) return -1; 70 | if (a.original.type !== DashboardItemType.Category && b.original.type === DashboardItemType.Category) return 1; 71 | 72 | // 如果url相同(都为空或都不为空),则比较updatedAt 73 | return new Date(b.original.lastEdit).getTime() - new Date(a.original.lastEdit).getTime(); 74 | } 75 | ) 76 | .map((row) => ( 77 | 81 | {row.getVisibleCells().map((cell) => ( 82 | 83 | {flexRender(cell.column.columnDef.cell, cell.getContext())} 84 | 85 | ))} 86 | 87 | )) 88 | )} 89 | 90 |
91 |
92 | ) : ( 93 |
94 | Empty 101 | Empty 108 |

Nothing Found!

109 |
110 | )} 111 | 112 | ) 113 | } -------------------------------------------------------------------------------- /app/(routes)/dashboard/_components/note-creator.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { Button } from "@/components/ui/button" 4 | import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog" 5 | import { Input } from "@/components/ui/input" 6 | import { CategoryType, DashboardItem, DashboardItemType } from "@/types/types" 7 | import { Note } from "@prisma/client" 8 | import axios from "axios" 9 | import { FolderPlus } from "lucide-react" 10 | import { useRouter } from "next/navigation" 11 | import { Dispatch, SetStateAction, useState } from "react" 12 | import { toast } from "sonner" 13 | 14 | interface NoteCreatorProps { 15 | type: DashboardItemType 16 | categoryId: string 17 | paperId?: string, 18 | setItems: Dispatch> 19 | } 20 | 21 | export const NoteCreator = ({ 22 | type, 23 | categoryId, 24 | paperId, 25 | setItems, 26 | }: NoteCreatorProps) => { 27 | const router = useRouter() 28 | const [noteName, setNoteName] = useState("") 29 | const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false) 30 | const handleInputChange = (e: React.ChangeEvent) => { 31 | setNoteName(e.target.value) 32 | } 33 | const handleCreateNote = async (title: string) => { 34 | try { 35 | const response = await axios.post("/api/note", JSON.stringify({ 36 | title: title ? title : "Untitled", 37 | type: type, 38 | categoryId: categoryId, 39 | paperId: paperId, 40 | })) 41 | if (response.status === 200) { 42 | const createdNote: Note = response.data 43 | setItems((current) => [...current, { 44 | id: createdNote.id, 45 | label: createdNote.title, 46 | type: createdNote.type as DashboardItemType, 47 | archived: createdNote.archived, 48 | favorited: createdNote.favorited, 49 | url: null, 50 | authors: null, 51 | publication: null, 52 | publicationDate: null, 53 | paperTile: null, 54 | paperId: createdNote.paperId, 55 | lastEdit: createdNote.updatedAt, 56 | }]) 57 | toast.success("Note created!"); 58 | router.refresh() 59 | } 60 | } catch (error) { 61 | console.log("Failed to create note", error); 62 | toast.error("Failed to create note!"); 63 | } 64 | }; 65 | 66 | return ( 67 | { 70 | setIsCreateDialogOpen(isOpen) 71 | }} 72 | > 73 | 74 | 81 | 82 | 83 | 84 | 85 | {type === DashboardItemType.Markdown ? "创建笔记" : "创建白板"} 86 | 87 | 88 |
89 | 93 |
94 | 95 | 108 | 117 | 118 |
119 |
120 | ) 121 | } -------------------------------------------------------------------------------- /app/(routes)/dashboard/_components/paper-table-columns.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { Paper } from "@prisma/client"; 4 | import { ColumnDef } from "@tanstack/react-table"; 5 | import { format } from "date-fns"; 6 | // import { FileActions } from "./file-actions"; 7 | import Link from "next/link"; 8 | import { BsFiletypePdf, BsMarkdown } from "react-icons/bs" 9 | // import { PaperActions } from "./paper-actions"; 10 | import { Folder, PenTool } from "lucide-react"; 11 | // import { CategoryActions } from "../../../../../components/category/category-actions"; 12 | import { DashboardItem, DashboardItemType } from "@/types/types"; 13 | import { ItemActions } from "./item-actions"; 14 | 15 | 16 | export const PaperTableColumns: ColumnDef[] = [ 17 | { 18 | header: "标题", 19 | cell: ({ row }) => { 20 | return ( 21 | 26 |
27 | {row.original.type === DashboardItemType.Paper && ( 28 | 29 | )} 30 | {row.original.type === DashboardItemType.Category && ( 31 | 32 | )} 33 | {row.original.label} 34 |
35 | 36 | ) 37 | } 38 | }, 39 | { 40 | header: "作者", 41 | cell: ({ row }) => { 42 | return ( 43 |
44 | {row.original.authors} 45 |
46 | ) 47 | } 48 | }, 49 | { 50 | header: "期刊", 51 | cell: ({ row }) => { 52 | return ( 53 |
54 | {row.original.publication} 55 |
56 | ) 57 | } 58 | }, 59 | // { 60 | // header: "发表时间", 61 | // cell: ({ row }) => { 62 | // return ( 63 | //
64 | // {row.original.publicationDate} 65 | //
66 | // ) 67 | // } 68 | // }, 69 | { 70 | header: "最后修改", 71 | cell: ({ row }) => { 72 | return ( 73 |
74 | {format(new Date(row.original.lastEdit), "yyyy/MM/dd")} 75 |
76 | ); 77 | }, 78 | }, 79 | { 80 | header: "管理", 81 | cell: ({ row }) => { 82 | return ( 83 |
84 | 87 |
88 | ); 89 | }, 90 | }, 91 | ] -------------------------------------------------------------------------------- /app/(routes)/dashboard/_components/recent-browser.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { defaultCategories } from "@/components/default-categories" 4 | import { SearchBar } from "@/components/search-bar" 5 | import { CategoryType, DashboardItem, DashboardItemType } from "@/types/types" 6 | import { Category } from "@prisma/client" 7 | import { useEffect, useState } from "react" 8 | import { CategoryCreator } from "./category-creator" 9 | import { PaperUploader } from "./paper-uploader" 10 | import { Separator } from "@/components/ui/separator" 11 | import { NoteCreator } from "./note-creator" 12 | import { RecentItem } from "./recent-item" 13 | 14 | interface RecentBrowserProps { 15 | category: Category | undefined | null, 16 | parentId: string, 17 | initItems: DashboardItem[] 18 | } 19 | 20 | function getStartOfToday(): Date { 21 | const today = new Date(); 22 | today.setHours(0, 0, 0, 0); 23 | return today; 24 | } 25 | 26 | function getStartOfYesterday(): Date { 27 | const yesterday = new Date(); 28 | yesterday.setDate(yesterday.getDate() - 1); 29 | yesterday.setHours(0, 0, 0, 0); 30 | return yesterday; 31 | } 32 | 33 | function getStartOfLastWeek(): Date { 34 | const today = new Date(); 35 | today.setDate(today.getDate() - 7); // 往前推7天 36 | today.setHours(0, 0, 0, 0); 37 | return today; 38 | } 39 | 40 | function getStartOfLastMonth(): Date { 41 | const today = new Date(); 42 | today.setDate(today.getDate() - 30); // 往前推30天 43 | today.setHours(0, 0, 0, 0); 44 | return today; 45 | } 46 | 47 | // 这里的item包含Category, Paper和Note 48 | export const RecentBrowser = ({ 49 | category, 50 | parentId, 51 | initItems, 52 | }: RecentBrowserProps) => { 53 | if (!category) { 54 | category = defaultCategories.find((c) => c.id === parentId) 55 | } 56 | const [query, setQuery] = useState(""); 57 | const [items, setItems] = useState(initItems) 58 | const [expanded, setExpanded] = useState>({}); 59 | const onExpand = (id: string) => { 60 | setExpanded(prevExpanded => ({ 61 | ...prevExpanded, 62 | [id]: !prevExpanded[id] 63 | })); 64 | }; 65 | 66 | const startOfToday = getStartOfToday(); 67 | const startOfYesterday = getStartOfYesterday(); 68 | const startOfLastWeek = getStartOfLastWeek(); 69 | const startOfLastMonth = getStartOfLastMonth(); 70 | const today = items.filter(item => item.lastEdit >= startOfToday); 71 | const yesterday = items.filter(item => item.lastEdit >= startOfYesterday && item.lastEdit < startOfToday) 72 | const week = items.filter(item => item.lastEdit >= startOfLastWeek && item.lastEdit < startOfYesterday) 73 | const month = items.filter(item => item.lastEdit >= startOfLastMonth && item.lastEdit < startOfLastWeek) 74 | 75 | useEffect(() => { 76 | const filterByQuery = (item: DashboardItem) => item.label.toLowerCase().includes(query.toLowerCase()); 77 | setItems(items.filter(filterByQuery)); 78 | }, [query]) 79 | 80 | return ( 81 | <> 82 | {category ? ( 83 |
84 |
85 |
86 |
87 | {category.name} 88 |
89 | {category.type === CategoryType.Papers && ( 90 |
91 | 92 | 93 |
94 | )} 95 | {category.type === CategoryType.Markdowns && ( 96 |
97 | 98 | 99 |
100 | )} 101 | {category.type === CategoryType.Whiteboards && ( 102 |
103 | 104 | 105 |
106 | )} 107 |
108 |
109 | 113 |
114 |
115 | 116 |
117 | onExpand("today")} 121 | items={today} 122 | /> 123 | onExpand("yesterday")} 127 | items={yesterday} 128 | /> 129 | onExpand("week")} 133 | items={week} 134 | /> 135 | onExpand("month")} 139 | items={month} 140 | /> 141 |
142 |
143 | ) : ( 144 |
145 | 出错啦 146 |
147 | )} 148 | 149 | ) 150 | } -------------------------------------------------------------------------------- /app/(routes)/dashboard/_components/recent-item.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { DashboardItem } from "@/types/types"; 4 | import { format } from "date-fns"; 5 | import { ChevronDown, ChevronRight } from "lucide-react"; 6 | import Link from "next/link"; 7 | import { BsFiletypePdf } from "react-icons/bs"; 8 | 9 | interface RecentItemProps { 10 | label: string, 11 | expanded?: boolean 12 | onExpand: () => void 13 | items: DashboardItem[] 14 | } 15 | 16 | export const RecentItem = ({ 17 | label, 18 | expanded = true, 19 | onExpand, 20 | items 21 | }: RecentItemProps) => { 22 | 23 | const handleExpand = ( 24 | event: React.MouseEvent 25 | ) => { 26 | event.stopPropagation(); 27 | onExpand?.(); 28 | }; 29 | 30 | const ChevronIcon = expanded ? ChevronDown : ChevronRight; 31 | 32 | 33 | return ( 34 |
35 |
36 |
41 | 44 |
45 | 46 | {label} 47 | 48 |
49 | {expanded && ( 50 |
51 | {items && items.map((item) => ( 52 |
53 | 57 | 58 | {item.label} 59 | 60 |
61 | {format(new Date(item.lastEdit), "MM/dd HH:mm")} 62 |
63 |
64 | ))} 65 |
66 | )} 67 |
68 | ) 69 | } -------------------------------------------------------------------------------- /app/(routes)/dashboard/_components/webs-browser.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | interface WebsBrowserProps { 4 | url: string 5 | } 6 | 7 | export const WebsBrowser = ({ 8 | url 9 | }: WebsBrowserProps) => { 10 | return ( 11 |
12 |

13 | Embed External Site 14 |

15 | 23 |
24 | ) 25 | } -------------------------------------------------------------------------------- /app/(routes)/dashboard/layout.tsx: -------------------------------------------------------------------------------- 1 | import { Sidebar } from "@/components/sidebar" 2 | import { Navbar } from "@/components/navbar" 3 | 4 | 5 | const DashboardLayout = ({ 6 | children 7 | }: { 8 | children: React.ReactNode 9 | }) => { 10 | return ( 11 |
12 | 13 |
14 | 15 |
16 | {children} 17 |
18 |
19 | 20 |
21 | ) 22 | } 23 | 24 | export default DashboardLayout -------------------------------------------------------------------------------- /app/api/category/[categoryId]/route.ts: -------------------------------------------------------------------------------- 1 | import { archiveCategory, favoriteCategory, moveCategory, removeCategory, renameCategory } from "@/data/category" 2 | import { NextResponse } from "next/server" 3 | 4 | export const PATCH = async ( 5 | req: Request, 6 | { params }: { params: { categoryId: string } } 7 | ) => { 8 | try { 9 | const body = await req.json() 10 | const { title, favorited, archived, parentId } = body 11 | if (title) { 12 | const renamedCategory = await renameCategory(params.categoryId, title); 13 | if (renamedCategory) { 14 | return new NextResponse(JSON.stringify(renamedCategory), { status: 200 }); 15 | } 16 | return new NextResponse("Failed to update category", { status: 500 }); 17 | } else if (parentId) { 18 | const movedCategory = await moveCategory(params.categoryId, parentId); 19 | if (movedCategory) { 20 | return new NextResponse(JSON.stringify(movedCategory), { status: 200 }); 21 | } 22 | return new NextResponse("Failed to move category", { status: 500 }); 23 | } else if (archived === true) { 24 | const archivedCategory = await archiveCategory(params.categoryId, true) 25 | if (archivedCategory) { 26 | return new NextResponse(JSON.stringify(archivedCategory), { status: 200 }) 27 | } 28 | return new NextResponse("Failed to update category", { status: 500 }) 29 | } else if (archived === false) { 30 | const restoredCategory = await archiveCategory(params.categoryId, false) 31 | if (restoredCategory) { 32 | return new NextResponse(JSON.stringify(restoredCategory), { status: 200 }) 33 | } 34 | return new NextResponse("Failed to update category", { status: 500 }) 35 | } else if (favorited === true) { 36 | const favoritedCategory = await favoriteCategory(params.categoryId, true) 37 | if (favoritedCategory) { 38 | return new NextResponse(JSON.stringify(favoritedCategory), { status: 200 }) 39 | } 40 | return new NextResponse("Failed to update category", { status: 500 }) 41 | } else if (favorited === false) { 42 | const unfavoritedCategory = await favoriteCategory(params.categoryId, false) 43 | if (unfavoritedCategory) { 44 | return new NextResponse(JSON.stringify(unfavoritedCategory), { status: 200 }) 45 | } 46 | return new NextResponse("Failed to update category", { status: 500 }) 47 | } else { 48 | return new NextResponse("Unexpected field", { status: 404 }) 49 | } 50 | } catch (error) { 51 | console.log("UPDATE CATEGORY ERROR", error) 52 | return new NextResponse("Internal Error", { status: 500 }) 53 | } 54 | } 55 | 56 | export const DELETE = async ( 57 | request: Request, 58 | { params }: { params: { categoryId: string } } 59 | ) => { 60 | try { 61 | const removedCategory = await removeCategory(params.categoryId) 62 | if (removedCategory) { 63 | return new NextResponse("Category removed", { status: 200 }) 64 | } 65 | } catch (error) { 66 | console.log("DELETE CATEGORY ERROR", error) 67 | return new NextResponse("Internal Error", { status: 500 }) 68 | } 69 | } -------------------------------------------------------------------------------- /app/api/category/route.ts: -------------------------------------------------------------------------------- 1 | import { createCategory, getAllCategories } from "@/data/category"; 2 | import { NextResponse } from "next/server"; 3 | 4 | export const POST = async (req: Request) => { 5 | try { 6 | const body = await req.json(); 7 | const { name, type, parentId } = body 8 | if (!name || name === "") { 9 | return new NextResponse("name must not be null", { status: 400 }); 10 | } 11 | if (!parentId || parentId === "") { 12 | return new NextResponse("parentId must not be null", { status: 400 }); 13 | } 14 | if (!type || type === "") { 15 | return new NextResponse("category type must not be null", { status: 400 }); 16 | } 17 | const category = await createCategory(name, type, parentId) 18 | return NextResponse.json(category) 19 | } catch (error) { 20 | console.log("CREATE CATEGORY ERROR", error); 21 | return new NextResponse("Internal Error", { status: 500 }); 22 | } 23 | } 24 | 25 | export const GET = async () => { 26 | try { 27 | const categories = await getAllCategories() 28 | return NextResponse.json(categories) 29 | } catch (error) { 30 | console.log("GET CATEGORIES ERROR", error); 31 | return new NextResponse("Internal Error", { status: 500 }); 32 | } 33 | } -------------------------------------------------------------------------------- /app/api/chat/route.ts: -------------------------------------------------------------------------------- 1 | import { OpenAIChat } from "@/ai/openai"; 2 | import { saveFileToVectorstore } from "@/ai/vectorstore"; 3 | import { StreamingTextResponse } from "ai"; 4 | import { NextRequest, NextResponse } from "next/server"; 5 | 6 | export const POST = async (req: NextRequest) => { 7 | try { 8 | const body = await req.json() 9 | const { paperId, paperUrl, prompt, prevMessages } = body 10 | // await saveFileToVectorstore(paperId, paperUrl) 11 | const stream = await OpenAIChat(paperId, prompt, prevMessages) 12 | // const stream = await BaiduChat(paperId, prompt, prevMessages) 13 | if (stream) { 14 | return new StreamingTextResponse(stream); 15 | } else { 16 | return new NextResponse("Openai chat error", { status: 500 }); 17 | } 18 | } catch (error) { 19 | console.log("CHAT WITH AI ERROR", error) 20 | return new NextResponse("Chat with ai error", { status: 500 }) 21 | } 22 | } -------------------------------------------------------------------------------- /app/api/image/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from "next/server"; 2 | import { promises as fs } from 'fs'; 3 | 4 | export async function POST(req: Request) { 5 | try { 6 | const formData = await req.formData(); 7 | const image = formData.get("image") as File; 8 | if (!image) { 9 | return new NextResponse("No image uploaded", { status: 404 }); 10 | } 11 | // 处理文件的保存 12 | const imageNameSplits = image.name.split(".") 13 | if (imageNameSplits.length === 0) { 14 | return new NextResponse("Uploaded file is not a valid image", { status: 404 }); 15 | } 16 | const url = `/images/${Date.now().toString()}.${imageNameSplits[imageNameSplits.length - 1]}`; 17 | const imageData = await image.arrayBuffer(); 18 | const buffer = Buffer.from(imageData); 19 | await fs.writeFile(`public${url}`, buffer); 20 | // remove old image 21 | const oldUrl = formData.get("oldUrl") 22 | if (oldUrl) { 23 | const resoveUrl = oldUrl as string 24 | await fs.unlink(resoveUrl) 25 | } 26 | return new NextResponse(url, { status: 200 }); 27 | } catch (err) { 28 | console.log("UPLOAD IMAGE ERROR", err); 29 | return new NextResponse("Upload image error", { status: 500 }); 30 | } 31 | } 32 | 33 | -------------------------------------------------------------------------------- /app/api/message/route.ts: -------------------------------------------------------------------------------- 1 | import { createMessage, getMessages, getLimitedMessages, removeMessagesOfPaper } from "@/data/message"; 2 | import { NextResponse } from "next/server"; 3 | 4 | export const POST = async (req: Request) => { 5 | try { 6 | const body = await req.json(); 7 | const { paperId, role, content } = body 8 | if (!paperId || paperId === "") { 9 | return new NextResponse("paperId must not be null", { status: 400 }); 10 | } 11 | if (!role || role === "") { 12 | return new NextResponse("role must not be null", { status: 400 }); 13 | } 14 | if (!content || content === "") { 15 | return new NextResponse("content must not be null", { status: 400 }); 16 | } 17 | const message = await createMessage(paperId, role, content) 18 | return NextResponse.json(message) 19 | } catch (error) { 20 | console.log("CREATE MESSAGE ERROR", error); 21 | return new NextResponse("Internal Error", { status: 500 }); 22 | } 23 | } 24 | 25 | export const GET = async (req: Request, 26 | { params }: { 27 | params: { 28 | paperId: string, 29 | limit: number 30 | } 31 | } 32 | ) => { 33 | try { 34 | if (!params.limit) { 35 | const messages = await getMessages(params.paperId) 36 | return NextResponse.json(messages) 37 | } else { 38 | const messages = await getLimitedMessages(params.paperId, params.limit) 39 | return NextResponse.json(messages) 40 | } 41 | } catch (error) { 42 | console.log("GET MESSAGES ERROR", error); 43 | return new NextResponse("Internal Error", { status: 500 }); 44 | } 45 | } 46 | 47 | export const DELETE = async ( 48 | request: Request, 49 | { params }: { params: { paperId: string } } 50 | ) => { 51 | try { 52 | const removedMessages = await removeMessagesOfPaper(params.paperId) 53 | if (removedMessages) { 54 | return new NextResponse("Note removed", { status: 200 }) 55 | } 56 | } catch (error) { 57 | console.log("DELETE NOTE ERROR", error) 58 | return new NextResponse("Internal Error", { status: 500 }) 59 | } 60 | } -------------------------------------------------------------------------------- /app/api/note/[noteId]/route.ts: -------------------------------------------------------------------------------- 1 | import { archiveNote, favoriteNote, removeNote, renameNote, updateContent, updateIcon } from "@/data/note" 2 | import { NextResponse } from "next/server" 3 | 4 | export const PATCH = async ( 5 | req: Request, 6 | { params }: { params: { noteId: string } } 7 | ) => { 8 | try { 9 | const body = await req.json() 10 | const { title, archived, favorited, content, icon } = body 11 | if (title) { 12 | const renamedNote = await renameNote(params.noteId, title) 13 | if (renamedNote) { 14 | return NextResponse.json(renamedNote) 15 | } 16 | return new NextResponse("Failed to update note", { status: 500 }) 17 | } else if (icon) { 18 | const updatedNote = await updateIcon(params.noteId, icon) 19 | if (updatedNote) { 20 | return NextResponse.json(updatedNote) 21 | } 22 | return new NextResponse("Failed to update note", { status: 500 }) 23 | } else if (favorited === true) { 24 | const favoritedNote = await favoriteNote(params.noteId, true) 25 | if (favoritedNote) { 26 | return NextResponse.json(favoritedNote) 27 | } 28 | return new NextResponse("Failed to update note", { status: 500 }) 29 | } else if (favorited === false) { 30 | const unfavoritedNote = await favoriteNote(params.noteId, false) 31 | if (unfavoritedNote) { 32 | return NextResponse.json(unfavoritedNote) 33 | } 34 | return new NextResponse("Failed to update note", { status: 500 }) 35 | } else if (archived === true) { 36 | const archivedNote = await archiveNote(params.noteId, true) 37 | if (archivedNote) { 38 | return NextResponse.json(archivedNote) 39 | } 40 | return new NextResponse("Failed to update note", { status: 500 }) 41 | } else if (archived === false) { 42 | const restoredNote = await archiveNote(params.noteId, false) 43 | if (restoredNote) { 44 | return NextResponse.json(restoredNote) 45 | } 46 | return new NextResponse("Failed to update note", { status: 500 }) 47 | } else if (content !== null) { 48 | const updatedNote = await updateContent(params.noteId, content) 49 | if (updatedNote) { 50 | return NextResponse.json(updatedNote) 51 | } 52 | return new NextResponse("Failed to update note", { status: 500 }) 53 | } else { 54 | return new NextResponse("Unexpected field", { status: 404 }) 55 | } 56 | } catch (error) { 57 | console.log("UPDATE NOTE ERROR", error) 58 | return new NextResponse("Internal Error", { status: 500 }) 59 | } 60 | } 61 | 62 | export const DELETE = async ( 63 | request: Request, 64 | { params }: { params: { noteId: string } } 65 | ) => { 66 | try { 67 | const removedNote = await removeNote(params.noteId) 68 | if (removedNote) { 69 | return new NextResponse("Note removed", { status: 200 }) 70 | } 71 | } catch (error) { 72 | console.log("DELETE NOTE ERROR", error) 73 | return new NextResponse("Internal Error", { status: 500 }) 74 | } 75 | } -------------------------------------------------------------------------------- /app/api/note/route.ts: -------------------------------------------------------------------------------- 1 | import { createNote } from "@/data/note"; 2 | import { NextResponse } from "next/server"; 3 | 4 | export const POST = async (req: Request) => { 5 | try { 6 | const body = await req.json(); 7 | const { title, type, categoryId, paperId } = body 8 | if (!title) { 9 | return new NextResponse("title cannot be null", { status: 400 }); 10 | } 11 | if (!type) { 12 | return new NextResponse("type cannot be null", { status: 400 }); 13 | } 14 | if (!categoryId) { 15 | return new NextResponse("categoryId cannot be null", { status: 400 }); 16 | } 17 | const note = await createNote(title, type, categoryId, paperId) 18 | return NextResponse.json(note) 19 | } catch (error) { 20 | console.log("CREATE NOTE ERROR", error); 21 | return new NextResponse("Internal Error", { status: 500 }); 22 | } 23 | } -------------------------------------------------------------------------------- /app/api/paper/[paperId]/route.ts: -------------------------------------------------------------------------------- 1 | import { archivePaper, favoritePaper, getPaperById, movePaper, removePaper, renamePaper, updateAnnotionOfPaper } from "@/data/paper" 2 | import { NextResponse } from "next/server" 3 | import path from "path" 4 | import fs from 'fs'; 5 | import { removeNotesOfPaper } from "@/data/note"; 6 | import { removeMessagesOfPaper } from "@/data/message"; 7 | 8 | export const PATCH = async ( 9 | req: Request, 10 | { params }: { params: { paperId: string } } 11 | ) => { 12 | try { 13 | const body = await req.json() 14 | const { title, archived, favorited, annotations, parentId } = body 15 | if (title) { 16 | const renamedPaper = await renamePaper(params.paperId, title) 17 | if (renamedPaper) { 18 | return NextResponse.json(renamedPaper) 19 | } 20 | return new NextResponse("Failed to update paper", { status: 500 }) 21 | } else if (parentId) { 22 | const movedPaper = await movePaper(params.paperId, parentId) 23 | if (movedPaper) { 24 | return NextResponse.json(movedPaper) 25 | } 26 | return new NextResponse("Failed to move paper", { status: 500 }) 27 | } else if (archived === true) { 28 | const archivedPaper = await archivePaper(params.paperId, true) 29 | if (archivedPaper) { 30 | return NextResponse.json(archivedPaper) 31 | } 32 | return new NextResponse("Failed to update paper", { status: 500 }) 33 | } else if (archived === false) { 34 | const restoredPaper = await archivePaper(params.paperId, false) 35 | if (restoredPaper) { 36 | return NextResponse.json(restoredPaper) 37 | } 38 | return new NextResponse("Failed to update paper", { status: 500 }) 39 | } else if (favorited === true) { 40 | const favoritedPaper = await favoritePaper(params.paperId, true) 41 | if (favoritedPaper) { 42 | return NextResponse.json(favoritedPaper) 43 | } 44 | return new NextResponse("Failed to update paper", { status: 500 }) 45 | } else if (favorited === false) { 46 | const unfavoritedPaper = await favoritePaper(params.paperId, false) 47 | if (unfavoritedPaper) { 48 | return NextResponse.json(unfavoritedPaper) 49 | } 50 | return new NextResponse("Failed to update paper", { status: 500 }) 51 | } else if (annotations === "" || annotations) { 52 | const updateAnnotations = await updateAnnotionOfPaper(params.paperId, annotations) 53 | if (updateAnnotations) { 54 | return NextResponse.json(updateAnnotations) 55 | } 56 | return new NextResponse("Failed to update paper", { status: 500 }) 57 | } else { 58 | return new NextResponse("Unexpected field", { status: 404 }) 59 | } 60 | } catch (error) { 61 | console.log("UPDATE PAPER ERROR", error) 62 | return new NextResponse("Internal Error", { status: 500 }) 63 | } 64 | } 65 | 66 | export const DELETE = async ( 67 | request: Request, 68 | { params }: { params: { paperId: string } } 69 | ) => { 70 | try { 71 | const removedPaper = await removePaper(params.paperId) 72 | if (removedPaper) { 73 | // 删除对应的notes 74 | await removeNotesOfPaper(removedPaper.id) 75 | // 删除对应的messages 76 | await removeMessagesOfPaper(removedPaper.id) 77 | // 删除对应的向量数据库文件(TODO) 78 | // 删除对应的pdf文件 79 | const filePath = path.join(process.cwd(), "public", removedPaper.url) 80 | if (fs.existsSync(filePath)) { 81 | try { 82 | fs.unlinkSync(filePath) 83 | console.log("PDF FILE REMOVED", filePath) 84 | } catch (error) { 85 | console.log("REMOVE PAPER PDF FILE ERROR", error) 86 | } 87 | } 88 | return new NextResponse("Paper removed", { status: 200 }) 89 | } 90 | } catch (error) { 91 | console.log("DELETE PAPER ERROR", error) 92 | return new NextResponse("Internal Error", { status: 500 }) 93 | } 94 | } 95 | 96 | export const GET = async ( 97 | req: Request, 98 | { params }: { params: { paperId: string } } 99 | ) => { 100 | try { 101 | const paper = await getPaperById(params.paperId) 102 | if (paper) { 103 | return NextResponse.json(paper) 104 | } 105 | return new NextResponse("Paper not found", { status: 404 }) 106 | } catch (error) { 107 | console.log("GET PAPER ERROR", error) 108 | return new NextResponse("Internal Error", { status: 500 }) 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /app/api/paper/route.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest, NextResponse } from "next/server"; 2 | import { promises as fs } from 'fs'; 3 | import { createPaper } from "@/data/paper"; 4 | import { createNote } from "@/data/note"; 5 | import PDFParser from "pdf2json"; 6 | 7 | const parsePdfDate = (dateStr: string): Date | null => { 8 | // 正则表达式来提取日期部分 9 | const match = /D:(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})Z/.exec(dateStr); 10 | if (match) { 11 | const year = parseInt(match[1], 10); 12 | const month = parseInt(match[2], 10) - 1; // 月份从 0 开始计数 13 | const day = parseInt(match[3], 10); 14 | const hour = parseInt(match[4], 10); 15 | const minute = parseInt(match[5], 10); 16 | const second = parseInt(match[6], 10); 17 | 18 | // 创建一个 UTC 日期对象 19 | return new Date(Date.UTC(year, month, day, hour, minute, second)); 20 | } 21 | return null; // 如果不匹配,返回 null 22 | } 23 | 24 | export const POST = async (req: NextRequest) => { 25 | try { 26 | const formData = await req.formData() 27 | const paper = formData.get("paper") as any; 28 | const title = formData.get("title") as string; 29 | const categoryId = formData.get("categoryId") as string; 30 | 31 | if (!paper) { 32 | return new NextResponse("No paper uploaded", { status: 404 }); 33 | } 34 | 35 | if (!title) { 36 | return new NextResponse("Title is not specified", { status: 404 }); 37 | } 38 | 39 | // 处理文件的保存 40 | const url = `/papers/${Date.now().toString()}.pdf`; 41 | const data = await paper.arrayBuffer(); 42 | const buffer = Buffer.from(data); 43 | 44 | const pdfData = await new Promise((resolve, reject) => { 45 | const pdfParser = new PDFParser() 46 | pdfParser.on("pdfParser_dataError", (errData: any) => { 47 | reject(errData) 48 | }) 49 | pdfParser.on("pdfParser_dataReady", async (pdfData: any) => { 50 | resolve(pdfData) 51 | }) 52 | pdfParser.parseBuffer(buffer) 53 | }) 54 | const paperTitle = (pdfData as any).Meta.Title ? (pdfData as any).Meta.Title : title 55 | const authors = (pdfData as any).Meta.Author 56 | const publication = (pdfData as any).Meta.Subject 57 | const publicateDate = (pdfData as any).Meta.publicateDate 58 | let date = undefined 59 | if (publicateDate) { 60 | date = parsePdfDate(publicateDate)?.toDateString() 61 | } 62 | await fs.writeFile(`public${url}`, buffer); 63 | const paperInfo = await createPaper(paperTitle, url, categoryId, authors, publication, publicateDate) 64 | if (paperInfo) { 65 | await createNote(`《${paperInfo.title}》的笔记`, "Markdown", "notes", paperInfo.id) 66 | await createNote(`《${paperInfo.title}》的白板`, "Whiteboard", "whiteboards", paperInfo.id) 67 | return NextResponse.json(paperInfo); 68 | } 69 | return new NextResponse("Internal server error", { status: 500 }); 70 | } catch (error) { 71 | console.log("UPLOAD PAPER ERROR", error); 72 | return new NextResponse("Internal server error", { status: 500 }); 73 | } 74 | } -------------------------------------------------------------------------------- /app/api/translate/route.ts: -------------------------------------------------------------------------------- 1 | 2 | import { NextRequest, NextResponse } from "next/server"; 3 | import crypto from 'crypto'; 4 | import axios from "axios"; 5 | 6 | export const POST = async (req: NextRequest) => { 7 | const body = await req.json(); 8 | const { text } = body; 9 | const salt = new Date().getTime(); 10 | const appid = process.env.BAIDU_TRANSLATION_APPID 11 | const key = process.env.BAIDU_TRANSLATION_SECRET_KEY 12 | const domain = 'academic'; 13 | const from = 'en'; 14 | const to = 'zh'; 15 | const str1 = appid + text + salt + domain + key; 16 | const sign = crypto.createHash('md5').update(str1).digest('hex'); 17 | const response = await axios.get( 18 | "https://fanyi-api.baidu.com/api/trans/vip/fieldtranslate", 19 | { 20 | params: { 21 | q: text, 22 | appid: appid, 23 | salt: salt, 24 | domain: domain, 25 | from: from, 26 | to: to, 27 | sign: sign 28 | }, 29 | } 30 | ) 31 | if (response.status !== 200) { 32 | return new NextResponse("translate error", { status: 500 }); 33 | } 34 | if (response.data.trans_result.length > 0) { 35 | return new NextResponse(response.data.trans_result[0].dst, { status: 200 }) 36 | } else { 37 | return new NextResponse("translate error", { status: 500 }); 38 | } 39 | 40 | } -------------------------------------------------------------------------------- /app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tsmotlp/knowtate/fe55070afb0379468895d5ca2866bf5cb99a8bc6/app/favicon.ico -------------------------------------------------------------------------------- /app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | html, 6 | body, 7 | :root { 8 | height: 100%; 9 | } 10 | 11 | @layer base { 12 | :root { 13 | --background: 0 0% 100%; 14 | --foreground: 222.2 84% 4.9%; 15 | --card: 0 0% 100%; 16 | --card-foreground: 222.2 84% 4.9%; 17 | --popover: 0 0% 100%; 18 | --popover-foreground: 222.2 84% 4.9%; 19 | --primary: 221.2 83.2% 53.3%; 20 | --primary-foreground: 210 40% 98%; 21 | --secondary: 210 40% 96.1%; 22 | --secondary-foreground: 222.2 47.4% 11.2%; 23 | --muted: 210 40% 96.1%; 24 | --muted-foreground: 215.4 16.3% 46.9%; 25 | --accent: 210 40% 96.1%; 26 | --accent-foreground: 222.2 47.4% 11.2%; 27 | --destructive: 0 84.2% 60.2%; 28 | --destructive-foreground: 210 40% 98%; 29 | --border: 214.3 31.8% 91.4%; 30 | --input: 214.3 31.8% 91.4%; 31 | --ring: 221.2 83.2% 53.3%; 32 | --radius: 0.5rem; 33 | } 34 | 35 | .dark { 36 | --background: 222.2 84% 4.9%; 37 | --foreground: 210 40% 98%; 38 | --card: 222.2 84% 4.9%; 39 | --card-foreground: 210 40% 98%; 40 | --popover: 222.2 84% 4.9%; 41 | --popover-foreground: 210 40% 98%; 42 | --primary: 217.2 91.2% 59.8%; 43 | --primary-foreground: 222.2 47.4% 11.2%; 44 | --secondary: 217.2 32.6% 17.5%; 45 | --secondary-foreground: 210 40% 98%; 46 | --muted: 217.2 32.6% 17.5%; 47 | --muted-foreground: 215 20.2% 65.1%; 48 | --accent: 217.2 32.6% 17.5%; 49 | --accent-foreground: 210 40% 98%; 50 | --destructive: 0 62.8% 30.6%; 51 | --destructive-foreground: 210 40% 98%; 52 | --border: 217.2 32.6% 17.5%; 53 | --input: 217.2 32.6% 17.5%; 54 | --ring: 224.3 76.3% 48%; 55 | } 56 | } 57 | 58 | @layer base { 59 | * { 60 | @apply border-border; 61 | } 62 | 63 | body { 64 | @apply bg-background text-foreground; 65 | } 66 | } -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | import { Outfit } from "next/font/google"; 3 | import "./globals.css"; 4 | import { Toaster } from "sonner"; 5 | 6 | const outfit = Outfit({ subsets: ["latin"] }); 7 | 8 | export const metadata: Metadata = { 9 | title: "Knowtate", 10 | description: "Generated by create next app", 11 | }; 12 | 13 | export default function RootLayout({ 14 | children, 15 | }: Readonly<{ 16 | children: React.ReactNode; 17 | }>) { 18 | return ( 19 | 20 | 21 | 22 | {children} 23 | 24 | 25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /app/notes/[noteId]/page.tsx: -------------------------------------------------------------------------------- 1 | import { PaperNote } from "@/app/papers/[paperId]/_components/paper-note" 2 | import { getNoteWithPaper } from "@/data/note"; 3 | 4 | interface NoteIdPageProps { 5 | params: { 6 | noteId: string; 7 | }; 8 | } 9 | 10 | const NoteIdPage = async ({ params }: NoteIdPageProps) => { 11 | const note = await getNoteWithPaper(params.noteId) 12 | return ( 13 |
14 | {note ? ( 15 |
16 | 17 |
18 | ) : ( 19 |
20 | 出错了 21 |
22 | )} 23 |
24 | ) 25 | } 26 | 27 | export default NoteIdPage -------------------------------------------------------------------------------- /app/page.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | import { Heading } from "@/components/heading"; 3 | import { Heroes } from "@/components/heros"; 4 | import { Button } from "@/components/ui/button"; 5 | import { ArrowRight } from "lucide-react"; 6 | import Image from "next/image"; 7 | import Link from "next/link"; 8 | import { useRouter } from "next/navigation"; 9 | 10 | export default function Home() { 11 | const router = useRouter() 12 | return ( 13 |
14 |
15 | 16 | 17 |
18 |
19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /app/papers/[paperId]/_components/chat/chat-client.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { FormEvent, useState } from "react"; 3 | import { ChatForm } from "./chat-form"; 4 | import { ChatMessages } from "./chat-messages"; 5 | import { useRouter } from "next/navigation"; 6 | import { ChatMessageProps } from "./chat-message"; 7 | import Image from "next/image"; 8 | import { HISTORY_MESSAGE_N } from "@/constants"; 9 | import { Message } from "@prisma/client"; 10 | import axios from "axios"; 11 | import { toast } from "sonner"; 12 | 13 | interface ChatClientProps { 14 | paperId: string 15 | paperUrl: string, 16 | messages: Message[] 17 | } 18 | 19 | export const ChatClient = ({ 20 | paperId, 21 | paperUrl, 22 | messages 23 | }: ChatClientProps) => { 24 | const router = useRouter(); 25 | const [historyMessages, setHistoryMessages] = useState(messages) 26 | const [input, setInput] = useState(""); 27 | const [isLoading, setIsLoading] = useState(false); // 使用 isLoading 状态 28 | const handleStream = async () => { 29 | let fullMessage = ''; // 用来累积完整的消息 30 | try { 31 | setIsLoading(true) 32 | const data = { 33 | paperId: paperId, 34 | paperUrl: paperUrl, 35 | prompt: input, 36 | prevMessages: historyMessages.slice(-1 * HISTORY_MESSAGE_N), 37 | } 38 | const response = await fetch("/api/chat", { 39 | method: "POST", 40 | headers: { "Content-Type": "application/json" }, 41 | body: JSON.stringify(data) 42 | }); 43 | 44 | const reader = response.body!.getReader(); 45 | const decoder = new TextDecoder("utf-8"); 46 | 47 | const userMessage: ChatMessageProps = { 48 | role: "user", 49 | content: input 50 | } 51 | setHistoryMessages((current) => [...current, userMessage]) 52 | while (true) { 53 | const { done, value } = await reader.read(); 54 | if (done) break; 55 | const chunk = decoder.decode(value, { stream: true }); // 确保文本流不会被截断 56 | // 解析并处理数据 57 | const lines = chunk.split('\n'); 58 | for (const line of lines) { 59 | if (line.trim() === '') continue; 60 | // 根据实际数据格式选择解析方式 61 | const match = line.match(/^0:"(.*)"$/); 62 | 63 | if (match) { 64 | const content = match[1]; 65 | fullMessage += content; 66 | 67 | setHistoryMessages((current) => { 68 | const lastMessage = current[current.length - 1]; 69 | if (lastMessage && lastMessage.role === 'assistant') { 70 | return current.slice(0, -1).concat({ ...lastMessage, content: fullMessage }); 71 | } 72 | return current.concat({ role: 'assistant', content: fullMessage }); 73 | }); 74 | } else { 75 | console.warn('Unrecognized line format:', line); 76 | } 77 | } 78 | } 79 | await axios.post("/api/message", { 80 | paperId: paperId, 81 | role: "assistant", 82 | content: fullMessage, 83 | }) 84 | } catch (error: any) { 85 | toast.error("Something went wrong."); 86 | } finally { 87 | setIsLoading(false); 88 | router.refresh() 89 | } 90 | }; 91 | 92 | const handleSubmit = async (e: FormEvent) => { 93 | e.preventDefault(); 94 | await axios.post("/api/message", JSON.stringify({ 95 | paperId: paperId, 96 | role: "user", 97 | content: input, 98 | })) 99 | await handleStream(); // 等待处理新消息 100 | setInput(''); // 清空输入框 101 | }; 102 | 103 | const handleInputChange = (e: React.ChangeEvent | React.ChangeEvent) => { 104 | // 函数实现 105 | setInput(e.target.value); 106 | }; 107 | 108 | return ( 109 |
110 |
111 | 112 |
113 |
114 | 120 |
121 |
122 | ); 123 | }; 124 | -------------------------------------------------------------------------------- /app/papers/[paperId]/_components/chat/chat-form.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Button } from "@/components/ui/button"; 4 | import { Input } from "@/components/ui/input"; 5 | import { ChatRequestOptions } from "ai"; 6 | import { SendHorizonal } from "lucide-react"; 7 | import { ChangeEvent, FormEvent } from "react"; 8 | 9 | interface ChatFormProps { 10 | input: string; 11 | handleInputChange: ( 12 | e: ChangeEvent | ChangeEvent, 13 | ) => void; 14 | onSubmit: ( 15 | e: FormEvent, 16 | chatRequestOptions?: ChatRequestOptions | undefined, 17 | ) => void; 18 | isLoading: boolean; 19 | } 20 | 21 | export const ChatForm = ({ 22 | input, 23 | handleInputChange, 24 | onSubmit, 25 | isLoading, 26 | }: ChatFormProps) => { 27 | return ( 28 |
32 | 39 | 42 |
43 | ); 44 | }; 45 | -------------------------------------------------------------------------------- /app/papers/[paperId]/_components/chat/chat-message.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { BeatLoader } from "react-spinners"; 4 | import { Copy } from "lucide-react"; 5 | 6 | import { cn } from "@/lib/utils"; 7 | import { Button } from "@/components/ui/button"; 8 | import { toast } from "sonner"; 9 | import ReactMarkdown from "react-markdown"; 10 | import React from "react"; 11 | import { Avatar, AvatarImage } from "@/components/ui/avatar"; 12 | 13 | export interface ChatMessageProps { 14 | role: string; 15 | content?: string; 16 | isLoading?: boolean; 17 | } 18 | 19 | export const ChatMessage = ({ role, content, isLoading }: ChatMessageProps) => { 20 | const onCopy = () => { 21 | if (!content) { 22 | return; 23 | } 24 | 25 | navigator.clipboard.writeText(content); 26 | toast("", { 27 | description: "Message copied to clipboard.", 28 | duration: 3000, 29 | }); 30 | }; 31 | 32 | return ( 33 |
39 | {role !== "user" && ( 40 | 41 | 42 | 43 | )} 44 |
45 | {isLoading ? ( 46 | 47 | ) : ( 48 | ( 51 |
52 |
53 |                 
54 | ), 55 | code: ({ node, ...props }) => ( 56 | 57 | ), 58 | }} 59 | className="text-sm overflow-hidden leading-7" 60 | > 61 | {content || ""} 62 |
63 | )} 64 |
65 | {role === "user" && ( 66 | 67 | 68 | 69 | )} 70 | {role !== "user" && !isLoading && ( 71 | 79 | )} 80 |
81 | ); 82 | }; 83 | -------------------------------------------------------------------------------- /app/papers/[paperId]/_components/chat/chat-messages.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { ElementRef, useEffect, useRef } from "react"; 4 | 5 | import { ChatMessage, ChatMessageProps } from "./chat-message"; 6 | 7 | interface ChatMessagesProps { 8 | messages: ChatMessageProps[]; 9 | isLoading: boolean; 10 | } 11 | 12 | export const ChatMessages = ({ 13 | messages = [], 14 | isLoading, 15 | }: ChatMessagesProps) => { 16 | const scrollRef = useRef>(null); 17 | useEffect(() => { 18 | scrollRef?.current?.scrollIntoView({ behavior: "smooth" }); 19 | }, [messages.length]); 20 | 21 | return ( 22 |
23 | {messages.map((message, index) => ( 24 | 29 | ))} 30 | {isLoading && ( 31 | 32 | )} 33 |
34 |
35 | ); 36 | }; 37 | -------------------------------------------------------------------------------- /app/papers/[paperId]/_components/menu-bar.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { Button } from "@/components/ui/button" 4 | import { BotMessageSquareIcon, InfoIcon, Library, NotebookTabsIcon } from "lucide-react" 5 | import { useRouter } from "next/navigation" 6 | import { ToolButton } from "./tool-button" 7 | 8 | interface MenuBarProps { 9 | paperTitle: string 10 | pageType: string, 11 | handleClick: (type: string) => void 12 | } 13 | 14 | export const MenuBar = ({ 15 | paperTitle, 16 | pageType, 17 | handleClick 18 | }: MenuBarProps) => { 19 | const router = useRouter() 20 | return ( 21 |
22 | { router.push("/dashboard/library") }} 28 | color="" 29 | /> 30 |
31 | {paperTitle} 32 |
33 |
34 | 43 | 52 | 61 |
62 |
63 | ) 64 | } -------------------------------------------------------------------------------- /app/papers/[paperId]/_components/note-navbar.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { IconPicker } from "@/components/editors/icon-picker"; 4 | import { Button } from "@/components/ui/button" 5 | import { Note, Paper } from "@prisma/client"; 6 | import axios from "axios"; 7 | import { Download, FileSymlinkIcon, Library, Save, Smile } from "lucide-react" 8 | import Link from "next/link"; 9 | import { useRouter } from "next/navigation"; 10 | import { ElementRef, useRef, useState } from "react"; 11 | import TextareaAutosize from "react-textarea-autosize"; 12 | import { ToolButton } from "./tool-button"; 13 | import { BsFiletypePdf } from "react-icons/bs"; 14 | 15 | interface NoteNavbarProps { 16 | paper?: Paper, 17 | note: Note, 18 | handleSave: () => void 19 | handleDownload: (title: string) => void 20 | showDashboardIcon: boolean 21 | } 22 | 23 | export const NoteNavbar = ({ 24 | paper, 25 | note, 26 | handleSave, 27 | handleDownload, 28 | showDashboardIcon, 29 | }: NoteNavbarProps) => { 30 | const router = useRouter(); 31 | const inputRef = useRef>(null); 32 | const [isEditing, setIsEditing] = useState(false); 33 | const [title, setTitle] = useState(note.title); 34 | const [icon, setIcon] = useState(note.icon); 35 | 36 | const enableInput = () => { 37 | setIsEditing(true); 38 | setTimeout(() => { 39 | setTitle(note.title); 40 | inputRef.current?.focus(); 41 | }, 0); 42 | }; 43 | 44 | const disableInput = () => setIsEditing(false); 45 | 46 | const onInput = async (value: string) => { 47 | setTitle(value); 48 | try { 49 | await axios.patch(`/api/note/${note.id}`, JSON.stringify({ 50 | title: value || "Untitled", 51 | })); 52 | } catch (error) { 53 | console.error("Failed to update note title:", error); 54 | } 55 | }; 56 | 57 | const onKeyDown = (event: React.KeyboardEvent) => { 58 | if (event.key === "Enter") { 59 | event.preventDefault(); 60 | disableInput(); 61 | } 62 | }; 63 | 64 | const onIconSelect = async (icon: string) => { 65 | try { 66 | await axios.patch(`/api/note/${note.id}`, JSON.stringify({ 67 | icon: icon, 68 | })); 69 | setIcon(icon); 70 | } catch (error) { 71 | console.error("Failed to update note icon:", error); 72 | } 73 | }; 74 | 75 | return ( 76 |
77 |
78 | {showDashboardIcon && ( 79 | router.push("/dashboard/library")} 85 | color="" 86 | /> 87 | )} 88 | {!!icon && ( 89 | 90 |

91 | {icon} 92 |

93 |
94 | )} 95 | {!icon && ( 96 | 97 | 104 | 105 | )} 106 | {isEditing ? ( 107 | onInput(e.target.value)} 113 | className="truncate bg-transparent font-bold break-words outline-none text-[#3F3F3F] dark:text-[#CFCFCF] resize-none overflow-hidden" 114 | /> 115 | ) : ( 116 |
120 | {title} 121 |
122 | )} 123 |
124 |
125 | 133 | { handleDownload(title!); }} 139 | color="" 140 | /> 141 | {paper && paper.id && ( 142 | 143 |
144 | 145 | {paper.title} 146 |
147 | 148 | )} 149 |
150 |
151 | ); 152 | }; 153 | -------------------------------------------------------------------------------- /app/papers/[paperId]/_components/note-sidebar.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { Button } from "@/components/ui/button" 4 | 5 | interface NoteSidebarPorps { 6 | noteSidebarType: string 7 | handleClick: (key: string) => void 8 | } 9 | 10 | export const NoteSidebar = ({ 11 | noteSidebarType = "markdown", 12 | handleClick 13 | }: NoteSidebarPorps) => { 14 | return ( 15 |
16 | { handleClick("markdown") }} 20 | /> 21 | { handleClick("whiteboard") }} 25 | /> 26 | { handleClick("vocabs") }} 30 | /> 31 | { handleClick("terms") }} 35 | /> 36 | { handleClick("sentences") }} 40 | /> 41 |
42 | ) 43 | } 44 | 45 | interface NoteSidebarItemProps { 46 | title: string, 47 | active: boolean, 48 | handleClick: () => void 49 | } 50 | 51 | const NoteSidebarItem = ({ 52 | title, 53 | active, 54 | handleClick 55 | }: NoteSidebarItemProps) => { 56 | return ( 57 | 64 | ) 65 | } -------------------------------------------------------------------------------- /app/papers/[paperId]/_components/paper-chat.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { Message, Paper } from "@prisma/client" 4 | import { ChatClient } from "./chat/chat-client" 5 | 6 | interface PaperChatProps { 7 | paperId: string 8 | messages: Message[] 9 | } 10 | 11 | 12 | export const PaperChat = ({ 13 | paperId, 14 | messages 15 | }: PaperChatProps) => { 16 | return ( 17 |
18 | 19 |
20 | ) 21 | } -------------------------------------------------------------------------------- /app/papers/[paperId]/_components/paper-info.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | export const PaperInfo = () => { 4 | return ( 5 |
6 | Paper info 7 |
8 | ) 9 | } -------------------------------------------------------------------------------- /app/papers/[paperId]/_components/paper-note.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { useEffect, useMemo, useState } from "react" 4 | import { toast } from "sonner" 5 | import '@mdxeditor/editor/style.css'; 6 | import dynamic from "next/dynamic" 7 | import axios from "axios" 8 | import { NoteNavbar } from "./note-navbar" 9 | import { Note, Paper } from "@prisma/client"; 10 | import { DashboardItemType } from "@/types/types"; 11 | 12 | interface PaperNoteProps { 13 | paper?: Paper, 14 | note: Note, 15 | showDashboardIcon?: boolean 16 | } 17 | 18 | export const PaperNote = ({ 19 | paper, 20 | note, 21 | showDashboardIcon = false 22 | }: PaperNoteProps) => { 23 | const MarkEditor = useMemo(() => dynamic(() => import("@/components/editors/markdown-editor"), { ssr: false }), []); 24 | const ExcalidrawEditor = useMemo(() => dynamic(() => import("@/components/editors/excalidraw-editor"), { ssr: false }), []); 25 | 26 | // 状态来跟踪编辑器的内容和最后一次更新的内容 27 | const [editorContent, setEditorContent] = useState(note.content ? note.content : ""); 28 | const [lastSavedContent, setLastSavedContent] = useState(note.content ? note.content : ""); 29 | 30 | // 修改 onChange 方法以保存当前编辑内容而不是立即更新 note 31 | const onChange = (content: string) => { 32 | setEditorContent(content); 33 | }; 34 | 35 | // 用于自动保存的逻辑 36 | const autoSaveContent = async () => { 37 | if (editorContent !== lastSavedContent) { 38 | const response = await axios.patch(`/api/note/${note.id}`, JSON.stringify({ 39 | content: editorContent 40 | })) 41 | if (!response || response.status !== 200) { 42 | toast.error("自动保存失败!") 43 | } else { 44 | setLastSavedContent(editorContent); 45 | toast.success("已自动保存!"); 46 | } 47 | } 48 | }; 49 | 50 | const handleSave = async () => { 51 | if (editorContent !== lastSavedContent) { 52 | const response = await axios.patch(`/api/note/${note.id}`, JSON.stringify({ 53 | content: editorContent 54 | })) 55 | if (!response || response.status !== 200) { 56 | toast.error("保存失败!") 57 | } else { 58 | setLastSavedContent(editorContent); 59 | toast.success("已保存!"); 60 | } 61 | } 62 | } 63 | 64 | const handleDownload = (title: string) => { 65 | const blob = new Blob([editorContent], { type: 'text/note;charset=utf-8' }); 66 | const link = document.createElement('a'); 67 | link.href = URL.createObjectURL(blob); 68 | link.download = `${title}.md`; 69 | link.click(); 70 | } 71 | 72 | useEffect(() => { 73 | // 设置定时器每 10 秒自动保存 74 | const intervalId = setInterval(autoSaveContent, 10000); 75 | 76 | // 处理组件卸载和页面切换时的保存 77 | const handleBeforeUnload = async (e: BeforeUnloadEvent) => { 78 | await autoSaveContent(); 79 | }; 80 | window.addEventListener('beforeunload', handleBeforeUnload); 81 | 82 | // 清理定时器和事件监听器 83 | return () => { 84 | clearInterval(intervalId); 85 | window.removeEventListener('beforeunload', handleBeforeUnload); 86 | }; 87 | }, [editorContent, note, lastSavedContent]); 88 | 89 | return ( 90 |
91 |
92 | 99 |
100 | {note.type === DashboardItemType.Markdown && ( 101 |
102 | 107 |
108 | )} 109 | {note.type === DashboardItemType.Whiteboard && ( 110 |
111 | 117 |
118 | )} 119 |
120 | ) 121 | } -------------------------------------------------------------------------------- /app/papers/[paperId]/_components/paper-notes.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { useState } from "react" 4 | import { PaperNote } from "./paper-note" 5 | import { NoteSidebar } from "./note-sidebar" 6 | import { Note } from "@prisma/client" 7 | 8 | interface PaperNotesProps { 9 | notes: Note[] 10 | } 11 | 12 | export const PaperNotes = ({ 13 | notes 14 | }: PaperNotesProps) => { 15 | const [noteType, setNoteType] = useState("markdown") 16 | const handleClick = (key: string) => { 17 | setNoteType(key) 18 | } 19 | return ( 20 |
21 |
22 | {noteType === "markdown" && ( 23 | 24 | )} 25 | {noteType === "whiteboard" && ( 26 | 27 | )} 28 |
29 |
30 | 31 |
32 |
33 | ) 34 | } -------------------------------------------------------------------------------- /app/papers/[paperId]/_components/paper-reader.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { MenuBar } from "./menu-bar" 4 | import { PDFViewer } from "./pdf-viewer" 5 | import { useState } from "react" 6 | import { PaperNotes } from "./paper-notes" 7 | import { PaperInfo } from "./paper-info" 8 | import { Message, Note, Paper } from "@prisma/client" 9 | import { ChatClient } from "./chat/chat-client" 10 | import { PaperNote } from "./paper-note" 11 | 12 | interface PaperReaderProps { 13 | paper: Paper & { 14 | notes: Note[] 15 | messages: Message[] 16 | } 17 | } 18 | 19 | export const PaperReader = ({ 20 | paper 21 | }: PaperReaderProps) => { 22 | const [papeType, setPageType] = useState("notes") 23 | const handleClick = (type: string) => { 24 | setPageType(type) 25 | } 26 | 27 | return ( 28 |
29 | {!paper || !paper.url || paper.url === "" ? ( 30 | <> 31 |
32 | 33 |
34 |
35 | 出错了~ 36 |
37 | 38 | ) : ( 39 | <> 40 |
41 | 42 |
43 |
44 |
45 |
46 | 47 |
48 |
49 | {papeType === "info" && ( 50 | 51 | )} 52 | {papeType === "notes" && ( 53 | // 54 | 55 | )} 56 | {papeType === "chat" && ( 57 | 58 | )} 59 |
60 |
61 |
62 | 63 | )} 64 |
65 | ) 66 | } -------------------------------------------------------------------------------- /app/papers/[paperId]/_components/style-setter.tsx: -------------------------------------------------------------------------------- 1 | // components/ColorPicker.tsx 2 | import { Button } from '@/components/ui/button'; 3 | import { Slider } from '@/components/ui/slider'; 4 | import { Circle, CircleCheck } from 'lucide-react'; 5 | import React, { useState } from 'react'; 6 | 7 | interface StyleSetterProps { 8 | initColor: string, 9 | initOpacity: number, 10 | handleColorChange: (color: string) => void 11 | handleOpacityChange: (opacity: number) => void 12 | } 13 | 14 | // 预定义的颜色列表 15 | const colors_row1 = [ 16 | '#ff7002', '#ffc09e', '#ffd100', '#fcf485', '#0000ff', '#38e5ff' 17 | ]; 18 | 19 | const colors_row2 = [ 20 | '#a33086', '#fb88ff', '#e52237', '#ff809d', '#6ad928', '#c5fb72' 21 | ]; 22 | 23 | const colors_row3 = [ 24 | '#000000', '#444444', '#777777', '#aaaaaa', '#cccccc', '#ffffff' 25 | ]; 26 | 27 | const StyleSetter = ({ 28 | initColor, 29 | initOpacity, 30 | handleColorChange, 31 | handleOpacityChange 32 | }: StyleSetterProps) => { 33 | // 状态来追踪当前选中的颜色 34 | const [selectedColor, setSelectedColor] = useState(initColor); 35 | const [opacity, setOpacity] = useState(initOpacity) 36 | 37 | 38 | const handleOpacityValueChange = (value: number[]) => { 39 | setOpacity(value[0]); // 立即更新输入框的值 40 | handleOpacityChange(value[0]) 41 | }; 42 | 43 | return ( 44 |
45 |
46 |
47 | {colors_row1.map((color, index) => ( 48 |
49 | 64 |
65 | ))} 66 |
67 |
68 | {colors_row2.map((color, index) => ( 69 |
70 | 85 |
86 | ))} 87 |
88 |
89 | {colors_row3.map((color, index) => ( 90 |
91 | 106 |
107 | ))} 108 |
109 |
110 |
111 |

Opacity

112 |
113 | 120 | {opacity} 121 |
122 |
123 |
124 | ); 125 | }; 126 | 127 | export default StyleSetter; 128 | -------------------------------------------------------------------------------- /app/papers/[paperId]/_components/tool-button.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { Button } from "@/components/ui/button" 4 | import { 5 | Tooltip, 6 | TooltipContent, 7 | TooltipProvider, 8 | TooltipTrigger 9 | } from "@/components/ui/tooltip" 10 | import { cn } from "@/lib/utils" 11 | import { LucideIcon } from "lucide-react" 12 | 13 | interface ToolButtonProps { 14 | label: string 15 | icon: LucideIcon 16 | size?: "icon" | "default" | "sm" | "lg" | "compact" | null | undefined, 17 | onClick: () => void 18 | iconClassName: string 19 | color: string 20 | } 21 | 22 | export const ToolButton = ({ 23 | label, 24 | icon: Icon, 25 | size = "icon", 26 | onClick, 27 | iconClassName, 28 | color 29 | }: ToolButtonProps) => { 30 | return ( 31 | 32 | 33 | 34 | 41 | 42 | 43 |

{label}

44 |
45 |
46 |
47 | ) 48 | } -------------------------------------------------------------------------------- /app/papers/[paperId]/page.tsx: -------------------------------------------------------------------------------- 1 | import { getPaperWithNotesAndMessages } from "@/data/paper"; 2 | import { PaperReader } from "./_components/paper-reader"; 3 | 4 | interface PaperIdPageProps { 5 | params: { 6 | paperId: string; 7 | }; 8 | } 9 | 10 | const PaperIdPage = async ({ params }: PaperIdPageProps) => { 11 | const paper = await getPaperWithNotesAndMessages(params.paperId) 12 | if (!paper) { 13 | return ( 14 |
15 | 出错了~ 16 |
17 | ) 18 | } 19 | return ( 20 | 21 | ) 22 | } 23 | 24 | export default PaperIdPage -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "default", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.ts", 8 | "css": "app/globals.css", 9 | "baseColor": "neutral", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils" 16 | } 17 | } -------------------------------------------------------------------------------- /components/category-list.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { SidebarItem } from "@/components/sidebar-item" 4 | import { CategoryWithIcon } from "@/types/types" 5 | import { Category } from "@prisma/client" 6 | import axios from "axios" 7 | import { Folder } from "lucide-react" 8 | import { useParams, usePathname, useRouter } from "next/navigation" 9 | import { useEffect, useState } from "react" 10 | import { toast } from "sonner" 11 | import { defaultCategories } from "./default-categories" 12 | 13 | 14 | interface CategoryListProps { 15 | parentId: string 16 | categories: CategoryWithIcon[] 17 | level: number 18 | } 19 | 20 | export const CategoryList = ({ 21 | parentId, 22 | categories, 23 | level, 24 | }: CategoryListProps) => { 25 | const params = useParams() 26 | const router = useRouter() 27 | const [expanded, setExpanded] = useState>({}) 28 | const subCategories = categories.filter((category) => category.parentId === parentId) 29 | const onExpand = (categoryId: string) => { 30 | setExpanded(prevExpanded => ({ 31 | ...prevExpanded, 32 | [categoryId]: !prevExpanded[categoryId] 33 | })); 34 | }; 35 | 36 | 37 | if (!subCategories || subCategories.length === 0) { 38 | return ( 39 | <> 40 | 41 | {level === 0 && ( 42 | <> 43 | 44 | 45 | 46 | )} 47 | 48 | ); 49 | } 50 | return ( 51 | <> 52 | {subCategories && subCategories.length > 0 && subCategories 53 | .map((category) => ( 54 |
55 | { router.push(`/dashboard/${category.id}`) }} 62 | onExpand={() => onExpand(category.id)} 63 | expanded={expanded[category.id]} 64 | showExpandIcon={categories.filter((item) => item.parentId === category.id).length > 0} 65 | /> 66 | {expanded[category.id] && ( 67 | 72 | )} 73 |
74 | )) 75 | } 76 | 77 | ) 78 | 79 | } 80 | -------------------------------------------------------------------------------- /components/default-categories.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { CategoryType, CategoryWithIcon } from "@/types/types" 4 | import { BookA, BotIcon, FileType, FolderOpen, Globe, GraduationCap, HeartIcon, History, Library, NotebookPenIcon, PenTool, ReceiptText, Rss, Trash2 } from "lucide-react" 5 | 6 | 7 | export const defaultCategories: CategoryWithIcon[] = [ 8 | { 9 | id: "library", 10 | name: "我的文库", 11 | type: CategoryType.Papers, 12 | favorited: false, 13 | archived: false, 14 | parentId: "text", 15 | icon: Library, 16 | createdAt: new Date(Date.now()), 17 | updatedAt: new Date(Date.now()), 18 | }, 19 | // { 20 | // id: "webs", 21 | // name: "学术网站", 22 | // type: CategoryType.Others, 23 | // favorited: false, 24 | // archived: false, 25 | // parentId: "text", 26 | // icon: Globe, 27 | // createdAt: new Date(Date.now()), 28 | // updatedAt: new Date(Date.now()), 29 | // }, 30 | { 31 | id: "notes", 32 | name: "我的笔记", 33 | type: CategoryType.Markdowns, 34 | favorited: false, 35 | archived: false, 36 | parentId: "", 37 | icon: NotebookPenIcon, 38 | createdAt: new Date(Date.now()), 39 | updatedAt: new Date(Date.now()), 40 | }, 41 | { 42 | id: "favorites", 43 | name: "收藏夹", 44 | type: CategoryType.Others, 45 | favorited: false, 46 | archived: false, 47 | parentId: "text", 48 | icon: HeartIcon, 49 | createdAt: new Date(Date.now()), 50 | updatedAt: new Date(Date.now()), 51 | }, 52 | { 53 | id: "trash", 54 | name: "回收站", 55 | type: CategoryType.Others, 56 | favorited: false, 57 | archived: false, 58 | parentId: "text", 59 | icon: Trash2, 60 | createdAt: new Date(Date.now()), 61 | updatedAt: new Date(Date.now()), 62 | }, 63 | { 64 | id: "ai", 65 | name: "AI助手", 66 | type: CategoryType.Others, 67 | favorited: false, 68 | archived: false, 69 | parentId: "tool", 70 | icon: BotIcon, 71 | createdAt: new Date(Date.now()), 72 | updatedAt: new Date(Date.now()), 73 | }, 74 | { 75 | id: "recents", 76 | name: "最近阅读", 77 | type: CategoryType.Papers, 78 | favorited: false, 79 | archived: false, 80 | parentId: "library", 81 | icon: History, 82 | createdAt: new Date(Date.now()), 83 | updatedAt: new Date(Date.now()), 84 | }, 85 | 86 | { 87 | id: "whiteboards", 88 | name: "白板", 89 | type: CategoryType.Whiteboards, 90 | favorited: false, 91 | archived: false, 92 | parentId: "notes", 93 | icon: PenTool, 94 | createdAt: new Date(Date.now()), 95 | updatedAt: new Date(Date.now()), 96 | }, 97 | { 98 | id: "vocabularies", 99 | name: "单词本", 100 | type: CategoryType.Others, 101 | favorited: false, 102 | archived: false, 103 | parentId: "notes", 104 | icon: BookA, 105 | createdAt: new Date(Date.now()), 106 | updatedAt: new Date(Date.now()), 107 | }, 108 | { 109 | id: "terms", 110 | name: "专业术语", 111 | type: CategoryType.Others, 112 | favorited: false, 113 | archived: false, 114 | parentId: "notes", 115 | icon: FileType, 116 | createdAt: new Date(Date.now()), 117 | updatedAt: new Date(Date.now()), 118 | }, 119 | { 120 | id: "sentences", 121 | name: "精彩句式", 122 | type: CategoryType.Others, 123 | favorited: false, 124 | archived: false, 125 | parentId: "notes", 126 | icon: ReceiptText, 127 | createdAt: new Date(Date.now()), 128 | updatedAt: new Date(Date.now()), 129 | }, 130 | // { 131 | // id: "googlescholar", 132 | // name: "谷歌学术", 133 | // type: CategoryType.Others, 134 | // favorited: false, 135 | // archived: false, 136 | // parentId: "webs", 137 | // icon: GraduationCap, 138 | // createdAt: new Date(Date.now()), 139 | // updatedAt: new Date(Date.now()), 140 | // }, 141 | // { 142 | // id: "semanticscholar", 143 | // name: "SemanticScholar", 144 | // type: CategoryType.Others, 145 | // favorited: false, 146 | // archived: false, 147 | // parentId: "webs", 148 | // icon: FolderOpen, 149 | // createdAt: new Date(Date.now()), 150 | // updatedAt: new Date(Date.now()), 151 | // }, 152 | // { 153 | // id: "arxiv", 154 | // name: "Arxiv", 155 | // type: CategoryType.Others, 156 | // favorited: false, 157 | // archived: false, 158 | // parentId: "webs", 159 | // icon: ReceiptText, 160 | // createdAt: new Date(Date.now()), 161 | // updatedAt: new Date(Date.now()), 162 | // }, 163 | ] -------------------------------------------------------------------------------- /components/editors/excalidraw-editor.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | import { Excalidraw, MainMenu } from "@excalidraw/excalidraw"; 3 | import { Theme } from "@excalidraw/excalidraw/types/element/types"; 4 | 5 | 6 | interface ExcalidrawEditorProps { 7 | className: string 8 | initContent: string 9 | onChange: (s: string) => void 10 | } 11 | 12 | const ExcalidrawEditor = ({ 13 | className, 14 | initContent, 15 | onChange 16 | }: ExcalidrawEditorProps) => { 17 | 18 | return ( 19 |
20 | onChange(JSON.stringify(excalidrawElements))} 26 | UIOptions={{ 27 | canvasActions: { 28 | saveToActiveFile: false, 29 | loadScene: false, 30 | export: false, 31 | toggleTheme: false, 32 | changeViewBackgroundColor: false, 33 | }, 34 | tools: { 35 | image: false, 36 | }, 37 | }} 38 | > 39 | 40 | 41 | 42 | 43 | 44 | 45 |
46 | ) 47 | } 48 | 49 | export default ExcalidrawEditor -------------------------------------------------------------------------------- /components/editors/icon-picker.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import EmojiPicker, { Theme } from "emoji-picker-react"; 4 | 5 | import { 6 | Popover, 7 | PopoverContent, 8 | PopoverTrigger 9 | } from "@/components/ui/popover"; 10 | 11 | interface IconPickerProps { 12 | onChange: (icon: string) => void; 13 | children: React.ReactNode; 14 | asChild?: boolean; 15 | }; 16 | 17 | export const IconPicker = ({ 18 | onChange, 19 | children, 20 | asChild 21 | }: IconPickerProps) => { 22 | const currentTheme = ("light") as keyof typeof themeMap 23 | 24 | const themeMap = { 25 | "dark": Theme.DARK, 26 | "light": Theme.LIGHT 27 | }; 28 | 29 | const theme = themeMap[currentTheme]; 30 | 31 | return ( 32 | 33 | 34 | {children} 35 | 36 | 37 | onChange(data.emoji)} 41 | /> 42 | 43 | 44 | ); 45 | }; -------------------------------------------------------------------------------- /components/editors/markdown-editor.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | AdmonitionDirectiveDescriptor, 3 | MDXEditor, 4 | toolbarPlugin, 5 | UndoRedo, 6 | BoldItalicUnderlineToggles, 7 | CodeToggle, 8 | CreateLink, 9 | InsertCodeBlock, 10 | InsertImage, 11 | InsertTable, 12 | InsertThematicBreak, 13 | ListsToggle, 14 | codeBlockPlugin, 15 | codeMirrorPlugin, 16 | directivesPlugin, 17 | frontmatterPlugin, 18 | headingsPlugin, 19 | imagePlugin, 20 | linkDialogPlugin, 21 | linkPlugin, 22 | listsPlugin, 23 | markdownShortcutPlugin, 24 | quotePlugin, 25 | tablePlugin, 26 | thematicBreakPlugin, 27 | type CodeBlockEditorDescriptor, 28 | MDXEditorMethods, 29 | CodeMirrorEditor, 30 | InsertAdmonition, 31 | BlockTypeSelect, 32 | Separator, 33 | } from "@mdxeditor/editor" 34 | import "@mdxeditor/editor/style.css" 35 | import axios from "axios" 36 | import { useRef } from 'react' 37 | import { toast } from "sonner" 38 | 39 | type EditorProps = { 40 | key: string, 41 | initContent: string 42 | onChange: (s: string) => void 43 | } 44 | 45 | const FallbackCodeEditorDescriptor: CodeBlockEditorDescriptor = { 46 | match: (language, meta) => true, 47 | priority: 0, 48 | Editor: CodeMirrorEditor 49 | } 50 | 51 | const MarkdownEditor = ({ 52 | key, 53 | initContent, 54 | onChange, 55 | }: EditorProps) => { 56 | const editorRef = useRef(null) 57 | 58 | const handleUpload = async (image: File) => { 59 | const formData = new FormData() 60 | formData.append("image", image) 61 | const response = await axios.post("/api/image", formData) 62 | if (response.status !== 200) { 63 | toast.error("Failed to upload image!") 64 | return; 65 | } else { 66 | const url = response.data 67 | toast.success("Image uploaded!") 68 | return url 69 | } 70 | } 71 | 72 | return ( 73 | ( 111 |
112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 |
128 | ), 129 | }), 130 | 131 | ]} 132 | contentEditableClassName="outline-none max-w-none prose prose-neutral dark:prose-invert caret-black dark:caret-white dark:text-white" 133 | /> 134 | ) 135 | } 136 | 137 | export default MarkdownEditor -------------------------------------------------------------------------------- /components/heading.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import Link from "next/link"; 4 | import { Button } from "./ui/button"; 5 | import { ArrowRight } from "lucide-react"; 6 | 7 | export const Heading = () => { 8 | return ( 9 |
10 |

11 | Welcome to Knowtate 12 |

13 |

14 | Embrace the power of centralized document management, seamless reading, and intelligent QA with AI. 15 |

16 | 22 |
23 | ) 24 | } -------------------------------------------------------------------------------- /components/heros.tsx: -------------------------------------------------------------------------------- 1 | import Image from "next/image"; 2 | 3 | export const Heroes = () => { 4 | return ( 5 |
6 |
7 |
8 | Documents 14 | Documents 20 |
21 |
22 | Reading 28 | Reading 34 |
35 |
36 |
37 | ) 38 | } -------------------------------------------------------------------------------- /components/logo.tsx: -------------------------------------------------------------------------------- 1 | import Image from "next/image" 2 | import Link from "next/link" 3 | import { SiMicrosoftacademic } from "react-icons/si"; 4 | 5 | 6 | export const Logo = ({ 7 | imageOnly 8 | }: { 9 | imageOnly: boolean 10 | }) => { 11 | return ( 12 | 13 |
14 | 15 | {!imageOnly && ( 16 |

17 | Knowtate 18 |

19 | )} 20 |
21 | 22 | ) 23 | } -------------------------------------------------------------------------------- /components/navbar.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { Logo } from "./logo" 4 | 5 | export const Navbar = () => { 6 | 7 | return ( 8 |
9 | 10 |
11 | ) 12 | } -------------------------------------------------------------------------------- /components/search-bar.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { Button } from "@/components/ui/button" 4 | import { zodResolver } from "@hookform/resolvers/zod" 5 | import { Dispatch, FormEvent, SetStateAction, useState } from "react" 6 | import { useForm } from "react-hook-form" 7 | import { z } from "zod" 8 | import { Input } from "@/components/ui/input" 9 | import { Loader2, SearchIcon } from "lucide-react" 10 | 11 | const formSchema = z.object({ 12 | query: z.string().min(0).max(200), 13 | }) 14 | 15 | interface SearchBarProps { 16 | query: string, 17 | setQuery: Dispatch>; 18 | } 19 | 20 | export const SearchBar = ({ 21 | query, 22 | setQuery, 23 | }: SearchBarProps) => { 24 | const [input, setInput] = useState("") 25 | const form = useForm>({ 26 | resolver: zodResolver(formSchema), 27 | defaultValues: { 28 | query 29 | } 30 | }) 31 | 32 | const onSubmit = async (e: FormEvent) => { 33 | e.preventDefault(); 34 | setQuery(input) 35 | setInput("") 36 | } 37 | 38 | const handleInputChange = (e: React.ChangeEvent | React.ChangeEvent) => { 39 | setInput(e.target.value); 40 | }; 41 | 42 | return ( 43 |
47 | 54 | 66 |
67 | ) 68 | } -------------------------------------------------------------------------------- /components/sidebar-item.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils" 2 | import { ChevronDown, ChevronUp, LucideIcon } from "lucide-react" 3 | import { Skeleton } from "./ui/skeleton" 4 | 5 | interface SidebarItemProps { 6 | id: string 7 | label: string 8 | icon: LucideIcon 9 | level?: number 10 | active?: boolean 11 | onClick?: () => void 12 | expanded?: boolean 13 | onExpand?: () => void; 14 | showExpandIcon?: boolean 15 | } 16 | 17 | export const SidebarItem = ({ 18 | id, 19 | label, 20 | icon: Icon, 21 | level = 0, 22 | active, 23 | onClick, 24 | onExpand, 25 | expanded, 26 | showExpandIcon = false, 27 | }: SidebarItemProps) => { 28 | const handleExpand = ( 29 | event: React.MouseEvent 30 | ) => { 31 | event.stopPropagation(); 32 | onExpand?.(); 33 | }; 34 | 35 | const ChevronIcon = expanded ? ChevronUp : ChevronDown; 36 | 37 | return ( 38 | <> 39 | {!!id && ( 40 |
52 | 59 | 60 | {label} 61 | 62 | {showExpandIcon && ( 63 |
68 | 71 |
72 | )} 73 |
74 | )} 75 | 76 | ) 77 | } 78 | 79 | SidebarItem.Skeleton = function ItemSkeleton({ level }: { level?: number }) { 80 | return ( 81 |
87 | 88 | 89 |
90 | ) 91 | } -------------------------------------------------------------------------------- /components/sidebar.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { CategoryList } from "./category-list" 4 | import { useEffect, useState } from "react" 5 | import axios from "axios" 6 | import { Category } from "@prisma/client" 7 | import { Folder } from "lucide-react" 8 | import { defaultCategories } from "./default-categories" 9 | import { CategoryWithIcon } from "@/types/types" 10 | import { toast } from "sonner" 11 | 12 | 13 | export const Sidebar = () => { 14 | const [categories, setCategories] = useState(defaultCategories) 15 | 16 | useEffect(() => { 17 | const getAllCategories = async () => { 18 | try { 19 | const response = await axios.get("/api/category"); 20 | if (response.status === 200) { 21 | const categories: Category[] = response.data; 22 | const updatedCategories = categories.map(category => ({ 23 | id: category.id, 24 | name: category.name, 25 | type: category.type, 26 | favorited: category.favorited, 27 | archived: category.archived, 28 | parentId: category.parentId, 29 | icon: Folder, 30 | createdAt: category.createdAt, 31 | updatedAt: category.updatedAt 32 | })); 33 | // 将默认类别和从API获取的类别合并 34 | setCategories([...defaultCategories, ...updatedCategories]); 35 | } else { 36 | toast.error('Failed to get categories'); 37 | } 38 | } catch (error) { 39 | console.error('GET CATEGORIES ERROR', error); 40 | toast.error('Failed to get categories'); 41 | } 42 | }; 43 | // 调用getAllCategories 44 | getAllCategories(); 45 | }, []) 46 | 47 | return ( 48 |
49 | 54 | {/*
55 | */} 60 |
61 | ) 62 | } -------------------------------------------------------------------------------- /components/ui/alert-dialog.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog" 5 | 6 | import { cn } from "@/lib/utils" 7 | import { buttonVariants } from "@/components/ui/button" 8 | 9 | const AlertDialog = AlertDialogPrimitive.Root 10 | 11 | const AlertDialogTrigger = AlertDialogPrimitive.Trigger 12 | 13 | const AlertDialogPortal = AlertDialogPrimitive.Portal 14 | 15 | const AlertDialogOverlay = React.forwardRef< 16 | React.ElementRef, 17 | React.ComponentPropsWithoutRef 18 | >(({ className, ...props }, ref) => ( 19 | 27 | )) 28 | AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName 29 | 30 | const AlertDialogContent = React.forwardRef< 31 | React.ElementRef, 32 | React.ComponentPropsWithoutRef 33 | >(({ className, ...props }, ref) => ( 34 | 35 | 36 | 44 | 45 | )) 46 | AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName 47 | 48 | const AlertDialogHeader = ({ 49 | className, 50 | ...props 51 | }: React.HTMLAttributes) => ( 52 |
59 | ) 60 | AlertDialogHeader.displayName = "AlertDialogHeader" 61 | 62 | const AlertDialogFooter = ({ 63 | className, 64 | ...props 65 | }: React.HTMLAttributes) => ( 66 |
73 | ) 74 | AlertDialogFooter.displayName = "AlertDialogFooter" 75 | 76 | const AlertDialogTitle = React.forwardRef< 77 | React.ElementRef, 78 | React.ComponentPropsWithoutRef 79 | >(({ className, ...props }, ref) => ( 80 | 85 | )) 86 | AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName 87 | 88 | const AlertDialogDescription = React.forwardRef< 89 | React.ElementRef, 90 | React.ComponentPropsWithoutRef 91 | >(({ className, ...props }, ref) => ( 92 | 97 | )) 98 | AlertDialogDescription.displayName = 99 | AlertDialogPrimitive.Description.displayName 100 | 101 | const AlertDialogAction = React.forwardRef< 102 | React.ElementRef, 103 | React.ComponentPropsWithoutRef 104 | >(({ className, ...props }, ref) => ( 105 | 110 | )) 111 | AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName 112 | 113 | const AlertDialogCancel = React.forwardRef< 114 | React.ElementRef, 115 | React.ComponentPropsWithoutRef 116 | >(({ className, ...props }, ref) => ( 117 | 126 | )) 127 | AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName 128 | 129 | export { 130 | AlertDialog, 131 | AlertDialogPortal, 132 | AlertDialogOverlay, 133 | AlertDialogTrigger, 134 | AlertDialogContent, 135 | AlertDialogHeader, 136 | AlertDialogFooter, 137 | AlertDialogTitle, 138 | AlertDialogDescription, 139 | AlertDialogAction, 140 | AlertDialogCancel, 141 | } 142 | -------------------------------------------------------------------------------- /components/ui/avatar.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as AvatarPrimitive from "@radix-ui/react-avatar" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const Avatar = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef 11 | >(({ className, ...props }, ref) => ( 12 | 20 | )) 21 | Avatar.displayName = AvatarPrimitive.Root.displayName 22 | 23 | const AvatarImage = React.forwardRef< 24 | React.ElementRef, 25 | React.ComponentPropsWithoutRef 26 | >(({ className, ...props }, ref) => ( 27 | 32 | )) 33 | AvatarImage.displayName = AvatarPrimitive.Image.displayName 34 | 35 | const AvatarFallback = React.forwardRef< 36 | React.ElementRef, 37 | React.ComponentPropsWithoutRef 38 | >(({ className, ...props }, ref) => ( 39 | 47 | )) 48 | AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName 49 | 50 | export { Avatar, AvatarImage, AvatarFallback } 51 | -------------------------------------------------------------------------------- /components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Slot } from "@radix-ui/react-slot" 3 | import { cva, type VariantProps } from "class-variance-authority" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const buttonVariants = cva( 8 | "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", 9 | { 10 | variants: { 11 | variant: { 12 | default: "bg-primary text-primary-foreground hover:bg-primary/90", 13 | destructive: 14 | "bg-destructive text-destructive-foreground hover:bg-destructive/90", 15 | outline: 16 | "border border-input bg-background hover:bg-accent hover:text-accent-foreground", 17 | secondary: 18 | "bg-secondary text-secondary-foreground hover:bg-secondary/80", 19 | ghost: "hover:bg-accent hover:text-accent-foreground", 20 | link: "text-primary underline-offset-4 hover:underline", 21 | }, 22 | size: { 23 | default: "h-10 px-4 py-2", 24 | sm: "h-9 rounded-md px-3", 25 | lg: "h-11 rounded-md px-8", 26 | icon: "h-10 w-10", 27 | compact: "h-6 w-6" 28 | }, 29 | }, 30 | defaultVariants: { 31 | variant: "default", 32 | size: "default", 33 | }, 34 | } 35 | ) 36 | 37 | export interface ButtonProps 38 | extends React.ButtonHTMLAttributes, 39 | VariantProps { 40 | asChild?: boolean 41 | } 42 | 43 | const Button = React.forwardRef( 44 | ({ className, variant, size, asChild = false, ...props }, ref) => { 45 | const Comp = asChild ? Slot : "button" 46 | return ( 47 | 52 | ) 53 | } 54 | ) 55 | Button.displayName = "Button" 56 | 57 | export { Button, buttonVariants } 58 | -------------------------------------------------------------------------------- /components/ui/dialog.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as DialogPrimitive from "@radix-ui/react-dialog" 5 | import { X } from "lucide-react" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | const Dialog = DialogPrimitive.Root 10 | 11 | const DialogTrigger = DialogPrimitive.Trigger 12 | 13 | const DialogPortal = DialogPrimitive.Portal 14 | 15 | const DialogClose = DialogPrimitive.Close 16 | 17 | const DialogOverlay = React.forwardRef< 18 | React.ElementRef, 19 | React.ComponentPropsWithoutRef 20 | >(({ className, ...props }, ref) => ( 21 | 29 | )) 30 | DialogOverlay.displayName = DialogPrimitive.Overlay.displayName 31 | 32 | const DialogContent = React.forwardRef< 33 | React.ElementRef, 34 | React.ComponentPropsWithoutRef 35 | >(({ className, children, ...props }, ref) => ( 36 | 37 | 38 | 46 | {children} 47 | 48 | 49 | Close 50 | 51 | 52 | 53 | )) 54 | DialogContent.displayName = DialogPrimitive.Content.displayName 55 | 56 | const DialogHeader = ({ 57 | className, 58 | ...props 59 | }: React.HTMLAttributes) => ( 60 |
67 | ) 68 | DialogHeader.displayName = "DialogHeader" 69 | 70 | const DialogFooter = ({ 71 | className, 72 | ...props 73 | }: React.HTMLAttributes) => ( 74 |
81 | ) 82 | DialogFooter.displayName = "DialogFooter" 83 | 84 | const DialogTitle = React.forwardRef< 85 | React.ElementRef, 86 | React.ComponentPropsWithoutRef 87 | >(({ className, ...props }, ref) => ( 88 | 96 | )) 97 | DialogTitle.displayName = DialogPrimitive.Title.displayName 98 | 99 | const DialogDescription = React.forwardRef< 100 | React.ElementRef, 101 | React.ComponentPropsWithoutRef 102 | >(({ className, ...props }, ref) => ( 103 | 108 | )) 109 | DialogDescription.displayName = DialogPrimitive.Description.displayName 110 | 111 | export { 112 | Dialog, 113 | DialogPortal, 114 | DialogOverlay, 115 | DialogClose, 116 | DialogTrigger, 117 | DialogContent, 118 | DialogHeader, 119 | DialogFooter, 120 | DialogTitle, 121 | DialogDescription, 122 | } 123 | -------------------------------------------------------------------------------- /components/ui/dropdown-menu.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu" 5 | import { Check, ChevronRight, Circle } from "lucide-react" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | const DropdownMenu = DropdownMenuPrimitive.Root 10 | 11 | const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger 12 | 13 | const DropdownMenuGroup = DropdownMenuPrimitive.Group 14 | 15 | const DropdownMenuPortal = DropdownMenuPrimitive.Portal 16 | 17 | const DropdownMenuSub = DropdownMenuPrimitive.Sub 18 | 19 | const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup 20 | 21 | const DropdownMenuSubTrigger = React.forwardRef< 22 | React.ElementRef, 23 | React.ComponentPropsWithoutRef & { 24 | inset?: boolean 25 | } 26 | >(({ className, inset, children, ...props }, ref) => ( 27 | 36 | {children} 37 | 38 | 39 | )) 40 | DropdownMenuSubTrigger.displayName = 41 | DropdownMenuPrimitive.SubTrigger.displayName 42 | 43 | const DropdownMenuSubContent = React.forwardRef< 44 | React.ElementRef, 45 | React.ComponentPropsWithoutRef 46 | >(({ className, ...props }, ref) => ( 47 | 55 | )) 56 | DropdownMenuSubContent.displayName = 57 | DropdownMenuPrimitive.SubContent.displayName 58 | 59 | const DropdownMenuContent = React.forwardRef< 60 | React.ElementRef, 61 | React.ComponentPropsWithoutRef 62 | >(({ className, sideOffset = 4, ...props }, ref) => ( 63 | 64 | 73 | 74 | )) 75 | DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName 76 | 77 | const DropdownMenuItem = React.forwardRef< 78 | React.ElementRef, 79 | React.ComponentPropsWithoutRef & { 80 | inset?: boolean 81 | } 82 | >(({ className, inset, ...props }, ref) => ( 83 | 92 | )) 93 | DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName 94 | 95 | const DropdownMenuCheckboxItem = React.forwardRef< 96 | React.ElementRef, 97 | React.ComponentPropsWithoutRef 98 | >(({ className, children, checked, ...props }, ref) => ( 99 | 108 | 109 | 110 | 111 | 112 | 113 | {children} 114 | 115 | )) 116 | DropdownMenuCheckboxItem.displayName = 117 | DropdownMenuPrimitive.CheckboxItem.displayName 118 | 119 | const DropdownMenuRadioItem = React.forwardRef< 120 | React.ElementRef, 121 | React.ComponentPropsWithoutRef 122 | >(({ className, children, ...props }, ref) => ( 123 | 131 | 132 | 133 | 134 | 135 | 136 | {children} 137 | 138 | )) 139 | DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName 140 | 141 | const DropdownMenuLabel = React.forwardRef< 142 | React.ElementRef, 143 | React.ComponentPropsWithoutRef & { 144 | inset?: boolean 145 | } 146 | >(({ className, inset, ...props }, ref) => ( 147 | 156 | )) 157 | DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName 158 | 159 | const DropdownMenuSeparator = React.forwardRef< 160 | React.ElementRef, 161 | React.ComponentPropsWithoutRef 162 | >(({ className, ...props }, ref) => ( 163 | 168 | )) 169 | DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName 170 | 171 | const DropdownMenuShortcut = ({ 172 | className, 173 | ...props 174 | }: React.HTMLAttributes) => { 175 | return ( 176 | 180 | ) 181 | } 182 | DropdownMenuShortcut.displayName = "DropdownMenuShortcut" 183 | 184 | export { 185 | DropdownMenu, 186 | DropdownMenuTrigger, 187 | DropdownMenuContent, 188 | DropdownMenuItem, 189 | DropdownMenuCheckboxItem, 190 | DropdownMenuRadioItem, 191 | DropdownMenuLabel, 192 | DropdownMenuSeparator, 193 | DropdownMenuShortcut, 194 | DropdownMenuGroup, 195 | DropdownMenuPortal, 196 | DropdownMenuSub, 197 | DropdownMenuSubContent, 198 | DropdownMenuSubTrigger, 199 | DropdownMenuRadioGroup, 200 | } 201 | -------------------------------------------------------------------------------- /components/ui/form.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as LabelPrimitive from "@radix-ui/react-label" 3 | import { Slot } from "@radix-ui/react-slot" 4 | import { 5 | Controller, 6 | ControllerProps, 7 | FieldPath, 8 | FieldValues, 9 | FormProvider, 10 | useFormContext, 11 | } from "react-hook-form" 12 | 13 | import { cn } from "@/lib/utils" 14 | import { Label } from "@/components/ui/label" 15 | 16 | const Form = FormProvider 17 | 18 | type FormFieldContextValue< 19 | TFieldValues extends FieldValues = FieldValues, 20 | TName extends FieldPath = FieldPath 21 | > = { 22 | name: TName 23 | } 24 | 25 | const FormFieldContext = React.createContext( 26 | {} as FormFieldContextValue 27 | ) 28 | 29 | const FormField = < 30 | TFieldValues extends FieldValues = FieldValues, 31 | TName extends FieldPath = FieldPath 32 | >({ 33 | ...props 34 | }: ControllerProps) => { 35 | return ( 36 | 37 | 38 | 39 | ) 40 | } 41 | 42 | const useFormField = () => { 43 | const fieldContext = React.useContext(FormFieldContext) 44 | const itemContext = React.useContext(FormItemContext) 45 | const { getFieldState, formState } = useFormContext() 46 | 47 | const fieldState = getFieldState(fieldContext.name, formState) 48 | 49 | if (!fieldContext) { 50 | throw new Error("useFormField should be used within ") 51 | } 52 | 53 | const { id } = itemContext 54 | 55 | return { 56 | id, 57 | name: fieldContext.name, 58 | formItemId: `${id}-form-item`, 59 | formDescriptionId: `${id}-form-item-description`, 60 | formMessageId: `${id}-form-item-message`, 61 | ...fieldState, 62 | } 63 | } 64 | 65 | type FormItemContextValue = { 66 | id: string 67 | } 68 | 69 | const FormItemContext = React.createContext( 70 | {} as FormItemContextValue 71 | ) 72 | 73 | const FormItem = React.forwardRef< 74 | HTMLDivElement, 75 | React.HTMLAttributes 76 | >(({ className, ...props }, ref) => { 77 | const id = React.useId() 78 | 79 | return ( 80 | 81 |
82 | 83 | ) 84 | }) 85 | FormItem.displayName = "FormItem" 86 | 87 | const FormLabel = React.forwardRef< 88 | React.ElementRef, 89 | React.ComponentPropsWithoutRef 90 | >(({ className, ...props }, ref) => { 91 | const { error, formItemId } = useFormField() 92 | 93 | return ( 94 |