├── .eslintrc.json ├── .github └── ISSUE_TEMPLATE │ └── sweep-template.yml ├── .gitignore ├── .vscode ├── extensions.json └── settings.json ├── README.md ├── api ├── document.ts ├── image.ts ├── invitation.ts └── user.ts ├── app ├── (main) │ ├── (routes) │ │ └── documents │ │ │ ├── [documentId] │ │ │ └── page.tsx │ │ │ └── page.tsx │ ├── components │ │ ├── banner.tsx │ │ ├── document-list.tsx │ │ ├── inbox-content.tsx │ │ ├── invite-item.tsx │ │ ├── invite-user.tsx │ │ ├── invite.tsx │ │ ├── item.tsx │ │ ├── menu.tsx │ │ ├── navbar.tsx │ │ ├── navigation.tsx │ │ ├── publish.tsx │ │ ├── title.tsx │ │ ├── trash-box.tsx │ │ ├── user-board.tsx │ │ └── user-item.tsx │ └── layout.tsx ├── (marking) │ ├── components │ │ ├── footer.tsx │ │ ├── heading.tsx │ │ ├── heroes.tsx │ │ ├── logo.tsx │ │ └── navbar.tsx │ ├── layout.tsx │ └── page.tsx ├── (public) │ ├── (routes) │ │ └── preview │ │ │ └── [documentId] │ │ │ └── page.tsx │ └── layout.tsx ├── error.tsx ├── globals.css └── layout.tsx ├── components.json ├── components ├── buttons │ └── auth-social-button.tsx ├── cover.tsx ├── editor │ ├── editor-blocks │ │ ├── fenced-code.tsx │ │ ├── index.tsx │ │ └── quote.tsx │ ├── editor-toolbars │ │ └── index.tsx │ ├── editor-wrapper.tsx │ └── editor.tsx ├── icon-picker.tsx ├── inputs │ └── input.tsx ├── modals │ ├── auth-modal.tsx │ ├── confirm-modal.tsx │ ├── cover-image-modal.tsx │ └── settings-modal.tsx ├── mode-toggle.tsx ├── providers │ ├── modal-provider.tsx │ └── theme-provider.tsx ├── search-command.tsx ├── single-image-dropzone.tsx ├── spinner.tsx ├── toolbar.tsx └── ui │ ├── alert-dialog.tsx │ ├── avatar.tsx │ ├── button.tsx │ ├── command.tsx │ ├── dialog.tsx │ ├── dropdown-menu.tsx │ ├── input.tsx │ ├── label.tsx │ ├── popover.tsx │ ├── scroll-area.tsx │ ├── select.tsx │ ├── separator.tsx │ └── skeleton.tsx ├── docs └── How-to-develop.md ├── hooks ├── use-origin.tsx ├── use-scroll-top.tsx └── use-session.tsx ├── laf-cloud ├── .gitignore ├── functions │ ├── __init__.ts │ ├── __init__.yaml │ ├── __interceptor__.ts │ ├── __interceptor__.yaml │ ├── __websocket__.ts │ ├── __websocket__.yaml │ ├── callback.yaml │ ├── document │ │ ├── archive.ts │ │ ├── archive.yaml │ │ ├── create.ts │ │ ├── create.yaml │ │ ├── get-basic-by-id.ts │ │ ├── get-basic-by-id.yaml │ │ ├── get-basic-info-by-id.ts │ │ ├── get-basic-info-by-id.yaml │ │ ├── get-by-id.ts │ │ ├── get-by-id.yaml │ │ ├── get-search.ts │ │ ├── get-search.yaml │ │ ├── get-trash.ts │ │ ├── get-trash.yaml │ │ ├── remove-access.ts │ │ ├── remove-access.yaml │ │ ├── remove-cover-image.ts │ │ ├── remove-cover-image.yaml │ │ ├── remove-icon.ts │ │ ├── remove-icon.yaml │ │ ├── remove.ts │ │ ├── remove.yaml │ │ ├── restore.ts │ │ ├── restore.yaml │ │ ├── sidebar.ts │ │ ├── sidebar.yaml │ │ ├── update.ts │ │ └── update.yaml │ ├── github-auth.ts │ ├── github-auth.yaml │ ├── image │ │ ├── delete.ts │ │ ├── delete.yaml │ │ ├── upload.ts │ │ └── upload.yaml │ ├── invitation │ │ ├── create.ts │ │ ├── create.yaml │ │ ├── get-by-email.ts │ │ ├── get-by-email.yaml │ │ ├── update.ts │ │ └── update.yaml │ ├── lib.ts │ ├── lib.yaml │ ├── user │ │ ├── get-info-by-email.ts │ │ ├── get-info-by-email.yaml │ │ ├── get-info.ts │ │ └── get-info.yaml │ └── utils.yaml ├── laf.yaml ├── package-lock.json ├── package.json ├── tsconfig.json └── types │ └── global.d.ts ├── lib ├── axios.ts └── utils.ts ├── next.config.js ├── package-lock.json ├── package.json ├── postcss.config.js ├── prettier.config.js ├── public ├── documents-dark.png ├── documents.png ├── empty-dark.png ├── empty.png ├── error-dark.png ├── error.png ├── logo-dark.svg ├── logo.svg ├── reading-dark.png └── reading.png ├── stores ├── use-auth.tsx ├── use-cover-image.tsx ├── use-document.tsx ├── use-search.tsx └── use-settings.tsx ├── sweep.yaml ├── tailwind.config.ts └── tsconfig.json /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals", 3 | "rules": { 4 | "import/no-anonymous-default-export": "off" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/sweep-template.yml: -------------------------------------------------------------------------------- 1 | name: Sweep Issue 2 | title: 'Sweep: ' 3 | description: For small bugs, features, refactors, and tests to be handled by Sweep, an AI-powered junior developer. 4 | labels: sweep 5 | body: 6 | - type: textarea 7 | id: description 8 | attributes: 9 | label: Details 10 | description: Tell Sweep where and what to edit and provide enough context for a new developer to the codebase 11 | placeholder: | 12 | Unit Tests: Write unit tests for . Test each function in the file. Make sure to test edge cases. 13 | Bugs: The bug might be in . Here are the logs: ... 14 | Features: the new endpoint should use the ... class from because it contains ... logic. 15 | Refactors: We are migrating this function to ... version because ... 16 | -------------------------------------------------------------------------------- /.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 | .trunk 9 | 10 | # testing 11 | /coverage 12 | 13 | # next.js 14 | /.next/ 15 | /out/ 16 | 17 | # production 18 | /build 19 | 20 | # misc 21 | .DS_Store 22 | *.pem 23 | 24 | # debug 25 | npm-debug.log* 26 | yarn-debug.log* 27 | yarn-error.log* 28 | 29 | # local env files 30 | .env*.local 31 | 32 | # vercel 33 | .vercel 34 | 35 | # typescript 36 | *.tsbuildinfo 37 | next-env.d.ts 38 | 39 | # Editor 40 | .idea -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "streetsidesoftware.code-spell-checker", 4 | "usernamehw.errorlens", 5 | "dsznajder.es7-react-js-snippets", 6 | "burkeholland.simple-react-snippets", 7 | "bradlc.vscode-tailwindcss", 8 | "pmneo.tsimporter" 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "cSpell.words": [ 3 | "apiurl", 4 | "Autosize", 5 | "blocknote", 6 | "calumk", 7 | "clike", 8 | "clsx", 9 | "cmdk", 10 | "codecup", 11 | "dompurify", 12 | "editorjs", 13 | "fastnote", 14 | "hljs", 15 | "jotlin", 16 | "lafjs", 17 | "Linkpreview", 18 | "lucide", 19 | "mantine", 20 | "markhtml", 21 | "nestedchecklist", 22 | "prismjs", 23 | "shadcn", 24 | "sonner", 25 | "sotaproject", 26 | "tailwindcss", 27 | "trunkio", 28 | "Unnest", 29 | "unpublish", 30 | "Unpublishing", 31 | "usehooks", 32 | "Webrtc", 33 | "ydoc", 34 | "zustand" 35 | ], 36 | "typescript.tsdk": "node_modules\\typescript\\lib" 37 | } 38 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Jotlin 2 | 3 | > 💡Note: The project is still in its early stages,your interest and contributions are welcome. 4 | 5 | ### 👀Key Features: 6 | 7 | - Jotlin is an open-source version of Notion, but it incorporates the capabilities of LLM throughout the writing process. 8 | - Notion-like editor experience. 9 | - Before writing, AI automatically assists in retrieving information. 10 | - During writing, it generates content. 11 | - After writing is completed, it automatically generates intelligent Q&A and summaries for the article's knowledge base. 12 | 13 | - Enterprises can deploy it in a private cloud environment to ensure data security and support team collaboration and permission control capabilities. 14 | 15 | ![image-20240412204148692](https://raw.githubusercontent.com/mlhiter/typora-images/master/202404122041888.png) 16 | 17 | ![image-20240412204109849](https://raw.githubusercontent.com/mlhiter/typora-images/master/202404122041156.png) 18 | 19 | ### 👜Technology stack: 20 | 21 | NextJS + Shadcn-UI + BlockNote + Laf 22 | 23 | ### 🤔Road Map: 24 | 25 | 1. Basic editor capabilities 26 | - [ ] Support common block and inline styles 27 | - [ ] Support Kanban and mention 28 | - [ ] Optimize editor performance 29 | - [ ] Improve markdown syntax import and export 30 | - [ ] meta information support 31 | 2. Advanced editor capabilities 32 | - [ ] notion like database 33 | - [ ] template 34 | 3. LLM integration 35 | 1. [FastGPT](https://github.com/labring/FastGPT) integration 36 | 1. [ ] Knowledge base support 37 | 2. [ ] Text reader support 38 | 3. [ ] database integration to ai work flow 39 | 2. Writing AI assistant 40 | - [ ] basic AI-assistant chat use Apikey 41 | - [ ] retrieving information with LLM 42 | - [ ] basic generates content feature 43 | - [ ] intelligent Q&A with your article and knowledge base 44 | - [ ] AI-label and content summarize 45 | 4. Team Collaboration 46 | - [ ] Memeber management 47 | 5. Devops capabilities 48 | - [ ] Deploy to docker 49 | - [ ] Deploy to [sealos](https://github.com/labring/sealos) 50 | 51 | ### 🥰 Thanks to: 52 | 53 | - [Antonio](https://github.com/AntonioErdeljac)(his tutorial helps me so much) 54 | - This project is base in a tutorial project from him,and it will go further.[His website](https://www.codewithantonio.com/) 55 | - [BlockNote](https://github.com/TypeCellOS/BlockNote) 56 | - Support core block editor,saved me so much time. 57 | -------------------------------------------------------------------------------- /api/document.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react' 2 | import useSWR, { mutate as globalMutate } from 'swr' 3 | 4 | import axios from '@/lib/axios' 5 | import { useDocument } from '@/stores/use-document' 6 | 7 | export interface Doc { 8 | _id: string 9 | title?: string 10 | userId?: string 11 | isArchived?: boolean 12 | isPublished?: boolean 13 | collaborators?: [string] 14 | parentDocument?: string 15 | content?: string 16 | icon?: string 17 | coverImage?: string 18 | } 19 | 20 | const fetcher = (url: string) => axios.get(url).then((res) => res.data) 21 | 22 | // get document by Id 23 | export const useDocumentById = (id: string) => { 24 | const { data, mutate, error, isLoading } = useSWR( 25 | `/api/document/get-by-id?id=${id}`, 26 | fetcher 27 | ) 28 | const { document, onSetDocument } = useDocument() 29 | useEffect(() => { 30 | if (data) { 31 | onSetDocument(data as Doc) 32 | } 33 | }, [data]) 34 | return { 35 | document, 36 | mutate, 37 | error, 38 | isLoading, 39 | } 40 | } 41 | 42 | // get sidebar info 43 | export const useSidebar = (parentDocumentId: string, type: string) => { 44 | const { 45 | data: documents, 46 | mutate, 47 | error, 48 | isLoading, 49 | } = useSWR( 50 | `/api/document/sidebar?parentDocument=${parentDocumentId}&type=${type}`, 51 | fetcher 52 | ) 53 | return { 54 | documents, 55 | mutate, 56 | error, 57 | isLoading, 58 | } 59 | } 60 | 61 | // get search document 62 | export const useSearchDocuments = () => { 63 | const { 64 | data: documents, 65 | mutate, 66 | error, 67 | isLoading, 68 | } = useSWR('/api/document/get-search', fetcher) 69 | return { 70 | documents, 71 | mutate, 72 | error, 73 | isLoading, 74 | } 75 | } 76 | 77 | // get basic information by id: title and icon 78 | type DocumentInfo = Pick 79 | export const useBasicInfoById = (id: string) => { 80 | const { 81 | data: documentInfo, 82 | mutate, 83 | error, 84 | isLoading, 85 | } = useSWR( 86 | `/api/document/get-basic-info-by-id?id=${id}`, 87 | fetcher 88 | ) 89 | return { 90 | documentInfo, 91 | mutate, 92 | error, 93 | isLoading, 94 | } 95 | } 96 | 97 | // get documents which are archived 98 | export const useTrash = () => { 99 | const { 100 | data: documents, 101 | mutate, 102 | error, 103 | isLoading, 104 | } = useSWR('/api/document/get-trash', fetcher) 105 | return { 106 | documents, 107 | mutate, 108 | error, 109 | isLoading, 110 | } 111 | } 112 | 113 | // create a new document 114 | export const create = async (title: string, parentDocument: string) => { 115 | const res = await axios.post('/api/document/create', { 116 | title, 117 | parentDocument, 118 | }) 119 | globalMutate( 120 | (key) => typeof key === 'string' && key.startsWith('/api/document/sidebar') 121 | ) 122 | return res.data 123 | } 124 | 125 | // archive a document to trash 126 | export const archive = async (id: string) => { 127 | await axios.put(`/api/document/archive?id=${id}`) 128 | globalMutate( 129 | (key) => typeof key === 'string' && key.startsWith('/api/document/sidebar') 130 | ) 131 | } 132 | 133 | // restore document to normal 134 | export const restore = async (id: string) => { 135 | await axios.put(`/api/document/restore?id=${id}`) 136 | globalMutate( 137 | (key) => typeof key === 'string' && key.startsWith('/api/document/sidebar') 138 | ) 139 | globalMutate( 140 | (key) => 141 | typeof key === 'string' && key.startsWith('/api/document/get-trash') 142 | ) 143 | } 144 | 145 | // remove document forever 146 | export const remove = async (id: string) => { 147 | await axios.delete(`/api/document/remove?id=${id}`) 148 | globalMutate( 149 | (key) => 150 | typeof key === 'string' && key.startsWith('/api/document/get-trash') 151 | ) 152 | } 153 | 154 | // update document content 155 | // TODO: update document content to global store 156 | export const update = async (document: Doc) => { 157 | const res = await axios.put('/api/document/update', document) 158 | globalMutate( 159 | (key) => typeof key === 'string' && key.startsWith('/api/document/sidebar') 160 | ) 161 | return res.data 162 | } 163 | 164 | // remove Icon 165 | export const removeIcon = async (id: string) => { 166 | const res = await axios.delete(`/api/document/remove-icon?id=${id}`) 167 | return res.data 168 | } 169 | 170 | // remove coverImage 171 | export const removeCoverImage = async (id: string) => { 172 | const res = await axios.delete(`/api/document/remove-cover-image?id=${id}`) 173 | return res.data 174 | } 175 | 176 | // remove access to this document 177 | export const removeAccess = async ( 178 | documentId: string, 179 | collaboratorEmail: string 180 | ) => { 181 | await axios.put('/api/document/remove-access', { 182 | documentId, 183 | collaboratorEmail, 184 | }) 185 | globalMutate( 186 | (key) => 187 | typeof key === 'string' && key.startsWith('/api/document/get-by-id') 188 | ) 189 | } 190 | -------------------------------------------------------------------------------- /api/image.ts: -------------------------------------------------------------------------------- 1 | import axios from '@/lib/axios' 2 | 3 | interface ImageUpload { 4 | file: File 5 | replaceTargetUrl?: string 6 | } 7 | 8 | export const upload = async ({ file, replaceTargetUrl }: ImageUpload) => { 9 | const formData = new FormData() 10 | formData.append('image', file) 11 | formData.append('replaceTargetUrl', replaceTargetUrl || '') 12 | 13 | const res = await axios.post('/api/image/upload', formData, { 14 | headers: { 15 | 'Content-type': 'multipart/form-data', 16 | }, 17 | }) 18 | return res.data 19 | } 20 | 21 | export const deleteImage = (url: string) => { 22 | return axios.delete(`/api/image/delete?url=${url}`) 23 | } 24 | -------------------------------------------------------------------------------- /api/invitation.ts: -------------------------------------------------------------------------------- 1 | import axios from '@/lib/axios' 2 | import useSWR, { mutate as globalMutate } from 'swr' 3 | 4 | export interface Invitation { 5 | _id: string 6 | documentId: string 7 | userEmail: string 8 | collaboratorEmail: string 9 | isAccepted: boolean 10 | isReplied: boolean 11 | isValid: boolean 12 | } 13 | 14 | type CreateParams = Pick< 15 | Invitation, 16 | 'documentId' | 'collaboratorEmail' | 'userEmail' 17 | > 18 | // create a new invitation 19 | export const create = (invitation: CreateParams) => { 20 | return axios.post('/api/invitation/create', invitation) 21 | } 22 | 23 | // get invitation by email 24 | const fetcher = (url: string) => axios.get(url).then((res) => res.data) 25 | export const useInvitationByEmail = (email: string) => { 26 | const { 27 | data: invitations, 28 | mutate, 29 | isLoading, 30 | error, 31 | } = useSWR( 32 | `/api/invitation/get-by-email?email=${email}`, 33 | fetcher 34 | ) 35 | return { 36 | invitations, 37 | mutate, 38 | isLoading, 39 | error, 40 | } 41 | } 42 | 43 | type UpdateParams = Pick 44 | // update invitation 45 | export const update = async (invitation: UpdateParams) => { 46 | await axios.put('/api/invitation/update', invitation) 47 | globalMutate( 48 | (key) => 49 | typeof key === 'string' && key.startsWith('/api/invitation/get-by-email') 50 | ) 51 | } 52 | -------------------------------------------------------------------------------- /api/user.ts: -------------------------------------------------------------------------------- 1 | import axios from '@/lib/axios' 2 | import useSWR from 'swr' 3 | 4 | export interface User { 5 | _id: string 6 | username: string 7 | imageUrl: string 8 | emailAddress: string 9 | created_at: string 10 | } 11 | 12 | export const githubLogin = async (code: string) => { 13 | const res = await axios.get(`/api/github-auth?code=${code}`) 14 | return res.data 15 | } 16 | 17 | export const getUserInfo = () => { 18 | return axios.get('/api/user/get-info') 19 | } 20 | 21 | export const getUserInfoByEmail = (email: string) => { 22 | return axios.get(`/api/user/get-info-by-email?email=${email}`) 23 | } 24 | 25 | const fetcher = (url: string) => axios.get(url).then((res) => res.data) 26 | 27 | export const useUserInfoByEmail = (email: string) => { 28 | const { data, isLoading } = useSWR( 29 | `/api/user/get-info-by-email?email=${email}`, 30 | fetcher 31 | ) 32 | 33 | return { 34 | userInfo: data, 35 | isLoading, 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /app/(main)/(routes)/documents/[documentId]/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { debounce } from 'lodash' 4 | 5 | import Cover from '@/components/cover' 6 | import Toolbar from '@/components/toolbar' 7 | import { Skeleton } from '@/components/ui/skeleton' 8 | import { EditorWrapper } from '@/components/editor/editor-wrapper' 9 | 10 | import { useDocumentById, update } from '@/api/document' 11 | 12 | interface DocumentIdPageProps { 13 | params: { 14 | documentId: string 15 | } 16 | } 17 | 18 | const DocumentIdPage = ({ params }: DocumentIdPageProps) => { 19 | const { document } = useDocumentById(params.documentId) 20 | const onChange = async (content: string) => { 21 | await update({ 22 | _id: params.documentId, 23 | content, 24 | }) 25 | } 26 | 27 | const debounceOnChange = debounce(onChange, 1000) 28 | 29 | if (document === undefined) { 30 | return ( 31 |
32 | 33 |
34 |
35 | 36 | 37 | 38 | 39 |
40 |
41 |
42 | ) 43 | } 44 | 45 | if (document === null) { 46 | return
Not found
47 | } 48 | 49 | return ( 50 |
51 | 52 |
53 | 54 | 1} 59 | /> 60 |
61 |
62 | ) 63 | } 64 | 65 | export default DocumentIdPage 66 | -------------------------------------------------------------------------------- /app/(main)/(routes)/documents/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import Image from 'next/image' 4 | import { toast } from 'sonner' 5 | import { PlusCircle } from 'lucide-react' 6 | import { useRouter } from 'next/navigation' 7 | 8 | import { create } from '@/api/document' 9 | import { Button } from '@/components/ui/button' 10 | import { useSession } from '@/hooks/use-session' 11 | 12 | const DocumentsPage = () => { 13 | const router = useRouter() 14 | const { user } = useSession() 15 | 16 | const onCreate = async () => { 17 | try { 18 | toast.loading('Creating a new note.....') 19 | const documentId = await create('untitled', '') 20 | router.push(`/documents/${documentId}`) 21 | } catch (error) { 22 | toast.error('Failed to create a new note.') 23 | } 24 | } 25 | 26 | return ( 27 |
28 | Empty 35 | Empty 42 |

43 | Welcome to {user?.username}'s Jotlin 44 |

45 | 49 |
50 | ) 51 | } 52 | 53 | export default DocumentsPage 54 | -------------------------------------------------------------------------------- /app/(main)/components/banner.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { toast } from 'sonner' 4 | import { useRouter } from 'next/navigation' 5 | 6 | import { restore, remove } from '@/api/document' 7 | import ConfirmModal from '@/components/modals/confirm-modal' 8 | import { Button } from '@/components/ui/button' 9 | 10 | interface BannerProps { 11 | documentId: string 12 | } 13 | 14 | const Banner = ({ documentId }: BannerProps) => { 15 | const router = useRouter() 16 | 17 | const onRemove = async () => { 18 | try { 19 | toast.loading('Deleting note...') 20 | 21 | await remove(documentId) 22 | 23 | toast.success('Note deleted!') 24 | 25 | router.push('/documents') 26 | } catch (error) { 27 | toast.error('Failed to delete note.') 28 | } 29 | } 30 | const onRestore = async () => { 31 | try { 32 | toast.loading('Restoring note...') 33 | 34 | await restore(documentId) 35 | 36 | toast.success('Note restored!') 37 | } catch (error) { 38 | toast.error('Failed to restore note.') 39 | } 40 | } 41 | 42 | return ( 43 |
44 |

This page is in the Trash.

45 | 52 | 53 | 59 | 60 |
61 | ) 62 | } 63 | 64 | export default Banner 65 | -------------------------------------------------------------------------------- /app/(main)/components/document-list.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { useState } from 'react' 4 | import { FileIcon } from 'lucide-react' 5 | import { useParams, useRouter } from 'next/navigation' 6 | 7 | import Item from './item' 8 | import { cn } from '@/lib/utils' 9 | import { Doc, useSidebar } from '@/api/document' 10 | 11 | interface DocumentListProps { 12 | parentDocumentId?: string 13 | level?: number 14 | data?: Doc[] 15 | type: 'private' | 'share' 16 | } 17 | 18 | const DocumentList = ({ 19 | parentDocumentId, 20 | level = 0, 21 | type, 22 | }: DocumentListProps) => { 23 | const params = useParams() 24 | const router = useRouter() 25 | const [expanded, setExpanded] = useState>({}) 26 | 27 | // function: 切换展开状态 28 | const onExpand = (documentId: string) => { 29 | setExpanded((prevExpanded) => ({ 30 | ...prevExpanded, 31 | [documentId]: !prevExpanded[documentId], 32 | })) 33 | } 34 | 35 | parentDocumentId = parentDocumentId ? parentDocumentId : '' 36 | 37 | const { documents } = useSidebar(parentDocumentId, type) 38 | 39 | // function: 点击重定向到文档详情页 40 | const onRedirect = (documentId: string) => { 41 | router.push(`/documents/${documentId}`) 42 | } 43 | 44 | if (documents === undefined) { 45 | return ( 46 | <> 47 | 48 | {level === 0 && ( 49 | <> 50 | 51 | 52 | 53 | )} 54 | 55 | ) 56 | } 57 | 58 | return ( 59 | <> 60 | {/* 展开时渲染 */} 61 |

69 | No pages inside 70 |

71 | {documents.map((document: Doc) => ( 72 |
73 | onRedirect(document._id)} 77 | label={document.title as string} 78 | icon={FileIcon} 79 | documentIcon={document.icon} 80 | active={params.documentId === document._id} 81 | level={level} 82 | onExpand={() => onExpand(document._id)} 83 | expanded={expanded[document._id]} 84 | /> 85 | {expanded[document._id] && ( 86 | 91 | )} 92 |
93 | ))} 94 | 95 | ) 96 | } 97 | 98 | export default DocumentList 99 | -------------------------------------------------------------------------------- /app/(main)/components/inbox-content.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { useInvitationByEmail } from '@/api/invitation' 4 | import { useSession } from '@/hooks/use-session' 5 | import { Spinner } from '@/components/spinner' 6 | import { ScrollArea } from '@/components/ui/scroll-area' 7 | 8 | import InviteItem from './invite-item' 9 | 10 | const InboxContent = () => { 11 | const { user } = useSession() 12 | const { invitations } = useInvitationByEmail(user?.emailAddress as string) 13 | 14 | if (invitations === undefined) { 15 | return ( 16 |
17 | 18 |
19 | ) 20 | } 21 | 22 | if (invitations.length === 0) { 23 | return
No invitations
24 | } 25 | 26 | return ( 27 | 28 | {invitations.map((invitation) => ( 29 |
30 | 31 |
32 | ))} 33 |
34 | ) 35 | } 36 | 37 | export default InboxContent 38 | -------------------------------------------------------------------------------- /app/(main)/components/invite-item.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { mutate } from 'swr' 4 | import { useEffect, useState } from 'react' 5 | import { FileIcon } from 'lucide-react' 6 | 7 | import { Spinner } from '@/components/spinner' 8 | import { Button } from '@/components/ui/button' 9 | import { Separator } from '@/components/ui/separator' 10 | import { Avatar, AvatarImage } from '@/components/ui/avatar' 11 | 12 | import { useSession } from '@/hooks/use-session' 13 | import { useBasicInfoById } from '@/api/document' 14 | import { User, getUserInfoByEmail } from '@/api/user' 15 | import { Invitation, update } from '@/api/invitation' 16 | 17 | type UserInfo = Pick 18 | 19 | interface InviteItemProps { 20 | invitation: Invitation 21 | } 22 | 23 | const InviteItem = ({ invitation }: InviteItemProps) => { 24 | const { user } = useSession() 25 | const { 26 | _id, 27 | documentId, 28 | userEmail, 29 | collaboratorEmail, 30 | isAccepted, 31 | isReplied, 32 | isValid, 33 | } = invitation 34 | const { documentInfo } = useBasicInfoById(documentId) 35 | const [userInfo, setUserInfo] = useState(undefined) 36 | 37 | useEffect(() => { 38 | // TODO: 逻辑看不懂了 39 | if (collaboratorEmail === user?.emailAddress) { 40 | const fetchUserInfo = async () => { 41 | try { 42 | const response = await getUserInfoByEmail(userEmail) 43 | setUserInfo(response.data) 44 | console.log(response) 45 | } catch (error) { 46 | console.error('Error fetching userInfo:', error) 47 | } 48 | } 49 | fetchUserInfo() 50 | } 51 | }, [collaboratorEmail, user?.emailAddress, userEmail]) 52 | 53 | const accept = async () => { 54 | try { 55 | await update({ _id, isAccepted: true }) 56 | } catch (error) { 57 | console.log(error) 58 | } 59 | } 60 | const reject = async () => { 61 | try { 62 | await update({ _id, isAccepted: false }) 63 | } catch (error) { 64 | console.log(error) 65 | } 66 | } 67 | 68 | // if document has been removed or user has been removed or the invitation is invalid 69 | if (!isValid) { 70 | return ( 71 |
72 |
This invitation has been expired
73 | 74 |
75 | ) 76 | } 77 | 78 | if (documentInfo === undefined || userInfo === undefined) { 79 | return ( 80 |
81 | 82 |
83 | ) 84 | } 85 | 86 | return ( 87 | <> 88 | {/* 你是邀请人 */} 89 | {userEmail === user?.emailAddress && ( 90 |
91 | 92 | 95 | 96 |
97 | You invite {collaboratorEmail}{' '} 98 | to 99 | 100 | {documentInfo?.icon ? ( 101 | {documentInfo.icon} 102 | ) : ( 103 | 104 | )} 105 | {documentInfo?.title} 106 | 107 |
108 | {!isReplied 109 | ? 'No reply yet' 110 | : isReplied && isAccepted 111 | ? 'Accepted' 112 | : 'rejected'} 113 |
114 |
115 |
116 | )} 117 | {/* 你是被邀请人 */} 118 | {collaboratorEmail === user?.emailAddress && ( 119 |
120 | 121 | 124 | 125 |
126 | {userEmail} invite you to to 127 | 128 | {documentInfo?.icon ? ( 129 | {documentInfo.icon} 130 | ) : ( 131 | 135 | )} 136 | 137 | {documentInfo?.title} 138 | 139 | 140 |
141 | {!isReplied ? ( 142 |
143 | 150 | 153 |
154 | ) : isReplied && isAccepted ? ( 155 | 'Accepted' 156 | ) : ( 157 | 'rejected' 158 | )} 159 |
160 |
161 |
162 | )} 163 | 164 | 165 | ) 166 | } 167 | 168 | export default InviteItem 169 | -------------------------------------------------------------------------------- /app/(main)/components/invite-user.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { toast } from 'sonner' 4 | import { useState } from 'react' 5 | 6 | import { Spinner } from '@/components/spinner' 7 | import { Button } from '@/components/ui/button' 8 | import { Avatar, AvatarImage } from '@/components/ui/avatar' 9 | 10 | import { Doc, removeAccess } from '@/api/document' 11 | import { useSession } from '@/hooks/use-session' 12 | import { useUserInfoByEmail } from '@/api/user' 13 | 14 | interface InviteUserProps { 15 | collaborator: string 16 | document: Doc 17 | first?: boolean 18 | } 19 | 20 | export const InviteUser = ({ 21 | collaborator, 22 | document, 23 | first = false, 24 | }: InviteUserProps) => { 25 | const [isSubmitting, setIsSubmitting] = useState(false) 26 | const { user } = useSession() 27 | const { userInfo: collaboratorInfo, isLoading } = 28 | useUserInfoByEmail(collaborator) 29 | 30 | const isOwner = document.userId === user?._id 31 | 32 | const onRemovePrivilege = () => { 33 | setIsSubmitting(true) 34 | 35 | const promise = removeAccess(document._id, collaborator).finally(() => 36 | setIsSubmitting(false) 37 | ) 38 | 39 | toast.promise(promise, { 40 | loading: 'removing...', 41 | success: 'Privilege has been removed', 42 | error: 'Failed to remove him.', 43 | }) 44 | } 45 | 46 | if (isLoading) 47 | return ( 48 |
49 | 50 |
51 | ) 52 | 53 | return ( 54 |
55 |
56 | 57 | 60 | 61 |
62 | {collaboratorInfo && first ? ( 63 |
创建者
64 | ) : collaboratorInfo ? ( 65 |
协作人
66 | ) : null} 67 |
68 |
69 | {collaboratorInfo?.emailAddress} 70 |
71 |
72 | {first || !isOwner ? ( 73 | 74 | ) : ( 75 | 83 | )} 84 |
85 | ) 86 | } 87 | -------------------------------------------------------------------------------- /app/(main)/components/invite.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { toast } from 'sonner' 4 | import { FormEvent, useState } from 'react' 5 | 6 | import { Button } from '@/components/ui/button' 7 | import { 8 | Popover, 9 | PopoverContent, 10 | PopoverTrigger, 11 | } from '@/components/ui/popover' 12 | 13 | import { create } from '@/api/invitation' 14 | import { useSession } from '@/hooks/use-session' 15 | 16 | import { UserBoard } from './user-board' 17 | 18 | interface InviteProps { 19 | documentId: string 20 | } 21 | 22 | // TODO: invite 相关组件style很丑,需要重构,这里只是简单的实现功能 23 | // TODO: invite相应文件结构也不合理,不清晰,需要重构 24 | const Invite = ({ documentId }: InviteProps) => { 25 | const [collaboratorEmail, setCollaboratorEmail] = useState('') 26 | const [isSubmitting, setIsSubmitting] = useState(false) 27 | const { user } = useSession() 28 | 29 | const onInvite = async (e: FormEvent) => { 30 | e.preventDefault() 31 | 32 | setIsSubmitting(true) 33 | try { 34 | toast.loading('Inviting...') 35 | await create({ 36 | documentId, 37 | collaboratorEmail, 38 | userEmail: user!.emailAddress, 39 | }).finally(() => setIsSubmitting(false)) 40 | toast.success('Invitation has been sent') 41 | } catch (error) { 42 | toast.error('Failed to invite him.') 43 | } 44 | } 45 | 46 | return ( 47 | 48 | 49 | 52 | 53 | 54 |
55 | setCollaboratorEmail(e.target.value)} 60 | placeholder="Enter collaborator email..." 61 | className="h-8 flex-1 truncate rounded-md border bg-muted px-2 text-xs focus-within:ring-transparent" 62 | /> 63 | 70 |
71 | 72 |
73 |
74 | ) 75 | } 76 | 77 | export default Invite 78 | -------------------------------------------------------------------------------- /app/(main)/components/item.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { mutate } from 'swr' 4 | import { toast } from 'sonner' 5 | import { useRouter } from 'next/navigation' 6 | import { 7 | ChevronDown, 8 | ChevronRight, 9 | LucideIcon, 10 | MoreHorizontal, 11 | Plus, 12 | Trash, 13 | } from 'lucide-react' 14 | 15 | import { 16 | DropdownMenu, 17 | DropdownMenuContent, 18 | DropdownMenuItem, 19 | DropdownMenuSeparator, 20 | DropdownMenuTrigger, 21 | } from '@/components/ui/dropdown-menu' 22 | import { Skeleton } from '@/components/ui/skeleton' 23 | 24 | import { cn } from '@/lib/utils' 25 | import { useSession } from '@/hooks/use-session' 26 | import { create, archive } from '@/api/document' 27 | import { removeAccess } from '@/api/document' 28 | 29 | interface ItemProps { 30 | id?: string 31 | documentIcon?: string 32 | active?: boolean 33 | expanded?: boolean 34 | isSearch?: boolean 35 | level?: number 36 | onExpand?: () => void 37 | label: string 38 | onClick?: () => void 39 | icon: LucideIcon 40 | type?: 'private' | 'share' 41 | } 42 | 43 | const Item = ({ 44 | id, 45 | documentIcon, 46 | active, 47 | expanded, 48 | isSearch, 49 | label, 50 | onExpand, 51 | level = 0, 52 | onClick, 53 | icon: Icon, 54 | type, 55 | }: ItemProps) => { 56 | const router = useRouter() 57 | const { user } = useSession() 58 | 59 | const onArchive = async ( 60 | event: React.MouseEvent 61 | ) => { 62 | event.stopPropagation() 63 | if (!id) return 64 | try { 65 | toast.loading('Moving to trash...') 66 | await archive(id) 67 | router.push('/documents') 68 | toast.success('Note moved to trash!') 69 | } catch (error) { 70 | toast.error('Failed to archive note.') 71 | } 72 | } 73 | 74 | const onQuitDocument = () => { 75 | if (!id) return 76 | const promise = removeAccess(id, user!.emailAddress).then((res) => { 77 | console.log(res) 78 | mutate( 79 | (key) => 80 | typeof key === 'string' && key.startsWith('/api/document/get-by-id') 81 | ) 82 | router.push('/documents') 83 | }) 84 | 85 | toast.promise(promise, { 86 | loading: 'quit...', 87 | success: 'You quit this document', 88 | error: 'Failed to quit it.', 89 | }) 90 | } 91 | 92 | //切换展开状态 93 | const handleExpand = ( 94 | event: React.MouseEvent 95 | ) => { 96 | event.stopPropagation() //阻止事件冒泡,因为父元素上绑定点击打开文档详情的函数,不阻止会进入详情页 97 | onExpand?.() 98 | } 99 | 100 | const onCreate = async ( 101 | event: React.MouseEvent 102 | ) => { 103 | event.stopPropagation() 104 | 105 | if (!id) return 106 | 107 | try { 108 | const documentId = await create('Untitled', id) 109 | if (!expanded) { 110 | onExpand?.() 111 | } 112 | router.push(`/documents/${documentId}`) 113 | } catch (error) { 114 | toast.error('Failed to create a new note.') 115 | } 116 | } 117 | 118 | const ChevronIcon = expanded ? ChevronDown : ChevronRight 119 | 120 | return ( 121 |
129 | {/* 展开子文档三角符 */} 130 | {!!id && ( 131 |
136 | 137 |
138 | )} 139 | {/* 文档的自定义emoji Icon,如果有就渲染,没有就渲染默认的icon */} 140 | {documentIcon ? ( 141 |
{documentIcon}
142 | ) : ( 143 | 144 | )} 145 | {/* 文档名字 */} 146 | {label} 147 | {/* 当是搜索时呈现 */} 148 | {isSearch && ( 149 | 150 | ControlK 151 | 152 | )} 153 | {/* 右侧功能区 */} 154 | {!!id && ( 155 |
156 | {/*下拉菜单,三个小点 */} 157 | 158 | e.stopPropagation()}> 159 |
162 | 163 |
164 |
165 | 170 | 172 | 173 | {type === 'private' ? 'Delete' : 'Quit'} 174 | 175 | 176 |
177 | Last edited by:{user?.username} 178 |
179 |
180 |
181 |
185 | 186 |
187 |
188 | )} 189 |
190 | ) 191 | } 192 | 193 | Item.Skeleton = function ItemSkeleton({ level }: { level?: number }) { 194 | return ( 195 |
200 | 201 | 202 |
203 | ) 204 | } 205 | export default Item 206 | -------------------------------------------------------------------------------- /app/(main)/components/menu.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { toast } from 'sonner' 4 | import { useRouter } from 'next/navigation' 5 | import { MoreHorizontal, Trash, FolderUp, Download } from 'lucide-react' 6 | 7 | import { 8 | DropdownMenu, 9 | DropdownMenuContent, 10 | DropdownMenuItem, 11 | DropdownMenuSeparator, 12 | DropdownMenuTrigger, 13 | } from '@/components/ui/dropdown-menu' 14 | import { Button } from '@/components/ui/button' 15 | import { Skeleton } from '@/components/ui/skeleton' 16 | import { useCreateBlockNote } from '@blocknote/react' 17 | 18 | import { archive, update, useDocumentById } from '@/api/document' 19 | import { useSession } from '@/hooks/use-session' 20 | 21 | interface MenuProps { 22 | documentId: string 23 | } 24 | 25 | const Menu = ({ documentId }: MenuProps) => { 26 | const router = useRouter() 27 | const { user } = useSession() 28 | const editor = useCreateBlockNote() 29 | const { document: currentDocument, mutate } = useDocumentById(documentId) 30 | 31 | const onArchive = async () => { 32 | try { 33 | toast.loading('Moving to trash...') 34 | archive(documentId) 35 | toast.success('Note moved to trash.') 36 | router.push('/documents') 37 | } catch (error) { 38 | toast.error('Failed to archive note.') 39 | } 40 | } 41 | 42 | const onImport = async () => { 43 | const input = document.createElement('input') 44 | input.type = 'file' 45 | input.accept = '.md' 46 | input.onchange = async (event) => { 47 | const file = (event.target as HTMLInputElement).files?.[0] 48 | if (file) { 49 | const title = file.name.replace('.md', '') 50 | const markdownContent = await file.text() 51 | const blocks = await editor.tryParseMarkdownToBlocks(markdownContent) 52 | try { 53 | toast.loading('Importing note...') 54 | await update({ 55 | _id: documentId, 56 | title, 57 | content: JSON.stringify(blocks), 58 | }) 59 | // NOTE: 这里有点特殊,不能将mutate嵌入到api请求里,否则正常书写容易出问题 60 | mutate() 61 | toast.success('Note imported.') 62 | } catch (error) { 63 | toast.error('Failed to import note.') 64 | } 65 | } 66 | } 67 | input.click() 68 | } 69 | 70 | const onExport = async () => { 71 | try { 72 | toast.loading('Exporting note...') 73 | const title = currentDocument?.title || 'Untitled' 74 | const blockContent = JSON.parse(currentDocument?.content as string) || [] 75 | const markdownContent = await editor.blocksToMarkdownLossy(blockContent) 76 | 77 | const blob = new Blob([markdownContent], { type: 'text/markdown' }) 78 | const url = URL.createObjectURL(blob) 79 | const a = document.createElement('a') 80 | a.href = url 81 | a.download = `${title}.md` 82 | a.click() 83 | toast.success('Note exported.') 84 | } catch (error) { 85 | toast.error('Failed to export note.') 86 | } 87 | } 88 | 89 | return ( 90 | 91 | 92 | 95 | 96 | 101 | 102 | 103 | Import from md file 104 | 105 | 106 | 107 | Export to md file 108 | 109 | 110 | 111 | Delete 112 | 113 | 114 |
115 | Last edited by:{user?.username} 116 |
117 |
118 |
119 | ) 120 | } 121 | 122 | Menu.Skeleton = function MenuSkeleton() { 123 | return 124 | } 125 | 126 | export default Menu 127 | -------------------------------------------------------------------------------- /app/(main)/components/navbar.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { MenuIcon } from 'lucide-react' 4 | 5 | import Menu from './menu' 6 | import Title from './title' 7 | import Banner from './banner' 8 | import Invite from './invite' 9 | import Publish from './publish' 10 | import { useDocument } from '@/stores/use-document' 11 | 12 | interface NavbarProps { 13 | isCollapsed: boolean 14 | onResetWidth: () => void 15 | } 16 | 17 | const Navbar = ({ isCollapsed, onResetWidth }: NavbarProps) => { 18 | const { document } = useDocument() 19 | 20 | if (document === undefined) { 21 | return ( 22 | 28 | ) 29 | } 30 | if (document === null) { 31 | return null 32 | } 33 | return ( 34 | <> 35 |