mirror of
https://github.com/Shadowfita/docmost.git
synced 2025-11-13 08:12:32 +10:00
switch to nx monorepo
This commit is contained in:
2
apps/client/.env.example
Normal file
2
apps/client/.env.example
Normal file
@ -0,0 +1,2 @@
|
||||
VITE_BACKEND_API_URL=http://localhost:3001
|
||||
VITE_COLLABORATION_URL=
|
||||
22
apps/client/.eslintrc.cjs
Normal file
22
apps/client/.eslintrc.cjs
Normal file
@ -0,0 +1,22 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
env: { browser: true, es2020: true },
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:react-hooks/recommended',
|
||||
'plugin:@tanstack/eslint-plugin-query/recommended',
|
||||
],
|
||||
ignorePatterns: ['dist', '.eslintrc.cjs'],
|
||||
parser: '@typescript-eslint/parser',
|
||||
plugins: ['react-refresh'],
|
||||
rules: {
|
||||
'react-refresh/only-export-components': [
|
||||
'warn',
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/ban-ts-comment': 'off',
|
||||
'@typescript-eslint/no-unused-vars': 'off',
|
||||
},
|
||||
}
|
||||
27
apps/client/.gitignore
vendored
Normal file
27
apps/client/.gitignore
vendored
Normal file
@ -0,0 +1,27 @@
|
||||
.env
|
||||
package-lock.json
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
27
apps/client/README.md
Normal file
27
apps/client/README.md
Normal file
@ -0,0 +1,27 @@
|
||||
# React + TypeScript + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type aware lint rules:
|
||||
|
||||
- Configure the top-level `parserOptions` property like this:
|
||||
|
||||
```js
|
||||
parserOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
project: ['./tsconfig.json', './tsconfig.node.json'],
|
||||
tsconfigRootDir: __dirname,
|
||||
},
|
||||
```
|
||||
|
||||
- Replace `plugin:@typescript-eslint/recommended` to `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked`
|
||||
- Optionally add `plugin:@typescript-eslint/stylistic-type-checked`
|
||||
- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and add `plugin:react/recommended` & `plugin:react/jsx-runtime` to the `extends` list
|
||||
13
apps/client/index.html
Normal file
13
apps/client/index.html
Normal file
@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Vite + React + TS</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
84
apps/client/package.json
Normal file
84
apps/client/package.json
Normal file
@ -0,0 +1,84 @@
|
||||
{
|
||||
"name": "client",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"lint": "eslint --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hocuspocus/provider": "^2.8.1",
|
||||
"@mantine/core": "^7.2.2",
|
||||
"@mantine/form": "^7.2.2",
|
||||
"@mantine/hooks": "^7.2.2",
|
||||
"@mantine/modals": "^7.2.2",
|
||||
"@mantine/notifications": "^7.2.2",
|
||||
"@mantine/spotlight": "^7.2.2",
|
||||
"@tabler/icons-react": "^2.42.0",
|
||||
"@tanstack/react-query": "^5.8.6",
|
||||
"@tiptap/extension-code-block": "^2.1.12",
|
||||
"@tiptap/extension-collaboration": "^2.1.12",
|
||||
"@tiptap/extension-collaboration-cursor": "^2.1.12",
|
||||
"@tiptap/extension-color": "^2.1.12",
|
||||
"@tiptap/extension-document": "^2.1.12",
|
||||
"@tiptap/extension-heading": "^2.1.12",
|
||||
"@tiptap/extension-highlight": "^2.1.12",
|
||||
"@tiptap/extension-link": "^2.1.12",
|
||||
"@tiptap/extension-list-item": "^2.1.12",
|
||||
"@tiptap/extension-list-keymap": "^2.1.12",
|
||||
"@tiptap/extension-mention": "^2.1.12",
|
||||
"@tiptap/extension-placeholder": "^2.1.12",
|
||||
"@tiptap/extension-subscript": "^2.1.12",
|
||||
"@tiptap/extension-superscript": "^2.1.12",
|
||||
"@tiptap/extension-task-item": "^2.1.12",
|
||||
"@tiptap/extension-task-list": "^2.1.12",
|
||||
"@tiptap/extension-text": "^2.1.12",
|
||||
"@tiptap/extension-text-align": "^2.1.12",
|
||||
"@tiptap/extension-text-style": "^2.1.12",
|
||||
"@tiptap/extension-typography": "^2.1.12",
|
||||
"@tiptap/extension-underline": "^2.1.12",
|
||||
"@tiptap/pm": "^2.1.12",
|
||||
"@tiptap/react": "^2.1.12",
|
||||
"@tiptap/starter-kit": "^2.1.12",
|
||||
"@tiptap/suggestion": "^2.1.12",
|
||||
"axios": "^1.6.2",
|
||||
"clsx": "^2.0.0",
|
||||
"date-fns": "^2.30.0",
|
||||
"jotai": "^2.5.1",
|
||||
"jotai-optics": "^0.3.1",
|
||||
"js-cookie": "^3.0.5",
|
||||
"react": "^18.2.0",
|
||||
"react-arborist": "^3.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.20.0",
|
||||
"socket.io-client": "^4.7.2",
|
||||
"tippy.js": "^6.3.7",
|
||||
"uuid": "^9.0.1",
|
||||
"y-indexeddb": "^9.0.12",
|
||||
"yjs": "^13.6.10",
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tanstack/eslint-plugin-query": "^5.8.4",
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
"@types/node": "20.10.0",
|
||||
"@types/react": "^18.2.38",
|
||||
"@types/react-dom": "^18.2.17",
|
||||
"@types/uuid": "^9.0.7",
|
||||
"@typescript-eslint/eslint-plugin": "^6.12.0",
|
||||
"@typescript-eslint/parser": "^6.12.0",
|
||||
"@vitejs/plugin-react": "^4.2.0",
|
||||
"eslint": "^8.54.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.4",
|
||||
"optics-ts": "^2.4.1",
|
||||
"postcss": "^8.4.31",
|
||||
"postcss-preset-mantine": "^1.11.0",
|
||||
"postcss-simple-vars": "^7.0.1",
|
||||
"prettier": "^3.1.0",
|
||||
"typescript": "^5.3.2",
|
||||
"vite": "^5.0.2"
|
||||
}
|
||||
}
|
||||
14
apps/client/postcss.config.js
Normal file
14
apps/client/postcss.config.js
Normal file
@ -0,0 +1,14 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
"postcss-preset-mantine": {},
|
||||
"postcss-simple-vars": {
|
||||
variables: {
|
||||
"mantine-breakpoint-xs": "36em",
|
||||
"mantine-breakpoint-sm": "48em",
|
||||
"mantine-breakpoint-md": "62em",
|
||||
"mantine-breakpoint-lg": "75em",
|
||||
"mantine-breakpoint-xl": "88em",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
1
apps/client/public/vite.svg
Normal file
1
apps/client/public/vite.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
26
apps/client/src/App.tsx
Normal file
26
apps/client/src/App.tsx
Normal file
@ -0,0 +1,26 @@
|
||||
import { Route, Routes } from 'react-router-dom';
|
||||
import { Welcome } from '@/pages/welcome';
|
||||
import SignUpPage from '@/pages/auth/signup';
|
||||
import LoginPage from '@/pages/auth/login';
|
||||
import DashboardLayout from '@/components/layouts/layout';
|
||||
import Home from '@/pages/dashboard/home';
|
||||
import Page from '@/pages/page/page';
|
||||
|
||||
export default function App() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Routes>
|
||||
<Route index element={<Welcome />} />
|
||||
<Route path={'/login'} element={<LoginPage />} />
|
||||
<Route path={'/signup'} element={<SignUpPage />} />
|
||||
|
||||
<Route element={<DashboardLayout />}>
|
||||
<Route path={'/home'} element={<Home />} />
|
||||
<Route path={'/p/:pageId'} element={<Page />} />
|
||||
</Route>
|
||||
|
||||
</Routes>
|
||||
</>
|
||||
);
|
||||
}
|
||||
1
apps/client/src/assets/react.svg
Normal file
1
apps/client/src/assets/react.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
39
apps/client/src/components/aside/aside.tsx
Normal file
39
apps/client/src/components/aside/aside.tsx
Normal file
@ -0,0 +1,39 @@
|
||||
import { Box, ScrollArea, Text } from '@mantine/core';
|
||||
import CommentList from '@/features/comment/components/comment-list';
|
||||
import { useAtom } from 'jotai';
|
||||
import { asideStateAtom } from '@/components/navbar/atoms/sidebar-atom';
|
||||
import React from 'react';
|
||||
|
||||
export default function Aside() {
|
||||
const [{ tab }] = useAtom(asideStateAtom);
|
||||
|
||||
let title;
|
||||
let component;
|
||||
|
||||
switch (tab) {
|
||||
case 'comments':
|
||||
component = <CommentList />;
|
||||
title = 'Comments';
|
||||
break;
|
||||
default:
|
||||
component = null;
|
||||
title = null;
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<Box p="md">
|
||||
{component && (
|
||||
<>
|
||||
<Text mb="md" fw={500}>{title}</Text>
|
||||
|
||||
<ScrollArea style={{ height: '85vh' }} scrollbarSize={5} type="scroll">
|
||||
<div style={{ paddingBottom: '200px' }}>
|
||||
{component}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,6 @@
|
||||
.breadcrumb {
|
||||
a {
|
||||
color: var(--mantine-color-default-color);
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
104
apps/client/src/components/layouts/components/breadcrumb.tsx
Normal file
104
apps/client/src/components/layouts/components/breadcrumb.tsx
Normal file
@ -0,0 +1,104 @@
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { treeDataAtom } from '@/features/page/tree/atoms/tree-data-atom';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { TreeNode } from '@/features/page/tree/types';
|
||||
import { findBreadcrumbPath } from '@/features/page/tree/utils';
|
||||
import {
|
||||
Button,
|
||||
Anchor,
|
||||
Popover,
|
||||
Breadcrumbs,
|
||||
ActionIcon,
|
||||
Text,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconDots,
|
||||
} from '@tabler/icons-react';
|
||||
import { Link, useParams } from 'react-router-dom';
|
||||
import classes from './breadcrumb.module.css';
|
||||
|
||||
export default function Breadcrumb() {
|
||||
const treeData = useAtomValue(treeDataAtom);
|
||||
const [breadcrumbNodes, setBreadcrumbNodes] = useState<TreeNode[] | null>(
|
||||
null,
|
||||
);
|
||||
const { pageId } = useParams();
|
||||
|
||||
useEffect(() => {
|
||||
if (treeData.length) {
|
||||
const breadcrumb = findBreadcrumbPath(treeData, pageId);
|
||||
if (breadcrumb) {
|
||||
setBreadcrumbNodes(breadcrumb);
|
||||
}
|
||||
}
|
||||
}, [pageId, treeData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (treeData.length) {
|
||||
const breadcrumb = findBreadcrumbPath(treeData, pageId);
|
||||
if (breadcrumb) setBreadcrumbNodes(breadcrumb);
|
||||
}
|
||||
}, [pageId, treeData]);
|
||||
|
||||
const HiddenNodesTooltipContent = () => (
|
||||
breadcrumbNodes?.slice(1, -2).map(node => (
|
||||
<Button.Group orientation="vertical" key={node.id}>
|
||||
<Button
|
||||
justify="start"
|
||||
component={Link}
|
||||
to={`/p/${node.id}`}
|
||||
variant="default"
|
||||
style={{ border: 'none' }}
|
||||
>
|
||||
<Text truncate="end">{node.name}</Text>
|
||||
</Button>
|
||||
</Button.Group>
|
||||
))
|
||||
);
|
||||
|
||||
const getLastNthNode = (n: number) => breadcrumbNodes && breadcrumbNodes[breadcrumbNodes.length - n];
|
||||
|
||||
const getBreadcrumbItems = () => {
|
||||
if (breadcrumbNodes?.length > 3) {
|
||||
return [
|
||||
<Anchor component={Link} to={`/p/${breadcrumbNodes[0].id}`} underline="never" key={breadcrumbNodes[0].id}>
|
||||
{breadcrumbNodes[0].name}
|
||||
</Anchor>,
|
||||
<Popover width={250} position="bottom" withArrow shadow="xl" key="hidden-nodes">
|
||||
<Popover.Target>
|
||||
<ActionIcon color="gray" variant="transparent">
|
||||
<IconDots size={20} stroke={2} />
|
||||
</ActionIcon>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<HiddenNodesTooltipContent />
|
||||
</Popover.Dropdown>
|
||||
</Popover>,
|
||||
<Anchor component={Link} to={`/p/${getLastNthNode(2)?.id}`} underline="never" key={getLastNthNode(2)?.id}>
|
||||
{getLastNthNode(2)?.name}
|
||||
</Anchor>,
|
||||
<Anchor component={Link} to={`/p/${getLastNthNode(1)?.id}`} underline="never" key={getLastNthNode(1)?.id}>
|
||||
{getLastNthNode(1)?.name}
|
||||
</Anchor>,
|
||||
];
|
||||
}
|
||||
|
||||
if (breadcrumbNodes) {
|
||||
return breadcrumbNodes.map(node => (
|
||||
<Anchor component={Link} to={`/p/${node.id}`} underline="never" key={node.id}>
|
||||
{node.name}
|
||||
</Anchor>
|
||||
));
|
||||
}
|
||||
|
||||
return [];
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={classes.breadcrumb}>
|
||||
{breadcrumbNodes ? (
|
||||
<Breadcrumbs>{getBreadcrumbItems()}</Breadcrumbs>
|
||||
) : (<></>)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
93
apps/client/src/components/layouts/header.tsx
Normal file
93
apps/client/src/components/layouts/header.tsx
Normal file
@ -0,0 +1,93 @@
|
||||
import {
|
||||
ActionIcon,
|
||||
Menu,
|
||||
Button,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconDots,
|
||||
IconFileInfo,
|
||||
IconHistory,
|
||||
IconLink,
|
||||
IconLock,
|
||||
IconShare,
|
||||
IconTrash,
|
||||
IconMessage,
|
||||
} from '@tabler/icons-react';
|
||||
import React from 'react';
|
||||
import useToggleAside from '@/hooks/use-toggle-aside';
|
||||
import { useAtom } from 'jotai';
|
||||
import { historyAtoms } from '@/features/page-history/atoms/history-atoms';
|
||||
|
||||
export default function Header() {
|
||||
const toggleAside = useToggleAside();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button variant="default" style={{ border: 'none' }} size="compact-sm">
|
||||
Share
|
||||
</Button>
|
||||
|
||||
<ActionIcon variant="default" style={{ border: 'none' }} onClick={() => toggleAside('comments')}>
|
||||
<IconMessage size={20} stroke={2} />
|
||||
</ActionIcon>
|
||||
|
||||
<PageActionMenu />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function PageActionMenu() {
|
||||
const [, setHistoryModalOpen] = useAtom(historyAtoms);
|
||||
|
||||
const openHistoryModal = () => {
|
||||
setHistoryModalOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<Menu
|
||||
shadow="xl"
|
||||
position="bottom-end"
|
||||
offset={20}
|
||||
width={200}
|
||||
withArrow
|
||||
arrowPosition="center"
|
||||
>
|
||||
<Menu.Target>
|
||||
<ActionIcon variant="default" style={{ border: 'none' }}>
|
||||
<IconDots size={20} stroke={2} />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
leftSection={<IconFileInfo size={16} stroke={2} />}>
|
||||
Page info
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<IconLink size={16} stroke={2} />}
|
||||
>
|
||||
Copy link
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<IconShare size={16} stroke={2} />}>
|
||||
Share
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<IconHistory size={16} stroke={2} />}
|
||||
onClick={openHistoryModal}>
|
||||
Page history
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
leftSection={<IconLock size={16} stroke={2} />}>
|
||||
Lock
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<IconTrash size={16} stroke={2} />}>
|
||||
Delete
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
14
apps/client/src/components/layouts/layout.tsx
Normal file
14
apps/client/src/components/layouts/layout.tsx
Normal file
@ -0,0 +1,14 @@
|
||||
import { UserProvider } from '@/features/user/user-provider';
|
||||
import Shell from './shell';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
|
||||
export default function DashboardLayout() {
|
||||
|
||||
return (
|
||||
<UserProvider>
|
||||
<Shell>
|
||||
<Outlet />
|
||||
</Shell>
|
||||
</UserProvider>
|
||||
);
|
||||
}
|
||||
26
apps/client/src/components/layouts/shell.module.css
Normal file
26
apps/client/src/components/layouts/shell.module.css
Normal file
@ -0,0 +1,26 @@
|
||||
.header,
|
||||
.footer {
|
||||
@media (max-width: 992px) {
|
||||
[data-layout='alt'] & {
|
||||
--_section-right: var(--app-shell-aside-offset, 0px);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.aside {
|
||||
@media (min-width: 993px) {
|
||||
background: var(--mantine-color-gray-light);
|
||||
|
||||
[data-layout='alt'] & {
|
||||
--_section-top: var(--_section-top, var(--app-shell-header-offset, 0px));
|
||||
--_section-height: var(
|
||||
--_section-height,
|
||||
calc(100dvh - var(--app-shell-header-offset, 0px) - var(--app-shell-footer-offset, 0px))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
88
apps/client/src/components/layouts/shell.tsx
Normal file
88
apps/client/src/components/layouts/shell.tsx
Normal file
@ -0,0 +1,88 @@
|
||||
import { asideStateAtom, desktopSidebarAtom } from '@/components/navbar/atoms/sidebar-atom';
|
||||
import { useToggleSidebar } from '@/components/navbar/hooks/use-toggle-sidebar';
|
||||
import { Navbar } from '@/components/navbar/navbar';
|
||||
import { AppShell, Burger, Group } from '@mantine/core';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import { useAtom } from 'jotai';
|
||||
import classes from './shell.module.css';
|
||||
import Header from '@/components/layouts/header';
|
||||
import Breadcrumb from '@/components/layouts/components/breadcrumb';
|
||||
import Aside from '@/components/aside/aside';
|
||||
import { useMatchPath } from '@/hooks/use-match-path';
|
||||
import React from 'react';
|
||||
|
||||
export default function Shell({ children }: { children: React.ReactNode }) {
|
||||
const [mobileOpened, { toggle: toggleMobile }] = useDisclosure();
|
||||
const [desktopOpened] = useAtom(desktopSidebarAtom);
|
||||
const toggleDesktop = useToggleSidebar(desktopSidebarAtom);
|
||||
const matchPath = useMatchPath();
|
||||
const isPageRoute = matchPath('/p/:pageId');
|
||||
const [{ isAsideOpen }] = useAtom(asideStateAtom);
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
layout="alt"
|
||||
header={{ height: 45 }}
|
||||
navbar={{
|
||||
width: 300,
|
||||
breakpoint: 'sm',
|
||||
collapsed: { mobile: !mobileOpened, desktop: !desktopOpened },
|
||||
}}
|
||||
aside={{
|
||||
width: 300,
|
||||
breakpoint: 'md',
|
||||
collapsed: { mobile: (!isAsideOpen), desktop: (!isAsideOpen) },
|
||||
}}
|
||||
padding="md"
|
||||
>
|
||||
<AppShell.Header
|
||||
className={classes.header}
|
||||
>
|
||||
|
||||
<Group justify="space-between" h="100%" px="md" wrap="nowrap">
|
||||
|
||||
<Group h="100%" maw="60%" px="md" wrap="nowrap" style={{ overflow: 'hidden' }}>
|
||||
<Burger
|
||||
opened={mobileOpened}
|
||||
onClick={toggleMobile}
|
||||
hiddenFrom="sm"
|
||||
size="sm"
|
||||
/>
|
||||
<Burger
|
||||
opened={desktopOpened}
|
||||
onClick={toggleDesktop}
|
||||
visibleFrom="sm"
|
||||
size="sm"
|
||||
/>
|
||||
|
||||
{isPageRoute && <Breadcrumb />}
|
||||
</Group>
|
||||
|
||||
{
|
||||
isPageRoute &&
|
||||
<Group justify="flex-end" h="100%" px="md" wrap="nowrap">
|
||||
<Header />
|
||||
</Group>
|
||||
}
|
||||
</Group>
|
||||
|
||||
</AppShell.Header>
|
||||
|
||||
<AppShell.Navbar>
|
||||
<Navbar />
|
||||
</AppShell.Navbar>
|
||||
|
||||
<AppShell.Main>
|
||||
{children}
|
||||
</AppShell.Main>
|
||||
|
||||
{
|
||||
isPageRoute &&
|
||||
<AppShell.Aside className={classes.aside}>
|
||||
<Aside />
|
||||
</AppShell.Aside>
|
||||
}
|
||||
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
16
apps/client/src/components/navbar/atoms/sidebar-atom.ts
Normal file
16
apps/client/src/components/navbar/atoms/sidebar-atom.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import { atomWithWebStorage } from '@/lib/jotai-helper';
|
||||
import { atom } from 'jotai';
|
||||
|
||||
export const desktopSidebarAtom = atomWithWebStorage('showSidebar', true);
|
||||
|
||||
export const desktopAsideAtom = atom(false);
|
||||
|
||||
type AsideStateType = {
|
||||
tab: string,
|
||||
isAsideOpen: boolean,
|
||||
}
|
||||
|
||||
export const asideStateAtom = atom<AsideStateType>({
|
||||
tab: '',
|
||||
isAsideOpen: false,
|
||||
});
|
||||
@ -0,0 +1,8 @@
|
||||
import { useAtom } from "jotai";
|
||||
|
||||
export function useToggleSidebar(sidebarAtom: any) {
|
||||
const [sidebarState, setSidebarState] = useAtom(sidebarAtom);
|
||||
return () => {
|
||||
setSidebarState(!sidebarState);
|
||||
}
|
||||
}
|
||||
88
apps/client/src/components/navbar/navbar.module.css
Normal file
88
apps/client/src/components/navbar/navbar.module.css
Normal file
@ -0,0 +1,88 @@
|
||||
.navbar {
|
||||
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-7));
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
padding: var(--mantine-spacing-md);
|
||||
padding-top: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
/*border-right: rem(1px) solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));*/
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-left: calc(var(--mantine-spacing-md) * -1);
|
||||
margin-right: calc(var(--mantine-spacing-md) * -1);
|
||||
margin-bottom: var(--mantine-spacing-md);
|
||||
|
||||
&:not(:last-of-type) {
|
||||
border-bottom: rem(1px) solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
|
||||
}
|
||||
}
|
||||
|
||||
.searchCode {
|
||||
font-weight: 700;
|
||||
font-size: rem(10px);
|
||||
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-7));
|
||||
border: rem(1px) solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-7));
|
||||
}
|
||||
|
||||
.menuItems {
|
||||
padding-left: calc(var(--mantine-spacing-md) - var(--mantine-spacing-xs));
|
||||
padding-right: calc(var(--mantine-spacing-md) - var(--mantine-spacing-xs));
|
||||
padding-bottom: var(--mantine-spacing-md);
|
||||
}
|
||||
|
||||
.menu {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
font-size: var(--mantine-font-size-sm);
|
||||
padding: rem(4px) var(--mantine-spacing-xs);
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
font-weight: 500;
|
||||
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-0));
|
||||
|
||||
&:hover {
|
||||
background-color: light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-6));
|
||||
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-0));
|
||||
}
|
||||
}
|
||||
|
||||
.menuItemInner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.menuItemIcon {
|
||||
margin-right: var(--mantine-spacing-sm);
|
||||
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
|
||||
}
|
||||
|
||||
.pages {
|
||||
padding-left: calc(var(--mantine-spacing-md) - rem(6px));
|
||||
padding-right: calc(var(--mantine-spacing-md) - rem(6px));
|
||||
padding-bottom: var(--mantine-spacing-md);
|
||||
}
|
||||
|
||||
.pagesHeader {
|
||||
padding-left: calc(var(--mantine-spacing-md) + rem(2px));
|
||||
padding-right: var(--mantine-spacing-md);
|
||||
margin-bottom: rem(5px);
|
||||
}
|
||||
|
||||
.pageLink {
|
||||
display: block;
|
||||
padding: rem(8px) var(--mantine-spacing-xs);
|
||||
text-decoration: none;
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
font-size: var(--mantine-font-size-xs);
|
||||
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-0));
|
||||
line-height: 1;
|
||||
font-weight: 500;
|
||||
|
||||
&:hover {
|
||||
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
|
||||
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-0));
|
||||
}
|
||||
}
|
||||
124
apps/client/src/components/navbar/navbar.tsx
Normal file
124
apps/client/src/components/navbar/navbar.tsx
Normal file
@ -0,0 +1,124 @@
|
||||
import {
|
||||
UnstyledButton,
|
||||
Text,
|
||||
Group,
|
||||
ActionIcon,
|
||||
Tooltip,
|
||||
rem,
|
||||
} from '@mantine/core';
|
||||
import { spotlight } from '@mantine/spotlight';
|
||||
import {
|
||||
IconSearch,
|
||||
IconPlus,
|
||||
IconSettings,
|
||||
IconFilePlus,
|
||||
IconHome
|
||||
} from '@tabler/icons-react';
|
||||
|
||||
import classes from './navbar.module.css';
|
||||
import { UserButton } from './user-button';
|
||||
import React from 'react';
|
||||
import { useAtom } from 'jotai';
|
||||
import { settingsModalAtom } from '@/features/settings/modal/atoms/settings-modal-atom';
|
||||
import SettingsModal from '@/features/settings/modal/settings-modal';
|
||||
import { SearchSpotlight } from '@/features/search/search-spotlight';
|
||||
import { treeApiAtom } from '@/features/page/tree/atoms/tree-api-atom';
|
||||
import PageTree from '@/features/page/tree/page-tree';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
interface PrimaryMenuItem {
|
||||
icon: React.ElementType;
|
||||
label: string;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
const primaryMenu: PrimaryMenuItem[] = [
|
||||
{ icon: IconHome, label: 'Home' },
|
||||
{ icon: IconSearch, label: 'Search' },
|
||||
{ icon: IconSettings, label: 'Settings' },
|
||||
// { icon: IconFilePlus, label: 'New Page' },
|
||||
];
|
||||
|
||||
export function Navbar() {
|
||||
const [, setSettingsModalOpen] = useAtom(settingsModalAtom);
|
||||
const [tree] = useAtom(treeApiAtom);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleMenuItemClick = (label: string) => {
|
||||
if (label === 'Home') {
|
||||
navigate('/home');
|
||||
}
|
||||
|
||||
if (label === 'Search') {
|
||||
spotlight.open();
|
||||
}
|
||||
|
||||
if (label === 'Settings') {
|
||||
setSettingsModalOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
function handleCreatePage() {
|
||||
tree?.create({ parentId: null, type: 'internal', index: 0 });
|
||||
}
|
||||
|
||||
const primaryMenuItems = primaryMenu.map((menuItem) => (
|
||||
<UnstyledButton
|
||||
key={menuItem.label}
|
||||
className={classes.menu}
|
||||
onClick={() => handleMenuItemClick(menuItem.label)}
|
||||
>
|
||||
<div className={classes.menuItemInner}>
|
||||
<menuItem.icon
|
||||
size={18}
|
||||
className={classes.menuItemIcon}
|
||||
stroke={2}
|
||||
/>
|
||||
<span>{menuItem.label}</span>
|
||||
</div>
|
||||
</UnstyledButton>
|
||||
));
|
||||
|
||||
return (
|
||||
<>
|
||||
<nav className={classes.navbar}>
|
||||
<div className={classes.section}>
|
||||
<UserButton />
|
||||
</div>
|
||||
|
||||
<div className={classes.section}>
|
||||
<div className={classes.menuItems}>{primaryMenuItems}</div>
|
||||
</div>
|
||||
|
||||
<div className={classes.section}>
|
||||
<Group className={classes.pagesHeader} justify="space-between">
|
||||
<Text size="xs" fw={500} c="dimmed">
|
||||
Pages
|
||||
</Text>
|
||||
|
||||
<Tooltip label="Create page" withArrow position="right">
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
size={18}
|
||||
onClick={handleCreatePage}
|
||||
>
|
||||
<IconPlus
|
||||
style={{ width: rem(12), height: rem(12) }}
|
||||
stroke={1.5}
|
||||
/>
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
|
||||
<div className={classes.pages}>
|
||||
<PageTree />
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<SearchSpotlight />
|
||||
<SettingsModal />
|
||||
</>
|
||||
);
|
||||
}
|
||||
10
apps/client/src/components/navbar/user-button.module.css
Normal file
10
apps/client/src/components/navbar/user-button.module.css
Normal file
@ -0,0 +1,10 @@
|
||||
.user {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: var(--mantine-spacing-md);
|
||||
color: light-dark(var(--mantine-color-black), var(--mantine-color-dark-0));
|
||||
|
||||
@mixin hover {
|
||||
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-8));
|
||||
}
|
||||
}
|
||||
32
apps/client/src/components/navbar/user-button.tsx
Normal file
32
apps/client/src/components/navbar/user-button.tsx
Normal file
@ -0,0 +1,32 @@
|
||||
import { UnstyledButton, Group, Avatar, Text, rem } from '@mantine/core';
|
||||
import { IconChevronRight } from '@tabler/icons-react';
|
||||
import classes from './user-button.module.css';
|
||||
import { useAtom } from 'jotai/index';
|
||||
import { currentUserAtom } from '@/features/user/atoms/current-user-atom';
|
||||
|
||||
export function UserButton() {
|
||||
const [currentUser] = useAtom(currentUserAtom);
|
||||
|
||||
return (
|
||||
<UnstyledButton className={classes.user}>
|
||||
<Group>
|
||||
<Avatar
|
||||
src="https://raw.githubusercontent.com/mantinedev/mantine/master/.demo/avatars/avatar-9.png"
|
||||
radius="xl"
|
||||
/>
|
||||
|
||||
<div style={{ flex: 1 }}>
|
||||
<Text size="sm" fw={500}>
|
||||
{currentUser?.user.name}
|
||||
</Text>
|
||||
|
||||
<Text c="dimmed" size="xs">
|
||||
{currentUser?.user.email}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<IconChevronRight style={{ width: rem(14), height: rem(14) }} stroke={1.5} />
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
);
|
||||
}
|
||||
19
apps/client/src/components/providers/tanstack-provider.tsx
Normal file
19
apps/client/src/components/providers/tanstack-provider.tsx
Normal file
@ -0,0 +1,19 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import React from 'react';
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export function TanstackProvider({ children }: React.PropsWithChildren) {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
13
apps/client/src/components/theme-toggle.tsx
Normal file
13
apps/client/src/components/theme-toggle.tsx
Normal file
@ -0,0 +1,13 @@
|
||||
import { Button, Group, useMantineColorScheme } from '@mantine/core';
|
||||
|
||||
export function ThemeToggle() {
|
||||
const { setColorScheme } = useMantineColorScheme();
|
||||
|
||||
return (
|
||||
<Group justify="center" mt="xl">
|
||||
<Button onClick={() => setColorScheme('light')}>Light</Button>
|
||||
<Button onClick={() => setColorScheme('dark')}>Dark</Button>
|
||||
<Button onClick={() => setColorScheme('auto')}>Auto</Button>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
37
apps/client/src/components/ui/user-avatar.tsx
Normal file
37
apps/client/src/components/ui/user-avatar.tsx
Normal file
@ -0,0 +1,37 @@
|
||||
import React from 'react';
|
||||
import { Avatar } from '@mantine/core';
|
||||
|
||||
interface UserAvatarProps {
|
||||
avatarUrl: string;
|
||||
name: string;
|
||||
color?: string;
|
||||
size?: string;
|
||||
radius?: string;
|
||||
style?: any;
|
||||
component?: any;
|
||||
}
|
||||
|
||||
export const UserAvatar = React.forwardRef<HTMLInputElement, UserAvatarProps>(
|
||||
({ avatarUrl, name, ...props }: UserAvatarProps, ref) => {
|
||||
|
||||
const getInitials = (name: string) => {
|
||||
const names = name.split(' ');
|
||||
return names.slice(0, 2).map(n => n[0]).join('');
|
||||
};
|
||||
|
||||
return (
|
||||
avatarUrl ? (
|
||||
<Avatar
|
||||
ref={ref}
|
||||
src={avatarUrl}
|
||||
alt={name}
|
||||
radius="xl"
|
||||
{...props}
|
||||
/>
|
||||
) : (
|
||||
<Avatar ref={ref}
|
||||
{...props}>{getInitials(name)}</Avatar>
|
||||
)
|
||||
);
|
||||
},
|
||||
);
|
||||
14
apps/client/src/features/auth/atoms/auth-tokens-atom.ts
Normal file
14
apps/client/src/features/auth/atoms/auth-tokens-atom.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import Cookies from "js-cookie";
|
||||
import { createJSONStorage, atomWithStorage } from "jotai/utils";
|
||||
import { ITokens } from '../types/auth.types';
|
||||
|
||||
|
||||
const cookieStorage = createJSONStorage<ITokens>(() => {
|
||||
return {
|
||||
getItem: () => Cookies.get("authTokens"),
|
||||
setItem: (key, value) => Cookies.set(key, value),
|
||||
removeItem: (key) => Cookies.remove(key),
|
||||
};
|
||||
});
|
||||
|
||||
export const authTokensAtom = atomWithStorage<ITokens | null>("authTokens", null, cookieStorage);
|
||||
78
apps/client/src/features/auth/components/login-form.tsx
Normal file
78
apps/client/src/features/auth/components/login-form.tsx
Normal file
@ -0,0 +1,78 @@
|
||||
import * as React from 'react';
|
||||
import * as z from 'zod';
|
||||
|
||||
import { useForm, zodResolver } from '@mantine/form';
|
||||
import useAuth from '@/features/auth/hooks/use-auth';
|
||||
import { ILogin } from '@/features/auth/types/auth.types';
|
||||
import {
|
||||
Container,
|
||||
Title,
|
||||
Anchor,
|
||||
Paper,
|
||||
TextInput,
|
||||
Button,
|
||||
Text,
|
||||
PasswordInput,
|
||||
} from '@mantine/core';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
const formSchema = z.object({
|
||||
email: z
|
||||
.string({ required_error: 'email is required' })
|
||||
.email({ message: 'Invalid email address' }),
|
||||
password: z.string({ required_error: 'password is required' }),
|
||||
});
|
||||
|
||||
export function LoginForm() {
|
||||
const { signIn, isLoading } = useAuth();
|
||||
|
||||
const form = useForm<ILogin>({
|
||||
validate: zodResolver(formSchema),
|
||||
initialValues: {
|
||||
email: '',
|
||||
password: '',
|
||||
},
|
||||
});
|
||||
|
||||
async function onSubmit(data: ILogin) {
|
||||
await signIn(data);
|
||||
}
|
||||
|
||||
return (
|
||||
<Container size={420} my={40}>
|
||||
<Title ta="center" fw={800}>
|
||||
Login
|
||||
</Title>
|
||||
<Text c="dimmed" size="sm" ta="center" mt={5}>
|
||||
Don't have an account yet?{' '}
|
||||
<Anchor size="sm" component={Link} to="/signup">
|
||||
Create account
|
||||
</Anchor>
|
||||
</Text>
|
||||
|
||||
<Paper withBorder shadow="md" p={30} mt={30} radius="md">
|
||||
<form onSubmit={form.onSubmit(onSubmit)}>
|
||||
<TextInput
|
||||
id="email"
|
||||
type="email"
|
||||
label="Email"
|
||||
placeholder="email@example.com"
|
||||
required
|
||||
{...form.getInputProps('email')}
|
||||
/>
|
||||
|
||||
<PasswordInput
|
||||
label="Password"
|
||||
placeholder="Your password"
|
||||
required
|
||||
mt="md"
|
||||
{...form.getInputProps('password')}
|
||||
/>
|
||||
<Button type="submit" fullWidth mt="xl" loading={isLoading}>
|
||||
Sign In
|
||||
</Button>
|
||||
</form>
|
||||
</Paper>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
78
apps/client/src/features/auth/components/sign-up-form.tsx
Normal file
78
apps/client/src/features/auth/components/sign-up-form.tsx
Normal file
@ -0,0 +1,78 @@
|
||||
import * as React from 'react';
|
||||
import * as z from 'zod';
|
||||
|
||||
import { useForm, zodResolver } from '@mantine/form';
|
||||
import {
|
||||
Container,
|
||||
Title,
|
||||
Anchor,
|
||||
Paper,
|
||||
TextInput,
|
||||
Button,
|
||||
Text,
|
||||
PasswordInput,
|
||||
} from '@mantine/core';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { IRegister } from '@/features/auth/types/auth.types';
|
||||
import useAuth from '@/features/auth/hooks/use-auth';
|
||||
|
||||
const formSchema = z.object({
|
||||
email: z
|
||||
.string({ required_error: 'email is required' })
|
||||
.email({ message: 'Invalid email address' }),
|
||||
password: z.string({ required_error: 'password is required' }),
|
||||
});
|
||||
|
||||
export function SignUpForm() {
|
||||
const { signUp, isLoading } = useAuth();
|
||||
|
||||
const form = useForm<IRegister>({
|
||||
validate: zodResolver(formSchema),
|
||||
initialValues: {
|
||||
email: '',
|
||||
password: '',
|
||||
},
|
||||
});
|
||||
|
||||
async function onSubmit(data: IRegister) {
|
||||
await signUp(data);
|
||||
}
|
||||
|
||||
return (
|
||||
<Container size={420} my={40}>
|
||||
<Title ta="center" fw={800}>
|
||||
Create an account
|
||||
</Title>
|
||||
<Text c="dimmed" size="sm" ta="center" mt={5}>
|
||||
Already have an account?{' '}
|
||||
<Anchor size="sm" component={Link} to="/login">
|
||||
Login
|
||||
</Anchor>
|
||||
</Text>
|
||||
|
||||
<Paper withBorder shadow="md" p={30} mt={30} radius="md">
|
||||
<form onSubmit={form.onSubmit(onSubmit)}>
|
||||
<TextInput
|
||||
id="email"
|
||||
type="email"
|
||||
label="Email"
|
||||
placeholder="email@example.com"
|
||||
required
|
||||
{...form.getInputProps('email')}
|
||||
/>
|
||||
|
||||
<PasswordInput
|
||||
label="Password"
|
||||
placeholder="Your password"
|
||||
required
|
||||
mt="md"
|
||||
{...form.getInputProps('password')}
|
||||
/>
|
||||
<Button type="submit" fullWidth mt="xl" loading={isLoading}>
|
||||
Sign Up
|
||||
</Button>
|
||||
</form>
|
||||
</Paper>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
64
apps/client/src/features/auth/hooks/use-auth.ts
Normal file
64
apps/client/src/features/auth/hooks/use-auth.ts
Normal file
@ -0,0 +1,64 @@
|
||||
import { useState } from 'react';
|
||||
import { login, register } from '@/features/auth/services/auth-service';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAtom } from 'jotai';
|
||||
import { authTokensAtom } from '@/features/auth/atoms/auth-tokens-atom';
|
||||
import { currentUserAtom } from '@/features/user/atoms/current-user-atom';
|
||||
import { ILogin, IRegister } from '@/features/auth/types/auth.types';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
|
||||
export default function useAuth() {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [, setCurrentUser] = useAtom(currentUserAtom);
|
||||
const [authToken, setAuthToken] = useAtom(authTokensAtom);
|
||||
|
||||
const handleSignIn = async (data: ILogin) => {
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const res = await login(data);
|
||||
setIsLoading(false);
|
||||
setAuthToken(res.tokens);
|
||||
|
||||
navigate('/home');
|
||||
} catch (err) {
|
||||
setIsLoading(false);
|
||||
notifications.show({
|
||||
message: err.response?.data.message,
|
||||
color: 'red',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleSignUp = async (data: IRegister) => {
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const res = await register(data);
|
||||
setIsLoading(false);
|
||||
|
||||
setAuthToken(res.tokens);
|
||||
|
||||
navigate('/home');
|
||||
} catch (err) {
|
||||
setIsLoading(false);
|
||||
notifications.show({
|
||||
message: err.response?.data.message,
|
||||
color: 'red',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const hasTokens = () => {
|
||||
return !!authToken;
|
||||
};
|
||||
|
||||
const handleLogout = async () => {
|
||||
setAuthToken(null);
|
||||
setCurrentUser(null);
|
||||
};
|
||||
|
||||
return { signIn: handleSignIn, signUp: handleSignUp, isLoading, hasTokens };
|
||||
}
|
||||
12
apps/client/src/features/auth/services/auth-service.ts
Normal file
12
apps/client/src/features/auth/services/auth-service.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import api from "@/lib/api-client";
|
||||
import { ILogin, IRegister, ITokenResponse } from "@/features/auth/types/auth.types";
|
||||
|
||||
export async function login(data: ILogin): Promise<ITokenResponse>{
|
||||
const req = await api.post<ITokenResponse>("/auth/login", data);
|
||||
return req.data as ITokenResponse;
|
||||
}
|
||||
|
||||
export async function register(data: IRegister): Promise<ITokenResponse>{
|
||||
const req = await api.post<ITokenResponse>("/auth/register", data);
|
||||
return req.data as ITokenResponse;
|
||||
}
|
||||
18
apps/client/src/features/auth/types/auth.types.ts
Normal file
18
apps/client/src/features/auth/types/auth.types.ts
Normal file
@ -0,0 +1,18 @@
|
||||
export interface ILogin {
|
||||
email: string,
|
||||
password: string
|
||||
}
|
||||
|
||||
export interface IRegister {
|
||||
email: string,
|
||||
password: string
|
||||
}
|
||||
|
||||
export interface ITokens {
|
||||
accessToken: string,
|
||||
refreshToken: string
|
||||
}
|
||||
|
||||
export interface ITokenResponse {
|
||||
tokens: ITokens
|
||||
}
|
||||
5
apps/client/src/features/comment/atoms/comment-atom.ts
Normal file
5
apps/client/src/features/comment/atoms/comment-atom.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { atom } from 'jotai';
|
||||
|
||||
export const showCommentPopupAtom = atom<boolean>(false);
|
||||
export const activeCommentIdAtom = atom<string>('');
|
||||
export const draftCommentIdAtom = atom<string>('');
|
||||
@ -0,0 +1,16 @@
|
||||
import { Button, Group } from '@mantine/core';
|
||||
|
||||
type CommentActionsProps = {
|
||||
onSave: () => void;
|
||||
isLoading?: boolean;
|
||||
};
|
||||
|
||||
function CommentActions({ onSave, isLoading }: CommentActionsProps) {
|
||||
return (
|
||||
<Group justify="flex-end" pt={2} wrap="nowrap">
|
||||
<Button size="compact-sm" loading={isLoading} onClick={onSave}>Save</Button>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
export default CommentActions;
|
||||
@ -0,0 +1,99 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Avatar, Dialog, Group, Stack, Text } from '@mantine/core';
|
||||
import { useClickOutside } from '@mantine/hooks';
|
||||
import { useAtom } from 'jotai';
|
||||
import {
|
||||
activeCommentIdAtom,
|
||||
draftCommentIdAtom,
|
||||
showCommentPopupAtom,
|
||||
} from '@/features/comment/atoms/comment-atom';
|
||||
import { Editor } from '@tiptap/core';
|
||||
import CommentEditor from '@/features/comment/components/comment-editor';
|
||||
import CommentActions from '@/features/comment/components/comment-actions';
|
||||
import { currentUserAtom } from '@/features/user/atoms/current-user-atom';
|
||||
import { useCreateCommentMutation } from '@/features/comment/queries/comment-query';
|
||||
import { asideStateAtom } from '@/components/navbar/atoms/sidebar-atom';
|
||||
|
||||
interface CommentDialogProps {
|
||||
editor: Editor,
|
||||
pageId: string,
|
||||
}
|
||||
|
||||
function CommentDialog({ editor, pageId }: CommentDialogProps) {
|
||||
const [comment, setComment] = useState('');
|
||||
const [, setShowCommentPopup] = useAtom(showCommentPopupAtom);
|
||||
const [, setActiveCommentId] = useAtom(activeCommentIdAtom);
|
||||
const [draftCommentId, setDraftCommentId] = useAtom(draftCommentIdAtom);
|
||||
const [currentUser] = useAtom(currentUserAtom);
|
||||
const [, setAsideState] = useAtom(asideStateAtom);
|
||||
const useClickOutsideRef = useClickOutside(() => {
|
||||
handleDialogClose();
|
||||
});
|
||||
const createCommentMutation = useCreateCommentMutation();
|
||||
const { isPending } = createCommentMutation;
|
||||
|
||||
const handleDialogClose = () => {
|
||||
setShowCommentPopup(false);
|
||||
editor.chain().focus().unsetCommentDecoration().run();
|
||||
};
|
||||
|
||||
const getSelectedText = () => {
|
||||
const { from, to } = editor.state.selection;
|
||||
return editor.state.doc.textBetween(from, to);
|
||||
};
|
||||
|
||||
const handleAddComment = async () => {
|
||||
try {
|
||||
const selectedText = getSelectedText();
|
||||
const commentData = {
|
||||
id: draftCommentId,
|
||||
pageId: pageId,
|
||||
content: JSON.stringify(comment),
|
||||
selection: selectedText,
|
||||
};
|
||||
|
||||
const createdComment = await createCommentMutation.mutateAsync(commentData);
|
||||
editor.chain().setComment(createdComment.id).unsetCommentDecoration().run();
|
||||
setActiveCommentId(createdComment.id);
|
||||
|
||||
setAsideState({ tab: 'comments', isAsideOpen: true });
|
||||
setTimeout(() => {
|
||||
const selector = `div[data-comment-id="${createdComment.id}"]`;
|
||||
const commentElement = document.querySelector(selector);
|
||||
commentElement?.scrollIntoView();
|
||||
});
|
||||
} finally {
|
||||
setShowCommentPopup(false);
|
||||
setDraftCommentId('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleCommentEditorChange = (newContent) => {
|
||||
setComment(newContent);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog opened={true} onClose={handleDialogClose} ref={useClickOutsideRef} size="lg" radius="md"
|
||||
w={300} position={{ bottom: 500, right: 50 }} withCloseButton withBorder>
|
||||
|
||||
<Stack gap={2}>
|
||||
<Group>
|
||||
<Avatar size="sm" color="blue">{currentUser.user.name.charAt(0)}</Avatar>
|
||||
<div style={{ flex: 1 }}>
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Text size="sm" fw={500} lineClamp={1}>{currentUser.user.name}</Text>
|
||||
</Group>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
<CommentEditor onUpdate={handleCommentEditorChange} placeholder="Write a comment"
|
||||
editable={true} autofocus={true}
|
||||
/>
|
||||
<CommentActions onSave={handleAddComment} isLoading={isPending}
|
||||
/>
|
||||
</Stack>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default CommentDialog;
|
||||
@ -0,0 +1,58 @@
|
||||
import { EditorContent, useEditor } from '@tiptap/react';
|
||||
import { Placeholder } from '@tiptap/extension-placeholder';
|
||||
import { Underline } from '@tiptap/extension-underline';
|
||||
import { Link } from '@tiptap/extension-link';
|
||||
import { StarterKit } from '@tiptap/starter-kit';
|
||||
import classes from './comment.module.css';
|
||||
import { useFocusWithin } from '@mantine/hooks';
|
||||
import clsx from 'clsx';
|
||||
import { forwardRef, useImperativeHandle } from 'react';
|
||||
|
||||
interface CommentEditorProps {
|
||||
defaultContent?: any;
|
||||
onUpdate?: any;
|
||||
editable: boolean;
|
||||
placeholder?: string;
|
||||
autofocus?: boolean;
|
||||
}
|
||||
|
||||
const CommentEditor = forwardRef(({ defaultContent, onUpdate, editable, placeholder, autofocus }: CommentEditorProps,
|
||||
ref) => {
|
||||
const { ref: focusRef, focused } = useFocusWithin();
|
||||
|
||||
const commentEditor = useEditor({
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
gapcursor: false,
|
||||
dropcursor: false,
|
||||
}),
|
||||
Placeholder.configure({
|
||||
placeholder: placeholder || 'Reply...',
|
||||
}),
|
||||
Underline,
|
||||
Link,
|
||||
],
|
||||
onUpdate({ editor }) {
|
||||
if (onUpdate) onUpdate(editor.getJSON());
|
||||
},
|
||||
content: defaultContent,
|
||||
editable,
|
||||
autofocus: (autofocus && 'end') || false,
|
||||
});
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
clearContent: () => {
|
||||
commentEditor.commands.clearContent();
|
||||
},
|
||||
}));
|
||||
|
||||
return (
|
||||
<div ref={focusRef} className={classes.commentEditor}>
|
||||
<EditorContent editor={commentEditor}
|
||||
className={clsx(classes.ProseMirror, { [classes.focused]: focused })}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default CommentEditor;
|
||||
@ -0,0 +1,110 @@
|
||||
import { Group, Text, Box } from '@mantine/core';
|
||||
import React, { useState } from 'react';
|
||||
import classes from './comment.module.css';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { timeAgo } from '@/lib/time';
|
||||
import CommentEditor from '@/features/comment/components/comment-editor';
|
||||
import { pageEditorAtom } from '@/features/editor/atoms/editor-atoms';
|
||||
import CommentActions from '@/features/comment/components/comment-actions';
|
||||
import CommentMenu from '@/features/comment/components/comment-menu';
|
||||
import { useHover } from '@mantine/hooks';
|
||||
import { useDeleteCommentMutation, useUpdateCommentMutation } from '@/features/comment/queries/comment-query';
|
||||
import { IComment } from '@/features/comment/types/comment.types';
|
||||
import { UserAvatar } from '@/components/ui/user-avatar';
|
||||
|
||||
interface CommentListItemProps {
|
||||
comment: IComment;
|
||||
}
|
||||
|
||||
function CommentListItem({ comment }: CommentListItemProps) {
|
||||
|
||||
const { hovered, ref } = useHover();
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const editor = useAtomValue(pageEditorAtom);
|
||||
const [content, setContent] = useState(comment.content);
|
||||
const updateCommentMutation = useUpdateCommentMutation();
|
||||
const deleteCommentMutation = useDeleteCommentMutation(comment.pageId);
|
||||
|
||||
async function handleUpdateComment() {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const commentToUpdate = {
|
||||
id: comment.id,
|
||||
content: JSON.stringify(content),
|
||||
};
|
||||
await updateCommentMutation.mutateAsync(commentToUpdate);
|
||||
setIsEditing(false);
|
||||
} catch (error) {
|
||||
console.error('Failed to update comment:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteComment() {
|
||||
try {
|
||||
await deleteCommentMutation.mutateAsync(comment.id);
|
||||
editor?.commands.unsetComment(comment.id);
|
||||
} catch (error) {
|
||||
console.error('Failed to delete comment:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function handleEditToggle() {
|
||||
setIsEditing(true);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box ref={ref} pb="xs">
|
||||
<Group>
|
||||
<UserAvatar color="blue" size="sm" avatarUrl={comment.creator.avatarUrl}
|
||||
name={comment.creator.name}
|
||||
/>
|
||||
|
||||
<div style={{ flex: 1 }}>
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Text size="sm" fw={500} lineClamp={1}>{comment.creator.name}</Text>
|
||||
|
||||
<div style={{ visibility: hovered ? 'visible' : 'hidden' }}>
|
||||
{/*!comment.parentCommentId && (
|
||||
<ResolveComment commentId={comment.id} pageId={comment.pageId} resolvedAt={comment.resolvedAt} />
|
||||
)*/}
|
||||
|
||||
<CommentMenu onEditComment={handleEditToggle} onDeleteComment={handleDeleteComment} />
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
<Text size="xs" fw={500} c="dimmed">
|
||||
{timeAgo(comment.createdAt)}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
<div>
|
||||
{!comment.parentCommentId && comment?.selection &&
|
||||
<Box className={classes.textSelection}>
|
||||
<Text size="sm">{comment?.selection}</Text>
|
||||
</Box>
|
||||
}
|
||||
|
||||
{
|
||||
!isEditing ?
|
||||
(<CommentEditor defaultContent={content} editable={false} />)
|
||||
:
|
||||
(<>
|
||||
<CommentEditor defaultContent={content} editable={true} onUpdate={(newContent) => setContent(newContent)}
|
||||
autofocus={true} />
|
||||
|
||||
<CommentActions onSave={handleUpdateComment} isLoading={isLoading} />
|
||||
</>)
|
||||
}
|
||||
|
||||
</div>
|
||||
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default CommentListItem;
|
||||
105
apps/client/src/features/comment/components/comment-list.tsx
Normal file
105
apps/client/src/features/comment/components/comment-list.tsx
Normal file
@ -0,0 +1,105 @@
|
||||
import React, { useState, useRef } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { Divider, Paper } from '@mantine/core';
|
||||
import CommentListItem from '@/features/comment/components/comment-list-item';
|
||||
import { useCommentsQuery, useCreateCommentMutation } from '@/features/comment/queries/comment-query';
|
||||
|
||||
import CommentEditor from '@/features/comment/components/comment-editor';
|
||||
import CommentActions from '@/features/comment/components/comment-actions';
|
||||
import { useFocusWithin } from '@mantine/hooks';
|
||||
|
||||
function CommentList() {
|
||||
const { pageId } = useParams();
|
||||
const { data: comments, isLoading: isCommentsLoading, isError } = useCommentsQuery(pageId);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const createCommentMutation = useCreateCommentMutation();
|
||||
|
||||
if (isCommentsLoading) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return <div>Error loading comments.</div>;
|
||||
}
|
||||
|
||||
if (!comments || comments.length === 0) {
|
||||
return <>No comments yet.</>;
|
||||
}
|
||||
|
||||
const renderComments = (comment) => {
|
||||
const handleAddReply = async (commentId, content) => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const commentData = {
|
||||
pageId: comment.pageId,
|
||||
parentCommentId: comment.id,
|
||||
content: JSON.stringify(content),
|
||||
};
|
||||
|
||||
await createCommentMutation.mutateAsync(commentData);
|
||||
} catch (error) {
|
||||
console.error('Failed to post comment:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Paper shadow="sm" radius="md" p="sm" mb="sm" withBorder key={comment.id} data-comment-id={comment.id}>
|
||||
<div>
|
||||
<CommentListItem comment={comment} />
|
||||
<ChildComments comments={comments} parentId={comment.id} />
|
||||
</div>
|
||||
|
||||
<Divider my={4} />
|
||||
|
||||
<CommentEditorWithActions commentId={comment.id} onSave={handleAddReply} isLoading={isLoading} />
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{comments.filter(comment => comment.parentCommentId === null).map(renderComments)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const ChildComments = ({ comments, parentId }) => {
|
||||
const getChildComments = (parentId) => {
|
||||
return comments.filter(comment => comment.parentCommentId === parentId);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{getChildComments(parentId).map(childComment => (
|
||||
<div key={childComment.id}>
|
||||
<CommentListItem comment={childComment} />
|
||||
<ChildComments comments={comments} parentId={childComment.id} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const CommentEditorWithActions = ({ commentId, onSave, isLoading }) => {
|
||||
const [content, setContent] = useState('');
|
||||
const { ref, focused } = useFocusWithin();
|
||||
const commentEditorRef = useRef(null);
|
||||
|
||||
const handleSave = () => {
|
||||
onSave(commentId, content);
|
||||
setContent('');
|
||||
commentEditorRef.current?.clearContent();
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={ref}>
|
||||
<CommentEditor ref={commentEditorRef} onUpdate={setContent} editable={true} />
|
||||
{focused && <CommentActions onSave={handleSave} isLoading={isLoading} />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
export default CommentList;
|
||||
45
apps/client/src/features/comment/components/comment-menu.tsx
Normal file
45
apps/client/src/features/comment/components/comment-menu.tsx
Normal file
@ -0,0 +1,45 @@
|
||||
import { ActionIcon, Menu } from '@mantine/core';
|
||||
import { IconDots, IconEdit, IconTrash } from '@tabler/icons-react';
|
||||
import { modals } from '@mantine/modals';
|
||||
|
||||
type CommentMenuProps = {
|
||||
onEditComment: () => void;
|
||||
onDeleteComment: () => void;
|
||||
};
|
||||
|
||||
function CommentMenu({ onEditComment, onDeleteComment }: CommentMenuProps) {
|
||||
|
||||
//@ts-ignore
|
||||
const openDeleteModal = () =>
|
||||
modals.openConfirmModal({
|
||||
title: 'Are you sure you want to delete this comment?',
|
||||
centered: true,
|
||||
labels: { confirm: 'Delete', cancel: 'Cancel' },
|
||||
confirmProps: { color: 'red' },
|
||||
onConfirm: onDeleteComment,
|
||||
});
|
||||
|
||||
return (
|
||||
<Menu shadow="md" width={200}>
|
||||
<Menu.Target>
|
||||
<ActionIcon variant="default" style={{ border: 'none' }}>
|
||||
<IconDots size={20} stroke={2} />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item onClick={onEditComment}
|
||||
leftSection={<IconEdit size={14} />}>
|
||||
Edit comment
|
||||
</Menu.Item>
|
||||
<Menu.Item leftSection={<IconTrash size={14} />}
|
||||
onClick={openDeleteModal}
|
||||
>
|
||||
Delete comment
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
|
||||
export default CommentMenu;
|
||||
@ -0,0 +1,45 @@
|
||||
.wrapper {
|
||||
padding: var(--mantine-spacing-md);
|
||||
}
|
||||
|
||||
.focused-thread {
|
||||
border: 2px solid #8d7249;
|
||||
}
|
||||
|
||||
.textSelection {
|
||||
margin-top: 4px;
|
||||
border-left: 2px solid var(--mantine-color-gray-6);
|
||||
padding: 8px;
|
||||
background: var(--mantine-color-gray-light);
|
||||
}
|
||||
|
||||
.commentEditor {
|
||||
|
||||
.focused {
|
||||
box-shadow: 0 0 0 2px var(--mantine-color-blue-3);
|
||||
}
|
||||
|
||||
.ProseMirror {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
max-height: 20vh;
|
||||
padding-left: 6px;
|
||||
padding-right: 6px;
|
||||
margin-top: 2px;
|
||||
margin-bottom: 2px;
|
||||
font-size: 14px;
|
||||
overflow: hidden auto;
|
||||
}
|
||||
|
||||
.ProseMirror p {
|
||||
margin-block-start: 0;
|
||||
margin-block-end: 0;
|
||||
}
|
||||
|
||||
.actions {
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,37 @@
|
||||
import { ActionIcon } from '@mantine/core';
|
||||
import { IconCircleCheck } from '@tabler/icons-react';
|
||||
import { modals } from '@mantine/modals';
|
||||
import { useResolveCommentMutation } from '@/features/comment/queries/comment-query';
|
||||
|
||||
function ResolveComment({ commentId, pageId, resolvedAt }) {
|
||||
const resolveCommentMutation = useResolveCommentMutation();
|
||||
const isResolved = resolvedAt != null;
|
||||
const iconColor = isResolved ? 'green' : 'gray';
|
||||
|
||||
//@ts-ignore
|
||||
const openConfirmModal = () =>
|
||||
modals.openConfirmModal({
|
||||
title: 'Are you sure you want to resolve this comment thread?',
|
||||
centered: true,
|
||||
labels: { confirm: 'Confirm', cancel: 'Cancel' },
|
||||
onConfirm: handleResolveToggle,
|
||||
});
|
||||
|
||||
const handleResolveToggle = async () => {
|
||||
try {
|
||||
await resolveCommentMutation.mutateAsync({ commentId, resolved: !isResolved });
|
||||
//TODO: remove comment mark
|
||||
// Remove comment thread from state on resolve
|
||||
} catch (error) {
|
||||
console.error('Failed to toggle resolved state:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ActionIcon onClick={openConfirmModal} variant="default" style={{ border: 'none' }}>
|
||||
<IconCircleCheck size={20} stroke={2} color={iconColor} />
|
||||
</ActionIcon>
|
||||
);
|
||||
}
|
||||
|
||||
export default ResolveComment;
|
||||
96
apps/client/src/features/comment/queries/comment-query.ts
Normal file
96
apps/client/src/features/comment/queries/comment-query.ts
Normal file
@ -0,0 +1,96 @@
|
||||
import { useMutation, useQuery, useQueryClient, UseQueryResult } from '@tanstack/react-query';
|
||||
import {
|
||||
createComment,
|
||||
deleteComment, getPageComments,
|
||||
resolveComment,
|
||||
updateComment,
|
||||
} from '@/features/comment/services/comment-service';
|
||||
import { IComment, IResolveComment } from '@/features/comment/types/comment.types';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
|
||||
export const RQ_KEY = (pageId: string) => ['comments', pageId];
|
||||
|
||||
export function useCommentsQuery(pageId: string): UseQueryResult<IComment[], Error> {
|
||||
return useQuery({
|
||||
queryKey: RQ_KEY(pageId),
|
||||
queryFn: () => getPageComments(pageId),
|
||||
enabled: !!pageId,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateCommentMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<IComment, Error, Partial<IComment>>({
|
||||
mutationFn: (data) => createComment(data),
|
||||
onSuccess: (data) => {
|
||||
const newComment = data;
|
||||
let comments = queryClient.getQueryData(RQ_KEY(data.pageId));
|
||||
if (comments) {
|
||||
comments = prevComments => [...prevComments, newComment];
|
||||
queryClient.setQueryData(RQ_KEY(data.pageId), comments);
|
||||
}
|
||||
|
||||
notifications.show({ message: 'Comment created successfully' });
|
||||
},
|
||||
onError: (error) => {
|
||||
notifications.show({ message: 'Error creating comment', color: 'red' });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateCommentMutation() {
|
||||
return useMutation<IComment, Error, Partial<IComment>>({
|
||||
mutationFn: (data) => updateComment(data),
|
||||
onSuccess: (data) => {
|
||||
notifications.show({ message: 'Comment updated successfully' });
|
||||
},
|
||||
onError: (error) => {
|
||||
notifications.show({ message: 'Failed to update comment', color: 'red' });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteCommentMutation(pageId?: string) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (commentId: string) => deleteComment(commentId),
|
||||
onSuccess: (data, variables) => {
|
||||
let comments = queryClient.getQueryData(RQ_KEY(pageId)) as IComment[];
|
||||
if (comments) {
|
||||
comments = comments.filter(comment => comment.id !== variables);
|
||||
queryClient.setQueryData(RQ_KEY(pageId), comments);
|
||||
}
|
||||
notifications.show({ message: 'Comment deleted successfully' });
|
||||
},
|
||||
onError: (error) => {
|
||||
notifications.show({ message: 'Failed to delete comment', color: 'red' });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useResolveCommentMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (data: IResolveComment) => resolveComment(data),
|
||||
onSuccess: (data: IComment, variables) => {
|
||||
|
||||
const currentComments = queryClient.getQueryData(RQ_KEY(data.pageId)) as IComment[];
|
||||
|
||||
if (currentComments) {
|
||||
const updatedComments = currentComments.map((comment) =>
|
||||
comment.id === variables.commentId ? { ...comment, ...data } : comment,
|
||||
);
|
||||
queryClient.setQueryData(RQ_KEY(data.pageId), updatedComments);
|
||||
}
|
||||
|
||||
notifications.show({ message: 'Comment resolved successfully' });
|
||||
},
|
||||
onError: (error) => {
|
||||
notifications.show({ message: 'Failed to resolve comment', color: 'red' });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
31
apps/client/src/features/comment/services/comment-service.ts
Normal file
31
apps/client/src/features/comment/services/comment-service.ts
Normal file
@ -0,0 +1,31 @@
|
||||
import api from '@/lib/api-client';
|
||||
import { IComment, IResolveComment } from '@/features/comment/types/comment.types';
|
||||
|
||||
export async function createComment(data: Partial<IComment>): Promise<IComment> {
|
||||
const req = await api.post<IComment>('/comments/create', data);
|
||||
return req.data as IComment;
|
||||
}
|
||||
|
||||
export async function resolveComment(data: IResolveComment): Promise<IComment> {
|
||||
const req = await api.post<IComment>(`/comments/resolve`, data);
|
||||
return req.data as IComment;
|
||||
}
|
||||
|
||||
export async function updateComment(data: Partial<IComment>): Promise<IComment> {
|
||||
const req = await api.post<IComment>(`/comments/update`, data);
|
||||
return req.data as IComment;
|
||||
}
|
||||
|
||||
export async function getCommentById(id: string): Promise<IComment> {
|
||||
const req = await api.post<IComment>('/comments/view', { id });
|
||||
return req.data as IComment;
|
||||
}
|
||||
|
||||
export async function getPageComments(pageId: string): Promise<IComment[]> {
|
||||
const req = await api.post<IComment[]>('/comments', { pageId });
|
||||
return req.data as IComment[];
|
||||
}
|
||||
|
||||
export async function deleteComment(id: string): Promise<void> {
|
||||
await api.post('/comments/delete', { id });
|
||||
}
|
||||
31
apps/client/src/features/comment/types/comment.types.ts
Normal file
31
apps/client/src/features/comment/types/comment.types.ts
Normal file
@ -0,0 +1,31 @@
|
||||
import { IUser } from '@/features/user/types/user.types';
|
||||
|
||||
export interface IComment {
|
||||
id: string;
|
||||
content: string;
|
||||
selection?: string;
|
||||
type?: string;
|
||||
creatorId: string;
|
||||
pageId: string;
|
||||
parentCommentId?: string;
|
||||
resolvedById?: string;
|
||||
resolvedAt?: Date;
|
||||
workspaceId: string;
|
||||
createdAt: Date;
|
||||
editedAt?: Date;
|
||||
deletedAt?: Date;
|
||||
creator: IUser
|
||||
}
|
||||
|
||||
export interface ICommentData {
|
||||
id: string;
|
||||
pageId: string;
|
||||
parentCommentId?: string;
|
||||
content: any;
|
||||
selection?: string;
|
||||
}
|
||||
|
||||
export interface IResolveComment {
|
||||
commentId: string;
|
||||
resolved: boolean;
|
||||
}
|
||||
6
apps/client/src/features/editor/atoms/editor-atoms.ts
Normal file
6
apps/client/src/features/editor/atoms/editor-atoms.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { atom } from 'jotai';
|
||||
import { Editor } from '@tiptap/core';
|
||||
|
||||
export const pageEditorAtom = atom<Editor | null>(null);
|
||||
|
||||
export const titleEditorAtom = atom<Editor | null>(null);
|
||||
@ -0,0 +1,25 @@
|
||||
.bubbleMenu {
|
||||
display: flex;
|
||||
width: fit-content;
|
||||
border-radius: 2px;
|
||||
border: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-gray-8));
|
||||
|
||||
.active {
|
||||
color: var(--mantine-color-blue-8);
|
||||
}
|
||||
|
||||
.colorButton {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.colorButton::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 1px;
|
||||
background-color: light-dark(var(--mantine-color-gray-3), var(--mantine-color-gray-8));
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,149 @@
|
||||
import { BubbleMenu, BubbleMenuProps, isNodeSelection } from '@tiptap/react';
|
||||
import { FC, useState } from 'react';
|
||||
import { IconBold, IconCode, IconItalic, IconStrikethrough, IconUnderline, IconMessage } from '@tabler/icons-react';
|
||||
import clsx from 'clsx';
|
||||
import classes from './bubble-menu.module.css';
|
||||
import { ActionIcon, rem, Tooltip } from '@mantine/core';
|
||||
import { ColorSelector } from './color-selector';
|
||||
import { NodeSelector } from './node-selector';
|
||||
import { draftCommentIdAtom, showCommentPopupAtom } from '@/features/comment/atoms/comment-atom';
|
||||
import { useAtom } from 'jotai';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
export interface BubbleMenuItem {
|
||||
name: string;
|
||||
isActive: () => boolean;
|
||||
command: () => void;
|
||||
icon: typeof IconBold;
|
||||
}
|
||||
|
||||
type EditorBubbleMenuProps = Omit<BubbleMenuProps, 'children'>;
|
||||
|
||||
export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
||||
const [, setShowCommentPopup] = useAtom(showCommentPopupAtom);
|
||||
const [, setDraftCommentId] = useAtom(draftCommentIdAtom);
|
||||
|
||||
const items: BubbleMenuItem[] = [
|
||||
{
|
||||
name: 'bold',
|
||||
isActive: () => props.editor.isActive('bold'),
|
||||
command: () => props.editor.chain().focus().toggleBold().run(),
|
||||
icon: IconBold,
|
||||
},
|
||||
{
|
||||
name: 'italic',
|
||||
isActive: () => props.editor.isActive('italic'),
|
||||
command: () => props.editor.chain().focus().toggleItalic().run(),
|
||||
icon: IconItalic,
|
||||
},
|
||||
{
|
||||
name: 'underline',
|
||||
isActive: () => props.editor.isActive('underline'),
|
||||
command: () => props.editor.chain().focus().toggleUnderline().run(),
|
||||
icon: IconUnderline,
|
||||
},
|
||||
{
|
||||
name: 'strike',
|
||||
isActive: () => props.editor.isActive('strike'),
|
||||
command: () => props.editor.chain().focus().toggleStrike().run(),
|
||||
icon: IconStrikethrough,
|
||||
},
|
||||
{
|
||||
name: 'code',
|
||||
isActive: () => props.editor.isActive('code'),
|
||||
command: () => props.editor.chain().focus().toggleCode().run(),
|
||||
icon: IconCode,
|
||||
},
|
||||
];
|
||||
|
||||
const commentItem: BubbleMenuItem = {
|
||||
|
||||
name: 'comment',
|
||||
isActive: () => props.editor.isActive('comment'),
|
||||
command: () => {
|
||||
const commentId = uuidv4();
|
||||
|
||||
props.editor.chain().focus().setCommentDecoration().run();
|
||||
setDraftCommentId(commentId);
|
||||
setShowCommentPopup(true);
|
||||
},
|
||||
icon: IconMessage,
|
||||
};
|
||||
|
||||
const bubbleMenuProps: EditorBubbleMenuProps = {
|
||||
...props,
|
||||
shouldShow: ({ state, editor }) => {
|
||||
const { selection } = state;
|
||||
const { empty } = selection;
|
||||
|
||||
if (editor.isActive('image') || empty || isNodeSelection(selection)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
tippyOptions: {
|
||||
moveTransition: 'transform 0.15s ease-out',
|
||||
onHidden: () => {
|
||||
setIsNodeSelectorOpen(false);
|
||||
setIsColorSelectorOpen(false);
|
||||
setIsLinkSelectorOpen(false);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const [isNodeSelectorOpen, setIsNodeSelectorOpen] = useState(false);
|
||||
const [isColorSelectorOpen, setIsColorSelectorOpen] = useState(false);
|
||||
const [isLinkSelectorOpen, setIsLinkSelectorOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<BubbleMenu
|
||||
{...bubbleMenuProps}
|
||||
className={classes.bubbleMenu}
|
||||
>
|
||||
<NodeSelector
|
||||
editor={props.editor}
|
||||
isOpen={isNodeSelectorOpen}
|
||||
setIsOpen={() => {
|
||||
setIsNodeSelectorOpen(!isNodeSelectorOpen);
|
||||
setIsColorSelectorOpen(false);
|
||||
setIsLinkSelectorOpen(false);
|
||||
}}
|
||||
/>
|
||||
|
||||
<ActionIcon.Group>
|
||||
{items.map((item, index) => (
|
||||
<Tooltip key={index} label={item.name} withArrow>
|
||||
|
||||
<ActionIcon key={index} variant="default" size="lg" radius="0" aria-label={item.name}
|
||||
className={clsx({ [classes.active]: item.isActive() })}
|
||||
style={{ border: 'none' }}
|
||||
onClick={item.command}>
|
||||
<item.icon style={{ width: rem(16) }} stroke={2} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
))}
|
||||
</ActionIcon.Group>
|
||||
|
||||
<ColorSelector
|
||||
editor={props.editor}
|
||||
isOpen={isColorSelectorOpen}
|
||||
setIsOpen={() => {
|
||||
setIsColorSelectorOpen(!isColorSelectorOpen);
|
||||
setIsNodeSelectorOpen(false);
|
||||
setIsLinkSelectorOpen(false);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Tooltip label={commentItem.name} withArrow>
|
||||
|
||||
<ActionIcon variant="default" size="lg" radius="0" aria-label={commentItem.name}
|
||||
style={{ border: 'none' }}
|
||||
onClick={commentItem.command}>
|
||||
<IconMessage style={{ width: rem(16) }} stroke={2} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
</BubbleMenu>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,189 @@
|
||||
import { Editor } from '@tiptap/core';
|
||||
import { Dispatch, FC, SetStateAction } from 'react';
|
||||
import { IconCheck, IconChevronDown } from '@tabler/icons-react';
|
||||
import { Button, Popover, rem, ScrollArea, Text } from '@mantine/core';
|
||||
import classes from './bubble-menu.module.css';
|
||||
|
||||
export interface BubbleColorMenuItem {
|
||||
name: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
interface ColorSelectorProps {
|
||||
editor: Editor;
|
||||
isOpen: boolean;
|
||||
setIsOpen: Dispatch<SetStateAction<boolean>>;
|
||||
}
|
||||
|
||||
const TEXT_COLORS: BubbleColorMenuItem[] = [
|
||||
{
|
||||
name: 'Default',
|
||||
color: '',
|
||||
},
|
||||
{
|
||||
name: 'Blue',
|
||||
color: '#2563EB',
|
||||
},
|
||||
{
|
||||
name: 'Green',
|
||||
color: '#008A00',
|
||||
},
|
||||
{
|
||||
name: 'Purple',
|
||||
color: '#9333EA',
|
||||
},
|
||||
{
|
||||
name: 'Red',
|
||||
color: '#E00000',
|
||||
},
|
||||
{
|
||||
name: 'Yellow',
|
||||
color: '#EAB308',
|
||||
},
|
||||
{
|
||||
name: 'Orange',
|
||||
color: '#FFA500',
|
||||
},
|
||||
{
|
||||
name: 'Pink',
|
||||
color: '#BA4081',
|
||||
},
|
||||
{
|
||||
name: 'Gray',
|
||||
color: '#A8A29E',
|
||||
},
|
||||
];
|
||||
|
||||
// TODO: handle dark mode
|
||||
const HIGHLIGHT_COLORS: BubbleColorMenuItem[] = [
|
||||
{
|
||||
name: 'Default',
|
||||
color: '',
|
||||
},
|
||||
{
|
||||
name: 'Blue',
|
||||
color: '#c1ecf9',
|
||||
},
|
||||
{
|
||||
name: 'Green',
|
||||
color: '#acf79f',
|
||||
},
|
||||
{
|
||||
name: 'Purple',
|
||||
color: '#f6f3f8',
|
||||
},
|
||||
{
|
||||
name: 'Red',
|
||||
color: '#fdebeb',
|
||||
},
|
||||
{
|
||||
name: 'Yellow',
|
||||
color: '#fbf4a2',
|
||||
},
|
||||
{
|
||||
name: 'Orange',
|
||||
color: '#faebdd',
|
||||
},
|
||||
{
|
||||
name: 'Pink',
|
||||
color: '#faf1f5',
|
||||
},
|
||||
{
|
||||
name: 'Gray',
|
||||
color: '#f1f1ef',
|
||||
},
|
||||
];
|
||||
|
||||
export const ColorSelector: FC<ColorSelectorProps> =
|
||||
({ editor, isOpen, setIsOpen }) => {
|
||||
|
||||
const activeColorItem = TEXT_COLORS.find(({ color }) =>
|
||||
editor.isActive('textStyle', { color }),
|
||||
);
|
||||
|
||||
const activeHighlightItem = HIGHLIGHT_COLORS.find(({ color }) =>
|
||||
editor.isActive('highlight', { color }),
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover width={200} opened={isOpen} withArrow>
|
||||
<Popover.Target>
|
||||
|
||||
<Button variant="default" radius="0"
|
||||
leftSection="A"
|
||||
rightSection={<IconChevronDown size={16} />}
|
||||
className={classes.colorButton}
|
||||
style={{
|
||||
color: activeColorItem?.color,
|
||||
}}
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
/>
|
||||
</Popover.Target>
|
||||
|
||||
<Popover.Dropdown>
|
||||
{/* make mah responsive */}
|
||||
<ScrollArea.Autosize type="scroll" mah='400'>
|
||||
|
||||
<Text span c="dimmed" inherit>COLOR</Text>
|
||||
|
||||
<Button.Group orientation="vertical">
|
||||
{TEXT_COLORS.map(({ name, color }, index) => (
|
||||
<Button
|
||||
key={index}
|
||||
variant="default"
|
||||
leftSection={<span style={{ color }}>A</span>}
|
||||
justify="left"
|
||||
fullWidth
|
||||
rightSection={editor.isActive('textStyle', { color })
|
||||
&& (<IconCheck style={{ width: rem(16) }} />)}
|
||||
|
||||
onClick={() => {
|
||||
editor.commands.unsetColor();
|
||||
name !== 'Default' &&
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.setColor(color || '')
|
||||
.run();
|
||||
setIsOpen(false);
|
||||
}}
|
||||
style={{ border: 'none' }}
|
||||
>
|
||||
{name}
|
||||
</Button>
|
||||
))}
|
||||
</Button.Group>
|
||||
|
||||
<Text span c="dimmed" inherit>BACKGROUND</Text>
|
||||
|
||||
<Button.Group orientation="vertical">
|
||||
{HIGHLIGHT_COLORS.map(({ name, color }, index) => (
|
||||
<Button
|
||||
key={index}
|
||||
variant="default"
|
||||
leftSection={<span style={{ padding: '4px', background: color }}>A</span>}
|
||||
justify="left"
|
||||
fullWidth
|
||||
rightSection={editor.isActive('highlight', { color })
|
||||
&& (<IconCheck style={{ width: rem(16) }} />)}
|
||||
|
||||
onClick={() => {
|
||||
editor.commands.unsetHighlight();
|
||||
name !== 'Default' &&
|
||||
editor
|
||||
.commands
|
||||
.setHighlight({ color });
|
||||
setIsOpen(false);
|
||||
}}
|
||||
style={{ border: 'none' }}
|
||||
>
|
||||
{name}
|
||||
</Button>
|
||||
))}
|
||||
</Button.Group>
|
||||
|
||||
</ScrollArea.Autosize>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,148 @@
|
||||
import { Editor } from '@tiptap/core';
|
||||
import React, { Dispatch, FC, SetStateAction } from 'react';
|
||||
import {
|
||||
IconBlockquote,
|
||||
IconCheck, IconCheckbox, IconChevronDown, IconCode,
|
||||
IconH1,
|
||||
IconH2,
|
||||
IconH3,
|
||||
IconList,
|
||||
IconListNumbers,
|
||||
IconTypography,
|
||||
} from '@tabler/icons-react';
|
||||
import { Popover, Button, rem, ScrollArea } from '@mantine/core';
|
||||
import classes from '@/features/editor/components/bubble-menu/bubble-menu.module.css';
|
||||
|
||||
interface NodeSelectorProps {
|
||||
editor: Editor;
|
||||
isOpen: boolean;
|
||||
setIsOpen: Dispatch<SetStateAction<boolean>>;
|
||||
}
|
||||
|
||||
export interface BubbleMenuItem {
|
||||
name: string;
|
||||
icon: React.ElementType;
|
||||
command: () => void;
|
||||
isActive: () => boolean;
|
||||
}
|
||||
|
||||
export const NodeSelector: FC<NodeSelectorProps> =
|
||||
({ editor, isOpen, setIsOpen }) => {
|
||||
|
||||
const items: BubbleMenuItem[] = [
|
||||
{
|
||||
name: 'Text',
|
||||
icon: IconTypography,
|
||||
command: () =>
|
||||
editor.chain().focus().toggleNode('paragraph', 'paragraph').run(),
|
||||
isActive: () =>
|
||||
editor.isActive('paragraph') &&
|
||||
!editor.isActive('bulletList') &&
|
||||
!editor.isActive('orderedList'),
|
||||
},
|
||||
{
|
||||
name: 'Heading 1',
|
||||
icon: IconH1,
|
||||
command: () => editor.chain().focus().toggleHeading({ level: 1 }).run(),
|
||||
isActive: () => editor.isActive('heading', { level: 1 }),
|
||||
},
|
||||
{
|
||||
name: 'Heading 2',
|
||||
icon: IconH2,
|
||||
command: () => editor.chain().focus().toggleHeading({ level: 2 }).run(),
|
||||
isActive: () => editor.isActive('heading', { level: 2 }),
|
||||
},
|
||||
{
|
||||
name: 'Heading 3',
|
||||
icon: IconH3,
|
||||
command: () => editor.chain().focus().toggleHeading({ level: 3 }).run(),
|
||||
isActive: () => editor.isActive('heading', { level: 3 }),
|
||||
},
|
||||
{
|
||||
name: 'To-do List',
|
||||
icon: IconCheckbox,
|
||||
command: () => editor.chain().focus().toggleTaskList().run(),
|
||||
isActive: () => editor.isActive('taskItem'),
|
||||
},
|
||||
{
|
||||
name: 'Bullet List',
|
||||
icon: IconList,
|
||||
command: () => editor.chain().focus().toggleBulletList().run(),
|
||||
isActive: () => editor.isActive('bulletList'),
|
||||
},
|
||||
{
|
||||
name: 'Numbered List',
|
||||
icon: IconListNumbers,
|
||||
command: () => editor.chain().focus().toggleOrderedList().run(),
|
||||
isActive: () => editor.isActive('orderedList'),
|
||||
},
|
||||
{
|
||||
name: 'Blockquote',
|
||||
icon: IconBlockquote,
|
||||
command: () =>
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.toggleNode('paragraph', 'paragraph')
|
||||
.toggleBlockquote()
|
||||
.run(),
|
||||
isActive: () => editor.isActive('blockquote'),
|
||||
},
|
||||
{
|
||||
name: 'Code',
|
||||
icon: IconCode,
|
||||
command: () => editor.chain().focus().toggleCodeBlock().run(),
|
||||
isActive: () => editor.isActive('codeBlock'),
|
||||
},
|
||||
];
|
||||
|
||||
const activeItem = items.filter((item) => item.isActive()).pop() ?? {
|
||||
name: 'Multiple',
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover opened={isOpen} withArrow>
|
||||
|
||||
<Popover.Target>
|
||||
<Button variant="default" radius="0"
|
||||
rightSection={<IconChevronDown size={16} />}
|
||||
className={classes.colorButton}
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
>
|
||||
{activeItem?.name}
|
||||
</Button>
|
||||
|
||||
</Popover.Target>
|
||||
|
||||
<Popover.Dropdown>
|
||||
<ScrollArea.Autosize type="scroll" mah={400}>
|
||||
|
||||
<Button.Group orientation="vertical">
|
||||
|
||||
{items.map((item, index) => (
|
||||
|
||||
<Button
|
||||
key={index}
|
||||
variant="default"
|
||||
leftSection={<item.icon size={16} />}
|
||||
rightSection={activeItem.name === item.name
|
||||
&& (<IconCheck size={16} />)}
|
||||
justify="left"
|
||||
fullWidth
|
||||
onClick={() => {
|
||||
item.command();
|
||||
setIsOpen(false);
|
||||
}}
|
||||
style={{ border: 'none' }}
|
||||
>
|
||||
{item.name}
|
||||
</Button>
|
||||
))}
|
||||
|
||||
</Button.Group>
|
||||
</ScrollArea.Autosize>
|
||||
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,39 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Skeleton } from '@mantine/core';
|
||||
|
||||
function EditorSkeleton() {
|
||||
const [showSkeleton, setShowSkeleton] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setShowSkeleton(true), 100);
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
if (!showSkeleton) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Skeleton height={12} mt={6} radius="xl" />
|
||||
<Skeleton height={12} mt={6} radius="xl" />
|
||||
<Skeleton height={12} mt={6} radius="xl" />
|
||||
<Skeleton height={12} mt={6} radius="xl" />
|
||||
<Skeleton height={12} mt={6} radius="xl" />
|
||||
<Skeleton height={12} mt={6} radius="xl" />
|
||||
<Skeleton height={12} mt={6} radius="xl" />
|
||||
<Skeleton height={12} mt={6} radius="xl" />
|
||||
<Skeleton height={12} mt={6} radius="xl" />
|
||||
<Skeleton height={12} mt={6} radius="xl" />
|
||||
<Skeleton height={12} mt={6} radius="xl" />
|
||||
<Skeleton height={12} mt={6} radius="xl" />
|
||||
<Skeleton height={12} mt={6} radius="xl" />
|
||||
<Skeleton height={12} mt={6} radius="xl" />
|
||||
<Skeleton height={12} mt={6} radius="xl" />
|
||||
<Skeleton height={12} mt={6} radius="xl" />
|
||||
</>
|
||||
|
||||
);
|
||||
}
|
||||
|
||||
export default EditorSkeleton;
|
||||
@ -0,0 +1,132 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import {
|
||||
SlashMenuGroupedItemsType,
|
||||
SlashMenuItemType,
|
||||
} from '@/features/editor/components/slash-menu/types';
|
||||
import {
|
||||
Group,
|
||||
Paper,
|
||||
ScrollArea,
|
||||
Text,
|
||||
UnstyledButton,
|
||||
} from '@mantine/core';
|
||||
import classes from './slash-menu.module.css';
|
||||
import clsx from 'clsx';
|
||||
|
||||
const CommandList = ({
|
||||
items,
|
||||
command,
|
||||
editor,
|
||||
range,
|
||||
}: {
|
||||
items: SlashMenuGroupedItemsType;
|
||||
command: any;
|
||||
editor: any;
|
||||
range: any;
|
||||
}) => {
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const viewportRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const flatItems = useMemo(() => {
|
||||
return Object.values(items).flat();
|
||||
}, [items]);
|
||||
|
||||
const selectItem = useCallback(
|
||||
(index: number) => {
|
||||
const item = flatItems[index];
|
||||
if (item) {
|
||||
command(item);
|
||||
}
|
||||
},
|
||||
[command, flatItems],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const navigationKeys = ['ArrowUp', 'ArrowDown', 'Enter'];
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (navigationKeys.includes(e.key)) {
|
||||
e.preventDefault();
|
||||
|
||||
if (e.key === 'ArrowUp') {
|
||||
setSelectedIndex((selectedIndex + flatItems.length - 1) % flatItems.length);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (e.key === 'ArrowDown') {
|
||||
setSelectedIndex((selectedIndex + 1) % flatItems.length);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (e.key === 'Enter') {
|
||||
selectItem(selectedIndex);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', onKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener('keydown', onKeyDown);
|
||||
};
|
||||
}, [flatItems, selectedIndex, setSelectedIndex, selectItem]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedIndex(0);
|
||||
}, [flatItems]);
|
||||
|
||||
useEffect(() => {
|
||||
viewportRef.current
|
||||
?.querySelector(`[data-item-index="${selectedIndex}"]`)
|
||||
?.scrollIntoView({ block: 'nearest' });
|
||||
}, [selectedIndex]);
|
||||
|
||||
return flatItems.length > 0 ? (
|
||||
<Paper id="slash-command" shadow="xl" p="sm" withBorder>
|
||||
<ScrollArea viewportRef={viewportRef} h={350} w={250} scrollbarSize={5}>
|
||||
{Object.entries(items).map(([category, categoryItems]) => (
|
||||
<div key={category}>
|
||||
<Text c="dimmed" mb={4} fw={500} tt="capitalize">
|
||||
{category}
|
||||
</Text>
|
||||
{categoryItems.map((item: SlashMenuItemType, index: number) => (
|
||||
<UnstyledButton
|
||||
data-item-index={index}
|
||||
key={index}
|
||||
onClick={() => selectItem(index)}
|
||||
className={clsx(classes.menuBtn, { [classes.selectedItem]: index === selectedIndex })}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: 'var(--mantine-spacing-xs)',
|
||||
color: 'var(--mantine-color-text)',
|
||||
borderRadius: 'var(--mantine-radius-sm)',
|
||||
}}
|
||||
>
|
||||
<Group>
|
||||
<item.icon size={18} />
|
||||
|
||||
<div style={{ flex: 1 }}>
|
||||
<Text size="sm" fw={500}>
|
||||
{item.title}
|
||||
</Text>
|
||||
|
||||
<Text c="dimmed" size="xs">
|
||||
{item.description}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</ScrollArea>
|
||||
</Paper>
|
||||
) : null;
|
||||
};
|
||||
|
||||
export default CommandList;
|
||||
@ -0,0 +1,163 @@
|
||||
import {
|
||||
IconBlockquote,
|
||||
IconCheckbox, IconCode,
|
||||
IconH1,
|
||||
IconH2,
|
||||
IconH3,
|
||||
IconList,
|
||||
IconListNumbers, IconPhoto,
|
||||
IconTypography,
|
||||
} from '@tabler/icons-react';
|
||||
import { CommandProps, SlashMenuGroupedItemsType } from '@/features/editor/components/slash-menu/types';
|
||||
|
||||
const CommandGroups: SlashMenuGroupedItemsType = {
|
||||
basic: [
|
||||
{
|
||||
title: 'Text',
|
||||
description: 'Just start typing with plain text.',
|
||||
searchTerms: ['p', 'paragraph'],
|
||||
icon: IconTypography,
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.deleteRange(range)
|
||||
.toggleNode('paragraph', 'paragraph')
|
||||
.run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'To-do List',
|
||||
description: 'Track tasks with a to-do list.',
|
||||
searchTerms: ['todo', 'task', 'list', 'check', 'checkbox'],
|
||||
icon: IconCheckbox,
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
editor.chain().focus().deleteRange(range).toggleTaskList().run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Heading 1',
|
||||
description: 'Big section heading.',
|
||||
searchTerms: ['title', 'big', 'large'],
|
||||
icon: IconH1,
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.deleteRange(range)
|
||||
.setNode('heading', { level: 1 })
|
||||
.run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Heading 2',
|
||||
description: 'Medium section heading.',
|
||||
searchTerms: ['subtitle', 'medium'],
|
||||
icon: IconH2,
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.deleteRange(range)
|
||||
.setNode('heading', { level: 2 })
|
||||
.run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Heading 3',
|
||||
description: 'Small section heading.',
|
||||
searchTerms: ['subtitle', 'small'],
|
||||
icon: IconH3,
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.deleteRange(range)
|
||||
.setNode('heading', { level: 3 })
|
||||
.run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Bullet List',
|
||||
description: 'Create a simple bullet list.',
|
||||
searchTerms: ['unordered', 'point'],
|
||||
icon: IconList,
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
editor.chain().focus().deleteRange(range).toggleBulletList().run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Numbered List',
|
||||
description: 'Create a list with numbering.',
|
||||
searchTerms: ['ordered'],
|
||||
icon: IconListNumbers,
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
editor.chain().focus().deleteRange(range).toggleOrderedList().run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Quote',
|
||||
description: 'Capture a quote.',
|
||||
searchTerms: ['blockquote', 'quotes'],
|
||||
icon: IconBlockquote,
|
||||
command: ({ editor, range }: CommandProps) =>
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.deleteRange(range)
|
||||
.toggleNode('paragraph', 'paragraph')
|
||||
.toggleBlockquote()
|
||||
.run(),
|
||||
},
|
||||
{
|
||||
title: 'Code',
|
||||
description: 'Capture a code snippet.',
|
||||
searchTerms: ['codeblock'],
|
||||
icon: IconCode,
|
||||
command: ({ editor, range }: CommandProps) =>
|
||||
editor.chain().focus().deleteRange(range).toggleCodeBlock().run(),
|
||||
},
|
||||
{
|
||||
title: 'Image',
|
||||
description: 'Upload an image from your computer.',
|
||||
searchTerms: ['photo', 'picture', 'media'],
|
||||
icon: IconPhoto,
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
editor.chain().focus().deleteRange(range).run();
|
||||
// upload image
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = 'image/*';
|
||||
input.onchange = async () => {
|
||||
if (input.files?.length) {
|
||||
const file = input.files[0];
|
||||
const pos = editor.view.state.selection.from;
|
||||
//startImageUpload(file, editor.view, pos);
|
||||
}
|
||||
};
|
||||
input.click();
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const getSuggestionItems = ({ query }: { query: string }): SlashMenuGroupedItemsType => {
|
||||
const search = query.toLowerCase();
|
||||
const filteredGroups: SlashMenuGroupedItemsType = {};
|
||||
|
||||
for (const [group, items] of Object.entries(CommandGroups)) {
|
||||
const filteredItems = items.filter((item) => {
|
||||
return item.title.toLowerCase().includes(search)
|
||||
|| item.description.toLowerCase().includes(search)
|
||||
|| (item.searchTerms && item.searchTerms.some((term: string) => term.includes(search)));
|
||||
});
|
||||
|
||||
if (filteredItems.length) {
|
||||
filteredGroups[group] = filteredItems;
|
||||
}
|
||||
}
|
||||
|
||||
return filteredGroups;
|
||||
};
|
||||
|
||||
export default getSuggestionItems;
|
||||
@ -0,0 +1,66 @@
|
||||
import { Editor } from '@tiptap/core';
|
||||
import { ReactRenderer } from '@tiptap/react';
|
||||
import CommandList from '@/features/editor/components/slash-menu/command-list';
|
||||
import tippy from 'tippy.js';
|
||||
|
||||
const renderItems = () => {
|
||||
let component: ReactRenderer | null = null;
|
||||
let popup: any | null = null;
|
||||
|
||||
return {
|
||||
onStart: (props: { editor: Editor; clientRect: DOMRect }) => {
|
||||
component = new ReactRenderer(CommandList, {
|
||||
props,
|
||||
editor: props.editor,
|
||||
});
|
||||
|
||||
if (!props.clientRect) {
|
||||
return;
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
popup = tippy('body', {
|
||||
getReferenceClientRect: props.clientRect,
|
||||
appendTo: () => document.body,
|
||||
content: component.element,
|
||||
showOnCreate: true,
|
||||
interactive: true,
|
||||
trigger: 'manual',
|
||||
placement: 'bottom-start',
|
||||
});
|
||||
},
|
||||
onUpdate: (props: { editor: Editor; clientRect: DOMRect }) => {
|
||||
component?.updateProps(props);
|
||||
|
||||
if (!props.clientRect) {
|
||||
return;
|
||||
}
|
||||
|
||||
popup &&
|
||||
popup[0].setProps({
|
||||
getReferenceClientRect: props.clientRect,
|
||||
});
|
||||
},
|
||||
onKeyDown: (props: { event: KeyboardEvent }) => {
|
||||
if (props.event.key === 'Escape') {
|
||||
popup?.[0].hide();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
return component?.ref?.onKeyDown(props);
|
||||
},
|
||||
onExit: () => {
|
||||
if (popup && !popup[0].state.isDestroyed) {
|
||||
popup[0].destroy();
|
||||
}
|
||||
|
||||
if (component) {
|
||||
component.destroy();
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export default renderItems;
|
||||
@ -0,0 +1,21 @@
|
||||
.menuBtn {
|
||||
&:hover {
|
||||
@mixin light {
|
||||
background: var(--mantine-color-gray-2);
|
||||
}
|
||||
|
||||
@mixin dark {
|
||||
background: var(--mantine-color-gray-light);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.selectedItem {
|
||||
@mixin light {
|
||||
background: var(--mantine-color-gray-2);
|
||||
}
|
||||
|
||||
@mixin dark {
|
||||
background: var(--mantine-color-gray-light);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,27 @@
|
||||
import { Editor, Range } from '@tiptap/core';
|
||||
|
||||
export type CommandProps = {
|
||||
editor: Editor;
|
||||
range: Range;
|
||||
}
|
||||
|
||||
export type CommandListProps = {
|
||||
items: SlashMenuGroupedItemsType;
|
||||
command: (item: SlashMenuItemType) => void;
|
||||
editor: Editor;
|
||||
range: Range;
|
||||
}
|
||||
|
||||
export type SlashMenuItemType = {
|
||||
title: string;
|
||||
description: string;
|
||||
icon: any;
|
||||
separator?: true;
|
||||
searchTerms: string[];
|
||||
command: (props: CommandProps) => void;
|
||||
disable?: (editor: Editor) => boolean;
|
||||
}
|
||||
|
||||
export type SlashMenuGroupedItemsType = {
|
||||
[category: string]: SlashMenuItemType[];
|
||||
};
|
||||
@ -0,0 +1,35 @@
|
||||
import { Plugin, PluginKey } from '@tiptap/pm/state';
|
||||
import { Decoration, DecorationSet } from '@tiptap/pm/view';
|
||||
import { commentDecorationMetaKey, commentMarkClass } from '@/features/editor/extensions/comment/comment';
|
||||
|
||||
export function commentDecoration(): Plugin {
|
||||
const commentDecorationPlugin = new PluginKey('commentDecoration');
|
||||
|
||||
return new Plugin({
|
||||
key: commentDecorationPlugin,
|
||||
state: {
|
||||
init() {
|
||||
return DecorationSet.empty;
|
||||
},
|
||||
apply(tr, oldSet) {
|
||||
const decorationMeta = tr.getMeta(commentDecorationMetaKey);
|
||||
|
||||
if (decorationMeta) {
|
||||
const { from, to } = tr.selection;
|
||||
const decoration = Decoration.inline(from, to, { class: commentMarkClass });
|
||||
return DecorationSet.create(tr.doc, [decoration]);
|
||||
} else if (decorationMeta === false) {
|
||||
return DecorationSet.empty;
|
||||
}
|
||||
|
||||
return oldSet.map(tr.mapping, tr.doc);
|
||||
},
|
||||
},
|
||||
|
||||
props: {
|
||||
decorations: (state) => {
|
||||
return commentDecorationPlugin.getState(state);
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
137
apps/client/src/features/editor/extensions/comment/comment.ts
Normal file
137
apps/client/src/features/editor/extensions/comment/comment.ts
Normal file
@ -0,0 +1,137 @@
|
||||
import { Mark, mergeAttributes } from '@tiptap/core';
|
||||
import { commentDecoration } from '@/features/editor/extensions/comment/comment-decoration';
|
||||
|
||||
export interface ICommentOptions {
|
||||
HTMLAttributes: Record<string, any>,
|
||||
}
|
||||
|
||||
export interface ICommentStorage {
|
||||
activeCommentId: string | null;
|
||||
}
|
||||
|
||||
export const commentMarkClass = 'comment-mark';
|
||||
export const commentDecorationMetaKey = 'decorateComment';
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
interface Commands<ReturnType> {
|
||||
comment: {
|
||||
setCommentDecoration: () => ReturnType,
|
||||
unsetCommentDecoration: () => ReturnType,
|
||||
setComment: (commentId: string) => ReturnType,
|
||||
unsetComment: (commentId: string) => ReturnType,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const Comment = Mark.create<ICommentOptions, ICommentStorage>({
|
||||
name: 'comment',
|
||||
exitable: true,
|
||||
inclusive: false,
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
HTMLAttributes: {},
|
||||
};
|
||||
},
|
||||
|
||||
addStorage() {
|
||||
return {
|
||||
activeCommentId: null,
|
||||
};
|
||||
},
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
commentId: {
|
||||
default: null,
|
||||
parseHTML: element => element.getAttribute('data-comment-id'),
|
||||
renderHTML: (attributes) => {
|
||||
if (!attributes.commentId) return;
|
||||
|
||||
return {
|
||||
'data-comment-id': attributes.commentId,
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: 'span[data-comment-id]',
|
||||
getAttrs: (el) => !!(el as HTMLSpanElement).getAttribute('data-comment-id')?.trim() && null,
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
setCommentDecoration: () => ({ tr, dispatch }) => {
|
||||
tr.setMeta(commentDecorationMetaKey, true);
|
||||
if (dispatch) dispatch(tr);
|
||||
return true;
|
||||
},
|
||||
unsetCommentDecoration: () => ({ tr, dispatch }) => {
|
||||
tr.setMeta(commentDecorationMetaKey, false);
|
||||
if (dispatch) dispatch(tr);
|
||||
return true;
|
||||
},
|
||||
setComment: (commentId) => ({ commands }) => {
|
||||
if (!commentId) return false;
|
||||
return commands.setMark(this.name, { commentId });
|
||||
},
|
||||
unsetComment:
|
||||
(commentId) =>
|
||||
({ tr, dispatch }) => {
|
||||
if (!commentId) return false;
|
||||
|
||||
tr.doc.descendants((node, pos) => {
|
||||
const from = pos;
|
||||
const to = pos + node.nodeSize;
|
||||
|
||||
const commentMark = node.marks.find(mark =>
|
||||
mark.type.name === this.name && mark.attrs.commentId === commentId);
|
||||
|
||||
if (commentMark) {
|
||||
tr = tr.removeMark(from, to, commentMark);
|
||||
}
|
||||
});
|
||||
|
||||
return dispatch?.(tr);
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
const commentId = HTMLAttributes?.['data-comment-id'] || null;
|
||||
const elem = document.createElement('span');
|
||||
|
||||
Object.entries(
|
||||
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes),
|
||||
).forEach(([attr, val]) => elem.setAttribute(attr, val));
|
||||
|
||||
elem.addEventListener('click', (e) => {
|
||||
const selection = document.getSelection();
|
||||
if (selection.type === 'Range') return;
|
||||
|
||||
this.storage.activeCommentId = commentId;
|
||||
const commentEventClick = new CustomEvent('ACTIVE_COMMENT_EVENT', {
|
||||
bubbles: true,
|
||||
detail: { commentId },
|
||||
});
|
||||
|
||||
elem.dispatchEvent(commentEventClick);
|
||||
});
|
||||
|
||||
return elem;
|
||||
},
|
||||
|
||||
// @ts-ignore
|
||||
addProseMirrorPlugins(): Plugin[] {
|
||||
// @ts-ignore
|
||||
return [commentDecoration()];
|
||||
},
|
||||
|
||||
},
|
||||
);
|
||||
223
apps/client/src/features/editor/extensions/drag-handle.ts
Normal file
223
apps/client/src/features/editor/extensions/drag-handle.ts
Normal file
@ -0,0 +1,223 @@
|
||||
import { Extension } from '@tiptap/core';
|
||||
import { NodeSelection, Plugin } from '@tiptap/pm/state';
|
||||
// @ts-ignore
|
||||
import { __serializeForClipboard as serializeForClipboard, EditorView } from '@tiptap/pm/view';
|
||||
|
||||
export interface DragHandleOptions {
|
||||
dragHandleWidth: number;
|
||||
}
|
||||
|
||||
function removeNode(node) {
|
||||
node.parentNode.removeChild(node);
|
||||
}
|
||||
|
||||
function absoluteRect(node) {
|
||||
const data = node.getBoundingClientRect();
|
||||
|
||||
return {
|
||||
top: data.top,
|
||||
left: data.left,
|
||||
width: data.width,
|
||||
};
|
||||
}
|
||||
|
||||
function nodeDOMAtCoords(coords: { x: number; y: number }) {
|
||||
return document
|
||||
.elementsFromPoint(coords.x, coords.y)
|
||||
.find(
|
||||
(elem: HTMLElement) =>
|
||||
elem.parentElement?.matches?.('.ProseMirror') ||
|
||||
elem.matches(
|
||||
[
|
||||
'li',
|
||||
'p:not(:first-child)',
|
||||
'pre',
|
||||
'blockquote',
|
||||
'h1, h2, h3',
|
||||
'[data-type=horizontalRule]',
|
||||
].join(', '),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function nodePosAtDOM(node: Element, view: EditorView) {
|
||||
const boundingRect = node.getBoundingClientRect();
|
||||
|
||||
return view.posAtCoords({
|
||||
left: boundingRect.left + 1,
|
||||
top: boundingRect.top + 1,
|
||||
})?.inside;
|
||||
}
|
||||
|
||||
function DragHandle(options: DragHandleOptions) {
|
||||
function handleDragStart(event: DragEvent, view: EditorView) {
|
||||
view.focus();
|
||||
|
||||
if (!event.dataTransfer) return;
|
||||
|
||||
const node = nodeDOMAtCoords({
|
||||
x: event.clientX + 50 + options.dragHandleWidth,
|
||||
y: event.clientY,
|
||||
});
|
||||
|
||||
if (!(node instanceof Element)) return;
|
||||
|
||||
const nodePos = nodePosAtDOM(node, view);
|
||||
if (!nodePos) return;
|
||||
|
||||
view.dispatch(
|
||||
view.state.tr.setSelection(
|
||||
NodeSelection.create(view.state.doc, nodePos),
|
||||
),
|
||||
);
|
||||
|
||||
const slice = view.state.selection.content();
|
||||
const { dom, text } = serializeForClipboard(view, slice);
|
||||
|
||||
event.dataTransfer.clearData();
|
||||
event.dataTransfer.setData('text/html', dom.innerHTML);
|
||||
event.dataTransfer.setData('text/plain', text);
|
||||
event.dataTransfer.effectAllowed = 'copyMove';
|
||||
|
||||
event.dataTransfer.setDragImage(node, 0, 0);
|
||||
|
||||
view.dragging = { slice, move: event.ctrlKey };
|
||||
}
|
||||
|
||||
function handleClick(event: MouseEvent, view: EditorView) {
|
||||
view.focus();
|
||||
view.dom.classList.remove('dragging');
|
||||
|
||||
const node = nodeDOMAtCoords({
|
||||
x: event.clientX + 50 + options.dragHandleWidth,
|
||||
y: event.clientY,
|
||||
});
|
||||
|
||||
if (!(node instanceof Element)) return;
|
||||
|
||||
const nodePos = nodePosAtDOM(node, view);
|
||||
if (!nodePos) return;
|
||||
|
||||
view.dispatch(
|
||||
view.state.tr.setSelection(
|
||||
NodeSelection.create(view.state.doc, nodePos),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
let dragHandleElement: HTMLElement | null = null;
|
||||
|
||||
function hideDragHandle() {
|
||||
if (dragHandleElement) {
|
||||
dragHandleElement.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
function showDragHandle() {
|
||||
if (dragHandleElement) {
|
||||
dragHandleElement.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
return new Plugin({
|
||||
view: (view) => {
|
||||
dragHandleElement = document.createElement('div');
|
||||
dragHandleElement.draggable = true;
|
||||
dragHandleElement.dataset.dragHandle = '';
|
||||
dragHandleElement.classList.add('drag-handle');
|
||||
dragHandleElement.addEventListener('dragstart', (e) => {
|
||||
handleDragStart(e, view);
|
||||
});
|
||||
dragHandleElement.addEventListener('click', (e) => {
|
||||
handleClick(e, view);
|
||||
});
|
||||
|
||||
hideDragHandle();
|
||||
|
||||
view?.dom?.parentElement?.appendChild(dragHandleElement);
|
||||
|
||||
return {
|
||||
destroy: () => {
|
||||
dragHandleElement?.remove?.();
|
||||
dragHandleElement = null;
|
||||
},
|
||||
};
|
||||
},
|
||||
props: {
|
||||
handleDOMEvents: {
|
||||
mousemove: (view, event) => {
|
||||
if (!view.editable) {
|
||||
return;
|
||||
}
|
||||
|
||||
const node = nodeDOMAtCoords({
|
||||
x: event.clientX + 50 + options.dragHandleWidth,
|
||||
y: event.clientY,
|
||||
});
|
||||
|
||||
if (!(node instanceof Element)) {
|
||||
hideDragHandle();
|
||||
return;
|
||||
}
|
||||
|
||||
const compStyle = window.getComputedStyle(node);
|
||||
const lineHeight = parseInt(compStyle.lineHeight, 10);
|
||||
const paddingTop = parseInt(compStyle.paddingTop, 10);
|
||||
|
||||
const rect = absoluteRect(node);
|
||||
|
||||
rect.top += (lineHeight - 24) / 2;
|
||||
rect.top += paddingTop;
|
||||
// Li markers
|
||||
if (
|
||||
node.matches('ul:not([data-type=taskList]) li, ol li')
|
||||
) {
|
||||
rect.left -= options.dragHandleWidth;
|
||||
}
|
||||
rect.width = options.dragHandleWidth;
|
||||
|
||||
if (!dragHandleElement) return;
|
||||
|
||||
dragHandleElement.style.left = `${rect.left - rect.width}px`;
|
||||
dragHandleElement.style.top = `${rect.top}px`;
|
||||
showDragHandle();
|
||||
},
|
||||
keydown: () => {
|
||||
hideDragHandle();
|
||||
},
|
||||
mousewheel: () => {
|
||||
hideDragHandle();
|
||||
},
|
||||
// dragging class is used for CSS
|
||||
dragstart: (view) => {
|
||||
view.dom.classList.add('dragging');
|
||||
},
|
||||
drop: (view) => {
|
||||
view.dom.classList.remove('dragging');
|
||||
},
|
||||
dragend: (view) => {
|
||||
view.dom.classList.remove('dragging');
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export interface DragAndDropOptions {
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
const DragAndDrop = Extension.create<DragAndDropOptions>({
|
||||
name: 'dragAndDrop',
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
DragHandle({
|
||||
dragHandleWidth: 24,
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
|
||||
export default DragAndDrop;
|
||||
67
apps/client/src/features/editor/extensions/extensions.ts
Normal file
67
apps/client/src/features/editor/extensions/extensions.ts
Normal file
@ -0,0 +1,67 @@
|
||||
import { StarterKit } from '@tiptap/starter-kit';
|
||||
import { Placeholder } from '@tiptap/extension-placeholder';
|
||||
import { TextAlign } from '@tiptap/extension-text-align';
|
||||
import { TaskList } from '@tiptap/extension-task-list';
|
||||
import { TaskItem } from '@tiptap/extension-task-item';
|
||||
import { Underline } from '@tiptap/extension-underline';
|
||||
import { Link } from '@tiptap/extension-link';
|
||||
import { Superscript } from '@tiptap/extension-superscript';
|
||||
import SubScript from '@tiptap/extension-subscript';
|
||||
import { Highlight } from '@tiptap/extension-highlight';
|
||||
import { Typography } from '@tiptap/extension-typography';
|
||||
import { TrailingNode } from '@/features/editor/extensions/trailing-node';
|
||||
import DragAndDrop from '@/features/editor/extensions/drag-handle';
|
||||
import { TextStyle } from '@tiptap/extension-text-style';
|
||||
import { Color } from '@tiptap/extension-color';
|
||||
import SlashCommand from '@/features/editor/extensions/slash-command';
|
||||
import { Collaboration } from '@tiptap/extension-collaboration';
|
||||
import { CollaborationCursor } from '@tiptap/extension-collaboration-cursor';
|
||||
import { Comment } from '@/features/editor/extensions/comment/comment';
|
||||
import { HocuspocusProvider } from '@hocuspocus/provider';
|
||||
|
||||
export const mainExtensions = [
|
||||
StarterKit.configure({
|
||||
history: false,
|
||||
dropcursor: {
|
||||
width: 3,
|
||||
color: '#70CFF8',
|
||||
},
|
||||
}),
|
||||
Placeholder.configure({
|
||||
placeholder: 'Enter "/" for commands',
|
||||
}),
|
||||
TextAlign.configure({ types: ['heading', 'paragraph'] }),
|
||||
TaskList,
|
||||
TaskItem.configure({
|
||||
nested: true,
|
||||
}),
|
||||
Underline,
|
||||
Link,
|
||||
Superscript,
|
||||
SubScript,
|
||||
Highlight.configure({
|
||||
multicolor: true,
|
||||
}),
|
||||
Typography,
|
||||
TrailingNode,
|
||||
DragAndDrop,
|
||||
TextStyle,
|
||||
Color,
|
||||
SlashCommand,
|
||||
Comment.configure({
|
||||
HTMLAttributes: {
|
||||
class: 'comment-mark',
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
type CollabExtensions = (provider: HocuspocusProvider) => any[];
|
||||
|
||||
export const collabExtensions: CollabExtensions = (provider) => [
|
||||
Collaboration.configure({
|
||||
document: provider.document,
|
||||
}),
|
||||
CollaborationCursor.configure({
|
||||
provider,
|
||||
}),
|
||||
];
|
||||
42
apps/client/src/features/editor/extensions/slash-command.ts
Normal file
42
apps/client/src/features/editor/extensions/slash-command.ts
Normal file
@ -0,0 +1,42 @@
|
||||
import { Extension } from '@tiptap/core';
|
||||
import { PluginKey } from '@tiptap/pm/state';
|
||||
import Suggestion, { SuggestionOptions } from '@tiptap/suggestion';
|
||||
import renderItems from '@/features/editor/components/slash-menu/render-items';
|
||||
import getSuggestionItems from '@/features/editor/components/slash-menu/menu-items';
|
||||
|
||||
export const slashMenuPluginKey = new PluginKey('slash-command');
|
||||
|
||||
// @ts-ignore
|
||||
const Command = Extension.create({
|
||||
name: 'slash-command',
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
suggestion: {
|
||||
char: '/',
|
||||
command: ({ editor, range, props }) => {
|
||||
props.command({ editor, range, props });
|
||||
},
|
||||
} as Partial<SuggestionOptions>,
|
||||
};
|
||||
},
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
Suggestion({
|
||||
pluginKey: slashMenuPluginKey,
|
||||
...this.options.suggestion,
|
||||
editor: this.editor,
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
|
||||
const SlashCommand = Command.configure({
|
||||
suggestion: {
|
||||
items: getSuggestionItems,
|
||||
render: renderItems,
|
||||
},
|
||||
});
|
||||
|
||||
export default SlashCommand;
|
||||
69
apps/client/src/features/editor/extensions/trailing-node.ts
Normal file
69
apps/client/src/features/editor/extensions/trailing-node.ts
Normal file
@ -0,0 +1,69 @@
|
||||
import { Extension } from '@tiptap/core'
|
||||
import { PluginKey, Plugin } from '@tiptap/pm/state';
|
||||
|
||||
export interface TrailingNodeExtensionOptions {
|
||||
node: string,
|
||||
notAfter: string[],
|
||||
}
|
||||
|
||||
function nodeEqualsType({ types, node }: { types: any, node: any }) {
|
||||
return (Array.isArray(types) && types.includes(node.type)) || node.type === types
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
/**
|
||||
* Extension based on:
|
||||
* - https://github.com/ueberdosis/tiptap/blob/v1/packages/tiptap-extensions/src/extensions/TrailingNode.js
|
||||
* - https://github.com/remirror/remirror/blob/e0f1bec4a1e8073ce8f5500d62193e52321155b9/packages/prosemirror-trailing-node/src/trailing-node-plugin.ts
|
||||
*/
|
||||
export const TrailingNode = Extension.create<TrailingNodeExtensionOptions>({
|
||||
name: 'trailingNode',
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
node: 'paragraph',
|
||||
notAfter: [
|
||||
'paragraph',
|
||||
],
|
||||
};
|
||||
},
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
const plugin = new PluginKey(this.name)
|
||||
const disabledNodes = Object.entries(this.editor.schema.nodes)
|
||||
.map(([, value]) => value)
|
||||
.filter(node => this.options.notAfter.includes(node.name))
|
||||
|
||||
return [
|
||||
new Plugin({
|
||||
key: plugin,
|
||||
appendTransaction: (_, __, state) => {
|
||||
const { doc, tr, schema } = state;
|
||||
const shouldInsertNodeAtEnd = plugin.getState(state);
|
||||
const endPosition = doc.content.size;
|
||||
const type = schema.nodes[this.options.node]
|
||||
|
||||
if (!shouldInsertNodeAtEnd) {
|
||||
return;
|
||||
}
|
||||
|
||||
return tr.insert(endPosition, type.create());
|
||||
},
|
||||
state: {
|
||||
init: (_, state) => {
|
||||
const lastNode = state.tr.doc.lastChild
|
||||
return !nodeEqualsType({ node: lastNode, types: disabledNodes })
|
||||
},
|
||||
apply: (tr, value) => {
|
||||
if (!tr.docChanged) {
|
||||
return value
|
||||
}
|
||||
|
||||
const lastNode = tr.doc.lastChild
|
||||
return !nodeEqualsType({ node: lastNode, types: disabledNodes })
|
||||
},
|
||||
},
|
||||
}),
|
||||
]
|
||||
}
|
||||
})
|
||||
20
apps/client/src/features/editor/full-editor.tsx
Normal file
20
apps/client/src/features/editor/full-editor.tsx
Normal file
@ -0,0 +1,20 @@
|
||||
import classes from '@/features/editor/styles/editor.module.css';
|
||||
import React from 'react';
|
||||
import { TitleEditor } from '@/features/editor/title-editor';
|
||||
import PageEditor from '@/features/editor/page-editor';
|
||||
|
||||
export interface FullEditorProps {
|
||||
pageId: string;
|
||||
title: any;
|
||||
}
|
||||
|
||||
export function FullEditor({ pageId, title }: FullEditorProps) {
|
||||
|
||||
return (
|
||||
<div className={classes.editor}>
|
||||
<TitleEditor pageId={pageId} title={title} />
|
||||
<PageEditor pageId={pageId} />
|
||||
</div>
|
||||
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,17 @@
|
||||
const useCollaborationURL = (): string => {
|
||||
const PATH = "/collaboration";
|
||||
|
||||
if (import.meta.env.VITE_COLLABORATION_URL) {
|
||||
return import.meta.env.VITE_COLLABORATION_URL + PATH;
|
||||
}
|
||||
|
||||
const API_URL = import.meta.env.VITE_BACKEND_API_URL;
|
||||
if (!API_URL) {
|
||||
throw new Error("Backend API URL is not defined");
|
||||
}
|
||||
|
||||
const wsProtocol = API_URL.startsWith('https') ? 'wss' : 'ws';
|
||||
return `${wsProtocol}://${API_URL.split('://')[1]}${PATH}`;
|
||||
};
|
||||
|
||||
export default useCollaborationURL;
|
||||
170
apps/client/src/features/editor/page-editor.tsx
Normal file
170
apps/client/src/features/editor/page-editor.tsx
Normal file
@ -0,0 +1,170 @@
|
||||
import '@/features/editor/styles/index.css';
|
||||
import React, {
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { IndexeddbPersistence } from 'y-indexeddb';
|
||||
import * as Y from 'yjs';
|
||||
import { HocuspocusProvider } from '@hocuspocus/provider';
|
||||
import { EditorContent, useEditor } from '@tiptap/react';
|
||||
import { collabExtensions, mainExtensions } from '@/features/editor/extensions/extensions';
|
||||
import { useAtom } from 'jotai';
|
||||
import { authTokensAtom } from '@/features/auth/atoms/auth-tokens-atom';
|
||||
import useCollaborationUrl from '@/features/editor/hooks/use-collaboration-url';
|
||||
import { currentUserAtom } from '@/features/user/atoms/current-user-atom';
|
||||
import { pageEditorAtom } from '@/features/editor/atoms/editor-atoms';
|
||||
import { asideStateAtom } from '@/components/navbar/atoms/sidebar-atom';
|
||||
import { activeCommentIdAtom, showCommentPopupAtom } from '@/features/comment/atoms/comment-atom';
|
||||
import CommentDialog from '@/features/comment/components/comment-dialog';
|
||||
import EditorSkeleton from '@/features/editor/components/editor-skeleton';
|
||||
import { EditorBubbleMenu } from '@/features/editor/components/bubble-menu/bubble-menu';
|
||||
|
||||
const colors = ['#958DF1', '#F98181', '#FBBC88', '#FAF594', '#70CFF8', '#94FADB', '#B9F18D'];
|
||||
const getRandomElement = list => list[Math.floor(Math.random() * list.length)];
|
||||
const getRandomColor = () => getRandomElement(colors);
|
||||
|
||||
interface PageEditorProps {
|
||||
pageId: string;
|
||||
editable?: boolean;
|
||||
}
|
||||
|
||||
export default function PageEditor({ pageId, editable = true }: PageEditorProps) {
|
||||
const [token] = useAtom(authTokensAtom);
|
||||
const collaborationURL = useCollaborationUrl();
|
||||
const [currentUser] = useAtom(currentUserAtom);
|
||||
const [, setEditor] = useAtom(pageEditorAtom);
|
||||
const [, setAsideState] = useAtom(asideStateAtom);
|
||||
|
||||
const [, setActiveCommentId] = useAtom(activeCommentIdAtom);
|
||||
const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom);
|
||||
|
||||
const ydoc = useMemo(() => new Y.Doc(), [pageId]);
|
||||
|
||||
const [isLocalSynced, setLocalSynced] = useState(false);
|
||||
const [isRemoteSynced, setRemoteSynced] = useState(false);
|
||||
|
||||
const localProvider = useMemo(() => {
|
||||
const provider = new IndexeddbPersistence(
|
||||
pageId,
|
||||
ydoc,
|
||||
);
|
||||
|
||||
provider.on('synced', () => {
|
||||
setLocalSynced(true);
|
||||
});
|
||||
|
||||
return provider;
|
||||
}, [pageId, ydoc]);
|
||||
|
||||
const remoteProvider = useMemo(() => {
|
||||
const provider = new HocuspocusProvider({
|
||||
name: pageId,
|
||||
url: collaborationURL,
|
||||
document: ydoc,
|
||||
token: token?.accessToken,
|
||||
connect: false,
|
||||
});
|
||||
|
||||
provider.on('synced', () => {
|
||||
setRemoteSynced(true);
|
||||
});
|
||||
|
||||
return provider;
|
||||
}, [ydoc, pageId, token?.accessToken]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
remoteProvider.connect();
|
||||
|
||||
return () => {
|
||||
setRemoteSynced(false);
|
||||
setLocalSynced(false);
|
||||
remoteProvider.destroy();
|
||||
localProvider.destroy();
|
||||
};
|
||||
}, [remoteProvider, localProvider]);
|
||||
|
||||
const extensions = [
|
||||
...mainExtensions,
|
||||
...collabExtensions(remoteProvider),
|
||||
];
|
||||
|
||||
const editor = useEditor(
|
||||
{
|
||||
extensions,
|
||||
editable,
|
||||
editorProps: {
|
||||
handleDOMEvents: {
|
||||
keydown: (_view, event) => {
|
||||
if (['ArrowUp', 'ArrowDown', 'Enter'].includes(event.key)) {
|
||||
const slashCommand = document.querySelector('#slash-command');
|
||||
if (slashCommand) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
onCreate({ editor }) {
|
||||
if (editor) {
|
||||
// @ts-ignore
|
||||
setEditor(editor);
|
||||
}
|
||||
},
|
||||
},
|
||||
[pageId, editable, remoteProvider],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (editor && currentUser.user) {
|
||||
editor.chain().focus().updateUser({ ...currentUser.user, color: getRandomColor() }).run();
|
||||
}
|
||||
}, [editor, currentUser.user]);
|
||||
|
||||
const handleActiveCommentEvent = (event) => {
|
||||
const { commentId } = event.detail;
|
||||
setActiveCommentId(commentId);
|
||||
setAsideState({ tab: 'comments', isAsideOpen: true });
|
||||
|
||||
const selector = `div[data-comment-id="${commentId}"]`;
|
||||
const commentElement = document.querySelector(selector);
|
||||
commentElement?.scrollIntoView();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener('ACTIVE_COMMENT_EVENT', handleActiveCommentEvent);
|
||||
return () => {
|
||||
document.removeEventListener('ACTIVE_COMMENT_EVENT', handleActiveCommentEvent);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setActiveCommentId(null);
|
||||
setShowCommentPopup(false);
|
||||
setAsideState({ tab: '', isAsideOpen: false });
|
||||
}, [pageId]);
|
||||
|
||||
const isSynced = isLocalSynced || isRemoteSynced;
|
||||
|
||||
return isSynced ? (
|
||||
<div>
|
||||
{isSynced && (
|
||||
<div>
|
||||
<EditorContent editor={editor} />
|
||||
|
||||
{editor && editor.isEditable && (
|
||||
<div>
|
||||
<EditorBubbleMenu editor={editor} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showCommentPopup && (
|
||||
<CommentDialog editor={editor} pageId={pageId} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : <EditorSkeleton />;
|
||||
|
||||
}
|
||||
26
apps/client/src/features/editor/styles/collaboration.css
Normal file
26
apps/client/src/features/editor/styles/collaboration.css
Normal file
@ -0,0 +1,26 @@
|
||||
/* Give a remote user a caret */
|
||||
.collaboration-cursor__caret {
|
||||
border-left: 1px solid #0d0d0d;
|
||||
border-right: 1px solid #0d0d0d;
|
||||
margin-left: -1px;
|
||||
margin-right: -1px;
|
||||
pointer-events: none;
|
||||
position: relative;
|
||||
word-break: normal;
|
||||
}
|
||||
|
||||
/* Render the username above the caret */
|
||||
.collaboration-cursor__label {
|
||||
border-radius: 3px 3px 3px 0;
|
||||
color: #0d0d0d;
|
||||
font-size: 0.75rem;
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
left: -1px;
|
||||
line-height: normal;
|
||||
padding: 0.1rem 0.3rem;
|
||||
position: absolute;
|
||||
top: -1.4em;
|
||||
user-select: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
100
apps/client/src/features/editor/styles/core.css
Normal file
100
apps/client/src/features/editor/styles/core.css
Normal file
@ -0,0 +1,100 @@
|
||||
.ProseMirror {
|
||||
background-color: light-dark(var(--mantine-color-white), var(--mantine-color-dark-7));
|
||||
color: light-dark(var(--mantine-color-default-color), var(--mantine-color-dark-0));
|
||||
font-size: var(--mantine-font-size-md);
|
||||
line-height: var(--mantine-line-height-lg);
|
||||
font-weight: 400;
|
||||
width: 100%;
|
||||
|
||||
> * + * {
|
||||
margin-top: 0.75em;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
ul,
|
||||
ol {
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
code {
|
||||
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-8));
|
||||
color: #616161;
|
||||
}
|
||||
|
||||
pre {
|
||||
padding: var(--mantine-spacing-xs);
|
||||
margin: var(--mantine-spacing-md) 0;
|
||||
font-family: var(--mantine-font-family-monospace);
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
|
||||
@mixin light {
|
||||
background-color: var(--mantine-color-gray-0);
|
||||
color: var(--mantine-color-black);
|
||||
|
||||
}
|
||||
|
||||
@mixin dark {
|
||||
background-color: var(--mantine-color-dark-8);
|
||||
color: var(--mantine-color-white);
|
||||
}
|
||||
|
||||
code {
|
||||
color: inherit;
|
||||
padding: 0;
|
||||
background: none;
|
||||
font-size: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
blockquote {
|
||||
padding-left: 25px;
|
||||
border-left: 2px solid var(--mantine-color-gray-6);
|
||||
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-8));
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
hr {
|
||||
border: none;
|
||||
border-top: 2px solid #ced4da;
|
||||
margin: 2rem 0;
|
||||
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
hr.ProseMirror-selectednode {
|
||||
border-top: 1px solid #68CEF8;
|
||||
}
|
||||
|
||||
.ProseMirror-selectednode {
|
||||
outline: 2px solid #70CFF8;
|
||||
}
|
||||
}
|
||||
|
||||
.resize-cursor {
|
||||
cursor: ew-resize;
|
||||
cursor: col-resize;
|
||||
}
|
||||
|
||||
.comment-mark {
|
||||
background: rgba(0,203,15,0.2);
|
||||
border-bottom: 2px solid #0ca678;
|
||||
}
|
||||
45
apps/client/src/features/editor/styles/drag-handle.css
Normal file
45
apps/client/src/features/editor/styles/drag-handle.css
Normal file
@ -0,0 +1,45 @@
|
||||
.ProseMirror:not(.dragging) {
|
||||
.ProseMirror-selectednode {
|
||||
outline: none !important;
|
||||
border-radius: 0.2rem;
|
||||
background-color: rgba(150, 170, 220, 0.2);
|
||||
transition: background-color 0.2s;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
.drag-handle {
|
||||
position: fixed;
|
||||
opacity: 1;
|
||||
transition: opacity ease-in 0.2s;
|
||||
border-radius: 0.25rem;
|
||||
background-size: calc(0.5em + 0.375rem) calc(0.5em + 0.375rem);
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
width: 1.2rem;
|
||||
height: 1.5rem;
|
||||
z-index: 50;
|
||||
cursor: grab;
|
||||
|
||||
@mixin light {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 10 10' style='fill: rgba(55, 53, 47, 0.3)'%3E%3Cpath d='M3,2 C2.44771525,2 2,1.55228475 2,1 C2,0.44771525 2.44771525,0 3,0 C3.55228475,0 4,0.44771525 4,1 C4,1.55228475 3.55228475,2 3,2 Z M3,6 C2.44771525,6 2,5.55228475 2,5 C2,4.44771525 2.44771525,4 3,4 C3.55228475,4 4,4.44771525 4,5 C4,5.55228475 3.55228475,6 3,6 Z M3,10 C2.44771525,10 2,9.55228475 2,9 C2,8.44771525 2.44771525,8 3,8 C3.55228475,8 4,8.44771525 4,9 C4,9.55228475 3.55228475,10 3,10 Z M7,2 C6.44771525,2 6,1.55228475 6,1 C6,0.44771525 6.44771525,0 7,0 C7.55228475,0 8,0.44771525 8,1 C8,1.55228475 7.55228475,2 7,2 Z M7,6 C6.44771525,6 6,5.55228475 6,5 C6,4.44771525 6.44771525,4 7,4 C7.55228475,4 8,4.44771525 8,5 C8,5.55228475 7.55228475,6 7,6 Z M7,10 C6.44771525,10 6,9.55228475 6,9 C6,8.44771525 6.44771525,8 7,8 C7.55228475,8 8,8.44771525 8,9 C8,9.55228475 7.55228475,10 7,10 Z'%3E%3C/path%3E%3C/svg%3E");
|
||||
}
|
||||
@mixin dark {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 10 10' style='fill: rgba(255, 255, 255, 0.5)'%3E%3Cpath d='M3,2 C2.44771525,2 2,1.55228475 2,1 C2,0.44771525 2.44771525,0 3,0 C3.55228475,0 4,0.44771525 4,1 C4,1.55228475 3.55228475,2 3,2 Z M3,6 C2.44771525,6 2,5.55228475 2,5 C2,4.44771525 2.44771525,4 3,4 C3.55228475,4 4,4.44771525 4,5 C4,5.55228475 3.55228475,6 3,6 Z M3,10 C2.44771525,10 2,9.55228475 2,9 C2,8.44771525 2.44771525,8 3,8 C3.55228475,8 4,8.44771525 4,9 C4,9.55228475 3.55228475,10 3,10 Z M7,2 C6.44771525,2 6,1.55228475 6,1 C6,0.44771525 6.44771525,0 7,0 C7.55228475,0 8,0.44771525 8,1 C8,1.55228475 7.55228475,2 7,2 Z M7,6 C6.44771525,6 6,5.55228475 6,5 C6,4.44771525 6.44771525,4 7,4 C7.55228475,4 8,4.44771525 8,5 C8,5.55228475 7.55228475,6 7,6 Z M7,10 C6.44771525,10 6,9.55228475 6,9 C6,8.44771525 6.44771525,8 7,8 C7.55228475,8 8,8.44771525 8,9 C8,9.55228475 7.55228475,10 7,10 Z'%3E%3C/path%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: #0d0d0d10;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
&.hidden {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 600px) {
|
||||
display: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
7
apps/client/src/features/editor/styles/editor.module.css
Normal file
7
apps/client/src/features/editor/styles/editor.module.css
Normal file
@ -0,0 +1,7 @@
|
||||
.editor {
|
||||
max-width: 800px;
|
||||
height: 100%;
|
||||
padding: 8px 20px;
|
||||
margin: 64px auto;
|
||||
}
|
||||
|
||||
5
apps/client/src/features/editor/styles/index.css
Normal file
5
apps/client/src/features/editor/styles/index.css
Normal file
@ -0,0 +1,5 @@
|
||||
@import './core';
|
||||
@import './collaboration';
|
||||
@import './task-list';
|
||||
@import './placeholder';
|
||||
@import './drag-handle';
|
||||
24
apps/client/src/features/editor/styles/placeholder.css
Normal file
24
apps/client/src/features/editor/styles/placeholder.css
Normal file
@ -0,0 +1,24 @@
|
||||
.ProseMirror p.is-editor-empty:first-child::before {
|
||||
content: attr(data-placeholder);
|
||||
float: left;
|
||||
color: #adb5bd;
|
||||
pointer-events: none;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.ProseMirror h1.is-editor-empty:first-child::before {
|
||||
content: attr(data-placeholder);
|
||||
float: left;
|
||||
color: #adb5bd;
|
||||
pointer-events: none;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
/* Placeholder (on every new line) */
|
||||
/*.ProseMirror p.is-empty::before {
|
||||
color: #adb5bd;
|
||||
content: attr(data-placeholder);
|
||||
float: left;
|
||||
height: 0;
|
||||
pointer-events: none;
|
||||
}*/
|
||||
31
apps/client/src/features/editor/styles/task-list.css
Normal file
31
apps/client/src/features/editor/styles/task-list.css
Normal file
@ -0,0 +1,31 @@
|
||||
ul[data-type="taskList"] {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
li {
|
||||
display: flex;
|
||||
|
||||
> label {
|
||||
flex: 0 0 auto;
|
||||
margin-right: 0.5rem;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
> div {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
ul li,
|
||||
ol li {
|
||||
display: list-item;
|
||||
}
|
||||
|
||||
ul[data-type="taskList"] > li {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
}
|
||||
93
apps/client/src/features/editor/title-editor.tsx
Normal file
93
apps/client/src/features/editor/title-editor.tsx
Normal file
@ -0,0 +1,93 @@
|
||||
import '@/features/editor/styles/index.css';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { EditorContent, useEditor } from '@tiptap/react';
|
||||
import { Document } from '@tiptap/extension-document';
|
||||
import { Heading } from '@tiptap/extension-heading';
|
||||
import { Text } from '@tiptap/extension-text';
|
||||
import { Placeholder } from '@tiptap/extension-placeholder';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { pageEditorAtom, titleEditorAtom } from '@/features/editor/atoms/editor-atoms';
|
||||
import { useUpdatePageMutation } from '@/features/page/queries/page-query';
|
||||
import { useDebouncedValue } from '@mantine/hooks';
|
||||
import { useAtom } from 'jotai';
|
||||
import { treeDataAtom } from '@/features/page/tree/atoms/tree-data-atom';
|
||||
import { updateTreeNodeName } from '@/features/page/tree/utils';
|
||||
|
||||
export interface TitleEditorProps {
|
||||
pageId: string;
|
||||
title: any;
|
||||
}
|
||||
|
||||
export function TitleEditor({ pageId, title }: TitleEditorProps) {
|
||||
const [debouncedTitleState, setDebouncedTitleState] = useState('');
|
||||
const [debouncedTitle] = useDebouncedValue(debouncedTitleState, 1000);
|
||||
const updatePageMutation = useUpdatePageMutation();
|
||||
const pageEditor = useAtomValue(pageEditorAtom);
|
||||
const [, setTitleEditor] = useAtom(titleEditorAtom);
|
||||
const [treeData, setTreeData] = useAtom(treeDataAtom);
|
||||
|
||||
const titleEditor = useEditor({
|
||||
extensions: [
|
||||
Document.extend({
|
||||
content: 'heading',
|
||||
}),
|
||||
Heading.configure({
|
||||
levels: [1],
|
||||
}),
|
||||
Text,
|
||||
Placeholder.configure({
|
||||
placeholder: 'Untitled',
|
||||
}),
|
||||
],
|
||||
onCreate({ editor }) {
|
||||
if (editor) {
|
||||
// @ts-ignore
|
||||
setTitleEditor(editor);
|
||||
}
|
||||
},
|
||||
onUpdate({ editor }) {
|
||||
const currentTitle = editor.getText();
|
||||
setDebouncedTitleState(currentTitle);
|
||||
},
|
||||
content: title,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (debouncedTitle !== '') {
|
||||
updatePageMutation.mutate({ id: pageId, title: debouncedTitle });
|
||||
|
||||
const newTreeData = updateTreeNodeName(treeData, pageId, debouncedTitle);
|
||||
setTreeData(newTreeData);
|
||||
}
|
||||
}, [debouncedTitle]);
|
||||
|
||||
useEffect(() => {
|
||||
if (titleEditor && title !== titleEditor.getText()) {
|
||||
titleEditor.commands.setContent(title);
|
||||
}
|
||||
}, [pageId, title, titleEditor]);
|
||||
|
||||
useEffect(() => {
|
||||
setTimeout(() => {
|
||||
titleEditor?.commands.focus('end');
|
||||
}, 500);
|
||||
}, [titleEditor]);
|
||||
|
||||
function handleTitleKeyDown(event) {
|
||||
if (!titleEditor || !pageEditor || event.shiftKey) return;
|
||||
|
||||
const { key } = event;
|
||||
const { $head } = titleEditor.state.selection;
|
||||
|
||||
const shouldFocusEditor = (key === 'Enter' || key === 'ArrowDown') ||
|
||||
(key === 'ArrowRight' && !$head.nodeAfter);
|
||||
|
||||
if (shouldFocusEditor) {
|
||||
pageEditor.commands.focus('start');
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<EditorContent editor={titleEditor} onKeyDown={handleTitleKeyDown} />
|
||||
);
|
||||
}
|
||||
34
apps/client/src/features/home/components/home-tabs.tsx
Normal file
34
apps/client/src/features/home/components/home-tabs.tsx
Normal file
@ -0,0 +1,34 @@
|
||||
import { Text, Tabs, Space } from '@mantine/core';
|
||||
import {
|
||||
IconClockHour3, IconStar,
|
||||
} from '@tabler/icons-react';
|
||||
import RecentChanges from '@/features/home/components/recent-changes';
|
||||
|
||||
export default function HomeTabs() {
|
||||
|
||||
return (
|
||||
<Tabs defaultValue="recent">
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="recent" leftSection={<IconClockHour3 size={18} />}>
|
||||
<Text size="sm" fw={500}>Recent changes</Text>
|
||||
</Tabs.Tab>
|
||||
|
||||
<Tabs.Tab value="favorites" leftSection={<IconStar size={18} />}>
|
||||
<Text size="sm" fw={500}>Favorites</Text>
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
<Space my="md" />
|
||||
|
||||
<Tabs.Panel value="recent">
|
||||
|
||||
<RecentChanges />
|
||||
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="favorites">
|
||||
My favorite pages
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
10
apps/client/src/features/home/components/home.module.css
Normal file
10
apps/client/src/features/home/components/home.module.css
Normal file
@ -0,0 +1,10 @@
|
||||
.page {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: var(--mantine-spacing-md);
|
||||
color: light-dark(var(--mantine-color-black), var(--mantine-color-dark-0));
|
||||
|
||||
@mixin hover {
|
||||
background-color: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-8));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,21 @@
|
||||
import { Skeleton } from '@mantine/core';
|
||||
|
||||
export default function PageListSkeleton() {
|
||||
return (
|
||||
<>
|
||||
<Skeleton height={25} my="xs" radius="xs" />
|
||||
<Skeleton height={25} my="xs" radius="xs" />
|
||||
<Skeleton height={25} my="xs" radius="xs" />
|
||||
<Skeleton height={25} my="xs" radius="xs" />
|
||||
<Skeleton height={25} my="xs" radius="xs" />
|
||||
<Skeleton height={25} my="xs" radius="xs" />
|
||||
<Skeleton height={25} my="xs" radius="xs" />
|
||||
<Skeleton height={25} my="xs" radius="xs" />
|
||||
<Skeleton height={25} my="xs" radius="xs" />
|
||||
<Skeleton height={25} my="xs" radius="xs" />
|
||||
<Skeleton height={25} my="xs" radius="xs" />
|
||||
<Skeleton height={25} my="xs" radius="xs" />
|
||||
<Skeleton height={25} my="xs" radius="xs" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
46
apps/client/src/features/home/components/recent-changes.tsx
Normal file
46
apps/client/src/features/home/components/recent-changes.tsx
Normal file
@ -0,0 +1,46 @@
|
||||
import { Text, Group, Stack, UnstyledButton, Divider } from '@mantine/core';
|
||||
import { format } from 'date-fns';
|
||||
import classes from './home.module.css';
|
||||
import { Link } from 'react-router-dom';
|
||||
import PageListSkeleton from '@/features/home/components/page-list-skeleton';
|
||||
import { useRecentChangesQuery } from '@/features/page/queries/page-query';
|
||||
|
||||
function RecentChanges() {
|
||||
const { data, isLoading, isError } = useRecentChangesQuery();
|
||||
|
||||
if (isLoading) {
|
||||
return <PageListSkeleton />;
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return <Text>Failed to fetch recent pages</Text>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{data
|
||||
.map((page) => (
|
||||
<div key={page.id}>
|
||||
<UnstyledButton component={Link} to={`/p/${page.id}`}
|
||||
className={classes.page} p="xs">
|
||||
<Group wrap="nowrap">
|
||||
|
||||
<Stack gap="xs" style={{ flex: 1 }}>
|
||||
<Text fw={500} size="md" lineClamp={1}>
|
||||
{page.title || 'Untitled'}
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
<Text c="dimmed" size="xs" fw={500}>
|
||||
{format(new Date(page.updatedAt), 'PP')}
|
||||
</Text>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
<Divider />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default RecentChanges;
|
||||
@ -0,0 +1,4 @@
|
||||
import { atom } from "jotai";
|
||||
|
||||
export const historyAtoms = atom<boolean>(false);
|
||||
export const activeHistoryIdAtom = atom<string>('');
|
||||
@ -0,0 +1,33 @@
|
||||
import '@/features/editor/styles/index.css';
|
||||
import React, { useEffect } from 'react';
|
||||
import { EditorContent, useEditor } from '@tiptap/react';
|
||||
import { mainExtensions } from '@/features/editor/extensions/extensions';
|
||||
import { Title } from '@mantine/core';
|
||||
|
||||
export interface HistoryEditorProps {
|
||||
title: string;
|
||||
content: any;
|
||||
}
|
||||
|
||||
export function HistoryEditor({ title, content }: HistoryEditorProps) {
|
||||
const editor = useEditor({
|
||||
extensions: mainExtensions,
|
||||
editable: false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (editor && content) {
|
||||
editor.commands.setContent(content);
|
||||
}
|
||||
}, [title, content, editor]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<Title order={1}>{title}</Title>
|
||||
|
||||
{editor && <EditorContent editor={editor} />}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,40 @@
|
||||
import { Text, Group, UnstyledButton } from '@mantine/core';
|
||||
import { UserAvatar } from '@/components/ui/user-avatar';
|
||||
import { formatDate } from '@/lib/time';
|
||||
import classes from './history.module.css';
|
||||
import clsx from 'clsx';
|
||||
|
||||
interface HistoryItemProps {
|
||||
historyItem: any,
|
||||
onSelect: (id: string) => void;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
function HistoryItem({ historyItem, onSelect, isActive }: HistoryItemProps) {
|
||||
|
||||
return (
|
||||
<UnstyledButton p="xs" onClick={() => onSelect(historyItem.id)}
|
||||
className={clsx(classes.history, { [classes.active]: isActive })}
|
||||
>
|
||||
<Group wrap="nowrap">
|
||||
<div>
|
||||
<Text size="sm">
|
||||
{formatDate(new Date(historyItem.createdAt))}
|
||||
</Text>
|
||||
|
||||
<div style={{ flex: 1 }}>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<UserAvatar color="blue" size="sm" avatarUrl={historyItem.lastUpdatedBy.avatarUrl}
|
||||
name={historyItem.lastUpdatedBy.name} />
|
||||
<Text size="sm" c="dimmed" lineClamp={1}>
|
||||
{historyItem.lastUpdatedBy.name}
|
||||
</Text>
|
||||
</Group>
|
||||
</div>
|
||||
</div>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
);
|
||||
}
|
||||
|
||||
export default HistoryItem;
|
||||
@ -0,0 +1,85 @@
|
||||
import { usePageHistoryListQuery, usePageHistoryQuery } from '@/features/page-history/queries/page-history-query';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import HistoryItem from '@/features/page-history/components/history-item';
|
||||
import { activeHistoryIdAtom, historyAtoms } from '@/features/page-history/atoms/history-atoms';
|
||||
import { useAtom } from 'jotai';
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { Button, ScrollArea, Group, Divider, Text } from '@mantine/core';
|
||||
import { pageEditorAtom, titleEditorAtom } from '@/features/editor/atoms/editor-atoms';
|
||||
import { modals } from '@mantine/modals';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
|
||||
function HistoryList() {
|
||||
const [activeHistoryId, setActiveHistoryId] = useAtom(activeHistoryIdAtom);
|
||||
const { pageId } = useParams();
|
||||
const { data, isLoading, isError } = usePageHistoryListQuery(pageId);
|
||||
const { data: activeHistoryData } = usePageHistoryQuery(activeHistoryId);
|
||||
|
||||
const [mainEditor] = useAtom(pageEditorAtom);
|
||||
const [mainEditorTitle] = useAtom(titleEditorAtom);
|
||||
const [, setHistoryModalOpen] = useAtom(historyAtoms);
|
||||
|
||||
const confirmModal = () => modals.openConfirmModal({
|
||||
title: 'Please confirm your action',
|
||||
children: (
|
||||
<Text size="sm">
|
||||
Are you sure you want to restore this version? Any changes not versioned will be lost.
|
||||
</Text>
|
||||
),
|
||||
labels: { confirm: 'Confirm', cancel: 'Cancel' },
|
||||
onConfirm: handleRestore,
|
||||
});
|
||||
|
||||
const handleRestore = useCallback(() => {
|
||||
if (activeHistoryData) {
|
||||
mainEditorTitle.chain().clearContent().setContent(activeHistoryData.title, true).run();
|
||||
mainEditor.chain().clearContent().setContent(activeHistoryData.content).run();
|
||||
setHistoryModalOpen(false);
|
||||
notifications.show({ message: 'Successfully restored' });
|
||||
|
||||
}
|
||||
}, [activeHistoryData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (data && data.length > 0 && !activeHistoryId) {
|
||||
setActiveHistoryId(data[0].id);
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
if (isLoading) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return <div>Error loading page history.</div>;
|
||||
}
|
||||
|
||||
if (!data || data.length === 0) {
|
||||
return <>No page history saved yet.</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<ScrollArea h={620} w="100%" type="scroll" scrollbarSize={5}>
|
||||
{data && data.map((historyItem, index) => (
|
||||
<HistoryItem
|
||||
key={index}
|
||||
historyItem={historyItem}
|
||||
onSelect={setActiveHistoryId}
|
||||
isActive={historyItem.id === activeHistoryId}
|
||||
/>
|
||||
))}
|
||||
</ScrollArea>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Group p="xs" wrap="nowrap">
|
||||
<Button size="compact-md" onClick={confirmModal}>Restore</Button>
|
||||
<Button variant="default" size="compact-md" onClick={() => setHistoryModalOpen(false)}>Cancel</Button>
|
||||
</Group>
|
||||
</div>
|
||||
|
||||
);
|
||||
}
|
||||
|
||||
export default HistoryList;
|
||||
@ -0,0 +1,27 @@
|
||||
import { ScrollArea } from '@mantine/core';
|
||||
import HistoryList from '@/features/page-history/components/history-list';
|
||||
import classes from './history.module.css';
|
||||
import { useAtom } from 'jotai';
|
||||
import { activeHistoryIdAtom } from '@/features/page-history/atoms/history-atoms';
|
||||
import HistoryView from '@/features/page-history/components/history-view';
|
||||
|
||||
export default function HistoryModalBody() {
|
||||
const [activeHistoryId] = useAtom(activeHistoryIdAtom);
|
||||
|
||||
return (
|
||||
<div className={classes.sidebarFlex}>
|
||||
<nav className={classes.sidebar}>
|
||||
<div className={classes.sidebarMain}>
|
||||
<HistoryList />
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<ScrollArea h="650" w="100%" scrollbarSize={5}>
|
||||
<div className={classes.sidebarRightSection}>
|
||||
{activeHistoryId && <HistoryView historyId={activeHistoryId} />}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,27 @@
|
||||
import { Modal, Text } from '@mantine/core';
|
||||
import { useAtom } from 'jotai';
|
||||
import { historyAtoms } from '@/features/page-history/atoms/history-atoms';
|
||||
import HistoryModalBody from '@/features/page-history/components/history-modal-body';
|
||||
|
||||
export default function HistoryModal() {
|
||||
const [isModalOpen, setModalOpen] = useAtom(historyAtoms);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal.Root size={1200} opened={isModalOpen} onClose={() => setModalOpen(false)}>
|
||||
<Modal.Overlay />
|
||||
<Modal.Content style={{ overflow: 'hidden' }}>
|
||||
<Modal.Header>
|
||||
<Modal.Title>
|
||||
<Text size="md" fw={500}>Page history</Text>
|
||||
</Modal.Title>
|
||||
<Modal.CloseButton />
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
<HistoryModalBody />
|
||||
</Modal.Body>
|
||||
</Modal.Content>
|
||||
</Modal.Root>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,26 @@
|
||||
import { usePageHistoryQuery } from '@/features/page-history/queries/page-history-query';
|
||||
import { HistoryEditor } from '@/features/page-history/components/history-editor';
|
||||
|
||||
interface HistoryProps {
|
||||
historyId: string;
|
||||
}
|
||||
|
||||
function HistoryView({ historyId }: HistoryProps) {
|
||||
const { data, isLoading, isError } = usePageHistoryQuery(historyId);
|
||||
|
||||
if (isLoading) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
if (isError || !data) {
|
||||
return <div>Error fetching page data.</div>;
|
||||
}
|
||||
|
||||
return (data &&
|
||||
<div>
|
||||
<HistoryEditor content={data.content} title={data.title} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default HistoryView;
|
||||
@ -0,0 +1,37 @@
|
||||
.history {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: var(--mantine-spacing-md);
|
||||
color: light-dark(var(--mantine-color-black), var(--mantine-color-dark-0));
|
||||
|
||||
@mixin hover {
|
||||
background-color: light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-8));
|
||||
}
|
||||
}
|
||||
|
||||
.active {
|
||||
background-color: light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-8));
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
max-height: rem(700px);
|
||||
width: rem(250px);
|
||||
padding: var(--mantine-spacing-sm);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-right: rem(1px) solid
|
||||
light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
|
||||
}
|
||||
|
||||
.sidebarFlex {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.sidebarMain {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.sidebarRightSection {
|
||||
flex: 1;
|
||||
padding: rem(16px) rem(40px);
|
||||
}
|
||||
@ -0,0 +1,20 @@
|
||||
import { useQuery, UseQueryResult } from '@tanstack/react-query';
|
||||
import { getPageHistoryById, getPageHistoryList } from '@/features/page-history/services/page-history-service';
|
||||
import { IPageHistory } from '@/features/page-history/types/page.types';
|
||||
|
||||
export function usePageHistoryListQuery(pageId: string): UseQueryResult<IPageHistory[], Error> {
|
||||
return useQuery({
|
||||
queryKey: ['page-history-list', pageId],
|
||||
queryFn: () => getPageHistoryList(pageId),
|
||||
enabled: !!pageId,
|
||||
});
|
||||
}
|
||||
|
||||
export function usePageHistoryQuery(historyId: string): UseQueryResult<IPageHistory, Error> {
|
||||
return useQuery({
|
||||
queryKey: ['page-history', historyId],
|
||||
queryFn: () => getPageHistoryById(historyId),
|
||||
enabled: !!historyId,
|
||||
staleTime: 10 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
@ -0,0 +1,12 @@
|
||||
import api from '@/lib/api-client';
|
||||
import { IPageHistory } from '@/features/page-history/types/page.types';
|
||||
|
||||
export async function getPageHistoryList(pageId: string): Promise<IPageHistory[]> {
|
||||
const req = await api.post<IPageHistory[]>('/pages/history', { pageId });
|
||||
return req.data as IPageHistory[];
|
||||
}
|
||||
|
||||
export async function getPageHistoryById(id: string): Promise<IPageHistory> {
|
||||
const req = await api.post<IPageHistory>('/pages/history/details', { id });
|
||||
return req.data as IPageHistory;
|
||||
}
|
||||
21
apps/client/src/features/page-history/types/page.types.ts
Normal file
21
apps/client/src/features/page-history/types/page.types.ts
Normal file
@ -0,0 +1,21 @@
|
||||
interface IPageHistoryUser {
|
||||
id: string;
|
||||
name: string;
|
||||
avatarUrl: string;
|
||||
}
|
||||
|
||||
export interface IPageHistory {
|
||||
id: string;
|
||||
pageId: string;
|
||||
title: string;
|
||||
content?: any;
|
||||
slug: string;
|
||||
icon: string;
|
||||
coverPhoto: string;
|
||||
version: number;
|
||||
lastUpdatedById: string;
|
||||
workspaceId: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
lastUpdatedBy: IPageHistoryUser;
|
||||
}
|
||||
55
apps/client/src/features/page/queries/page-query.ts
Normal file
55
apps/client/src/features/page/queries/page-query.ts
Normal file
@ -0,0 +1,55 @@
|
||||
import { useMutation, useQuery, UseQueryResult, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
createPage,
|
||||
deletePage,
|
||||
getPageById,
|
||||
getRecentChanges,
|
||||
updatePage,
|
||||
} from '@/features/page/services/page-service';
|
||||
import { IPage } from '@/features/page/types/page.types';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
|
||||
const RECENT_CHANGES_KEY = ['recentChanges'];
|
||||
|
||||
export function usePageQuery(pageId: string): UseQueryResult<IPage, Error> {
|
||||
return useQuery({
|
||||
queryKey: ['pages', pageId],
|
||||
queryFn: () => getPageById(pageId),
|
||||
enabled: !!pageId,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useRecentChangesQuery(): UseQueryResult<IPage[], Error> {
|
||||
return useQuery({
|
||||
queryKey: RECENT_CHANGES_KEY,
|
||||
queryFn: () => getRecentChanges(),
|
||||
refetchOnMount: true,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreatePageMutation() {
|
||||
return useMutation<IPage, Error, Partial<IPage>>({
|
||||
mutationFn: (data) => createPage(data),
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdatePageMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<IPage, Error, Partial<IPage>>({
|
||||
mutationFn: (data) => updatePage(data),
|
||||
onSuccess: (data) => {
|
||||
queryClient.setQueryData(['pages', data.id], data);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeletePageMutation() {
|
||||
return useMutation({
|
||||
mutationFn: (pageId: string) => deletePage(pageId),
|
||||
onSuccess: () => {
|
||||
notifications.show({ message: 'Page deleted successfully' });
|
||||
},
|
||||
});
|
||||
}
|
||||
40
apps/client/src/features/page/services/page-service.ts
Normal file
40
apps/client/src/features/page/services/page-service.ts
Normal file
@ -0,0 +1,40 @@
|
||||
import api from '@/lib/api-client';
|
||||
import { IMovePage, IPage, IWorkspacePageOrder } from '@/features/page/types/page.types';
|
||||
|
||||
export async function createPage(data: Partial<IPage>): Promise<IPage> {
|
||||
const req = await api.post<IPage>('/pages/create', data);
|
||||
return req.data as IPage;
|
||||
}
|
||||
|
||||
export async function getPageById(id: string): Promise<IPage> {
|
||||
const req = await api.post<IPage>('/pages/details', { id });
|
||||
return req.data as IPage;
|
||||
}
|
||||
|
||||
export async function getRecentChanges(): Promise<IPage[]> {
|
||||
const req = await api.post<IPage[]>('/pages/recent');
|
||||
return req.data as IPage[];
|
||||
}
|
||||
|
||||
export async function getPages(): Promise<IPage[]> {
|
||||
const req = await api.post<IPage[]>('/pages');
|
||||
return req.data as IPage[];
|
||||
}
|
||||
|
||||
export async function getWorkspacePageOrder(): Promise<IWorkspacePageOrder[]> {
|
||||
const req = await api.post<IWorkspacePageOrder[]>('/pages/ordering');
|
||||
return req.data as IWorkspacePageOrder[];
|
||||
}
|
||||
|
||||
export async function updatePage(data: Partial<IPage>): Promise<IPage> {
|
||||
const req = await api.post<IPage>(`/pages/update`, data);
|
||||
return req.data as IPage;
|
||||
}
|
||||
|
||||
export async function movePage(data: IMovePage): Promise<void> {
|
||||
await api.post<IMovePage>('/pages/move', data);
|
||||
}
|
||||
|
||||
export async function deletePage(id: string): Promise<void> {
|
||||
await api.post('/pages/delete', { id });
|
||||
}
|
||||
@ -0,0 +1,5 @@
|
||||
import { atom } from "jotai";
|
||||
import { TreeApi } from 'react-arborist';
|
||||
import { TreeNode } from "../types";
|
||||
|
||||
export const treeApiAtom = atom<TreeApi<TreeNode> | null>(null);
|
||||
@ -0,0 +1,4 @@
|
||||
import { atom } from "jotai";
|
||||
import { TreeNode } from '@/features/page/tree/types';
|
||||
|
||||
export const treeDataAtom = atom<TreeNode[]>([]);
|
||||
@ -0,0 +1,4 @@
|
||||
import { atomWithStorage } from "jotai/utils";
|
||||
import { IWorkspacePageOrder } from '@/features/page/types/page.types';
|
||||
|
||||
export const workspacePageOrderAtom = atomWithStorage<IWorkspacePageOrder | null>("workspace-page-order", null);
|
||||
@ -0,0 +1,28 @@
|
||||
import React, { ReactElement } from 'react';
|
||||
import { useElementSize } from '@mantine/hooks';
|
||||
import { useMergedRef } from '@mantine/hooks';
|
||||
|
||||
type Props = {
|
||||
children: (dimens: { width: number; height: number }) => ReactElement;
|
||||
};
|
||||
|
||||
const style = {
|
||||
flex: 1,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
minHeight: 0,
|
||||
minWidth: 0,
|
||||
};
|
||||
|
||||
export const FillFlexParent = React.forwardRef(function FillFlexParent(
|
||||
props: Props,
|
||||
forwardRef
|
||||
) {
|
||||
const { ref, width, height } = useElementSize();
|
||||
const mergedRef = useMergedRef(ref, forwardRef);
|
||||
return (
|
||||
<div style={style} ref={mergedRef}>
|
||||
{width && height ? props.children({ width, height }) : null}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
105
apps/client/src/features/page/tree/hooks/use-persistence.ts
Normal file
105
apps/client/src/features/page/tree/hooks/use-persistence.ts
Normal file
@ -0,0 +1,105 @@
|
||||
import { useMemo } from 'react';
|
||||
import {
|
||||
CreateHandler,
|
||||
DeleteHandler,
|
||||
MoveHandler,
|
||||
RenameHandler,
|
||||
SimpleTree,
|
||||
} from 'react-arborist';
|
||||
import { useAtom } from 'jotai';
|
||||
import { treeDataAtom } from '@/features/page/tree/atoms/tree-data-atom';
|
||||
import { movePage } from '@/features/page/services/page-service';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { IMovePage } from '@/features/page/types/page.types';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { TreeNode } from '@/features/page/tree/types';
|
||||
import { useCreatePageMutation, useDeletePageMutation, useUpdatePageMutation } from '@/features/page/queries/page-query';
|
||||
|
||||
export function usePersistence<T>() {
|
||||
const [data, setData] = useAtom(treeDataAtom);
|
||||
const createPageMutation = useCreatePageMutation();
|
||||
const updatePageMutation = useUpdatePageMutation();
|
||||
const deletePageMutation = useDeletePageMutation();
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const tree = useMemo(() => new SimpleTree<TreeNode>(data), [data]);
|
||||
|
||||
const onMove: MoveHandler<T> = (args: { parentId, index, parentNode, dragNodes, dragIds }) => {
|
||||
for (const id of args.dragIds) {
|
||||
tree.move({ id, parentId: args.parentId, index: args.index });
|
||||
}
|
||||
setData(tree.data);
|
||||
|
||||
const newDragIndex = tree.find(args.dragIds[0])?.childIndex;
|
||||
|
||||
const currentTreeData = args.parentId ? tree.find(args.parentId).children : tree.data;
|
||||
const afterId = currentTreeData[newDragIndex - 1]?.id || null;
|
||||
const beforeId = !afterId && currentTreeData[newDragIndex + 1]?.id || null;
|
||||
|
||||
const params: IMovePage = {
|
||||
id: args.dragIds[0],
|
||||
after: afterId,
|
||||
before: beforeId,
|
||||
parentId: args.parentId || null,
|
||||
};
|
||||
|
||||
const payload = Object.fromEntries(
|
||||
Object.entries(params).filter(([key, value]) => value !== null && value !== undefined),
|
||||
);
|
||||
|
||||
try {
|
||||
movePage(payload as IMovePage);
|
||||
} catch (error) {
|
||||
console.error('Error moving page:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const onRename: RenameHandler<T> = ({ name, id }) => {
|
||||
tree.update({ id, changes: { name } as any });
|
||||
setData(tree.data);
|
||||
|
||||
try {
|
||||
updatePageMutation.mutateAsync({ id, title: name });
|
||||
} catch (error) {
|
||||
console.error('Error updating page title:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const onCreate: CreateHandler<T> = async ({ parentId, index, type }) => {
|
||||
const data = { id: uuidv4(), name: '' } as any;
|
||||
data.children = [];
|
||||
tree.create({ parentId, index, data });
|
||||
setData(tree.data);
|
||||
|
||||
const payload: { id: string; parentPageId?: string } = { id: data.id };
|
||||
if (parentId) {
|
||||
payload.parentPageId = parentId;
|
||||
}
|
||||
|
||||
try {
|
||||
await createPageMutation.mutateAsync(payload);
|
||||
navigate(`/p/${payload.id}`);
|
||||
} catch (error) {
|
||||
console.error('Error creating the page:', error);
|
||||
}
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
const onDelete: DeleteHandler<T> = async (args: { ids: string[] }) => {
|
||||
args.ids.forEach((id) => tree.drop({ id }));
|
||||
setData(tree.data);
|
||||
|
||||
try {
|
||||
await deletePageMutation.mutateAsync(args.ids[0]);
|
||||
navigate('/home');
|
||||
} catch (error) {
|
||||
console.error('Error deleting page:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const controllers = { onMove, onRename, onCreate, onDelete };
|
||||
|
||||
return { data, setData, controllers } as const;
|
||||
}
|
||||
@ -0,0 +1,12 @@
|
||||
import { useQuery, UseQueryResult } from "@tanstack/react-query";
|
||||
import { IWorkspacePageOrder } from '@/features/page/types/page.types';
|
||||
import { getWorkspacePageOrder } from '@/features/page/services/page-service';
|
||||
|
||||
export default function useWorkspacePageOrder(): UseQueryResult<IWorkspacePageOrder> {
|
||||
return useQuery({
|
||||
queryKey: ["workspace-page-order"],
|
||||
queryFn: async () => {
|
||||
return await getWorkspacePageOrder();
|
||||
},
|
||||
});
|
||||
}
|
||||
266
apps/client/src/features/page/tree/page-tree.tsx
Normal file
266
apps/client/src/features/page/tree/page-tree.tsx
Normal file
@ -0,0 +1,266 @@
|
||||
import { NodeApi, NodeRendererProps, Tree, TreeApi } from 'react-arborist';
|
||||
import {
|
||||
IconArrowsLeftRight,
|
||||
IconChevronDown,
|
||||
IconChevronRight,
|
||||
IconCornerRightUp,
|
||||
IconDotsVertical,
|
||||
IconEdit,
|
||||
IconFileDescription,
|
||||
IconLink,
|
||||
IconPlus,
|
||||
IconStar,
|
||||
IconTrash,
|
||||
} from '@tabler/icons-react';
|
||||
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import clsx from 'clsx';
|
||||
|
||||
import classes from './styles/tree.module.css';
|
||||
import { ActionIcon, Menu, rem } from '@mantine/core';
|
||||
import { useAtom } from 'jotai';
|
||||
import { FillFlexParent } from './components/fill-flex-parent';
|
||||
import { TreeNode } from './types';
|
||||
import { treeApiAtom } from './atoms/tree-api-atom';
|
||||
import { usePersistence } from '@/features/page/tree/hooks/use-persistence';
|
||||
import { getPages } from '@/features/page/services/page-service';
|
||||
import useWorkspacePageOrder from '@/features/page/tree/hooks/use-workspace-page-order';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { convertToTree } from '@/features/page/tree/utils';
|
||||
|
||||
export default function PageTree() {
|
||||
const { data, setData, controllers } = usePersistence<TreeApi<TreeNode>>();
|
||||
const [tree, setTree] = useAtom<TreeApi<TreeNode>>(treeApiAtom);
|
||||
const { data: pageOrderData } = useWorkspacePageOrder();
|
||||
const rootElement = useRef<HTMLDivElement>();
|
||||
const { pageId } = useParams();
|
||||
|
||||
const fetchAndSetTreeData = async () => {
|
||||
if (pageOrderData?.childrenIds) {
|
||||
try {
|
||||
const pages = await getPages();
|
||||
const treeData = convertToTree(pages, pageOrderData.childrenIds);
|
||||
setData(treeData);
|
||||
} catch (err) {
|
||||
console.error('Error fetching tree data: ', err);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchAndSetTreeData();
|
||||
}, [pageOrderData?.childrenIds]);
|
||||
|
||||
useEffect(() => {
|
||||
setTimeout(() => {
|
||||
tree?.select(pageId);
|
||||
tree?.scrollTo(pageId, 'center');
|
||||
}, 200);
|
||||
}, [tree, pageId]);
|
||||
|
||||
return (
|
||||
<div ref={rootElement} className={classes.treeContainer}>
|
||||
<FillFlexParent>
|
||||
{(dimens) => (
|
||||
<Tree
|
||||
data={data}
|
||||
{...controllers}
|
||||
{...dimens}
|
||||
// @ts-ignore
|
||||
ref={(t) => setTree(t)}
|
||||
openByDefault={false}
|
||||
disableMultiSelection={true}
|
||||
className={classes.tree}
|
||||
rowClassName={classes.row}
|
||||
padding={15}
|
||||
rowHeight={30}
|
||||
overscanCount={5}
|
||||
dndRootElement={rootElement.current}
|
||||
>
|
||||
{Node}
|
||||
</Tree>
|
||||
)}
|
||||
</FillFlexParent>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Node({ node, style, dragHandle }: NodeRendererProps<any>) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleClick = () => {
|
||||
navigate(`/p/${node.id}`);
|
||||
};
|
||||
|
||||
if (node.willReceiveDrop && node.isClosed) {
|
||||
setTimeout(() => {
|
||||
if (node.state.willReceiveDrop) node.open();
|
||||
}, 650);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
style={style}
|
||||
className={clsx(classes.node, node.state)}
|
||||
ref={dragHandle}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<PageArrow node={node} />
|
||||
|
||||
<IconFileDescription size="18px" style={{ marginRight: '4px' }} />
|
||||
|
||||
<span className={classes.text}>
|
||||
{node.isEditing ? (
|
||||
<Input node={node} />
|
||||
) : (
|
||||
node.data.name || 'untitled'
|
||||
)}
|
||||
</span>
|
||||
|
||||
<div className={classes.actions}>
|
||||
<NodeMenu node={node} />
|
||||
<CreateNode node={node} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function CreateNode({ node }: { node: NodeApi<TreeNode> }) {
|
||||
const [tree] = useAtom(treeApiAtom);
|
||||
|
||||
function handleCreate() {
|
||||
tree?.create({ type: 'internal', parentId: node.id, index: 0 });
|
||||
}
|
||||
|
||||
return (
|
||||
<ActionIcon variant="transparent" color="gray" onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleCreate();
|
||||
}}>
|
||||
<IconPlus style={{ width: rem(20), height: rem(20) }} stroke={2} />
|
||||
</ActionIcon>
|
||||
);
|
||||
}
|
||||
|
||||
function NodeMenu({ node }: { node: NodeApi<TreeNode> }) {
|
||||
const [tree] = useAtom(treeApiAtom);
|
||||
|
||||
function handleDelete() {
|
||||
tree?.delete(node);
|
||||
}
|
||||
|
||||
return (
|
||||
<Menu shadow="md" width={200}>
|
||||
<Menu.Target>
|
||||
<ActionIcon variant="transparent" color="gray" onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}>
|
||||
<IconDotsVertical
|
||||
style={{ width: rem(20), height: rem(20) }}
|
||||
stroke={2}
|
||||
/>
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
leftSection={<IconEdit style={{ width: rem(14), height: rem(14) }} />}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
node.edit();
|
||||
}}
|
||||
>
|
||||
Rename
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<IconStar style={{ width: rem(14), height: rem(14) }} />}
|
||||
>
|
||||
Favorite
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Divider />
|
||||
|
||||
<Menu.Item
|
||||
leftSection={<IconLink style={{ width: rem(14), height: rem(14) }} />}
|
||||
>
|
||||
Copy link
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Item
|
||||
leftSection={
|
||||
<IconCornerRightUp style={{ width: rem(14), height: rem(14) }} />
|
||||
}
|
||||
>
|
||||
Move
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
leftSection={
|
||||
<IconArrowsLeftRight style={{ width: rem(14), height: rem(14) }} />
|
||||
}
|
||||
>
|
||||
Archive
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
color="red"
|
||||
leftSection={
|
||||
<IconTrash style={{ width: rem(14), height: rem(14) }} />
|
||||
}
|
||||
onClick={() => handleDelete()}
|
||||
>
|
||||
Delete
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
|
||||
function PageArrow({ node }: { node: NodeApi<TreeNode> }) {
|
||||
return (
|
||||
<ActionIcon size={20} variant="subtle" color="gray"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
node.toggle();
|
||||
}}>
|
||||
|
||||
{node.isInternal ? (
|
||||
node.children && node.children.length > 0 ? (
|
||||
node.isOpen ? (
|
||||
<IconChevronDown stroke={2} size={18} />
|
||||
) : (
|
||||
<IconChevronRight stroke={2} size={18} />
|
||||
)
|
||||
) : (
|
||||
<IconChevronRight size={18} style={{ visibility: 'hidden' }} />
|
||||
)
|
||||
) : null}
|
||||
</ActionIcon>
|
||||
);
|
||||
}
|
||||
|
||||
function Input({ node }: { node: NodeApi<TreeNode> }) {
|
||||
|
||||
return (
|
||||
<input
|
||||
autoFocus
|
||||
name="name"
|
||||
type="text"
|
||||
placeholder="untitled"
|
||||
defaultValue={node.data.name}
|
||||
onFocus={(e) => e.currentTarget.select()}
|
||||
onBlur={() => node.reset()}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape') node.reset();
|
||||
if (e.key === 'Enter') node.submit(e.currentTarget.value);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
99
apps/client/src/features/page/tree/styles/tree.module.css
Normal file
99
apps/client/src/features/page/tree/styles/tree.module.css
Normal file
@ -0,0 +1,99 @@
|
||||
.tree {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.treeContainer {
|
||||
display: flex;
|
||||
height: 60vh;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.node {
|
||||
position: relative;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
|
||||
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-0));
|
||||
|
||||
&:hover {
|
||||
background-color: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5));
|
||||
}
|
||||
|
||||
|
||||
.actions {
|
||||
visibility: hidden;
|
||||
position: absolute;
|
||||
height: 100%;
|
||||
top: 0;
|
||||
right: 0;
|
||||
border-top-right-radius: 4px;
|
||||
border-bottom-right-radius: 4px;
|
||||
background-color: light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-6));
|
||||
}
|
||||
|
||||
&:hover .actions {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.node:global(.willReceiveDrop) {
|
||||
background-color: light-dark(var(--mantine-color-blue-1), var(--mantine-color-gray-7));
|
||||
}
|
||||
|
||||
.node:global(.isSelected) {
|
||||
border-radius: 0;
|
||||
|
||||
background-color: light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-6));
|
||||
/*
|
||||
color: white;
|
||||
|
||||
// background-color: light-dark(
|
||||
// var(--mantine-color-gray-0),
|
||||
// var(--mantine-color-dark-6)
|
||||
//);
|
||||
//background: rgb(20, 127, 250, 0.5);*/
|
||||
}
|
||||
|
||||
.node:global(.isSelectedStart.isSelectedEnd) {
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.row:focus .node:global(.isSelected) {
|
||||
background-color: light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-6));
|
||||
}
|
||||
|
||||
.row {
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.row:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.row:focus .node {
|
||||
/** come back to this **/
|
||||
background-color: light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
|
||||
}
|
||||
|
||||
.icon {
|
||||
margin: 0 rem(10px);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.text {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-size: rem(14px);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.arrow {
|
||||
display: flex;
|
||||
}
|
||||
7
apps/client/src/features/page/tree/types.ts
Normal file
7
apps/client/src/features/page/tree/types.ts
Normal file
@ -0,0 +1,7 @@
|
||||
export type TreeNode = {
|
||||
id: string
|
||||
name: string
|
||||
icon?: string
|
||||
slug?: string
|
||||
children: TreeNode[]
|
||||
}
|
||||
62
apps/client/src/features/page/tree/utils/index.ts
Normal file
62
apps/client/src/features/page/tree/utils/index.ts
Normal file
@ -0,0 +1,62 @@
|
||||
import { IPage } from '@/features/page/types/page.types';
|
||||
import { TreeNode } from '@/features/page/tree/types';
|
||||
|
||||
export function convertToTree(pages: IPage[], pageOrder: string[]): TreeNode[] {
|
||||
const pageMap: { [id: string]: IPage } = {};
|
||||
pages.forEach(page => {
|
||||
pageMap[page.id] = page;
|
||||
});
|
||||
|
||||
function buildTreeNode(id: string): TreeNode | undefined {
|
||||
const page = pageMap[id];
|
||||
if (!page) return;
|
||||
|
||||
const node: TreeNode = {
|
||||
id: page.id,
|
||||
name: page.title,
|
||||
children: [],
|
||||
};
|
||||
|
||||
if (page.icon) node.icon = page.icon;
|
||||
|
||||
if (page.childrenIds && page.childrenIds.length > 0) {
|
||||
node.children = page.childrenIds.map(childId => buildTreeNode(childId)).filter(Boolean) as TreeNode[];
|
||||
}
|
||||
|
||||
return node;
|
||||
}
|
||||
|
||||
return pageOrder.map(id => buildTreeNode(id)).filter(Boolean) as TreeNode[];
|
||||
}
|
||||
|
||||
export function findBreadcrumbPath(tree: TreeNode[], pageId: string, path: TreeNode[] = []): TreeNode[] | null {
|
||||
for (const node of tree) {
|
||||
if (!node.name || node.name.trim() === "") {
|
||||
node.name = "untitled";
|
||||
}
|
||||
|
||||
if (node.id === pageId) {
|
||||
return [...path, node];
|
||||
}
|
||||
|
||||
if (node.children) {
|
||||
const newPath = findBreadcrumbPath(node.children, pageId, [...path, node]);
|
||||
if (newPath) {
|
||||
return newPath;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export const updateTreeNodeName = (nodes: TreeNode[], nodeId: string, newName: string): TreeNode[] => {
|
||||
return nodes.map(node => {
|
||||
if (node.id === nodeId) {
|
||||
return { ...node, name: newName };
|
||||
}
|
||||
if (node.children && node.children.length > 0) {
|
||||
return { ...node, children: updateTreeNodeName(node.children, nodeId, newName) };
|
||||
}
|
||||
return node;
|
||||
});
|
||||
};
|
||||
35
apps/client/src/features/page/types/page.types.ts
Normal file
35
apps/client/src/features/page/types/page.types.ts
Normal file
@ -0,0 +1,35 @@
|
||||
export interface IPage {
|
||||
id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
html: string;
|
||||
slug: string;
|
||||
icon: string;
|
||||
coverPhoto: string;
|
||||
editor: string;
|
||||
shareId: string;
|
||||
parentPageId: string;
|
||||
creatorId: string;
|
||||
workspaceId: string;
|
||||
children:[]
|
||||
childrenIds:[]
|
||||
isLocked: boolean;
|
||||
status: string;
|
||||
publishedAt: Date;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
deletedAt: Date;
|
||||
}
|
||||
|
||||
export interface IMovePage {
|
||||
id: string;
|
||||
after?: string;
|
||||
before?: string;
|
||||
parentId?: string;
|
||||
}
|
||||
|
||||
export interface IWorkspacePageOrder {
|
||||
id: string;
|
||||
childrenIds: string[];
|
||||
workspaceId: string;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user