├── .editorconfig ├── .env.development ├── .env.production ├── .github ├── ISSUE_TEMPLATE │ ├── BUGS.yml │ ├── DISCUSS.yml │ ├── FEATURE.yml │ ├── Regression.yml │ ├── TASK.md │ └── config.yml ├── PULL_REQUEST_TEMPLATE.md └── workflows │ └── lint.yml ├── .gitignore ├── .husky └── pre-commit ├── .npmrc ├── .prettierignore ├── .prettierrc ├── .vscode ├── launch.json ├── settings.json └── tailwind.json ├── Dockerfile ├── LICENSE.md ├── README.md ├── commitlint.config.cjs ├── components.json ├── eslint.config.mjs ├── next.config.ts ├── package.json ├── pnpm-lock.yaml ├── postcss.config.mjs ├── public ├── favicon.svg ├── fonts │ ├── CalSans-SemiBold.ttf │ ├── Inter-roman.ttf │ └── Inter.ttf └── placeholder-image.jpg ├── src ├── app │ ├── auth │ │ ├── callback │ │ │ └── page.tsx │ │ └── page.tsx │ ├── docs │ │ ├── [room] │ │ │ ├── _components │ │ │ │ └── no_permission_view │ │ │ │ │ └── index.tsx │ │ │ └── page.tsx │ │ ├── layout.tsx │ │ └── page.tsx │ ├── layout.tsx │ ├── opengraph-image.png │ ├── page.tsx │ ├── robots.ts │ └── share │ │ └── [id] │ │ └── page.tsx ├── components │ ├── FileExplorer │ │ ├── FileExplorer.tsx │ │ └── index.ts │ ├── layout │ │ ├── Header │ │ │ └── index.tsx │ │ ├── TabSidebar │ │ │ ├── index.tsx │ │ │ └── tabs │ │ │ │ ├── BlocksTab.tsx │ │ │ │ ├── SearchTab.tsx │ │ │ │ ├── SettingsTab.tsx │ │ │ │ ├── TemplatesTab.tsx │ │ │ │ └── folder │ │ │ │ ├── FileItemMenu.tsx │ │ │ │ ├── ShareDialog.tsx │ │ │ │ ├── components │ │ │ │ ├── FileTree.tsx │ │ │ │ ├── SharedDocuments.tsx │ │ │ │ └── UserSelector.tsx │ │ │ │ ├── hooks │ │ │ │ ├── useFileOperations.ts │ │ │ │ └── useFileSearch.ts │ │ │ │ ├── index.tsx │ │ │ │ └── type.ts │ │ └── toc │ │ │ └── index.tsx │ ├── menus │ │ ├── ContentItemMenu │ │ │ ├── ContentItemMenu.tsx │ │ │ ├── hooks │ │ │ │ ├── useContentItemActions.tsx │ │ │ │ └── useData.tsx │ │ │ └── index.tsx │ │ ├── LinkMenu │ │ │ ├── LinkMenu.tsx │ │ │ └── index.tsx │ │ ├── TextMenu │ │ │ ├── TextMenu.tsx │ │ │ ├── components │ │ │ │ ├── ContentTypePicker.tsx │ │ │ │ ├── EditLinkPopover.tsx │ │ │ │ ├── FontFamilyPicker.tsx │ │ │ │ └── FontSizePicker.tsx │ │ │ ├── hooks │ │ │ │ ├── useTextMenuCommands.ts │ │ │ │ ├── useTextMenuContentTypes.ts │ │ │ │ └── useTextMenuStates.ts │ │ │ └── index.tsx │ │ ├── index.ts │ │ └── types.ts │ ├── panels │ │ ├── Colorpicker │ │ │ ├── ColorButton.tsx │ │ │ ├── Colorpicker.tsx │ │ │ └── index.tsx │ │ ├── LinkEditorPanel │ │ │ ├── LinkEditorPanel.tsx │ │ │ └── index.tsx │ │ ├── LinkPreviewPanel │ │ │ ├── LinkPreviewPanel.tsx │ │ │ └── index.tsx │ │ └── index.tsx │ └── ui │ │ ├── BubbleMenu.tsx │ │ ├── Dropdown │ │ ├── Dropdown.tsx │ │ └── index.tsx │ │ ├── Icon.tsx │ │ ├── Panel │ │ └── index.tsx │ │ ├── PopoverMenu.tsx │ │ ├── Spinner │ │ ├── Spinner.tsx │ │ └── index.tsx │ │ ├── Surface.tsx │ │ ├── Textarea │ │ ├── Textarea.tsx │ │ └── index.tsx │ │ ├── Toggle │ │ ├── Toggle.tsx │ │ └── index.tsx │ │ ├── Toolbar.tsx │ │ ├── Tooltip │ │ ├── index.tsx │ │ └── types.ts │ │ ├── button.tsx │ │ ├── calendar.tsx │ │ ├── dialog.tsx │ │ └── select.tsx ├── extensions │ ├── BlockquoteFigure │ │ ├── BlockquoteFigure.ts │ │ ├── Quote │ │ │ ├── Quote.ts │ │ │ └── index.ts │ │ ├── QuoteCaption │ │ │ ├── QuoteCaption.ts │ │ │ └── index.ts │ │ └── index.ts │ ├── ChartBlock │ │ ├── ChartBlock.ts │ │ ├── ChartBlockView.tsx │ │ └── index.ts │ ├── CodeBlock │ │ ├── CodeBlock.ts │ │ └── index.ts │ ├── CustomBlock │ │ ├── CustomBlock.ts │ │ ├── CustomBlockView.tsx │ │ └── index.ts │ ├── Document │ │ ├── Document.ts │ │ └── index.ts │ ├── DragHandler │ │ ├── DragHandler.ts │ │ └── index.ts │ ├── DraggableBlock │ │ ├── DraggableBlock.ts │ │ ├── DraggableBlockView.tsx │ │ └── index.ts │ ├── EmojiSuggestion │ │ ├── components │ │ │ ├── EmojiList.tsx │ │ │ └── EmojiPopover.tsx │ │ ├── index.ts │ │ ├── suggestion.ts │ │ └── types.ts │ ├── Figcaption │ │ ├── Figcaption.ts │ │ └── index.ts │ ├── Figure │ │ ├── Figure.ts │ │ └── index.ts │ ├── FontSize │ │ ├── FontSize.ts │ │ └── index.ts │ ├── Heading │ │ ├── Heading.ts │ │ └── index.ts │ ├── HorizontalRule │ │ ├── HorizontalRule.ts │ │ └── index.ts │ ├── Image │ │ ├── Image.ts │ │ └── index.ts │ ├── ImageBlock │ │ ├── ImageBlock.ts │ │ ├── components │ │ │ ├── ImageBlockMenu.tsx │ │ │ ├── ImageBlockView.tsx │ │ │ └── ImageBlockWidth.tsx │ │ └── index.ts │ ├── ImageUpload │ │ ├── ImageUpload.ts │ │ ├── index.ts │ │ └── view │ │ │ ├── ImageUpload.tsx │ │ │ ├── ImageUploader.tsx │ │ │ ├── hooks.ts │ │ │ └── index.tsx │ ├── Link │ │ ├── Link.ts │ │ └── index.ts │ ├── MindMapBlock │ │ ├── MindMapBlock.ts │ │ ├── MindMapBlockView.tsx │ │ ├── README.md │ │ ├── _components │ │ │ └── mind_map │ │ │ │ ├── index.tsx │ │ │ │ └── types.ts │ │ └── index.ts │ ├── MultiColumn │ │ ├── Column.ts │ │ ├── Columns.ts │ │ ├── index.ts │ │ └── menus │ │ │ ├── ColumnsMenu.tsx │ │ │ └── index.ts │ ├── Selection │ │ ├── Selection.ts │ │ └── index.ts │ ├── SlashCommand │ │ ├── CommandButton.tsx │ │ ├── MenuList.tsx │ │ ├── SlashCommand.ts │ │ ├── SlashCommandPopover.tsx │ │ ├── groups.ts │ │ ├── index.ts │ │ └── types.ts │ ├── Table │ │ ├── Cell.ts │ │ ├── Header.ts │ │ ├── Row.ts │ │ ├── Table.ts │ │ ├── index.ts │ │ ├── menus │ │ │ ├── TableColumn │ │ │ │ ├── index.tsx │ │ │ │ └── utils.ts │ │ │ ├── TableRow │ │ │ │ ├── index.tsx │ │ │ │ └── utils.ts │ │ │ └── index.tsx │ │ └── utils.ts │ ├── TableOfContentsNode │ │ ├── TableOfContentsNode.tsx │ │ └── index.ts │ ├── TrailingNode │ │ ├── index.ts │ │ └── trailing-node.ts │ ├── extension-kit.ts │ └── index.ts ├── hooks │ ├── useCollaborativeEditor.ts │ ├── useDarkMode.tsx │ └── useSidebar.tsx ├── lib │ └── utils.ts ├── middleware.ts ├── services │ ├── auth │ │ ├── index.ts │ │ └── type.ts │ ├── document │ │ ├── index.ts │ │ └── type.ts │ ├── request.ts │ ├── users │ │ ├── index.ts │ │ └── type.ts │ └── video │ │ ├── index.ts │ │ └── type.ts ├── styles │ ├── index.css │ └── partials │ │ ├── animations.css │ │ ├── blocks.css │ │ ├── code.css │ │ ├── collab.css │ │ ├── draggable.css │ │ ├── lists.css │ │ ├── placeholder.css │ │ ├── table.css │ │ └── typography.css ├── types │ └── antv-hierarchy.d.ts └── utils │ ├── api.ts │ ├── constants.tsx │ ├── cookie.ts │ ├── cursor_color.ts │ ├── data │ └── initialContent.tsx │ ├── http.ts │ └── utils │ ├── getRenderContainer.ts │ ├── index.ts │ ├── isCustomNodeSelected.ts │ └── isTextSelected.ts ├── tailwind.config.ts └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | 15 | [Makefile] 16 | indent_style = tab -------------------------------------------------------------------------------- /.env.development: -------------------------------------------------------------------------------- 1 | NEXT_PUBLIC_AUTH_LOGIN_URL = http://localhost:8080/api/v1/auth/github 2 | 3 | NEXT_PUBLIC_SERVER_URL = http://localhost:8080 4 | 5 | NEXT_PUBLIC_WEBSOCKET_URL = ws://127.0.0.1:1234 -------------------------------------------------------------------------------- /.env.production: -------------------------------------------------------------------------------- 1 | NEXT_PUBLIC_AUTH_LOGIN_URL = https://www.codecrack.cn/collaboration/api/v1/github 2 | 3 | NEXT_PUBLIC_SERVER_URL = https://www.codecrack.cn/collaboration 4 | 5 | NEXT_PUBLIC_WEBSOCKET_URL = wss://www.codecrack.cn/ws -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/BUGS.yml: -------------------------------------------------------------------------------- 1 | name: "\U0001F41B Bug Report" 2 | description: "If something isn't working as expected \U0001F914" 3 | labels: ['needs triage', 'bug'] 4 | body: 5 | - type: textarea 6 | validations: 7 | required: true 8 | attributes: 9 | label: 'Current behavior' 10 | description: 'How the issue manifests?' 11 | 12 | - type: input 13 | validations: 14 | required: true 15 | attributes: 16 | label: 'Minimum reproduction code' 17 | description: 'An URL to some git repository or gist that reproduces this issue. [Wtf is a minimum reproduction?](https://jmcdo29.github.io/wtf-is-a-minimum-reproduction)' 18 | placeholder: 'https://github.com/...' 19 | 20 | - type: textarea 21 | attributes: 22 | label: 'Steps to reproduce' 23 | description: | 24 | How the issue manifests? 25 | You could leave this blank if you alread write this in your reproduction code/repo 26 | placeholder: | 27 | 1. `npm i` 28 | 2. `npm start:dev` 29 | 3. See error... 30 | 31 | - type: textarea 32 | validations: 33 | required: true 34 | attributes: 35 | label: 'Expected behavior' 36 | description: 'A clear and concise description of what you expected to happend (or code)' 37 | 38 | - type: markdown 39 | attributes: 40 | value: | 41 | --- 42 | 43 | - type: input 44 | validations: 45 | required: true 46 | attributes: 47 | label: 'Package version' 48 | description: | 49 | Which version of `@nestjs/cli` are you using? 50 | **Tip**: Make sure that all of yours `@nestjs/*` dependencies are in sync! 51 | placeholder: '8.1.3' 52 | 53 | - type: dropdown 54 | attributes: 55 | label: 'Template Package' 56 | description: 'Which project template are you using?' 57 | options: 58 | - react-web-ts 59 | - vue-web-js 60 | - react-web-js 61 | - vue-web-ts 62 | validations: 63 | required: true 64 | 65 | - type: input 66 | attributes: 67 | label: 'Node.js version' 68 | description: 'Which version of Node.js are you using?' 69 | placeholder: '14.17.6' 70 | 71 | - type: checkboxes 72 | attributes: 73 | label: 'In which operating systems have you tested?' 74 | options: 75 | - label: macOS 76 | - label: Windows 77 | - label: Linux 78 | 79 | - type: markdown 80 | attributes: 81 | value: | 82 | --- 83 | 84 | - type: textarea 85 | attributes: 86 | label: 'Other' 87 | description: | 88 | Anything else relevant? eg: Logs, OS version, IDE, package manager, etc. 89 | **Tip:** You can attach images, recordings or log files by clicking this area to highlight it and then dragging files in 90 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/DISCUSS.yml: -------------------------------------------------------------------------------- 1 | name: '💬 Discussion' 2 | description: Start a discussion 3 | title: '[Discuss]: Discussion Title' 4 | labels: [discussion] 5 | assignees: [] 6 | body: 7 | - type: textarea 8 | id: topic 9 | attributes: 10 | label: Discussion Topic 11 | description: 'Please provide a clear and concise topic for discussion.' 12 | validations: 13 | required: true 14 | - type: textarea 15 | id: screenshots 16 | attributes: 17 | label: Screenshots 18 | description: 'If applicable, add screenshots to help explain your topic.' 19 | - type: textarea 20 | id: links 21 | attributes: 22 | label: Links 23 | description: 'Include any relevant links that could provide more context for the discussion.' 24 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/FEATURE.yml: -------------------------------------------------------------------------------- 1 | name: "\U0001F680 Feature Request" 2 | description: "I have a suggestion \U0001F63B!" 3 | labels: ['feature'] 4 | body: 5 | - type: textarea 6 | validations: 7 | required: true 8 | attributes: 9 | label: 'Is your feature request related to a problem? Please describe it' 10 | description: 'A clear and concise description of what the problem is' 11 | placeholder: | 12 | I have an issue when ... 13 | 14 | - type: textarea 15 | validations: 16 | required: true 17 | attributes: 18 | label: "Describe the solution you'd like" 19 | description: 'A clear and concise description of what you want to happen. Add any considered drawbacks' 20 | 21 | - type: textarea 22 | attributes: 23 | label: 'Teachability, documentation, adoption, migration strategy' 24 | description: 'If you can, explain how users will be able to use this and possibly write out a version the docs. Maybe a screenshot or design?' 25 | 26 | - type: textarea 27 | validations: 28 | required: true 29 | attributes: 30 | label: 'What is the motivation / use case for changing the behavior?' 31 | description: 'Describe the motivation or the concrete use case' 32 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/Regression.yml: -------------------------------------------------------------------------------- 1 | name: "\U0001F4A5 Regression" 2 | description: 'Report an unexpected behavior while upgrading your application!' 3 | labels: ['needs triage'] 4 | body: 5 | - type: checkboxes 6 | attributes: 7 | label: 'Is there an existing issue that is already proposing this?' 8 | description: 'Please search [here](./?q=is%3Aissue) to see if an issue already exists for the feature you are requesting' 9 | options: 10 | - label: 'I have searched the existing issues' 11 | required: true 12 | 13 | - type: input 14 | attributes: 15 | label: 'Potential Commit/PR that introduced the regression' 16 | description: 'If you have time to investigate, what PR/date/version introduced this issue' 17 | placeholder: 'PR #123 or commit 5b3c4a4' 18 | 19 | - type: input 20 | attributes: 21 | label: 'Versions' 22 | description: 'From which version of `create-neat` to which version you are upgrading' 23 | placeholder: '1.0.0 -> 1.1.0' 24 | 25 | - type: textarea 26 | validations: 27 | required: true 28 | attributes: 29 | label: 'Describe the regression' 30 | description: 'A clear and concise description of what the regression is' 31 | 32 | - type: textarea 33 | attributes: 34 | label: 'Minimum reproduction code' 35 | description: | 36 | Please share a git repo, a gist, or step-by-step instructions. [Wtf is a minimum reproduction?](https://jmcdo29.github.io/wtf-is-a-minimum-reproduction) 37 | **Tip:** If you leave a minimum repository, we will understand your issue faster! 38 | value: | 39 | ```ts 40 | 41 | ``` 42 | 43 | - type: textarea 44 | validations: 45 | required: true 46 | attributes: 47 | label: 'Expected behavior' 48 | description: 'A clear and concise description of what you expected to happend (or code)' 49 | 50 | - type: textarea 51 | attributes: 52 | label: 'Other' 53 | description: | 54 | Anything else relevant? eg: Logs, OS version, IDE, package manager, etc. 55 | **Tip:** You can attach images, recordings or log files by clicking this area to highlight it and then dragging files in 56 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/TASK.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 📋 需求任务 3 | about: 用于发布新的开发需求任务 4 | title: '[需求] 需求标题' 5 | labels: '待认领' 6 | assignees: '' 7 | --- 8 | 9 | 10 | 11 | ## 需求描述 12 | 13 | 14 | 15 | ## 具体实现内容 16 | 17 | 18 | 19 | - [ ] 功能点 1 20 | - [ ] 功能点 2 21 | - [ ] 功能点 3 22 | 23 | ## 技术规范 24 | 25 | 26 | 27 | ## 验收标准 28 | 29 | 30 | 31 | ## 相关资料 32 | 33 | 34 | 35 | ## 工作量评估 36 | 37 | - 预计所需时间: 38 | - 难度级别: 39 | - 优先级: 40 | 41 | ## 任务认领说明 42 | 43 | 1. 在评论中回复"我要认领此任务" 44 | 2. 等待管理员确认并分配给你 45 | 3. 完成后提交PR并在PR中关联此Issue (使用 "Closes #Issue编号") 46 | 47 | ## 其他说明 48 | 49 | 50 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | ## To encourage contributors to use issue templates, we don't allow blank issues 2 | blank_issues_enabled: false 3 | 4 | contact_links: 5 | - name: "\u2753 Discord Community of Create-Neat" 6 | url: 'https://raw.githubusercontent.com/xun082/md/main/blogs.images20240307173700.png' 7 | about: 'Please ask support questions or discuss suggestions/enhancements here.' 8 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## PR 描述 2 | 3 | **这个 PR 引入了什么变更?** 4 | 5 | 6 | 7 | ## PR 类型 8 | 9 | 10 | 11 | - [ ] 🐛 Bug 修复 12 | - [ ] ✨ 新功能 13 | - [ ] 💄 UI/UX 改进 14 | - [ ] ♻️ 重构(无功能变化) 15 | - [ ] 🚀 性能优化 16 | - [ ] 📝 文档更新 17 | - [ ] 🔧 构建/CI 相关变更 18 | - [ ] 🧪 测试相关变更 19 | - [ ] 🔒 安全修复 20 | - [ ] ⬆️ 依赖更新 21 | - [ ] ⏪ 还原变更 22 | - [ ] 🔄 其他... 请描述: 23 | 24 | ## Issue 关联 25 | 26 | 29 | 30 | - Closes # 31 | - Related to # 32 | 33 | ## 实现细节 34 | 35 | 36 | 37 | ## 测试情况 38 | 39 | 40 | 41 | - [ ] 已添加/更新单元测试 42 | - [ ] 已添加/更新集成测试 43 | - [ ] 已完成手动测试 44 | 45 | ## 破坏性变更 46 | 47 | - [ ] 是(请在下方描述影响和迁移路径) 48 | - [ ] 否 49 | 50 | 51 | 52 | ## UI 变更 53 | 54 | 55 | 56 | ## 部署注意事项 57 | 58 | 59 | 60 | ## 提交前检查清单 61 | 62 | - [ ] 我的代码符合项目的代码风格 63 | - [ ] 我已经审核了自己的代码 64 | - [ ] 我已经为复杂的代码部分添加了注释 65 | - [ ] 我已经更新了相关文档 66 | - [ ] 我的变更不会产生新的警告或错误 67 | - [ ] 我已确认所有依赖项都已正确更新 68 | 69 | 76 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Commit Message Check on PR 2 | 3 | on: 4 | pull_request: 5 | types: [opened, synchronize, reopened] 6 | 7 | jobs: 8 | test: 9 | runs-on: ubuntu-latest 10 | strategy: 11 | matrix: 12 | node-version: [20] 13 | steps: 14 | - name: Checkout 🛎️ 15 | uses: actions/checkout@v3 16 | with: 17 | persist-credentials: false 18 | 19 | - name: Install PNPM 20 | uses: pnpm/action-setup@v2 21 | with: 22 | version: 9.4.0 23 | 24 | - name: Install Deps 25 | run: pnpm i --no-frozen-lockfile 26 | 27 | - name: Format 28 | run: | 29 | pnpm run format:ci 30 | 31 | - name: Lint 32 | run: pnpm run lint:ci 33 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | .env 4 | 5 | # dependencies 6 | /node_modules 7 | /.pnp 8 | .pnp.js 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 | .npmrc -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | npx lint-staged 2 | 3 | pnpm type-check -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | @tiptap-pro:registry=https://registry.tiptap.dev/ 2 | //registry.tiptap.dev/:_authToken=FQEZySdAEquh64SIgHnytWbXfQmerrxlW6vmBVNrxy0Brho6KpM+IVXCjhV1xpPY -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | node_modules/ 3 | pnpm-lock.yaml -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "tabWidth": 2, 4 | "arrowParens": "always", 5 | "bracketSpacing": true, 6 | "proseWrap": "preserve", 7 | "trailingComma": "all", 8 | "jsxSingleQuote": false, 9 | "printWidth": 100, 10 | "endOfLine": "auto" 11 | } 12 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "type": "node", 6 | "request": "launch", 7 | "name": "调试图片处理脚本", 8 | "skipFiles": ["/**"], 9 | "program": "${workspaceFolder}/scripts/image.js", 10 | "args": ["./src/content"], 11 | "console": "integratedTerminal", 12 | "runtimeExecutable": "node", 13 | "runtimeArgs": ["--inspect-brk"] 14 | } 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.defaultFormatter": "esbenp.prettier-vscode", 3 | "editor.formatOnSave": true, 4 | "editor.codeActionsOnSave": { 5 | "source.fixAll": "always", 6 | "source.fixAll.eslint": "always" 7 | }, 8 | "eslint.validate": ["javascript", "javascriptreact", "typescript", "typescriptreact"] 9 | } 10 | -------------------------------------------------------------------------------- /.vscode/tailwind.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 1.1, 3 | "atDirectives": [ 4 | { 5 | "name": "@tailwind", 6 | "description": "Use the `@tailwind` directive to insert Tailwind's `base`, `components`, `utilities` and `screens` styles into your CSS.", 7 | "references": [ 8 | { 9 | "name": "Tailwind Documentation", 10 | "url": "https://tailwindcss.com/docs/functions-and-directives#tailwind" 11 | } 12 | ] 13 | }, 14 | { 15 | "name": "@apply", 16 | "description": "Use the `@apply` directive to inline any existing utility classes into your own custom CSS. This is useful when you find a common utility pattern in your HTML that you’d like to extract to a new component.", 17 | "references": [ 18 | { 19 | "name": "Tailwind Documentation", 20 | "url": "https://tailwindcss.com/docs/functions-and-directives#apply" 21 | } 22 | ] 23 | }, 24 | { 25 | "name": "@responsive", 26 | "description": "You can generate responsive variants of your own classes by wrapping their definitions in the `@responsive` directive:\n```css\n@responsive {\n .alert {\n background-color: #E53E3E;\n }\n}\n```\n", 27 | "references": [ 28 | { 29 | "name": "Tailwind Documentation", 30 | "url": "https://tailwindcss.com/docs/functions-and-directives#responsive" 31 | } 32 | ] 33 | }, 34 | { 35 | "name": "@screen", 36 | "description": "The `@screen` directive allows you to create media queries that reference your breakpoints by **name** instead of duplicating their values in your own CSS:\n```css\n@screen sm {\n /* ... */\n}\n```\n…gets transformed into this:\n```css\n@media (min-width: 640px) {\n /* ... */\n}\n```\n", 37 | "references": [ 38 | { 39 | "name": "Tailwind Documentation", 40 | "url": "https://tailwindcss.com/docs/functions-and-directives#screen" 41 | } 42 | ] 43 | }, 44 | { 45 | "name": "@variants", 46 | "description": "Generate `hover`, `focus`, `active` and other **variants** of your own utilities by wrapping their definitions in the `@variants` directive:\n```css\n@variants hover, focus {\n .btn-brand {\n background-color: #3182CE;\n }\n}\n```\n", 47 | "references": [ 48 | { 49 | "name": "Tailwind Documentation", 50 | "url": "https://tailwindcss.com/docs/functions-and-directives#variants" 51 | } 52 | ] 53 | }, 54 | { 55 | "name": "@reference", 56 | "description": "If you want to use @apply or @variant in the 13 | 14 | 15 | -------------------------------------------------------------------------------- /public/fonts/CalSans-SemiBold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xun082/DocFlow/64736d578ed9ca848073b372a141f7cf4863f718/public/fonts/CalSans-SemiBold.ttf -------------------------------------------------------------------------------- /public/fonts/Inter-roman.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xun082/DocFlow/64736d578ed9ca848073b372a141f7cf4863f718/public/fonts/Inter-roman.ttf -------------------------------------------------------------------------------- /public/fonts/Inter.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xun082/DocFlow/64736d578ed9ca848073b372a141f7cf4863f718/public/fonts/Inter.ttf -------------------------------------------------------------------------------- /public/placeholder-image.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xun082/DocFlow/64736d578ed9ca848073b372a141f7cf4863f718/public/placeholder-image.jpg -------------------------------------------------------------------------------- /src/app/auth/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import React from 'react'; 4 | import { Github } from 'lucide-react'; 5 | 6 | import { Button } from '@/components/ui/button'; 7 | 8 | export default function LoginPage() { 9 | const handleGitHubLogin = () => { 10 | window.location.href = `${process.env.NEXT_PUBLIC_SERVER_URL}/api/v1/auth/github`; 11 | }; 12 | 13 | return ( 14 |
15 |
16 |
17 |

欢迎回来

18 |

请登录以继续使用文档系统

19 |
20 | 21 |
22 |
23 | 24 | 32 | 33 |

安全登录,保护您的账户隐私

34 |
35 | 36 |
37 |

© {new Date().getFullYear()} 文档系统. 保留所有权利.

38 |
39 |
40 |
41 | ); 42 | } 43 | -------------------------------------------------------------------------------- /src/app/docs/[room]/_components/no_permission_view/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Link from 'next/link'; 3 | import { LockIcon, FileIcon } from 'lucide-react'; 4 | 5 | interface NoPermissionViewProps { 6 | reason: string; 7 | } 8 | 9 | const NoPermissionView: React.FC = ({ reason }) => { 10 | const isDocumentNotFound = 11 | reason.toLowerCase().includes('not found') || reason.toLowerCase().includes('不存在'); 12 | 13 | return ( 14 |
15 |
16 | {isDocumentNotFound ? ( 17 | 18 | ) : ( 19 | 20 | )} 21 |
22 | 23 |

24 | {isDocumentNotFound ? '文档不存在' : '无权访问此文档'} 25 |

26 | 27 |

28 | {isDocumentNotFound 29 | ? '您尝试访问的文档不存在或已被删除。' 30 | : '您没有编辑此文档的权限,请联系文档所有者获取访问权限。'} 31 |

32 | 33 | 37 | 返回文档列表 38 | 39 |
40 | ); 41 | }; 42 | 43 | export default NoPermissionView; 44 | -------------------------------------------------------------------------------- /src/app/docs/layout.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useState, useEffect } from 'react'; 4 | import dynamic from 'next/dynamic'; 5 | 6 | const DynamicTabSidebar = dynamic(() => import('@/components/layout/TabSidebar'), { 7 | ssr: false, 8 | }); 9 | 10 | export default function DocsLayout({ children }: { children: React.ReactNode }) { 11 | const [isSidebarOpen, setSidebarOpen] = useState(true); 12 | const [mounted, setMounted] = useState(false); 13 | 14 | useEffect(() => { 15 | setMounted(true); 16 | }, []); 17 | 18 | const toggleSidebar = () => { 19 | setSidebarOpen(!isSidebarOpen); 20 | }; 21 | 22 | if (!mounted) { 23 | return ( 24 |
25 |
{children}
26 |
27 | ); 28 | } 29 | 30 | return ( 31 |
32 | {mounted && } 33 |
{children}
34 |
35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /src/app/docs/page.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const Page = () => { 4 | return
Page1111
; 5 | }; 6 | 7 | export default Page; 8 | -------------------------------------------------------------------------------- /src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from 'next'; 2 | import { Toaster } from 'sonner'; 3 | 4 | import 'cal-sans'; 5 | import '@/styles/index.css'; 6 | 7 | import '@fontsource/inter/100.css'; 8 | import '@fontsource/inter/200.css'; 9 | import '@fontsource/inter/300.css'; 10 | import '@fontsource/inter/400.css'; 11 | import '@fontsource/inter/500.css'; 12 | import '@fontsource/inter/600.css'; 13 | import '@fontsource/inter/700.css'; 14 | 15 | export const metadata: Metadata = { 16 | metadataBase: new URL('https://demos.tiptap.dev'), 17 | title: 'Tiptap block editor template', 18 | description: 19 | 'Tiptap is a suite of open source content editing and real-time collaboration tools for developers building apps like Notion or Google Docs.', 20 | robots: 'noindex, nofollow', 21 | icons: [{ url: '/favicon.svg' }], 22 | twitter: { 23 | card: 'summary_large_image', 24 | site: '@tiptap_editor', 25 | creator: '@tiptap_editor', 26 | }, 27 | openGraph: { 28 | title: 'Tiptap block editor template', 29 | description: 30 | 'Tiptap is a suite of open source content editing and real-time collaboration tools for developers building apps like Notion or Google Docs.', 31 | }, 32 | }; 33 | 34 | export default function RootLayout({ children }: { children: React.ReactNode }) { 35 | return ( 36 | 37 | 38 |
{children}
39 | 40 | 41 | 42 | ); 43 | } 44 | -------------------------------------------------------------------------------- /src/app/opengraph-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xun082/DocFlow/64736d578ed9ca848073b372a141f7cf4863f718/src/app/opengraph-image.png -------------------------------------------------------------------------------- /src/app/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import React from 'react'; 4 | import Link from 'next/link'; 5 | import { useRouter } from 'next/navigation'; 6 | 7 | const Page = () => { 8 | const router = useRouter(); 9 | 10 | return ( 11 |
12 |

Tiptap 协作编辑器

13 | 14 |
15 | 19 | 进入文档1 20 | 21 | 22 | 28 |
29 |
30 | ); 31 | }; 32 | 33 | export default Page; 34 | -------------------------------------------------------------------------------- /src/app/robots.ts: -------------------------------------------------------------------------------- 1 | import { MetadataRoute } from 'next'; 2 | 3 | export default function robots(): MetadataRoute.Robots { 4 | return { 5 | rules: { 6 | allow: '*', 7 | }, 8 | }; 9 | } 10 | -------------------------------------------------------------------------------- /src/components/FileExplorer/index.ts: -------------------------------------------------------------------------------- 1 | export { default as FileExplorer } from './FileExplorer'; 2 | -------------------------------------------------------------------------------- /src/components/layout/TabSidebar/tabs/BlocksTab.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { icons } from 'lucide-react'; 3 | 4 | import { Icon } from '@/components/ui/Icon'; 5 | 6 | interface BlockItemProps { 7 | icon: keyof typeof icons; 8 | label: string; 9 | blockType: string; 10 | onDragStart: (event: React.DragEvent, blockType: string) => void; 11 | } 12 | 13 | const BlockItem: React.FC = ({ icon, label, blockType, onDragStart }) => { 14 | return ( 15 |
onDragStart(e, blockType)} 19 | data-block-type={blockType} 20 | > 21 | 22 |
{label}
23 |
24 | ); 25 | }; 26 | 27 | const BlocksTab = () => { 28 | // Handle drag start event 29 | const handleDragStart = (event: React.DragEvent, blockType: string) => { 30 | // Set data for the drag event 31 | event.dataTransfer.setData('application/x-block-type', blockType); 32 | event.dataTransfer.effectAllowed = 'copy'; 33 | 34 | // Create a custom drag image 35 | const dragImage = document.createElement('div'); 36 | dragImage.className = 'drag-image'; 37 | dragImage.textContent = blockType; 38 | dragImage.style.position = 'absolute'; 39 | dragImage.style.top = '-1000px'; 40 | document.body.appendChild(dragImage); 41 | 42 | event.dataTransfer.setDragImage(dragImage, 0, 0); 43 | 44 | // Clean up the drag image after dragging 45 | setTimeout(() => { 46 | document.body.removeChild(dragImage); 47 | }, 0); 48 | }; 49 | 50 | return ( 51 |
52 |
内容区块
53 |
54 | 55 | 56 | 57 | 58 | 64 | 65 | 66 |
67 |
68 | ); 69 | }; 70 | 71 | export default BlocksTab; 72 | -------------------------------------------------------------------------------- /src/components/layout/TabSidebar/tabs/SearchTab.tsx: -------------------------------------------------------------------------------- 1 | import { Icon } from '@/components/ui/Icon'; 2 | 3 | interface SearchTabProps { 4 | isActive: boolean; 5 | } 6 | 7 | const SearchTab = ({ isActive }: SearchTabProps) => { 8 | return ( 9 |
10 |
11 | 17 | 18 |
19 |
输入关键词搜索文档内容
20 |
21 | ); 22 | }; 23 | 24 | export default SearchTab; 25 | -------------------------------------------------------------------------------- /src/components/layout/TabSidebar/tabs/SettingsTab.tsx: -------------------------------------------------------------------------------- 1 | const SettingsTab = () => { 2 | return
文档设置
; 3 | }; 4 | 5 | export default SettingsTab; 6 | -------------------------------------------------------------------------------- /src/components/layout/TabSidebar/tabs/TemplatesTab.tsx: -------------------------------------------------------------------------------- 1 | const TemplatesTab = () => { 2 | return ( 3 |
4 |
文档模板
5 |
6 |
7 |
空白文档
8 |
从零开始创建内容
9 |
10 |
11 |
会议记录
12 |
包含标题、议程和决策点
13 |
14 |
15 |
知识库
16 |
结构化的文档组织
17 |
18 |
19 |
20 | ); 21 | }; 22 | 23 | export default TemplatesTab; 24 | -------------------------------------------------------------------------------- /src/components/layout/TabSidebar/tabs/folder/hooks/useFileOperations.ts: -------------------------------------------------------------------------------- 1 | import { useCallback } from 'react'; 2 | import { toast } from 'sonner'; 3 | 4 | import { FileItem } from '../type'; 5 | 6 | import DocumentApi from '@/services/document'; 7 | 8 | interface UseFileOperationsReturn { 9 | handleShare: (file: FileItem) => void; 10 | handleDownload: (file: FileItem) => Promise; 11 | handleDuplicate: (file: FileItem) => Promise; 12 | handleDelete: (file: FileItem) => Promise; 13 | } 14 | 15 | export const useFileOperations = (refreshFiles: () => Promise): UseFileOperationsReturn => { 16 | // 处理文件分享 17 | const handleShare = useCallback((file: FileItem) => { 18 | // 这个会在主组件中处理,因为涉及到状态管理 19 | console.log('Share file:', file); 20 | }, []); 21 | 22 | // 处理文件下载 23 | const handleDownload = useCallback(async (file: FileItem) => { 24 | try { 25 | const response = await DocumentApi.DownloadDocument(parseInt(file.id)); 26 | 27 | if (response?.data?.data) { 28 | // 创建下载链接 29 | const blob = response.data.data as unknown as Blob; 30 | const url = window.URL.createObjectURL(blob); 31 | const link = document.createElement('a'); 32 | link.href = url; 33 | link.download = file.name; 34 | document.body.appendChild(link); 35 | link.click(); 36 | document.body.removeChild(link); 37 | window.URL.revokeObjectURL(url); 38 | 39 | toast.success(`文件 "${file.name}" 下载成功`); 40 | } 41 | } catch (error) { 42 | console.error('下载文件失败:', error); 43 | toast.error('下载文件失败,请重试'); 44 | } 45 | }, []); 46 | 47 | // 处理文件复制 48 | const handleDuplicate = useCallback( 49 | async (file: FileItem) => { 50 | try { 51 | const response = await DocumentApi.DuplicateDocument({ 52 | document_id: parseInt(file.id), 53 | title: `${file.name} - 副本`, 54 | }); 55 | 56 | if (response?.data?.code === 201) { 57 | // 刷新文件列表 58 | await refreshFiles(); 59 | toast.success(`文件 "${file.name}" 已复制`); 60 | } 61 | } catch (error) { 62 | console.error('复制文件失败:', error); 63 | toast.error('复制文件失败,请重试'); 64 | } 65 | }, 66 | [refreshFiles], 67 | ); 68 | 69 | // 处理文件删除 70 | const handleDelete = useCallback( 71 | async (file: FileItem) => { 72 | if (confirm(`确定要删除 "${file.name}" 吗?`)) { 73 | try { 74 | const response = await DocumentApi.DeleteDocument({ 75 | document_id: parseInt(file.id), 76 | permanent: false, // 软删除 77 | }); 78 | 79 | if (response?.data?.data?.success) { 80 | // 刷新文件列表 81 | await refreshFiles(); 82 | toast.success(`文件 "${file.name}" 已删除`); 83 | } 84 | } catch (error) { 85 | console.error('删除文件失败:', error); 86 | toast.error('删除文件失败,请重试'); 87 | } 88 | } 89 | }, 90 | [refreshFiles], 91 | ); 92 | 93 | return { 94 | handleShare, 95 | handleDownload, 96 | handleDuplicate, 97 | handleDelete, 98 | }; 99 | }; 100 | -------------------------------------------------------------------------------- /src/components/layout/TabSidebar/tabs/folder/type.ts: -------------------------------------------------------------------------------- 1 | // 文件/文件夹类型 2 | export type FileItem = { 3 | id: string; 4 | name: string; 5 | type: 'file' | 'folder'; 6 | children?: FileItem[]; 7 | is_starred?: boolean; 8 | created_at?: string; 9 | updated_at?: string; 10 | }; 11 | 12 | // 搜索结果项 13 | export type SearchResultItem = { 14 | item: FileItem; 15 | path: string[]; 16 | ancestors: string[]; 17 | }; 18 | 19 | export interface FileExplorerProps { 20 | initialFiles?: FileItem[]; 21 | onFileSelect?: (file: FileItem) => void; 22 | } 23 | -------------------------------------------------------------------------------- /src/components/menus/ContentItemMenu/ContentItemMenu.tsx: -------------------------------------------------------------------------------- 1 | import DragHandle from '@tiptap-pro/extension-drag-handle-react'; 2 | import { Editor } from '@tiptap/react'; 3 | import * as Popover from '@radix-ui/react-popover'; 4 | import { useEffect, useState } from 'react'; 5 | 6 | import useContentItemActions from './hooks/useContentItemActions'; 7 | import { useData } from './hooks/useData'; 8 | 9 | import { Icon } from '@/components/ui/Icon'; 10 | import { Toolbar } from '@/components/ui/Toolbar'; 11 | import { Surface } from '@/components/ui/Surface'; 12 | import { DropdownButton } from '@/components/ui/Dropdown'; 13 | 14 | export type ContentItemMenuProps = { 15 | editor: Editor; 16 | isEditable?: boolean; 17 | }; 18 | 19 | export const ContentItemMenu = ({ editor, isEditable = true }: ContentItemMenuProps) => { 20 | const [menuOpen, setMenuOpen] = useState(false); 21 | const data = useData(); 22 | const actions = useContentItemActions(editor, data.currentNode, data.currentNodePos); 23 | 24 | useEffect(() => { 25 | if (menuOpen) { 26 | editor.commands.setMeta('lockDragHandle', true); 27 | } else { 28 | editor.commands.setMeta('lockDragHandle', false); 29 | } 30 | }, [editor, menuOpen]); 31 | 32 | return ( 33 | 42 | {isEditable ? ( 43 |
44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | Clear formatting 59 | 60 | 61 | 62 | 63 | 64 | Copy to clipboard 65 | 66 | 67 | 68 | 69 | 70 | Duplicate 71 | 72 | 73 | 74 | 75 | 79 | 80 | Delete 81 | 82 | 83 | 84 | 85 | 86 |
87 | ) : null} 88 |
89 | ); 90 | }; 91 | -------------------------------------------------------------------------------- /src/components/menus/ContentItemMenu/hooks/useContentItemActions.tsx: -------------------------------------------------------------------------------- 1 | import { Node } from '@tiptap/pm/model'; 2 | import { NodeSelection } from '@tiptap/pm/state'; 3 | import { Editor } from '@tiptap/react'; 4 | import { useCallback } from 'react'; 5 | 6 | const useContentItemActions = ( 7 | editor: Editor, 8 | currentNode: Node | null, 9 | currentNodePos: number, 10 | ) => { 11 | const resetTextFormatting = useCallback(() => { 12 | const chain = editor.chain(); 13 | 14 | chain.setNodeSelection(currentNodePos).unsetAllMarks(); 15 | 16 | if (currentNode?.type.name !== 'paragraph') { 17 | chain.setParagraph(); 18 | } 19 | 20 | chain.run(); 21 | }, [editor, currentNodePos, currentNode?.type.name]); 22 | 23 | const duplicateNode = useCallback(() => { 24 | editor.commands.setNodeSelection(currentNodePos); 25 | 26 | const { $anchor } = editor.state.selection; 27 | const selectedNode = $anchor.node(1) || (editor.state.selection as NodeSelection).node; 28 | 29 | editor 30 | .chain() 31 | .setMeta('hideDragHandle', true) 32 | .insertContentAt(currentNodePos + (currentNode?.nodeSize || 0), selectedNode.toJSON()) 33 | .run(); 34 | }, [editor, currentNodePos, currentNode?.nodeSize]); 35 | 36 | const copyNodeToClipboard = useCallback(() => { 37 | editor.chain().setMeta('hideDragHandle', true).setNodeSelection(currentNodePos).run(); 38 | 39 | document.execCommand('copy'); 40 | }, [editor, currentNodePos]); 41 | 42 | const deleteNode = useCallback(() => { 43 | editor 44 | .chain() 45 | .setMeta('hideDragHandle', true) 46 | .setNodeSelection(currentNodePos) 47 | .deleteSelection() 48 | .run(); 49 | }, [editor, currentNodePos]); 50 | 51 | const handleAdd = useCallback(() => { 52 | if (currentNodePos !== -1) { 53 | const currentNodeSize = currentNode?.nodeSize || 0; 54 | const insertPos = currentNodePos + currentNodeSize; 55 | const currentNodeIsEmptyParagraph = 56 | currentNode?.type.name === 'paragraph' && currentNode?.content?.size === 0; 57 | const focusPos = currentNodeIsEmptyParagraph ? currentNodePos + 2 : insertPos + 2; 58 | 59 | editor 60 | .chain() 61 | .command(({ dispatch, tr, state }) => { 62 | if (dispatch) { 63 | if (currentNodeIsEmptyParagraph) { 64 | tr.insertText('/', currentNodePos, currentNodePos + 1); 65 | } else { 66 | tr.insert( 67 | insertPos, 68 | state.schema.nodes.paragraph.create(null, [state.schema.text('/')]), 69 | ); 70 | } 71 | 72 | return dispatch(tr); 73 | } 74 | 75 | return true; 76 | }) 77 | .focus(focusPos) 78 | .run(); 79 | } 80 | }, [currentNode, currentNodePos, editor]); 81 | 82 | return { 83 | resetTextFormatting, 84 | duplicateNode, 85 | copyNodeToClipboard, 86 | deleteNode, 87 | handleAdd, 88 | }; 89 | }; 90 | 91 | export default useContentItemActions; 92 | -------------------------------------------------------------------------------- /src/components/menus/ContentItemMenu/hooks/useData.tsx: -------------------------------------------------------------------------------- 1 | import { Node } from '@tiptap/pm/model'; 2 | import { Editor } from '@tiptap/core'; 3 | import { useCallback, useState } from 'react'; 4 | 5 | export const useData = () => { 6 | const [currentNode, setCurrentNode] = useState(null); 7 | const [currentNodePos, setCurrentNodePos] = useState(-1); 8 | 9 | const handleNodeChange = useCallback( 10 | (data: { node: Node | null; editor: Editor; pos: number }) => { 11 | if (data.node) { 12 | setCurrentNode(data.node); 13 | } 14 | 15 | setCurrentNodePos(data.pos); 16 | }, 17 | [setCurrentNodePos, setCurrentNode], 18 | ); 19 | 20 | return { 21 | currentNode, 22 | currentNodePos, 23 | setCurrentNode, 24 | setCurrentNodePos, 25 | handleNodeChange, 26 | }; 27 | }; 28 | -------------------------------------------------------------------------------- /src/components/menus/ContentItemMenu/index.tsx: -------------------------------------------------------------------------------- 1 | export * from './ContentItemMenu'; 2 | -------------------------------------------------------------------------------- /src/components/menus/LinkMenu/LinkMenu.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useState, JSX } from 'react'; 2 | import { BubbleMenu as BaseBubbleMenu, useEditorState } from '@tiptap/react'; 3 | 4 | import { MenuProps } from '../types'; 5 | 6 | import { LinkPreviewPanel } from '@/components/panels/LinkPreviewPanel'; 7 | import { LinkEditorPanel } from '@/components/panels'; 8 | 9 | export const LinkMenu = ({ editor, appendTo }: MenuProps): JSX.Element => { 10 | const [showEdit, setShowEdit] = useState(false); 11 | const { link, target } = useEditorState({ 12 | editor, 13 | selector: (ctx) => { 14 | const attrs = ctx.editor.getAttributes('link'); 15 | 16 | return { link: attrs.href, target: attrs.target }; 17 | }, 18 | }); 19 | 20 | const shouldShow = useCallback(() => { 21 | const isActive = editor.isActive('link'); 22 | 23 | return isActive; 24 | }, [editor]); 25 | 26 | const handleEdit = useCallback(() => { 27 | setShowEdit(true); 28 | }, []); 29 | 30 | const onSetLink = useCallback( 31 | (url: string, openInNewTab?: boolean) => { 32 | editor 33 | .chain() 34 | .focus() 35 | .extendMarkRange('link') 36 | .setLink({ href: url, target: openInNewTab ? '_blank' : '' }) 37 | .run(); 38 | setShowEdit(false); 39 | }, 40 | [editor], 41 | ); 42 | 43 | const onUnsetLink = useCallback(() => { 44 | editor.chain().focus().extendMarkRange('link').unsetLink().run(); 45 | setShowEdit(false); 46 | 47 | return null; 48 | }, [editor]); 49 | 50 | return ( 51 | { 61 | return appendTo?.current; 62 | }, 63 | onHidden: () => { 64 | setShowEdit(false); 65 | }, 66 | }} 67 | > 68 | {showEdit ? ( 69 | 74 | ) : ( 75 | 76 | )} 77 | 78 | ); 79 | }; 80 | 81 | export default LinkMenu; 82 | -------------------------------------------------------------------------------- /src/components/menus/LinkMenu/index.tsx: -------------------------------------------------------------------------------- 1 | export { LinkMenu } from './LinkMenu'; 2 | -------------------------------------------------------------------------------- /src/components/menus/TextMenu/components/ContentTypePicker.tsx: -------------------------------------------------------------------------------- 1 | import { icons } from 'lucide-react'; 2 | import { useMemo } from 'react'; 3 | import * as Dropdown from '@radix-ui/react-dropdown-menu'; 4 | 5 | import { Icon } from '@/components/ui/Icon'; 6 | import { Toolbar } from '@/components/ui/Toolbar'; 7 | import { Surface } from '@/components/ui/Surface'; 8 | import { DropdownButton, DropdownCategoryTitle } from '@/components/ui/Dropdown'; 9 | 10 | export type ContentTypePickerOption = { 11 | label: string; 12 | id: string; 13 | type: 'option'; 14 | disabled: () => boolean; 15 | isActive: () => boolean; 16 | onClick: () => void; 17 | icon: keyof typeof icons; 18 | }; 19 | 20 | export type ContentTypePickerCategory = { 21 | label: string; 22 | id: string; 23 | type: 'category'; 24 | }; 25 | 26 | export type ContentPickerOptions = Array; 27 | 28 | export type ContentTypePickerProps = { 29 | options: ContentPickerOptions; 30 | }; 31 | 32 | const isOption = ( 33 | option: ContentTypePickerOption | ContentTypePickerCategory, 34 | ): option is ContentTypePickerOption => option.type === 'option'; 35 | const isCategory = ( 36 | option: ContentTypePickerOption | ContentTypePickerCategory, 37 | ): option is ContentTypePickerCategory => option.type === 'category'; 38 | 39 | export const ContentTypePicker = ({ options }: ContentTypePickerProps) => { 40 | const activeItem = useMemo( 41 | () => options.find((option) => option.type === 'option' && option.isActive()), 42 | [options], 43 | ); 44 | 45 | return ( 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | {options.map((option) => { 56 | if (isOption(option)) { 57 | return ( 58 | 63 | 64 | {option.label} 65 | 66 | ); 67 | } else if (isCategory(option)) { 68 | return ( 69 |
70 | {option.label} 71 |
72 | ); 73 | } 74 | })} 75 |
76 |
77 |
78 | ); 79 | }; 80 | -------------------------------------------------------------------------------- /src/components/menus/TextMenu/components/EditLinkPopover.tsx: -------------------------------------------------------------------------------- 1 | import * as Popover from '@radix-ui/react-popover'; 2 | 3 | import { LinkEditorPanel } from '@/components/panels'; 4 | import { Icon } from '@/components/ui/Icon'; 5 | import { Toolbar } from '@/components/ui/Toolbar'; 6 | 7 | export type EditLinkPopoverProps = { 8 | onSetLink: (link: string, openInNewTab?: boolean) => void; 9 | }; 10 | 11 | export const EditLinkPopover = ({ onSetLink }: EditLinkPopoverProps) => { 12 | return ( 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | ); 24 | }; 25 | -------------------------------------------------------------------------------- /src/components/menus/TextMenu/components/FontFamilyPicker.tsx: -------------------------------------------------------------------------------- 1 | import * as Dropdown from '@radix-ui/react-dropdown-menu'; 2 | import { useCallback } from 'react'; 3 | 4 | import { DropdownButton, DropdownCategoryTitle } from '@/components/ui/Dropdown'; 5 | import { Icon } from '@/components/ui/Icon'; 6 | import { Surface } from '@/components/ui/Surface'; 7 | import { Toolbar } from '@/components/ui/Toolbar'; 8 | 9 | const FONT_FAMILY_GROUPS = [ 10 | { 11 | label: 'Sans Serif', 12 | options: [ 13 | { label: 'Inter', value: '' }, 14 | { label: 'Arial', value: 'Arial' }, 15 | { label: 'Helvetica', value: 'Helvetica' }, 16 | ], 17 | }, 18 | { 19 | label: 'Serif', 20 | options: [ 21 | { label: 'Times New Roman', value: 'Times' }, 22 | { label: 'Garamond', value: 'Garamond' }, 23 | { label: 'Georgia', value: 'Georgia' }, 24 | ], 25 | }, 26 | { 27 | label: 'Monospace', 28 | options: [ 29 | { label: 'Courier', value: 'Courier' }, 30 | { label: 'Courier New', value: 'Courier New' }, 31 | ], 32 | }, 33 | ]; 34 | 35 | const FONT_FAMILIES = FONT_FAMILY_GROUPS.flatMap((group) => [group.options]).flat(); 36 | 37 | export type FontFamilyPickerProps = { 38 | onChange: (value: string) => void; 39 | value: string; 40 | }; 41 | 42 | export const FontFamilyPicker = ({ onChange, value }: FontFamilyPickerProps) => { 43 | const currentValue = FONT_FAMILIES.find((size) => size.value === value); 44 | const currentFontLabel = currentValue?.label.split(' ')[0] || 'Inter'; 45 | 46 | const selectFont = useCallback((font: string) => () => onChange(font), [onChange]); 47 | 48 | return ( 49 | 50 | 51 | 52 | {currentFontLabel} 53 | 54 | 55 | 56 | 57 | 58 | {FONT_FAMILY_GROUPS.map((group) => ( 59 |
60 | {group.label} 61 | {group.options.map((font) => ( 62 | 67 | {font.label} 68 | 69 | ))} 70 |
71 | ))} 72 |
73 |
74 |
75 | ); 76 | }; 77 | -------------------------------------------------------------------------------- /src/components/menus/TextMenu/components/FontSizePicker.tsx: -------------------------------------------------------------------------------- 1 | import * as Dropdown from '@radix-ui/react-dropdown-menu'; 2 | import { useCallback } from 'react'; 3 | 4 | import { DropdownButton } from '@/components/ui/Dropdown'; 5 | import { Icon } from '@/components/ui/Icon'; 6 | import { Surface } from '@/components/ui/Surface'; 7 | import { Toolbar } from '@/components/ui/Toolbar'; 8 | 9 | const FONT_SIZES = [ 10 | { label: 'Smaller', value: '12px' }, 11 | { label: 'Small', value: '14px' }, 12 | { label: 'Medium', value: '' }, 13 | { label: 'Large', value: '18px' }, 14 | { label: 'Extra Large', value: '24px' }, 15 | ]; 16 | 17 | export type FontSizePickerProps = { 18 | onChange: (value: string) => void; 19 | value: string; 20 | }; 21 | 22 | export const FontSizePicker = ({ onChange, value }: FontSizePickerProps) => { 23 | const currentValue = FONT_SIZES.find((size) => size.value === value); 24 | const currentSizeLabel = currentValue?.label.split(' ')[0] || 'Medium'; 25 | 26 | const selectSize = useCallback((size: string) => () => onChange(size), [onChange]); 27 | 28 | return ( 29 | 30 | 31 | 32 | {currentSizeLabel} 33 | 34 | 35 | 36 | 37 | 38 | {FONT_SIZES.map((size) => ( 39 | 44 | {size.label} 45 | 46 | ))} 47 | 48 | 49 | 50 | ); 51 | }; 52 | -------------------------------------------------------------------------------- /src/components/menus/TextMenu/hooks/useTextMenuCommands.ts: -------------------------------------------------------------------------------- 1 | import { Editor } from '@tiptap/react'; 2 | import { useCallback } from 'react'; 3 | 4 | export const useTextMenuCommands = (editor: Editor) => { 5 | const onBold = useCallback(() => editor.chain().focus().toggleBold().run(), [editor]); 6 | const onItalic = useCallback(() => editor.chain().focus().toggleItalic().run(), [editor]); 7 | const onStrike = useCallback(() => editor.chain().focus().toggleStrike().run(), [editor]); 8 | const onUnderline = useCallback(() => editor.chain().focus().toggleUnderline().run(), [editor]); 9 | const onCode = useCallback(() => editor.chain().focus().toggleCode().run(), [editor]); 10 | const onCodeBlock = useCallback(() => editor.chain().focus().toggleCodeBlock().run(), [editor]); 11 | 12 | const onSubscript = useCallback(() => editor.chain().focus().toggleSubscript().run(), [editor]); 13 | const onSuperscript = useCallback( 14 | () => editor.chain().focus().toggleSuperscript().run(), 15 | [editor], 16 | ); 17 | const onAlignLeft = useCallback( 18 | () => editor.chain().focus().setTextAlign('left').run(), 19 | [editor], 20 | ); 21 | const onAlignCenter = useCallback( 22 | () => editor.chain().focus().setTextAlign('center').run(), 23 | [editor], 24 | ); 25 | const onAlignRight = useCallback( 26 | () => editor.chain().focus().setTextAlign('right').run(), 27 | [editor], 28 | ); 29 | const onAlignJustify = useCallback( 30 | () => editor.chain().focus().setTextAlign('justify').run(), 31 | [editor], 32 | ); 33 | 34 | const onChangeColor = useCallback( 35 | (color: string) => editor.chain().setColor(color).run(), 36 | [editor], 37 | ); 38 | const onClearColor = useCallback(() => editor.chain().focus().unsetColor().run(), [editor]); 39 | 40 | const onChangeHighlight = useCallback( 41 | (color: string) => editor.chain().setHighlight({ color }).run(), 42 | [editor], 43 | ); 44 | const onClearHighlight = useCallback( 45 | () => editor.chain().focus().unsetHighlight().run(), 46 | [editor], 47 | ); 48 | 49 | const onLink = useCallback( 50 | (url: string, inNewTab?: boolean) => 51 | editor 52 | .chain() 53 | .focus() 54 | .setLink({ href: url, target: inNewTab ? '_blank' : '' }) 55 | .run(), 56 | [editor], 57 | ); 58 | 59 | const onSetFont = useCallback( 60 | (font: string) => { 61 | if (!font || font.length === 0) { 62 | return editor.chain().focus().unsetFontFamily().run(); 63 | } 64 | 65 | return editor.chain().focus().setFontFamily(font).run(); 66 | }, 67 | [editor], 68 | ); 69 | 70 | const onSetFontSize = useCallback( 71 | (fontSize: string) => { 72 | if (!fontSize || fontSize.length === 0) { 73 | return editor.chain().focus().unsetFontSize().run(); 74 | } 75 | 76 | return editor.chain().focus().setFontSize(fontSize).run(); 77 | }, 78 | [editor], 79 | ); 80 | 81 | return { 82 | onBold, 83 | onItalic, 84 | onStrike, 85 | onUnderline, 86 | onCode, 87 | onCodeBlock, 88 | onSubscript, 89 | onSuperscript, 90 | onAlignLeft, 91 | onAlignCenter, 92 | onAlignRight, 93 | onAlignJustify, 94 | onChangeColor, 95 | onClearColor, 96 | onChangeHighlight, 97 | onClearHighlight, 98 | onSetFont, 99 | onSetFontSize, 100 | onLink, 101 | }; 102 | }; 103 | -------------------------------------------------------------------------------- /src/components/menus/TextMenu/index.tsx: -------------------------------------------------------------------------------- 1 | export * from './TextMenu'; 2 | -------------------------------------------------------------------------------- /src/components/menus/index.ts: -------------------------------------------------------------------------------- 1 | export * from './LinkMenu'; 2 | export * from './TextMenu'; 3 | export * from './ContentItemMenu'; 4 | -------------------------------------------------------------------------------- /src/components/menus/types.ts: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Editor as CoreEditor } from '@tiptap/core'; 3 | import { Editor } from '@tiptap/react'; 4 | import { EditorState } from '@tiptap/pm/state'; 5 | import { EditorView } from '@tiptap/pm/view'; 6 | 7 | export interface MenuProps { 8 | editor: Editor; 9 | appendTo?: React.RefObject; 10 | shouldHide?: boolean; 11 | } 12 | 13 | export interface ShouldShowProps { 14 | editor?: CoreEditor; 15 | view: EditorView; 16 | state?: EditorState; 17 | oldState?: EditorState; 18 | from?: number; 19 | to?: number; 20 | } 21 | -------------------------------------------------------------------------------- /src/components/panels/Colorpicker/ColorButton.tsx: -------------------------------------------------------------------------------- 1 | import { memo, useCallback } from 'react'; 2 | 3 | import { cn } from '@/utils/utils'; 4 | 5 | export type ColorButtonProps = { 6 | color?: string; 7 | active?: boolean; 8 | onColorChange?: (color: string) => void; 9 | }; 10 | 11 | export const ColorButton = memo(({ color, active, onColorChange }: ColorButtonProps) => { 12 | const wrapperClassName = cn( 13 | 'flex items-center justify-center px-1.5 py-1.5 rounded group', 14 | !active && 'hover:bg-neutral-100', 15 | active && 'bg-neutral-100', 16 | ); 17 | const bubbleClassName = cn( 18 | 'w-4 h-4 rounded bg-slate-100 shadow-sm ring-offset-2 ring-current', 19 | !active && `hover:ring-1`, 20 | active && `ring-1`, 21 | ); 22 | 23 | const handleClick = useCallback(() => { 24 | if (onColorChange) { 25 | onColorChange(color || ''); 26 | } 27 | }, [onColorChange, color]); 28 | 29 | return ( 30 | 33 | ); 34 | }); 35 | 36 | ColorButton.displayName = 'ColorButton'; 37 | -------------------------------------------------------------------------------- /src/components/panels/Colorpicker/Colorpicker.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useState } from 'react'; 2 | import { HexColorPicker } from 'react-colorful'; 3 | 4 | import { ColorButton } from './ColorButton'; 5 | import { Toolbar } from '../../ui/Toolbar'; 6 | import { Icon } from '../../ui/Icon'; 7 | 8 | import { themeColors } from '@/utils/constants'; 9 | 10 | export type ColorPickerProps = { 11 | color?: string; 12 | onChange?: (color: string) => void; 13 | onClear?: () => void; 14 | }; 15 | 16 | export const ColorPicker = ({ color, onChange, onClear }: ColorPickerProps) => { 17 | const [colorInputValue, setColorInputValue] = useState(color || ''); 18 | 19 | const handleColorUpdate = useCallback((event: React.ChangeEvent) => { 20 | setColorInputValue(event.target.value); 21 | }, []); 22 | 23 | const handleColorChange = useCallback(() => { 24 | const isCorrectColor = /^#([0-9A-F]{3}){1,2}$/i.test(colorInputValue); 25 | 26 | if (!isCorrectColor) { 27 | if (onChange) { 28 | onChange(''); 29 | } 30 | 31 | return; 32 | } 33 | 34 | if (onChange) { 35 | onChange(colorInputValue); 36 | } 37 | }, [colorInputValue, onChange]); 38 | 39 | return ( 40 |
41 | 42 | 50 |
51 | {themeColors.map((currentColor) => ( 52 | 58 | ))} 59 | 60 | 61 | 62 |
63 |
64 | ); 65 | }; 66 | -------------------------------------------------------------------------------- /src/components/panels/Colorpicker/index.tsx: -------------------------------------------------------------------------------- 1 | export * from './Colorpicker'; 2 | -------------------------------------------------------------------------------- /src/components/panels/LinkEditorPanel/LinkEditorPanel.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useCallback, useMemo } from 'react'; 2 | 3 | import { Button } from '@/components/ui/button'; 4 | import { Icon } from '@/components/ui/Icon'; 5 | import { Surface } from '@/components/ui/Surface'; 6 | import { Toggle } from '@/components/ui/Toggle'; 7 | 8 | export type LinkEditorPanelProps = { 9 | initialUrl?: string; 10 | initialOpenInNewTab?: boolean; 11 | onSetLink: (url: string, openInNewTab?: boolean) => void; 12 | }; 13 | 14 | export const useLinkEditorState = ({ 15 | initialUrl, 16 | initialOpenInNewTab, 17 | onSetLink, 18 | }: LinkEditorPanelProps) => { 19 | const [url, setUrl] = useState(initialUrl || ''); 20 | const [openInNewTab, setOpenInNewTab] = useState(initialOpenInNewTab || false); 21 | 22 | const onChange = useCallback((event: React.ChangeEvent) => { 23 | setUrl(event.target.value); 24 | }, []); 25 | 26 | const isValidUrl = useMemo(() => /^(\S+):(\/\/)?\S+$/.test(url), [url]); 27 | 28 | const handleSubmit = useCallback( 29 | (e: React.FormEvent) => { 30 | e.preventDefault(); 31 | 32 | if (isValidUrl) { 33 | onSetLink(url, openInNewTab); 34 | } 35 | }, 36 | [url, isValidUrl, openInNewTab, onSetLink], 37 | ); 38 | 39 | return { 40 | url, 41 | setUrl, 42 | openInNewTab, 43 | setOpenInNewTab, 44 | onChange, 45 | handleSubmit, 46 | isValidUrl, 47 | }; 48 | }; 49 | 50 | export const LinkEditorPanel = ({ 51 | onSetLink, 52 | initialOpenInNewTab, 53 | initialUrl, 54 | }: LinkEditorPanelProps) => { 55 | const state = useLinkEditorState({ onSetLink, initialOpenInNewTab, initialUrl }); 56 | 57 | return ( 58 | 59 |
60 | 70 | 73 |
74 |
75 | 79 |
80 |
81 | ); 82 | }; 83 | -------------------------------------------------------------------------------- /src/components/panels/LinkEditorPanel/index.tsx: -------------------------------------------------------------------------------- 1 | export * from './LinkEditorPanel'; 2 | -------------------------------------------------------------------------------- /src/components/panels/LinkPreviewPanel/LinkPreviewPanel.tsx: -------------------------------------------------------------------------------- 1 | import { Icon } from '@/components/ui/Icon'; 2 | import { Surface } from '@/components/ui/Surface'; 3 | import { Toolbar } from '@/components/ui/Toolbar'; 4 | import Tooltip from '@/components/ui/Tooltip'; 5 | 6 | export type LinkPreviewPanelProps = { 7 | url: string; 8 | onEdit: () => void; 9 | onClear: () => void; 10 | }; 11 | 12 | export const LinkPreviewPanel = ({ onClear, onEdit, url }: LinkPreviewPanelProps) => { 13 | const sanitizedLink = url?.startsWith('javascript:') ? '' : url; 14 | 15 | return ( 16 | 17 | 23 | {url} 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | ); 38 | }; 39 | -------------------------------------------------------------------------------- /src/components/panels/LinkPreviewPanel/index.tsx: -------------------------------------------------------------------------------- 1 | export * from './LinkPreviewPanel'; 2 | -------------------------------------------------------------------------------- /src/components/panels/index.tsx: -------------------------------------------------------------------------------- 1 | export * from './Colorpicker'; 2 | export * from './LinkEditorPanel'; 3 | export * from './LinkPreviewPanel'; 4 | -------------------------------------------------------------------------------- /src/components/ui/BubbleMenu.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState, ReactNode, useRef } from 'react'; 2 | import * as Popover from '@radix-ui/react-popover'; 3 | import { Editor } from '@tiptap/core'; 4 | 5 | interface BubbleMenuProps { 6 | editor: Editor; 7 | children: ReactNode; 8 | shouldShow?: () => boolean; 9 | updateDelay?: number; 10 | pluginKey?: string; 11 | getReferenceClientRect?: () => DOMRect; 12 | } 13 | 14 | export const BubbleMenu: React.FC = ({ 15 | editor, 16 | children, 17 | shouldShow = () => true, 18 | updateDelay = 0, 19 | getReferenceClientRect, 20 | }) => { 21 | const [isOpen, setIsOpen] = useState(false); 22 | const [position, setPosition] = useState(null); 23 | const updateTimeout = useRef(null); 24 | 25 | useEffect(() => { 26 | const updatePosition = () => { 27 | if (!editor || !shouldShow()) { 28 | setIsOpen(false); 29 | 30 | return; 31 | } 32 | 33 | // Get the position 34 | let rect: DOMRect; 35 | 36 | if (getReferenceClientRect) { 37 | rect = getReferenceClientRect(); 38 | } else { 39 | const { ranges } = editor.state.selection; 40 | const from = Math.min(...ranges.map((range) => range.$from.pos)); 41 | const to = Math.max(...ranges.map((range) => range.$to.pos)); 42 | 43 | if (from === to) { 44 | setIsOpen(false); 45 | 46 | return; 47 | } 48 | 49 | const domResult = editor.view.domAtPos(from); 50 | const node = domResult.node as Element; 51 | rect = node.getBoundingClientRect(); 52 | } 53 | 54 | // Set position and open the menu 55 | setPosition(rect); 56 | setIsOpen(true); 57 | }; 58 | 59 | // Handle delayed updates 60 | const debouncedUpdate = () => { 61 | if (updateTimeout.current) { 62 | clearTimeout(updateTimeout.current); 63 | } 64 | 65 | updateTimeout.current = setTimeout(() => { 66 | updatePosition(); 67 | updateTimeout.current = null; 68 | }, updateDelay); 69 | }; 70 | 71 | // Subscribe to editor events 72 | editor.on('selectionUpdate', debouncedUpdate); 73 | editor.on('focus', debouncedUpdate); 74 | editor.on('blur', () => setIsOpen(false)); 75 | editor.on('update', debouncedUpdate); 76 | 77 | // Initial position update 78 | updatePosition(); 79 | 80 | return () => { 81 | // Cleanup 82 | editor.off('selectionUpdate', debouncedUpdate); 83 | editor.off('focus', debouncedUpdate); 84 | editor.off('blur', () => setIsOpen(false)); 85 | editor.off('update', debouncedUpdate); 86 | 87 | if (updateTimeout.current) { 88 | clearTimeout(updateTimeout.current); 89 | } 90 | }; 91 | }, [editor, shouldShow, updateDelay, getReferenceClientRect]); 92 | 93 | if (!position) { 94 | return null; 95 | } 96 | 97 | return ( 98 | 99 | 100 | 110 | {children} 111 | 112 | 113 | 114 | ); 115 | }; 116 | 117 | export default BubbleMenu; 118 | -------------------------------------------------------------------------------- /src/components/ui/Dropdown/Dropdown.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { cn } from '@/utils/utils'; 4 | 5 | export const DropdownCategoryTitle = ({ children }: { children: React.ReactNode }) => { 6 | return ( 7 |
8 | {children} 9 |
10 | ); 11 | }; 12 | 13 | export const DropdownButton = React.forwardRef< 14 | HTMLButtonElement, 15 | { 16 | children: React.ReactNode; 17 | isActive?: boolean; 18 | onClick?: (e: React.MouseEvent) => void; 19 | disabled?: boolean; 20 | className?: string; 21 | } 22 | >(function DropdownButtonInner({ children, isActive, onClick, disabled, className }, ref) { 23 | const buttonClass = cn( 24 | 'flex items-center gap-2 p-2 text-sm font-normal text-neutral-600 text-left bg-transparent w-full rounded-lg transition-all duration-150 ease-in-out', 25 | !isActive && !disabled, 26 | 'hover:bg-blue-50 hover:text-blue-600', 27 | isActive && !disabled && 'bg-blue-50 text-blue-600', 28 | disabled && 'text-neutral-300 cursor-not-allowed', 29 | className, 30 | ); 31 | 32 | return ( 33 | 36 | ); 37 | }); 38 | -------------------------------------------------------------------------------- /src/components/ui/Dropdown/index.tsx: -------------------------------------------------------------------------------- 1 | export * from './Dropdown'; 2 | -------------------------------------------------------------------------------- /src/components/ui/Icon.tsx: -------------------------------------------------------------------------------- 1 | import { icons } from 'lucide-react'; 2 | import { memo } from 'react'; 3 | 4 | import { cn } from '@/utils/utils'; 5 | 6 | export type IconProps = { 7 | name: keyof typeof icons; 8 | className?: string; 9 | strokeWidth?: number; 10 | }; 11 | 12 | export const Icon = memo(({ name, className, strokeWidth }: IconProps) => { 13 | const IconComponent = icons[name]; 14 | 15 | if (!IconComponent) { 16 | return null; 17 | } 18 | 19 | return ; 20 | }); 21 | 22 | Icon.displayName = 'Icon'; 23 | -------------------------------------------------------------------------------- /src/components/ui/Panel/index.tsx: -------------------------------------------------------------------------------- 1 | import { forwardRef } from 'react'; 2 | import { Slot } from '@radix-ui/react-slot'; 3 | 4 | import { Surface } from '../Surface'; 5 | 6 | import { cn } from '@/utils/utils'; 7 | 8 | export type PanelProps = { 9 | spacing?: 'medium' | 'small'; 10 | noShadow?: boolean; 11 | asChild?: boolean; 12 | } & React.HTMLAttributes; 13 | 14 | export const Panel = forwardRef( 15 | ({ asChild, className, children, spacing, noShadow, ...rest }, ref) => { 16 | const panelClass = cn('p-2', spacing === 'small' && 'p-[0.2rem]', className); 17 | 18 | const Comp = asChild ? Slot : 'div'; 19 | 20 | return ( 21 | 22 | 23 | {children} 24 | 25 | 26 | ); 27 | }, 28 | ); 29 | 30 | Panel.displayName = 'Panel'; 31 | 32 | export const PanelDivider = forwardRef< 33 | HTMLDivElement, 34 | { asChild?: boolean } & React.HTMLAttributes 35 | >(({ asChild, className, children, ...rest }, ref) => { 36 | const dividerClass = cn('border-b border-b-black/10 mb-2 pb-2', className); 37 | 38 | const Comp = asChild ? Slot : 'div'; 39 | 40 | return ( 41 | 42 | {children} 43 | 44 | ); 45 | }); 46 | 47 | PanelDivider.displayName = 'PanelDivider'; 48 | 49 | export const PanelHeader = forwardRef< 50 | HTMLDivElement, 51 | { asChild?: boolean } & React.HTMLAttributes 52 | >(({ asChild, className, children, ...rest }, ref) => { 53 | const headerClass = cn('border-b border-b-black/10 text-sm mb-2 pb-2', className); 54 | 55 | const Comp = asChild ? Slot : 'div'; 56 | 57 | return ( 58 | 59 | {children} 60 | 61 | ); 62 | }); 63 | 64 | PanelHeader.displayName = 'PanelHeader'; 65 | 66 | export const PanelSection = forwardRef< 67 | HTMLDivElement, 68 | { asChild?: boolean } & React.HTMLAttributes 69 | >(({ asChild, className, children, ...rest }, ref) => { 70 | const sectionClass = cn('mt-4 first:mt-1', className); 71 | 72 | const Comp = asChild ? Slot : 'div'; 73 | 74 | return ( 75 | 76 | {children} 77 | 78 | ); 79 | }); 80 | 81 | PanelSection.displayName = 'PanelSection'; 82 | 83 | export const PanelHeadline = forwardRef< 84 | HTMLDivElement, 85 | { asChild?: boolean } & React.HTMLAttributes 86 | >(({ asChild, className, children, ...rest }, ref) => { 87 | const headlineClass = cn( 88 | 'text-black/80 dark:text-white/80 text-xs font-medium mb-2 ml-1.5', 89 | className, 90 | ); 91 | 92 | const Comp = asChild ? Slot : 'div'; 93 | 94 | return ( 95 | 96 | {children} 97 | 98 | ); 99 | }); 100 | 101 | PanelHeadline.displayName = 'PanelHeadline'; 102 | 103 | export const PanelFooter = forwardRef< 104 | HTMLDivElement, 105 | { asChild?: boolean } & React.HTMLAttributes 106 | >(({ asChild, className, children, ...rest }, ref) => { 107 | const footerClass = cn('border-t border-black/10 text-sm mt-2 pt-2', className); 108 | 109 | const Comp = asChild ? Slot : 'div'; 110 | 111 | return ( 112 | 113 | {children} 114 | 115 | ); 116 | }); 117 | 118 | PanelFooter.displayName = 'PanelFooter'; 119 | -------------------------------------------------------------------------------- /src/components/ui/Spinner/Spinner.tsx: -------------------------------------------------------------------------------- 1 | import { HTMLProps, forwardRef } from 'react'; 2 | 3 | import { cn } from '@/utils/utils'; 4 | 5 | export const Spinner = forwardRef>( 6 | ({ className, ...rest }, ref) => { 7 | const spinnerClass = cn( 8 | 'animate-spin rounded-full border-2 border-current border-t-transparent h-4 w-4', 9 | className, 10 | ); 11 | 12 | return
; 13 | }, 14 | ); 15 | 16 | Spinner.displayName = 'Spinner'; 17 | -------------------------------------------------------------------------------- /src/components/ui/Spinner/index.tsx: -------------------------------------------------------------------------------- 1 | export * from './Spinner'; 2 | -------------------------------------------------------------------------------- /src/components/ui/Surface.tsx: -------------------------------------------------------------------------------- 1 | import { HTMLProps, forwardRef } from 'react'; 2 | 3 | import { cn } from '@/utils/utils'; 4 | 5 | export type SurfaceProps = HTMLProps & { 6 | withShadow?: boolean; 7 | withBorder?: boolean; 8 | elevation?: 'low' | 'medium' | 'high'; 9 | }; 10 | 11 | export const Surface = forwardRef( 12 | ( 13 | { children, className, withShadow = true, withBorder = true, elevation = 'medium', ...props }, 14 | ref, 15 | ) => { 16 | const shadowClasses = { 17 | low: 'shadow-sm', 18 | medium: 'shadow', 19 | high: 'shadow-lg', 20 | }; 21 | 22 | const surfaceClass = cn( 23 | className, 24 | 'bg-white rounded-xl', 25 | withShadow ? shadowClasses[elevation] : '', 26 | withBorder ? 'border border-neutral-100' : '', 27 | ); 28 | 29 | return ( 30 |
31 | {children} 32 |
33 | ); 34 | }, 35 | ); 36 | 37 | Surface.displayName = 'Surface'; 38 | -------------------------------------------------------------------------------- /src/components/ui/Textarea/Textarea.tsx: -------------------------------------------------------------------------------- 1 | import { forwardRef } from 'react'; 2 | 3 | import { cn } from '@/utils/utils'; 4 | 5 | export const Textarea = forwardRef< 6 | HTMLTextAreaElement, 7 | React.TextareaHTMLAttributes 8 | >(({ className, ...rest }, ref) => { 9 | const textAreaClassName = cn( 10 | 'bg-black/5 border-0 rounded-lg caret-black block text-black text-sm font-medium h-[4.5rem] px-2 py-1 w-full', 11 | 'dark:bg-white/10 dark:text-white dark:caret-white', 12 | 'hover:bg-black/10', 13 | 'dark:hover:bg-white/20', 14 | 'focus:bg-transparent active:bg-transparent focus:outline focus:outline-black active:outline active:outline-black', 15 | 'dark:focus:outline-white dark:active:outline-white', 16 | className, 17 | ); 18 | 19 | return