mirror of
https://github.com/docmost/docmost.git
synced 2025-11-25 08:41:10 +10:00
Compare commits
69 Commits
0.2.3
...
be0d97661a
| Author | SHA1 | Date | |
|---|---|---|---|
| be0d97661a | |||
| 4e2b23c97e | |||
| dc3ce27762 | |||
| 8af2d4e8cf | |||
| 73ddec4ca7 | |||
| 2b9765fb35 | |||
| 7fdd355cc3 | |||
| 6c6b47599a | |||
| 7e6a71fa2d | |||
| 1141796f24 | |||
| 11dbc079be | |||
| 87b99f8646 | |||
| 38e9eef2dc | |||
| 77b541ec71 | |||
| 7dc37b933f | |||
| 7e80797e3f | |||
| 17475bf123 | |||
| 4433d5174d | |||
| c810d0b314 | |||
| 463480ae67 | |||
| 2449d69fab | |||
| e0d74fcb0e | |||
| 4967849e3a | |||
| 0a447e91bb | |||
| 48e76aa9f4 | |||
| 2bd6422a35 | |||
| 407a1aff3b | |||
| b4bc184cb3 | |||
| 109dbdbe02 | |||
| 2df7de5828 | |||
| 373fc86e47 | |||
| 5052a9ea40 | |||
| cd47c79d86 | |||
| 78746938b7 | |||
| 4d2936627c | |||
| d2ecd28047 | |||
| bb92ca75e9 | |||
| 8f3e2ff663 | |||
| 89f6311e46 | |||
| e5a97d2a26 | |||
| 7f0fd45f3a | |||
| 078959dfa0 | |||
| 937a07059a | |||
| 227ac30d5e | |||
| a2ae341934 | |||
| 3c70e40d16 | |||
| 14197d7365 | |||
| f388540293 | |||
| b43de81013 | |||
| 6659adc7fe | |||
| 24adff9679 | |||
| e960b8c1a9 | |||
| 1958067110 | |||
| 3e519ebcd8 | |||
| 07cd650205 | |||
| 949d782a28 | |||
| 295d4325bf | |||
| 40a40bb3c7 | |||
| bc1579b022 | |||
| 85b3073681 | |||
| 4af3a54649 | |||
| c4c169b17a | |||
| a0536d852f | |||
| f12f93b373 | |||
| 35dcd5f254 | |||
| 9496ec9b57 | |||
| 5ace7616d0 | |||
| ce6a05ab66 | |||
| 66773dfaca |
@ -19,6 +19,7 @@ AWS_S3_SECRET_ACCESS_KEY=
|
|||||||
AWS_S3_REGION=
|
AWS_S3_REGION=
|
||||||
AWS_S3_BUCKET=
|
AWS_S3_BUCKET=
|
||||||
AWS_S3_ENDPOINT=
|
AWS_S3_ENDPOINT=
|
||||||
|
AWS_S3_FORCE_PATH_STYLE=
|
||||||
|
|
||||||
# options: smtp | postmark
|
# options: smtp | postmark
|
||||||
MAIL_DRIVER=smtp
|
MAIL_DRIVER=smtp
|
||||||
@ -30,6 +31,7 @@ SMTP_HOST=127.0.0.1
|
|||||||
SMTP_PORT=587
|
SMTP_PORT=587
|
||||||
SMTP_USERNAME=
|
SMTP_USERNAME=
|
||||||
SMTP_PASSWORD=
|
SMTP_PASSWORD=
|
||||||
|
SMTP_SECURE=false
|
||||||
|
|
||||||
# Postmark driver config
|
# Postmark driver config
|
||||||
POSTMARK_TOKEN=
|
POSTMARK_TOKEN=
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
FROM node:21-alpine AS base
|
FROM node:21-alpine AS base
|
||||||
|
LABEL org.opencontainers.image.source="https://github.com/docmost/docmost"
|
||||||
|
|
||||||
FROM base AS builder
|
FROM base AS builder
|
||||||
|
|
||||||
|
|||||||
@ -17,6 +17,7 @@ To get started with Docmost, please refer to our [documentation](https://docmost
|
|||||||
|
|
||||||
## Features
|
## Features
|
||||||
- Real-time collaboration
|
- Real-time collaboration
|
||||||
|
- Diagrams (Draw.io, Excalidraw and Mermaid)
|
||||||
- Spaces
|
- Spaces
|
||||||
- Permissions management
|
- Permissions management
|
||||||
- Groups
|
- Groups
|
||||||
@ -32,4 +33,4 @@ To get started with Docmost, please refer to our [documentation](https://docmost
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
### Contributing
|
### Contributing
|
||||||
See the [development doc](https://docmost.com/docs/self-hosting/development)
|
See the [development documentation](https://docmost.com/docs/self-hosting/development)
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "client",
|
"name": "client",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.2.3",
|
"version": "0.3.1",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc && vite build",
|
"build": "tsc && vite build",
|
||||||
@ -13,55 +13,61 @@
|
|||||||
"@casl/react": "^4.0.0",
|
"@casl/react": "^4.0.0",
|
||||||
"@emoji-mart/data": "^1.2.1",
|
"@emoji-mart/data": "^1.2.1",
|
||||||
"@emoji-mart/react": "^1.1.1",
|
"@emoji-mart/react": "^1.1.1",
|
||||||
"@mantine/core": "^7.11.0",
|
"@excalidraw/excalidraw": "^0.17.6",
|
||||||
"@mantine/form": "^7.11.0",
|
"@mantine/core": "^7.12.2",
|
||||||
"@mantine/hooks": "^7.11.0",
|
"@mantine/form": "^7.12.2",
|
||||||
"@mantine/modals": "^7.11.0",
|
"@mantine/hooks": "^7.12.2",
|
||||||
"@mantine/notifications": "^7.11.0",
|
"@mantine/modals": "^7.12.2",
|
||||||
"@mantine/spotlight": "^7.11.0",
|
"@mantine/notifications": "^7.12.2",
|
||||||
"@tabler/icons-react": "^3.7.0",
|
"@mantine/spotlight": "^7.12.2",
|
||||||
"@tanstack/react-query": "^5.48.0",
|
"@tabler/icons-react": "^3.14.0",
|
||||||
"@tiptap/extension-code-block-lowlight": "^2.4.0",
|
"@tanstack/react-query": "^5.53.2",
|
||||||
"axios": "^1.7.2",
|
"axios": "^1.7.7",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"date-fns": "^3.6.0",
|
"date-fns": "^3.6.0",
|
||||||
"emoji-mart": "^5.6.0",
|
"emoji-mart": "^5.6.0",
|
||||||
"jotai": "^2.8.3",
|
"file-saver": "^2.0.5",
|
||||||
|
"jotai": "^2.9.3",
|
||||||
"jotai-optics": "^0.4.0",
|
"jotai-optics": "^0.4.0",
|
||||||
"js-cookie": "^3.0.5",
|
"js-cookie": "^3.0.5",
|
||||||
"jwt-decode": "^4.0.0",
|
"jwt-decode": "^4.0.0",
|
||||||
"katex": "^0.16.10",
|
"katex": "^0.16.11",
|
||||||
"lowlight": "^3.1.0",
|
"lowlight": "^3.1.0",
|
||||||
|
"mermaid": "^11.0.2",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-arborist": "^3.4.0",
|
"react-arborist": "^3.4.0",
|
||||||
|
"react-clear-modal": "^2.0.9",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
|
"react-drawio": "^0.2.0",
|
||||||
"react-error-boundary": "^4.0.13",
|
"react-error-boundary": "^4.0.13",
|
||||||
"react-helmet-async": "^2.0.5",
|
"react-helmet-async": "^2.0.5",
|
||||||
"react-moveable": "^0.56.0",
|
"react-moveable": "^0.56.0",
|
||||||
"react-router-dom": "^6.24.0",
|
"react-router-dom": "^6.26.1",
|
||||||
"socket.io-client": "^4.7.5",
|
"socket.io-client": "^4.7.5",
|
||||||
"tippy.js": "^6.3.7",
|
"tippy.js": "^6.3.7",
|
||||||
|
"tiptap-extension-global-drag-handle": "^0.1.12",
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tanstack/eslint-plugin-query": "^5.47.0",
|
"@tanstack/eslint-plugin-query": "^5.53.0",
|
||||||
|
"@types/file-saver": "^2.0.7",
|
||||||
"@types/js-cookie": "^3.0.6",
|
"@types/js-cookie": "^3.0.6",
|
||||||
"@types/katex": "^0.16.7",
|
"@types/katex": "^0.16.7",
|
||||||
"@types/node": "20.14.9",
|
"@types/node": "22.5.2",
|
||||||
"@types/react": "^18.3.3",
|
"@types/react": "^18.3.5",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react-dom": "^18.3.0",
|
||||||
"@typescript-eslint/eslint-plugin": "^7.14.1",
|
"@typescript-eslint/eslint-plugin": "^8.3.0",
|
||||||
"@typescript-eslint/parser": "^7.14.1",
|
"@typescript-eslint/parser": "^8.3.0",
|
||||||
"@vitejs/plugin-react": "^4.3.1",
|
"@vitejs/plugin-react": "^4.3.1",
|
||||||
"eslint": "^9.5.0",
|
"eslint": "^9.9.1",
|
||||||
"eslint-plugin-react-hooks": "^4.6.2",
|
"eslint-plugin-react-hooks": "^4.6.2",
|
||||||
"eslint-plugin-react-refresh": "^0.4.7",
|
"eslint-plugin-react-refresh": "^0.4.11",
|
||||||
"optics-ts": "^2.4.1",
|
"optics-ts": "^2.4.1",
|
||||||
"postcss": "^8.4.38",
|
"postcss": "^8.4.43",
|
||||||
"postcss-preset-mantine": "^1.15.0",
|
"postcss-preset-mantine": "^1.17.0",
|
||||||
"postcss-simple-vars": "^7.0.1",
|
"postcss-simple-vars": "^7.0.1",
|
||||||
"prettier": "^3.3.2",
|
"prettier": "^3.3.3",
|
||||||
"typescript": "^5.5.2",
|
"typescript": "^5.5.4",
|
||||||
"vite": "^5.3.1"
|
"vite": "^5.4.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
20
apps/client/src/components/icons/icon-drawio.tsx
Normal file
20
apps/client/src/components/icons/icon-drawio.tsx
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import { rem } from '@mantine/core';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
size?: number | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function IconDrawio({ size }: Props) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="#F08705"
|
||||||
|
style={{ width: rem(size), height: rem(size) }}
|
||||||
|
>
|
||||||
|
<path d="M19.69 13.419h-2.527l-2.667-4.555a1.292 1.292 0 001.035-1.28V4.16c0-.725-.576-1.312-1.302-1.312H9.771c-.726 0-1.312.576-1.312 1.301v3.435c0 .619.426 1.152 1.034 1.28l-2.666 4.555H4.309c-.725 0-1.312.576-1.312 1.301v3.435c0 .725.576 1.312 1.302 1.312h4.458c.726 0 1.312-.576 1.312-1.302v-3.434c0-.726-.576-1.312-1.301-1.312h-.437l2.645-4.523h2.059l2.656 4.523h-.438c-.725 0-1.312.576-1.312 1.301v3.435c0 .725.576 1.312 1.302 1.312H19.7c.726 0 1.312-.576 1.312-1.302v-3.434c0-.726-.576-1.312-1.301-1.312zM24 22.976c0 .565-.459 1.024-1.013 1.024H1.024A1.022 1.022 0 010 22.987V1.024C0 .459.459 0 1.013 0h21.963C23.541 0 24 .459 24 1.013z"></path>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default IconDrawio;
|
||||||
20
apps/client/src/components/icons/icon-excalidraw.tsx
Normal file
20
apps/client/src/components/icons/icon-excalidraw.tsx
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import { rem } from "@mantine/core";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
size?: number | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function IconExcalidraw({ size }: Props) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="#6965DB"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
style={{ width: rem(size), height: rem(size) }}
|
||||||
|
>
|
||||||
|
<path d="M23.943 19.806a.196.196 0 00-.168-.034c-1.26-1.855-2.873-3.61-4.419-5.315l-.252-.284c-.001-.073-.067-.12-.134-.15l-.084-.084c-.05-.1-.169-.167-.286-.1-.47.234-.907.585-1.327.919-.554.434-1.109.87-1.63 1.354a5.058 5.058 0 00-.588.618c-.084.117-.017.217.084.267-.37.368-.74.736-1.109 1.12a.19.19 0 00-.05.134c0 .05.033.1.067.117l.655.502v.016c.924.92 2.554 2.173 4.285 3.527.251.201.52.402.773.602.117.134.234.285.335.418.05.066.169.084.236.033.033.034.084.067.118.1a.24.24 0 00.1.034.153.153 0 00.135-.066.237.237 0 00.033-.1c.017 0 .017.016.034.016a.192.192 0 00.134-.05l3.058-3.327c.12-.116.014-.267 0-.267zm-7.628-.134l-1.546-1.17-.15-.1c-.035-.017-.068-.05-.102-.067l-.117-.1c.66-.66 1.33-1.308 2-1.956-.488.484-1.463 1.906-1.261 2.373.002 0 .018.042.067.084l1.11.936zm4.1 3.126l-1.277-.97a26.906 26.906 0 00-1.58-1.504c.69.518 1.277.97 1.361 1.053.673.585.638.485 1.093.87l.554.4c-.074.103-.151.148-.151.151zm.336.25l-.034-.016a.913.913 0 00.152-.117zM.587 3.476c.034.217.085.435.118.636.201 1.103.403 2.106.772 2.858l.152.568c.05.217.134.485.219.552a66.769 66.769 0 003.578 2.942.177.177 0 00.219 0s0 .016.016.016a.153.153 0 00.118.05.191.191 0 00.134-.05c1.798-1.989 3.142-3.627 4.1-4.998.068-.066.084-.167.084-.25.067-.067.118-.151.185-.201.067-.067.067-.184 0-.235l-.017-.016c0-.033-.017-.084-.05-.1-.42-.401-.722-.685-1.042-.986a93.555 93.555 0 01-2.352-2.273c-.017-.017-.034-.034-.067-.034-.336-.117-1.025-.234-1.882-.385-1.277-.216-3.008-.517-4.57-.986 0 0-.101 0-.118.017l-.05.05C.05.714.022.707 0 .718c.017.1.017.167.05.284 0 .033.068.301.068.334zm7.191 4.78l-.033.034a.036.036 0 01.033-.034zM6.553 2.238c.101.1.521.502.622.585-.437-.2-1.529-.702-2.034-.869.505.1 1.194.201 1.412.284zM.79 1.403c.252.434.454 1.939.655 3.41-.118-.469-.201-.936-.302-1.372C.992 2.673.84 1.988.638 1.386c.124 0 .152.021.152.017zm-.286-.369c0-.016 0-.033-.017-.033.085 0 .135.017.202.05 0 .006-.145-.017-.185-.017zm23.17-.217c.017-.066-.336-.367-.219-.384.253-.017.253-.401 0-.401-.335.017-.688.1-1.008.15-.587.117-1.192.234-1.78.367a79.696 79.696 0 00-3.949.937c-.403.117-.857.2-1.243.401-.135.067-.118.2-.05.284-.034.017-.051.017-.085.034-.117.017-.218.034-.335.05-.102.017-.152.1-.135.2 0 .017.017.05.017.067-.706.936-1.496 1.923-2.353 2.976-.84.969-1.73 1.989-2.62 3.042-2.84 3.31-6.05 7.07-9.594 10.38a.161.161 0 000 .234c.016.016.033.033.05.033-.05.05-.101.085-.152.134-.033.034-.05.067-.05.1a.364.364 0 00-.067.084c-.067.067-.067.184.017.234.067.066.185.066.235-.017.017-.017.017-.033.033-.033a.265.265 0 01.37 0c.202.217.404.435.588.618l-.42-.35c-.067-.067-.184-.05-.234.016-.068.066-.051.184.016.234l4.469 3.727c.034.034.067.034.118.034a.15.15 0 00.117-.05l.101-.1c.017.016.05.016.067.016.05 0 .084-.016.118-.05 6.049-6.05 10.922-10.614 16.5-14.693.05-.033.067-.1.067-.15.067 0 .118-.05.15-.117 1.026-3.125 1.228-5.9 1.295-7.27 0-.059.016-.038.016-.068.017-.033.017-.05.017-.05a.978.978 0 00-.067-.619zm-10.82 4.915c.268-.301.537-.619.806-.903-1.73 2.273-4.603 5.766-8.67 9.929 2.773-3.059 5.562-6.218 7.864-9.026zM5.14 23.466c-.016-.017-.016-.017 0-.017zm2.504-2.156c.135-.15.27-.284.42-.434 0 0 0 .016.017.016-.224.198-.433.418-.437.418zm.69-.668c.099-.1.14-.173.284-.318.992-1.02 2.017-2.04 3.059-3.076l.016-.016c.252-.2.555-.418.824-.619a228.063 228.063 0 00-4.184 4.029zM14.852 3.91c-.554.719-1.176 1.671-1.697 2.423-1.646 2.374-6.94 8.174-7.057 8.274a1189.647 1189.647 0 01-4.839 4.597l-.1.1c-.085-.1-.085-.25.016-.334C8.652 11.966 13.19 6.133 15.021 3.576c-.05.116-.084.216-.168.334zm2.906 3.427c-.671-.386-.99-.987-.806-1.572l.05-.2a.775.775 0 01.085-.167 1.9 1.9 0 01.756-.703c.016 0 .033 0 .05-.016-.017-.034-.017-.084-.017-.134.017-.1.085-.167.202-.167.202 0 .824.184 1.059.384.067.05.134.117.202.184.084.1.218.268.285.401.034.017.067.184.118.268.033.134.067.284.05.418-.017.016 0 .116-.017.116a1.605 1.605 0 01-.218.619c-.03.03.006.012-.05.067a1.22 1.22 0 01-.32.334 1.49 1.49 0 01-1.26.234 2.191 2.191 0 00-.169-.066zm4.37 1.403c0 .017-.017.05 0 .067-.034 0-.05.017-.085.034a109.886 109.886 0 00-3.915 3.025c1.11-.986 2.218-1.989 3.378-2.975.336-.301.571-.686.638-1.12l.168-1.003v-.033c.085-.201.404-.118.353.1-.004-.001-.173.795-.537 1.905z"></path>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default IconExcalidraw;
|
||||||
20
apps/client/src/components/icons/icon-mermaid.tsx
Normal file
20
apps/client/src/components/icons/icon-mermaid.tsx
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import { rem } from "@mantine/core";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
size?: number | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function IconMermaid({ size }: Props) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="#FF3670"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
style={{ width: rem(size), height: rem(size) }}
|
||||||
|
>
|
||||||
|
<path d="M23.99 2.115A12.223 12.223 0 0012 10.149 12.223 12.223 0 00.01 2.115a12.23 12.23 0 005.32 10.604 6.562 6.562 0 012.845 5.423v3.754h7.65v-3.754a6.561 6.561 0 012.844-5.423 12.223 12.223 0 005.32-10.604z"></path>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default IconMermaid;
|
||||||
@ -57,6 +57,8 @@ export function AppHeader() {
|
|||||||
size="lg"
|
size="lg"
|
||||||
fw={600}
|
fw={600}
|
||||||
style={{ cursor: "pointer", userSelect: "none" }}
|
style={{ cursor: "pointer", userSelect: "none" }}
|
||||||
|
component={Link}
|
||||||
|
to="/home"
|
||||||
>
|
>
|
||||||
Docmost
|
Docmost
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@ -93,6 +93,18 @@ export default function SettingsSidebar() {
|
|||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<ScrollArea w="100%">{menuItems}</ScrollArea>
|
<ScrollArea w="100%">{menuItems}</ScrollArea>
|
||||||
|
<div className={classes.version}>
|
||||||
|
<Text
|
||||||
|
className={classes.version}
|
||||||
|
size="sm"
|
||||||
|
c="dimmed"
|
||||||
|
component="a"
|
||||||
|
href="https://github.com/docmost/docmost/releases"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
v{APP_VERSION}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -57,3 +57,8 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.version {
|
||||||
|
padding-left: var(--mantine-spacing-xs) ;
|
||||||
|
padding-top: 10px;
|
||||||
|
}
|
||||||
|
|||||||
@ -1,13 +1,14 @@
|
|||||||
import React, { ReactNode } from "react";
|
import React, { ReactNode } from 'react';
|
||||||
import data from "@emoji-mart/data";
|
|
||||||
import Picker from "@emoji-mart/react";
|
|
||||||
import {
|
import {
|
||||||
ActionIcon,
|
ActionIcon,
|
||||||
Popover,
|
Popover,
|
||||||
Button,
|
Button,
|
||||||
useMantineColorScheme,
|
useMantineColorScheme,
|
||||||
} from "@mantine/core";
|
} from '@mantine/core';
|
||||||
import { useDisclosure } from "@mantine/hooks";
|
import { useDisclosure } from '@mantine/hooks';
|
||||||
|
import { Suspense } from 'react';
|
||||||
|
|
||||||
|
const Picker = React.lazy(() => import('@emoji-mart/react'));
|
||||||
|
|
||||||
export interface EmojiPickerInterface {
|
export interface EmojiPickerInterface {
|
||||||
onEmojiSelect: (emoji: any) => void;
|
onEmojiSelect: (emoji: any) => void;
|
||||||
@ -48,23 +49,25 @@ function EmojiPicker({
|
|||||||
{icon}
|
{icon}
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Popover.Target>
|
</Popover.Target>
|
||||||
<Popover.Dropdown bg="000" style={{ border: "none" }}>
|
<Popover.Dropdown bg="000" style={{ border: 'none' }}>
|
||||||
|
<Suspense fallback={null}>
|
||||||
<Picker
|
<Picker
|
||||||
data={data}
|
data={async () => (await import('@emoji-mart/data')).default}
|
||||||
onEmojiSelect={handleEmojiSelect}
|
onEmojiSelect={handleEmojiSelect}
|
||||||
perLine={8}
|
perLine={8}
|
||||||
skinTonePosition="search"
|
skinTonePosition="search"
|
||||||
theme={colorScheme}
|
theme={colorScheme}
|
||||||
/>
|
/>
|
||||||
|
</Suspense>
|
||||||
<Button
|
<Button
|
||||||
variant="default"
|
variant="default"
|
||||||
c="gray"
|
c="gray"
|
||||||
size="xs"
|
size="xs"
|
||||||
style={{
|
style={{
|
||||||
position: "absolute",
|
position: 'absolute',
|
||||||
zIndex: 2,
|
zIndex: 2,
|
||||||
bottom: "1rem",
|
bottom: '1rem',
|
||||||
right: "1rem",
|
right: '1rem',
|
||||||
}}
|
}}
|
||||||
onClick={handleRemoveEmoji}
|
onClick={handleRemoveEmoji}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -23,7 +23,7 @@ const RoleButton = forwardRef<HTMLButtonElement, RoleButtonProps>(
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
interface SpaceRoleMenuProps {
|
interface RoleMenuProps {
|
||||||
roles: IRoleData[];
|
roles: IRoleData[];
|
||||||
roleName: string;
|
roleName: string;
|
||||||
onChange?: (value: string) => void;
|
onChange?: (value: string) => void;
|
||||||
@ -35,7 +35,7 @@ export default function RoleSelectMenu({
|
|||||||
roleName,
|
roleName,
|
||||||
onChange,
|
onChange,
|
||||||
disabled,
|
disabled,
|
||||||
}: SpaceRoleMenuProps) {
|
}: RoleMenuProps) {
|
||||||
return (
|
return (
|
||||||
<Menu withArrow>
|
<Menu withArrow>
|
||||||
<Menu.Target>
|
<Menu.Target>
|
||||||
|
|||||||
@ -1,14 +1,17 @@
|
|||||||
import Cookies from "js-cookie";
|
import Cookies from "js-cookie";
|
||||||
import { createJSONStorage, atomWithStorage } from "jotai/utils";
|
import { createJSONStorage, atomWithStorage } from "jotai/utils";
|
||||||
import { ITokens } from '../types/auth.types';
|
import { ITokens } from "../types/auth.types";
|
||||||
|
|
||||||
|
|
||||||
const cookieStorage = createJSONStorage<ITokens>(() => {
|
const cookieStorage = createJSONStorage<ITokens>(() => {
|
||||||
return {
|
return {
|
||||||
getItem: () => Cookies.get("authTokens"),
|
getItem: () => Cookies.get("authTokens"),
|
||||||
setItem: (key, value) => Cookies.set(key, value),
|
setItem: (key, value) => Cookies.set(key, value, { expires: 30 }),
|
||||||
removeItem: (key) => Cookies.remove(key),
|
removeItem: (key) => Cookies.remove(key),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
export const authTokensAtom = atomWithStorage<ITokens | null>("authTokens", null, cookieStorage);
|
export const authTokensAtom = atomWithStorage<ITokens | null>(
|
||||||
|
"authTokens",
|
||||||
|
null,
|
||||||
|
cookieStorage,
|
||||||
|
);
|
||||||
|
|||||||
@ -4,11 +4,7 @@ import { useNavigate } from "react-router-dom";
|
|||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { authTokensAtom } from "@/features/auth/atoms/auth-tokens-atom";
|
import { authTokensAtom } from "@/features/auth/atoms/auth-tokens-atom";
|
||||||
import { currentUserAtom } from "@/features/user/atoms/current-user-atom";
|
import { currentUserAtom } from "@/features/user/atoms/current-user-atom";
|
||||||
import {
|
import { ILogin, ISetupWorkspace } from "@/features/auth/types/auth.types";
|
||||||
ILogin,
|
|
||||||
IRegister,
|
|
||||||
ISetupWorkspace,
|
|
||||||
} from "@/features/auth/types/auth.types";
|
|
||||||
import { notifications } from "@mantine/notifications";
|
import { notifications } from "@mantine/notifications";
|
||||||
import { IAcceptInvite } from "@/features/workspace/types/workspace.types.ts";
|
import { IAcceptInvite } from "@/features/workspace/types/workspace.types.ts";
|
||||||
import { acceptInvitation } from "@/features/workspace/services/workspace-service.ts";
|
import { acceptInvitation } from "@/features/workspace/services/workspace-service.ts";
|
||||||
@ -49,7 +45,6 @@ export default function useAuth() {
|
|||||||
const res = await acceptInvitation(data);
|
const res = await acceptInvitation(data);
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
|
|
||||||
console.log(res);
|
|
||||||
setAuthToken(res.tokens);
|
setAuthToken(res.tokens);
|
||||||
|
|
||||||
navigate(APP_ROUTE.HOME);
|
navigate(APP_ROUTE.HOME);
|
||||||
|
|||||||
@ -0,0 +1,48 @@
|
|||||||
|
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
||||||
|
import { Group, Text, Paper, ActionIcon } from "@mantine/core";
|
||||||
|
import { getFileUrl } from "@/lib/config.ts";
|
||||||
|
import { IconDownload, IconPaperclip } from "@tabler/icons-react";
|
||||||
|
import { useHover } from "@mantine/hooks";
|
||||||
|
import { formatBytes } from "@/lib";
|
||||||
|
|
||||||
|
export default function AttachmentView(props: NodeViewProps) {
|
||||||
|
const { node, selected } = props;
|
||||||
|
const { url, name, size } = node.attrs;
|
||||||
|
const { hovered, ref } = useHover();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NodeViewWrapper>
|
||||||
|
<Paper withBorder p="4px" ref={ref} data-drag-handle>
|
||||||
|
<Group
|
||||||
|
justify="space-between"
|
||||||
|
gap="xl"
|
||||||
|
style={{ cursor: "pointer" }}
|
||||||
|
wrap="nowrap"
|
||||||
|
h={25}
|
||||||
|
>
|
||||||
|
<Group justify="space-between" wrap="nowrap">
|
||||||
|
<IconPaperclip size={20} />
|
||||||
|
|
||||||
|
<Text component="span" size="md" truncate="end">
|
||||||
|
{name}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text component="span" size="sm" c="dimmed" inline>
|
||||||
|
{formatBytes(size)}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{selected || hovered ? (
|
||||||
|
<a href={getFileUrl(url)} target="_blank">
|
||||||
|
<ActionIcon variant="default" aria-label="download file">
|
||||||
|
<IconDownload size={18} />
|
||||||
|
</ActionIcon>
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
""
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
</Paper>
|
||||||
|
</NodeViewWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,31 @@
|
|||||||
|
import { handleAttachmentUpload } from "@docmost/editor-ext";
|
||||||
|
import { uploadFile } from "@/features/page/services/page-service.ts";
|
||||||
|
import { notifications } from "@mantine/notifications";
|
||||||
|
|
||||||
|
export const uploadAttachmentAction = handleAttachmentUpload({
|
||||||
|
onUpload: async (file: File, pageId: string): Promise<any> => {
|
||||||
|
try {
|
||||||
|
return await uploadFile(file, pageId);
|
||||||
|
} catch (err) {
|
||||||
|
notifications.show({
|
||||||
|
color: "red",
|
||||||
|
message: err?.response.data.message,
|
||||||
|
});
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
validateFn: (file) => {
|
||||||
|
if (file.type.includes("image/") || file.type.includes("video/")) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (file.size / 1024 / 1024 > 50) {
|
||||||
|
notifications.show({
|
||||||
|
color: "red",
|
||||||
|
message: `File exceeds the 50 MB attachment limit`,
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
});
|
||||||
@ -1,9 +1,4 @@
|
|||||||
import {
|
import { NodeViewContent, NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
||||||
Editor,
|
|
||||||
NodeViewContent,
|
|
||||||
NodeViewProps,
|
|
||||||
NodeViewWrapper,
|
|
||||||
} from "@tiptap/react";
|
|
||||||
import {
|
import {
|
||||||
IconAlertTriangleFilled,
|
IconAlertTriangleFilled,
|
||||||
IconCircleCheckFilled,
|
IconCircleCheckFilled,
|
||||||
|
|||||||
@ -0,0 +1,98 @@
|
|||||||
|
import { NodeViewContent, NodeViewProps, NodeViewWrapper } from '@tiptap/react';
|
||||||
|
import { ActionIcon, CopyButton, Group, Select, Tooltip } from '@mantine/core';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { IconCheck, IconCopy } from '@tabler/icons-react';
|
||||||
|
//import MermaidView from "@/features/editor/components/code-block/mermaid-view.tsx";
|
||||||
|
import classes from './code-block.module.css';
|
||||||
|
import React from 'react';
|
||||||
|
import { Suspense } from 'react';
|
||||||
|
|
||||||
|
const MermaidView = React.lazy(
|
||||||
|
() => import('@/features/editor/components/code-block/mermaid-view.tsx')
|
||||||
|
);
|
||||||
|
|
||||||
|
export default function CodeBlockView(props: NodeViewProps) {
|
||||||
|
const { node, updateAttributes, extension, editor, getPos } = props;
|
||||||
|
const { language } = node.attrs;
|
||||||
|
const [languageValue, setLanguageValue] = useState<string | null>(
|
||||||
|
language || null
|
||||||
|
);
|
||||||
|
const [isSelected, setIsSelected] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const updateSelection = () => {
|
||||||
|
const { state } = editor;
|
||||||
|
const { from, to } = state.selection;
|
||||||
|
// Check if the selection intersects with the node's range
|
||||||
|
const isNodeSelected =
|
||||||
|
(from >= getPos() && from < getPos() + node.nodeSize) ||
|
||||||
|
(to > getPos() && to <= getPos() + node.nodeSize);
|
||||||
|
setIsSelected(isNodeSelected);
|
||||||
|
};
|
||||||
|
|
||||||
|
editor.on('selectionUpdate', updateSelection);
|
||||||
|
return () => {
|
||||||
|
editor.off('selectionUpdate', updateSelection);
|
||||||
|
};
|
||||||
|
}, [editor, getPos(), node.nodeSize]);
|
||||||
|
|
||||||
|
function changeLanguage(language: string) {
|
||||||
|
setLanguageValue(language);
|
||||||
|
updateAttributes({
|
||||||
|
language: language,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NodeViewWrapper className="codeBlock">
|
||||||
|
<Group justify="flex-end" contentEditable={false}>
|
||||||
|
<Select
|
||||||
|
placeholder="auto"
|
||||||
|
checkIconPosition="right"
|
||||||
|
data={extension.options.lowlight.listLanguages()}
|
||||||
|
value={languageValue}
|
||||||
|
onChange={changeLanguage}
|
||||||
|
searchable
|
||||||
|
style={{ maxWidth: '130px' }}
|
||||||
|
classNames={{ input: classes.selectInput }}
|
||||||
|
disabled={!editor.isEditable}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<CopyButton value={node?.textContent} timeout={2000}>
|
||||||
|
{({ copied, copy }) => (
|
||||||
|
<Tooltip
|
||||||
|
label={copied ? 'Copied' : 'Copy'}
|
||||||
|
withArrow
|
||||||
|
position="right"
|
||||||
|
>
|
||||||
|
<ActionIcon
|
||||||
|
color={copied ? 'teal' : 'gray'}
|
||||||
|
variant="subtle"
|
||||||
|
onClick={copy}
|
||||||
|
>
|
||||||
|
{copied ? <IconCheck size={16} /> : <IconCopy size={16} />}
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</CopyButton>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<pre
|
||||||
|
spellCheck="false"
|
||||||
|
hidden={
|
||||||
|
((language === 'mermaid' && !editor.isEditable) ||
|
||||||
|
(language === 'mermaid' && !isSelected)) &&
|
||||||
|
node.textContent.length > 0
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<NodeViewContent as="code" className={`language-${language}`} />
|
||||||
|
</pre>
|
||||||
|
|
||||||
|
{language === 'mermaid' && (
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<MermaidView props={props} />
|
||||||
|
</Suspense>
|
||||||
|
)}
|
||||||
|
</NodeViewWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,18 @@
|
|||||||
|
.selectInput {
|
||||||
|
height: 25px;
|
||||||
|
min-height: 25px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
color: light-dark(var(--mantine-color-red-8), var(--mantine-color-red-7));
|
||||||
|
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-gray-8));
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mermaid {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
@ -0,0 +1,50 @@
|
|||||||
|
import { NodeViewProps } from "@tiptap/react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import mermaid from "mermaid";
|
||||||
|
import { v4 as uuidv4 } from "uuid";
|
||||||
|
import classes from "./code-block.module.css";
|
||||||
|
|
||||||
|
mermaid.initialize({
|
||||||
|
startOnLoad: false,
|
||||||
|
suppressErrorRendering: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
interface MermaidViewProps {
|
||||||
|
props: NodeViewProps;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MermaidView({ props }: MermaidViewProps) {
|
||||||
|
const { node } = props;
|
||||||
|
const [preview, setPreview] = useState<string>("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const id = `mermaid-${uuidv4()}`;
|
||||||
|
|
||||||
|
if (node.textContent.length > 0) {
|
||||||
|
mermaid
|
||||||
|
.render(id, node.textContent)
|
||||||
|
.then((item) => {
|
||||||
|
setPreview(item.svg);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
if (props.editor.isEditable) {
|
||||||
|
setPreview(
|
||||||
|
`<div class="${classes.error}">Mermaid diagram error: ${err}</div>`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
setPreview(
|
||||||
|
`<div class="${classes.error}">Invalid Mermaid Diagram</div>`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [node.textContent]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={classes.mermaid}
|
||||||
|
contentEditable={false}
|
||||||
|
dangerouslySetInnerHTML={{ __html: preview }}
|
||||||
|
></div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,6 +1,7 @@
|
|||||||
import type { EditorView } from "@tiptap/pm/view";
|
import type { EditorView } from "@tiptap/pm/view";
|
||||||
import { uploadImageAction } from "@/features/editor/components/image/upload-image-action.tsx";
|
import { uploadImageAction } from "@/features/editor/components/image/upload-image-action.tsx";
|
||||||
import { uploadVideoAction } from "@/features/editor/components/video/upload-video-action.tsx";
|
import { uploadVideoAction } from "@/features/editor/components/video/upload-video-action.tsx";
|
||||||
|
import { uploadAttachmentAction } from "../attachment/upload-attachment-action";
|
||||||
|
|
||||||
export const handleFilePaste = (
|
export const handleFilePaste = (
|
||||||
view: EditorView,
|
view: EditorView,
|
||||||
@ -15,6 +16,7 @@ export const handleFilePaste = (
|
|||||||
if (file) {
|
if (file) {
|
||||||
uploadImageAction(file, view, pos, pageId);
|
uploadImageAction(file, view, pos, pageId);
|
||||||
uploadVideoAction(file, view, pos, pageId);
|
uploadVideoAction(file, view, pos, pageId);
|
||||||
|
uploadAttachmentAction(file, view, pos, pageId);
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@ -38,6 +40,7 @@ export const handleFileDrop = (
|
|||||||
if (file) {
|
if (file) {
|
||||||
uploadImageAction(file, view, coordinates?.pos ?? 0 - 1, pageId);
|
uploadImageAction(file, view, coordinates?.pos ?? 0 - 1, pageId);
|
||||||
uploadVideoAction(file, view, coordinates?.pos ?? 0 - 1, pageId);
|
uploadVideoAction(file, view, coordinates?.pos ?? 0 - 1, pageId);
|
||||||
|
uploadAttachmentAction(file, view, coordinates?.pos ?? 0 - 1, pageId);
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,28 +1,34 @@
|
|||||||
import React, { memo, useCallback, useState } from "react";
|
import { memo, useCallback, useEffect, useState } from 'react';
|
||||||
import { Slider } from "@mantine/core";
|
import { Slider } from '@mantine/core';
|
||||||
|
|
||||||
export type ImageWidthProps = {
|
export type ImageWidthProps = {
|
||||||
onChange: (value: number) => void;
|
onChange: (value: number) => void;
|
||||||
value: number;
|
value: number;
|
||||||
|
width?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const NodeWidthResize = memo(({ onChange, value }: ImageWidthProps) => {
|
export const NodeWidthResize = memo(({ onChange, value, width }: ImageWidthProps) => {
|
||||||
const [currentValue, setCurrentValue] = useState(value);
|
const [currentValue, setCurrentValue] = useState(value);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCurrentValue(value);
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
const handleChange = useCallback(
|
const handleChange = useCallback(
|
||||||
(newValue: number) => {
|
(newValue: number) => {
|
||||||
onChange(newValue);
|
onChange(newValue);
|
||||||
},
|
},
|
||||||
[onChange],
|
[onChange]
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Slider
|
<Slider
|
||||||
p={"sm"}
|
p={'sm'}
|
||||||
min={10}
|
min={10}
|
||||||
value={currentValue}
|
value={currentValue}
|
||||||
onChange={setCurrentValue}
|
onChange={setCurrentValue}
|
||||||
onChangeEnd={handleChange}
|
onChangeEnd={handleChange}
|
||||||
|
w={width || 100}
|
||||||
label={(value) => `${value}%`}
|
label={(value) => `${value}%`}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -0,0 +1,82 @@
|
|||||||
|
import {
|
||||||
|
BubbleMenu as BaseBubbleMenu,
|
||||||
|
findParentNode,
|
||||||
|
posToDOMRect,
|
||||||
|
} from '@tiptap/react';
|
||||||
|
import { useCallback } from 'react';
|
||||||
|
import { sticky } from 'tippy.js';
|
||||||
|
import { Node as PMNode } from 'prosemirror-model';
|
||||||
|
import {
|
||||||
|
EditorMenuProps,
|
||||||
|
ShouldShowProps,
|
||||||
|
} from '@/features/editor/components/table/types/types.ts';
|
||||||
|
import { NodeWidthResize } from '@/features/editor/components/common/node-width-resize.tsx';
|
||||||
|
|
||||||
|
export function DrawioMenu({ editor }: EditorMenuProps) {
|
||||||
|
const shouldShow = useCallback(
|
||||||
|
({ state }: ShouldShowProps) => {
|
||||||
|
if (!state) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return editor.isActive('drawio') && editor.getAttributes('drawio')?.src;
|
||||||
|
},
|
||||||
|
[editor]
|
||||||
|
);
|
||||||
|
|
||||||
|
const getReferenceClientRect = useCallback(() => {
|
||||||
|
const { selection } = editor.state;
|
||||||
|
const predicate = (node: PMNode) => node.type.name === 'drawio';
|
||||||
|
const parent = findParentNode(predicate)(selection);
|
||||||
|
|
||||||
|
if (parent) {
|
||||||
|
const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement;
|
||||||
|
return dom.getBoundingClientRect();
|
||||||
|
}
|
||||||
|
|
||||||
|
return posToDOMRect(editor.view, selection.from, selection.to);
|
||||||
|
}, [editor]);
|
||||||
|
|
||||||
|
const onWidthChange = useCallback(
|
||||||
|
(value: number) => {
|
||||||
|
editor.commands.updateAttributes('drawio', { width: `${value}%` });
|
||||||
|
},
|
||||||
|
[editor]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BaseBubbleMenu
|
||||||
|
editor={editor}
|
||||||
|
pluginKey={`drawio-menu}`}
|
||||||
|
updateDelay={0}
|
||||||
|
tippyOptions={{
|
||||||
|
getReferenceClientRect,
|
||||||
|
offset: [0, 8],
|
||||||
|
zIndex: 99,
|
||||||
|
popperOptions: {
|
||||||
|
modifiers: [{ name: 'flip', enabled: false }],
|
||||||
|
},
|
||||||
|
plugins: [sticky],
|
||||||
|
sticky: 'popper',
|
||||||
|
}}
|
||||||
|
shouldShow={shouldShow}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{editor.getAttributes('drawio')?.width && (
|
||||||
|
<NodeWidthResize
|
||||||
|
onChange={onWidthChange}
|
||||||
|
value={parseInt(editor.getAttributes('drawio').width)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</BaseBubbleMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DrawioMenu;
|
||||||
@ -0,0 +1,173 @@
|
|||||||
|
import { NodeViewProps, NodeViewWrapper } from '@tiptap/react';
|
||||||
|
import { ActionIcon, Card, Image, Modal, Text } from '@mantine/core';
|
||||||
|
import { useRef, useState } from 'react';
|
||||||
|
import { uploadFile } from '@/features/page/services/page-service.ts';
|
||||||
|
import { useDisclosure } from '@mantine/hooks';
|
||||||
|
import { getFileUrl } from '@/lib/config.ts';
|
||||||
|
import {
|
||||||
|
DrawIoEmbed,
|
||||||
|
DrawIoEmbedRef,
|
||||||
|
EventExit,
|
||||||
|
EventSave,
|
||||||
|
} from 'react-drawio';
|
||||||
|
import { IAttachment } from '@/lib/types';
|
||||||
|
import { decodeBase64ToSvgString, svgStringToFile } from '@/lib/utils';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import { IconEdit } from '@tabler/icons-react';
|
||||||
|
|
||||||
|
export default function DrawioView(props: NodeViewProps) {
|
||||||
|
const { node, updateAttributes, editor, selected } = props;
|
||||||
|
const { src, title, width, attachmentId } = node.attrs;
|
||||||
|
const drawioRef = useRef<DrawIoEmbedRef>(null);
|
||||||
|
const [initialXML, setInitialXML] = useState<string>('');
|
||||||
|
const [opened, { open, close }] = useDisclosure(false);
|
||||||
|
|
||||||
|
const handleOpen = async () => {
|
||||||
|
if (!editor.isEditable) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (src) {
|
||||||
|
const url = getFileUrl(src);
|
||||||
|
const request = await fetch(url, {
|
||||||
|
credentials: 'include',
|
||||||
|
cache: 'no-store',
|
||||||
|
});
|
||||||
|
const blob = await request.blob();
|
||||||
|
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.readAsDataURL(blob);
|
||||||
|
reader.onloadend = () => {
|
||||||
|
let base64data = (reader.result || '') as string;
|
||||||
|
setInitialXML(base64data);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
open();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async (data: EventSave) => {
|
||||||
|
const svgString = decodeBase64ToSvgString(data.xml);
|
||||||
|
|
||||||
|
const fileName = 'diagram.drawio.svg';
|
||||||
|
const drawioSVGFile = await svgStringToFile(svgString, fileName);
|
||||||
|
|
||||||
|
const pageId = editor.storage?.pageId;
|
||||||
|
|
||||||
|
let attachment: IAttachment = null;
|
||||||
|
|
||||||
|
if (attachmentId) {
|
||||||
|
attachment = await uploadFile(drawioSVGFile, pageId, attachmentId);
|
||||||
|
} else {
|
||||||
|
attachment = await uploadFile(drawioSVGFile, pageId);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateAttributes({
|
||||||
|
src: `/files/${attachment.id}/${attachment.fileName}?t=${new Date(attachment.updatedAt).getTime()}`,
|
||||||
|
title: attachment.fileName,
|
||||||
|
size: attachment.fileSize,
|
||||||
|
attachmentId: attachment.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
close();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NodeViewWrapper>
|
||||||
|
<Modal.Root opened={opened} onClose={close} fullScreen>
|
||||||
|
<Modal.Overlay />
|
||||||
|
<Modal.Content style={{ overflow: 'hidden' }}>
|
||||||
|
<Modal.Body>
|
||||||
|
<div style={{ height: '100vh' }}>
|
||||||
|
<DrawIoEmbed
|
||||||
|
ref={drawioRef}
|
||||||
|
xml={initialXML}
|
||||||
|
urlParameters={{
|
||||||
|
ui: 'kennedy',
|
||||||
|
spin: true,
|
||||||
|
libraries: true,
|
||||||
|
saveAndExit: true,
|
||||||
|
noSaveBtn: true,
|
||||||
|
}}
|
||||||
|
onSave={(data: EventSave) => {
|
||||||
|
// If the save is triggered by another event, then do nothing
|
||||||
|
if (data.parentEvent !== 'save') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
handleSave(data);
|
||||||
|
}}
|
||||||
|
onClose={(data: EventExit) => {
|
||||||
|
// If the exit is triggered by another event, then do nothing
|
||||||
|
if (data.parentEvent) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
close();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Modal.Body>
|
||||||
|
</Modal.Content>
|
||||||
|
</Modal.Root>
|
||||||
|
|
||||||
|
{src ? (
|
||||||
|
<div style={{ position: 'relative' }}>
|
||||||
|
<Image
|
||||||
|
onClick={(e) => e.detail === 2 && handleOpen()}
|
||||||
|
radius="md"
|
||||||
|
fit="contain"
|
||||||
|
w={width}
|
||||||
|
src={getFileUrl(src)}
|
||||||
|
alt={title}
|
||||||
|
className={clsx(
|
||||||
|
selected ? 'ProseMirror-selectednode' : '',
|
||||||
|
'alignCenter'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{selected && (
|
||||||
|
<ActionIcon
|
||||||
|
onClick={handleOpen}
|
||||||
|
variant="default"
|
||||||
|
color="gray"
|
||||||
|
mx="xs"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 8,
|
||||||
|
right: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<IconEdit size={18} />
|
||||||
|
</ActionIcon>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Card
|
||||||
|
radius="md"
|
||||||
|
onClick={(e) => e.detail === 2 && handleOpen()}
|
||||||
|
p="xs"
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
}}
|
||||||
|
withBorder
|
||||||
|
className={clsx(selected ? 'ProseMirror-selectednode' : '')}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||||||
|
<ActionIcon variant="transparent" color="gray">
|
||||||
|
<IconEdit size={18} />
|
||||||
|
</ActionIcon>
|
||||||
|
|
||||||
|
<Text component="span" size="lg" c="dimmed">
|
||||||
|
Double-click to edit drawio diagram
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</NodeViewWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,82 @@
|
|||||||
|
import {
|
||||||
|
BubbleMenu as BaseBubbleMenu,
|
||||||
|
findParentNode,
|
||||||
|
posToDOMRect,
|
||||||
|
} from '@tiptap/react';
|
||||||
|
import { useCallback } from 'react';
|
||||||
|
import { sticky } from 'tippy.js';
|
||||||
|
import { Node as PMNode } from 'prosemirror-model';
|
||||||
|
import {
|
||||||
|
EditorMenuProps,
|
||||||
|
ShouldShowProps,
|
||||||
|
} from '@/features/editor/components/table/types/types.ts';
|
||||||
|
import { NodeWidthResize } from '@/features/editor/components/common/node-width-resize.tsx';
|
||||||
|
|
||||||
|
export function ExcalidrawMenu({ editor }: EditorMenuProps) {
|
||||||
|
const shouldShow = useCallback(
|
||||||
|
({ state }: ShouldShowProps) => {
|
||||||
|
if (!state) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return editor.isActive('excalidraw') && editor.getAttributes('excalidraw')?.src;
|
||||||
|
},
|
||||||
|
[editor]
|
||||||
|
);
|
||||||
|
|
||||||
|
const getReferenceClientRect = useCallback(() => {
|
||||||
|
const { selection } = editor.state;
|
||||||
|
const predicate = (node: PMNode) => node.type.name === 'excalidraw';
|
||||||
|
const parent = findParentNode(predicate)(selection);
|
||||||
|
|
||||||
|
if (parent) {
|
||||||
|
const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement;
|
||||||
|
return dom.getBoundingClientRect();
|
||||||
|
}
|
||||||
|
|
||||||
|
return posToDOMRect(editor.view, selection.from, selection.to);
|
||||||
|
}, [editor]);
|
||||||
|
|
||||||
|
const onWidthChange = useCallback(
|
||||||
|
(value: number) => {
|
||||||
|
editor.commands.updateAttributes('excalidraw', { width: `${value}%` });
|
||||||
|
},
|
||||||
|
[editor]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BaseBubbleMenu
|
||||||
|
editor={editor}
|
||||||
|
pluginKey={`excalidraw-menu}`}
|
||||||
|
updateDelay={0}
|
||||||
|
tippyOptions={{
|
||||||
|
getReferenceClientRect,
|
||||||
|
offset: [0, 8],
|
||||||
|
zIndex: 99,
|
||||||
|
popperOptions: {
|
||||||
|
modifiers: [{ name: 'flip', enabled: false }],
|
||||||
|
},
|
||||||
|
plugins: [sticky],
|
||||||
|
sticky: 'popper',
|
||||||
|
}}
|
||||||
|
shouldShow={shouldShow}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{editor.getAttributes('excalidraw')?.width && (
|
||||||
|
<NodeWidthResize
|
||||||
|
onChange={onWidthChange}
|
||||||
|
value={parseInt(editor.getAttributes('excalidraw').width)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</BaseBubbleMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ExcalidrawMenu;
|
||||||
@ -0,0 +1,212 @@
|
|||||||
|
import { NodeViewProps, NodeViewWrapper } from '@tiptap/react';
|
||||||
|
import {
|
||||||
|
ActionIcon,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Group,
|
||||||
|
Image,
|
||||||
|
Text,
|
||||||
|
useComputedColorScheme,
|
||||||
|
} from '@mantine/core';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { uploadFile } from '@/features/page/services/page-service.ts';
|
||||||
|
import { svgStringToFile } from '@/lib';
|
||||||
|
import { useDisclosure } from '@mantine/hooks';
|
||||||
|
import { getFileUrl } from '@/lib/config.ts';
|
||||||
|
import { ExcalidrawImperativeAPI } from '@excalidraw/excalidraw/types/types';
|
||||||
|
import { IAttachment } from '@/lib/types';
|
||||||
|
import ReactClearModal from 'react-clear-modal';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import { IconEdit } from '@tabler/icons-react';
|
||||||
|
import { lazy } from 'react';
|
||||||
|
import { Suspense } from 'react';
|
||||||
|
|
||||||
|
const Excalidraw = lazy(() =>
|
||||||
|
import('@excalidraw/excalidraw').then((module) => ({
|
||||||
|
default: module.Excalidraw,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
export default function ExcalidrawView(props: NodeViewProps) {
|
||||||
|
const { node, updateAttributes, editor, selected } = props;
|
||||||
|
const { src, title, width, attachmentId } = node.attrs;
|
||||||
|
|
||||||
|
const [excalidrawAPI, setExcalidrawAPI] =
|
||||||
|
useState<ExcalidrawImperativeAPI>(null);
|
||||||
|
const [excalidrawData, setExcalidrawData] = useState<any>(null);
|
||||||
|
const [opened, { open, close }] = useDisclosure(false);
|
||||||
|
const computedColorScheme = useComputedColorScheme();
|
||||||
|
|
||||||
|
const handleOpen = async () => {
|
||||||
|
if (!editor.isEditable) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (src) {
|
||||||
|
const url = getFileUrl(src);
|
||||||
|
const request = await fetch(url, {
|
||||||
|
credentials: 'include',
|
||||||
|
cache: 'no-store',
|
||||||
|
});
|
||||||
|
|
||||||
|
const { loadFromBlob } = await import('@excalidraw/excalidraw');
|
||||||
|
|
||||||
|
const data = await loadFromBlob(await request.blob(), null, null);
|
||||||
|
setExcalidrawData(data);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
open();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!excalidrawAPI) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { exportToSvg } = await import('@excalidraw/excalidraw');
|
||||||
|
|
||||||
|
const svg = await exportToSvg({
|
||||||
|
elements: excalidrawAPI?.getSceneElements(),
|
||||||
|
appState: {
|
||||||
|
exportEmbedScene: true,
|
||||||
|
exportWithDarkMode: computedColorScheme == 'light' ? false : true,
|
||||||
|
},
|
||||||
|
files: excalidrawAPI?.getFiles(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const serializer = new XMLSerializer();
|
||||||
|
let svgString = serializer.serializeToString(svg);
|
||||||
|
|
||||||
|
svgString = svgString.replace(
|
||||||
|
/https:\/\/unpkg\.com\/@excalidraw\/excalidraw@undefined/g,
|
||||||
|
'https://unpkg.com/@excalidraw/excalidraw@latest'
|
||||||
|
);
|
||||||
|
|
||||||
|
const fileName = 'diagram.excalidraw.svg';
|
||||||
|
const excalidrawSvgFile = await svgStringToFile(svgString, fileName);
|
||||||
|
|
||||||
|
const pageId = editor.storage?.pageId;
|
||||||
|
|
||||||
|
let attachment: IAttachment = null;
|
||||||
|
if (attachmentId) {
|
||||||
|
attachment = await uploadFile(excalidrawSvgFile, pageId, attachmentId);
|
||||||
|
} else {
|
||||||
|
attachment = await uploadFile(excalidrawSvgFile, pageId);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateAttributes({
|
||||||
|
src: `/files/${attachment.id}/${attachment.fileName}?t=${new Date(attachment.updatedAt).getTime()}`,
|
||||||
|
title: attachment.fileName,
|
||||||
|
size: attachment.fileSize,
|
||||||
|
attachmentId: attachment.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
close();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NodeViewWrapper>
|
||||||
|
<ReactClearModal
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||||
|
padding: 0,
|
||||||
|
zIndex: 200,
|
||||||
|
}}
|
||||||
|
isOpen={opened}
|
||||||
|
onRequestClose={close}
|
||||||
|
disableCloseOnBgClick={true}
|
||||||
|
contentProps={{
|
||||||
|
style: {
|
||||||
|
padding: 0,
|
||||||
|
width: '90vw',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Group
|
||||||
|
justify="flex-end"
|
||||||
|
wrap="nowrap"
|
||||||
|
bg="var(--mantine-color-body)"
|
||||||
|
p="xs"
|
||||||
|
>
|
||||||
|
<Button onClick={handleSave} size={'compact-sm'}>
|
||||||
|
Save & Exit
|
||||||
|
</Button>
|
||||||
|
<Button onClick={close} color="red" size={'compact-sm'}>
|
||||||
|
Exit
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
<div style={{ height: '90vh' }}>
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<Excalidraw
|
||||||
|
excalidrawAPI={(api) => setExcalidrawAPI(api)}
|
||||||
|
initialData={{
|
||||||
|
...excalidrawData,
|
||||||
|
scrollToContent: true,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
</ReactClearModal>
|
||||||
|
|
||||||
|
{src ? (
|
||||||
|
<div style={{ position: 'relative' }}>
|
||||||
|
<Image
|
||||||
|
onClick={(e) => e.detail === 2 && handleOpen()}
|
||||||
|
radius="md"
|
||||||
|
fit="contain"
|
||||||
|
w={width}
|
||||||
|
src={getFileUrl(src)}
|
||||||
|
alt={title}
|
||||||
|
className={clsx(
|
||||||
|
selected ? 'ProseMirror-selectednode' : '',
|
||||||
|
'alignCenter'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{selected && (
|
||||||
|
<ActionIcon
|
||||||
|
onClick={handleOpen}
|
||||||
|
variant="default"
|
||||||
|
color="gray"
|
||||||
|
mx="xs"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 8,
|
||||||
|
right: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<IconEdit size={18} />
|
||||||
|
</ActionIcon>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Card
|
||||||
|
radius="md"
|
||||||
|
onClick={(e) => e.detail === 2 && handleOpen()}
|
||||||
|
p="xs"
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
}}
|
||||||
|
withBorder
|
||||||
|
className={clsx(selected ? 'ProseMirror-selectednode' : '')}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||||||
|
<ActionIcon variant="transparent" color="gray">
|
||||||
|
<IconEdit size={18} />
|
||||||
|
</ActionIcon>
|
||||||
|
|
||||||
|
<Text component="span" size="lg" c="dimmed">
|
||||||
|
Double-click to edit excalidraw diagram
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</NodeViewWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,12 +1,16 @@
|
|||||||
import { handleImageUpload } from "@docmost/editor-ext";
|
import { handleImageUpload } from "@docmost/editor-ext";
|
||||||
import { uploadFile } from "@/features/page/services/page-service.ts";
|
import { uploadFile } from "@/features/page/services/page-service.ts";
|
||||||
|
import { notifications } from "@mantine/notifications";
|
||||||
|
|
||||||
export const uploadImageAction = handleImageUpload({
|
export const uploadImageAction = handleImageUpload({
|
||||||
onUpload: async (file: File, pageId: string): Promise<any> => {
|
onUpload: async (file: File, pageId: string): Promise<any> => {
|
||||||
try {
|
try {
|
||||||
return await uploadFile(file, pageId);
|
return await uploadFile(file, pageId);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("failed to upload image", err);
|
notifications.show({
|
||||||
|
color: "red",
|
||||||
|
message: err?.response.data.message,
|
||||||
|
});
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -14,8 +18,11 @@ export const uploadImageAction = handleImageUpload({
|
|||||||
if (!file.type.includes("image/")) {
|
if (!file.type.includes("image/")) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (file.size / 1024 / 1024 > 20) {
|
if (file.size / 1024 / 1024 > 50) {
|
||||||
//error("File size too big (max 20MB).");
|
notifications.show({
|
||||||
|
color: "red",
|
||||||
|
message: `File exceeds the 50 MB attachment limit`,
|
||||||
|
});
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@ -12,9 +12,11 @@ import {
|
|||||||
IconMath,
|
IconMath,
|
||||||
IconMathFunction,
|
IconMathFunction,
|
||||||
IconMovie,
|
IconMovie,
|
||||||
|
IconPaperclip,
|
||||||
IconPhoto,
|
IconPhoto,
|
||||||
IconTable,
|
IconTable,
|
||||||
IconTypography,
|
IconTypography,
|
||||||
|
IconMenu4
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import {
|
import {
|
||||||
CommandProps,
|
CommandProps,
|
||||||
@ -22,6 +24,10 @@ import {
|
|||||||
} from "@/features/editor/components/slash-menu/types";
|
} from "@/features/editor/components/slash-menu/types";
|
||||||
import { uploadImageAction } from "@/features/editor/components/image/upload-image-action.tsx";
|
import { uploadImageAction } from "@/features/editor/components/image/upload-image-action.tsx";
|
||||||
import { uploadVideoAction } from "@/features/editor/components/video/upload-video-action.tsx";
|
import { uploadVideoAction } from "@/features/editor/components/video/upload-video-action.tsx";
|
||||||
|
import { uploadAttachmentAction } from "@/features/editor/components/attachment/upload-attachment-action.tsx";
|
||||||
|
import IconExcalidraw from "@/components/icons/icon-excalidraw";
|
||||||
|
import IconMermaid from "@/components/icons/icon-mermaid";
|
||||||
|
import IconDrawio from "@/components/icons/icon-drawio";
|
||||||
|
|
||||||
const CommandGroups: SlashMenuGroupedItemsType = {
|
const CommandGroups: SlashMenuGroupedItemsType = {
|
||||||
basic: [
|
basic: [
|
||||||
@ -118,15 +124,23 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Code",
|
title: "Code",
|
||||||
description: "Capture a code snippet.",
|
description: "Insert code snippet.",
|
||||||
searchTerms: ["codeblock"],
|
searchTerms: ["codeblock"],
|
||||||
icon: IconCode,
|
icon: IconCode,
|
||||||
command: ({ editor, range }: CommandProps) =>
|
command: ({ editor, range }: CommandProps) =>
|
||||||
editor.chain().focus().deleteRange(range).toggleCodeBlock().run(),
|
editor.chain().focus().deleteRange(range).toggleCodeBlock().run(),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "Divider",
|
||||||
|
description: "Insert horizontal rule divider",
|
||||||
|
searchTerms: ["horizontal rule", "hr"],
|
||||||
|
icon: IconMenu4,
|
||||||
|
command: ({ editor, range }: CommandProps) =>
|
||||||
|
editor.chain().focus().deleteRange(range).setHorizontalRule().run(),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: "Image",
|
title: "Image",
|
||||||
description: "Upload an image from your computer.",
|
description: "Upload any image from your device.",
|
||||||
searchTerms: ["photo", "picture", "media"],
|
searchTerms: ["photo", "picture", "media"],
|
||||||
icon: IconPhoto,
|
icon: IconPhoto,
|
||||||
command: ({ editor, range }) => {
|
command: ({ editor, range }) => {
|
||||||
@ -139,19 +153,21 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
|||||||
const input = document.createElement("input");
|
const input = document.createElement("input");
|
||||||
input.type = "file";
|
input.type = "file";
|
||||||
input.accept = "image/*";
|
input.accept = "image/*";
|
||||||
|
input.multiple = true;
|
||||||
input.onchange = async () => {
|
input.onchange = async () => {
|
||||||
if (input.files?.length) {
|
if (input.files?.length) {
|
||||||
const file = input.files[0];
|
for (const file of input.files) {
|
||||||
const pos = editor.view.state.selection.from;
|
const pos = editor.view.state.selection.from;
|
||||||
uploadImageAction(file, editor.view, pos, pageId);
|
uploadImageAction(file, editor.view, pos, pageId);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
input.click();
|
input.click();
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Video",
|
title: "Video",
|
||||||
description: "Upload an video from your computer.",
|
description: "Upload any video from your device.",
|
||||||
searchTerms: ["video", "mp4", "media"],
|
searchTerms: ["video", "mp4", "media"],
|
||||||
icon: IconMovie,
|
icon: IconMovie,
|
||||||
command: ({ editor, range }) => {
|
command: ({ editor, range }) => {
|
||||||
@ -174,6 +190,37 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
|||||||
input.click();
|
input.click();
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "File attachment",
|
||||||
|
description: "Upload any file from your device.",
|
||||||
|
searchTerms: ["file", "attachment", "upload", "pdf", "csv", "zip"],
|
||||||
|
icon: IconPaperclip,
|
||||||
|
command: ({ editor, range }) => {
|
||||||
|
editor.chain().focus().deleteRange(range).run();
|
||||||
|
|
||||||
|
const pageId = editor.storage?.pageId;
|
||||||
|
if (!pageId) return;
|
||||||
|
|
||||||
|
// upload file
|
||||||
|
const input = document.createElement("input");
|
||||||
|
input.type = "file";
|
||||||
|
input.accept = "";
|
||||||
|
input.onchange = async () => {
|
||||||
|
if (input.files?.length) {
|
||||||
|
const file = input.files[0];
|
||||||
|
const pos = editor.view.state.selection.from;
|
||||||
|
if (file.type.includes("image/*")) {
|
||||||
|
uploadImageAction(file, editor.view, pos, pageId);
|
||||||
|
} else if (file.type.includes("video/*")) {
|
||||||
|
uploadVideoAction(file, editor.view, pos, pageId);
|
||||||
|
} else {
|
||||||
|
uploadAttachmentAction(file, editor.view, pos, pageId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
input.click();
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: "Table",
|
title: "Table",
|
||||||
description: "Insert a table.",
|
description: "Insert a table.",
|
||||||
@ -184,7 +231,7 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
|||||||
.chain()
|
.chain()
|
||||||
.focus()
|
.focus()
|
||||||
.deleteRange(range)
|
.deleteRange(range)
|
||||||
.insertTable({ rows: 3, cols: 3, withHeaderRow: false })
|
.insertTable({ rows: 3, cols: 3, withHeaderRow: true })
|
||||||
.run(),
|
.run(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -253,6 +300,36 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
|||||||
command: ({ editor, range }: CommandProps) =>
|
command: ({ editor, range }: CommandProps) =>
|
||||||
editor.chain().focus().deleteRange(range).setMathBlock().run(),
|
editor.chain().focus().deleteRange(range).setMathBlock().run(),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "Mermaid diagram",
|
||||||
|
description: "Insert mermaid diagram",
|
||||||
|
searchTerms: ["mermaid", "diagrams", "chart", "uml"],
|
||||||
|
icon: IconMermaid,
|
||||||
|
command: ({ editor, range }: CommandProps) =>
|
||||||
|
editor
|
||||||
|
.chain()
|
||||||
|
.focus()
|
||||||
|
.deleteRange(range)
|
||||||
|
.setCodeBlock({ language: "mermaid" })
|
||||||
|
.insertContent("flowchart LR\n" + " A --> B")
|
||||||
|
.run(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Draw.io (diagrams.net) ",
|
||||||
|
description: "Insert and design Drawio diagrams",
|
||||||
|
searchTerms: ["drawio", "diagrams", "charts", "uml", "whiteboard"],
|
||||||
|
icon: IconDrawio,
|
||||||
|
command: ({ editor, range }: CommandProps) =>
|
||||||
|
editor.chain().focus().deleteRange(range).setDrawio().run(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Excalidraw diagram",
|
||||||
|
description: "Draw and sketch excalidraw diagrams",
|
||||||
|
searchTerms: ["diagrams", "draw", "sketch", "whiteboard"],
|
||||||
|
icon: IconExcalidraw,
|
||||||
|
command: ({ editor, range }: CommandProps) =>
|
||||||
|
editor.chain().focus().deleteRange(range).setExcalidraw().run(),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -1,12 +1,16 @@
|
|||||||
import { handleVideoUpload } from "@docmost/editor-ext";
|
import { handleVideoUpload } from "@docmost/editor-ext";
|
||||||
import { uploadFile } from "@/features/page/services/page-service.ts";
|
import { uploadFile } from "@/features/page/services/page-service.ts";
|
||||||
|
import { notifications } from "@mantine/notifications";
|
||||||
|
|
||||||
export const uploadVideoAction = handleVideoUpload({
|
export const uploadVideoAction = handleVideoUpload({
|
||||||
onUpload: async (file: File, pageId: string): Promise<any> => {
|
onUpload: async (file: File, pageId: string): Promise<any> => {
|
||||||
try {
|
try {
|
||||||
return await uploadFile(file, pageId);
|
return await uploadFile(file, pageId);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("failed to upload image", err);
|
notifications.show({
|
||||||
|
color: "red",
|
||||||
|
message: err?.response.data.message,
|
||||||
|
});
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -15,7 +19,11 @@ export const uploadVideoAction = handleVideoUpload({
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (file.size / 1024 / 1024 > 20) {
|
if (file.size / 1024 / 1024 > 50) {
|
||||||
|
notifications.show({
|
||||||
|
color: "red",
|
||||||
|
message: `File exceeds the 50 MB attachment limit`,
|
||||||
|
});
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@ -1,371 +0,0 @@
|
|||||||
// MIT - source: https://github.com/NiclasDev63/tiptap-extension-global-drag-handle
|
|
||||||
import { Extension } from "@tiptap/core";
|
|
||||||
import {
|
|
||||||
NodeSelection,
|
|
||||||
Plugin,
|
|
||||||
PluginKey,
|
|
||||||
TextSelection,
|
|
||||||
} from "@tiptap/pm/state";
|
|
||||||
import { Fragment, Slice, Node } from "@tiptap/pm/model";
|
|
||||||
|
|
||||||
// @ts-ignore
|
|
||||||
import { __serializeForClipboard, EditorView } from "@tiptap/pm/view";
|
|
||||||
|
|
||||||
export interface GlobalDragHandleOptions {
|
|
||||||
/**
|
|
||||||
* The width of the drag handle
|
|
||||||
*/
|
|
||||||
dragHandleWidth: number;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The treshold for scrolling
|
|
||||||
*/
|
|
||||||
scrollTreshold: number;
|
|
||||||
|
|
||||||
/*
|
|
||||||
* The css selector to query for the drag handle. (eg: '.custom-handle').
|
|
||||||
* If handle element is found, that element will be used as drag handle. If not, a default handle will be created
|
|
||||||
*/
|
|
||||||
dragHandleSelector?: string;
|
|
||||||
}
|
|
||||||
function absoluteRect(node: Element) {
|
|
||||||
const data = node.getBoundingClientRect();
|
|
||||||
const modal = node.closest('[role="dialog"]');
|
|
||||||
|
|
||||||
if (modal && window.getComputedStyle(modal).transform !== "none") {
|
|
||||||
const modalRect = modal.getBoundingClientRect();
|
|
||||||
|
|
||||||
return {
|
|
||||||
top: data.top - modalRect.top,
|
|
||||||
left: data.left - modalRect.left,
|
|
||||||
width: data.width,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
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: Element) =>
|
|
||||||
elem.parentElement?.matches?.(".ProseMirror") ||
|
|
||||||
elem.matches(
|
|
||||||
[
|
|
||||||
"li",
|
|
||||||
"p:not(:first-child)",
|
|
||||||
"pre",
|
|
||||||
"blockquote",
|
|
||||||
"h1, h2, h3, h4, h5, h6",
|
|
||||||
".tableWrapper",
|
|
||||||
].join(", "),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function nodePosAtDOM(
|
|
||||||
node: Element,
|
|
||||||
view: EditorView,
|
|
||||||
options: GlobalDragHandleOptions,
|
|
||||||
) {
|
|
||||||
const boundingRect = node.getBoundingClientRect();
|
|
||||||
|
|
||||||
return view.posAtCoords({
|
|
||||||
left: boundingRect.left + 50 + options.dragHandleWidth,
|
|
||||||
top: boundingRect.top + 1,
|
|
||||||
})?.inside;
|
|
||||||
}
|
|
||||||
|
|
||||||
function calcNodePos(pos: number, view: EditorView) {
|
|
||||||
const $pos = view.state.doc.resolve(pos);
|
|
||||||
if ($pos.depth > 1) return $pos.before($pos.depth);
|
|
||||||
return pos;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DragHandlePlugin(
|
|
||||||
options: GlobalDragHandleOptions & { pluginKey: string },
|
|
||||||
) {
|
|
||||||
let listType = "";
|
|
||||||
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;
|
|
||||||
|
|
||||||
let draggedNodePos = nodePosAtDOM(node, view, options);
|
|
||||||
if (draggedNodePos == null || draggedNodePos < 0) return;
|
|
||||||
draggedNodePos = calcNodePos(draggedNodePos, view);
|
|
||||||
|
|
||||||
const { from, to } = view.state.selection;
|
|
||||||
const diff = from - to;
|
|
||||||
|
|
||||||
const fromSelectionPos = calcNodePos(from, view);
|
|
||||||
let differentNodeSelected = false;
|
|
||||||
|
|
||||||
const nodePos = view.state.doc.resolve(fromSelectionPos);
|
|
||||||
|
|
||||||
// Check if nodePos points to the top level node
|
|
||||||
if (nodePos.node().type.name === "doc") differentNodeSelected = true;
|
|
||||||
else {
|
|
||||||
const nodeSelection = NodeSelection.create(
|
|
||||||
view.state.doc,
|
|
||||||
nodePos.before(),
|
|
||||||
);
|
|
||||||
// Check if the node where the drag event started is part of the current selection
|
|
||||||
differentNodeSelected = !(
|
|
||||||
draggedNodePos + 1 >= nodeSelection.$from.pos &&
|
|
||||||
draggedNodePos <= nodeSelection.$to.pos
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
!differentNodeSelected &&
|
|
||||||
diff !== 0 &&
|
|
||||||
!(view.state.selection instanceof NodeSelection)
|
|
||||||
) {
|
|
||||||
const endSelection = NodeSelection.create(view.state.doc, to - 1);
|
|
||||||
const multiNodeSelection = TextSelection.create(
|
|
||||||
view.state.doc,
|
|
||||||
draggedNodePos,
|
|
||||||
endSelection.$to.pos,
|
|
||||||
);
|
|
||||||
view.dispatch(view.state.tr.setSelection(multiNodeSelection));
|
|
||||||
} else {
|
|
||||||
const nodeSelection = NodeSelection.create(
|
|
||||||
view.state.doc,
|
|
||||||
draggedNodePos,
|
|
||||||
);
|
|
||||||
view.dispatch(view.state.tr.setSelection(nodeSelection));
|
|
||||||
}
|
|
||||||
|
|
||||||
// If the selected node is a list item, we need to save the type of the wrapping list e.g. OL or UL
|
|
||||||
if (
|
|
||||||
view.state.selection instanceof NodeSelection &&
|
|
||||||
view.state.selection.node.type.name === "listItem"
|
|
||||||
) {
|
|
||||||
listType = node.parentElement!.tagName;
|
|
||||||
}
|
|
||||||
|
|
||||||
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 };
|
|
||||||
}
|
|
||||||
|
|
||||||
let dragHandleElement: HTMLElement | null = null;
|
|
||||||
|
|
||||||
function hideDragHandle() {
|
|
||||||
if (dragHandleElement) {
|
|
||||||
dragHandleElement.classList.add("hide");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function showDragHandle() {
|
|
||||||
if (dragHandleElement) {
|
|
||||||
dragHandleElement.classList.remove("hide");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Plugin({
|
|
||||||
key: new PluginKey(options.pluginKey),
|
|
||||||
view: (view) => {
|
|
||||||
const handleBySelector = options.dragHandleSelector
|
|
||||||
? document.querySelector<HTMLElement>(options.dragHandleSelector)
|
|
||||||
: null;
|
|
||||||
dragHandleElement = handleBySelector ?? document.createElement("div");
|
|
||||||
dragHandleElement.draggable = true;
|
|
||||||
dragHandleElement.dataset.dragHandle = "";
|
|
||||||
dragHandleElement.classList.add("drag-handle");
|
|
||||||
|
|
||||||
function onDragHandleDragStart(e: DragEvent) {
|
|
||||||
handleDragStart(e, view);
|
|
||||||
}
|
|
||||||
|
|
||||||
dragHandleElement.addEventListener("dragstart", onDragHandleDragStart);
|
|
||||||
|
|
||||||
function onDragHandleDrag(e: DragEvent) {
|
|
||||||
hideDragHandle();
|
|
||||||
let scrollY = window.scrollY;
|
|
||||||
if (e.clientY < options.scrollTreshold) {
|
|
||||||
window.scrollTo({ top: scrollY - 30, behavior: "smooth" });
|
|
||||||
} else if (window.innerHeight - e.clientY < options.scrollTreshold) {
|
|
||||||
window.scrollTo({ top: scrollY + 30, behavior: "smooth" });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
dragHandleElement.addEventListener("drag", onDragHandleDrag);
|
|
||||||
|
|
||||||
hideDragHandle();
|
|
||||||
|
|
||||||
if (!handleBySelector) {
|
|
||||||
view?.dom?.parentElement?.appendChild(dragHandleElement);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
destroy: () => {
|
|
||||||
if (!handleBySelector) {
|
|
||||||
dragHandleElement?.remove?.();
|
|
||||||
}
|
|
||||||
dragHandleElement?.removeEventListener("drag", onDragHandleDrag);
|
|
||||||
dragHandleElement?.removeEventListener(
|
|
||||||
"dragstart",
|
|
||||||
onDragHandleDragStart,
|
|
||||||
);
|
|
||||||
dragHandleElement = null;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
props: {
|
|
||||||
handleDOMEvents: {
|
|
||||||
mousemove: (view, event) => {
|
|
||||||
if (!view.editable) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const node = nodeDOMAtCoords({
|
|
||||||
x: event.clientX + 50 + options.dragHandleWidth,
|
|
||||||
y: event.clientY,
|
|
||||||
});
|
|
||||||
|
|
||||||
const notDragging = node?.closest(".not-draggable");
|
|
||||||
|
|
||||||
//const isNodeInTable = node?.closest("td") || node?.closest("th");
|
|
||||||
|
|
||||||
if (
|
|
||||||
!(node instanceof Element) ||
|
|
||||||
node.matches("ul, ol") ||
|
|
||||||
notDragging ||
|
|
||||||
node.matches(".tableWrapper")
|
|
||||||
) {
|
|
||||||
hideDragHandle();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const compStyle = window.getComputedStyle(node);
|
|
||||||
const parsedLineHeight = parseInt(compStyle.lineHeight, 10);
|
|
||||||
const lineHeight = isNaN(parsedLineHeight)
|
|
||||||
? parseInt(compStyle.fontSize) * 1.2
|
|
||||||
: parsedLineHeight;
|
|
||||||
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, event) => {
|
|
||||||
view.dom.classList.remove("dragging");
|
|
||||||
hideDragHandle();
|
|
||||||
let droppedNode: Node | null = null;
|
|
||||||
const dropPos = view.posAtCoords({
|
|
||||||
left: event.clientX,
|
|
||||||
top: event.clientY,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!dropPos) return;
|
|
||||||
|
|
||||||
if (view.state.selection instanceof NodeSelection) {
|
|
||||||
droppedNode = view.state.selection.node;
|
|
||||||
}
|
|
||||||
if (!droppedNode) return;
|
|
||||||
|
|
||||||
const resolvedPos = view.state.doc.resolve(dropPos.pos);
|
|
||||||
|
|
||||||
const isDroppedInsideList =
|
|
||||||
resolvedPos.parent.type.name === "listItem";
|
|
||||||
|
|
||||||
// If the selected node is a list item and is not dropped inside a list, we need to wrap it inside <ol> tag otherwise ol list items will be transformed into ul list item when dropped
|
|
||||||
if (
|
|
||||||
view.state.selection instanceof NodeSelection &&
|
|
||||||
view.state.selection.node.type.name === "listItem" &&
|
|
||||||
!isDroppedInsideList &&
|
|
||||||
listType == "OL"
|
|
||||||
) {
|
|
||||||
const text = droppedNode.textContent;
|
|
||||||
if (!text) return;
|
|
||||||
const paragraph = view.state.schema.nodes.paragraph?.createAndFill(
|
|
||||||
{},
|
|
||||||
view.state.schema.text(text),
|
|
||||||
);
|
|
||||||
const listItem = view.state.schema.nodes.listItem?.createAndFill(
|
|
||||||
{},
|
|
||||||
paragraph,
|
|
||||||
);
|
|
||||||
|
|
||||||
const newList = view.state.schema.nodes.orderedList?.createAndFill(
|
|
||||||
null,
|
|
||||||
listItem,
|
|
||||||
);
|
|
||||||
const slice = new Slice(Fragment.from(newList), 0, 0);
|
|
||||||
view.dragging = { slice, move: event.ctrlKey };
|
|
||||||
}
|
|
||||||
},
|
|
||||||
dragend: (view) => {
|
|
||||||
view.dom.classList.remove("dragging");
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const GlobalDragHandle = Extension.create({
|
|
||||||
name: "globalDragHandle",
|
|
||||||
|
|
||||||
addOptions() {
|
|
||||||
return {
|
|
||||||
dragHandleWidth: 20,
|
|
||||||
scrollTreshold: 100,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
|
|
||||||
addProseMirrorPlugins() {
|
|
||||||
return [
|
|
||||||
DragHandlePlugin({
|
|
||||||
pluginKey: "globalDragHandle",
|
|
||||||
dragHandleWidth: this.options.dragHandleWidth,
|
|
||||||
scrollTreshold: this.options.scrollTreshold,
|
|
||||||
dragHandleSelector: this.options.dragHandleSelector,
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default GlobalDragHandle;
|
|
||||||
@ -4,14 +4,14 @@ import { TextAlign } from "@tiptap/extension-text-align";
|
|||||||
import { TaskList } from "@tiptap/extension-task-list";
|
import { TaskList } from "@tiptap/extension-task-list";
|
||||||
import { TaskItem } from "@tiptap/extension-task-item";
|
import { TaskItem } from "@tiptap/extension-task-item";
|
||||||
import { Underline } from "@tiptap/extension-underline";
|
import { Underline } from "@tiptap/extension-underline";
|
||||||
import { Link } from "@tiptap/extension-link";
|
|
||||||
import { Superscript } from "@tiptap/extension-superscript";
|
import { Superscript } from "@tiptap/extension-superscript";
|
||||||
import SubScript from "@tiptap/extension-subscript";
|
import SubScript from "@tiptap/extension-subscript";
|
||||||
import { Highlight } from "@tiptap/extension-highlight";
|
import { Highlight } from "@tiptap/extension-highlight";
|
||||||
import { Typography } from "@tiptap/extension-typography";
|
import { Typography } from "@tiptap/extension-typography";
|
||||||
import { TextStyle } from "@tiptap/extension-text-style";
|
import { TextStyle } from "@tiptap/extension-text-style";
|
||||||
import { Color } from "@tiptap/extension-color";
|
import { Color } from "@tiptap/extension-color";
|
||||||
import CodeBlockLowlight from "@tiptap/extension-code-block-lowlight";
|
import Table from "@tiptap/extension-table";
|
||||||
|
import TableHeader from "@tiptap/extension-table-header";
|
||||||
import SlashCommand from "@/features/editor/extensions/slash-command";
|
import SlashCommand from "@/features/editor/extensions/slash-command";
|
||||||
import { Collaboration } from "@tiptap/extension-collaboration";
|
import { Collaboration } from "@tiptap/extension-collaboration";
|
||||||
import { CollaborationCursor } from "@tiptap/extension-collaboration-cursor";
|
import { CollaborationCursor } from "@tiptap/extension-collaboration-cursor";
|
||||||
@ -23,8 +23,6 @@ import {
|
|||||||
DetailsSummary,
|
DetailsSummary,
|
||||||
MathBlock,
|
MathBlock,
|
||||||
MathInline,
|
MathInline,
|
||||||
Table,
|
|
||||||
TableHeader,
|
|
||||||
TableCell,
|
TableCell,
|
||||||
TableRow,
|
TableRow,
|
||||||
TrailingNode,
|
TrailingNode,
|
||||||
@ -33,6 +31,10 @@ import {
|
|||||||
TiptapVideo,
|
TiptapVideo,
|
||||||
LinkExtension,
|
LinkExtension,
|
||||||
Selection,
|
Selection,
|
||||||
|
Attachment,
|
||||||
|
CustomCodeBlock,
|
||||||
|
Drawio,
|
||||||
|
Excalidraw,
|
||||||
} from "@docmost/editor-ext";
|
} from "@docmost/editor-ext";
|
||||||
import {
|
import {
|
||||||
randomElement,
|
randomElement,
|
||||||
@ -41,13 +43,20 @@ import {
|
|||||||
import { IUser } from "@/features/user/types/user.types.ts";
|
import { IUser } from "@/features/user/types/user.types.ts";
|
||||||
import MathInlineView from "@/features/editor/components/math/math-inline.tsx";
|
import MathInlineView from "@/features/editor/components/math/math-inline.tsx";
|
||||||
import MathBlockView from "@/features/editor/components/math/math-block.tsx";
|
import MathBlockView from "@/features/editor/components/math/math-block.tsx";
|
||||||
import GlobalDragHandle from "@/features/editor/extensions/drag-handle.ts";
|
import GlobalDragHandle from "tiptap-extension-global-drag-handle";
|
||||||
import { Youtube } from "@tiptap/extension-youtube";
|
import { Youtube } from "@tiptap/extension-youtube";
|
||||||
import ImageView from "@/features/editor/components/image/image-view.tsx";
|
import ImageView from "@/features/editor/components/image/image-view.tsx";
|
||||||
import CalloutView from "@/features/editor/components/callout/callout-view.tsx";
|
import CalloutView from "@/features/editor/components/callout/callout-view.tsx";
|
||||||
import { common, createLowlight } from "lowlight";
|
import { common, createLowlight } from "lowlight";
|
||||||
import VideoView from "@/features/editor/components/video/video-view.tsx";
|
import VideoView from "@/features/editor/components/video/video-view.tsx";
|
||||||
|
import AttachmentView from "@/features/editor/components/attachment/attachment-view.tsx";
|
||||||
|
import CodeBlockView from "@/features/editor/components/code-block/code-block-view.tsx";
|
||||||
|
import DrawioView from "../components/drawio/drawio-view";
|
||||||
|
import ExcalidrawView from "@/features/editor/components/excalidraw/excalidraw-view.tsx";
|
||||||
|
import plaintext from "highlight.js/lib/languages/plaintext";
|
||||||
|
|
||||||
const lowlight = createLowlight(common);
|
const lowlight = createLowlight(common);
|
||||||
|
lowlight.register("mermaid", plaintext);
|
||||||
|
|
||||||
export const mainExtensions = [
|
export const mainExtensions = [
|
||||||
StarterKit.configure({
|
StarterKit.configure({
|
||||||
@ -57,6 +66,11 @@ export const mainExtensions = [
|
|||||||
color: "#70CFF8",
|
color: "#70CFF8",
|
||||||
},
|
},
|
||||||
codeBlock: false,
|
codeBlock: false,
|
||||||
|
code: {
|
||||||
|
HTMLAttributes: {
|
||||||
|
spellcheck: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
Placeholder.configure({
|
Placeholder.configure({
|
||||||
placeholder: ({ node }) => {
|
placeholder: ({ node }) => {
|
||||||
@ -66,9 +80,12 @@ export const mainExtensions = [
|
|||||||
if (node.type.name === "detailsSummary") {
|
if (node.type.name === "detailsSummary") {
|
||||||
return "Toggle title";
|
return "Toggle title";
|
||||||
}
|
}
|
||||||
|
if (node.type.name === "paragraph") {
|
||||||
return 'Write anything. Enter "/" for commands';
|
return 'Write anything. Enter "/" for commands';
|
||||||
|
}
|
||||||
},
|
},
|
||||||
includeChildren: true,
|
includeChildren: true,
|
||||||
|
showOnlyWhenEditable: true,
|
||||||
}),
|
}),
|
||||||
TextAlign.configure({ types: ["heading", "paragraph"] }),
|
TextAlign.configure({ types: ["heading", "paragraph"] }),
|
||||||
TaskList,
|
TaskList,
|
||||||
@ -95,10 +112,16 @@ export const mainExtensions = [
|
|||||||
class: "comment-mark",
|
class: "comment-mark",
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
Table,
|
|
||||||
|
Table.configure({
|
||||||
|
resizable: true,
|
||||||
|
lastColumnResizable: false,
|
||||||
|
allowTableNodeSelection: true,
|
||||||
|
}),
|
||||||
TableRow,
|
TableRow,
|
||||||
TableCell,
|
TableCell,
|
||||||
TableHeader,
|
TableHeader,
|
||||||
|
|
||||||
MathInline.configure({
|
MathInline.configure({
|
||||||
view: MathInlineView,
|
view: MathInlineView,
|
||||||
}),
|
}),
|
||||||
@ -122,10 +145,23 @@ export const mainExtensions = [
|
|||||||
Callout.configure({
|
Callout.configure({
|
||||||
view: CalloutView,
|
view: CalloutView,
|
||||||
}),
|
}),
|
||||||
CodeBlockLowlight.configure({
|
CustomCodeBlock.configure({
|
||||||
|
view: CodeBlockView,
|
||||||
lowlight,
|
lowlight,
|
||||||
|
HTMLAttributes: {
|
||||||
|
spellcheck: false,
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
Selection,
|
Selection,
|
||||||
|
Attachment.configure({
|
||||||
|
view: AttachmentView,
|
||||||
|
}),
|
||||||
|
Drawio.configure({
|
||||||
|
view: DrawioView,
|
||||||
|
}),
|
||||||
|
Excalidraw.configure({
|
||||||
|
view: ExcalidrawView,
|
||||||
|
}),
|
||||||
] as any;
|
] as any;
|
||||||
|
|
||||||
type CollabExtensions = (provider: HocuspocusProvider, user: IUser) => any[];
|
type CollabExtensions = (provider: HocuspocusProvider, user: IUser) => any[];
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import React from "react";
|
|||||||
import { TitleEditor } from "@/features/editor/title-editor";
|
import { TitleEditor } from "@/features/editor/title-editor";
|
||||||
import PageEditor from "@/features/editor/page-editor";
|
import PageEditor from "@/features/editor/page-editor";
|
||||||
import { Container } from "@mantine/core";
|
import { Container } from "@mantine/core";
|
||||||
import { useAtom } from "jotai/index";
|
import { useAtom } from "jotai";
|
||||||
import { userAtom } from "@/features/user/atoms/current-user-atom.ts";
|
import { userAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||||
|
|
||||||
const MemoizedTitleEditor = React.memo(TitleEditor);
|
const MemoizedTitleEditor = React.memo(TitleEditor);
|
||||||
|
|||||||
@ -31,14 +31,14 @@ import TableCellMenu from "@/features/editor/components/table/table-cell-menu.ts
|
|||||||
import TableMenu from "@/features/editor/components/table/table-menu.tsx";
|
import TableMenu from "@/features/editor/components/table/table-menu.tsx";
|
||||||
import ImageMenu from "@/features/editor/components/image/image-menu.tsx";
|
import ImageMenu from "@/features/editor/components/image/image-menu.tsx";
|
||||||
import CalloutMenu from "@/features/editor/components/callout/callout-menu.tsx";
|
import CalloutMenu from "@/features/editor/components/callout/callout-menu.tsx";
|
||||||
import { uploadImageAction } from "@/features/editor/components/image/upload-image-action.tsx";
|
|
||||||
import { uploadVideoAction } from "@/features/editor/components/video/upload-video-action.tsx";
|
|
||||||
import VideoMenu from "@/features/editor/components/video/video-menu.tsx";
|
import VideoMenu from "@/features/editor/components/video/video-menu.tsx";
|
||||||
import {
|
import {
|
||||||
handleFileDrop,
|
handleFileDrop,
|
||||||
handleFilePaste,
|
handleFilePaste,
|
||||||
} from "@/features/editor/components/common/file-upload-handler.tsx";
|
} from "@/features/editor/components/common/file-upload-handler.tsx";
|
||||||
import LinkMenu from "@/features/editor/components/link/link-menu.tsx";
|
import LinkMenu from "@/features/editor/components/link/link-menu.tsx";
|
||||||
|
import ExcalidrawMenu from "./components/excalidraw/excalidraw-menu";
|
||||||
|
import DrawioMenu from "./components/drawio/drawio-menu";
|
||||||
|
|
||||||
interface PageEditorProps {
|
interface PageEditorProps {
|
||||||
pageId: string;
|
pageId: string;
|
||||||
@ -173,6 +173,8 @@ export default function PageEditor({ pageId, editable }: PageEditorProps) {
|
|||||||
<ImageMenu editor={editor} />
|
<ImageMenu editor={editor} />
|
||||||
<VideoMenu editor={editor} />
|
<VideoMenu editor={editor} />
|
||||||
<CalloutMenu editor={editor} />
|
<CalloutMenu editor={editor} />
|
||||||
|
<ExcalidrawMenu editor={editor} />
|
||||||
|
<DrawioMenu editor={editor} />
|
||||||
<LinkMenu editor={editor} appendTo={menuContainerRef} />
|
<LinkMenu editor={editor} appendTo={menuContainerRef} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -1,6 +1,13 @@
|
|||||||
.ProseMirror {
|
.ProseMirror {
|
||||||
|
.codeBlock {
|
||||||
|
padding: 4px;
|
||||||
|
border-radius: var(--mantine-radius-default);
|
||||||
|
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-8));
|
||||||
|
}
|
||||||
|
|
||||||
pre {
|
pre {
|
||||||
padding: var(--mantine-spacing-sm) var(--mantine-spacing-md);
|
padding: var(--mantine-spacing-xs) var(--mantine-spacing-md);
|
||||||
|
margin: 4px;
|
||||||
font-family: "JetBrainsMono", var(--mantine-font-family-monospace);
|
font-family: "JetBrainsMono", var(--mantine-font-family-monospace);
|
||||||
border-radius: var(--mantine-radius-default);
|
border-radius: var(--mantine-radius-default);
|
||||||
|
|
||||||
@ -86,4 +93,22 @@
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:not(pre) > code {
|
||||||
|
font-family: "JetBrainsMono", var(--mantine-font-family-monospace);
|
||||||
|
line-height: var(--mantine-line-height);
|
||||||
|
padding: 2px calc(var(--mantine-spacing-xs) / 2);
|
||||||
|
border-radius: var(--mantine-radius-sm);
|
||||||
|
margin: 0;
|
||||||
|
|
||||||
|
@mixin where-light {
|
||||||
|
background-color: var(--code-bg, var(--mantine-color-gray-1));
|
||||||
|
color: var(--mantine-color-black);
|
||||||
|
}
|
||||||
|
|
||||||
|
@mixin where-dark {
|
||||||
|
background-color: var(--mantine-color-dark-8);
|
||||||
|
color: var(--mantine-color-gray-4);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -20,6 +20,11 @@
|
|||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin-top: 0.65em;
|
||||||
|
margin-bottom: 0.65em;
|
||||||
|
}
|
||||||
|
|
||||||
ul,
|
ul,
|
||||||
ol {
|
ol {
|
||||||
padding: 0 1rem;
|
padding: 0 1rem;
|
||||||
@ -27,6 +32,12 @@
|
|||||||
margin-bottom: 0.25rem;
|
margin-bottom: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ul p,
|
||||||
|
ol p {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
h1,
|
h1,
|
||||||
h2,
|
h2,
|
||||||
h3,
|
h3,
|
||||||
@ -80,15 +91,9 @@
|
|||||||
outline: 2px solid #70cff8;
|
outline: 2px solid #70cff8;
|
||||||
}
|
}
|
||||||
|
|
||||||
.node-mathInline {
|
|
||||||
.katex-display {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
& > .react-renderer {
|
& > .react-renderer {
|
||||||
margin-top: var(--mantine-spacing-xl);
|
margin-top: var(--mantine-spacing-sm);
|
||||||
margin-bottom: var(--mantine-spacing-xl);
|
margin-bottom: var(--mantine-spacing-sm);
|
||||||
|
|
||||||
&:first-child {
|
&:first-child {
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
@ -163,3 +168,4 @@
|
|||||||
.actionIconGroup {
|
.actionIconGroup {
|
||||||
background: var(--mantine-color-body);
|
background: var(--mantine-color-body);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -50,8 +50,7 @@
|
|||||||
|
|
||||||
[data-type="detailsContainer"] {
|
[data-type="detailsContainer"] {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
margin-left: 0.2em;
|
padding: 4px;
|
||||||
overflow-x: hidden;
|
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
overflow-wrap: break-word;
|
overflow-wrap: break-word;
|
||||||
|
|
||||||
|
|||||||
@ -2,5 +2,10 @@
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
padding: 8px 20px;
|
padding: 8px 20px;
|
||||||
margin: 64px auto;
|
margin: 64px auto;
|
||||||
|
|
||||||
|
@media print {
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -8,5 +8,4 @@
|
|||||||
@import "./youtube.css";
|
@import "./youtube.css";
|
||||||
@import "./media.css";
|
@import "./media.css";
|
||||||
@import "./code.css";
|
@import "./code.css";
|
||||||
|
@import "./print.css";
|
||||||
|
|
||||||
|
|||||||
@ -4,10 +4,34 @@
|
|||||||
height: auto;
|
height: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.node-image, .node-video {
|
.node-image, .node-video, .node-excalidraw, .node-drawio {
|
||||||
&.ProseMirror-selectednode {
|
&.ProseMirror-selectednode {
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.attachment-placeholder {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background-color: var(--mantine-color-body);
|
||||||
|
border-radius: var(--mantine-radius-default);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 15px;
|
||||||
|
height: 25px;
|
||||||
|
|
||||||
|
@mixin light {
|
||||||
|
border: 1px solid var(--mantine-color-gray-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
@mixin dark {
|
||||||
|
border: 1px solid var(--mantine-color-dark-4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.uploading-text {
|
||||||
|
font-size: var(--mantine-font-size-md);
|
||||||
|
line-height: var(--mantine-line-height-md);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -4,6 +4,10 @@
|
|||||||
color: #adb5bd;
|
color: #adb5bd;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
height: 0;
|
height: 0;
|
||||||
|
|
||||||
|
@media print {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.ProseMirror .is-empty::before {
|
.ProseMirror .is-empty::before {
|
||||||
@ -12,9 +16,17 @@
|
|||||||
color: #adb5bd;
|
color: #adb5bd;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
height: 0;
|
height: 0;
|
||||||
|
|
||||||
|
@media print {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.ProseMirror table .is-editor-empty:first-child::before,
|
.ProseMirror table .is-editor-empty:first-child::before,
|
||||||
.ProseMirror table .is-empty::before {
|
.ProseMirror table .is-empty::before {
|
||||||
content: '';
|
display: none;
|
||||||
|
|
||||||
|
@media print {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
11
apps/client/src/features/editor/styles/print.css
Normal file
11
apps/client/src/features/editor/styles/print.css
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
@media print {
|
||||||
|
.mantine-AppShell-header,
|
||||||
|
.mantine-AppShell-navbar,
|
||||||
|
.mantine-AppShell-aside{
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mantine-AppShell-main {
|
||||||
|
padding-top: 0 !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,6 +1,6 @@
|
|||||||
.tableWrapper {
|
.tableWrapper {
|
||||||
margin-top: 3rem;
|
margin-top: 1rem;
|
||||||
margin-bottom: 3rem;
|
margin-bottom: 1rem;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
& table {
|
& table {
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
|
|||||||
@ -17,5 +17,9 @@
|
|||||||
&.ProseMirror-selectednode {
|
&.ProseMirror-selectednode {
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media print {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { Button, Divider, Group, Modal, ScrollArea } from "@mantine/core";
|
import { Button, Divider, Group, Modal } from "@mantine/core";
|
||||||
import { useDisclosure } from "@mantine/hooks";
|
import { useDisclosure } from "@mantine/hooks";
|
||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { MultiUserSelect } from "@/features/group/components/multi-user-select.tsx";
|
import { MultiUserSelect } from "@/features/group/components/multi-user-select.tsx";
|
||||||
|
|||||||
@ -2,7 +2,6 @@ import {
|
|||||||
usePageHistoryListQuery,
|
usePageHistoryListQuery,
|
||||||
usePageHistoryQuery,
|
usePageHistoryQuery,
|
||||||
} from "@/features/page-history/queries/page-history-query";
|
} from "@/features/page-history/queries/page-history-query";
|
||||||
import { useParams } from "react-router-dom";
|
|
||||||
import HistoryItem from "@/features/page-history/components/history-item";
|
import HistoryItem from "@/features/page-history/components/history-item";
|
||||||
import {
|
import {
|
||||||
activeHistoryIdAtom,
|
activeHistoryIdAtom,
|
||||||
|
|||||||
@ -2,16 +2,18 @@ import { ActionIcon, Group, Menu, Tooltip } from "@mantine/core";
|
|||||||
import {
|
import {
|
||||||
IconArrowsHorizontal,
|
IconArrowsHorizontal,
|
||||||
IconDots,
|
IconDots,
|
||||||
|
IconDownload,
|
||||||
IconHistory,
|
IconHistory,
|
||||||
IconLink,
|
IconLink,
|
||||||
IconMessage,
|
IconMessage,
|
||||||
|
IconPrinter,
|
||||||
IconTrash,
|
IconTrash,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import useToggleAside from "@/hooks/use-toggle-aside.tsx";
|
import useToggleAside from "@/hooks/use-toggle-aside.tsx";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { historyAtoms } from "@/features/page-history/atoms/history-atoms.ts";
|
import { historyAtoms } from "@/features/page-history/atoms/history-atoms.ts";
|
||||||
import { useClipboard } from "@mantine/hooks";
|
import { useClipboard, useDisclosure } from "@mantine/hooks";
|
||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
import { usePageQuery } from "@/features/page/queries/page-query.ts";
|
import { usePageQuery } from "@/features/page/queries/page-query.ts";
|
||||||
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
||||||
@ -21,6 +23,7 @@ import { extractPageSlugId } from "@/lib";
|
|||||||
import { treeApiAtom } from "@/features/page/tree/atoms/tree-api-atom.ts";
|
import { treeApiAtom } from "@/features/page/tree/atoms/tree-api-atom.ts";
|
||||||
import { useDeletePageModal } from "@/features/page/hooks/use-delete-page-modal.tsx";
|
import { useDeletePageModal } from "@/features/page/hooks/use-delete-page-modal.tsx";
|
||||||
import { PageWidthToggle } from "@/features/user/components/page-width-pref.tsx";
|
import { PageWidthToggle } from "@/features/user/components/page-width-pref.tsx";
|
||||||
|
import PageExportModal from "@/features/page/components/page-export-modal.tsx";
|
||||||
|
|
||||||
interface PageHeaderMenuProps {
|
interface PageHeaderMenuProps {
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
@ -57,6 +60,8 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
|
|||||||
});
|
});
|
||||||
const { openDeleteModal } = useDeletePageModal();
|
const { openDeleteModal } = useDeletePageModal();
|
||||||
const [tree] = useAtom(treeApiAtom);
|
const [tree] = useAtom(treeApiAtom);
|
||||||
|
const [exportOpened, { open: openExportModal, close: closeExportModal }] =
|
||||||
|
useDisclosure(false);
|
||||||
|
|
||||||
const handleCopyLink = () => {
|
const handleCopyLink = () => {
|
||||||
const pageUrl =
|
const pageUrl =
|
||||||
@ -66,6 +71,12 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
|
|||||||
notifications.show({ message: "Link copied" });
|
notifications.show({ message: "Link copied" });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handlePrint = () => {
|
||||||
|
setTimeout(() => {
|
||||||
|
window.print();
|
||||||
|
}, 250);
|
||||||
|
};
|
||||||
|
|
||||||
const openHistoryModal = () => {
|
const openHistoryModal = () => {
|
||||||
setHistoryModalOpen(true);
|
setHistoryModalOpen(true);
|
||||||
};
|
};
|
||||||
@ -75,6 +86,7 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<Menu
|
<Menu
|
||||||
shadow="xl"
|
shadow="xl"
|
||||||
position="bottom-end"
|
position="bottom-end"
|
||||||
@ -85,38 +97,54 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
|
|||||||
>
|
>
|
||||||
<Menu.Target>
|
<Menu.Target>
|
||||||
<ActionIcon variant="default" style={{ border: "none" }}>
|
<ActionIcon variant="default" style={{ border: "none" }}>
|
||||||
<IconDots size={20} stroke={2} />
|
<IconDots size={20} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Menu.Target>
|
</Menu.Target>
|
||||||
|
|
||||||
<Menu.Dropdown>
|
<Menu.Dropdown>
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
leftSection={<IconLink size={16} stroke={2} />}
|
leftSection={<IconLink size={16} />}
|
||||||
onClick={handleCopyLink}
|
onClick={handleCopyLink}
|
||||||
>
|
>
|
||||||
Copy link
|
Copy link
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
<Menu.Divider />
|
<Menu.Divider />
|
||||||
|
|
||||||
<Menu.Item leftSection={<IconArrowsHorizontal size={16} stroke={2} />}>
|
<Menu.Item leftSection={<IconArrowsHorizontal size={16} />}>
|
||||||
<Group wrap="nowrap">
|
<Group wrap="nowrap">
|
||||||
<PageWidthToggle label="Full width" />
|
<PageWidthToggle label="Full width" />
|
||||||
</Group>
|
</Group>
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
|
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
leftSection={<IconHistory size={16} stroke={2} />}
|
leftSection={<IconHistory size={16} />}
|
||||||
onClick={openHistoryModal}
|
onClick={openHistoryModal}
|
||||||
>
|
>
|
||||||
Page history
|
Page history
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
|
|
||||||
|
<Menu.Divider />
|
||||||
|
|
||||||
|
<Menu.Item
|
||||||
|
leftSection={<IconDownload size={16} />}
|
||||||
|
onClick={openExportModal}
|
||||||
|
>
|
||||||
|
Export
|
||||||
|
</Menu.Item>
|
||||||
|
|
||||||
|
<Menu.Item
|
||||||
|
leftSection={<IconPrinter size={16} />}
|
||||||
|
onClick={handlePrint}
|
||||||
|
>
|
||||||
|
Print PDF
|
||||||
|
</Menu.Item>
|
||||||
|
|
||||||
{!readOnly && (
|
{!readOnly && (
|
||||||
<>
|
<>
|
||||||
<Menu.Divider />
|
<Menu.Divider />
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
color={"red"}
|
color={"red"}
|
||||||
leftSection={<IconTrash size={16} stroke={2} />}
|
leftSection={<IconTrash size={16} />}
|
||||||
onClick={handleDeletePage}
|
onClick={handleDeletePage}
|
||||||
>
|
>
|
||||||
Delete
|
Delete
|
||||||
@ -125,5 +153,12 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
|
|||||||
)}
|
)}
|
||||||
</Menu.Dropdown>
|
</Menu.Dropdown>
|
||||||
</Menu>
|
</Menu>
|
||||||
|
|
||||||
|
<PageExportModal
|
||||||
|
pageId={page.id}
|
||||||
|
open={exportOpened}
|
||||||
|
onClose={closeExportModal}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,4 +8,8 @@
|
|||||||
top: var(--app-shell-header-offset, 0rem);
|
top: var(--app-shell-header-offset, 0rem);
|
||||||
inset-inline-start: var(--app-shell-navbar-offset, 0rem);
|
inset-inline-start: var(--app-shell-navbar-offset, 0rem);
|
||||||
inset-inline-end: var(--app-shell-aside-offset, 0rem);
|
inset-inline-end: var(--app-shell-aside-offset, 0rem);
|
||||||
|
|
||||||
|
@media print {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,94 @@
|
|||||||
|
import { Modal, Button, Group, Text, Select } from "@mantine/core";
|
||||||
|
import { exportPage } from "@/features/page/services/page-service.ts";
|
||||||
|
import { useState } from "react";
|
||||||
|
import * as React from "react";
|
||||||
|
import { ExportFormat } from "@/features/page/types/page.types.ts";
|
||||||
|
import { notifications } from "@mantine/notifications";
|
||||||
|
|
||||||
|
interface PageExportModalProps {
|
||||||
|
pageId: string;
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PageExportModal({
|
||||||
|
pageId,
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
}: PageExportModalProps) {
|
||||||
|
const [format, setFormat] = useState<ExportFormat>(ExportFormat.Markdown);
|
||||||
|
|
||||||
|
const handleExport = async () => {
|
||||||
|
try {
|
||||||
|
await exportPage({ pageId: pageId, format });
|
||||||
|
onClose();
|
||||||
|
} catch (err) {
|
||||||
|
notifications.show({
|
||||||
|
message: "Export failed:" + err.response?.data.message,
|
||||||
|
color: "red",
|
||||||
|
});
|
||||||
|
console.error("export error", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChange = (format: ExportFormat) => {
|
||||||
|
setFormat(format);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal.Root
|
||||||
|
opened={open}
|
||||||
|
onClose={onClose}
|
||||||
|
size={500}
|
||||||
|
padding="xl"
|
||||||
|
yOffset="10vh"
|
||||||
|
xOffset={0}
|
||||||
|
mah={400}
|
||||||
|
>
|
||||||
|
<Modal.Overlay />
|
||||||
|
<Modal.Content style={{ overflow: "hidden" }}>
|
||||||
|
<Modal.Header py={0}>
|
||||||
|
<Modal.Title fw={500}>Export page</Modal.Title>
|
||||||
|
<Modal.CloseButton />
|
||||||
|
</Modal.Header>
|
||||||
|
<Modal.Body>
|
||||||
|
<Group justify="space-between" wrap="nowrap">
|
||||||
|
<div>
|
||||||
|
<Text size="md">Format</Text>
|
||||||
|
</div>
|
||||||
|
<ExportFormatSelection format={format} onChange={handleChange} />
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Group justify="center" mt="md">
|
||||||
|
<Button onClick={onClose} variant="default">
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleExport}>Export</Button>
|
||||||
|
</Group>
|
||||||
|
</Modal.Body>
|
||||||
|
</Modal.Content>
|
||||||
|
</Modal.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ExportFormatSelection {
|
||||||
|
format: ExportFormat;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
}
|
||||||
|
function ExportFormatSelection({ format, onChange }: ExportFormatSelection) {
|
||||||
|
return (
|
||||||
|
<Select
|
||||||
|
data={[
|
||||||
|
{ value: "markdown", label: "Markdown" },
|
||||||
|
{ value: "html", label: "HTML" },
|
||||||
|
]}
|
||||||
|
defaultValue={format}
|
||||||
|
onChange={onChange}
|
||||||
|
styles={{ wrapper: { maxWidth: 120 } }}
|
||||||
|
comboboxProps={{ width: "120" }}
|
||||||
|
allowDeselect={false}
|
||||||
|
withCheckIcon={false}
|
||||||
|
aria-label="Select export format"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
150
apps/client/src/features/page/components/page-import-modal.tsx
Normal file
150
apps/client/src/features/page/components/page-import-modal.tsx
Normal file
@ -0,0 +1,150 @@
|
|||||||
|
import { Modal, Button, SimpleGrid, FileButton } from "@mantine/core";
|
||||||
|
import {
|
||||||
|
IconCheck,
|
||||||
|
IconFileCode,
|
||||||
|
IconMarkdown,
|
||||||
|
IconX,
|
||||||
|
} from "@tabler/icons-react";
|
||||||
|
import { importPage } from "@/features/page/services/page-service.ts";
|
||||||
|
import { notifications } from "@mantine/notifications";
|
||||||
|
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { buildTree } from "@/features/page/tree/utils";
|
||||||
|
import { IPage } from "@/features/page/types/page.types.ts";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
interface PageImportModalProps {
|
||||||
|
spaceId: string;
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PageImportModal({
|
||||||
|
spaceId,
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
}: PageImportModalProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Modal.Root
|
||||||
|
opened={open}
|
||||||
|
onClose={onClose}
|
||||||
|
size={600}
|
||||||
|
padding="xl"
|
||||||
|
yOffset="10vh"
|
||||||
|
xOffset={0}
|
||||||
|
mah={400}
|
||||||
|
>
|
||||||
|
<Modal.Overlay />
|
||||||
|
<Modal.Content style={{ overflow: "hidden" }}>
|
||||||
|
<Modal.Header py={0}>
|
||||||
|
<Modal.Title fw={500}>Import pages</Modal.Title>
|
||||||
|
<Modal.CloseButton />
|
||||||
|
</Modal.Header>
|
||||||
|
<Modal.Body>
|
||||||
|
<ImportFormatSelection spaceId={spaceId} onClose={onClose} />
|
||||||
|
</Modal.Body>
|
||||||
|
</Modal.Content>
|
||||||
|
</Modal.Root>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportFormatSelection {
|
||||||
|
spaceId: string;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
|
||||||
|
const [treeData, setTreeData] = useAtom(treeDataAtom);
|
||||||
|
|
||||||
|
const handleFileUpload = async (selectedFiles: File[]) => {
|
||||||
|
if (!selectedFiles) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onClose();
|
||||||
|
|
||||||
|
const alert = notifications.show({
|
||||||
|
title: "Importing pages",
|
||||||
|
message: "Page import is in progress. Please do not close this tab.",
|
||||||
|
loading: true,
|
||||||
|
autoClose: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const pages: IPage[] = [];
|
||||||
|
let pageCount = 0;
|
||||||
|
|
||||||
|
for (const file of selectedFiles) {
|
||||||
|
try {
|
||||||
|
const page = await importPage(file, spaceId);
|
||||||
|
pages.push(page);
|
||||||
|
pageCount += 1;
|
||||||
|
} catch (err) {
|
||||||
|
console.log("Failed to import page", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pages?.length > 0 && pageCount > 0) {
|
||||||
|
const newTreeNodes = buildTree(pages);
|
||||||
|
const fullTree = treeData.concat(newTreeNodes);
|
||||||
|
|
||||||
|
if (newTreeNodes?.length && fullTree?.length > 0) {
|
||||||
|
setTreeData(fullTree);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pageCountText = pageCount === 1 ? "1 page" : `${pageCount} pages`;
|
||||||
|
|
||||||
|
notifications.update({
|
||||||
|
id: alert,
|
||||||
|
color: "teal",
|
||||||
|
title: `Successfully imported ${pageCountText}`,
|
||||||
|
message: "Your import is complete.",
|
||||||
|
icon: <IconCheck size={18} />,
|
||||||
|
loading: false,
|
||||||
|
autoClose: 5000,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
notifications.update({
|
||||||
|
id: alert,
|
||||||
|
color: "red",
|
||||||
|
title: `Failed to import pages`,
|
||||||
|
message: "Unable to import pages. Please try again.",
|
||||||
|
icon: <IconX size={18} />,
|
||||||
|
loading: false,
|
||||||
|
autoClose: 5000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SimpleGrid cols={2}>
|
||||||
|
<FileButton onChange={handleFileUpload} accept="text/markdown" multiple>
|
||||||
|
{(props) => (
|
||||||
|
<Button
|
||||||
|
justify="start"
|
||||||
|
variant="default"
|
||||||
|
leftSection={<IconMarkdown size={18} />}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
Markdown
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</FileButton>
|
||||||
|
|
||||||
|
<FileButton onChange={handleFileUpload} accept="text/html" multiple>
|
||||||
|
{(props) => (
|
||||||
|
<Button
|
||||||
|
justify="start"
|
||||||
|
variant="default"
|
||||||
|
leftSection={<IconFileCode size={18} />}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
HTML
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</FileButton>
|
||||||
|
</SimpleGrid>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,11 +1,13 @@
|
|||||||
import api from "@/lib/api-client";
|
import api from "@/lib/api-client";
|
||||||
import {
|
import {
|
||||||
|
IExportPageParams,
|
||||||
IMovePage,
|
IMovePage,
|
||||||
IPage,
|
IPage,
|
||||||
IPageInput,
|
IPageInput,
|
||||||
SidebarPagesParams,
|
SidebarPagesParams,
|
||||||
} from "@/features/page/types/page.types";
|
} from "@/features/page/types/page.types";
|
||||||
import { IAttachment, IPagination } from "@/lib/types.ts";
|
import { IAttachment, IPagination } from "@/lib/types.ts";
|
||||||
|
import { saveAs } from "file-saver";
|
||||||
|
|
||||||
export async function createPage(data: Partial<IPage>): Promise<IPage> {
|
export async function createPage(data: Partial<IPage>): Promise<IPage> {
|
||||||
const req = await api.post<IPage>("/pages/create", data);
|
const req = await api.post<IPage>("/pages/create", data);
|
||||||
@ -53,18 +55,46 @@ export async function getRecentChanges(
|
|||||||
return req.data;
|
return req.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function uploadFile(file: File, pageId: string) {
|
export async function exportPage(data: IExportPageParams): Promise<void> {
|
||||||
|
const req = await api.post("/pages/export", data, {
|
||||||
|
responseType: "blob",
|
||||||
|
});
|
||||||
|
|
||||||
|
const fileName = req?.headers["content-disposition"]
|
||||||
|
.split("filename=")[1]
|
||||||
|
.replace(/"/g, "");
|
||||||
|
|
||||||
|
saveAs(req.data, fileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function importPage(file: File, spaceId: string) {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
|
formData.append("spaceId", spaceId);
|
||||||
|
formData.append("file", file);
|
||||||
|
|
||||||
|
const req = await api.post<IPage>("/pages/import", formData, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "multipart/form-data",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return req.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uploadFile(file: File, pageId: string, attachmentId?: string): Promise<IAttachment> {
|
||||||
|
const formData = new FormData();
|
||||||
|
if(attachmentId){
|
||||||
|
formData.append("attachmentId", attachmentId);
|
||||||
|
}
|
||||||
formData.append("pageId", pageId);
|
formData.append("pageId", pageId);
|
||||||
formData.append("file", file);
|
formData.append("file", file);
|
||||||
|
|
||||||
// should be file endpoint
|
|
||||||
const req = await api.post<IAttachment>("/files/upload", formData, {
|
const req = await api.post<IAttachment>("/files/upload", formData, {
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "multipart/form-data",
|
"Content-Type": "multipart/form-data",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
// console.log("req", req);
|
|
||||||
|
|
||||||
return req;
|
return req as unknown as IAttachment;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -447,9 +447,11 @@ function NodeMenu({ node, treeApi }: NodeMenuProps) {
|
|||||||
leftSection={
|
leftSection={
|
||||||
<IconTrash style={{ width: rem(14), height: rem(14) }} />
|
<IconTrash style={{ width: rem(14), height: rem(14) }} />
|
||||||
}
|
}
|
||||||
onClick={() =>
|
onClick={(e) => {
|
||||||
openDeleteModal({ onConfirm: () => treeApi?.delete(node) })
|
e.preventDefault();
|
||||||
}
|
e.stopPropagation();
|
||||||
|
openDeleteModal({ onConfirm: () => treeApi?.delete(node) });
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Delete
|
Delete
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
|
|||||||
@ -44,3 +44,13 @@ export interface IPageInput {
|
|||||||
coverPhoto: string;
|
coverPhoto: string;
|
||||||
position: string;
|
position: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface IExportPageParams {
|
||||||
|
pageId: string;
|
||||||
|
format: ExportFormat;
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum ExportFormat {
|
||||||
|
HTML = "html",
|
||||||
|
Markdown = "markdown",
|
||||||
|
}
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import { Group, Center, Text } from "@mantine/core";
|
|||||||
import { Spotlight } from "@mantine/spotlight";
|
import { Spotlight } from "@mantine/spotlight";
|
||||||
import { IconFileDescription, IconSearch } from "@tabler/icons-react";
|
import { IconFileDescription, IconSearch } from "@tabler/icons-react";
|
||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { useDebouncedValue } from "@mantine/hooks";
|
import { useDebouncedValue } from "@mantine/hooks";
|
||||||
import { usePageSearchQuery } from "@/features/search/queries/search-query";
|
import { usePageSearchQuery } from "@/features/search/queries/search-query";
|
||||||
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { Modal, Tabs, rem, Group, Divider, ScrollArea } from "@mantine/core";
|
import { Modal, Tabs, rem, Group, ScrollArea } from "@mantine/core";
|
||||||
import SpaceMembersList from "@/features/space/components/space-members.tsx";
|
import SpaceMembersList from "@/features/space/components/space-members.tsx";
|
||||||
import AddSpaceMembersModal from "@/features/space/components/add-space-members-modal.tsx";
|
import AddSpaceMembersModal from "@/features/space/components/add-space-members-modal.tsx";
|
||||||
import React, { useMemo } from "react";
|
import React, { useMemo } from "react";
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { UnstyledButton, Group, Avatar, Text, rem } from "@mantine/core";
|
import { UnstyledButton, Group, Text } from "@mantine/core";
|
||||||
import classes from "./space-name.module.css";
|
import classes from "./space-name.module.css";
|
||||||
|
|
||||||
interface SpaceNameProps {
|
interface SpaceNameProps {
|
||||||
|
|||||||
@ -1,13 +1,15 @@
|
|||||||
import {
|
import {
|
||||||
ActionIcon,
|
ActionIcon,
|
||||||
Group,
|
Group,
|
||||||
rem,
|
Menu,
|
||||||
Text,
|
Text,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
UnstyledButton,
|
UnstyledButton,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { spotlight } from "@mantine/spotlight";
|
import { spotlight } from "@mantine/spotlight";
|
||||||
import {
|
import {
|
||||||
|
IconArrowDown,
|
||||||
|
IconDots,
|
||||||
IconHome,
|
IconHome,
|
||||||
IconPlus,
|
IconPlus,
|
||||||
IconSearch,
|
IconSearch,
|
||||||
@ -32,6 +34,7 @@ import {
|
|||||||
SpaceCaslAction,
|
SpaceCaslAction,
|
||||||
SpaceCaslSubject,
|
SpaceCaslSubject,
|
||||||
} from "@/features/space/permissions/permissions.type.ts";
|
} from "@/features/space/permissions/permissions.type.ts";
|
||||||
|
import PageImportModal from "@/features/page/components/page-import-modal.tsx";
|
||||||
|
|
||||||
export function SpaceSidebar() {
|
export function SpaceSidebar() {
|
||||||
const [tree] = useAtom(treeApiAtom);
|
const [tree] = useAtom(treeApiAtom);
|
||||||
@ -140,18 +143,20 @@ export function SpaceSidebar() {
|
|||||||
SpaceCaslAction.Manage,
|
SpaceCaslAction.Manage,
|
||||||
SpaceCaslSubject.Page,
|
SpaceCaslSubject.Page,
|
||||||
) && (
|
) && (
|
||||||
|
<Group gap="xs">
|
||||||
|
<SpaceMenu spaceId={space.id} onSpaceSettings={openSettings} />
|
||||||
|
|
||||||
<Tooltip label="Create page" withArrow position="right">
|
<Tooltip label="Create page" withArrow position="right">
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant="default"
|
variant="default"
|
||||||
size={18}
|
size={18}
|
||||||
onClick={handleCreatePage}
|
onClick={handleCreatePage}
|
||||||
|
aria-label="Create page"
|
||||||
>
|
>
|
||||||
<IconPlus
|
<IconPlus />
|
||||||
style={{ width: rem(12), height: rem(12) }}
|
|
||||||
stroke={1.5}
|
|
||||||
/>
|
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
</Group>
|
||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
@ -177,3 +182,54 @@ export function SpaceSidebar() {
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface SpaceMenuProps {
|
||||||
|
spaceId: string;
|
||||||
|
onSpaceSettings: () => void;
|
||||||
|
}
|
||||||
|
function SpaceMenu({ spaceId, onSpaceSettings }: SpaceMenuProps) {
|
||||||
|
const [importOpened, { open: openImportModal, close: closeImportModal }] =
|
||||||
|
useDisclosure(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Menu width={200} shadow="md" withArrow>
|
||||||
|
<Menu.Target>
|
||||||
|
<Tooltip
|
||||||
|
label="Import pages & space settings"
|
||||||
|
withArrow
|
||||||
|
position="top"
|
||||||
|
>
|
||||||
|
<ActionIcon variant="default" size={18} aria-label="Space menu">
|
||||||
|
<IconDots />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
</Menu.Target>
|
||||||
|
|
||||||
|
<Menu.Dropdown>
|
||||||
|
<Menu.Item
|
||||||
|
onClick={openImportModal}
|
||||||
|
leftSection={<IconArrowDown size={16} />}
|
||||||
|
>
|
||||||
|
Import pages
|
||||||
|
</Menu.Item>
|
||||||
|
|
||||||
|
<Menu.Divider />
|
||||||
|
|
||||||
|
<Menu.Item
|
||||||
|
onClick={onSpaceSettings}
|
||||||
|
leftSection={<IconSettings size={16} />}
|
||||||
|
>
|
||||||
|
Space settings
|
||||||
|
</Menu.Item>
|
||||||
|
</Menu.Dropdown>
|
||||||
|
</Menu>
|
||||||
|
|
||||||
|
<PageImportModal
|
||||||
|
spaceId={spaceId}
|
||||||
|
open={importOpened}
|
||||||
|
onClose={closeImportModal}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import React, { useState } from "react";
|
|||||||
import { useGetSpacesQuery } from "@/features/space/queries/space-query.ts";
|
import { useGetSpacesQuery } from "@/features/space/queries/space-query.ts";
|
||||||
import SpaceSettingsModal from "@/features/space/components/settings-modal.tsx";
|
import SpaceSettingsModal from "@/features/space/components/settings-modal.tsx";
|
||||||
import { useDisclosure } from "@mantine/hooks";
|
import { useDisclosure } from "@mantine/hooks";
|
||||||
|
import { formatMemberCount } from "@/lib";
|
||||||
|
|
||||||
export default function SpaceList() {
|
export default function SpaceList() {
|
||||||
const { data, isLoading } = useGetSpacesQuery();
|
const { data, isLoading } = useGetSpacesQuery();
|
||||||
@ -50,7 +51,7 @@ export default function SpaceList() {
|
|||||||
</Group>
|
</Group>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
|
|
||||||
<Table.Td>{space.memberCount} members</Table.Td>
|
<Table.Td>{formatMemberCount(space.memberCount)}</Table.Td>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
))}
|
))}
|
||||||
</Table.Tbody>
|
</Table.Tbody>
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
import { Group, Table, Text, Menu, ActionIcon } from "@mantine/core";
|
import { Group, Table, Text, Menu, ActionIcon } from "@mantine/core";
|
||||||
import { useParams } from "react-router-dom";
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { IconDots } from "@tabler/icons-react";
|
import { IconDots } from "@tabler/icons-react";
|
||||||
import { modals } from "@mantine/modals";
|
import { modals } from "@mantine/modals";
|
||||||
|
|||||||
@ -95,9 +95,11 @@ function ChangePasswordForm({ onClose }: ChangePasswordFormProps) {
|
|||||||
{...form.getInputProps("newPassword")}
|
{...form.getInputProps("newPassword")}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Group justify="flex-end" mt="md">
|
||||||
<Button type="submit" disabled={isLoading} loading={isLoading}>
|
<Button type="submit" disabled={isLoading} loading={isLoading}>
|
||||||
Change password
|
Change password
|
||||||
</Button>
|
</Button>
|
||||||
|
</Group>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
export const SOCKET_URL = import.meta.env.DEV
|
export const SOCKET_URL = import.meta.env.DEV
|
||||||
? "http://localhost:3000"
|
? process.env.APP_URL
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
@ -1,4 +1,4 @@
|
|||||||
import { Group, Table, Avatar, Text, Badge, Alert } from "@mantine/core";
|
import { Group, Table, Avatar, Text, Alert } from "@mantine/core";
|
||||||
import { useWorkspaceInvitationsQuery } from "@/features/workspace/queries/workspace-query.ts";
|
import { useWorkspaceInvitationsQuery } from "@/features/workspace/queries/workspace-query.ts";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { getUserRoleLabel } from "@/features/workspace/types/user-role-data.ts";
|
import { getUserRoleLabel } from "@/features/workspace/types/user-role-data.ts";
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { Group, Table, Avatar, Text, Badge } from "@mantine/core";
|
import { Group, Table, Text, Badge } from "@mantine/core";
|
||||||
import {
|
import {
|
||||||
useChangeMemberRoleMutation,
|
useChangeMemberRoleMutation,
|
||||||
useWorkspaceMembersQuery,
|
useWorkspaceMembersQuery,
|
||||||
@ -11,11 +11,14 @@ import {
|
|||||||
userRoleData,
|
userRoleData,
|
||||||
} from "@/features/workspace/types/user-role-data.ts";
|
} from "@/features/workspace/types/user-role-data.ts";
|
||||||
import useUserRole from "@/hooks/use-user-role.tsx";
|
import useUserRole from "@/hooks/use-user-role.tsx";
|
||||||
|
import { UserRole } from "@/lib/types.ts";
|
||||||
|
|
||||||
export default function WorkspaceMembersTable() {
|
export default function WorkspaceMembersTable() {
|
||||||
const { data, isLoading } = useWorkspaceMembersQuery({ limit: 100 });
|
const { data, isLoading } = useWorkspaceMembersQuery({ limit: 100 });
|
||||||
const changeMemberRoleMutation = useChangeMemberRoleMutation();
|
const changeMemberRoleMutation = useChangeMemberRoleMutation();
|
||||||
const { isAdmin } = useUserRole();
|
const { isAdmin, isOwner } = useUserRole();
|
||||||
|
|
||||||
|
const assignableUserRoles = isOwner ? userRoleData : userRoleData.filter((role) => role.value !== UserRole.OWNER);
|
||||||
|
|
||||||
const handleRoleChange = async (
|
const handleRoleChange = async (
|
||||||
userId: string,
|
userId: string,
|
||||||
@ -69,7 +72,7 @@ export default function WorkspaceMembersTable() {
|
|||||||
|
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<RoleSelectMenu
|
<RoleSelectMenu
|
||||||
roles={userRoleData}
|
roles={assignableUserRoles}
|
||||||
roleName={getUserRoleLabel(user.role)}
|
roleName={getUserRoleLabel(user.role)}
|
||||||
onChange={(newRole) =>
|
onChange={(newRole) =>
|
||||||
handleRoleChange(user.id, user.role, newRole)
|
handleRoleChange(user.id, user.role, newRole)
|
||||||
|
|||||||
@ -53,9 +53,10 @@ export function useChangeMemberRoleMutation() {
|
|||||||
return useMutation<any, Error, any>({
|
return useMutation<any, Error, any>({
|
||||||
mutationFn: (data) => changeMemberRole(data),
|
mutationFn: (data) => changeMemberRole(data),
|
||||||
onSuccess: (data, variables) => {
|
onSuccess: (data, variables) => {
|
||||||
|
// TODO: change in cache instead
|
||||||
notifications.show({ message: "Member role updated successfully" });
|
notifications.show({ message: "Member role updated successfully" });
|
||||||
queryClient.refetchQueries({
|
queryClient.refetchQueries({
|
||||||
queryKey: ["workspaceMembers", variables.spaceId],
|
queryKey: ["workspaceMembers"],
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
|
|||||||
@ -1,10 +1,9 @@
|
|||||||
import axios, { AxiosInstance } from "axios";
|
import axios, { AxiosInstance } from "axios";
|
||||||
import Cookies from "js-cookie";
|
import Cookies from "js-cookie";
|
||||||
import Routes from "@/lib/app-route.ts";
|
import Routes from "@/lib/app-route.ts";
|
||||||
import { getBackendUrl } from "@/lib/config.ts";
|
|
||||||
|
|
||||||
const api: AxiosInstance = axios.create({
|
const api: AxiosInstance = axios.create({
|
||||||
baseURL: getBackendUrl(),
|
baseURL: "/api",
|
||||||
withCredentials: true,
|
withCredentials: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -32,6 +31,11 @@ api.interceptors.request.use(
|
|||||||
|
|
||||||
api.interceptors.response.use(
|
api.interceptors.response.use(
|
||||||
(response) => {
|
(response) => {
|
||||||
|
// we need the response headers
|
||||||
|
if (response.request.responseURL.includes("/api/pages/export")) {
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
(error) => {
|
(error) => {
|
||||||
|
|||||||
@ -5,24 +5,29 @@ declare global {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getAppUrl(): string {
|
export function getAppUrl(): string {
|
||||||
let appUrl = window.CONFIG?.APP_URL || process.env.APP_URL;
|
//let appUrl = window.CONFIG?.APP_URL || process.env.APP_URL;
|
||||||
|
|
||||||
if (import.meta.env.DEV) {
|
// if (import.meta.env.DEV) {
|
||||||
return appUrl || "http://localhost:3000";
|
// return appUrl || "http://localhost:3000";
|
||||||
}
|
//}
|
||||||
|
|
||||||
return `${window.location.protocol}//${window.location.host}`;
|
return `${window.location.protocol}//${window.location.host}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getBackendUrl(): string {
|
export function getBackendUrl(): string {
|
||||||
return getAppUrl() + "/api";
|
return getAppUrl() + '/api';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getCollaborationUrl(): string {
|
export function getCollaborationUrl(): string {
|
||||||
const COLLAB_PATH = "/collab";
|
const COLLAB_PATH = '/collab';
|
||||||
|
|
||||||
const wsProtocol = getAppUrl().startsWith("https") ? "wss" : "ws";
|
let url = getAppUrl();
|
||||||
return `${wsProtocol}://${getAppUrl().split("://")[1]}${COLLAB_PATH}`;
|
if (import.meta.env.DEV) {
|
||||||
|
url = process.env.APP_URL;
|
||||||
|
}
|
||||||
|
|
||||||
|
const wsProtocol = url.startsWith('https') ? 'wss' : 'ws';
|
||||||
|
return `${wsProtocol}://${url.split('://')[1]}${COLLAB_PATH}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getAvatarUrl(avatarUrl: string) {
|
export function getAvatarUrl(avatarUrl: string) {
|
||||||
@ -30,17 +35,17 @@ export function getAvatarUrl(avatarUrl: string) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (avatarUrl.startsWith("http")) {
|
if (avatarUrl?.startsWith('http')) {
|
||||||
return avatarUrl;
|
return avatarUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
return getBackendUrl() + "/attachments/img/avatar/" + avatarUrl;
|
return getBackendUrl() + '/attachments/img/avatar/' + avatarUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getSpaceUrl(spaceSlug: string) {
|
export function getSpaceUrl(spaceSlug: string) {
|
||||||
return "/s/" + spaceSlug;
|
return '/s/' + spaceSlug;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getFileUrl(src: string) {
|
export function getFileUrl(src: string) {
|
||||||
return src.startsWith("/files/") ? getBackendUrl() + src : src;
|
return src?.startsWith('/files/') ? getBackendUrl() + src : src;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -22,6 +22,10 @@ export interface IRoleData {
|
|||||||
description: string;
|
description: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ApiResponse<T> {
|
||||||
|
data: T;
|
||||||
|
}
|
||||||
|
|
||||||
export type IPaginationMeta = {
|
export type IPaginationMeta = {
|
||||||
limit: number;
|
limit: number;
|
||||||
page: number;
|
page: number;
|
||||||
|
|||||||
@ -25,3 +25,39 @@ export const computeSpaceSlug = (name: string) => {
|
|||||||
return alphanumericName.toLowerCase();
|
return alphanumericName.toLowerCase();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const formatBytes = (
|
||||||
|
bytes: number,
|
||||||
|
decimalPlaces: number = 2,
|
||||||
|
): string => {
|
||||||
|
if (bytes === 0) return "0.0 KB";
|
||||||
|
|
||||||
|
const unitSize = 1024;
|
||||||
|
const precision = decimalPlaces < 0 ? 0 : decimalPlaces;
|
||||||
|
const units = ["KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
|
||||||
|
|
||||||
|
const kilobytes = bytes / unitSize;
|
||||||
|
|
||||||
|
const unitIndex = Math.floor(Math.log(kilobytes) / Math.log(unitSize));
|
||||||
|
const adjustedUnitIndex = Math.max(unitIndex, 0);
|
||||||
|
const adjustedSize = kilobytes / Math.pow(unitSize, adjustedUnitIndex);
|
||||||
|
|
||||||
|
return `${adjustedSize.toFixed(precision)} ${units[adjustedUnitIndex]}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function svgStringToFile(
|
||||||
|
svgString: string,
|
||||||
|
fileName: string,
|
||||||
|
): Promise<File> {
|
||||||
|
const blob = new Blob([svgString], { type: "image/svg+xml" });
|
||||||
|
return new File([blob], fileName, { type: "image/svg+xml" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decodeBase64ToSvgString(base64Data: string): string {
|
||||||
|
const base64Prefix = 'data:image/svg+xml;base64,';
|
||||||
|
if (base64Data.startsWith(base64Prefix)) {
|
||||||
|
base64Data = base64Data.replace(base64Prefix, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
return atob(base64Data);
|
||||||
|
}
|
||||||
|
|||||||
@ -22,6 +22,7 @@ export const queryClient = new QueryClient({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
const root = ReactDOM.createRoot(
|
const root = ReactDOM.createRoot(
|
||||||
document.getElementById("root") as HTMLElement,
|
document.getElementById("root") as HTMLElement,
|
||||||
);
|
);
|
||||||
|
|||||||
1
apps/client/src/vite-env.d.ts
vendored
1
apps/client/src/vite-env.d.ts
vendored
@ -1 +1,2 @@
|
|||||||
/// <reference types="vite/client" />
|
/// <reference types="vite/client" />
|
||||||
|
declare const APP_VERSION: string
|
||||||
|
|||||||
@ -12,6 +12,7 @@ export default defineConfig(({ mode }) => {
|
|||||||
"process.env": {
|
"process.env": {
|
||||||
APP_URL,
|
APP_URL,
|
||||||
},
|
},
|
||||||
|
'APP_VERSION': JSON.stringify(process.env.npm_package_version),
|
||||||
},
|
},
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
resolve: {
|
resolve: {
|
||||||
@ -19,5 +20,13 @@ export default defineConfig(({ mode }) => {
|
|||||||
"@": "/src",
|
"@": "/src",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
"/api": {
|
||||||
|
target: APP_URL,
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "server",
|
"name": "server",
|
||||||
"version": "0.2.3",
|
"version": "0.3.1",
|
||||||
"description": "",
|
"description": "",
|
||||||
"author": "",
|
"author": "",
|
||||||
"private": true,
|
"private": true,
|
||||||
@ -28,37 +28,38 @@
|
|||||||
"test:e2e": "jest --config test/jest-e2e.json"
|
"test:e2e": "jest --config test/jest-e2e.json"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-s3": "^3.600.0",
|
"@aws-sdk/client-s3": "^3.637.0",
|
||||||
"@aws-sdk/s3-request-presigner": "^3.600.0",
|
"@aws-sdk/s3-request-presigner": "^3.637.0",
|
||||||
"@casl/ability": "^6.7.1",
|
"@casl/ability": "^6.7.1",
|
||||||
"@fastify/cookie": "^9.3.1",
|
"@fastify/cookie": "^9.4.0",
|
||||||
"@fastify/multipart": "^8.3.0",
|
"@fastify/multipart": "^8.3.0",
|
||||||
"@fastify/static": "^7.0.4",
|
"@fastify/static": "^7.0.4",
|
||||||
"@nestjs/bullmq": "^10.1.1",
|
"@nestjs/bullmq": "^10.2.1",
|
||||||
"@nestjs/common": "^10.3.9",
|
"@nestjs/common": "^10.4.1",
|
||||||
"@nestjs/config": "^3.2.2",
|
"@nestjs/config": "^3.2.3",
|
||||||
"@nestjs/core": "^10.3.9",
|
"@nestjs/core": "^10.4.1",
|
||||||
"@nestjs/event-emitter": "^2.0.4",
|
"@nestjs/event-emitter": "^2.0.4",
|
||||||
"@nestjs/jwt": "^10.2.0",
|
"@nestjs/jwt": "^10.2.0",
|
||||||
"@nestjs/mapped-types": "^2.0.5",
|
"@nestjs/mapped-types": "^2.0.5",
|
||||||
"@nestjs/passport": "^10.0.3",
|
"@nestjs/passport": "^10.0.3",
|
||||||
"@nestjs/platform-fastify": "^10.3.9",
|
"@nestjs/platform-fastify": "^10.4.1",
|
||||||
"@nestjs/platform-socket.io": "^10.3.9",
|
"@nestjs/platform-socket.io": "^10.4.1",
|
||||||
"@nestjs/websockets": "^10.3.9",
|
"@nestjs/terminus": "^10.2.3",
|
||||||
"@react-email/components": "0.0.19",
|
"@nestjs/websockets": "^10.4.1",
|
||||||
"@react-email/render": "^0.0.15",
|
"@react-email/components": "0.0.24",
|
||||||
|
"@react-email/render": "^1.0.1",
|
||||||
"@socket.io/redis-adapter": "^8.3.0",
|
"@socket.io/redis-adapter": "^8.3.0",
|
||||||
"@types/pg": "^8.11.6",
|
|
||||||
"bcrypt": "^5.1.1",
|
"bcrypt": "^5.1.1",
|
||||||
"bullmq": "^5.8.2",
|
"bullmq": "^5.12.12",
|
||||||
"bytes": "^3.1.2",
|
"bytes": "^3.1.2",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.14.1",
|
"class-validator": "^0.14.1",
|
||||||
"fastify": "^4.28.0",
|
|
||||||
"fix-esm": "^1.0.1",
|
"fix-esm": "^1.0.1",
|
||||||
"fs-extra": "^11.2.0",
|
"fs-extra": "^11.2.0",
|
||||||
"kysely": "^0.27.3",
|
"happy-dom": "^15.7.3",
|
||||||
|
"kysely": "^0.27.4",
|
||||||
"kysely-migration-cli": "^0.4.2",
|
"kysely-migration-cli": "^0.4.2",
|
||||||
|
"marked": "^13.0.3",
|
||||||
"mime-types": "^2.1.35",
|
"mime-types": "^2.1.35",
|
||||||
"nanoid": "^5.0.7",
|
"nanoid": "^5.0.7",
|
||||||
"nestjs-kysely": "^1.0.0",
|
"nestjs-kysely": "^1.0.0",
|
||||||
@ -66,46 +67,47 @@
|
|||||||
"passport-jwt": "^4.0.1",
|
"passport-jwt": "^4.0.1",
|
||||||
"pg": "^8.12.0",
|
"pg": "^8.12.0",
|
||||||
"pg-tsquery": "^8.4.2",
|
"pg-tsquery": "^8.4.2",
|
||||||
"postmark": "^4.0.4",
|
"postmark": "^4.0.5",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"redis": "^4.6.14",
|
"redis": "^4.7.0",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
"sanitize-filename-ts": "^1.0.2",
|
"sanitize-filename-ts": "^1.0.2",
|
||||||
"socket.io": "^4.7.5",
|
"socket.io": "^4.7.5",
|
||||||
"ws": "^8.17.1"
|
"ws": "^8.18.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@nestjs/cli": "^10.3.2",
|
"@nestjs/cli": "^10.4.5",
|
||||||
"@nestjs/schematics": "^10.1.1",
|
"@nestjs/schematics": "^10.1.4",
|
||||||
"@nestjs/testing": "^10.3.9",
|
"@nestjs/testing": "^10.4.1",
|
||||||
"@types/bcrypt": "^5.0.2",
|
"@types/bcrypt": "^5.0.2",
|
||||||
"@types/bytes": "^3.1.4",
|
"@types/bytes": "^3.1.4",
|
||||||
"@types/debounce": "^1.2.4",
|
"@types/debounce": "^1.2.4",
|
||||||
"@types/fs-extra": "^11.0.4",
|
"@types/fs-extra": "^11.0.4",
|
||||||
"@types/jest": "^29.5.12",
|
"@types/jest": "^29.5.12",
|
||||||
"@types/mime-types": "^2.1.4",
|
"@types/mime-types": "^2.1.4",
|
||||||
"@types/node": "^20.14.9",
|
"@types/node": "^22.5.2",
|
||||||
"@types/nodemailer": "^6.4.15",
|
"@types/nodemailer": "^6.4.15",
|
||||||
"@types/passport-jwt": "^4.0.1",
|
"@types/passport-jwt": "^4.0.1",
|
||||||
|
"@types/pg": "^8.11.8",
|
||||||
"@types/supertest": "^6.0.2",
|
"@types/supertest": "^6.0.2",
|
||||||
"@types/ws": "^8.5.10",
|
"@types/ws": "^8.5.12",
|
||||||
"@typescript-eslint/eslint-plugin": "^7.14.1",
|
"@typescript-eslint/eslint-plugin": "^8.3.0",
|
||||||
"@typescript-eslint/parser": "^7.14.1",
|
"@typescript-eslint/parser": "^8.3.0",
|
||||||
"eslint": "^9.5.0",
|
"eslint": "^9.9.1",
|
||||||
"eslint-config-prettier": "^9.1.0",
|
"eslint-config-prettier": "^9.1.0",
|
||||||
"eslint-plugin-prettier": "^5.1.3",
|
"eslint-plugin-prettier": "^5.2.1",
|
||||||
"jest": "^29.7.0",
|
"jest": "^29.7.0",
|
||||||
"kysely-codegen": "^0.15.0",
|
"kysely-codegen": "^0.16.3",
|
||||||
"prettier": "^3.3.2",
|
"prettier": "^3.3.3",
|
||||||
"react-email": "^2.1.4",
|
"react-email": "^3.0.1",
|
||||||
"source-map-support": "^0.5.21",
|
"source-map-support": "^0.5.21",
|
||||||
"supertest": "^7.0.0",
|
"supertest": "^7.0.0",
|
||||||
"ts-jest": "^29.1.5",
|
"ts-jest": "^29.2.5",
|
||||||
"ts-loader": "^9.5.1",
|
"ts-loader": "^9.5.1",
|
||||||
"ts-node": "^10.9.2",
|
"ts-node": "^10.9.2",
|
||||||
"tsconfig-paths": "^4.2.0",
|
"tsconfig-paths": "^4.2.0",
|
||||||
"typescript": "^5.5.2"
|
"typescript": "^5.5.4"
|
||||||
},
|
},
|
||||||
"jest": {
|
"jest": {
|
||||||
"moduleFileExtensions": [
|
"moduleFileExtensions": [
|
||||||
|
|||||||
@ -11,6 +11,9 @@ import { MailModule } from './integrations/mail/mail.module';
|
|||||||
import { QueueModule } from './integrations/queue/queue.module';
|
import { QueueModule } from './integrations/queue/queue.module';
|
||||||
import { StaticModule } from './integrations/static/static.module';
|
import { StaticModule } from './integrations/static/static.module';
|
||||||
import { EventEmitterModule } from '@nestjs/event-emitter';
|
import { EventEmitterModule } from '@nestjs/event-emitter';
|
||||||
|
import { HealthModule } from './integrations/health/health.module';
|
||||||
|
import { ExportModule } from './integrations/export/export.module';
|
||||||
|
import { ImportModule } from './integrations/import/import.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@ -21,6 +24,9 @@ import { EventEmitterModule } from '@nestjs/event-emitter';
|
|||||||
WsModule,
|
WsModule,
|
||||||
QueueModule,
|
QueueModule,
|
||||||
StaticModule,
|
StaticModule,
|
||||||
|
HealthModule,
|
||||||
|
ImportModule,
|
||||||
|
ExportModule,
|
||||||
StorageModule.forRootAsync({
|
StorageModule.forRootAsync({
|
||||||
imports: [EnvironmentModule],
|
imports: [EnvironmentModule],
|
||||||
}),
|
}),
|
||||||
|
|||||||
@ -10,28 +10,38 @@ import { Typography } from '@tiptap/extension-typography';
|
|||||||
import { TextStyle } from '@tiptap/extension-text-style';
|
import { TextStyle } from '@tiptap/extension-text-style';
|
||||||
import { Color } from '@tiptap/extension-color';
|
import { Color } from '@tiptap/extension-color';
|
||||||
import { Youtube } from '@tiptap/extension-youtube';
|
import { Youtube } from '@tiptap/extension-youtube';
|
||||||
|
import Table from '@tiptap/extension-table';
|
||||||
|
import TableHeader from '@tiptap/extension-table-header';
|
||||||
import {
|
import {
|
||||||
Callout,
|
Callout,
|
||||||
Comment,
|
Comment,
|
||||||
|
CustomCodeBlock,
|
||||||
Details,
|
Details,
|
||||||
DetailsContent,
|
DetailsContent,
|
||||||
DetailsSummary,
|
DetailsSummary,
|
||||||
LinkExtension,
|
LinkExtension,
|
||||||
MathBlock,
|
MathBlock,
|
||||||
MathInline,
|
MathInline,
|
||||||
Table,
|
|
||||||
TableCell,
|
TableCell,
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
TableRow,
|
||||||
TiptapImage,
|
TiptapImage,
|
||||||
TiptapVideo,
|
TiptapVideo,
|
||||||
TrailingNode,
|
TrailingNode,
|
||||||
|
Attachment,
|
||||||
|
Drawio,
|
||||||
|
Excalidraw,
|
||||||
} from '@docmost/editor-ext';
|
} from '@docmost/editor-ext';
|
||||||
import { generateHTML, generateJSON } from '@tiptap/html';
|
|
||||||
import { generateText, JSONContent } from '@tiptap/core';
|
import { generateText, JSONContent } from '@tiptap/core';
|
||||||
|
import { generateHTML } from '../common/helpers/prosemirror/html';
|
||||||
|
// @tiptap/html library works best for generating prosemirror json state but not HTML
|
||||||
|
// see: https://github.com/ueberdosis/tiptap/issues/5352
|
||||||
|
// see:https://github.com/ueberdosis/tiptap/issues/4089
|
||||||
|
import { generateJSON } from '@tiptap/html';
|
||||||
|
|
||||||
export const tiptapExtensions = [
|
export const tiptapExtensions = [
|
||||||
StarterKit,
|
StarterKit.configure({
|
||||||
|
codeBlock: false,
|
||||||
|
}),
|
||||||
Comment,
|
Comment,
|
||||||
TextAlign,
|
TextAlign,
|
||||||
TaskList,
|
TaskList,
|
||||||
@ -58,9 +68,13 @@ export const tiptapExtensions = [
|
|||||||
TiptapImage,
|
TiptapImage,
|
||||||
TiptapVideo,
|
TiptapVideo,
|
||||||
Callout,
|
Callout,
|
||||||
|
Attachment,
|
||||||
|
CustomCodeBlock,
|
||||||
|
Drawio,
|
||||||
|
Excalidraw,
|
||||||
] as any;
|
] as any;
|
||||||
|
|
||||||
export function jsonToHtml(tiptapJson: JSONContent) {
|
export function jsonToHtml(tiptapJson: any) {
|
||||||
return generateHTML(tiptapJson, tiptapExtensions);
|
return generateHTML(tiptapJson, tiptapExtensions);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,21 @@
|
|||||||
|
import { Extensions, getSchema, JSONContent } from '@tiptap/core';
|
||||||
|
import { DOMSerializer, Node } from '@tiptap/pm/model';
|
||||||
|
import { Window } from 'happy-dom';
|
||||||
|
|
||||||
|
export function generateHTML(doc: JSONContent, extensions: Extensions): string {
|
||||||
|
const schema = getSchema(extensions);
|
||||||
|
const contentNode = Node.fromJSON(schema, doc);
|
||||||
|
|
||||||
|
const window = new Window();
|
||||||
|
|
||||||
|
const fragment = DOMSerializer.fromSchema(schema).serializeFragment(
|
||||||
|
contentNode.content,
|
||||||
|
{
|
||||||
|
document: window.document as unknown as Document,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const serializer = new window.XMLSerializer();
|
||||||
|
// @ts-ignore
|
||||||
|
return serializer.serializeToString(fragment as unknown as Node);
|
||||||
|
}
|
||||||
@ -0,0 +1,17 @@
|
|||||||
|
import { Extensions, getSchema } from '@tiptap/core';
|
||||||
|
import { DOMParser, ParseOptions } from '@tiptap/pm/model';
|
||||||
|
import { Window, DOMParser as HappyDomParser } from 'happy-dom';
|
||||||
|
|
||||||
|
export function generateJSON(
|
||||||
|
html: string,
|
||||||
|
extensions: Extensions,
|
||||||
|
options?: ParseOptions,
|
||||||
|
): Record<string, any> {
|
||||||
|
const schema = getSchema(extensions);
|
||||||
|
|
||||||
|
const window = new Window();
|
||||||
|
const dom = new HappyDomParser().parseFromString(html, 'text/html').body;
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
return DOMParser.fromSchema(schema).parse(dom, options).toJSON();
|
||||||
|
}
|
||||||
2
apps/server/src/common/helpers/prosemirror/html/index.ts
Normal file
2
apps/server/src/common/helpers/prosemirror/html/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export * from './generateHTML.js';
|
||||||
|
export * from './generateJSON.js';
|
||||||
@ -9,7 +9,7 @@ import { Observable } from 'rxjs';
|
|||||||
import { FastifyRequest } from 'fastify';
|
import { FastifyRequest } from 'fastify';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AttachmentInterceptor implements NestInterceptor {
|
export class FileInterceptor implements NestInterceptor {
|
||||||
public intercept(
|
public intercept(
|
||||||
context: ExecutionContext,
|
context: ExecutionContext,
|
||||||
next: CallHandler,
|
next: CallHandler,
|
||||||
@ -16,7 +16,15 @@ export class TransformHttpResponseInterceptor<T>
|
|||||||
intercept(
|
intercept(
|
||||||
context: ExecutionContext,
|
context: ExecutionContext,
|
||||||
next: CallHandler<T>,
|
next: CallHandler<T>,
|
||||||
): Observable<Response<T>> {
|
): Observable<Response<T> | any> {
|
||||||
|
const request = context.switchToHttp().getRequest();
|
||||||
|
const path = request.url;
|
||||||
|
|
||||||
|
// Skip interceptor for the /api/health path
|
||||||
|
if (path === '/api/health') {
|
||||||
|
return next.handle();
|
||||||
|
}
|
||||||
|
|
||||||
return next.handle().pipe(
|
return next.handle().pipe(
|
||||||
map((data) => {
|
map((data) => {
|
||||||
const status = context.switchToHttp().getResponse().statusCode;
|
const status = context.switchToHttp().getResponse().statusCode;
|
||||||
|
|||||||
@ -5,10 +5,10 @@ export enum AttachmentType {
|
|||||||
File = 'file',
|
File = 'file',
|
||||||
}
|
}
|
||||||
|
|
||||||
export const validImageExtensions = ['.jpg', '.png', '.jpeg', 'gif'];
|
export const validImageExtensions = ['.jpg', '.png', '.jpeg'];
|
||||||
export const MAX_AVATAR_SIZE = '5MB';
|
export const MAX_AVATAR_SIZE = '5MB';
|
||||||
|
|
||||||
export const InlineFileExtensions = [
|
export const inlineFileExtensions = [
|
||||||
'.jpg',
|
'.jpg',
|
||||||
'.png',
|
'.png',
|
||||||
'.jpeg',
|
'.jpeg',
|
||||||
@ -16,4 +16,4 @@ export const InlineFileExtensions = [
|
|||||||
'.mp4',
|
'.mp4',
|
||||||
'.mov',
|
'.mov',
|
||||||
];
|
];
|
||||||
export const MAX_FILE_SIZE = '20MB';
|
export const MAX_FILE_SIZE = '50MB';
|
||||||
|
|||||||
@ -16,7 +16,7 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { AttachmentService } from './services/attachment.service';
|
import { AttachmentService } from './services/attachment.service';
|
||||||
import { FastifyReply } from 'fastify';
|
import { FastifyReply } from 'fastify';
|
||||||
import { AttachmentInterceptor } from './interceptors/attachment.interceptor';
|
import { FileInterceptor } from '../../common/interceptors/file.interceptor';
|
||||||
import * as bytes from 'bytes';
|
import * as bytes from 'bytes';
|
||||||
import { AuthUser } from '../../common/decorators/auth-user.decorator';
|
import { AuthUser } from '../../common/decorators/auth-user.decorator';
|
||||||
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
|
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
|
||||||
@ -30,6 +30,7 @@ import {
|
|||||||
import { getMimeType } from '../../common/helpers';
|
import { getMimeType } from '../../common/helpers';
|
||||||
import {
|
import {
|
||||||
AttachmentType,
|
AttachmentType,
|
||||||
|
inlineFileExtensions,
|
||||||
MAX_AVATAR_SIZE,
|
MAX_AVATAR_SIZE,
|
||||||
MAX_FILE_SIZE,
|
MAX_FILE_SIZE,
|
||||||
} from './attachment.constants';
|
} from './attachment.constants';
|
||||||
@ -63,7 +64,7 @@ export class AttachmentController {
|
|||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@Post('files/upload')
|
@Post('files/upload')
|
||||||
@UseInterceptors(AttachmentInterceptor)
|
@UseInterceptors(FileInterceptor)
|
||||||
async uploadFile(
|
async uploadFile(
|
||||||
@Req() req: any,
|
@Req() req: any,
|
||||||
@Res() res: FastifyReply,
|
@Res() res: FastifyReply,
|
||||||
@ -75,7 +76,7 @@ export class AttachmentController {
|
|||||||
let file = null;
|
let file = null;
|
||||||
try {
|
try {
|
||||||
file = await req.file({
|
file = await req.file({
|
||||||
limits: { fileSize: maxFileSize, fields: 2, files: 1 },
|
limits: { fileSize: maxFileSize, fields: 3, files: 1 },
|
||||||
});
|
});
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
this.logger.error(err.message);
|
this.logger.error(err.message);
|
||||||
@ -112,6 +113,11 @@ export class AttachmentController {
|
|||||||
|
|
||||||
const spaceId = page.spaceId;
|
const spaceId = page.spaceId;
|
||||||
|
|
||||||
|
const attachmentId = file.fields?.attachmentId?.value;
|
||||||
|
if (attachmentId && !isValidUUID(attachmentId)) {
|
||||||
|
throw new BadRequestException('Invalid attachment id');
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const fileResponse = await this.attachmentService.uploadFile({
|
const fileResponse = await this.attachmentService.uploadFile({
|
||||||
filePromise: file,
|
filePromise: file,
|
||||||
@ -119,10 +125,16 @@ export class AttachmentController {
|
|||||||
spaceId: spaceId,
|
spaceId: spaceId,
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
workspaceId: workspace.id,
|
workspaceId: workspace.id,
|
||||||
|
attachmentId: attachmentId,
|
||||||
});
|
});
|
||||||
|
|
||||||
return res.send(fileResponse);
|
return res.send(fileResponse);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
|
if (err?.statusCode === 413) {
|
||||||
|
const errMessage = `File too large. Exceeds the ${MAX_FILE_SIZE} limit`;
|
||||||
|
this.logger.error(errMessage);
|
||||||
|
throw new BadRequestException(errMessage);
|
||||||
|
}
|
||||||
this.logger.error(err);
|
this.logger.error(err);
|
||||||
throw new BadRequestException('Error processing file upload.');
|
throw new BadRequestException('Error processing file upload.');
|
||||||
}
|
}
|
||||||
@ -163,9 +175,17 @@ export class AttachmentController {
|
|||||||
try {
|
try {
|
||||||
const fileStream = await this.storageService.read(attachment.filePath);
|
const fileStream = await this.storageService.read(attachment.filePath);
|
||||||
res.headers({
|
res.headers({
|
||||||
'Content-Type': getMimeType(attachment.filePath),
|
'Content-Type': attachment.mimeType,
|
||||||
'Cache-Control': 'public, max-age=3600',
|
'Cache-Control': 'public, max-age=3600',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!inlineFileExtensions.includes(attachment.fileExt)) {
|
||||||
|
res.header(
|
||||||
|
'Content-Disposition',
|
||||||
|
`attachment; filename="${attachment.fileName}"`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return res.send(fileStream);
|
return res.send(fileStream);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.error(err);
|
this.logger.error(err);
|
||||||
@ -176,7 +196,7 @@ export class AttachmentController {
|
|||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@Post('attachments/upload-image')
|
@Post('attachments/upload-image')
|
||||||
@UseInterceptors(AttachmentInterceptor)
|
@UseInterceptors(FileInterceptor)
|
||||||
async uploadAvatarOrLogo(
|
async uploadAvatarOrLogo(
|
||||||
@Req() req: any,
|
@Req() req: any,
|
||||||
@Res() res: FastifyReply,
|
@Res() res: FastifyReply,
|
||||||
|
|||||||
@ -38,7 +38,6 @@ export async function prepareFile(
|
|||||||
mimeType: file.mimetype,
|
mimeType: file.mimetype,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error in file preparation:', error);
|
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,9 @@
|
|||||||
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
Injectable,
|
||||||
|
Logger,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
import { StorageService } from '../../../integrations/storage/storage.service';
|
import { StorageService } from '../../../integrations/storage/storage.service';
|
||||||
import { MultipartFile } from '@fastify/multipart';
|
import { MultipartFile } from '@fastify/multipart';
|
||||||
import {
|
import {
|
||||||
@ -36,17 +41,53 @@ export class AttachmentService {
|
|||||||
userId: string;
|
userId: string;
|
||||||
spaceId: string;
|
spaceId: string;
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
|
attachmentId?: string;
|
||||||
}) {
|
}) {
|
||||||
const { filePromise, pageId, spaceId, userId, workspaceId } = opts;
|
const { filePromise, pageId, spaceId, userId, workspaceId } = opts;
|
||||||
const preparedFile: PreparedFile = await prepareFile(filePromise);
|
const preparedFile: PreparedFile = await prepareFile(filePromise);
|
||||||
|
|
||||||
const attachmentId = uuid7();
|
let isUpdate = false;
|
||||||
|
let attachmentId = null;
|
||||||
|
|
||||||
|
// passing attachmentId to allow for updating diagrams
|
||||||
|
// instead of creating new files for each save
|
||||||
|
if (opts?.attachmentId) {
|
||||||
|
let existingAttachment = await this.attachmentRepo.findById(
|
||||||
|
opts.attachmentId,
|
||||||
|
);
|
||||||
|
if (!existingAttachment) {
|
||||||
|
throw new NotFoundException(
|
||||||
|
'Existing attachment to overwrite not found',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
existingAttachment.pageId !== pageId &&
|
||||||
|
existingAttachment.fileExt !== preparedFile.fileExtension &&
|
||||||
|
existingAttachment.workspaceId !== workspaceId
|
||||||
|
) {
|
||||||
|
throw new BadRequestException('File attachment does not match');
|
||||||
|
}
|
||||||
|
attachmentId = opts.attachmentId;
|
||||||
|
isUpdate = true;
|
||||||
|
} else {
|
||||||
|
attachmentId = uuid7();
|
||||||
|
}
|
||||||
|
|
||||||
const filePath = `${getAttachmentFolderPath(AttachmentType.File, workspaceId)}/${attachmentId}/${preparedFile.fileName}`;
|
const filePath = `${getAttachmentFolderPath(AttachmentType.File, workspaceId)}/${attachmentId}/${preparedFile.fileName}`;
|
||||||
|
|
||||||
await this.uploadToDrive(filePath, preparedFile.buffer);
|
await this.uploadToDrive(filePath, preparedFile.buffer);
|
||||||
|
|
||||||
let attachment: Attachment = null;
|
let attachment: Attachment = null;
|
||||||
try {
|
try {
|
||||||
|
if (isUpdate) {
|
||||||
|
attachment = await this.attachmentRepo.updateAttachment(
|
||||||
|
{
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
attachmentId,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
attachment = await this.saveAttachment({
|
attachment = await this.saveAttachment({
|
||||||
attachmentId,
|
attachmentId,
|
||||||
preparedFile,
|
preparedFile,
|
||||||
@ -57,9 +98,10 @@ export class AttachmentService {
|
|||||||
workspaceId,
|
workspaceId,
|
||||||
pageId,
|
pageId,
|
||||||
});
|
});
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// delete uploaded file on error
|
// delete uploaded file on error
|
||||||
console.error(err);
|
this.logger.error(err);
|
||||||
}
|
}
|
||||||
|
|
||||||
return attachment;
|
return attachment;
|
||||||
|
|||||||
@ -34,7 +34,10 @@ export class CoreModule implements NestModule {
|
|||||||
configure(consumer: MiddlewareConsumer) {
|
configure(consumer: MiddlewareConsumer) {
|
||||||
consumer
|
consumer
|
||||||
.apply(DomainMiddleware)
|
.apply(DomainMiddleware)
|
||||||
.exclude({ path: 'auth/setup', method: RequestMethod.POST })
|
.exclude(
|
||||||
|
{ path: 'auth/setup', method: RequestMethod.POST },
|
||||||
|
{ path: 'health', method: RequestMethod.GET },
|
||||||
|
)
|
||||||
.forRoutes('*');
|
.forRoutes('*');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
|
ForbiddenException,
|
||||||
Injectable,
|
Injectable,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
@ -217,11 +218,21 @@ export class WorkspaceService {
|
|||||||
) {
|
) {
|
||||||
const user = await this.userRepo.findById(userRoleDto.userId, workspaceId);
|
const user = await this.userRepo.findById(userRoleDto.userId, workspaceId);
|
||||||
|
|
||||||
|
const newRole = userRoleDto.role.toLowerCase();
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new BadRequestException('Workspace member not found');
|
throw new BadRequestException('Workspace member not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (user.role === userRoleDto.role) {
|
// prevent ADMIN from managing OWNER role
|
||||||
|
if (
|
||||||
|
(authUser.role === UserRole.ADMIN && newRole === UserRole.OWNER) ||
|
||||||
|
(authUser.role === UserRole.ADMIN && user.role === UserRole.OWNER)
|
||||||
|
) {
|
||||||
|
throw new ForbiddenException();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.role === newRole) {
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -238,7 +249,7 @@ export class WorkspaceService {
|
|||||||
|
|
||||||
await this.userRepo.updateUser(
|
await this.userRepo.updateUser(
|
||||||
{
|
{
|
||||||
role: userRoleDto.role,
|
role: newRole,
|
||||||
},
|
},
|
||||||
user.id,
|
user.id,
|
||||||
workspaceId,
|
workspaceId,
|
||||||
|
|||||||
@ -36,6 +36,8 @@ types.setTypeParser(types.builtins.INT8, (val) => Number(val));
|
|||||||
dialect: new PostgresDialect({
|
dialect: new PostgresDialect({
|
||||||
pool: new Pool({
|
pool: new Pool({
|
||||||
connectionString: environmentService.getDatabaseURL(),
|
connectionString: environmentService.getDatabaseURL(),
|
||||||
|
}).on('error', (err) => {
|
||||||
|
console.error('Database error:', err.message);
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
plugins: [new CamelCasePlugin()],
|
plugins: [new CamelCasePlugin()],
|
||||||
@ -102,7 +104,7 @@ export class DatabaseModule implements OnModuleDestroy, OnApplicationBootstrap {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async establishConnection() {
|
async establishConnection() {
|
||||||
const retryAttempts = 10;
|
const retryAttempts = 15;
|
||||||
const retryDelay = 3000;
|
const retryDelay = 3000;
|
||||||
|
|
||||||
this.logger.log('Establishing database connection');
|
this.logger.log('Establishing database connection');
|
||||||
|
|||||||
@ -0,0 +1,7 @@
|
|||||||
|
import { type Kysely } from 'kysely';
|
||||||
|
|
||||||
|
export async function up(db: Kysely<any>): Promise<void> {
|
||||||
|
await db.schema.dropIndex('pages_slug_id_idx').ifExists().execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down(db: Kysely<any>): Promise<void> {}
|
||||||
@ -43,8 +43,8 @@ export class AttachmentRepo {
|
|||||||
async updateAttachment(
|
async updateAttachment(
|
||||||
updatableAttachment: UpdatableAttachment,
|
updatableAttachment: UpdatableAttachment,
|
||||||
attachmentId: string,
|
attachmentId: string,
|
||||||
): Promise<void> {
|
): Promise<Attachment> {
|
||||||
await this.db
|
return await this.db
|
||||||
.updateTable('attachments')
|
.updateTable('attachments')
|
||||||
.set(updatableAttachment)
|
.set(updatableAttachment)
|
||||||
.where('id', '=', attachmentId)
|
.where('id', '=', attachmentId)
|
||||||
|
|||||||
@ -6,15 +6,11 @@ import { hashPassword } from '../../../common/helpers';
|
|||||||
import { dbOrTx } from '@docmost/db/utils';
|
import { dbOrTx } from '@docmost/db/utils';
|
||||||
import {
|
import {
|
||||||
InsertableUser,
|
InsertableUser,
|
||||||
Space,
|
|
||||||
UpdatableUser,
|
UpdatableUser,
|
||||||
User,
|
User,
|
||||||
} from '@docmost/db/types/entity.types';
|
} from '@docmost/db/types/entity.types';
|
||||||
import { PaginationOptions } from '../../pagination/pagination-options';
|
import { PaginationOptions } from '../../pagination/pagination-options';
|
||||||
import {
|
import { executeWithPagination } from '@docmost/db/pagination/pagination';
|
||||||
executeWithPagination,
|
|
||||||
PaginationResult,
|
|
||||||
} from '@docmost/db/pagination/pagination';
|
|
||||||
import { sql } from 'kysely';
|
import { sql } from 'kysely';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@ -66,7 +62,7 @@ export class UserRepo {
|
|||||||
.selectFrom('users')
|
.selectFrom('users')
|
||||||
.select(this.baseFields)
|
.select(this.baseFields)
|
||||||
.$if(includePassword, (qb) => qb.select('password'))
|
.$if(includePassword, (qb) => qb.select('password'))
|
||||||
.where('email', '=', email)
|
.where(sql`LOWER(email)`, '=', sql`LOWER(${email})`)
|
||||||
.where('workspaceId', '=', workspaceId)
|
.where('workspaceId', '=', workspaceId)
|
||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -63,6 +63,10 @@ export class EnvironmentService {
|
|||||||
return this.configService.get<string>('AWS_S3_ENDPOINT');
|
return this.configService.get<string>('AWS_S3_ENDPOINT');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getAwsS3ForcePathStyle(): boolean {
|
||||||
|
return this.configService.get<boolean>('AWS_S3_FORCE_PATH_STYLE')
|
||||||
|
}
|
||||||
|
|
||||||
getAwsS3Url(): string {
|
getAwsS3Url(): string {
|
||||||
return this.configService.get<string>('AWS_S3_URL');
|
return this.configService.get<string>('AWS_S3_URL');
|
||||||
}
|
}
|
||||||
@ -87,6 +91,13 @@ export class EnvironmentService {
|
|||||||
return parseInt(this.configService.get<string>('SMTP_PORT'));
|
return parseInt(this.configService.get<string>('SMTP_PORT'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getSmtpSecure(): boolean {
|
||||||
|
const secure = this.configService
|
||||||
|
.get<string>('SMTP_SECURE', 'false')
|
||||||
|
.toLowerCase();
|
||||||
|
return secure === 'true';
|
||||||
|
}
|
||||||
|
|
||||||
getSmtpUsername(): string {
|
getSmtpUsername(): string {
|
||||||
return this.configService.get<string>('SMTP_USERNAME');
|
return this.configService.get<string>('SMTP_USERNAME');
|
||||||
}
|
}
|
||||||
|
|||||||
26
apps/server/src/integrations/export/dto/export-dto.ts
Normal file
26
apps/server/src/integrations/export/dto/export-dto.ts
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import {
|
||||||
|
IsBoolean,
|
||||||
|
IsIn,
|
||||||
|
IsNotEmpty,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
export enum ExportFormat {
|
||||||
|
HTML = 'html',
|
||||||
|
Markdown = 'markdown',
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ExportPageDto {
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
pageId: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsIn(['html', 'markdown'])
|
||||||
|
format: ExportFormat;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
includeFiles?: boolean;
|
||||||
|
}
|
||||||
69
apps/server/src/integrations/export/export.controller.ts
Normal file
69
apps/server/src/integrations/export/export.controller.ts
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
ForbiddenException,
|
||||||
|
HttpCode,
|
||||||
|
HttpStatus,
|
||||||
|
NotFoundException,
|
||||||
|
Post,
|
||||||
|
Res,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { ExportService } from './export.service';
|
||||||
|
import { ExportPageDto } from './dto/export-dto';
|
||||||
|
import { AuthUser } from '../../common/decorators/auth-user.decorator';
|
||||||
|
import { User } from '@docmost/db/types/entity.types';
|
||||||
|
import SpaceAbilityFactory from '../../core/casl/abilities/space-ability.factory';
|
||||||
|
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||||
|
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
||||||
|
import {
|
||||||
|
SpaceCaslAction,
|
||||||
|
SpaceCaslSubject,
|
||||||
|
} from '../../core/casl/interfaces/space-ability.type';
|
||||||
|
import { FastifyReply } from 'fastify';
|
||||||
|
import { sanitize } from 'sanitize-filename-ts';
|
||||||
|
import { getExportExtension } from './utils';
|
||||||
|
import { getMimeType } from '../../common/helpers';
|
||||||
|
|
||||||
|
@Controller()
|
||||||
|
export class ImportController {
|
||||||
|
constructor(
|
||||||
|
private readonly exportService: ExportService,
|
||||||
|
private readonly pageRepo: PageRepo,
|
||||||
|
private readonly spaceAbility: SpaceAbilityFactory,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@Post('pages/export')
|
||||||
|
async exportPage(
|
||||||
|
@Body() dto: ExportPageDto,
|
||||||
|
@AuthUser() user: User,
|
||||||
|
@Res() res: FastifyReply,
|
||||||
|
) {
|
||||||
|
const page = await this.pageRepo.findById(dto.pageId, {
|
||||||
|
includeContent: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!page) {
|
||||||
|
throw new NotFoundException('Page not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
|
||||||
|
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
|
||||||
|
throw new ForbiddenException();
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawContent = await this.exportService.exportPage(dto.format, page);
|
||||||
|
|
||||||
|
const fileExt = getExportExtension(dto.format);
|
||||||
|
const fileName = sanitize(page.title || 'Untitled') + fileExt;
|
||||||
|
|
||||||
|
res.headers({
|
||||||
|
'Content-Type': getMimeType(fileExt),
|
||||||
|
'Content-Disposition': 'attachment; filename="' + fileName + '"',
|
||||||
|
});
|
||||||
|
|
||||||
|
res.send(rawContent);
|
||||||
|
}
|
||||||
|
}
|
||||||
9
apps/server/src/integrations/export/export.module.ts
Normal file
9
apps/server/src/integrations/export/export.module.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ExportService } from './export.service';
|
||||||
|
import { ImportController } from './export.controller';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
providers: [ExportService],
|
||||||
|
controllers: [ImportController],
|
||||||
|
})
|
||||||
|
export class ExportModule {}
|
||||||
34
apps/server/src/integrations/export/export.service.ts
Normal file
34
apps/server/src/integrations/export/export.service.ts
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { jsonToHtml } from '../../collaboration/collaboration.util';
|
||||||
|
import { turndown } from './turndown-utils';
|
||||||
|
import { ExportFormat } from './dto/export-dto';
|
||||||
|
import { Page } from '@docmost/db/types/entity.types';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ExportService {
|
||||||
|
async exportPage(format: string, page: Page) {
|
||||||
|
const titleNode = {
|
||||||
|
type: 'heading',
|
||||||
|
attrs: { level: 1 },
|
||||||
|
content: [{ type: 'text', text: page.title }],
|
||||||
|
};
|
||||||
|
|
||||||
|
let prosemirrorJson: any = page.content || { type: 'doc', content: [] };
|
||||||
|
|
||||||
|
if (page.title) {
|
||||||
|
prosemirrorJson.content.unshift(titleNode);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pageHtml = jsonToHtml(prosemirrorJson);
|
||||||
|
|
||||||
|
if (format === ExportFormat.HTML) {
|
||||||
|
return `<!DOCTYPE html><html><head><title>${page.title}</title></head><body>${pageHtml}</body></html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (format === ExportFormat.Markdown) {
|
||||||
|
return turndown(pageHtml);
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
122
apps/server/src/integrations/export/turndown-utils.ts
Normal file
122
apps/server/src/integrations/export/turndown-utils.ts
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
import * as TurndownService from '@joplin/turndown';
|
||||||
|
import * as TurndownPluginGfm from '@joplin/turndown-plugin-gfm';
|
||||||
|
|
||||||
|
export function turndown(html: string): string {
|
||||||
|
const turndownService = new TurndownService({
|
||||||
|
headingStyle: 'atx',
|
||||||
|
codeBlockStyle: 'fenced',
|
||||||
|
hr: '---',
|
||||||
|
bulletListMarker: '-',
|
||||||
|
});
|
||||||
|
const tables = TurndownPluginGfm.tables;
|
||||||
|
const strikethrough = TurndownPluginGfm.strikethrough;
|
||||||
|
const highlightedCodeBlock = TurndownPluginGfm.highlightedCodeBlock;
|
||||||
|
|
||||||
|
turndownService.use([
|
||||||
|
tables,
|
||||||
|
strikethrough,
|
||||||
|
highlightedCodeBlock,
|
||||||
|
taskList,
|
||||||
|
callout,
|
||||||
|
preserveDetail,
|
||||||
|
listParagraph,
|
||||||
|
mathInline,
|
||||||
|
mathBlock,
|
||||||
|
]);
|
||||||
|
return turndownService.turndown(html).replaceAll('<br>', ' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function listParagraph(turndownService: TurndownService) {
|
||||||
|
turndownService.addRule('paragraph', {
|
||||||
|
filter: ['p'],
|
||||||
|
replacement: (content: any, node: HTMLInputElement) => {
|
||||||
|
if (node.parentElement?.nodeName === 'LI') {
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `\n\n${content}\n\n`;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function callout(turndownService: TurndownService) {
|
||||||
|
turndownService.addRule('callout', {
|
||||||
|
filter: function (node: HTMLInputElement) {
|
||||||
|
return (
|
||||||
|
node.nodeName === 'DIV' && node.getAttribute('data-type') === 'callout'
|
||||||
|
);
|
||||||
|
},
|
||||||
|
replacement: function (content: any, node: HTMLInputElement) {
|
||||||
|
const calloutType = node.getAttribute('data-callout-type');
|
||||||
|
return `\n\n:::${calloutType}\n${content.trim()}\n:::\n\n`;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function taskList(turndownService: TurndownService) {
|
||||||
|
turndownService.addRule('taskListItem', {
|
||||||
|
filter: function (node: HTMLInputElement) {
|
||||||
|
return (
|
||||||
|
node.getAttribute('data-type') === 'taskItem' &&
|
||||||
|
node.parentNode.nodeName === 'UL'
|
||||||
|
);
|
||||||
|
},
|
||||||
|
replacement: function (content: any, node: HTMLInputElement) {
|
||||||
|
const checkbox = node.querySelector(
|
||||||
|
'input[type="checkbox"]',
|
||||||
|
) as HTMLInputElement;
|
||||||
|
const isChecked = checkbox.checked;
|
||||||
|
|
||||||
|
return `- ${isChecked ? '[x]' : '[ ]'} ${content.trim()} \n`;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function preserveDetail(turndownService: TurndownService) {
|
||||||
|
turndownService.addRule('preserveDetail', {
|
||||||
|
filter: function (node: HTMLInputElement) {
|
||||||
|
return node.nodeName === 'DETAILS';
|
||||||
|
},
|
||||||
|
replacement: function (content: any, node: HTMLInputElement) {
|
||||||
|
// TODO: preserve summary of nested details
|
||||||
|
const summary = node.querySelector(':scope > summary');
|
||||||
|
let detailSummary = '';
|
||||||
|
|
||||||
|
if (summary) {
|
||||||
|
detailSummary = `<summary>${turndownService.turndown(summary.innerHTML)}</summary>`;
|
||||||
|
summary.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
const detailsContent = turndownService.turndown(node.innerHTML);
|
||||||
|
return `\n<details>\n${detailSummary}\n\n${detailsContent}\n\n</details>\n`;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function mathInline(turndownService: TurndownService) {
|
||||||
|
turndownService.addRule('mathInline', {
|
||||||
|
filter: function (node: HTMLInputElement) {
|
||||||
|
return (
|
||||||
|
node.nodeName === 'SPAN' &&
|
||||||
|
node.getAttribute('data-type') === 'mathInline'
|
||||||
|
);
|
||||||
|
},
|
||||||
|
replacement: function (content: any, node: HTMLInputElement) {
|
||||||
|
return `$${content}$`;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function mathBlock(turndownService: TurndownService) {
|
||||||
|
turndownService.addRule('mathBlock', {
|
||||||
|
filter: function (node: HTMLInputElement) {
|
||||||
|
return (
|
||||||
|
node.nodeName === 'DIV' &&
|
||||||
|
node.getAttribute('data-type') === 'mathBlock'
|
||||||
|
);
|
||||||
|
},
|
||||||
|
replacement: function (content: any, node: HTMLInputElement) {
|
||||||
|
return `\n$$${content}$$\n`;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
12
apps/server/src/integrations/export/utils.ts
Normal file
12
apps/server/src/integrations/export/utils.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { ExportFormat } from './dto/export-dto';
|
||||||
|
|
||||||
|
export function getExportExtension(format: string) {
|
||||||
|
if (format === ExportFormat.HTML) {
|
||||||
|
return '.html';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (format === ExportFormat.Markdown) {
|
||||||
|
return '.md';
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
22
apps/server/src/integrations/health/health.controller.ts
Normal file
22
apps/server/src/integrations/health/health.controller.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import { Controller, Get } from '@nestjs/common';
|
||||||
|
import { HealthCheck, HealthCheckService } from '@nestjs/terminus';
|
||||||
|
import { PostgresHealthIndicator } from './postgres.health';
|
||||||
|
import { RedisHealthIndicator } from './redis.health';
|
||||||
|
|
||||||
|
@Controller('health')
|
||||||
|
export class HealthController {
|
||||||
|
constructor(
|
||||||
|
private health: HealthCheckService,
|
||||||
|
private postgres: PostgresHealthIndicator,
|
||||||
|
private redis: RedisHealthIndicator,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@HealthCheck()
|
||||||
|
async check() {
|
||||||
|
return this.health.check([
|
||||||
|
() => this.postgres.pingCheck('database'),
|
||||||
|
() => this.redis.pingCheck('redis'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
13
apps/server/src/integrations/health/health.module.ts
Normal file
13
apps/server/src/integrations/health/health.module.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { Global, Module } from '@nestjs/common';
|
||||||
|
import { HealthController } from './health.controller';
|
||||||
|
import { TerminusModule } from '@nestjs/terminus';
|
||||||
|
import { PostgresHealthIndicator } from './postgres.health';
|
||||||
|
import { RedisHealthIndicator } from './redis.health';
|
||||||
|
|
||||||
|
@Global()
|
||||||
|
@Module({
|
||||||
|
controllers: [HealthController],
|
||||||
|
providers: [PostgresHealthIndicator, RedisHealthIndicator],
|
||||||
|
imports: [TerminusModule],
|
||||||
|
})
|
||||||
|
export class HealthModule {}
|
||||||
31
apps/server/src/integrations/health/postgres.health.ts
Normal file
31
apps/server/src/integrations/health/postgres.health.ts
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import { InjectKysely } from 'nestjs-kysely';
|
||||||
|
import {
|
||||||
|
HealthCheckError,
|
||||||
|
HealthIndicator,
|
||||||
|
HealthIndicatorResult,
|
||||||
|
} from '@nestjs/terminus';
|
||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { sql } from 'kysely';
|
||||||
|
import { KyselyDB } from '@docmost/db/types/kysely.types';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PostgresHealthIndicator extends HealthIndicator {
|
||||||
|
private readonly logger = new Logger(PostgresHealthIndicator.name);
|
||||||
|
|
||||||
|
constructor(@InjectKysely() private readonly db: KyselyDB) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
async pingCheck(key: string): Promise<HealthIndicatorResult> {
|
||||||
|
try {
|
||||||
|
await sql`SELECT 1=1`.execute(this.db);
|
||||||
|
return this.getStatus(key, true);
|
||||||
|
} catch (e) {
|
||||||
|
this.logger.error(JSON.stringify(e));
|
||||||
|
throw new HealthCheckError(
|
||||||
|
`${key} is not available`,
|
||||||
|
this.getStatus(key, false),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
34
apps/server/src/integrations/health/redis.health.ts
Normal file
34
apps/server/src/integrations/health/redis.health.ts
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import {
|
||||||
|
HealthCheckError,
|
||||||
|
HealthIndicator,
|
||||||
|
HealthIndicatorResult,
|
||||||
|
} from '@nestjs/terminus';
|
||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { EnvironmentService } from '../environment/environment.service';
|
||||||
|
import { Redis } from 'ioredis';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class RedisHealthIndicator extends HealthIndicator {
|
||||||
|
private readonly logger = new Logger(RedisHealthIndicator.name);
|
||||||
|
|
||||||
|
constructor(private environmentService: EnvironmentService) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
async pingCheck(key: string): Promise<HealthIndicatorResult> {
|
||||||
|
try {
|
||||||
|
const redis = new Redis(this.environmentService.getRedisUrl(), {
|
||||||
|
maxRetriesPerRequest: 15,
|
||||||
|
});
|
||||||
|
|
||||||
|
await redis.ping();
|
||||||
|
return this.getStatus(key, true);
|
||||||
|
} catch (e) {
|
||||||
|
this.logger.error(e);
|
||||||
|
throw new HealthCheckError(
|
||||||
|
`${key} is not available`,
|
||||||
|
this.getStatus(key, false),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
4
apps/server/src/integrations/import/dto/import-dto.ts
Normal file
4
apps/server/src/integrations/import/dto/import-dto.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
export enum ImportFormat {
|
||||||
|
HTML = 'html',
|
||||||
|
Markdown = 'markdown',
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user