mirror of
https://github.com/docmost/docmost.git
synced 2025-11-12 23:32:37 +10:00
Compare commits
1 Commits
feat/resol
...
nanoid-esm
| Author | SHA1 | Date | |
|---|---|---|---|
| 674769df02 |
18
README.md
18
README.md
@ -4,15 +4,14 @@
|
|||||||
Open-source collaborative wiki and documentation software.
|
Open-source collaborative wiki and documentation software.
|
||||||
<br />
|
<br />
|
||||||
<a href="https://docmost.com"><strong>Website</strong></a> |
|
<a href="https://docmost.com"><strong>Website</strong></a> |
|
||||||
<a href="https://docmost.com/docs"><strong>Documentation</strong></a> |
|
<a href="https://docmost.com/docs"><strong>Documentation</strong></a>
|
||||||
<a href="https://twitter.com/DocmostHQ"><strong>Twitter / X</strong></a>
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<br />
|
<br />
|
||||||
|
|
||||||
## Getting started
|
## Getting started
|
||||||
|
|
||||||
To get started with Docmost, please refer to our [documentation](https://docmost.com/docs) or try our [cloud version](https://docmost.com/pricing) .
|
To get started with Docmost, please refer to our [documentation](https://docmost.com/docs).
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
@ -47,16 +46,3 @@ All files in the following directories are licensed under the Docmost Enterprise
|
|||||||
### Contributing
|
### Contributing
|
||||||
|
|
||||||
See the [development documentation](https://docmost.com/docs/self-hosting/development)
|
See the [development documentation](https://docmost.com/docs/self-hosting/development)
|
||||||
|
|
||||||
## Thanks
|
|
||||||
Special thanks to;
|
|
||||||
|
|
||||||
<img width="100" alt="Crowdin" src="https://github.com/user-attachments/assets/a6c3d352-e41b-448d-b6cd-3fbca3109f07" />
|
|
||||||
|
|
||||||
[Crowdin](https://crowdin.com/) for providing access to their localization platform.
|
|
||||||
|
|
||||||
|
|
||||||
<img width="48" alt="Algolia-mark-square-white" src="https://github.com/user-attachments/assets/6ccad04a-9589-4965-b6a1-d5cb1f4f9e94" />
|
|
||||||
|
|
||||||
[Algolia](https://www.algolia.com/) for providing full-text search to the docs.
|
|
||||||
|
|
||||||
|
|||||||
@ -6,7 +6,6 @@
|
|||||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
|
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Docmost</title>
|
<title>Docmost</title>
|
||||||
<!--meta-tags-->
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "client",
|
"name": "client",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.21.0",
|
"version": "0.10.2",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc && vite build",
|
"build": "tsc && vite build",
|
||||||
@ -15,48 +15,44 @@
|
|||||||
"@docmost/editor-ext": "workspace:*",
|
"@docmost/editor-ext": "workspace:*",
|
||||||
"@emoji-mart/data": "^1.2.1",
|
"@emoji-mart/data": "^1.2.1",
|
||||||
"@emoji-mart/react": "^1.1.1",
|
"@emoji-mart/react": "^1.1.1",
|
||||||
"@excalidraw/excalidraw": "0.18.0-864353b",
|
"@excalidraw/excalidraw": "^0.17.6",
|
||||||
"@mantine/core": "^8.1.3",
|
"@mantine/core": "^7.17.0",
|
||||||
"@mantine/form": "^8.1.3",
|
"@mantine/form": "^7.17.0",
|
||||||
"@mantine/hooks": "^8.1.3",
|
"@mantine/hooks": "^7.17.0",
|
||||||
"@mantine/modals": "^8.1.3",
|
"@mantine/modals": "^7.17.0",
|
||||||
"@mantine/notifications": "^8.1.3",
|
"@mantine/notifications": "^7.17.0",
|
||||||
"@mantine/spotlight": "^8.1.3",
|
"@mantine/spotlight": "^7.17.0",
|
||||||
"@tabler/icons-react": "^3.34.0",
|
"@tabler/icons-react": "^3.22.0",
|
||||||
"@tanstack/react-query": "^5.80.6",
|
"@tanstack/react-query": "^5.61.4",
|
||||||
"@tiptap/extension-character-count": "^2.10.3",
|
"@tiptap/extension-character-count": "^2.11.5",
|
||||||
"alfaaz": "^1.1.0",
|
"axios": "^1.7.9",
|
||||||
"axios": "^1.9.0",
|
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"emoji-mart": "^5.6.0",
|
"emoji-mart": "^5.6.0",
|
||||||
"file-saver": "^2.0.5",
|
"file-saver": "^2.0.5",
|
||||||
"highlightjs-sap-abap": "^0.3.0",
|
|
||||||
"i18next": "^23.14.0",
|
"i18next": "^23.14.0",
|
||||||
"i18next-http-backend": "^2.6.1",
|
"i18next-http-backend": "^2.6.1",
|
||||||
"jotai": "^2.12.5",
|
"jotai": "^2.12.1",
|
||||||
"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.22",
|
"katex": "0.16.21",
|
||||||
"lowlight": "^3.3.0",
|
"lowlight": "^3.2.0",
|
||||||
"mantine-form-zod-resolver": "^1.3.0",
|
"mermaid": "^11.4.1",
|
||||||
"mermaid": "^11.6.0",
|
|
||||||
"mitt": "^3.0.1",
|
"mitt": "^3.0.1",
|
||||||
"posthog-js": "^1.255.1",
|
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-arborist": "3.4.0",
|
"react-arborist": "3.4.0",
|
||||||
"react-clear-modal": "^2.0.15",
|
"react-clear-modal": "^2.0.11",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-drawio": "^1.0.1",
|
"react-drawio": "^1.0.1",
|
||||||
"react-error-boundary": "^4.1.2",
|
"react-error-boundary": "^4.1.2",
|
||||||
"react-helmet-async": "^2.0.5",
|
"react-helmet-async": "^2.0.5",
|
||||||
"react-i18next": "^15.0.1",
|
"react-i18next": "^15.0.1",
|
||||||
"react-router-dom": "^7.0.1",
|
"react-router-dom": "^7.0.1",
|
||||||
"semver": "^7.7.2",
|
"semver": "^7.7.1",
|
||||||
"socket.io-client": "^4.8.1",
|
"socket.io-client": "^4.8.1",
|
||||||
"tippy.js": "^6.3.7",
|
"tippy.js": "^6.3.7",
|
||||||
"tiptap-extension-global-drag-handle": "^0.1.18",
|
"tiptap-extension-global-drag-handle": "^0.1.18",
|
||||||
"zod": "^3.25.56"
|
"zod": "^3.23.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.16.0",
|
"@eslint/js": "^9.16.0",
|
||||||
@ -67,7 +63,7 @@
|
|||||||
"@types/node": "22.10.0",
|
"@types/node": "22.10.0",
|
||||||
"@types/react": "^18.3.12",
|
"@types/react": "^18.3.12",
|
||||||
"@types/react-dom": "^18.3.1",
|
"@types/react-dom": "^18.3.1",
|
||||||
"@vitejs/plugin-react": "^4.4.1",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
"eslint": "^9.15.0",
|
"eslint": "^9.15.0",
|
||||||
"eslint-plugin-react": "^7.37.2",
|
"eslint-plugin-react": "^7.37.2",
|
||||||
"eslint-plugin-react-hooks": "^5.1.0",
|
"eslint-plugin-react-hooks": "^5.1.0",
|
||||||
@ -80,6 +76,6 @@
|
|||||||
"prettier": "^3.4.1",
|
"prettier": "^3.4.1",
|
||||||
"typescript": "^5.7.2",
|
"typescript": "^5.7.2",
|
||||||
"typescript-eslint": "^8.17.0",
|
"typescript-eslint": "^8.17.0",
|
||||||
"vite": "^6.3.5"
|
"vite": "^6.1.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -362,29 +362,5 @@
|
|||||||
"Move page to a different space.": "Seite in einen anderen Bereich verschieben.",
|
"Move page to a different space.": "Seite in einen anderen Bereich verschieben.",
|
||||||
"Real-time editor connection lost. Retrying...": "Echtzeit-Editor-Verbindung verloren. Wiederholen...",
|
"Real-time editor connection lost. Retrying...": "Echtzeit-Editor-Verbindung verloren. Wiederholen...",
|
||||||
"Table of contents": "Inhaltsverzeichnis",
|
"Table of contents": "Inhaltsverzeichnis",
|
||||||
"Add headings (H1, H2, H3) to generate a table of contents.": "Fügen Sie Überschriften (H1, H2, H3) hinzu, um ein Inhaltsverzeichnis zu erstellen.",
|
"Add headings (H1, H2, H3) to generate a table of contents.": "Fügen Sie Überschriften (H1, H2, H3) hinzu, um ein Inhaltsverzeichnis zu erstellen."
|
||||||
"Share": "Teilen",
|
|
||||||
"Public sharing": "Öffentliches Teilen",
|
|
||||||
"Shared by": "Geteilt von",
|
|
||||||
"Shared at": "Geteilt am",
|
|
||||||
"Inherits public sharing from": "Erbt das öffentliche Teilen von",
|
|
||||||
"Share to web": "Im Web teilen",
|
|
||||||
"Shared to web": "Im Web geteilt",
|
|
||||||
"Anyone with the link can view this page": "Jeder mit dem Link kann diese Seite ansehen",
|
|
||||||
"Make this page publicly accessible": "Diese Seite öffentlich zugänglich machen",
|
|
||||||
"Include sub-pages": "Unterseiten einbeziehen",
|
|
||||||
"Make sub-pages public too": "Unterseiten auch öffentlich machen",
|
|
||||||
"Allow search engines to index page": "Suchmaschinen erlauben, die Seite zu indexieren",
|
|
||||||
"Open page": "Seite öffnen",
|
|
||||||
"Page": "Seite",
|
|
||||||
"Delete public share link": "Öffentlichen Freigabelink löschen",
|
|
||||||
"Delete share": "Freigabe löschen",
|
|
||||||
"Are you sure you want to delete this shared link?": "Möchten Sie diesen Freigabelink wirklich löschen?",
|
|
||||||
"Publicly shared pages from spaces you are a member of will appear here": "Öffentlich geteilte Seiten aus Bereichen, in denen Sie Mitglied sind, erscheinen hier",
|
|
||||||
"Share deleted successfully": "Freigabe erfolgreich gelöscht",
|
|
||||||
"Share not found": "Freigabe nicht gefunden",
|
|
||||||
"Failed to share page": "Fehler beim Teilen der Seite",
|
|
||||||
"Copy page": "Seite kopieren",
|
|
||||||
"Copy page to a different space.": "Seite in einen anderen Bereich kopieren.",
|
|
||||||
"Page copied successfully": "Seite erfolgreich kopiert"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -213,18 +213,7 @@
|
|||||||
"Comment deleted successfully": "Comment deleted successfully",
|
"Comment deleted successfully": "Comment deleted successfully",
|
||||||
"Failed to delete comment": "Failed to delete comment",
|
"Failed to delete comment": "Failed to delete comment",
|
||||||
"Comment resolved successfully": "Comment resolved successfully",
|
"Comment resolved successfully": "Comment resolved successfully",
|
||||||
"Comment re-opened successfully": "Comment re-opened successfully",
|
|
||||||
"Comment unresolved successfully": "Comment unresolved successfully",
|
|
||||||
"Failed to resolve comment": "Failed to resolve comment",
|
"Failed to resolve comment": "Failed to resolve comment",
|
||||||
"Resolve comment": "Resolve comment",
|
|
||||||
"Unresolve comment": "Unresolve comment",
|
|
||||||
"Resolve Comment Thread": "Resolve Comment Thread",
|
|
||||||
"Unresolve Comment Thread": "Unresolve Comment Thread",
|
|
||||||
"Are you sure you want to resolve this comment thread? This will mark it as completed.": "Are you sure you want to resolve this comment thread? This will mark it as completed.",
|
|
||||||
"Are you sure you want to unresolve this comment thread?": "Are you sure you want to unresolve this comment thread?",
|
|
||||||
"Resolved": "Resolved",
|
|
||||||
"No active comments.": "No active comments.",
|
|
||||||
"No resolved comments.": "No resolved comments.",
|
|
||||||
"Revoke invitation": "Revoke invitation",
|
"Revoke invitation": "Revoke invitation",
|
||||||
"Revoke": "Revoke",
|
"Revoke": "Revoke",
|
||||||
"Don't": "Don't",
|
"Don't": "Don't",
|
||||||
@ -233,9 +222,7 @@
|
|||||||
"Anyone with this link can join this workspace.": "Anyone with this link can join this workspace.",
|
"Anyone with this link can join this workspace.": "Anyone with this link can join this workspace.",
|
||||||
"Invite link": "Invite link",
|
"Invite link": "Invite link",
|
||||||
"Copy": "Copy",
|
"Copy": "Copy",
|
||||||
"Copy to space": "Copy to space",
|
|
||||||
"Copied": "Copied",
|
"Copied": "Copied",
|
||||||
"Duplicate": "Duplicate",
|
|
||||||
"Select a user": "Select a user",
|
"Select a user": "Select a user",
|
||||||
"Select a group": "Select a group",
|
"Select a group": "Select a group",
|
||||||
"Export all pages and attachments in this space.": "Export all pages and attachments in this space.",
|
"Export all pages and attachments in this space.": "Export all pages and attachments in this space.",
|
||||||
@ -367,9 +354,6 @@
|
|||||||
"Character count: {{characterCount}}": "Character count: {{characterCount}}",
|
"Character count: {{characterCount}}": "Character count: {{characterCount}}",
|
||||||
"New update": "New update",
|
"New update": "New update",
|
||||||
"{{latestVersion}} is available": "{{latestVersion}} is available",
|
"{{latestVersion}} is available": "{{latestVersion}} is available",
|
||||||
"Default page edit mode": "Default page edit mode",
|
|
||||||
"Choose your preferred page edit mode. Avoid accidental edits.": "Choose your preferred page edit mode. Avoid accidental edits.",
|
|
||||||
"Reading": "Reading",
|
|
||||||
"Delete member": "Delete member",
|
"Delete member": "Delete member",
|
||||||
"Member deleted successfully": "Member deleted successfully",
|
"Member deleted successfully": "Member deleted successfully",
|
||||||
"Are you sure you want to delete this workspace member? This action is irreversible.": "Are you sure you want to delete this workspace member? This action is irreversible.",
|
"Are you sure you want to delete this workspace member? This action is irreversible.": "Are you sure you want to delete this workspace member? This action is irreversible.",
|
||||||
@ -378,122 +362,5 @@
|
|||||||
"Move page to a different space.": "Move page to a different space.",
|
"Move page to a different space.": "Move page to a different space.",
|
||||||
"Real-time editor connection lost. Retrying...": "Real-time editor connection lost. Retrying...",
|
"Real-time editor connection lost. Retrying...": "Real-time editor connection lost. Retrying...",
|
||||||
"Table of contents": "Table of contents",
|
"Table of contents": "Table of contents",
|
||||||
"Add headings (H1, H2, H3) to generate a table of contents.": "Add headings (H1, H2, H3) to generate a table of contents.",
|
"Add headings (H1, H2, H3) to generate a table of contents.": "Add headings (H1, H2, H3) to generate a table of contents."
|
||||||
"Share": "Share",
|
|
||||||
"Public sharing": "Public sharing",
|
|
||||||
"Shared by": "Shared by",
|
|
||||||
"Shared at": "Shared at",
|
|
||||||
"Inherits public sharing from": "Inherits public sharing from",
|
|
||||||
"Share to web": "Share to web",
|
|
||||||
"Shared to web": "Shared to web",
|
|
||||||
"Anyone with the link can view this page": "Anyone with the link can view this page",
|
|
||||||
"Make this page publicly accessible": "Make this page publicly accessible",
|
|
||||||
"Include sub-pages": "Include sub-pages",
|
|
||||||
"Make sub-pages public too": "Make sub-pages public too",
|
|
||||||
"Allow search engines to index page": "Allow search engines to index page",
|
|
||||||
"Open page": "Open page",
|
|
||||||
"Page": "Page",
|
|
||||||
"Delete public share link": "Delete public share link",
|
|
||||||
"Delete share": "Delete share",
|
|
||||||
"Are you sure you want to delete this shared link?": "Are you sure you want to delete this shared link?",
|
|
||||||
"Publicly shared pages from spaces you are a member of will appear here": "Publicly shared pages from spaces you are a member of will appear here",
|
|
||||||
"Share deleted successfully": "Share deleted successfully",
|
|
||||||
"Share not found": "Share not found",
|
|
||||||
"Failed to share page": "Failed to share page",
|
|
||||||
"Copy page": "Copy page",
|
|
||||||
"Copy page to a different space.": "Copy page to a different space.",
|
|
||||||
"Page copied successfully": "Page copied successfully",
|
|
||||||
"Page duplicated successfully": "Page duplicated successfully",
|
|
||||||
"Find": "Find",
|
|
||||||
"Not found": "Not found",
|
|
||||||
"Previous Match (Shift+Enter)": "Previous Match (Shift+Enter)",
|
|
||||||
"Next match (Enter)": "Next match (Enter)",
|
|
||||||
"Match case (Alt+C)": "Match case (Alt+C)",
|
|
||||||
"Replace": "Replace",
|
|
||||||
"Close (Escape)": "Close (Escape)",
|
|
||||||
"Replace (Enter)": "Replace (Enter)",
|
|
||||||
"Replace all (Ctrl+Alt+Enter)": "Replace all (Ctrl+Alt+Enter)",
|
|
||||||
"Replace all": "Replace all",
|
|
||||||
"View all spaces": "View all spaces",
|
|
||||||
"Error": "Error",
|
|
||||||
"Failed to disable MFA": "Failed to disable MFA",
|
|
||||||
"Disable two-factor authentication": "Disable two-factor authentication",
|
|
||||||
"Disabling two-factor authentication will make your account less secure. You'll only need your password to sign in.": "Disabling two-factor authentication will make your account less secure. You'll only need your password to sign in.",
|
|
||||||
"Please enter your password to disable two-factor authentication:": "Please enter your password to disable two-factor authentication:",
|
|
||||||
"Two-factor authentication has been enabled": "Two-factor authentication has been enabled",
|
|
||||||
"Two-factor authentication has been disabled": "Two-factor authentication has been disabled",
|
|
||||||
"2-step verification": "2-step verification",
|
|
||||||
"Protect your account with an additional verification layer when signing in.": "Protect your account with an additional verification layer when signing in.",
|
|
||||||
"Two-factor authentication is active on your account.": "Two-factor authentication is active on your account.",
|
|
||||||
"Add 2FA method": "Add 2FA method",
|
|
||||||
"Backup codes": "Backup codes",
|
|
||||||
"Disable": "Disable",
|
|
||||||
"Invalid verification code": "Invalid verification code",
|
|
||||||
"New backup codes have been generated": "New backup codes have been generated",
|
|
||||||
"Failed to regenerate backup codes": "Failed to regenerate backup codes",
|
|
||||||
"About backup codes": "About backup codes",
|
|
||||||
"Backup codes can be used to access your account if you lose access to your authenticator app. Each code can only be used once.": "Backup codes can be used to access your account if you lose access to your authenticator app. Each code can only be used once.",
|
|
||||||
"You can regenerate new backup codes at any time. This will invalidate all existing codes.": "You can regenerate new backup codes at any time. This will invalidate all existing codes.",
|
|
||||||
"Confirm password": "Confirm password",
|
|
||||||
"Generate new backup codes": "Generate new backup codes",
|
|
||||||
"Save your new backup codes": "Save your new backup codes",
|
|
||||||
"Make sure to save these codes in a secure place. Your old backup codes are no longer valid.": "Make sure to save these codes in a secure place. Your old backup codes are no longer valid.",
|
|
||||||
"Your new backup codes": "Your new backup codes",
|
|
||||||
"I've saved my backup codes": "I've saved my backup codes",
|
|
||||||
"Failed to setup MFA": "Failed to setup MFA",
|
|
||||||
"Setup & Verify": "Setup & Verify",
|
|
||||||
"Add to authenticator": "Add to authenticator",
|
|
||||||
"1. Scan this QR code with your authenticator app": "1. Scan this QR code with your authenticator app",
|
|
||||||
"Can't scan the code?": "Can't scan the code?",
|
|
||||||
"Enter this code manually in your authenticator app:": "Enter this code manually in your authenticator app:",
|
|
||||||
"2. Enter the 6-digit code from your authenticator": "2. Enter the 6-digit code from your authenticator",
|
|
||||||
"Verify and enable": "Verify and enable",
|
|
||||||
"Failed to generate QR code. Please try again.": "Failed to generate QR code. Please try again.",
|
|
||||||
"Backup": "Backup",
|
|
||||||
"Save codes": "Save codes",
|
|
||||||
"Save your backup codes": "Save your backup codes",
|
|
||||||
"These codes can be used to access your account if you lose access to your authenticator app. Each code can only be used once.": "These codes can be used to access your account if you lose access to your authenticator app. Each code can only be used once.",
|
|
||||||
"Print": "Print",
|
|
||||||
"Two-factor authentication has been set up. Please log in again.": "Two-factor authentication has been set up. Please log in again.",
|
|
||||||
"Two-Factor authentication required": "Two-factor authentication required",
|
|
||||||
"Your workspace requires two-factor authentication for all users": "Your workspace requires two-factor authentication for all users",
|
|
||||||
"To continue accessing your workspace, you must set up two-factor authentication. This adds an extra layer of security to your account.": "To continue accessing your workspace, you must set up two-factor authentication. This adds an extra layer of security to your account.",
|
|
||||||
"Set up two-factor authentication": "Set up two-factor authentication",
|
|
||||||
"Cancel and logout": "Cancel and logout",
|
|
||||||
"Your workspace requires two-factor authentication. Please set it up to continue.": "Your workspace requires two-factor authentication. Please set it up to continue.",
|
|
||||||
"This adds an extra layer of security to your account by requiring a verification code from your authenticator app.": "This adds an extra layer of security to your account by requiring a verification code from your authenticator app.",
|
|
||||||
"Password is required": "Password is required",
|
|
||||||
"Password must be at least 8 characters": "Password must be at least 8 characters",
|
|
||||||
"Please enter a 6-digit code": "Please enter a 6-digit code",
|
|
||||||
"Code must be exactly 6 digits": "Code must be exactly 6 digits",
|
|
||||||
"Enter the 6-digit code found in your authenticator app": "Enter the 6-digit code found in your authenticator app",
|
|
||||||
"Need help authenticating?": "Need help authenticating?",
|
|
||||||
"MFA QR Code": "MFA QR Code",
|
|
||||||
"Account created successfully. Please log in to set up two-factor authentication.": "Account created successfully. Please log in to set up two-factor authentication.",
|
|
||||||
"Password reset successful. Please log in with your new password and complete two-factor authentication.": "Password reset successful. Please log in with your new password and complete two-factor authentication.",
|
|
||||||
"Password reset successful. Please log in with your new password to set up two-factor authentication.": "Password reset successful. Please log in with your new password to set up two-factor authentication.",
|
|
||||||
"Password reset was successful. Please log in with your new password.": "Password reset was successful. Please log in with your new password.",
|
|
||||||
"Two-factor authentication": "Two-factor authentication",
|
|
||||||
"Use authenticator app instead": "Use authenticator app instead",
|
|
||||||
"Verify backup code": "Verify backup code",
|
|
||||||
"Use backup code": "Use backup code",
|
|
||||||
"Enter one of your backup codes": "Enter one of your backup codes",
|
|
||||||
"Backup code": "Backup code",
|
|
||||||
"Enter one of your backup codes. Each backup code can only be used once.": "Enter one of your backup codes. Each backup code can only be used once.",
|
|
||||||
"Verify": "Verify",
|
|
||||||
"Trash": "Trash",
|
|
||||||
"Pages in trash will be permanently deleted after 30 days.": "Pages in trash will be permanently deleted after 30 days.",
|
|
||||||
"Deleted": "Deleted",
|
|
||||||
"No pages in trash": "No pages in trash",
|
|
||||||
"Permanently delete page?": "Permanently delete page?",
|
|
||||||
"Are you sure you want to permanently delete '{{title}}'? This action cannot be undone.": "Are you sure you want to permanently delete '{{title}}'? This action cannot be undone.",
|
|
||||||
"Restore '{{title}}' and its sub-pages?": "Restore '{{title}}' and its sub-pages?",
|
|
||||||
"Move to trash": "Move to trash",
|
|
||||||
"Move this page to trash?": "Move this page to trash?",
|
|
||||||
"Restore page": "Restore page",
|
|
||||||
"Page moved to trash": "Page moved to trash",
|
|
||||||
"Page restored successfully": "Page restored successfully",
|
|
||||||
"Deleted by": "Deleted by",
|
|
||||||
"Deleted at": "Deleted at",
|
|
||||||
"Preview": "Preview"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -94,7 +94,7 @@
|
|||||||
"Invited members will be granted access to spaces the groups can access": "Los miembros invitados recibirán acceso a los espacios a los que los grupos pueden acceder",
|
"Invited members will be granted access to spaces the groups can access": "Los miembros invitados recibirán acceso a los espacios a los que los grupos pueden acceder",
|
||||||
"Join the workspace": "Unirse al espacio de trabajo",
|
"Join the workspace": "Unirse al espacio de trabajo",
|
||||||
"Language": "Idioma",
|
"Language": "Idioma",
|
||||||
"Light": "Claro",
|
"Light": "Ligero",
|
||||||
"Link copied": "Enlace copiado",
|
"Link copied": "Enlace copiado",
|
||||||
"Login": "Iniciar sesión",
|
"Login": "Iniciar sesión",
|
||||||
"Logout": "Cerrar sesión",
|
"Logout": "Cerrar sesión",
|
||||||
@ -362,29 +362,5 @@
|
|||||||
"Move page to a different space.": "Mover página a un espacio diferente.",
|
"Move page to a different space.": "Mover página a un espacio diferente.",
|
||||||
"Real-time editor connection lost. Retrying...": "Conexión del editor en tiempo real perdida. Reintentando...",
|
"Real-time editor connection lost. Retrying...": "Conexión del editor en tiempo real perdida. Reintentando...",
|
||||||
"Table of contents": "Índice de contenidos",
|
"Table of contents": "Índice de contenidos",
|
||||||
"Add headings (H1, H2, H3) to generate a table of contents.": "Añadir encabezados (H1, H2, H3) para generar un índice de contenidos.",
|
"Add headings (H1, H2, H3) to generate a table of contents.": "Añadir encabezados (H1, H2, H3) para generar un índice de contenidos."
|
||||||
"Share": "Compartir",
|
|
||||||
"Public sharing": "Compartición pública",
|
|
||||||
"Shared by": "Compartido por",
|
|
||||||
"Shared at": "Compartido en",
|
|
||||||
"Inherits public sharing from": "Hereda la compartición pública de",
|
|
||||||
"Share to web": "Compartir en la web",
|
|
||||||
"Shared to web": "Compartido en la web",
|
|
||||||
"Anyone with the link can view this page": "Cualquiera con el enlace puede ver esta página",
|
|
||||||
"Make this page publicly accessible": "Hacer esta página accesible públicamente",
|
|
||||||
"Include sub-pages": "Incluir subpáginas",
|
|
||||||
"Make sub-pages public too": "Hacer públicas también las subpáginas",
|
|
||||||
"Allow search engines to index page": "Permitir a los motores de búsqueda indexar la página",
|
|
||||||
"Open page": "Abrir página",
|
|
||||||
"Page": "Página",
|
|
||||||
"Delete public share link": "Eliminar enlace de compartición pública",
|
|
||||||
"Delete share": "Eliminar compartición",
|
|
||||||
"Are you sure you want to delete this shared link?": "¿Está seguro de que desea eliminar este enlace compartido?",
|
|
||||||
"Publicly shared pages from spaces you are a member of will appear here": "Las páginas compartidas públicamente de los espacios a los que pertenece aparecerán aquí",
|
|
||||||
"Share deleted successfully": "Compartición eliminada con éxito",
|
|
||||||
"Share not found": "Compartición no encontrada",
|
|
||||||
"Failed to share page": "Error al compartir la página",
|
|
||||||
"Copy page": "Copy page",
|
|
||||||
"Copy page to a different space.": "Copy page to a different space.",
|
|
||||||
"Page copied successfully": "Page copied successfully"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -362,29 +362,5 @@
|
|||||||
"Move page to a different space.": "Déplacer la page vers un autre espace.",
|
"Move page to a different space.": "Déplacer la page vers un autre espace.",
|
||||||
"Real-time editor connection lost. Retrying...": "Connexion avec l'éditeur en temps réel perdue. Nouvelle tentative...",
|
"Real-time editor connection lost. Retrying...": "Connexion avec l'éditeur en temps réel perdue. Nouvelle tentative...",
|
||||||
"Table of contents": "",
|
"Table of contents": "",
|
||||||
"Add headings (H1, H2, H3) to generate a table of contents.": "Ajoutez des titres (H1, H2, H3) pour générer une table des matières.",
|
"Add headings (H1, H2, H3) to generate a table of contents.": "Ajoutez des titres (H1, H2, H3) pour générer une table des matières."
|
||||||
"Share": "Partager",
|
|
||||||
"Public sharing": "Partage public",
|
|
||||||
"Shared by": "Partagé par",
|
|
||||||
"Shared at": "Partagé à",
|
|
||||||
"Inherits public sharing from": "Hérite du partage public de",
|
|
||||||
"Share to web": "Partager sur le web",
|
|
||||||
"Shared to web": "Partagé sur le web",
|
|
||||||
"Anyone with the link can view this page": "Toute personne avec le lien peut voir cette page",
|
|
||||||
"Make this page publicly accessible": "Rendre cette page accessible au public",
|
|
||||||
"Include sub-pages": "Inclure les sous-pages",
|
|
||||||
"Make sub-pages public too": "Rendre également les sous-pages publiques",
|
|
||||||
"Allow search engines to index page": "Autoriser les moteurs de recherche à indexer la page",
|
|
||||||
"Open page": "Ouvrir la page",
|
|
||||||
"Page": "Page",
|
|
||||||
"Delete public share link": "Supprimer le lien de partage public",
|
|
||||||
"Delete share": "Supprimer le partage",
|
|
||||||
"Are you sure you want to delete this shared link?": "Êtes-vous sûr de vouloir supprimer ce lien partagé ?",
|
|
||||||
"Publicly shared pages from spaces you are a member of will appear here": "Les pages partagées publiquement des espaces dont vous êtes membre apparaîtront ici",
|
|
||||||
"Share deleted successfully": "Partage supprimé avec succès",
|
|
||||||
"Share not found": "Partage non trouvé",
|
|
||||||
"Failed to share page": "Échec du partage de la page",
|
|
||||||
"Copy page": "Copier la page",
|
|
||||||
"Copy page to a different space.": "Copier la page dans un autre espace.",
|
|
||||||
"Page copied successfully": "Page copiée avec succès"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -362,29 +362,5 @@
|
|||||||
"Move page to a different space.": "Sposta la pagina in un altro spazio.",
|
"Move page to a different space.": "Sposta la pagina in un altro spazio.",
|
||||||
"Real-time editor connection lost. Retrying...": "Connessione all'editor in tempo reale persa. Riprovo...",
|
"Real-time editor connection lost. Retrying...": "Connessione all'editor in tempo reale persa. Riprovo...",
|
||||||
"Table of contents": "Indice dei contenuti",
|
"Table of contents": "Indice dei contenuti",
|
||||||
"Add headings (H1, H2, H3) to generate a table of contents.": "Aggiungi intestazioni (H1, H2, H3) per generare un sommario.",
|
"Add headings (H1, H2, H3) to generate a table of contents.": "Aggiungi intestazioni (H1, H2, H3) per generare un sommario."
|
||||||
"Share": "Condividi",
|
|
||||||
"Public sharing": "Condivisione pubblica",
|
|
||||||
"Shared by": "Condiviso da",
|
|
||||||
"Shared at": "Condiviso il",
|
|
||||||
"Inherits public sharing from": "Eredita la condivisione pubblica da",
|
|
||||||
"Share to web": "Condividi su web",
|
|
||||||
"Shared to web": "Condiviso su web",
|
|
||||||
"Anyone with the link can view this page": "Chiunque abbia il link può visualizzare questa pagina",
|
|
||||||
"Make this page publicly accessible": "Rendi questa pagina accessibile pubblicamente",
|
|
||||||
"Include sub-pages": "Includi sotto-pagine",
|
|
||||||
"Make sub-pages public too": "Rendi pubbliche anche le sotto-pagine",
|
|
||||||
"Allow search engines to index page": "Permetti ai motori di ricerca di indicizzare la pagina",
|
|
||||||
"Open page": "Apri pagina",
|
|
||||||
"Page": "Pagina",
|
|
||||||
"Delete public share link": "Elimina il link di condivisione pubblica",
|
|
||||||
"Delete share": "Elimina condivisione",
|
|
||||||
"Are you sure you want to delete this shared link?": "Sei sicuro di voler eliminare questo link condiviso?",
|
|
||||||
"Publicly shared pages from spaces you are a member of will appear here": "Le pagine condivise pubblicamente dagli spazi di cui sei membro appariranno qui",
|
|
||||||
"Share deleted successfully": "Condivisione eliminata con successo",
|
|
||||||
"Share not found": "Condivisione non trovata",
|
|
||||||
"Failed to share page": "Condivisione della pagina fallita",
|
|
||||||
"Copy page": "Copia pagina",
|
|
||||||
"Copy page to a different space.": "Copia pagina in un altro spazio.",
|
|
||||||
"Page copied successfully": "Pagina copiata con successo"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -347,7 +347,7 @@
|
|||||||
"Members added successfully": "メンバーを追加しました",
|
"Members added successfully": "メンバーを追加しました",
|
||||||
"Member removed successfully": "メンバーが削除されました",
|
"Member removed successfully": "メンバーが削除されました",
|
||||||
"Member role updated successfully": "メンバーのロールを更新しました",
|
"Member role updated successfully": "メンバーのロールを更新しました",
|
||||||
"Created by: <b>{{creatorName}}</b>": "作成者: <b>{{creatorName}}</b>",
|
"Created by: <b>{{creatorName}}</b>": "作成者: <b>{{creatorName}}</b>",
|
||||||
"Created at: {{time}}": "が作成しました:{{time}}",
|
"Created at: {{time}}": "が作成しました:{{time}}",
|
||||||
"Edited by {{name}} {{time}}": "最終編集: {{name}} {{time}}",
|
"Edited by {{name}} {{time}}": "最終編集: {{name}} {{time}}",
|
||||||
"Word count: {{wordCount}}": "ワード数: {{wordCount}}",
|
"Word count: {{wordCount}}": "ワード数: {{wordCount}}",
|
||||||
@ -362,29 +362,5 @@
|
|||||||
"Move page to a different space.": "ページを別のスペースに移動します。",
|
"Move page to a different space.": "ページを別のスペースに移動します。",
|
||||||
"Real-time editor connection lost. Retrying...": "リアルタイムエディターの接続が失われました。再試行しています…",
|
"Real-time editor connection lost. Retrying...": "リアルタイムエディターの接続が失われました。再試行しています…",
|
||||||
"Table of contents": "目次",
|
"Table of contents": "目次",
|
||||||
"Add headings (H1, H2, H3) to generate a table of contents.": "見出し(H1、H2、H3)を追加して目次を生成します。",
|
"Add headings (H1, H2, H3) to generate a table of contents.": "見出し(H1、H2、H3)を追加して目次を生成します。"
|
||||||
"Share": "共有",
|
|
||||||
"Public sharing": "公開共有",
|
|
||||||
"Shared by": "共有者",
|
|
||||||
"Shared at": "共有日時",
|
|
||||||
"Inherits public sharing from": "から公開共有を継承する",
|
|
||||||
"Share to web": "ウェブで共有",
|
|
||||||
"Shared to web": "ウェブに共有済み",
|
|
||||||
"Anyone with the link can view this page": "リンクを持っている人はこのページを閲覧できます",
|
|
||||||
"Make this page publicly accessible": "このページを公開します",
|
|
||||||
"Include sub-pages": "サブページを含む",
|
|
||||||
"Make sub-pages public too": "サブページも公開する",
|
|
||||||
"Allow search engines to index page": "検索エンジンにページのインデックス作成を許可する",
|
|
||||||
"Open page": "ページを開く",
|
|
||||||
"Page": "ページ",
|
|
||||||
"Delete public share link": "公開リンクを削除",
|
|
||||||
"Delete share": "共有を削除",
|
|
||||||
"Are you sure you want to delete this shared link?": "この共有リンクを削除してもよろしいですか?",
|
|
||||||
"Publicly shared pages from spaces you are a member of will appear here": "メンバーであるスペースからの公開ページがここに表示されます",
|
|
||||||
"Share deleted successfully": "共有が正常に削除されました",
|
|
||||||
"Share not found": "共有が見つかりません",
|
|
||||||
"Failed to share page": "ページの共有に失敗しました",
|
|
||||||
"Copy page": "ページをコピー",
|
|
||||||
"Copy page to a different space.": "ページを別のスペースにコピーします。",
|
|
||||||
"Page copied successfully": "ページのコピーに成功しました"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -58,7 +58,7 @@
|
|||||||
"Enter a strong password": "강력한 비밀번호를 입력하세요",
|
"Enter a strong password": "강력한 비밀번호를 입력하세요",
|
||||||
"Enter valid email addresses separated by comma or space max_50": "유효한 이메일 주소를 쉼표나 공백으로 구분하여 입력하세요 [최대: 50]",
|
"Enter valid email addresses separated by comma or space max_50": "유효한 이메일 주소를 쉼표나 공백으로 구분하여 입력하세요 [최대: 50]",
|
||||||
"enter valid emails addresses": "유효한 이메일 주소를 입력하세요",
|
"enter valid emails addresses": "유효한 이메일 주소를 입력하세요",
|
||||||
"Enter your current password": "기존 비밀번호를 입력하세요",
|
"Enter your current password": "현재 비밀번호를 입력하세요",
|
||||||
"enter your full name": "전체 이름을 입력하세요",
|
"enter your full name": "전체 이름을 입력하세요",
|
||||||
"Enter your new password": "새 비밀번호를 입력하세요",
|
"Enter your new password": "새 비밀번호를 입력하세요",
|
||||||
"Enter your new preferred email": "새로운 이메일을 입력하세요",
|
"Enter your new preferred email": "새로운 이메일을 입력하세요",
|
||||||
@ -170,7 +170,7 @@
|
|||||||
"Successfully restored": "복원 완료",
|
"Successfully restored": "복원 완료",
|
||||||
"System settings": "시스템 설정",
|
"System settings": "시스템 설정",
|
||||||
"Theme": "배경",
|
"Theme": "배경",
|
||||||
"To change your email, you have to enter your password and new email.": "이메일을 변경하려면 기존 비밀번호와 새 이메일을 입력해야 합니다.",
|
"To change your email, you have to enter your password and new email.": "이메일을 변경하려면 현재 비밀번호와 새 이메일을 입력해야 합니다.",
|
||||||
"Toggle full page width": "전체 페이지 너비 전환",
|
"Toggle full page width": "전체 페이지 너비 전환",
|
||||||
"Unable to import pages. Please try again.": "페이지를 가져올 수 없습니다. 다시 시도해주세요.",
|
"Unable to import pages. Please try again.": "페이지를 가져올 수 없습니다. 다시 시도해주세요.",
|
||||||
"untitled": "제목 없음",
|
"untitled": "제목 없음",
|
||||||
@ -362,29 +362,5 @@
|
|||||||
"Move page to a different space.": "페이지를 다른 공간으로 이동합니다.",
|
"Move page to a different space.": "페이지를 다른 공간으로 이동합니다.",
|
||||||
"Real-time editor connection lost. Retrying...": "실시간 편집기 연결이 끊어졌습니다. 재시도 중...",
|
"Real-time editor connection lost. Retrying...": "실시간 편집기 연결이 끊어졌습니다. 재시도 중...",
|
||||||
"Table of contents": "목차",
|
"Table of contents": "목차",
|
||||||
"Add headings (H1, H2, H3) to generate a table of contents.": "목차를 생성하려면 제목 (H1, H2, H3)을 추가하세요.",
|
"Add headings (H1, H2, H3) to generate a table of contents.": "목차를 생성하려면 제목 (H1, H2, H3)을 추가하세요."
|
||||||
"Share": "공유",
|
|
||||||
"Public sharing": "공개 공유",
|
|
||||||
"Shared by": "공유자",
|
|
||||||
"Shared at": "공유 시간",
|
|
||||||
"Inherits public sharing from": "로부터 공개 공유를 상속함",
|
|
||||||
"Share to web": "웹에 공유",
|
|
||||||
"Shared to web": "웹에 공유됨",
|
|
||||||
"Anyone with the link can view this page": "링크가 있는 사람은 이 페이지를 볼 수 있습니다",
|
|
||||||
"Make this page publicly accessible": "이 페이지를 공개적으로 접근 가능하게 만들기",
|
|
||||||
"Include sub-pages": "하위 페이지 포함",
|
|
||||||
"Make sub-pages public too": "하위 페이지도 공개로 설정",
|
|
||||||
"Allow search engines to index page": "검색 엔진이 페이지를 색인할 수 있도록 허용",
|
|
||||||
"Open page": "페이지 열기",
|
|
||||||
"Page": "페이지",
|
|
||||||
"Delete public share link": "공유 링크 삭제",
|
|
||||||
"Delete share": "공유 삭제",
|
|
||||||
"Are you sure you want to delete this shared link?": "이 공유 링크를 삭제하시겠습니까?",
|
|
||||||
"Publicly shared pages from spaces you are a member of will appear here": "회원인 공간의 공개 공유된 페이지가 여기에 표시됩니다",
|
|
||||||
"Share deleted successfully": "공유가 성공적으로 삭제되었습니다",
|
|
||||||
"Share not found": "공유를 찾을 수 없습니다",
|
|
||||||
"Failed to share page": "페이지 공유에 실패했습니다",
|
|
||||||
"Copy page": "Copy page",
|
|
||||||
"Copy page to a different space.": "Copy page to a different space.",
|
|
||||||
"Page copied successfully": "Page copied successfully"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -362,29 +362,5 @@
|
|||||||
"Move page to a different space.": "Verplaats pagina naar een andere ruimte.",
|
"Move page to a different space.": "Verplaats pagina naar een andere ruimte.",
|
||||||
"Real-time editor connection lost. Retrying...": "Realtime editorverbinding verloren. Opnieuw proberen...",
|
"Real-time editor connection lost. Retrying...": "Realtime editorverbinding verloren. Opnieuw proberen...",
|
||||||
"Table of contents": "Inhoudsopgave",
|
"Table of contents": "Inhoudsopgave",
|
||||||
"Add headings (H1, H2, H3) to generate a table of contents.": "Voeg koppen (H1, H2, H3) toe om een inhoudsopgave te genereren.",
|
"Add headings (H1, H2, H3) to generate a table of contents.": "Voeg koppen (H1, H2, H3) toe om een inhoudsopgave te genereren."
|
||||||
"Share": "Delen",
|
|
||||||
"Public sharing": "Openbaar delen",
|
|
||||||
"Shared by": "Gedeeld door",
|
|
||||||
"Shared at": "Gedeeld op",
|
|
||||||
"Inherits public sharing from": "Erft openbaar delen van",
|
|
||||||
"Share to web": "Delen naar web",
|
|
||||||
"Shared to web": "Gedeeld naar web",
|
|
||||||
"Anyone with the link can view this page": "Iedereen met de link kan deze pagina bekijken",
|
|
||||||
"Make this page publicly accessible": "Maak deze pagina openbaar toegankelijk",
|
|
||||||
"Include sub-pages": "Inclusief subpagina's",
|
|
||||||
"Make sub-pages public too": "Maak subpagina's ook openbaar",
|
|
||||||
"Allow search engines to index page": "Sta zoekmachines toe om pagina te indexeren",
|
|
||||||
"Open page": "Pagina openen",
|
|
||||||
"Page": "Pagina",
|
|
||||||
"Delete public share link": "Verwijder openbare deel-link",
|
|
||||||
"Delete share": "Verwijder deel",
|
|
||||||
"Are you sure you want to delete this shared link?": "Weet u zeker dat u deze gedeelde link wilt verwijderen?",
|
|
||||||
"Publicly shared pages from spaces you are a member of will appear here": "Openbaar gedeelde pagina's van ruimtes waarvan u lid bent, verschijnen hier",
|
|
||||||
"Share deleted successfully": "Delen succesvol verwijderd",
|
|
||||||
"Share not found": "Delen niet gevonden",
|
|
||||||
"Failed to share page": "Pagina delen mislukt",
|
|
||||||
"Copy page": "Copy page",
|
|
||||||
"Copy page to a different space.": "Copy page to a different space.",
|
|
||||||
"Page copied successfully": "Page copied successfully"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -362,29 +362,5 @@
|
|||||||
"Move page to a different space.": "Mover página para um espaço diferente.",
|
"Move page to a different space.": "Mover página para um espaço diferente.",
|
||||||
"Real-time editor connection lost. Retrying...": "Conexão do editor em tempo real perdida. Tentando novamente...",
|
"Real-time editor connection lost. Retrying...": "Conexão do editor em tempo real perdida. Tentando novamente...",
|
||||||
"Table of contents": "Tabela de conteúdos",
|
"Table of contents": "Tabela de conteúdos",
|
||||||
"Add headings (H1, H2, H3) to generate a table of contents.": "Adicionar títulos (H1, H2, H3) para gerar uma tabela de conteúdo.",
|
"Add headings (H1, H2, H3) to generate a table of contents.": "Adicionar títulos (H1, H2, H3) para gerar uma tabela de conteúdo."
|
||||||
"Share": "Compartilhar",
|
|
||||||
"Public sharing": "Compartilhamento público",
|
|
||||||
"Shared by": "Compartilhado por",
|
|
||||||
"Shared at": "Compartilhado em",
|
|
||||||
"Inherits public sharing from": "Herdado do compartilhamento público de",
|
|
||||||
"Share to web": "Compartilhar na web",
|
|
||||||
"Shared to web": "Compartilhado na web",
|
|
||||||
"Anyone with the link can view this page": "Qualquer um com o link pode ver esta página",
|
|
||||||
"Make this page publicly accessible": "Tornar esta página publicamente acessível",
|
|
||||||
"Include sub-pages": "Incluir sub-páginas",
|
|
||||||
"Make sub-pages public too": "Tornar as sub-páginas públicas também",
|
|
||||||
"Allow search engines to index page": "Permitir que mecanismos de busca indexem a página",
|
|
||||||
"Open page": "Abrir página",
|
|
||||||
"Page": "Página",
|
|
||||||
"Delete public share link": "Excluir o link público compartilhado",
|
|
||||||
"Delete share": "Excluir compartilhamento",
|
|
||||||
"Are you sure you want to delete this shared link?": "Tem certeza de que deseja excluir este link compartilhado?",
|
|
||||||
"Publicly shared pages from spaces you are a member of will appear here": "Páginas compartilhadas publicamente de espaços que você é membro aparecerão aqui",
|
|
||||||
"Share deleted successfully": "Compartilhamento excluído com sucesso",
|
|
||||||
"Share not found": "Compartilhamento não encontrado",
|
|
||||||
"Failed to share page": "Falha ao compartilhar página",
|
|
||||||
"Copy page": "Copy page",
|
|
||||||
"Copy page to a different space.": "Copy page to a different space.",
|
|
||||||
"Page copied successfully": "Page copied successfully"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -13,11 +13,11 @@
|
|||||||
"Are you sure you want to remove this user from the group? The user will lose access to resources this group has access to.": "Вы уверены, что хотите удалить этого пользователя из группы? Пользователь потеряет доступ к материалам, к которым у этой группы есть доступ.",
|
"Are you sure you want to remove this user from the group? The user will lose access to resources this group has access to.": "Вы уверены, что хотите удалить этого пользователя из группы? Пользователь потеряет доступ к материалам, к которым у этой группы есть доступ.",
|
||||||
"Are you sure you want to remove this user from the space? The user will lose all access to this space.": "Вы уверены, что хотите удалить этого пользователя из пространства? Пользователь потеряет весь доступ к этому пространству.",
|
"Are you sure you want to remove this user from the space? The user will lose all access to this space.": "Вы уверены, что хотите удалить этого пользователя из пространства? Пользователь потеряет весь доступ к этому пространству.",
|
||||||
"Are you sure you want to restore this version? Any changes not versioned will be lost.": "Вы уверены, что хотите восстановить эту версию? Все не зафиксированные изменения будут потеряны.",
|
"Are you sure you want to restore this version? Any changes not versioned will be lost.": "Вы уверены, что хотите восстановить эту версию? Все не зафиксированные изменения будут потеряны.",
|
||||||
"Can become members of groups and spaces in workspace": "Могут становиться участниками групп и пространств в рабочей области",
|
"Can become members of groups and spaces in workspace": "Могут становиться участниками групп и пространств в рабочем пространстве",
|
||||||
"Can create and edit pages in space.": "Может создавать и редактировать страницы в пространстве.",
|
"Can create and edit pages in space.": "Может создавать и редактировать страницы в пространстве.",
|
||||||
"Can edit": "Может изменять",
|
"Can edit": "Может изменять",
|
||||||
"Can manage workspace": "Может управлять рабочей областью",
|
"Can manage workspace": "Может управлять рабочим пространством",
|
||||||
"Can manage workspace but cannot delete it": "Может управлять рабочей областью, но не может ее удалить",
|
"Can manage workspace but cannot delete it": "Может управлять рабочим пространством, но не может его удалить",
|
||||||
"Can view": "Может просматривать",
|
"Can view": "Может просматривать",
|
||||||
"Can view pages in space but not edit.": "Может просматривать страницы в пространстве, но не может их редактировать.",
|
"Can view pages in space but not edit.": "Может просматривать страницы в пространстве, но не может их редактировать.",
|
||||||
"Cancel": "Отменить",
|
"Cancel": "Отменить",
|
||||||
@ -34,7 +34,7 @@
|
|||||||
"Create group": "Создать группу",
|
"Create group": "Создать группу",
|
||||||
"Create page": "Создать страницу",
|
"Create page": "Создать страницу",
|
||||||
"Create space": "Создать пространство",
|
"Create space": "Создать пространство",
|
||||||
"Create workspace": "Создать рабочую область",
|
"Create workspace": "Создать рабочее пространство",
|
||||||
"Current password": "Текущий пароль",
|
"Current password": "Текущий пароль",
|
||||||
"Dark": "Темная",
|
"Dark": "Темная",
|
||||||
"Date": "Дата",
|
"Date": "Дата",
|
||||||
@ -92,7 +92,7 @@
|
|||||||
"Invite new members": "Пригласить новых участников",
|
"Invite new members": "Пригласить новых участников",
|
||||||
"Invited members who are yet to accept their invitation will appear here.": "Приглашённые участники, которые ещё не приняли приглашение, появятся здесь.",
|
"Invited members who are yet to accept their invitation will appear here.": "Приглашённые участники, которые ещё не приняли приглашение, появятся здесь.",
|
||||||
"Invited members will be granted access to spaces the groups can access": "Приглашённые участники получат доступ к пространствам, доступ к которым есть у группы",
|
"Invited members will be granted access to spaces the groups can access": "Приглашённые участники получат доступ к пространствам, доступ к которым есть у группы",
|
||||||
"Join the workspace": "Присоединиться к рабочей области",
|
"Join the workspace": "Присоединиться к рабочему пространству",
|
||||||
"Language": "Язык",
|
"Language": "Язык",
|
||||||
"Light": "Светлая",
|
"Light": "Светлая",
|
||||||
"Link copied": "Ссылка скопирована",
|
"Link copied": "Ссылка скопирована",
|
||||||
@ -150,7 +150,7 @@
|
|||||||
"Send invitation": "Отправить приглашение",
|
"Send invitation": "Отправить приглашение",
|
||||||
"Invitation sent": "Приглашение отправлено",
|
"Invitation sent": "Приглашение отправлено",
|
||||||
"Settings": "Настройки",
|
"Settings": "Настройки",
|
||||||
"Setup workspace": "Настроить рабочую область",
|
"Setup workspace": "Настроить рабочее пространство",
|
||||||
"Sign In": "Вход",
|
"Sign In": "Вход",
|
||||||
"Sign Up": "Регистрация",
|
"Sign Up": "Регистрация",
|
||||||
"Slug": "Slug",
|
"Slug": "Slug",
|
||||||
@ -177,9 +177,9 @@
|
|||||||
"Untitled": "Без названия",
|
"Untitled": "Без названия",
|
||||||
"Updated successfully": "Обновлено успешно",
|
"Updated successfully": "Обновлено успешно",
|
||||||
"User": "Пользователь",
|
"User": "Пользователь",
|
||||||
"Workspace": "Рабочая область",
|
"Workspace": "Рабочее пространство",
|
||||||
"Workspace Name": "Имя рабочей области",
|
"Workspace Name": "Имя рабочего пространства",
|
||||||
"Workspace settings": "Настройки рабочей области",
|
"Workspace settings": "Настройки рабочего пространства",
|
||||||
"You can change your password here.": "Вы можете изменить свой пароль здесь.",
|
"You can change your password here.": "Вы можете изменить свой пароль здесь.",
|
||||||
"Your Email": "Ваш адрес электронной почты",
|
"Your Email": "Ваш адрес электронной почты",
|
||||||
"Your import is complete.": "Ваш импорт завершен.",
|
"Your import is complete.": "Ваш импорт завершен.",
|
||||||
@ -217,9 +217,9 @@
|
|||||||
"Revoke invitation": "Отозвать приглашение",
|
"Revoke invitation": "Отозвать приглашение",
|
||||||
"Revoke": "Отозвать",
|
"Revoke": "Отозвать",
|
||||||
"Don't": "Нет",
|
"Don't": "Нет",
|
||||||
"Are you sure you want to revoke this invitation? The user will not be able to join the workspace.": "Вы уверены, что хотите отозвать это приглашение? Пользователь не сможет присоединиться к рабочей области.",
|
"Are you sure you want to revoke this invitation? The user will not be able to join the workspace.": "Вы уверены, что хотите отозвать это приглашение? Пользователь не сможет присоединиться к рабочему пространству.",
|
||||||
"Resend invitation": "Отправить приглашение повторно",
|
"Resend invitation": "Отправить приглашение повторно",
|
||||||
"Anyone with this link can join this workspace.": "Любой, у кого есть данная ссылка, может присоединиться к этой рабочей области.",
|
"Anyone with this link can join this workspace.": "Любой, у кого есть эта ссылка, может присоединиться к этому рабочему пространству.",
|
||||||
"Invite link": "Ссылка для приглашения",
|
"Invite link": "Ссылка для приглашения",
|
||||||
"Copy": "Копировать",
|
"Copy": "Копировать",
|
||||||
"Copied": "Скопировано",
|
"Copied": "Скопировано",
|
||||||
@ -362,29 +362,5 @@
|
|||||||
"Move page to a different space.": "Переместите страницу в другое пространство.",
|
"Move page to a different space.": "Переместите страницу в другое пространство.",
|
||||||
"Real-time editor connection lost. Retrying...": "Соединение с редактором в реальном времени потеряно. Повторная попытка...",
|
"Real-time editor connection lost. Retrying...": "Соединение с редактором в реальном времени потеряно. Повторная попытка...",
|
||||||
"Table of contents": "Содержание",
|
"Table of contents": "Содержание",
|
||||||
"Add headings (H1, H2, H3) to generate a table of contents.": "Добавьте заголовки (H1, H2, H3), чтобы создать оглавление.",
|
"Add headings (H1, H2, H3) to generate a table of contents.": "Добавьте заголовки (H1, H2, H3), чтобы создать оглавление."
|
||||||
"Share": "Поделиться",
|
|
||||||
"Public sharing": "Общий доступ",
|
|
||||||
"Shared by": "Поделился",
|
|
||||||
"Shared at": "Поделился в",
|
|
||||||
"Inherits public sharing from": "Наследует общий доступ от",
|
|
||||||
"Share to web": "Поделиться в интернете",
|
|
||||||
"Shared to web": "Размещено в интернете",
|
|
||||||
"Anyone with the link can view this page": "Любой, у кого есть ссылка, может просмотреть эту страницу",
|
|
||||||
"Make this page publicly accessible": "Сделать эту страницу общедоступной",
|
|
||||||
"Include sub-pages": "Включить подстраницы",
|
|
||||||
"Make sub-pages public too": "Сделать подстраницы также общедоступными",
|
|
||||||
"Allow search engines to index page": "Разрешить поисковым системам индексировать страницу",
|
|
||||||
"Open page": "Открыть страницу",
|
|
||||||
"Page": "Страница",
|
|
||||||
"Delete public share link": "Удалить ссылку на общий доступ",
|
|
||||||
"Delete share": "Удалить общий доступ",
|
|
||||||
"Are you sure you want to delete this shared link?": "Вы уверены, что хотите удалить эту ссылку общего доступа?",
|
|
||||||
"Publicly shared pages from spaces you are a member of will appear here": "Общие страницы из пространств, участником которых вы являетесь, появятся здесь",
|
|
||||||
"Share deleted successfully": "Общий доступ успешно удален",
|
|
||||||
"Share not found": "Общий доступ не найден",
|
|
||||||
"Failed to share page": "Не удалось поделиться страницей",
|
|
||||||
"Copy page": "Копировать страницу",
|
|
||||||
"Copy page to a different space.": "Копировать страницу в другое пространство.",
|
|
||||||
"Page copied successfully": "Страница успешно скопирована"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,390 +0,0 @@
|
|||||||
{
|
|
||||||
"Account": "Обліковий запис",
|
|
||||||
"Active": "Активний",
|
|
||||||
"Add": "Додати",
|
|
||||||
"Add group members": "Додати учасників групи",
|
|
||||||
"Add groups": "Додати групи",
|
|
||||||
"Add members": "Додати учасників",
|
|
||||||
"Add to groups": "Додати до груп",
|
|
||||||
"Add space members": "Додати учасників простору",
|
|
||||||
"Admin": "Адміністратор",
|
|
||||||
"Are you sure you want to delete this group? Members will lose access to resources this group has access to.": "Ви впевнені, що хочете видалити цю групу? Учасники втратять доступ до матеріалів, до яких ця група має доступ.",
|
|
||||||
"Are you sure you want to delete this page?": "Ви впевнені, що хочете видалити цю сторінку?",
|
|
||||||
"Are you sure you want to remove this user from the group? The user will lose access to resources this group has access to.": "Ви впевнені, що хочете видалити цього користувача з групи? Користувач втратить доступ до матеріалів, до яких ця група має доступ.",
|
|
||||||
"Are you sure you want to remove this user from the space? The user will lose all access to this space.": "Ви впевнені, що хочете видалити цього користувача з простору? Користувач втратить весь доступ до цього простору.",
|
|
||||||
"Are you sure you want to restore this version? Any changes not versioned will be lost.": "Ви впевнені, що хочете відновити цю версію? Усі не збережені зміни будуть втрачені.",
|
|
||||||
"Can become members of groups and spaces in workspace": "Можуть ставати учасниками груп та просторів у робочій області",
|
|
||||||
"Can create and edit pages in space.": "Може створювати та редагувати сторінки в просторі.",
|
|
||||||
"Can edit": "Може редагувати",
|
|
||||||
"Can manage workspace": "Може керувати робочою областю",
|
|
||||||
"Can manage workspace but cannot delete it": "Може керувати робочою областю, але не може її видалити",
|
|
||||||
"Can view": "Може переглядати",
|
|
||||||
"Can view pages in space but not edit.": "Може переглядати сторінки в просторі, але не може їх редагувати.",
|
|
||||||
"Cancel": "Скасувати",
|
|
||||||
"Change email": "Змінити електронну пошту",
|
|
||||||
"Change password": "Змінити пароль",
|
|
||||||
"Change photo": "Змінити фото",
|
|
||||||
"Choose a role": "Оберіть роль",
|
|
||||||
"Choose your preferred color scheme.": "Оберіть бажану кольорову схему.",
|
|
||||||
"Choose your preferred interface language.": "Оберіть бажану мову інтерфейсу.",
|
|
||||||
"Choose your preferred page width.": "Оберіть бажану ширину сторінки.",
|
|
||||||
"Confirm": "Підтвердити",
|
|
||||||
"Copy link": "Копіювати посилання",
|
|
||||||
"Create": "Створити",
|
|
||||||
"Create group": "Створити групу",
|
|
||||||
"Create page": "Створити сторінку",
|
|
||||||
"Create space": "Створити простір",
|
|
||||||
"Create workspace": "Створити робочу область",
|
|
||||||
"Current password": "Поточний пароль",
|
|
||||||
"Dark": "Темна",
|
|
||||||
"Date": "Дата",
|
|
||||||
"Delete": "Видалити",
|
|
||||||
"Delete group": "Видалити групу",
|
|
||||||
"Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.": "Ви впевнені, що хочете видалити цю сторінку? Це видалить її дочірні сторінки, а також історію сторінки. Ця дія необоротна.",
|
|
||||||
"Description": "Опис",
|
|
||||||
"Details": "Деталі",
|
|
||||||
"e.g ACME": "наприклад, ACME",
|
|
||||||
"e.g ACME Inc": "наприклад, ACME Inc",
|
|
||||||
"e.g Developers": "наприклад, Розробники",
|
|
||||||
"e.g Group for developers": "наприклад, Група для розробників",
|
|
||||||
"e.g product": "наприклад, продукт",
|
|
||||||
"e.g Product Team": "наприклад, Продуктова команда",
|
|
||||||
"e.g Sales": "наприклад, Продажі",
|
|
||||||
"e.g Space for product team": "наприклад, Простір для продуктової команди",
|
|
||||||
"e.g Space for sales team to collaborate": "наприклад, Простір для спільної роботи команди продажів",
|
|
||||||
"Edit": "Редагувати",
|
|
||||||
"Edit group": "Редагувати групу",
|
|
||||||
"Email": "Електронна пошта",
|
|
||||||
"Enter a strong password": "Введіть надійний пароль",
|
|
||||||
"Enter valid email addresses separated by comma or space max_50": "Введіть дійсні адреси електронної пошти, розділені комою або пробілом [макс: 50]",
|
|
||||||
"enter valid emails addresses": "введіть дійсні адреси електронної пошти",
|
|
||||||
"Enter your current password": "Введіть ваш поточний пароль",
|
|
||||||
"enter your full name": "введіть ваше повне ім'я",
|
|
||||||
"Enter your new password": "Введіть ваш новий пароль",
|
|
||||||
"Enter your new preferred email": "Введіть вашу нову бажану електронну пошту",
|
|
||||||
"Enter your password": "Введіть ваш пароль",
|
|
||||||
"Error fetching page data.": "Помилка при завантаженні даних сторінки.",
|
|
||||||
"Error loading page history.": "Помилка при завантаженні історії сторінки.",
|
|
||||||
"Export": "Експорт",
|
|
||||||
"Failed to create page": "Не вдалося створити сторінку",
|
|
||||||
"Failed to delete page": "Не вдалося видалити сторінку",
|
|
||||||
"Failed to fetch recent pages": "Не вдалося отримати нещодавні сторінки",
|
|
||||||
"Failed to import pages": "Не вдалося імпортувати сторінки",
|
|
||||||
"Failed to load page. An error occurred.": "Не вдалося завантажити сторінку. Сталася помилка.",
|
|
||||||
"Failed to update data": "Не вдалося оновити дані",
|
|
||||||
"Full access": "Повний доступ",
|
|
||||||
"Full page width": "Ширина на всю сторінку",
|
|
||||||
"Full width": "На всю ширину",
|
|
||||||
"General": "Загальні",
|
|
||||||
"Group": "Група",
|
|
||||||
"Group description": "Опис групи",
|
|
||||||
"Group name": "Назва групи",
|
|
||||||
"Groups": "Групи",
|
|
||||||
"Has full access to space settings and pages.": "Має повний доступ до налаштувань простору та сторінок.",
|
|
||||||
"Home": "Головна",
|
|
||||||
"Import pages": "Імпорт сторінок",
|
|
||||||
"Import pages & space settings": "Імпорт сторінок і налаштування простору",
|
|
||||||
"Importing pages": "Імпортування сторінок",
|
|
||||||
"invalid invitation link": "посилання на запрошення недійсне",
|
|
||||||
"Invitation signup": "Реєстрація за запрошенням",
|
|
||||||
"Invite by email": "Запросити електронною поштою",
|
|
||||||
"Invite members": "Запросити учасників",
|
|
||||||
"Invite new members": "Запросити нових учасників",
|
|
||||||
"Invited members who are yet to accept their invitation will appear here.": "Запрошені учасники, які ще не прийняли запрошення, з'являться тут.",
|
|
||||||
"Invited members will be granted access to spaces the groups can access": "Запрошені учасники отримають доступ до просторів, доступ до яких має група",
|
|
||||||
"Join the workspace": "Приєднатися до робочої області",
|
|
||||||
"Language": "Мова",
|
|
||||||
"Light": "Світла",
|
|
||||||
"Link copied": "Посилання скопійовано",
|
|
||||||
"Login": "Увійти",
|
|
||||||
"Logout": "Вийти",
|
|
||||||
"Manage Group": "Керування групою",
|
|
||||||
"Manage members": "Керування учасниками",
|
|
||||||
"member": "учасник",
|
|
||||||
"Member": "Учасник",
|
|
||||||
"members": "учасники",
|
|
||||||
"Members": "Учасники",
|
|
||||||
"My preferences": "Мої налаштування",
|
|
||||||
"My Profile": "Мій профіль",
|
|
||||||
"My profile": "Мій профіль",
|
|
||||||
"Name": "Ім'я",
|
|
||||||
"New email": "Нова електронна адреса",
|
|
||||||
"New page": "Нова сторінка",
|
|
||||||
"New password": "Новий пароль",
|
|
||||||
"No group found": "Групу не знайдено",
|
|
||||||
"No page history saved yet.": "Історія сторінок ще не збережена.",
|
|
||||||
"No pages yet": "Сторінок поки немає",
|
|
||||||
"No results found...": "Результати не знайдено...",
|
|
||||||
"No user found": "Користувача не знайдено",
|
|
||||||
"Overview": "Огляд",
|
|
||||||
"Owner": "Власник",
|
|
||||||
"page": "сторінка",
|
|
||||||
"Page deleted successfully": "Сторінку успішно видалено",
|
|
||||||
"Page history": "Історія сторінки",
|
|
||||||
"Page import is in progress. Please do not close this tab.": "Імпорт сторінки в процесі. Будь ласка, не закривайте цю вкладку.",
|
|
||||||
"Pages": "Сторінки",
|
|
||||||
"pages": "сторінки",
|
|
||||||
"Password": "Пароль",
|
|
||||||
"Password changed successfully": "Пароль успішно змінено",
|
|
||||||
"Pending": "В очікуванні",
|
|
||||||
"Please confirm your action": "Будь ласка, підтвердіть вашу дію",
|
|
||||||
"Preferences": "Налаштування",
|
|
||||||
"Print PDF": "Друк PDF",
|
|
||||||
"Profile": "Профіль",
|
|
||||||
"Recently updated": "Нещодавно оновлено",
|
|
||||||
"Remove": "Видалити",
|
|
||||||
"Remove group member": "Видалити учасника групи",
|
|
||||||
"Remove space member": "Видалити учасника простору",
|
|
||||||
"Restore": "Відновити",
|
|
||||||
"Role": "Роль",
|
|
||||||
"Save": "Зберегти",
|
|
||||||
"Search": "Пошук",
|
|
||||||
"Search for groups": "Пошук груп",
|
|
||||||
"Search for users": "Пошук користувачів",
|
|
||||||
"Search for users and groups": "Пошук користувачів та груп",
|
|
||||||
"Search...": "Пошук...",
|
|
||||||
"Select language": "Оберіть мову",
|
|
||||||
"Select role": "Оберіть роль",
|
|
||||||
"Select role to assign to all invited members": "Оберіть роль для всіх запрошених учасників",
|
|
||||||
"Select theme": "Оберіть тему",
|
|
||||||
"Send invitation": "Надіслати запрошення",
|
|
||||||
"Invitation sent": "Запрошення надіслано",
|
|
||||||
"Settings": "Налаштування",
|
|
||||||
"Setup workspace": "Налаштувати робочу область",
|
|
||||||
"Sign In": "Вхід",
|
|
||||||
"Sign Up": "Реєстрація",
|
|
||||||
"Slug": "Slug",
|
|
||||||
"Space": "Простір",
|
|
||||||
"Space description": "Опис простору",
|
|
||||||
"Space menu": "Меню простору",
|
|
||||||
"Space name": "Назва простору",
|
|
||||||
"Space settings": "Налаштування простору",
|
|
||||||
"Space slug": "Slug простору",
|
|
||||||
"Spaces": "Простори",
|
|
||||||
"Spaces you belong to": "Простори, до яких ви належите",
|
|
||||||
"No space found": "Простори не знайдено",
|
|
||||||
"Search for spaces": "Пошук просторів",
|
|
||||||
"Start typing to search...": "Почніть вводити для пошуку...",
|
|
||||||
"Status": "Статус",
|
|
||||||
"Successfully imported": "Успішно імпортовано",
|
|
||||||
"Successfully restored": "Успішно відновлено",
|
|
||||||
"System settings": "Системні налаштування",
|
|
||||||
"Theme": "Тема",
|
|
||||||
"To change your email, you have to enter your password and new email.": "Щоб змінити електронну пошту, вам потрібно ввести пароль і нову адресу.",
|
|
||||||
"Toggle full page width": "Перемкнути ширину на всю сторінку",
|
|
||||||
"Unable to import pages. Please try again.": "Не вдалося імпортувати сторінки. Будь ласка, спробуйте ще раз.",
|
|
||||||
"untitled": "без назви",
|
|
||||||
"Untitled": "Без назви",
|
|
||||||
"Updated successfully": "Оновлено успішно",
|
|
||||||
"User": "Користувач",
|
|
||||||
"Workspace": "Робоча область",
|
|
||||||
"Workspace Name": "Ім'я робочої області",
|
|
||||||
"Workspace settings": "Налаштування робочої області",
|
|
||||||
"You can change your password here.": "Ви можете змінити свій пароль тут.",
|
|
||||||
"Your Email": "Ваша електронна пошта",
|
|
||||||
"Your import is complete.": "Ваш імпорт завершено.",
|
|
||||||
"Your name": "Ваше ім'я",
|
|
||||||
"Your Name": "Ваше ім'я",
|
|
||||||
"Your password": "Ваш пароль",
|
|
||||||
"Your password must be a minimum of 8 characters.": "Ваш пароль повинен містити мінімум 8 символів.",
|
|
||||||
"Sidebar toggle": "Перемкнути бічну панель",
|
|
||||||
"Comments": "Коментарі",
|
|
||||||
"404 page not found": "404 сторінку не знайдено",
|
|
||||||
"Sorry, we can't find the page you are looking for.": "На жаль, ми не можемо знайти сторінку, яку ви шукаєте.",
|
|
||||||
"Take me back to homepage": "Повернутися на головну сторінку",
|
|
||||||
"Forgot password": "Забули пароль",
|
|
||||||
"Forgot your password?": "Забули пароль?",
|
|
||||||
"A password reset link has been sent to your email. Please check your inbox.": "Посилання для скидання пароля було надіслано на вашу електронну адресу. Будь ласка, перевірте вхідні повідомлення.",
|
|
||||||
"Send reset link": "Надіслати посилання для скидання",
|
|
||||||
"Password reset": "Скидання пароля",
|
|
||||||
"Your new password": "Ваш новий пароль",
|
|
||||||
"Set password": "Встановити пароль",
|
|
||||||
"Write a comment": "Написати коментар",
|
|
||||||
"Reply...": "Відповісти...",
|
|
||||||
"Error loading comments.": "Помилка при завантаженні коментарів.",
|
|
||||||
"No comments yet.": "Коментарів поки немає.",
|
|
||||||
"Edit comment": "Редагувати коментар",
|
|
||||||
"Delete comment": "Видалити коментар",
|
|
||||||
"Are you sure you want to delete this comment?": "Ви впевнені, що хочете видалити цей коментар?",
|
|
||||||
"Comment created successfully": "Коментар успішно створено",
|
|
||||||
"Error creating comment": "Помилка при створенні коментаря",
|
|
||||||
"Comment updated successfully": "Коментар успішно оновлено",
|
|
||||||
"Failed to update comment": "Не вдалося оновити коментар",
|
|
||||||
"Comment deleted successfully": "Коментар успішно видалено",
|
|
||||||
"Failed to delete comment": "Не вдалося видалити коментар",
|
|
||||||
"Comment resolved successfully": "Коментар успішно вирішено",
|
|
||||||
"Failed to resolve comment": "Не вдалося вирішити коментар",
|
|
||||||
"Revoke invitation": "Відкликати запрошення",
|
|
||||||
"Revoke": "Відкликати",
|
|
||||||
"Don't": "Ні",
|
|
||||||
"Are you sure you want to revoke this invitation? The user will not be able to join the workspace.": "Ви впевнені, що хочете відкликати це запрошення? Користувач не зможе приєднатися до робочої області.",
|
|
||||||
"Resend invitation": "Надіслати запрошення повторно",
|
|
||||||
"Anyone with this link can join this workspace.": "Будь-хто, хто має це посилання, може приєднатися до цієї робочої області.",
|
|
||||||
"Invite link": "Посилання для запрошення",
|
|
||||||
"Copy": "Копіювати",
|
|
||||||
"Copied": "Скопійовано",
|
|
||||||
"Select a user": "Оберіть користувача",
|
|
||||||
"Select a group": "Оберіть групу",
|
|
||||||
"Export all pages and attachments in this space.": "Експортувати всі сторінки та вкладення в цьому просторі.",
|
|
||||||
"Delete space": "Видалити простір",
|
|
||||||
"Are you sure you want to delete this space?": "Ви впевнені, що хочете видалити цей простір?",
|
|
||||||
"Delete this space with all its pages and data.": "Видалити цей простір з усіма його сторінками та даними.",
|
|
||||||
"All pages, comments, attachments and permissions in this space will be deleted irreversibly.": "Усі сторінки, коментарі, вкладення та дозволи в цьому просторі будуть видалені безповоротно.",
|
|
||||||
"Confirm space name": "Підтвердіть назву простору",
|
|
||||||
"Type the space name <b>{{spaceName}}</b> to confirm your action.": "Введіть назву простору <b>{{spaceName}}</b>, щоб підтвердити вашу дію.",
|
|
||||||
"Format": "Формат",
|
|
||||||
"Include subpages": "Включити вкладені сторінки",
|
|
||||||
"Include attachments": "Включити вкладення",
|
|
||||||
"Select export format": "Виберіть формат експорту",
|
|
||||||
"Export failed:": "Експортування не вдалося:",
|
|
||||||
"export error": "помилка експорту",
|
|
||||||
"Export page": "Експорт сторінки",
|
|
||||||
"Export space": "Експорт простору",
|
|
||||||
"Export {{type}}": "Експорт {{type}}",
|
|
||||||
"File exceeds the {{limit}} attachment limit": "Файл перевищує ліміт вкладень {{limit}}",
|
|
||||||
"Align left": "По лівому краю",
|
|
||||||
"Align right": "По правому краю",
|
|
||||||
"Align center": "По центру",
|
|
||||||
"Justify": "По ширині",
|
|
||||||
"Merge cells": "Об'єднати комірки",
|
|
||||||
"Split cell": "Розділити комірку",
|
|
||||||
"Delete column": "Видалити стовпець",
|
|
||||||
"Delete row": "Видалити рядок",
|
|
||||||
"Add left column": "Додати стовпець ліворуч",
|
|
||||||
"Add right column": "Додати стовпець праворуч",
|
|
||||||
"Add row above": "Додати рядок вище",
|
|
||||||
"Add row below": "Додати рядок нижче",
|
|
||||||
"Delete table": "Видалити таблицю",
|
|
||||||
"Info": "Інформація",
|
|
||||||
"Success": "Успішно",
|
|
||||||
"Warning": "Попередження",
|
|
||||||
"Danger": "Важливо",
|
|
||||||
"Mermaid diagram error:": "Помилка діаграми Mermaid:",
|
|
||||||
"Invalid Mermaid diagram": "Неприпустима діаграма Mermaid",
|
|
||||||
"Double-click to edit Draw.io diagram": "Клацніть двічі для редагування діаграми Draw.io",
|
|
||||||
"Exit": "Вийти",
|
|
||||||
"Save & Exit": "Зберегти та вийти",
|
|
||||||
"Double-click to edit Excalidraw diagram": "Клацніть двічі для редагування діаграми Excalidraw",
|
|
||||||
"Paste link": "Вставити посилання",
|
|
||||||
"Edit link": "Редагувати посилання",
|
|
||||||
"Remove link": "Видалити посилання",
|
|
||||||
"Add link": "Додати посилання",
|
|
||||||
"Please enter a valid url": "Будь ласка, введіть коректний url",
|
|
||||||
"Empty equation": "Порожнє рівняння",
|
|
||||||
"Invalid equation": "Неприпустиме рівняння",
|
|
||||||
"Color": "Колір",
|
|
||||||
"Text color": "Колір тексту",
|
|
||||||
"Default": "За замовчуванням",
|
|
||||||
"Blue": "Синій",
|
|
||||||
"Green": "Зелений",
|
|
||||||
"Purple": "Фіолетовий",
|
|
||||||
"Red": "Червоний",
|
|
||||||
"Yellow": "Жовтий",
|
|
||||||
"Orange": "Помаранчевий",
|
|
||||||
"Pink": "Рожевий",
|
|
||||||
"Gray": "Сірий",
|
|
||||||
"Embed link": "Вбудоване посилання",
|
|
||||||
"Invalid {{provider}} embed link": "Невірне посилання для вбудовування {{provider}}",
|
|
||||||
"Embed {{provider}}": "Вбудувати {{provider}}",
|
|
||||||
"Enter {{provider}} link to embed": "Введіть посилання для вбудовування {{provider}}",
|
|
||||||
"Bold": "Жирний",
|
|
||||||
"Italic": "Курсив",
|
|
||||||
"Underline": "Підкреслений",
|
|
||||||
"Strike": "Закреслений",
|
|
||||||
"Code": "Код",
|
|
||||||
"Comment": "Коментар",
|
|
||||||
"Text": "Текст",
|
|
||||||
"Heading 1": "Заголовок 1",
|
|
||||||
"Heading 2": "Заголовок 2",
|
|
||||||
"Heading 3": "Заголовок 3",
|
|
||||||
"To-do List": "Список справ",
|
|
||||||
"Bullet List": "Маркований список",
|
|
||||||
"Numbered List": "Нумерований список",
|
|
||||||
"Blockquote": "Блок цитування",
|
|
||||||
"Just start typing with plain text.": "Просто почніть друкувати звичайний текст.",
|
|
||||||
"Track tasks with a to-do list.": "Відстежуйте завдання за допомогою списку справ.",
|
|
||||||
"Big section heading.": "Великий заголовок розділу.",
|
|
||||||
"Medium section heading.": "Середній заголовок розділу.",
|
|
||||||
"Small section heading.": "Малий заголовок розділу.",
|
|
||||||
"Create a simple bullet list.": "Створити простий маркований список.",
|
|
||||||
"Create a list with numbering.": "Створити нумерований список.",
|
|
||||||
"Create block quote.": "Створити блок цитування.",
|
|
||||||
"Insert code snippet.": "Вставити фрагмент коду.",
|
|
||||||
"Insert horizontal rule divider": "Вставити горизонтальний роздільник",
|
|
||||||
"Upload any image from your device.": "Завантажити будь-яке зображення з вашого пристрою.",
|
|
||||||
"Upload any video from your device.": "Завантажити будь-яке відео з вашого пристрою.",
|
|
||||||
"Upload any file from your device.": "Завантажити будь-який файл з вашого пристрою.",
|
|
||||||
"Table": "Таблиця",
|
|
||||||
"Insert a table.": "Вставити таблицю.",
|
|
||||||
"Insert collapsible block.": "Вставити блок, що згортається.",
|
|
||||||
"Video": "Відео",
|
|
||||||
"Divider": "Роздільник",
|
|
||||||
"Quote": "Цитата",
|
|
||||||
"Image": "Зображення",
|
|
||||||
"File attachment": "Прикріплений файл",
|
|
||||||
"Toggle block": "Блок, що згортається",
|
|
||||||
"Callout": "Виноска",
|
|
||||||
"Insert callout notice.": "Вставити виноску з повідомленням.",
|
|
||||||
"Math inline": "Формула",
|
|
||||||
"Insert inline math equation.": "Вставити математичне рівняння в рядок.",
|
|
||||||
"Math block": "Блок формул",
|
|
||||||
"Insert math equation": "Вставити математичне рівняння",
|
|
||||||
"Mermaid diagram": "Діаграма Mermaid",
|
|
||||||
"Insert mermaid diagram": "Вставити діаграму Mermaid",
|
|
||||||
"Insert and design Drawio diagrams": "Вставити та розробити діаграми Draw.io",
|
|
||||||
"Insert current date": "Вставити поточну дату",
|
|
||||||
"Draw and sketch excalidraw diagrams": "Вставити та малювати діаграми Excalidraw",
|
|
||||||
"Multiple": "Декілька",
|
|
||||||
"Heading {{level}}": "Заголовок {{level}}",
|
|
||||||
"Toggle title": "Перемкнути заголовок",
|
|
||||||
"Write anything. Enter \"/\" for commands": "Почніть писати. Введіть \"/\" для списку команд",
|
|
||||||
"Names do not match": "Назви не співпадають",
|
|
||||||
"Today, {{time}}": "Сьогодні, {{time}}",
|
|
||||||
"Yesterday, {{time}}": "Вчора, {{time}}",
|
|
||||||
"Space created successfully": "Простір успішно створено",
|
|
||||||
"Space updated successfully": "Простір успішно оновлено",
|
|
||||||
"Space deleted successfully": "Простір успішно видалено",
|
|
||||||
"Members added successfully": "Учасників успішно додано",
|
|
||||||
"Member removed successfully": "Учасника успішно видалено",
|
|
||||||
"Member role updated successfully": "Роль учасника успішно оновлено",
|
|
||||||
"Created by: <b>{{creatorName}}</b>": "Автор: <b>{{creatorName}}</b>",
|
|
||||||
"Created at: {{time}}": "Дата створення: {{time}}",
|
|
||||||
"Edited by {{name}} {{time}}": "Змінено {{name}} {{time}}",
|
|
||||||
"Word count: {{wordCount}}": "Кількість слів: {{wordCount}}",
|
|
||||||
"Character count: {{characterCount}}": "Кількість символів: {{characterCount}}",
|
|
||||||
"New update": "Нове оновлення",
|
|
||||||
"{{latestVersion}} is available": "Доступна нова версія {{latestVersion}}",
|
|
||||||
"Delete member": "Видалити учасника",
|
|
||||||
"Member deleted successfully": "Учасника успішно видалено",
|
|
||||||
"Are you sure you want to delete this workspace member? This action is irreversible.": "Ви впевнені, що хочете видалити цього учасника робочої області? Ця дія незворотна.",
|
|
||||||
"Move": "Перемістити",
|
|
||||||
"Move page": "Перемістити сторінку",
|
|
||||||
"Move page to a different space.": "Перемістити сторінку в інший простір.",
|
|
||||||
"Real-time editor connection lost. Retrying...": "З'єднання з редактором у реальному часі втрачено. Повторна спроба...",
|
|
||||||
"Table of contents": "Зміст",
|
|
||||||
"Add headings (H1, H2, H3) to generate a table of contents.": "Додайте заголовки (H1, H2, H3), щоб створити зміст.",
|
|
||||||
"Share": "Поділитися",
|
|
||||||
"Public sharing": "Публічний доступ",
|
|
||||||
"Shared by": "Поділився",
|
|
||||||
"Shared at": "Поділився в",
|
|
||||||
"Inherits public sharing from": "Успадковує публічний доступ від",
|
|
||||||
"Share to web": "Поділитися в інтернеті",
|
|
||||||
"Shared to web": "Розміщено в інтернеті",
|
|
||||||
"Anyone with the link can view this page": "Будь-хто, хто має посилання, може переглянути цю сторінку",
|
|
||||||
"Make this page publicly accessible": "Зробити цю сторінку загальнодоступною",
|
|
||||||
"Include sub-pages": "Включити підсторінки",
|
|
||||||
"Make sub-pages public too": "Зробити підсторінки також загальнодоступними",
|
|
||||||
"Allow search engines to index page": "Дозволити пошуковим системам індексувати сторінку",
|
|
||||||
"Open page": "Відкрити сторінку",
|
|
||||||
"Page": "Сторінка",
|
|
||||||
"Delete public share link": "Видалити посилання на публічний доступ",
|
|
||||||
"Delete share": "Видалити спільний доступ",
|
|
||||||
"Are you sure you want to delete this shared link?": "Ви впевнені, що хочете видалити це посилання спільного доступу?",
|
|
||||||
"Publicly shared pages from spaces you are a member of will appear here": "Публічні сторінки з просторів, учасником яких ви є, з'являться тут",
|
|
||||||
"Share deleted successfully": "Спільний доступ успішно видалено",
|
|
||||||
"Share not found": "Спільний доступ не знайдено",
|
|
||||||
"Failed to share page": "Не вдалося поділитися сторінкою",
|
|
||||||
"Copy page": "Копіювати сторінки",
|
|
||||||
"Copy page to a different space.": "Скопіювати сторінку в інший простір.",
|
|
||||||
"Page copied successfully": "Сторінку успішно скопійовано"
|
|
||||||
}
|
|
||||||
@ -298,7 +298,7 @@
|
|||||||
"Heading 2": "2 级标题",
|
"Heading 2": "2 级标题",
|
||||||
"Heading 3": "3 级标题",
|
"Heading 3": "3 级标题",
|
||||||
"To-do List": "代办列表",
|
"To-do List": "代办列表",
|
||||||
"Bullet List": "无序列表",
|
"Bullet List": "无需列表",
|
||||||
"Numbered List": "有序列表",
|
"Numbered List": "有序列表",
|
||||||
"Blockquote": "引用块",
|
"Blockquote": "引用块",
|
||||||
"Just start typing with plain text.": "只需开始键入纯文本",
|
"Just start typing with plain text.": "只需开始键入纯文本",
|
||||||
@ -362,29 +362,5 @@
|
|||||||
"Move page to a different space.": "将页面移动到不同的空间。",
|
"Move page to a different space.": "将页面移动到不同的空间。",
|
||||||
"Real-time editor connection lost. Retrying...": "实时编辑器连接丢失。重试中……",
|
"Real-time editor connection lost. Retrying...": "实时编辑器连接丢失。重试中……",
|
||||||
"Table of contents": "目录",
|
"Table of contents": "目录",
|
||||||
"Add headings (H1, H2, H3) to generate a table of contents.": "添加标题(H1,H2,H3)以生成目录。",
|
"Add headings (H1, H2, H3) to generate a table of contents.": "添加标题(H1,H2,H3)以生成目录。"
|
||||||
"Share": "分享",
|
|
||||||
"Public sharing": "公开分享",
|
|
||||||
"Shared by": "分享者",
|
|
||||||
"Shared at": "分享时间",
|
|
||||||
"Inherits public sharing from": "继承自的公开分享",
|
|
||||||
"Share to web": "分享到网页",
|
|
||||||
"Shared to web": "已分享到网页",
|
|
||||||
"Anyone with the link can view this page": "任何有链接的人都可以查看此页面",
|
|
||||||
"Make this page publicly accessible": "使此页面可公开访问",
|
|
||||||
"Include sub-pages": "包括子页面",
|
|
||||||
"Make sub-pages public too": "将子页面也设为公开",
|
|
||||||
"Allow search engines to index page": "允许搜索引擎索引页面",
|
|
||||||
"Open page": "打开页面",
|
|
||||||
"Page": "页面",
|
|
||||||
"Delete public share link": "删除公开分享链接",
|
|
||||||
"Delete share": "删除分享",
|
|
||||||
"Are you sure you want to delete this shared link?": "您确定要删除此分享链接吗?",
|
|
||||||
"Publicly shared pages from spaces you are a member of will appear here": "您所在空间的公开共享页面会显示在此处",
|
|
||||||
"Share deleted successfully": "分享已成功删除",
|
|
||||||
"Share not found": "未找到分享",
|
|
||||||
"Failed to share page": "页面分享失败",
|
|
||||||
"Copy page": "复制页面",
|
|
||||||
"Copy page to a different space.": "将页面复制到不同的空间。",
|
|
||||||
"Page copied successfully": "页面复制成功"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -26,20 +26,10 @@ import { useTranslation } from "react-i18next";
|
|||||||
import Security from "@/ee/security/pages/security.tsx";
|
import Security from "@/ee/security/pages/security.tsx";
|
||||||
import License from "@/ee/licence/pages/license.tsx";
|
import License from "@/ee/licence/pages/license.tsx";
|
||||||
import { useRedirectToCloudSelect } from "@/ee/hooks/use-redirect-to-cloud-select.tsx";
|
import { useRedirectToCloudSelect } from "@/ee/hooks/use-redirect-to-cloud-select.tsx";
|
||||||
import SharedPage from "@/pages/share/shared-page.tsx";
|
|
||||||
import Shares from "@/pages/settings/shares/shares.tsx";
|
|
||||||
import ShareLayout from "@/features/share/components/share-layout.tsx";
|
|
||||||
import ShareRedirect from "@/pages/share/share-redirect.tsx";
|
|
||||||
import { useTrackOrigin } from "@/hooks/use-track-origin";
|
|
||||||
import SpacesPage from "@/pages/spaces/spaces.tsx";
|
|
||||||
import { MfaChallengePage } from "@/ee/mfa/pages/mfa-challenge-page";
|
|
||||||
import { MfaSetupRequiredPage } from "@/ee/mfa/pages/mfa-setup-required-page";
|
|
||||||
import SpaceTrash from "@/pages/space/trash.tsx";
|
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
useRedirectToCloudSelect();
|
useRedirectToCloudSelect();
|
||||||
useTrackOrigin();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -49,11 +39,6 @@ export default function App() {
|
|||||||
<Route path={"/invites/:invitationId"} element={<InviteSignup />} />
|
<Route path={"/invites/:invitationId"} element={<InviteSignup />} />
|
||||||
<Route path={"/forgot-password"} element={<ForgotPassword />} />
|
<Route path={"/forgot-password"} element={<ForgotPassword />} />
|
||||||
<Route path={"/password-reset"} element={<PasswordReset />} />
|
<Route path={"/password-reset"} element={<PasswordReset />} />
|
||||||
<Route path={"/login/mfa"} element={<MfaChallengePage />} />
|
|
||||||
<Route
|
|
||||||
path={"/login/mfa/setup"}
|
|
||||||
element={<MfaSetupRequiredPage />}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{!isCloud() && (
|
{!isCloud() && (
|
||||||
<Route path={"/setup/register"} element={<SetupWorkspace />} />
|
<Route path={"/setup/register"} element={<SetupWorkspace />} />
|
||||||
@ -66,22 +51,11 @@ export default function App() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Route element={<ShareLayout />}>
|
|
||||||
<Route
|
|
||||||
path={"/share/:shareId/p/:pageSlug"}
|
|
||||||
element={<SharedPage />}
|
|
||||||
/>
|
|
||||||
<Route path={"/share/p/:pageSlug"} element={<SharedPage />} />
|
|
||||||
</Route>
|
|
||||||
|
|
||||||
<Route path={"/share/:shareId"} element={<ShareRedirect />} />
|
|
||||||
<Route path={"/p/:pageSlug"} element={<PageRedirect />} />
|
<Route path={"/p/:pageSlug"} element={<PageRedirect />} />
|
||||||
|
|
||||||
<Route element={<Layout />}>
|
<Route element={<Layout />}>
|
||||||
<Route path={"/home"} element={<Home />} />
|
<Route path={"/home"} element={<Home />} />
|
||||||
<Route path={"/spaces"} element={<SpacesPage />} />
|
|
||||||
<Route path={"/s/:spaceSlug"} element={<SpaceHome />} />
|
<Route path={"/s/:spaceSlug"} element={<SpaceHome />} />
|
||||||
<Route path={"/s/:spaceSlug/trash"} element={<SpaceTrash />} />
|
|
||||||
<Route
|
<Route
|
||||||
path={"/s/:spaceSlug/p/:pageSlug"}
|
path={"/s/:spaceSlug/p/:pageSlug"}
|
||||||
element={
|
element={
|
||||||
@ -104,7 +78,6 @@ export default function App() {
|
|||||||
<Route path={"groups"} element={<Groups />} />
|
<Route path={"groups"} element={<Groups />} />
|
||||||
<Route path={"groups/:groupId"} element={<GroupInfo />} />
|
<Route path={"groups/:groupId"} element={<GroupInfo />} />
|
||||||
<Route path={"spaces"} element={<Spaces />} />
|
<Route path={"spaces"} element={<Spaces />} />
|
||||||
<Route path={"sharing"} element={<Shares />} />
|
|
||||||
<Route path={"security"} element={<Security />} />
|
<Route path={"security"} element={<Security />} />
|
||||||
{!isCloud() && <Route path={"license"} element={<License />} />}
|
{!isCloud() && <Route path={"license"} element={<License />} />}
|
||||||
{isCloud() && <Route path={"billing"} element={<Billing />} />}
|
{isCloud() && <Route path={"billing"} element={<Billing />} />}
|
||||||
|
|||||||
@ -1,24 +0,0 @@
|
|||||||
import { Group, Text } from "@mantine/core";
|
|
||||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
|
||||||
import React from "react";
|
|
||||||
import { User } from "server/dist/database/types/entity.types";
|
|
||||||
|
|
||||||
interface UserInfoProps {
|
|
||||||
user: User;
|
|
||||||
size?: string;
|
|
||||||
}
|
|
||||||
export function UserInfo({ user, size }: UserInfoProps) {
|
|
||||||
return (
|
|
||||||
<Group gap="sm" wrap="nowrap">
|
|
||||||
<CustomAvatar avatarUrl={user?.avatarUrl} name={user?.name} size={size} />
|
|
||||||
<div>
|
|
||||||
<Text fz="sm" fw={500} lineClamp={1}>
|
|
||||||
{user?.name}
|
|
||||||
</Text>
|
|
||||||
<Text fz="xs" c="dimmed">
|
|
||||||
{user?.email}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
</Group>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,20 +0,0 @@
|
|||||||
import { rem } from "@mantine/core";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
size?: number | string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ConfluenceIcon({ size }: Props) {
|
|
||||||
return (
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
style={{ width: rem(size), height: rem(size) }}
|
|
||||||
>
|
|
||||||
<path d="M.87 18.257c-.248.382-.53.875-.763 1.245a.764.764 0 0 0 .255 1.04l4.965 3.054a.764.764 0 0 0 1.058-.26c.199-.332.454-.763.733-1.221 1.967-3.247 3.945-2.853 7.508-1.146l4.957 2.337a.764.764 0 0 0 1.028-.382l2.364-5.346a.764.764 0 0 0-.382-1 599.851 599.851 0 0 1-4.965-2.361C10.911 10.97 5.224 11.185.87 18.257zM23.131 5.743c.249-.405.531-.875.764-1.25a.764.764 0 0 0-.256-1.034L18.675.404a.764.764 0 0 0-1.058.26c-.195.335-.451.763-.734 1.225-1.966 3.246-3.945 2.85-7.508 1.146L4.437.694a.764.764 0 0 0-1.027.382L1.046 6.422a.764.764 0 0 0 .382 1c1.039.49 3.105 1.467 4.965 2.361 6.698 3.246 12.392 3.029 16.738-4.04z" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -27,8 +27,6 @@ export function AppHeader() {
|
|||||||
const { isTrial, trialDaysLeft } = useTrial();
|
const { isTrial, trialDaysLeft } = useTrial();
|
||||||
|
|
||||||
const isHomeRoute = location.pathname.startsWith("/home");
|
const isHomeRoute = location.pathname.startsWith("/home");
|
||||||
const isSpacesRoute = location.pathname === "/spaces";
|
|
||||||
const hideSidebar = isHomeRoute || isSpacesRoute;
|
|
||||||
|
|
||||||
const items = links.map((link) => (
|
const items = links.map((link) => (
|
||||||
<Link key={link.label} to={link.link} className={classes.link}>
|
<Link key={link.label} to={link.link} className={classes.link}>
|
||||||
@ -40,7 +38,7 @@ export function AppHeader() {
|
|||||||
<>
|
<>
|
||||||
<Group h="100%" px="md" justify="space-between" wrap={"nowrap"}>
|
<Group h="100%" px="md" justify="space-between" wrap={"nowrap"}>
|
||||||
<Group wrap="nowrap">
|
<Group wrap="nowrap">
|
||||||
{!hideSidebar && (
|
{!isHomeRoute && (
|
||||||
<>
|
<>
|
||||||
<Tooltip label={t("Sidebar toggle")}>
|
<Tooltip label={t("Sidebar toggle")}>
|
||||||
<SidebarToggle
|
<SidebarToggle
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { Box, ScrollArea, Text } from "@mantine/core";
|
import { Box, ScrollArea, Text } from "@mantine/core";
|
||||||
import CommentListWithTabs from "@/features/comment/components/comment-list-with-tabs.tsx";
|
import CommentList from "@/features/comment/components/comment-list.tsx";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
||||||
import React, { ReactNode } from "react";
|
import React, { ReactNode } from "react";
|
||||||
@ -18,7 +18,7 @@ export default function Aside() {
|
|||||||
|
|
||||||
switch (tab) {
|
switch (tab) {
|
||||||
case "comments":
|
case "comments":
|
||||||
component = <CommentListWithTabs />;
|
component = <CommentList />;
|
||||||
title = "Comments";
|
title = "Comments";
|
||||||
break;
|
break;
|
||||||
case "toc":
|
case "toc":
|
||||||
@ -38,17 +38,13 @@ export default function Aside() {
|
|||||||
{t(title)}
|
{t(title)}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
{tab === "comments" ? (
|
<ScrollArea
|
||||||
<CommentListWithTabs />
|
style={{ height: "85vh" }}
|
||||||
) : (
|
scrollbarSize={5}
|
||||||
<ScrollArea
|
type="scroll"
|
||||||
style={{ height: "85vh" }}
|
>
|
||||||
scrollbarSize={5}
|
<div style={{ paddingBottom: "200px" }}>{component}</div>
|
||||||
type="scroll"
|
</ScrollArea>
|
||||||
>
|
|
||||||
<div style={{ paddingBottom: "200px" }}>{component}</div>
|
|
||||||
</ScrollArea>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@ -14,7 +14,6 @@ import { AppHeader } from "@/components/layouts/global/app-header.tsx";
|
|||||||
import Aside from "@/components/layouts/global/aside.tsx";
|
import Aside from "@/components/layouts/global/aside.tsx";
|
||||||
import classes from "./app-shell.module.css";
|
import classes from "./app-shell.module.css";
|
||||||
import { useTrialEndAction } from "@/ee/hooks/use-trial-end-action.tsx";
|
import { useTrialEndAction } from "@/ee/hooks/use-trial-end-action.tsx";
|
||||||
import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts";
|
|
||||||
|
|
||||||
export default function GlobalAppShell({
|
export default function GlobalAppShell({
|
||||||
children,
|
children,
|
||||||
@ -23,7 +22,6 @@ export default function GlobalAppShell({
|
|||||||
}) {
|
}) {
|
||||||
useTrialEndAction();
|
useTrialEndAction();
|
||||||
const [mobileOpened] = useAtom(mobileSidebarAtom);
|
const [mobileOpened] = useAtom(mobileSidebarAtom);
|
||||||
const toggleMobile = useToggleSidebar(mobileSidebarAtom);
|
|
||||||
const [desktopOpened] = useAtom(desktopSidebarAtom);
|
const [desktopOpened] = useAtom(desktopSidebarAtom);
|
||||||
const [{ isAsideOpen }] = useAtom(asideStateAtom);
|
const [{ isAsideOpen }] = useAtom(asideStateAtom);
|
||||||
const [sidebarWidth, setSidebarWidth] = useAtom(sidebarWidthAtom);
|
const [sidebarWidth, setSidebarWidth] = useAtom(sidebarWidthAtom);
|
||||||
@ -73,15 +71,13 @@ export default function GlobalAppShell({
|
|||||||
const isSettingsRoute = location.pathname.startsWith("/settings");
|
const isSettingsRoute = location.pathname.startsWith("/settings");
|
||||||
const isSpaceRoute = location.pathname.startsWith("/s/");
|
const isSpaceRoute = location.pathname.startsWith("/s/");
|
||||||
const isHomeRoute = location.pathname.startsWith("/home");
|
const isHomeRoute = location.pathname.startsWith("/home");
|
||||||
const isSpacesRoute = location.pathname === "/spaces";
|
|
||||||
const isPageRoute = location.pathname.includes("/p/");
|
const isPageRoute = location.pathname.includes("/p/");
|
||||||
const hideSidebar = isHomeRoute || isSpacesRoute;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell
|
<AppShell
|
||||||
header={{ height: 45 }}
|
header={{ height: 45 }}
|
||||||
navbar={
|
navbar={
|
||||||
!hideSidebar && {
|
!isHomeRoute && {
|
||||||
width: isSpaceRoute ? sidebarWidth : 300,
|
width: isSpaceRoute ? sidebarWidth : 300,
|
||||||
breakpoint: "sm",
|
breakpoint: "sm",
|
||||||
collapsed: {
|
collapsed: {
|
||||||
@ -102,7 +98,7 @@ export default function GlobalAppShell({
|
|||||||
<AppShell.Header px="md" className={classes.header}>
|
<AppShell.Header px="md" className={classes.header}>
|
||||||
<AppHeader />
|
<AppHeader />
|
||||||
</AppShell.Header>
|
</AppShell.Header>
|
||||||
{!hideSidebar && (
|
{!isHomeRoute && (
|
||||||
<AppShell.Navbar
|
<AppShell.Navbar
|
||||||
className={classes.navbar}
|
className={classes.navbar}
|
||||||
withBorder={false}
|
withBorder={false}
|
||||||
@ -115,7 +111,7 @@ export default function GlobalAppShell({
|
|||||||
)}
|
)}
|
||||||
<AppShell.Main>
|
<AppShell.Main>
|
||||||
{isSettingsRoute ? (
|
{isSettingsRoute ? (
|
||||||
<Container size={850}>{children}</Container>
|
<Container size={800}>{children}</Container>
|
||||||
) : (
|
) : (
|
||||||
children
|
children
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -1,8 +1,6 @@
|
|||||||
import { UserProvider } from "@/features/user/user-provider.tsx";
|
import { UserProvider } from "@/features/user/user-provider.tsx";
|
||||||
import { Outlet } from "react-router-dom";
|
import { Outlet } from "react-router-dom";
|
||||||
import GlobalAppShell from "@/components/layouts/global/global-app-shell.tsx";
|
import GlobalAppShell from "@/components/layouts/global/global-app-shell.tsx";
|
||||||
import { PosthogUser } from "@/ee/components/posthog-user.tsx";
|
|
||||||
import { isCloud } from "@/lib/config.ts";
|
|
||||||
|
|
||||||
export default function Layout() {
|
export default function Layout() {
|
||||||
return (
|
return (
|
||||||
@ -10,7 +8,6 @@ export default function Layout() {
|
|||||||
<GlobalAppShell>
|
<GlobalAppShell>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</GlobalAppShell>
|
</GlobalAppShell>
|
||||||
{isCloud() && <PosthogUser />}
|
|
||||||
</UserProvider>
|
</UserProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,21 +1,9 @@
|
|||||||
|
import { Group, Menu, UnstyledButton, Text } from "@mantine/core";
|
||||||
import {
|
import {
|
||||||
Group,
|
|
||||||
Menu,
|
|
||||||
UnstyledButton,
|
|
||||||
Text,
|
|
||||||
useMantineColorScheme,
|
|
||||||
} from "@mantine/core";
|
|
||||||
import {
|
|
||||||
IconBrightnessFilled,
|
|
||||||
IconBrush,
|
IconBrush,
|
||||||
IconCheck,
|
|
||||||
IconChevronDown,
|
IconChevronDown,
|
||||||
IconChevronRight,
|
|
||||||
IconDeviceDesktop,
|
|
||||||
IconLogout,
|
IconLogout,
|
||||||
IconMoon,
|
|
||||||
IconSettings,
|
IconSettings,
|
||||||
IconSun,
|
|
||||||
IconUserCircle,
|
IconUserCircle,
|
||||||
IconUsers,
|
IconUsers,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
@ -31,7 +19,6 @@ export default function TopMenu() {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [currentUser] = useAtom(currentUserAtom);
|
const [currentUser] = useAtom(currentUserAtom);
|
||||||
const { logout } = useAuth();
|
const { logout } = useAuth();
|
||||||
const { colorScheme, setColorScheme } = useMantineColorScheme();
|
|
||||||
|
|
||||||
const user = currentUser?.user;
|
const user = currentUser?.user;
|
||||||
const workspace = currentUser?.workspace;
|
const workspace = currentUser?.workspace;
|
||||||
@ -88,7 +75,7 @@ export default function TopMenu() {
|
|||||||
name={user.name}
|
name={user.name}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div style={{ width: 190 }}>
|
<div style={{width: 190}}>
|
||||||
<Text size="sm" fw={500} lineClamp={1}>
|
<Text size="sm" fw={500} lineClamp={1}>
|
||||||
{user.name}
|
{user.name}
|
||||||
</Text>
|
</Text>
|
||||||
@ -114,44 +101,6 @@ export default function TopMenu() {
|
|||||||
{t("My preferences")}
|
{t("My preferences")}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
|
|
||||||
<Menu.Sub>
|
|
||||||
<Menu.Sub.Target>
|
|
||||||
<Menu.Sub.Item leftSection={<IconBrightnessFilled size={16} />}>
|
|
||||||
{t("Theme")}
|
|
||||||
</Menu.Sub.Item>
|
|
||||||
</Menu.Sub.Target>
|
|
||||||
|
|
||||||
<Menu.Sub.Dropdown>
|
|
||||||
<Menu.Item
|
|
||||||
onClick={() => setColorScheme("light")}
|
|
||||||
leftSection={<IconSun size={16} />}
|
|
||||||
rightSection={
|
|
||||||
colorScheme === "light" ? <IconCheck size={16} /> : null
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{t("Light")}
|
|
||||||
</Menu.Item>
|
|
||||||
<Menu.Item
|
|
||||||
onClick={() => setColorScheme("dark")}
|
|
||||||
leftSection={<IconMoon size={16} />}
|
|
||||||
rightSection={
|
|
||||||
colorScheme === "dark" ? <IconCheck size={16} /> : null
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{t("Dark")}
|
|
||||||
</Menu.Item>
|
|
||||||
<Menu.Item
|
|
||||||
onClick={() => setColorScheme("auto")}
|
|
||||||
leftSection={<IconDeviceDesktop size={16} />}
|
|
||||||
rightSection={
|
|
||||||
colorScheme === "auto" ? <IconCheck size={16} /> : null
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{t("System settings")}
|
|
||||||
</Menu.Item>
|
|
||||||
</Menu.Sub.Dropdown>
|
|
||||||
</Menu.Sub>
|
|
||||||
|
|
||||||
<Menu.Divider />
|
<Menu.Divider />
|
||||||
|
|
||||||
<Menu.Item onClick={logout} leftSection={<IconLogout size={16} />}>
|
<Menu.Item onClick={logout} leftSection={<IconLogout size={16} />}>
|
||||||
|
|||||||
@ -1,10 +0,0 @@
|
|||||||
import { atom, WritableAtom } from "jotai";
|
|
||||||
|
|
||||||
export const settingsOriginAtom: WritableAtom<string | null, [string | null], void> = atom(
|
|
||||||
null,
|
|
||||||
(get, set, newValue) => {
|
|
||||||
if (get(settingsOriginAtom) !== newValue) {
|
|
||||||
set(settingsOriginAtom, newValue);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
@ -8,8 +8,7 @@ import { getGroups } from "@/features/group/services/group-service.ts";
|
|||||||
import { QueryParams } from "@/lib/types.ts";
|
import { QueryParams } from "@/lib/types.ts";
|
||||||
import { getWorkspaceMembers } from "@/features/workspace/services/workspace-service.ts";
|
import { getWorkspaceMembers } from "@/features/workspace/services/workspace-service.ts";
|
||||||
import { getLicenseInfo } from "@/ee/licence/services/license-service.ts";
|
import { getLicenseInfo } from "@/ee/licence/services/license-service.ts";
|
||||||
import { getSsoProviders } from "@/ee/security/services/security-service.ts";
|
import { getSsoProviders } from '@/ee/security/services/security-service.ts';
|
||||||
import { getShares } from "@/features/share/services/share-service.ts";
|
|
||||||
|
|
||||||
export const prefetchWorkspaceMembers = () => {
|
export const prefetchWorkspaceMembers = () => {
|
||||||
const params = { limit: 100, page: 1, query: "" } as QueryParams;
|
const params = { limit: 100, page: 1, query: "" } as QueryParams;
|
||||||
@ -58,10 +57,3 @@ export const prefetchSsoProviders = () => {
|
|||||||
queryFn: () => getSsoProviders(),
|
queryFn: () => getSsoProviders(),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export const prefetchShares = () => {
|
|
||||||
queryClient.prefetchQuery({
|
|
||||||
queryKey: ["share-list", { page: 1 }],
|
|
||||||
queryFn: () => getShares({ page: 1, limit: 100 }),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|||||||
@ -11,9 +11,8 @@ import {
|
|||||||
IconCoin,
|
IconCoin,
|
||||||
IconLock,
|
IconLock,
|
||||||
IconKey,
|
IconKey,
|
||||||
IconWorld,
|
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import { Link, useLocation } from "react-router-dom";
|
import { Link, useLocation, useNavigate } from "react-router-dom";
|
||||||
import classes from "./settings.module.css";
|
import classes from "./settings.module.css";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { isCloud } from "@/lib/config.ts";
|
import { isCloud } from "@/lib/config.ts";
|
||||||
@ -24,15 +23,11 @@ import {
|
|||||||
prefetchBilling,
|
prefetchBilling,
|
||||||
prefetchGroups,
|
prefetchGroups,
|
||||||
prefetchLicense,
|
prefetchLicense,
|
||||||
prefetchShares,
|
|
||||||
prefetchSpaces,
|
prefetchSpaces,
|
||||||
prefetchSsoProviders,
|
prefetchSsoProviders,
|
||||||
prefetchWorkspaceMembers,
|
prefetchWorkspaceMembers,
|
||||||
} from "@/components/settings/settings-queries.tsx";
|
} from "@/components/settings/settings-queries.tsx";
|
||||||
import AppVersion from "@/components/settings/app-version.tsx";
|
import AppVersion from "@/components/settings/app-version.tsx";
|
||||||
import { mobileSidebarAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
|
||||||
import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts";
|
|
||||||
import { useSettingsNavigation } from "@/hooks/use-settings-navigation";
|
|
||||||
|
|
||||||
interface DataItem {
|
interface DataItem {
|
||||||
label: string;
|
label: string;
|
||||||
@ -87,7 +82,6 @@ const groupedData: DataGroup[] = [
|
|||||||
},
|
},
|
||||||
{ label: "Groups", icon: IconUsersGroup, path: "/settings/groups" },
|
{ label: "Groups", icon: IconUsersGroup, path: "/settings/groups" },
|
||||||
{ label: "Spaces", icon: IconSpaces, path: "/settings/spaces" },
|
{ label: "Spaces", icon: IconSpaces, path: "/settings/spaces" },
|
||||||
{ label: "Public sharing", icon: IconWorld, path: "/settings/sharing" },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -106,11 +100,9 @@ export default function SettingsSidebar() {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const [active, setActive] = useState(location.pathname);
|
const [active, setActive] = useState(location.pathname);
|
||||||
const { goBack } = useSettingsNavigation();
|
const navigate = useNavigate();
|
||||||
const { isAdmin } = useUserRole();
|
const { isAdmin } = useUserRole();
|
||||||
const [workspace] = useAtom(workspaceAtom);
|
const [workspace] = useAtom(workspaceAtom);
|
||||||
const [mobileSidebarOpened] = useAtom(mobileSidebarAtom);
|
|
||||||
const toggleMobileSidebar = useToggleSidebar(mobileSidebarAtom);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setActive(location.pathname);
|
setActive(location.pathname);
|
||||||
@ -178,9 +170,6 @@ export default function SettingsSidebar() {
|
|||||||
case "Security & SSO":
|
case "Security & SSO":
|
||||||
prefetchHandler = prefetchSsoProviders;
|
prefetchHandler = prefetchSsoProviders;
|
||||||
break;
|
break;
|
||||||
case "Public sharing":
|
|
||||||
prefetchHandler = prefetchShares;
|
|
||||||
break;
|
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@ -192,11 +181,6 @@ export default function SettingsSidebar() {
|
|||||||
data-active={active.startsWith(item.path) || undefined}
|
data-active={active.startsWith(item.path) || undefined}
|
||||||
key={item.label}
|
key={item.label}
|
||||||
to={item.path}
|
to={item.path}
|
||||||
onClick={() => {
|
|
||||||
if (mobileSidebarOpened) {
|
|
||||||
toggleMobileSidebar();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<item.icon className={classes.linkIcon} stroke={2} />
|
<item.icon className={classes.linkIcon} stroke={2} />
|
||||||
<span>{t(item.label)}</span>
|
<span>{t(item.label)}</span>
|
||||||
@ -211,12 +195,7 @@ export default function SettingsSidebar() {
|
|||||||
<div className={classes.navbar}>
|
<div className={classes.navbar}>
|
||||||
<Group className={classes.title} justify="flex-start">
|
<Group className={classes.title} justify="flex-start">
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
onClick={() => {
|
onClick={() => navigate(-1)}
|
||||||
goBack();
|
|
||||||
if (mobileSidebarOpened) {
|
|
||||||
toggleMobileSidebar();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
variant="transparent"
|
variant="transparent"
|
||||||
c="gray"
|
c="gray"
|
||||||
aria-label="Back"
|
aria-label="Back"
|
||||||
|
|||||||
@ -1,19 +0,0 @@
|
|||||||
.dark {
|
|
||||||
@mixin dark {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
@mixin light {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.light {
|
|
||||||
@mixin light {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
@mixin dark {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,28 +1,13 @@
|
|||||||
import {
|
import { Button, Group, useMantineColorScheme } from '@mantine/core';
|
||||||
ActionIcon,
|
|
||||||
Tooltip,
|
|
||||||
useComputedColorScheme,
|
|
||||||
useMantineColorScheme,
|
|
||||||
} from "@mantine/core";
|
|
||||||
import { IconMoon, IconSun } from "@tabler/icons-react";
|
|
||||||
import classes from "./theme-toggle.module.css";
|
|
||||||
|
|
||||||
export function ThemeToggle() {
|
export function ThemeToggle() {
|
||||||
const { setColorScheme } = useMantineColorScheme();
|
const { setColorScheme } = useMantineColorScheme();
|
||||||
const computedColorScheme = useComputedColorScheme();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip label="Toggle Color Scheme">
|
<Group justify="center" mt="xl">
|
||||||
<ActionIcon
|
<Button onClick={() => setColorScheme('light')}>Light</Button>
|
||||||
variant="default"
|
<Button onClick={() => setColorScheme('dark')}>Dark</Button>
|
||||||
onClick={() => {
|
<Button onClick={() => setColorScheme('auto')}>Auto</Button>
|
||||||
setColorScheme(computedColorScheme === "light" ? "dark" : "light");
|
</Group>
|
||||||
}}
|
);
|
||||||
aria-label="Toggle color scheme"
|
|
||||||
>
|
|
||||||
<IconSun className={classes.light} size={18} stroke={1.5} />
|
|
||||||
<IconMoon className={classes.dark} size={18} stroke={1.5} />
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -30,12 +30,12 @@ export default function BillingDetails() {
|
|||||||
>
|
>
|
||||||
Plan
|
Plan
|
||||||
</Text>
|
</Text>
|
||||||
<Text fw={700} fz="lg" tt="capitalize">
|
<Text fw={700} fz="lg">
|
||||||
{plans.find(
|
{
|
||||||
(plan) => plan.productId === billing.stripeProductId,
|
plans.find(
|
||||||
)?.name ||
|
(plan) => plan.productId === billing.stripeProductId,
|
||||||
billing.planName ||
|
)?.name
|
||||||
"Standard"}
|
}
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
</Group>
|
</Group>
|
||||||
@ -112,59 +112,18 @@ export default function BillingDetails() {
|
|||||||
fz="xs"
|
fz="xs"
|
||||||
className={classes.label}
|
className={classes.label}
|
||||||
>
|
>
|
||||||
Cost
|
Total
|
||||||
|
</Text>
|
||||||
|
<Text fw={700} fz="lg">
|
||||||
|
{(billing.amount / 100) * billing.quantity}{" "}
|
||||||
|
{billing.currency.toUpperCase()}
|
||||||
|
</Text>
|
||||||
|
<Text c="dimmed" fz="sm">
|
||||||
|
${billing.amount / 100} /user/{billing.interval}
|
||||||
</Text>
|
</Text>
|
||||||
{billing.billingScheme === "tiered" && (
|
|
||||||
<>
|
|
||||||
<Text fw={700} fz="lg">
|
|
||||||
${billing.amount / 100} {billing.currency.toUpperCase()} /{" "}
|
|
||||||
{billing.interval}
|
|
||||||
</Text>
|
|
||||||
<Text c="dimmed" fz="sm">
|
|
||||||
per {billing.interval}
|
|
||||||
</Text>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{billing.billingScheme !== "tiered" && (
|
|
||||||
<>
|
|
||||||
<Text fw={700} fz="lg">
|
|
||||||
{(billing.amount / 100) * billing.quantity}{" "}
|
|
||||||
{billing.currency.toUpperCase()} / {billing.interval}
|
|
||||||
</Text>
|
|
||||||
<Text c="dimmed" fz="sm">
|
|
||||||
${billing.amount / 100} /user/{billing.interval}
|
|
||||||
</Text>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</Group>
|
</Group>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|
||||||
{billing.billingScheme === "tiered" && billing.tieredUpTo && (
|
|
||||||
<Paper p="md" radius="md">
|
|
||||||
<Group justify="apart">
|
|
||||||
<div>
|
|
||||||
<Text
|
|
||||||
c="dimmed"
|
|
||||||
tt="uppercase"
|
|
||||||
fw={700}
|
|
||||||
fz="xs"
|
|
||||||
className={classes.label}
|
|
||||||
>
|
|
||||||
Current Tier
|
|
||||||
</Text>
|
|
||||||
<Text fw={700} fz="lg">
|
|
||||||
For {billing.tieredUpTo} users
|
|
||||||
</Text>
|
|
||||||
{/*billing.tieredFlatAmount && (
|
|
||||||
<Text c="dimmed" fz="sm">
|
|
||||||
</Text>
|
|
||||||
)*/}
|
|
||||||
</div>
|
|
||||||
</Group>
|
|
||||||
</Paper>
|
|
||||||
)}
|
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -2,32 +2,24 @@ import {
|
|||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
List,
|
List,
|
||||||
|
SegmentedControl,
|
||||||
ThemeIcon,
|
ThemeIcon,
|
||||||
Title,
|
Title,
|
||||||
Text,
|
Text,
|
||||||
Group,
|
Group,
|
||||||
Select,
|
|
||||||
Container,
|
|
||||||
Stack,
|
|
||||||
Badge,
|
|
||||||
Flex,
|
|
||||||
Switch,
|
|
||||||
Alert,
|
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { IconCheck, IconInfoCircle } from "@tabler/icons-react";
|
import { IconCheck } from "@tabler/icons-react";
|
||||||
import { getCheckoutLink } from "@/ee/billing/services/billing-service.ts";
|
|
||||||
import { useBillingPlans } from "@/ee/billing/queries/billing-query.ts";
|
import { useBillingPlans } from "@/ee/billing/queries/billing-query.ts";
|
||||||
import { useAtomValue } from "jotai";
|
import { getCheckoutLink } from "@/ee/billing/services/billing-service.ts";
|
||||||
import { workspaceAtom } from "@/features/user/atoms/current-user-atom";
|
|
||||||
|
|
||||||
export default function BillingPlans() {
|
export default function BillingPlans() {
|
||||||
const { data: plans } = useBillingPlans();
|
const { data: plans } = useBillingPlans();
|
||||||
const workspace = useAtomValue(workspaceAtom);
|
const [interval, setInterval] = useState("yearly");
|
||||||
const [isAnnual, setIsAnnual] = useState(true);
|
|
||||||
const [selectedTierValue, setSelectedTierValue] = useState<string | null>(
|
if (!plans) {
|
||||||
null,
|
return null;
|
||||||
);
|
}
|
||||||
|
|
||||||
const handleCheckout = async (priceId: string) => {
|
const handleCheckout = async (priceId: string) => {
|
||||||
try {
|
try {
|
||||||
@ -40,194 +32,84 @@ export default function BillingPlans() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// TODO: remove by July 30.
|
|
||||||
// Check if workspace was created between June 28 and July 14, 2025
|
|
||||||
const showTieredPricingNotice = (() => {
|
|
||||||
if (!workspace?.createdAt) return false;
|
|
||||||
const createdDate = new Date(workspace.createdAt);
|
|
||||||
const startDate = new Date('2025-06-20');
|
|
||||||
const endDate = new Date('2025-07-14');
|
|
||||||
return createdDate >= startDate && createdDate <= endDate;
|
|
||||||
})();
|
|
||||||
|
|
||||||
if (!plans || plans.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if any plan is tiered
|
|
||||||
const hasTieredPlans = plans.some(plan => plan.billingScheme === 'tiered' && plan.pricingTiers?.length > 0);
|
|
||||||
const firstTieredPlan = plans.find(plan => plan.billingScheme === 'tiered' && plan.pricingTiers?.length > 0);
|
|
||||||
|
|
||||||
// Set initial tier value if not set and we have tiered plans
|
|
||||||
if (hasTieredPlans && !selectedTierValue && firstTieredPlan) {
|
|
||||||
setSelectedTierValue(firstTieredPlan.pricingTiers[0].upTo.toString());
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// For tiered plans, ensure we have a selected tier
|
|
||||||
if (hasTieredPlans && !selectedTierValue) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectData = firstTieredPlan?.pricingTiers
|
|
||||||
?.filter((tier) => !tier.custom)
|
|
||||||
.map((tier, index) => {
|
|
||||||
const prevMaxUsers =
|
|
||||||
index > 0 ? firstTieredPlan.pricingTiers[index - 1].upTo : 0;
|
|
||||||
return {
|
|
||||||
value: tier.upTo.toString(),
|
|
||||||
label: `${prevMaxUsers + 1}-${tier.upTo} users`,
|
|
||||||
};
|
|
||||||
}) || [];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container size="xl" py="xl">
|
<Group justify="center" p="xl">
|
||||||
{/* Tiered pricing notice for eligible workspaces */}
|
{plans.map((plan) => {
|
||||||
{showTieredPricingNotice && !hasTieredPlans && (
|
const price =
|
||||||
<Alert
|
interval === "monthly" ? plan.price.monthly : plan.price.yearly;
|
||||||
icon={<IconInfoCircle size={16} />}
|
const priceId = interval === "monthly" ? plan.monthlyId : plan.yearlyId;
|
||||||
title="Want the old tiered pricing?"
|
const yearlyMonthPrice = parseInt(plan.price.yearly) / 12;
|
||||||
color="blue"
|
|
||||||
mb="lg"
|
|
||||||
>
|
|
||||||
Contact support to switch back to our tiered pricing model.
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Controls Section */}
|
return (
|
||||||
<Stack gap="xl" mb="md">
|
<Card
|
||||||
{/* Team Size and Billing Controls */}
|
key={plan.name}
|
||||||
<Group justify="center" align="center" gap="sm">
|
withBorder
|
||||||
{hasTieredPlans && (
|
radius="md"
|
||||||
<Select
|
shadow="sm"
|
||||||
label="Team size"
|
p="xl"
|
||||||
description="Select the number of users"
|
w={300}
|
||||||
value={selectedTierValue}
|
>
|
||||||
onChange={setSelectedTierValue}
|
<SegmentedControl
|
||||||
data={selectData}
|
value={interval}
|
||||||
w={250}
|
onChange={setInterval}
|
||||||
size="md"
|
fullWidth
|
||||||
allowDeselect={false}
|
data={[
|
||||||
|
{ label: "Monthly", value: "monthly" },
|
||||||
|
{ label: "Yearly (25% OFF)", value: "yearly" },
|
||||||
|
]}
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
|
|
||||||
<Group justify="center" align="start">
|
<Title order={3} ta="center" mt="sm" mb="xs">
|
||||||
<Flex justify="center" gap="md" align="center">
|
{plan.name}
|
||||||
<Text size="md">Monthly</Text>
|
</Title>
|
||||||
<Switch
|
<Text ta="center" size="lg" fw={700}>
|
||||||
defaultChecked={isAnnual}
|
{interval === "monthly" && (
|
||||||
onChange={(event) => setIsAnnual(event.target.checked)}
|
<>
|
||||||
size="sm"
|
${price}{" "}
|
||||||
/>
|
<Text span size="sm" fw={500} c="dimmed">
|
||||||
<Text size="md">
|
/user/month
|
||||||
Annually
|
|
||||||
<Badge component="span" variant="light" color="blue">
|
|
||||||
15% OFF
|
|
||||||
</Badge>
|
|
||||||
</Text>
|
|
||||||
</Flex>
|
|
||||||
</Group>
|
|
||||||
</Group>
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
{/* Plans Grid */}
|
|
||||||
<Group justify="center" gap="lg" align="stretch">
|
|
||||||
{plans.map((plan, index) => {
|
|
||||||
let price;
|
|
||||||
let displayPrice;
|
|
||||||
const priceId = isAnnual ? plan.yearlyId : plan.monthlyId;
|
|
||||||
|
|
||||||
if (plan.billingScheme === 'tiered' && plan.pricingTiers?.length > 0) {
|
|
||||||
// Tiered billing logic
|
|
||||||
const planSelectedTier =
|
|
||||||
plan.pricingTiers.find(
|
|
||||||
(tier) => tier.upTo.toString() === selectedTierValue,
|
|
||||||
) || plan.pricingTiers[0];
|
|
||||||
|
|
||||||
price = isAnnual
|
|
||||||
? planSelectedTier.yearly
|
|
||||||
: planSelectedTier.monthly;
|
|
||||||
displayPrice = isAnnual ? (price / 12).toFixed(0) : price;
|
|
||||||
} else {
|
|
||||||
// Per-unit billing logic
|
|
||||||
const monthlyPrice = parseFloat(plan.price?.monthly || '0');
|
|
||||||
const yearlyPrice = parseFloat(plan.price?.yearly || '0');
|
|
||||||
price = isAnnual ? yearlyPrice : monthlyPrice;
|
|
||||||
displayPrice = isAnnual ? (yearlyPrice / 12).toFixed(0) : monthlyPrice;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card
|
|
||||||
key={plan.name}
|
|
||||||
withBorder
|
|
||||||
radius="lg"
|
|
||||||
shadow="sm"
|
|
||||||
p="xl"
|
|
||||||
w={350}
|
|
||||||
miw={300}
|
|
||||||
style={{
|
|
||||||
position: "relative",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Stack gap="lg">
|
|
||||||
{/* Plan Header */}
|
|
||||||
<Stack gap="xs">
|
|
||||||
<Title order={3} size="h4">
|
|
||||||
{plan.name}
|
|
||||||
</Title>
|
|
||||||
{plan.description && (
|
|
||||||
<Text size="sm" c="dimmed">
|
|
||||||
{plan.description}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
{/* Pricing */}
|
|
||||||
<Stack gap="xs">
|
|
||||||
<Group align="baseline" gap="xs">
|
|
||||||
<Title order={1} size="h1">
|
|
||||||
${displayPrice}
|
|
||||||
</Title>
|
|
||||||
<Text size="lg" c="dimmed">
|
|
||||||
{plan.billingScheme === 'per_unit'
|
|
||||||
? `per user/month`
|
|
||||||
: `per month`}
|
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
<Text size="sm" c="dimmed">
|
|
||||||
{isAnnual ? "Billed annually" : "Billed monthly"}
|
|
||||||
</Text>
|
</Text>
|
||||||
{plan.billingScheme === 'tiered' && plan.pricingTiers && (
|
</>
|
||||||
<Text size="md" fw={500}>
|
)}
|
||||||
For {plan.pricingTiers.find(tier => tier.upTo.toString() === selectedTierValue)?.upTo || plan.pricingTiers[0].upTo} users
|
{interval === "yearly" && (
|
||||||
</Text>
|
<>
|
||||||
)}
|
${yearlyMonthPrice}{" "}
|
||||||
</Stack>
|
<Text span size="sm" fw={500} c="dimmed">
|
||||||
|
/user/month
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<br/>
|
||||||
|
<Text span ta="center" size="md" fw={500} c="dimmed">
|
||||||
|
billed {interval}
|
||||||
|
</Text>
|
||||||
|
</Text>
|
||||||
|
|
||||||
{/* CTA Button */}
|
<Card.Section mt="lg">
|
||||||
<Button onClick={() => handleCheckout(priceId)} fullWidth>
|
<Button onClick={() => handleCheckout(priceId)} fullWidth>
|
||||||
Subscribe
|
Subscribe
|
||||||
</Button>
|
</Button>
|
||||||
|
</Card.Section>
|
||||||
|
|
||||||
{/* Features */}
|
<Card.Section mt="md">
|
||||||
<List
|
<List
|
||||||
spacing="xs"
|
spacing="xs"
|
||||||
size="sm"
|
size="sm"
|
||||||
icon={
|
center
|
||||||
<ThemeIcon size={20} radius="xl">
|
icon={
|
||||||
<IconCheck size={14} />
|
<ThemeIcon variant="light" size={24} radius="xl">
|
||||||
</ThemeIcon>
|
<IconCheck size={16} />
|
||||||
}
|
</ThemeIcon>
|
||||||
>
|
}
|
||||||
{plan.features.map((feature, featureIndex) => (
|
>
|
||||||
<List.Item key={featureIndex}>{feature}</List.Item>
|
{plan.features.map((feature, index) => (
|
||||||
))}
|
<List.Item key={index}>{feature}</List.Item>
|
||||||
</List>
|
))}
|
||||||
</Stack>
|
</List>
|
||||||
</Card>
|
</Card.Section>
|
||||||
);
|
</Card>
|
||||||
})}
|
);
|
||||||
</Group>
|
})}
|
||||||
</Container>
|
</Group>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
import { Alert } from "@mantine/core";
|
import { Alert } from "@mantine/core";
|
||||||
import { useBillingQuery } from "@/ee/billing/queries/billing-query.ts";
|
import { useBillingQuery } from "@/ee/billing/queries/billing-query.ts";
|
||||||
import useTrial from "@/ee/hooks/use-trial.tsx";
|
import useTrial from "@/ee/hooks/use-trial.tsx";
|
||||||
import { getBillingTrialDays } from '@/lib/config.ts';
|
|
||||||
|
|
||||||
export default function BillingTrial() {
|
export default function BillingTrial() {
|
||||||
const { data: billing, isLoading } = useBillingQuery();
|
const { data: billing, isLoading } = useBillingQuery();
|
||||||
@ -16,14 +15,14 @@ export default function BillingTrial() {
|
|||||||
{trialDaysLeft > 0 && !billing && (
|
{trialDaysLeft > 0 && !billing && (
|
||||||
<Alert title="Your Trial is Active 🎉" color="blue" radius="md">
|
<Alert title="Your Trial is Active 🎉" color="blue" radius="md">
|
||||||
You have {trialDaysLeft} {trialDaysLeft === 1 ? "day" : "days"} left
|
You have {trialDaysLeft} {trialDaysLeft === 1 ? "day" : "days"} left
|
||||||
in your {getBillingTrialDays()}-day free trial. Please subscribe to a paid plan before your trial
|
in your 7-day trial. Please subscribe to a plan before your trial
|
||||||
ends.
|
ends.
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{trialDaysLeft === 0 && (
|
{trialDaysLeft === 0 && (
|
||||||
<Alert title="Your Trial has ended" color="red" radius="md">
|
<Alert title="Your Trial has ended" color="red" radius="md">
|
||||||
Your {getBillingTrialDays()}-day free trial has come to an end. Please subscribe to a paid plan to
|
Your 7-day trial has come to an end. Please subscribe to a plan to
|
||||||
continue using this service.
|
continue using this service.
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
export enum BillingPlan {
|
export enum BillingPlan {
|
||||||
STANDARD = "standard",
|
STANDARD = "standard",
|
||||||
BUSINESS = "business",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IBilling {
|
export interface IBilling {
|
||||||
@ -25,11 +24,6 @@ export interface IBilling {
|
|||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
deletedAt: Date;
|
deletedAt: Date;
|
||||||
billingScheme: string | null;
|
|
||||||
tieredUpTo: string | null;
|
|
||||||
tieredFlatAmount: number | null;
|
|
||||||
tieredUnitAmount: number | null;
|
|
||||||
planName: string | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ICheckoutLink {
|
export interface ICheckoutLink {
|
||||||
@ -47,18 +41,9 @@ export interface IBillingPlan {
|
|||||||
monthlyId: string;
|
monthlyId: string;
|
||||||
yearlyId: string;
|
yearlyId: string;
|
||||||
currency: string;
|
currency: string;
|
||||||
price?: {
|
price: {
|
||||||
monthly: string;
|
monthly: string;
|
||||||
yearly: string;
|
yearly: string;
|
||||||
};
|
};
|
||||||
features: string[];
|
features: string[];
|
||||||
billingScheme: string | null;
|
|
||||||
pricingTiers?: PricingTier[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PricingTier {
|
|
||||||
upTo: number;
|
|
||||||
monthly?: number;
|
|
||||||
yearly?: number;
|
|
||||||
custom?: boolean;
|
|
||||||
}
|
}
|
||||||
@ -1,67 +0,0 @@
|
|||||||
import { ActionIcon, Tooltip } from "@mantine/core";
|
|
||||||
import { IconCircleCheck, IconCircleCheckFilled } from "@tabler/icons-react";
|
|
||||||
import { useResolveCommentMutation } from "@/ee/comment/queries/comment-query";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Editor } from "@tiptap/react";
|
|
||||||
|
|
||||||
interface ResolveCommentProps {
|
|
||||||
editor: Editor;
|
|
||||||
commentId: string;
|
|
||||||
pageId: string;
|
|
||||||
resolvedAt?: Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ResolveComment({
|
|
||||||
editor,
|
|
||||||
commentId,
|
|
||||||
pageId,
|
|
||||||
resolvedAt,
|
|
||||||
}: ResolveCommentProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const resolveCommentMutation = useResolveCommentMutation();
|
|
||||||
|
|
||||||
const isResolved = resolvedAt != null;
|
|
||||||
const iconColor = isResolved ? "green" : "gray";
|
|
||||||
|
|
||||||
const handleResolveToggle = async () => {
|
|
||||||
try {
|
|
||||||
await resolveCommentMutation.mutateAsync({
|
|
||||||
commentId,
|
|
||||||
pageId,
|
|
||||||
resolved: !isResolved,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (editor) {
|
|
||||||
editor.commands.setCommentResolved(commentId, !isResolved);
|
|
||||||
}
|
|
||||||
|
|
||||||
//
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to toggle resolved state:", error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Tooltip
|
|
||||||
label={isResolved ? t("Re-Open comment") : t("Resolve comment")}
|
|
||||||
position="top"
|
|
||||||
>
|
|
||||||
<ActionIcon
|
|
||||||
onClick={handleResolveToggle}
|
|
||||||
variant="subtle"
|
|
||||||
color={isResolved ? "green" : "gray"}
|
|
||||||
size="sm"
|
|
||||||
loading={resolveCommentMutation.isPending}
|
|
||||||
disabled={resolveCommentMutation.isPending}
|
|
||||||
>
|
|
||||||
{isResolved ? (
|
|
||||||
<IconCircleCheckFilled size={18} />
|
|
||||||
) : (
|
|
||||||
<IconCircleCheck size={18} />
|
|
||||||
)}
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default ResolveComment;
|
|
||||||
@ -1,87 +0,0 @@
|
|||||||
import {
|
|
||||||
useMutation,
|
|
||||||
useQueryClient,
|
|
||||||
} from "@tanstack/react-query";
|
|
||||||
import { resolveComment } from "@/features/comment/services/comment-service";
|
|
||||||
import {
|
|
||||||
IComment,
|
|
||||||
IResolveComment,
|
|
||||||
} from "@/features/comment/types/comment.types";
|
|
||||||
import { notifications } from "@mantine/notifications";
|
|
||||||
import { IPagination } from "@/lib/types.ts";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { useQueryEmit } from "@/features/websocket/use-query-emit";
|
|
||||||
import { RQ_KEY } from "@/features/comment/queries/comment-query";
|
|
||||||
|
|
||||||
export function useResolveCommentMutation() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const emit = useQueryEmit();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (data: IResolveComment) => resolveComment(data),
|
|
||||||
onMutate: async (variables) => {
|
|
||||||
await queryClient.cancelQueries({ queryKey: RQ_KEY(variables.pageId) });
|
|
||||||
const previousComments = queryClient.getQueryData(RQ_KEY(variables.pageId));
|
|
||||||
queryClient.setQueryData(RQ_KEY(variables.pageId), (old: IPagination<IComment>) => {
|
|
||||||
if (!old || !old.items) return old;
|
|
||||||
const updatedItems = old.items.map((comment) =>
|
|
||||||
comment.id === variables.commentId
|
|
||||||
? {
|
|
||||||
...comment,
|
|
||||||
resolvedAt: variables.resolved ? new Date() : null,
|
|
||||||
resolvedById: variables.resolved ? 'optimistic-user' : null,
|
|
||||||
resolvedBy: variables.resolved ? { id: 'optimistic-user', name: 'Resolving...', avatarUrl: null } : null
|
|
||||||
}
|
|
||||||
: comment,
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
...old,
|
|
||||||
items: updatedItems,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
return { previousComments };
|
|
||||||
},
|
|
||||||
onError: (err, variables, context) => {
|
|
||||||
if (context?.previousComments) {
|
|
||||||
queryClient.setQueryData(RQ_KEY(variables.pageId), context.previousComments);
|
|
||||||
}
|
|
||||||
notifications.show({
|
|
||||||
message: t("Failed to resolve comment"),
|
|
||||||
color: "red",
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onSuccess: (data: IComment, variables) => {
|
|
||||||
const pageId = data.pageId;
|
|
||||||
const currentComments = queryClient.getQueryData(
|
|
||||||
RQ_KEY(pageId),
|
|
||||||
) as IPagination<IComment>;
|
|
||||||
if (currentComments && currentComments.items) {
|
|
||||||
const updatedComments = currentComments.items.map((comment) =>
|
|
||||||
comment.id === variables.commentId
|
|
||||||
? { ...comment, resolvedAt: data.resolvedAt, resolvedById: data.resolvedById, resolvedBy: data.resolvedBy }
|
|
||||||
: comment,
|
|
||||||
);
|
|
||||||
queryClient.setQueryData(RQ_KEY(pageId), {
|
|
||||||
...currentComments,
|
|
||||||
items: updatedComments,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
emit({
|
|
||||||
operation: "resolveComment",
|
|
||||||
pageId: pageId,
|
|
||||||
commentId: variables.commentId,
|
|
||||||
resolved: variables.resolved,
|
|
||||||
resolvedAt: data.resolvedAt,
|
|
||||||
resolvedById: data.resolvedById,
|
|
||||||
resolvedBy: data.resolvedBy,
|
|
||||||
});
|
|
||||||
queryClient.invalidateQueries({ queryKey: RQ_KEY(pageId) });
|
|
||||||
notifications.show({
|
|
||||||
message: variables.resolved
|
|
||||||
? t("Comment resolved successfully")
|
|
||||||
: t("Comment re-opened successfully")
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@ -1,41 +0,0 @@
|
|||||||
import { usePostHog } from "posthog-js/react";
|
|
||||||
import { useEffect } from "react";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
|
|
||||||
|
|
||||||
export function PosthogUser() {
|
|
||||||
const posthog = usePostHog();
|
|
||||||
const [currentUser] = useAtom(currentUserAtom);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (currentUser) {
|
|
||||||
const user = currentUser?.user;
|
|
||||||
const workspace = currentUser?.workspace;
|
|
||||||
if (!user || !workspace) return;
|
|
||||||
|
|
||||||
posthog?.identify(user.id, {
|
|
||||||
name: user.name,
|
|
||||||
email: user.email,
|
|
||||||
workspaceId: user.workspaceId,
|
|
||||||
workspaceHostname: workspace.hostname,
|
|
||||||
lastActiveAt: new Date().toISOString(),
|
|
||||||
createdAt: user.createdAt,
|
|
||||||
source: "docmost-app",
|
|
||||||
});
|
|
||||||
posthog?.group("workspace", workspace.id, {
|
|
||||||
name: workspace.name,
|
|
||||||
hostname: workspace.hostname,
|
|
||||||
plan: workspace?.plan,
|
|
||||||
status: workspace.status,
|
|
||||||
isOnTrial: !!workspace.trialEndAt,
|
|
||||||
hasStripeCustomerId: !!workspace.stripeCustomerId,
|
|
||||||
memberCount: workspace.memberCount,
|
|
||||||
lastActiveAt: new Date().toISOString(),
|
|
||||||
createdAt: workspace.createdAt,
|
|
||||||
source: "docmost-app",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [posthog, currentUser]);
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
@ -2,18 +2,14 @@ import { useAtom } from "jotai";
|
|||||||
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
|
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||||
import { BillingPlan } from "@/ee/billing/types/billing.types.ts";
|
import { BillingPlan } from "@/ee/billing/types/billing.types.ts";
|
||||||
|
|
||||||
const usePlan = () => {
|
export const usePlan = () => {
|
||||||
const [workspace] = useAtom(workspaceAtom);
|
const [workspace] = useAtom(workspaceAtom);
|
||||||
|
|
||||||
const isStandard =
|
const isStandard =
|
||||||
typeof workspace?.plan === "string" &&
|
typeof workspace?.plan === "string" &&
|
||||||
workspace?.plan.toLowerCase() === BillingPlan.STANDARD.toLowerCase();
|
workspace?.plan.toLowerCase() === BillingPlan.STANDARD.toLowerCase();
|
||||||
|
|
||||||
const isBusiness =
|
return { isStandard };
|
||||||
typeof workspace?.plan === "string" &&
|
|
||||||
workspace?.plan.toLowerCase() === BillingPlan.BUSINESS.toLowerCase();
|
|
||||||
|
|
||||||
return { isStandard, isBusiness };
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default usePlan;
|
export default usePlan;
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useLocation, useNavigate } from "react-router-dom";
|
import { useLocation, useNavigate } from "react-router-dom";
|
||||||
import { getBillingTrialDays, isCloud } from "@/lib/config.ts";
|
import { isCloud } from "@/lib/config.ts";
|
||||||
import APP_ROUTE from "@/lib/app-route.ts";
|
import APP_ROUTE from "@/lib/app-route.ts";
|
||||||
import useUserRole from "@/hooks/use-user-role.tsx";
|
import useUserRole from "@/hooks/use-user-role.tsx";
|
||||||
import { notifications } from "@mantine/notifications";
|
import { notifications } from "@mantine/notifications";
|
||||||
@ -18,7 +18,7 @@ export const useTrialEndAction = () => {
|
|||||||
notifications.show({
|
notifications.show({
|
||||||
position: "top-right",
|
position: "top-right",
|
||||||
color: "red",
|
color: "red",
|
||||||
title: `Your ${getBillingTrialDays()}-day trial has ended`,
|
title: "Your 7-day trial has ended",
|
||||||
message:
|
message:
|
||||||
"Please upgrade to a paid plan or contact your workspace admin.",
|
"Please upgrade to a paid plan or contact your workspace admin.",
|
||||||
autoClose: false,
|
autoClose: false,
|
||||||
|
|||||||
@ -1,81 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import {
|
|
||||||
TextInput,
|
|
||||||
Button,
|
|
||||||
Stack,
|
|
||||||
Text,
|
|
||||||
Alert,
|
|
||||||
} from "@mantine/core";
|
|
||||||
import { IconKey, IconAlertCircle } from "@tabler/icons-react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
interface MfaBackupCodeInputProps {
|
|
||||||
value: string;
|
|
||||||
onChange: (value: string) => void;
|
|
||||||
error?: string;
|
|
||||||
onSubmit: () => void;
|
|
||||||
onCancel: () => void;
|
|
||||||
isLoading?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function MfaBackupCodeInput({
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
error,
|
|
||||||
onSubmit,
|
|
||||||
onCancel,
|
|
||||||
isLoading,
|
|
||||||
}: MfaBackupCodeInputProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Stack>
|
|
||||||
<Alert icon={<IconAlertCircle size={16} />} color="blue" variant="light">
|
|
||||||
<Text size="sm">
|
|
||||||
{t(
|
|
||||||
"Enter one of your backup codes. Each backup code can only be used once.",
|
|
||||||
)}
|
|
||||||
</Text>
|
|
||||||
</Alert>
|
|
||||||
|
|
||||||
<TextInput
|
|
||||||
label={t("Backup code")}
|
|
||||||
placeholder="XXXXXXXX"
|
|
||||||
value={value}
|
|
||||||
onChange={(e) => onChange(e.currentTarget.value.toUpperCase())}
|
|
||||||
error={error}
|
|
||||||
autoFocus
|
|
||||||
maxLength={8}
|
|
||||||
styles={{
|
|
||||||
input: {
|
|
||||||
fontFamily: "monospace",
|
|
||||||
letterSpacing: "0.1em",
|
|
||||||
fontSize: "1rem",
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Stack>
|
|
||||||
<Button
|
|
||||||
fullWidth
|
|
||||||
size="md"
|
|
||||||
loading={isLoading}
|
|
||||||
onClick={onSubmit}
|
|
||||||
leftSection={<IconKey size={18} />}
|
|
||||||
>
|
|
||||||
{t("Verify backup code")}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
fullWidth
|
|
||||||
variant="subtle"
|
|
||||||
color="gray"
|
|
||||||
onClick={onCancel}
|
|
||||||
disabled={isLoading}
|
|
||||||
>
|
|
||||||
{t("Use authenticator app instead")}
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,193 +0,0 @@
|
|||||||
import React, { useState } from "react";
|
|
||||||
import {
|
|
||||||
Modal,
|
|
||||||
Stack,
|
|
||||||
Text,
|
|
||||||
Button,
|
|
||||||
Paper,
|
|
||||||
Group,
|
|
||||||
List,
|
|
||||||
Code,
|
|
||||||
CopyButton,
|
|
||||||
Alert,
|
|
||||||
PasswordInput,
|
|
||||||
} from "@mantine/core";
|
|
||||||
import {
|
|
||||||
IconRefresh,
|
|
||||||
IconCopy,
|
|
||||||
IconCheck,
|
|
||||||
IconAlertCircle,
|
|
||||||
} from "@tabler/icons-react";
|
|
||||||
import { useMutation } from "@tanstack/react-query";
|
|
||||||
import { notifications } from "@mantine/notifications";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { regenerateBackupCodes } from "@/ee/mfa";
|
|
||||||
import { useForm } from "@mantine/form";
|
|
||||||
import { zodResolver } from "mantine-form-zod-resolver";
|
|
||||||
import { z } from "zod";
|
|
||||||
|
|
||||||
interface MfaBackupCodesModalProps {
|
|
||||||
opened: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const formSchema = z.object({
|
|
||||||
confirmPassword: z.string().min(1, { message: "Password is required" }),
|
|
||||||
});
|
|
||||||
|
|
||||||
export function MfaBackupCodesModal({
|
|
||||||
opened,
|
|
||||||
onClose,
|
|
||||||
}: MfaBackupCodesModalProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [backupCodes, setBackupCodes] = useState<string[]>([]);
|
|
||||||
const [showNewCodes, setShowNewCodes] = useState(false);
|
|
||||||
|
|
||||||
const form = useForm({
|
|
||||||
validate: zodResolver(formSchema),
|
|
||||||
initialValues: {
|
|
||||||
confirmPassword: "",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const regenerateMutation = useMutation({
|
|
||||||
mutationFn: (data: { confirmPassword: string }) =>
|
|
||||||
regenerateBackupCodes(data),
|
|
||||||
onSuccess: (data) => {
|
|
||||||
setBackupCodes(data.backupCodes);
|
|
||||||
setShowNewCodes(true);
|
|
||||||
form.reset();
|
|
||||||
notifications.show({
|
|
||||||
title: t("Success"),
|
|
||||||
message: t("New backup codes have been generated"),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onError: (error: any) => {
|
|
||||||
notifications.show({
|
|
||||||
title: t("Error"),
|
|
||||||
message:
|
|
||||||
error.response?.data?.message ||
|
|
||||||
t("Failed to regenerate backup codes"),
|
|
||||||
color: "red",
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleRegenerate = (values: { confirmPassword: string }) => {
|
|
||||||
regenerateMutation.mutate(values);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleClose = () => {
|
|
||||||
setShowNewCodes(false);
|
|
||||||
setBackupCodes([]);
|
|
||||||
form.reset();
|
|
||||||
onClose();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
opened={opened}
|
|
||||||
onClose={handleClose}
|
|
||||||
title={t("Backup codes")}
|
|
||||||
size="md"
|
|
||||||
>
|
|
||||||
<Stack gap="md">
|
|
||||||
{!showNewCodes ? (
|
|
||||||
<form onSubmit={form.onSubmit(handleRegenerate)}>
|
|
||||||
<Stack gap="md">
|
|
||||||
<Alert
|
|
||||||
icon={<IconAlertCircle size={20} />}
|
|
||||||
title={t("About backup codes")}
|
|
||||||
color="blue"
|
|
||||||
variant="light"
|
|
||||||
>
|
|
||||||
<Text size="sm">
|
|
||||||
{t(
|
|
||||||
"Backup codes can be used to access your account if you lose access to your authenticator app. Each code can only be used once.",
|
|
||||||
)}
|
|
||||||
</Text>
|
|
||||||
</Alert>
|
|
||||||
|
|
||||||
<Text size="sm">
|
|
||||||
{t(
|
|
||||||
"You can regenerate new backup codes at any time. This will invalidate all existing codes.",
|
|
||||||
)}
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
<PasswordInput
|
|
||||||
label={t("Confirm password")}
|
|
||||||
placeholder={t("Enter your password")}
|
|
||||||
variant="filled"
|
|
||||||
{...form.getInputProps("confirmPassword")}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
fullWidth
|
|
||||||
loading={regenerateMutation.isPending}
|
|
||||||
leftSection={<IconRefresh size={18} />}
|
|
||||||
>
|
|
||||||
{t("Generate new backup codes")}
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
</form>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Alert
|
|
||||||
icon={<IconAlertCircle size={20} />}
|
|
||||||
title={t("Save your new backup codes")}
|
|
||||||
color="yellow"
|
|
||||||
>
|
|
||||||
<Text size="sm">
|
|
||||||
{t(
|
|
||||||
"Make sure to save these codes in a secure place. Your old backup codes are no longer valid.",
|
|
||||||
)}
|
|
||||||
</Text>
|
|
||||||
</Alert>
|
|
||||||
|
|
||||||
<Paper p="md" withBorder>
|
|
||||||
<Group justify="space-between" mb="sm">
|
|
||||||
<Text size="sm" fw={600}>
|
|
||||||
{t("Your new backup codes")}
|
|
||||||
</Text>
|
|
||||||
<CopyButton value={backupCodes.join("\n")}>
|
|
||||||
{({ copied, copy }) => (
|
|
||||||
<Button
|
|
||||||
size="xs"
|
|
||||||
variant="subtle"
|
|
||||||
onClick={copy}
|
|
||||||
leftSection={
|
|
||||||
copied ? (
|
|
||||||
<IconCheck size={14} />
|
|
||||||
) : (
|
|
||||||
<IconCopy size={14} />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{copied ? t("Copied") : t("Copy")}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</CopyButton>
|
|
||||||
</Group>
|
|
||||||
<List size="sm" spacing="xs">
|
|
||||||
{backupCodes.map((code, index) => (
|
|
||||||
<List.Item key={index}>
|
|
||||||
<Code>{code}</Code>
|
|
||||||
</List.Item>
|
|
||||||
))}
|
|
||||||
</List>
|
|
||||||
</Paper>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
fullWidth
|
|
||||||
onClick={handleClose}
|
|
||||||
leftSection={<IconCheck size={18} />}
|
|
||||||
>
|
|
||||||
{t("I've saved my backup codes")}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,12 +0,0 @@
|
|||||||
.container {
|
|
||||||
min-height: 100vh;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.paper {
|
|
||||||
width: 100%;
|
|
||||||
box-shadow: var(--mantine-shadow-lg);
|
|
||||||
}
|
|
||||||
@ -1,160 +0,0 @@
|
|||||||
import React, { useState } from "react";
|
|
||||||
import {
|
|
||||||
Container,
|
|
||||||
Title,
|
|
||||||
Text,
|
|
||||||
PinInput,
|
|
||||||
Button,
|
|
||||||
Stack,
|
|
||||||
Anchor,
|
|
||||||
Paper,
|
|
||||||
Center,
|
|
||||||
ThemeIcon,
|
|
||||||
} from "@mantine/core";
|
|
||||||
import { useForm } from "@mantine/form";
|
|
||||||
import { zodResolver } from "mantine-form-zod-resolver";
|
|
||||||
import { IconDeviceMobile, IconLock } from "@tabler/icons-react";
|
|
||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
import { notifications } from "@mantine/notifications";
|
|
||||||
import classes from "./mfa-challenge.module.css";
|
|
||||||
import { verifyMfa } from "@/ee/mfa";
|
|
||||||
import APP_ROUTE from "@/lib/app-route";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import * as z from "zod";
|
|
||||||
import { MfaBackupCodeInput } from "./mfa-backup-code-input";
|
|
||||||
|
|
||||||
const formSchema = z.object({
|
|
||||||
code: z
|
|
||||||
.string()
|
|
||||||
.refine(
|
|
||||||
(val) => (val.length === 6 && /^\d{6}$/.test(val)) || val.length === 8,
|
|
||||||
{
|
|
||||||
message: "Enter a 6-digit code or 8-character backup code",
|
|
||||||
},
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
type MfaChallengeFormValues = z.infer<typeof formSchema>;
|
|
||||||
|
|
||||||
export function MfaChallenge() {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const [useBackupCode, setUseBackupCode] = useState(false);
|
|
||||||
|
|
||||||
const form = useForm<MfaChallengeFormValues>({
|
|
||||||
validate: zodResolver(formSchema),
|
|
||||||
initialValues: {
|
|
||||||
code: "",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleSubmit = async (values: MfaChallengeFormValues) => {
|
|
||||||
setIsLoading(true);
|
|
||||||
try {
|
|
||||||
await verifyMfa(values.code);
|
|
||||||
navigate(APP_ROUTE.HOME);
|
|
||||||
} catch (error: any) {
|
|
||||||
setIsLoading(false);
|
|
||||||
notifications.show({
|
|
||||||
message:
|
|
||||||
error.response?.data?.message || t("Invalid verification code"),
|
|
||||||
color: "red",
|
|
||||||
});
|
|
||||||
form.setFieldValue("code", "");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Container size={420} className={classes.container}>
|
|
||||||
<Paper radius="lg" p={40} className={classes.paper}>
|
|
||||||
<Stack align="center" gap="xl">
|
|
||||||
<Center>
|
|
||||||
<ThemeIcon size={80} radius="xl" variant="light" color="blue">
|
|
||||||
<IconDeviceMobile size={40} stroke={1.5} />
|
|
||||||
</ThemeIcon>
|
|
||||||
</Center>
|
|
||||||
|
|
||||||
<Stack align="center" gap="xs">
|
|
||||||
<Title order={2} ta="center" fw={600}>
|
|
||||||
{t("Two-factor authentication")}
|
|
||||||
</Title>
|
|
||||||
<Text size="sm" c="dimmed" ta="center">
|
|
||||||
{useBackupCode
|
|
||||||
? t("Enter one of your backup codes")
|
|
||||||
: t("Enter the 6-digit code found in your authenticator app")}
|
|
||||||
</Text>
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
{!useBackupCode ? (
|
|
||||||
<form
|
|
||||||
onSubmit={form.onSubmit(handleSubmit)}
|
|
||||||
style={{ width: "100%" }}
|
|
||||||
>
|
|
||||||
<Stack gap="lg">
|
|
||||||
<Center>
|
|
||||||
<PinInput
|
|
||||||
length={6}
|
|
||||||
type="number"
|
|
||||||
autoFocus
|
|
||||||
oneTimeCode
|
|
||||||
{...form.getInputProps("code")}
|
|
||||||
error={!!form.errors.code}
|
|
||||||
styles={{
|
|
||||||
input: {
|
|
||||||
fontSize: "1.2rem",
|
|
||||||
textAlign: "center",
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Center>
|
|
||||||
{form.errors.code && (
|
|
||||||
<Text c="red" size="sm" ta="center">
|
|
||||||
{form.errors.code}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
fullWidth
|
|
||||||
size="md"
|
|
||||||
loading={isLoading}
|
|
||||||
leftSection={<IconLock size={18} />}
|
|
||||||
>
|
|
||||||
{t("Verify")}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Anchor
|
|
||||||
component="button"
|
|
||||||
type="button"
|
|
||||||
size="sm"
|
|
||||||
c="dimmed"
|
|
||||||
onClick={() => {
|
|
||||||
setUseBackupCode(true);
|
|
||||||
form.setFieldValue("code", "");
|
|
||||||
form.clearErrors();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t("Use backup code")}
|
|
||||||
</Anchor>
|
|
||||||
</Stack>
|
|
||||||
</form>
|
|
||||||
) : (
|
|
||||||
<MfaBackupCodeInput
|
|
||||||
value={form.values.code}
|
|
||||||
onChange={(value) => form.setFieldValue("code", value)}
|
|
||||||
error={form.errors.code?.toString()}
|
|
||||||
onSubmit={() => handleSubmit(form.values)}
|
|
||||||
onCancel={() => {
|
|
||||||
setUseBackupCode(false);
|
|
||||||
form.setFieldValue("code", "");
|
|
||||||
form.clearErrors();
|
|
||||||
}}
|
|
||||||
isLoading={isLoading}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
</Paper>
|
|
||||||
</Container>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,124 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import {
|
|
||||||
Modal,
|
|
||||||
Stack,
|
|
||||||
Text,
|
|
||||||
Button,
|
|
||||||
PasswordInput,
|
|
||||||
Alert,
|
|
||||||
} from "@mantine/core";
|
|
||||||
import { IconShieldOff, IconAlertTriangle } from "@tabler/icons-react";
|
|
||||||
import { useForm } from "@mantine/form";
|
|
||||||
import { zodResolver } from "mantine-form-zod-resolver";
|
|
||||||
import { useMutation } from "@tanstack/react-query";
|
|
||||||
import { notifications } from "@mantine/notifications";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { disableMfa } from "@/ee/mfa";
|
|
||||||
|
|
||||||
interface MfaDisableModalProps {
|
|
||||||
opened: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
onComplete: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const formSchema = z.object({
|
|
||||||
confirmPassword: z.string().min(1, { message: "Password is required" }),
|
|
||||||
});
|
|
||||||
|
|
||||||
export function MfaDisableModal({
|
|
||||||
opened,
|
|
||||||
onClose,
|
|
||||||
onComplete,
|
|
||||||
}: MfaDisableModalProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
const form = useForm({
|
|
||||||
validate: zodResolver(formSchema),
|
|
||||||
initialValues: {
|
|
||||||
confirmPassword: "",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const disableMutation = useMutation({
|
|
||||||
mutationFn: disableMfa,
|
|
||||||
onSuccess: () => {
|
|
||||||
onComplete();
|
|
||||||
},
|
|
||||||
onError: (error: any) => {
|
|
||||||
notifications.show({
|
|
||||||
title: t("Error"),
|
|
||||||
message: error.response?.data?.message || t("Failed to disable MFA"),
|
|
||||||
color: "red",
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleSubmit = async (values: { confirmPassword: string }) => {
|
|
||||||
await disableMutation.mutateAsync(values);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleClose = () => {
|
|
||||||
form.reset();
|
|
||||||
onClose();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
opened={opened}
|
|
||||||
onClose={handleClose}
|
|
||||||
title={t("Disable two-factor authentication")}
|
|
||||||
size="md"
|
|
||||||
>
|
|
||||||
<form onSubmit={form.onSubmit(handleSubmit)}>
|
|
||||||
<Stack gap="md">
|
|
||||||
<Alert
|
|
||||||
icon={<IconAlertTriangle size={20} />}
|
|
||||||
title={t("Warning")}
|
|
||||||
color="red"
|
|
||||||
variant="light"
|
|
||||||
>
|
|
||||||
<Text size="sm">
|
|
||||||
{t(
|
|
||||||
"Disabling two-factor authentication will make your account less secure. You'll only need your password to sign in.",
|
|
||||||
)}
|
|
||||||
</Text>
|
|
||||||
</Alert>
|
|
||||||
|
|
||||||
<Text size="sm">
|
|
||||||
{t(
|
|
||||||
"Please enter your password to disable two-factor authentication:",
|
|
||||||
)}
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
<PasswordInput
|
|
||||||
label={t("Password")}
|
|
||||||
placeholder={t("Enter your password")}
|
|
||||||
{...form.getInputProps("confirmPassword")}
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Stack gap="sm">
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
fullWidth
|
|
||||||
color="red"
|
|
||||||
loading={disableMutation.isPending}
|
|
||||||
leftSection={<IconShieldOff size={18} />}
|
|
||||||
>
|
|
||||||
{t("Disable two-factor authentication")}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
fullWidth
|
|
||||||
variant="default"
|
|
||||||
onClick={handleClose}
|
|
||||||
disabled={disableMutation.isPending}
|
|
||||||
>
|
|
||||||
{t("Cancel")}
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
</Stack>
|
|
||||||
</form>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,112 +0,0 @@
|
|||||||
import React, { useState } from "react";
|
|
||||||
import { Group, Text, Button } from "@mantine/core";
|
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
||||||
import { notifications } from "@mantine/notifications";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { getMfaStatus } from "@/ee/mfa";
|
|
||||||
import { MfaSetupModal } from "@/ee/mfa";
|
|
||||||
import { MfaDisableModal } from "@/ee/mfa";
|
|
||||||
import { MfaBackupCodesModal } from "@/ee/mfa";
|
|
||||||
|
|
||||||
export function MfaSettings() {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const [setupModalOpen, setSetupModalOpen] = useState(false);
|
|
||||||
const [disableModalOpen, setDisableModalOpen] = useState(false);
|
|
||||||
const [backupCodesModalOpen, setBackupCodesModalOpen] = useState(false);
|
|
||||||
|
|
||||||
const { data: mfaStatus, isLoading } = useQuery({
|
|
||||||
queryKey: ["mfa-status"],
|
|
||||||
queryFn: getMfaStatus,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if MFA is truly enabled
|
|
||||||
const isMfaEnabled = mfaStatus?.isEnabled === true;
|
|
||||||
|
|
||||||
const handleSetupComplete = () => {
|
|
||||||
setSetupModalOpen(false);
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["mfa-status"] });
|
|
||||||
notifications.show({
|
|
||||||
title: t("Success"),
|
|
||||||
message: t("Two-factor authentication has been enabled"),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDisableComplete = () => {
|
|
||||||
setDisableModalOpen(false);
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["mfa-status"] });
|
|
||||||
notifications.show({
|
|
||||||
title: t("Success"),
|
|
||||||
message: t("Two-factor authentication has been disabled"),
|
|
||||||
color: "blue",
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Group justify="space-between" wrap="nowrap" gap="xl">
|
|
||||||
<div style={{ minWidth: 0, flex: 1 }}>
|
|
||||||
<Text size="md">{t("2-step verification")}</Text>
|
|
||||||
<Text size="sm" c="dimmed">
|
|
||||||
{!isMfaEnabled
|
|
||||||
? t(
|
|
||||||
"Protect your account with an additional verification layer when signing in.",
|
|
||||||
)
|
|
||||||
: t("Two-factor authentication is active on your account.")}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{!isMfaEnabled ? (
|
|
||||||
<Button
|
|
||||||
variant="default"
|
|
||||||
onClick={() => setSetupModalOpen(true)}
|
|
||||||
style={{ whiteSpace: "nowrap" }}
|
|
||||||
>
|
|
||||||
{t("Add 2FA method")}
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<Group gap="sm" wrap="nowrap">
|
|
||||||
<Button
|
|
||||||
variant="default"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setBackupCodesModalOpen(true)}
|
|
||||||
style={{ whiteSpace: "nowrap" }}
|
|
||||||
>
|
|
||||||
{t("Backup codes")} ({mfaStatus?.backupCodesCount || 0})
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="default"
|
|
||||||
size="sm"
|
|
||||||
color="red"
|
|
||||||
onClick={() => setDisableModalOpen(true)}
|
|
||||||
style={{ whiteSpace: "nowrap" }}
|
|
||||||
>
|
|
||||||
{t("Disable")}
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
)}
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
<MfaSetupModal
|
|
||||||
opened={setupModalOpen}
|
|
||||||
onClose={() => setSetupModalOpen(false)}
|
|
||||||
onComplete={handleSetupComplete}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<MfaDisableModal
|
|
||||||
opened={disableModalOpen}
|
|
||||||
onClose={() => setDisableModalOpen(false)}
|
|
||||||
onComplete={handleDisableComplete}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<MfaBackupCodesModal
|
|
||||||
opened={backupCodesModalOpen}
|
|
||||||
onClose={() => setBackupCodesModalOpen(false)}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,347 +0,0 @@
|
|||||||
import React, { useState } from "react";
|
|
||||||
import {
|
|
||||||
Modal,
|
|
||||||
Stack,
|
|
||||||
Text,
|
|
||||||
Button,
|
|
||||||
Group,
|
|
||||||
Stepper,
|
|
||||||
Center,
|
|
||||||
Image,
|
|
||||||
PinInput,
|
|
||||||
Alert,
|
|
||||||
List,
|
|
||||||
CopyButton,
|
|
||||||
ActionIcon,
|
|
||||||
Tooltip,
|
|
||||||
Paper,
|
|
||||||
Code,
|
|
||||||
Loader,
|
|
||||||
Collapse,
|
|
||||||
UnstyledButton,
|
|
||||||
} from "@mantine/core";
|
|
||||||
import {
|
|
||||||
IconQrcode,
|
|
||||||
IconShieldCheck,
|
|
||||||
IconKey,
|
|
||||||
IconCopy,
|
|
||||||
IconCheck,
|
|
||||||
IconAlertCircle,
|
|
||||||
IconChevronDown,
|
|
||||||
IconChevronRight,
|
|
||||||
IconPrinter,
|
|
||||||
} from "@tabler/icons-react";
|
|
||||||
import { useForm } from "@mantine/form";
|
|
||||||
import { useMutation } from "@tanstack/react-query";
|
|
||||||
import { notifications } from "@mantine/notifications";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { setupMfa, enableMfa } from "@/ee/mfa";
|
|
||||||
import { zodResolver } from "mantine-form-zod-resolver";
|
|
||||||
import { z } from "zod";
|
|
||||||
|
|
||||||
interface MfaSetupModalProps {
|
|
||||||
opened: boolean;
|
|
||||||
onClose?: () => void;
|
|
||||||
onComplete: () => void;
|
|
||||||
isRequired?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SetupData {
|
|
||||||
secret: string;
|
|
||||||
qrCode: string;
|
|
||||||
manualKey: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const formSchema = z.object({
|
|
||||||
verificationCode: z
|
|
||||||
.string()
|
|
||||||
.length(6, { message: "Please enter a 6-digit code" }),
|
|
||||||
});
|
|
||||||
|
|
||||||
export function MfaSetupModal({
|
|
||||||
opened,
|
|
||||||
onClose,
|
|
||||||
onComplete,
|
|
||||||
isRequired = false,
|
|
||||||
}: MfaSetupModalProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [active, setActive] = useState(0);
|
|
||||||
const [setupData, setSetupData] = useState<SetupData | null>(null);
|
|
||||||
const [backupCodes, setBackupCodes] = useState<string[]>([]);
|
|
||||||
const [manualEntryOpen, setManualEntryOpen] = useState(false);
|
|
||||||
|
|
||||||
const form = useForm({
|
|
||||||
validate: zodResolver(formSchema),
|
|
||||||
initialValues: {
|
|
||||||
verificationCode: "",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const setupMutation = useMutation({
|
|
||||||
mutationFn: () => setupMfa({ method: "totp" }),
|
|
||||||
onSuccess: (data) => {
|
|
||||||
setSetupData(data);
|
|
||||||
},
|
|
||||||
onError: (error: any) => {
|
|
||||||
notifications.show({
|
|
||||||
title: t("Error"),
|
|
||||||
message: error.response?.data?.message || t("Failed to setup MFA"),
|
|
||||||
color: "red",
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Generate QR code when modal opens
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (opened && !setupData && !setupMutation.isPending) {
|
|
||||||
setupMutation.mutate();
|
|
||||||
}
|
|
||||||
}, [opened]);
|
|
||||||
|
|
||||||
const enableMutation = useMutation({
|
|
||||||
mutationFn: (verificationCode: string) =>
|
|
||||||
enableMfa({
|
|
||||||
secret: setupData!.secret,
|
|
||||||
verificationCode,
|
|
||||||
}),
|
|
||||||
onSuccess: (data) => {
|
|
||||||
setBackupCodes(data.backupCodes);
|
|
||||||
setActive(1); // Move to backup codes step
|
|
||||||
},
|
|
||||||
onError: (error: any) => {
|
|
||||||
notifications.show({
|
|
||||||
title: t("Error"),
|
|
||||||
message:
|
|
||||||
error.response?.data?.message || t("Invalid verification code"),
|
|
||||||
color: "red",
|
|
||||||
});
|
|
||||||
form.setFieldValue("verificationCode", "");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleClose = () => {
|
|
||||||
if (active === 1 && backupCodes.length > 0) {
|
|
||||||
onComplete();
|
|
||||||
}
|
|
||||||
onClose();
|
|
||||||
// Reset state
|
|
||||||
setTimeout(() => {
|
|
||||||
setActive(0);
|
|
||||||
setSetupData(null);
|
|
||||||
setBackupCodes([]);
|
|
||||||
setManualEntryOpen(false);
|
|
||||||
form.reset();
|
|
||||||
}, 200);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleVerify = async (values: { verificationCode: string }) => {
|
|
||||||
await enableMutation.mutateAsync(values.verificationCode);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePrintBackupCodes = () => {
|
|
||||||
window.print();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
opened={opened}
|
|
||||||
onClose={handleClose}
|
|
||||||
title={t("Set up two-factor authentication")}
|
|
||||||
size="md"
|
|
||||||
>
|
|
||||||
<Stepper active={active} size="sm">
|
|
||||||
<Stepper.Step
|
|
||||||
label={t("Setup & Verify")}
|
|
||||||
description={t("Add to authenticator")}
|
|
||||||
icon={<IconQrcode size={18} />}
|
|
||||||
>
|
|
||||||
<form onSubmit={form.onSubmit(handleVerify)}>
|
|
||||||
<Stack gap="md" mt="xl">
|
|
||||||
{setupMutation.isPending ? (
|
|
||||||
<Center py="xl">
|
|
||||||
<Loader size="lg" />
|
|
||||||
</Center>
|
|
||||||
) : setupData ? (
|
|
||||||
<>
|
|
||||||
<Text size="sm">
|
|
||||||
{t("1. Scan this QR code with your authenticator app")}
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
<Center>
|
|
||||||
<Paper p="md" withBorder>
|
|
||||||
<Image
|
|
||||||
src={setupData.qrCode}
|
|
||||||
alt="MFA QR Code"
|
|
||||||
width={200}
|
|
||||||
height={200}
|
|
||||||
/>
|
|
||||||
</Paper>
|
|
||||||
</Center>
|
|
||||||
|
|
||||||
<UnstyledButton
|
|
||||||
onClick={() => setManualEntryOpen(!manualEntryOpen)}
|
|
||||||
>
|
|
||||||
<Group gap="xs">
|
|
||||||
{manualEntryOpen ? (
|
|
||||||
<IconChevronDown size={16} />
|
|
||||||
) : (
|
|
||||||
<IconChevronRight size={16} />
|
|
||||||
)}
|
|
||||||
<Text size="sm" c="dimmed">
|
|
||||||
{t("Can't scan the code?")}
|
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
</UnstyledButton>
|
|
||||||
|
|
||||||
<Collapse in={manualEntryOpen}>
|
|
||||||
<Alert
|
|
||||||
icon={<IconAlertCircle size={20} />}
|
|
||||||
color="gray"
|
|
||||||
variant="light"
|
|
||||||
>
|
|
||||||
<Text size="sm" mb="sm">
|
|
||||||
{t(
|
|
||||||
"Enter this code manually in your authenticator app:",
|
|
||||||
)}
|
|
||||||
</Text>
|
|
||||||
<Group gap="xs">
|
|
||||||
<Code block>{setupData.manualKey}</Code>
|
|
||||||
<CopyButton value={setupData.manualKey}>
|
|
||||||
{({ copied, copy }) => (
|
|
||||||
<Tooltip label={copied ? t("Copied") : t("Copy")}>
|
|
||||||
<ActionIcon
|
|
||||||
color={copied ? "green" : "gray"}
|
|
||||||
onClick={copy}
|
|
||||||
>
|
|
||||||
{copied ? (
|
|
||||||
<IconCheck size={16} />
|
|
||||||
) : (
|
|
||||||
<IconCopy size={16} />
|
|
||||||
)}
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
</CopyButton>
|
|
||||||
</Group>
|
|
||||||
</Alert>
|
|
||||||
</Collapse>
|
|
||||||
|
|
||||||
<Text size="sm" mt="md">
|
|
||||||
{t("2. Enter the 6-digit code from your authenticator")}
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
<Stack align="center">
|
|
||||||
<PinInput
|
|
||||||
length={6}
|
|
||||||
type="number"
|
|
||||||
autoFocus
|
|
||||||
oneTimeCode
|
|
||||||
{...form.getInputProps("verificationCode")}
|
|
||||||
styles={{
|
|
||||||
input: {
|
|
||||||
fontSize: "1.2rem",
|
|
||||||
textAlign: "center",
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{form.errors.verificationCode && (
|
|
||||||
<Text c="red" size="sm">
|
|
||||||
{form.errors.verificationCode}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
fullWidth
|
|
||||||
loading={enableMutation.isPending}
|
|
||||||
leftSection={<IconShieldCheck size={18} />}
|
|
||||||
>
|
|
||||||
{t("Verify and enable")}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<Center py="xl">
|
|
||||||
<Text size="sm" c="dimmed">
|
|
||||||
{t("Failed to generate QR code. Please try again.")}
|
|
||||||
</Text>
|
|
||||||
</Center>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
</form>
|
|
||||||
</Stepper.Step>
|
|
||||||
|
|
||||||
<Stepper.Step
|
|
||||||
label={t("Backup")}
|
|
||||||
description={t("Save codes")}
|
|
||||||
icon={<IconKey size={18} />}
|
|
||||||
>
|
|
||||||
<Stack gap="md" mt="xl">
|
|
||||||
<Alert
|
|
||||||
icon={<IconAlertCircle size={20} />}
|
|
||||||
title={t("Save your backup codes")}
|
|
||||||
color="yellow"
|
|
||||||
>
|
|
||||||
<Text size="sm">
|
|
||||||
{t(
|
|
||||||
"These codes can be used to access your account if you lose access to your authenticator app. Each code can only be used once.",
|
|
||||||
)}
|
|
||||||
</Text>
|
|
||||||
</Alert>
|
|
||||||
|
|
||||||
<Paper p="md" withBorder>
|
|
||||||
<Group justify="space-between" mb="sm">
|
|
||||||
<Text size="sm" fw={600}>
|
|
||||||
{t("Backup codes")}
|
|
||||||
</Text>
|
|
||||||
<Group gap="xs" wrap="nowrap">
|
|
||||||
<CopyButton value={backupCodes.join("\n")}>
|
|
||||||
{({ copied, copy }) => (
|
|
||||||
<Button
|
|
||||||
size="xs"
|
|
||||||
variant="subtle"
|
|
||||||
onClick={copy}
|
|
||||||
leftSection={
|
|
||||||
copied ? (
|
|
||||||
<IconCheck size={14} />
|
|
||||||
) : (
|
|
||||||
<IconCopy size={14} />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{copied ? t("Copied") : t("Copy")}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</CopyButton>
|
|
||||||
<Button
|
|
||||||
size="xs"
|
|
||||||
variant="subtle"
|
|
||||||
onClick={handlePrintBackupCodes}
|
|
||||||
leftSection={<IconPrinter size={14} />}
|
|
||||||
>
|
|
||||||
{t("Print")}
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
</Group>
|
|
||||||
<List size="sm" spacing="xs">
|
|
||||||
{backupCodes.map((code, index) => (
|
|
||||||
<List.Item key={index}>
|
|
||||||
<Code>{code}</Code>
|
|
||||||
</List.Item>
|
|
||||||
))}
|
|
||||||
</List>
|
|
||||||
</Paper>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
fullWidth
|
|
||||||
onClick={handleClose}
|
|
||||||
leftSection={<IconCheck size={18} />}
|
|
||||||
>
|
|
||||||
{t("I've saved my backup codes")}
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
</Stepper.Step>
|
|
||||||
</Stepper>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,48 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import { Container, Paper, Title, Text, Alert, Stack } from "@mantine/core";
|
|
||||||
import { IconAlertCircle } from "@tabler/icons-react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { MfaSetupModal } from "@/ee/mfa";
|
|
||||||
import APP_ROUTE from "@/lib/app-route.ts";
|
|
||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
|
|
||||||
export default function MfaSetupRequired() {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
const handleSetupComplete = () => {
|
|
||||||
navigate(APP_ROUTE.HOME);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Container size="sm" py="xl">
|
|
||||||
<Paper shadow="sm" p="xl" radius="md" withBorder>
|
|
||||||
<Stack>
|
|
||||||
<Title order={2} ta="center">
|
|
||||||
{t("Two-factor authentication required")}
|
|
||||||
</Title>
|
|
||||||
|
|
||||||
<Alert icon={<IconAlertCircle size="1rem" />} color="yellow">
|
|
||||||
<Text size="sm">
|
|
||||||
{t(
|
|
||||||
"Your workspace requires two-factor authentication. Please set it up to continue.",
|
|
||||||
)}
|
|
||||||
</Text>
|
|
||||||
</Alert>
|
|
||||||
|
|
||||||
<Text c="dimmed" size="sm" ta="center">
|
|
||||||
{t(
|
|
||||||
"This adds an extra layer of security to your account by requiring a verification code from your authenticator app.",
|
|
||||||
)}
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
<MfaSetupModal
|
|
||||||
opened={true}
|
|
||||||
onComplete={handleSetupComplete}
|
|
||||||
isRequired={true}
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
</Paper>
|
|
||||||
</Container>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,31 +0,0 @@
|
|||||||
.qrCodeContainer {
|
|
||||||
background-color: white;
|
|
||||||
padding: 1rem;
|
|
||||||
border-radius: var(--mantine-radius-md);
|
|
||||||
display: inline-block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.backupCodesList {
|
|
||||||
font-family: var(--mantine-font-family-monospace);
|
|
||||||
background-color: var(--mantine-color-gray-0);
|
|
||||||
padding: 1rem;
|
|
||||||
border-radius: var(--mantine-radius-md);
|
|
||||||
|
|
||||||
@mixin dark {
|
|
||||||
background-color: var(--mantine-color-dark-7);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.codeItem {
|
|
||||||
padding: 0.25rem 0;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.setupStep {
|
|
||||||
min-height: 400px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.verificationInput {
|
|
||||||
max-width: 320px;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
@ -1,51 +0,0 @@
|
|||||||
import { useEffect, useState } from "react";
|
|
||||||
import { useNavigate, useLocation } from "react-router-dom";
|
|
||||||
import APP_ROUTE from "@/lib/app-route";
|
|
||||||
import { validateMfaAccess } from "@/ee/mfa";
|
|
||||||
|
|
||||||
export function useMfaPageProtection() {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const location = useLocation();
|
|
||||||
const [isValidating, setIsValidating] = useState(true);
|
|
||||||
const [isValid, setIsValid] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const checkAccess = async () => {
|
|
||||||
const result = await validateMfaAccess();
|
|
||||||
|
|
||||||
if (!result.valid) {
|
|
||||||
navigate(APP_ROUTE.AUTH.LOGIN);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if user is on the correct page based on their MFA state
|
|
||||||
const isOnChallengePage =
|
|
||||||
location.pathname === APP_ROUTE.AUTH.MFA_CHALLENGE;
|
|
||||||
const isOnSetupPage =
|
|
||||||
location.pathname === APP_ROUTE.AUTH.MFA_SETUP_REQUIRED;
|
|
||||||
|
|
||||||
if (result.requiresMfaSetup && !isOnSetupPage) {
|
|
||||||
// User needs to set up MFA but is on challenge page
|
|
||||||
navigate(APP_ROUTE.AUTH.MFA_SETUP_REQUIRED);
|
|
||||||
} else if (
|
|
||||||
!result.requiresMfaSetup &&
|
|
||||||
result.userHasMfa &&
|
|
||||||
!isOnChallengePage
|
|
||||||
) {
|
|
||||||
// User has MFA and should be on challenge page
|
|
||||||
navigate(APP_ROUTE.AUTH.MFA_CHALLENGE);
|
|
||||||
} else if (!result.isTransferToken) {
|
|
||||||
// User has a regular auth token, shouldn't be on MFA pages
|
|
||||||
navigate(APP_ROUTE.HOME);
|
|
||||||
} else {
|
|
||||||
setIsValid(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsValidating(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
checkAccess();
|
|
||||||
}, [navigate, location.pathname]);
|
|
||||||
|
|
||||||
return { isValidating, isValid };
|
|
||||||
}
|
|
||||||
@ -1,19 +0,0 @@
|
|||||||
// Components
|
|
||||||
export { MfaChallenge } from "./components/mfa-challenge";
|
|
||||||
export { MfaSettings } from "./components/mfa-settings";
|
|
||||||
export { MfaSetupModal } from "./components/mfa-setup-modal";
|
|
||||||
export { MfaDisableModal } from "./components/mfa-disable-modal";
|
|
||||||
export { MfaBackupCodesModal } from "./components/mfa-backup-codes-modal";
|
|
||||||
|
|
||||||
// Pages
|
|
||||||
export { MfaChallengePage } from "./pages/mfa-challenge-page";
|
|
||||||
export { MfaSetupRequiredPage } from "./pages/mfa-setup-required-page";
|
|
||||||
|
|
||||||
// Services
|
|
||||||
export * from "./services/mfa-service";
|
|
||||||
|
|
||||||
// Types
|
|
||||||
export * from "./types/mfa.types";
|
|
||||||
|
|
||||||
// Hooks
|
|
||||||
export { useMfaPageProtection } from "./hooks/use-mfa-page-protection.ts";
|
|
||||||
@ -1,13 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import { MfaChallenge } from "@/ee/mfa";
|
|
||||||
import { useMfaPageProtection } from "@/ee/mfa";
|
|
||||||
|
|
||||||
export function MfaChallengePage() {
|
|
||||||
const { isValid } = useMfaPageProtection();
|
|
||||||
|
|
||||||
if (!isValid) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return <MfaChallenge />;
|
|
||||||
}
|
|
||||||
@ -1,113 +0,0 @@
|
|||||||
import React, { useState } from "react";
|
|
||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
import {
|
|
||||||
Container,
|
|
||||||
Title,
|
|
||||||
Text,
|
|
||||||
Button,
|
|
||||||
Stack,
|
|
||||||
Paper,
|
|
||||||
Alert,
|
|
||||||
Center,
|
|
||||||
ThemeIcon,
|
|
||||||
} from "@mantine/core";
|
|
||||||
import { IconShieldCheck, IconAlertCircle } from "@tabler/icons-react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import APP_ROUTE from "@/lib/app-route";
|
|
||||||
import { MfaSetupModal } from "@/ee/mfa";
|
|
||||||
import classes from "@/features/auth/components/auth.module.css";
|
|
||||||
import { notifications } from "@mantine/notifications";
|
|
||||||
import { useMfaPageProtection } from "@/ee/mfa";
|
|
||||||
|
|
||||||
export function MfaSetupRequiredPage() {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const [setupModalOpen, setSetupModalOpen] = useState(false);
|
|
||||||
const { isValid } = useMfaPageProtection();
|
|
||||||
|
|
||||||
const handleSetupComplete = async () => {
|
|
||||||
setSetupModalOpen(false);
|
|
||||||
|
|
||||||
notifications.show({
|
|
||||||
title: t("Success"),
|
|
||||||
message: t(
|
|
||||||
"Two-factor authentication has been set up. Please log in again.",
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
navigate(APP_ROUTE.AUTH.LOGIN);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleLogout = () => {
|
|
||||||
navigate(APP_ROUTE.AUTH.LOGIN);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!isValid) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Container size={480} className={classes.container}>
|
|
||||||
<Paper radius="lg" p={40}>
|
|
||||||
<Stack align="center" gap="xl">
|
|
||||||
<Center>
|
|
||||||
<ThemeIcon size={80} radius="xl" variant="light" color="blue">
|
|
||||||
<IconShieldCheck size={40} stroke={1.5} />
|
|
||||||
</ThemeIcon>
|
|
||||||
</Center>
|
|
||||||
|
|
||||||
<Stack align="center" gap="xs">
|
|
||||||
<Title order={2} ta="center" fw={600}>
|
|
||||||
{t("Two-factor authentication required")}
|
|
||||||
</Title>
|
|
||||||
<Text size="md" c="dimmed" ta="center">
|
|
||||||
{t(
|
|
||||||
"Your workspace requires two-factor authentication for all users",
|
|
||||||
)}
|
|
||||||
</Text>
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
<Alert
|
|
||||||
icon={<IconAlertCircle size={20} />}
|
|
||||||
color="blue"
|
|
||||||
variant="light"
|
|
||||||
w="100%"
|
|
||||||
>
|
|
||||||
<Text size="sm">
|
|
||||||
{t(
|
|
||||||
"To continue accessing your workspace, you must set up two-factor authentication. This adds an extra layer of security to your account.",
|
|
||||||
)}
|
|
||||||
</Text>
|
|
||||||
</Alert>
|
|
||||||
|
|
||||||
<Stack w="100%" gap="sm">
|
|
||||||
<Button
|
|
||||||
fullWidth
|
|
||||||
size="md"
|
|
||||||
onClick={() => setSetupModalOpen(true)}
|
|
||||||
leftSection={<IconShieldCheck size={18} />}
|
|
||||||
>
|
|
||||||
{t("Set up two-factor authentication")}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
fullWidth
|
|
||||||
variant="subtle"
|
|
||||||
color="gray"
|
|
||||||
onClick={handleLogout}
|
|
||||||
>
|
|
||||||
{t("Cancel and logout")}
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
</Stack>
|
|
||||||
</Paper>
|
|
||||||
|
|
||||||
<MfaSetupModal
|
|
||||||
opened={setupModalOpen}
|
|
||||||
onClose={() => setSetupModalOpen(false)}
|
|
||||||
onComplete={handleSetupComplete}
|
|
||||||
isRequired={true}
|
|
||||||
/>
|
|
||||||
</Container>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,61 +0,0 @@
|
|||||||
import api from "@/lib/api-client";
|
|
||||||
import {
|
|
||||||
MfaBackupCodesResponse,
|
|
||||||
MfaDisableRequest,
|
|
||||||
MfaEnableRequest,
|
|
||||||
MfaEnableResponse,
|
|
||||||
MfaSetupRequest,
|
|
||||||
MfaSetupResponse,
|
|
||||||
MfaStatusResponse,
|
|
||||||
MfaAccessValidationResponse,
|
|
||||||
} from "@/ee/mfa";
|
|
||||||
|
|
||||||
export async function getMfaStatus(): Promise<MfaStatusResponse> {
|
|
||||||
const req = await api.post("/mfa/status");
|
|
||||||
return req.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function setupMfa(
|
|
||||||
data: MfaSetupRequest,
|
|
||||||
): Promise<MfaSetupResponse> {
|
|
||||||
const req = await api.post<MfaSetupResponse>("/mfa/setup", data);
|
|
||||||
return req.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function enableMfa(
|
|
||||||
data: MfaEnableRequest,
|
|
||||||
): Promise<MfaEnableResponse> {
|
|
||||||
const req = await api.post<MfaEnableResponse>("/mfa/enable", data);
|
|
||||||
return req.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function disableMfa(
|
|
||||||
data: MfaDisableRequest,
|
|
||||||
): Promise<{ success: boolean }> {
|
|
||||||
const req = await api.post<{ success: boolean }>("/mfa/disable", data);
|
|
||||||
return req.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function regenerateBackupCodes(data: {
|
|
||||||
confirmPassword: string;
|
|
||||||
}): Promise<MfaBackupCodesResponse> {
|
|
||||||
const req = await api.post<MfaBackupCodesResponse>(
|
|
||||||
"/mfa/generate-backup-codes",
|
|
||||||
data,
|
|
||||||
);
|
|
||||||
return req.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function verifyMfa(code: string): Promise<any> {
|
|
||||||
const req = await api.post("/mfa/verify", { code });
|
|
||||||
return req.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function validateMfaAccess(): Promise<MfaAccessValidationResponse> {
|
|
||||||
try {
|
|
||||||
const res = await api.post("/mfa/validate-access");
|
|
||||||
return res.data;
|
|
||||||
} catch {
|
|
||||||
return { valid: false };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,62 +0,0 @@
|
|||||||
export interface MfaMethod {
|
|
||||||
type: 'totp' | 'email';
|
|
||||||
isEnabled: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MfaSettings {
|
|
||||||
isEnabled: boolean;
|
|
||||||
methods: MfaMethod[];
|
|
||||||
backupCodesCount: number;
|
|
||||||
lastUpdated?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MfaSetupState {
|
|
||||||
method: 'totp' | 'email';
|
|
||||||
secret?: string;
|
|
||||||
qrCode?: string;
|
|
||||||
manualEntry?: string;
|
|
||||||
backupCodes?: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MfaStatusResponse {
|
|
||||||
isEnabled?: boolean;
|
|
||||||
method?: string | null;
|
|
||||||
backupCodesCount?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MfaSetupRequest {
|
|
||||||
method: 'totp';
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MfaSetupResponse {
|
|
||||||
method: string;
|
|
||||||
qrCode: string;
|
|
||||||
secret: string;
|
|
||||||
manualKey: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MfaEnableRequest {
|
|
||||||
secret: string;
|
|
||||||
verificationCode: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MfaEnableResponse {
|
|
||||||
success: boolean;
|
|
||||||
backupCodes: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MfaDisableRequest {
|
|
||||||
confirmPassword: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MfaBackupCodesResponse {
|
|
||||||
backupCodes: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MfaAccessValidationResponse {
|
|
||||||
valid: boolean;
|
|
||||||
isTransferToken?: boolean;
|
|
||||||
requiresMfaSetup?: boolean;
|
|
||||||
userHasMfa?: boolean;
|
|
||||||
isMfaEnforced?: boolean;
|
|
||||||
}
|
|
||||||
@ -1,66 +0,0 @@
|
|||||||
import { Group, Text, Switch, MantineSize, Title } from "@mantine/core";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
|
|
||||||
import React, { useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts";
|
|
||||||
import { notifications } from "@mantine/notifications";
|
|
||||||
|
|
||||||
export default function EnforceMfa() {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Title order={4} my="sm">
|
|
||||||
MFA
|
|
||||||
</Title>
|
|
||||||
<Group justify="space-between" wrap="nowrap" gap="xl">
|
|
||||||
<div>
|
|
||||||
<Text size="md">{t("Enforce two-factor authentication")}</Text>
|
|
||||||
<Text size="sm" c="dimmed">
|
|
||||||
{t(
|
|
||||||
"Once enforced, all members must enable two-factor authentication to access the workspace.",
|
|
||||||
)}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<EnforceMfaToggle />
|
|
||||||
</Group>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface EnforceMfaToggleProps {
|
|
||||||
size?: MantineSize;
|
|
||||||
label?: string;
|
|
||||||
}
|
|
||||||
export function EnforceMfaToggle({ size, label }: EnforceMfaToggleProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [workspace, setWorkspace] = useAtom(workspaceAtom);
|
|
||||||
const [checked, setChecked] = useState(workspace?.enforceMfa);
|
|
||||||
|
|
||||||
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const value = event.currentTarget.checked;
|
|
||||||
try {
|
|
||||||
const updatedWorkspace = await updateWorkspace({ enforceMfa: value });
|
|
||||||
setChecked(value);
|
|
||||||
setWorkspace(updatedWorkspace);
|
|
||||||
} catch (err) {
|
|
||||||
notifications.show({
|
|
||||||
message: err?.response?.data?.message,
|
|
||||||
color: "red",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Switch
|
|
||||||
size={size}
|
|
||||||
label={label}
|
|
||||||
labelPosition="left"
|
|
||||||
defaultChecked={checked}
|
|
||||||
onChange={handleChange}
|
|
||||||
aria-label={t("Toggle MFA enforcement")}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -15,7 +15,7 @@ export default function EnforceSso() {
|
|||||||
<Text size="md">{t("Enforce SSO")}</Text>
|
<Text size="md">{t("Enforce SSO")}</Text>
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
{t(
|
{t(
|
||||||
"Once enforced, members will not be able to login with email and password.",
|
"Once enforced, members will not able able to login with email and password.",
|
||||||
)}
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -10,14 +10,11 @@ import EnforceSso from "@/ee/security/components/enforce-sso.tsx";
|
|||||||
import AllowedDomains from "@/ee/security/components/allowed-domains.tsx";
|
import AllowedDomains from "@/ee/security/components/allowed-domains.tsx";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import useLicense from "@/ee/hooks/use-license.tsx";
|
import useLicense from "@/ee/hooks/use-license.tsx";
|
||||||
import usePlan from "@/ee/hooks/use-plan.tsx";
|
|
||||||
import EnforceMfa from "@/ee/security/components/enforce-mfa.tsx";
|
|
||||||
|
|
||||||
export default function Security() {
|
export default function Security() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { isAdmin } = useUserRole();
|
const { isAdmin } = useUserRole();
|
||||||
const { hasLicenseKey } = useLicense();
|
const { hasLicenseKey } = useLicense();
|
||||||
const { isBusiness } = usePlan();
|
|
||||||
|
|
||||||
if (!isAdmin) {
|
if (!isAdmin) {
|
||||||
return null;
|
return null;
|
||||||
@ -34,15 +31,12 @@ export default function Security() {
|
|||||||
|
|
||||||
<Divider my="lg" />
|
<Divider my="lg" />
|
||||||
|
|
||||||
<EnforceMfa />
|
|
||||||
|
|
||||||
<Divider my="lg" />
|
|
||||||
|
|
||||||
<Title order={4} my="lg">
|
<Title order={4} my="lg">
|
||||||
Single sign-on (SSO)
|
Single sign-on (SSO)
|
||||||
</Title>
|
</Title>
|
||||||
|
|
||||||
{(isCloud() && isBusiness) || (!isCloud() && hasLicenseKey) ? (
|
{/*TODO: revisit when we add a second plan */}
|
||||||
|
{!isCloud() && hasLicenseKey ? (
|
||||||
<>
|
<>
|
||||||
<EnforceSso />
|
<EnforceSso />
|
||||||
<Divider my="lg" />
|
<Divider my="lg" />
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import * as z from "zod";
|
import * as z from "zod";
|
||||||
|
|
||||||
import { useForm } from "@mantine/form";
|
import { useForm, zodResolver } from "@mantine/form";
|
||||||
import {
|
import {
|
||||||
Container,
|
Container,
|
||||||
Title,
|
Title,
|
||||||
@ -11,7 +11,6 @@ import {
|
|||||||
Box,
|
Box,
|
||||||
Stack,
|
Stack,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { zodResolver } from "mantine-form-zod-resolver";
|
|
||||||
import { useParams, useSearchParams } from "react-router-dom";
|
import { useParams, useSearchParams } from "react-router-dom";
|
||||||
import { IRegister } from "@/features/auth/types/auth.types";
|
import { IRegister } from "@/features/auth/types/auth.types";
|
||||||
import useAuth from "@/features/auth/hooks/use-auth";
|
import useAuth from "@/features/auth/hooks/use-auth";
|
||||||
@ -19,7 +18,6 @@ import classes from "@/features/auth/components/auth.module.css";
|
|||||||
import { useGetInvitationQuery } from "@/features/workspace/queries/workspace-query.ts";
|
import { useGetInvitationQuery } from "@/features/workspace/queries/workspace-query.ts";
|
||||||
import { useRedirectIfAuthenticated } from "@/features/auth/hooks/use-redirect-if-authenticated.ts";
|
import { useRedirectIfAuthenticated } from "@/features/auth/hooks/use-redirect-if-authenticated.ts";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import SsoLogin from "@/ee/components/sso-login.tsx";
|
|
||||||
|
|
||||||
const formSchema = z.object({
|
const formSchema = z.object({
|
||||||
name: z.string().trim().min(1),
|
name: z.string().trim().min(1),
|
||||||
@ -73,43 +71,39 @@ export function InviteSignUpForm() {
|
|||||||
{t("Join the workspace")}
|
{t("Join the workspace")}
|
||||||
</Title>
|
</Title>
|
||||||
|
|
||||||
<SsoLogin />
|
<Stack align="stretch" justify="center" gap="xl">
|
||||||
|
<form onSubmit={form.onSubmit(onSubmit)}>
|
||||||
|
<TextInput
|
||||||
|
id="name"
|
||||||
|
type="text"
|
||||||
|
label={t("Name")}
|
||||||
|
placeholder={t("enter your full name")}
|
||||||
|
variant="filled"
|
||||||
|
{...form.getInputProps("name")}
|
||||||
|
/>
|
||||||
|
|
||||||
{!invitation.enforceSso && (
|
<TextInput
|
||||||
<Stack align="stretch" justify="center" gap="xl">
|
id="email"
|
||||||
<form onSubmit={form.onSubmit(onSubmit)}>
|
type="email"
|
||||||
<TextInput
|
label={t("Email")}
|
||||||
id="name"
|
value={invitation.email}
|
||||||
type="text"
|
disabled
|
||||||
label={t("Name")}
|
variant="filled"
|
||||||
placeholder={t("enter your full name")}
|
mt="md"
|
||||||
variant="filled"
|
/>
|
||||||
{...form.getInputProps("name")}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<TextInput
|
<PasswordInput
|
||||||
id="email"
|
label={t("Password")}
|
||||||
type="email"
|
placeholder={t("Your password")}
|
||||||
label={t("Email")}
|
variant="filled"
|
||||||
value={invitation.email}
|
mt="md"
|
||||||
disabled
|
{...form.getInputProps("password")}
|
||||||
variant="filled"
|
/>
|
||||||
mt="md"
|
<Button type="submit" fullWidth mt="xl" loading={isLoading}>
|
||||||
/>
|
{t("Sign Up")}
|
||||||
|
</Button>
|
||||||
<PasswordInput
|
</form>
|
||||||
label={t("Password")}
|
</Stack>
|
||||||
placeholder={t("Your password")}
|
|
||||||
variant="filled"
|
|
||||||
mt="md"
|
|
||||||
{...form.getInputProps("password")}
|
|
||||||
/>
|
|
||||||
<Button type="submit" fullWidth mt="xl" loading={isLoading}>
|
|
||||||
{t("Sign Up")}
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
</Stack>
|
|
||||||
)}
|
|
||||||
</Box>
|
</Box>
|
||||||
</Container>
|
</Container>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -21,7 +21,7 @@ import { Link } from "react-router-dom";
|
|||||||
import APP_ROUTE from "@/lib/app-route.ts";
|
import APP_ROUTE from "@/lib/app-route.ts";
|
||||||
|
|
||||||
const formSchema = z.object({
|
const formSchema = z.object({
|
||||||
workspaceName: z.string().trim().max(50).optional(),
|
workspaceName: z.string().trim().min(3).max(50),
|
||||||
name: z.string().min(1).max(50),
|
name: z.string().min(1).max(50),
|
||||||
email: z
|
email: z
|
||||||
.string()
|
.string()
|
||||||
@ -60,17 +60,15 @@ export function SetupWorkspaceForm() {
|
|||||||
{isCloud() && <SsoCloudSignup />}
|
{isCloud() && <SsoCloudSignup />}
|
||||||
|
|
||||||
<form onSubmit={form.onSubmit(onSubmit)}>
|
<form onSubmit={form.onSubmit(onSubmit)}>
|
||||||
{!isCloud() && (
|
<TextInput
|
||||||
<TextInput
|
id="workspaceName"
|
||||||
id="workspaceName"
|
type="text"
|
||||||
type="text"
|
label={t("Workspace Name")}
|
||||||
label={t("Workspace Name")}
|
placeholder={t("e.g ACME Inc")}
|
||||||
placeholder={t("e.g ACME Inc")}
|
variant="filled"
|
||||||
variant="filled"
|
mt="md"
|
||||||
mt="md"
|
{...form.getInputProps("workspaceName")}
|
||||||
{...form.getInputProps("workspaceName")}
|
/>
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<TextInput
|
<TextInput
|
||||||
id="name"
|
id="name"
|
||||||
|
|||||||
@ -27,7 +27,7 @@ import APP_ROUTE from "@/lib/app-route.ts";
|
|||||||
import { RESET } from "jotai/utils";
|
import { RESET } from "jotai/utils";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { isCloud } from "@/lib/config.ts";
|
import { isCloud } from "@/lib/config.ts";
|
||||||
import { exchangeTokenRedirectUrl } from "@/ee/utils.ts";
|
import { exchangeTokenRedirectUrl, getHostnameUrl } from "@/ee/utils.ts";
|
||||||
|
|
||||||
export default function useAuth() {
|
export default function useAuth() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@ -39,17 +39,9 @@ export default function useAuth() {
|
|||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await login(data);
|
await login(data);
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
|
navigate(APP_ROUTE.HOME);
|
||||||
// Check if MFA is required
|
|
||||||
if (response?.userHasMfa) {
|
|
||||||
navigate(APP_ROUTE.AUTH.MFA_CHALLENGE);
|
|
||||||
} else if (response?.requiresMfaSetup) {
|
|
||||||
navigate(APP_ROUTE.AUTH.MFA_SETUP_REQUIRED);
|
|
||||||
} else {
|
|
||||||
navigate(APP_ROUTE.HOME);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
console.log(err);
|
console.log(err);
|
||||||
@ -64,19 +56,9 @@ export default function useAuth() {
|
|||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await acceptInvitation(data);
|
await acceptInvitation(data);
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
|
navigate(APP_ROUTE.HOME);
|
||||||
if (response?.requiresLogin) {
|
|
||||||
notifications.show({
|
|
||||||
message: t(
|
|
||||||
"Account created successfully. Please log in to set up two-factor authentication.",
|
|
||||||
),
|
|
||||||
});
|
|
||||||
navigate(APP_ROUTE.AUTH.LOGIN);
|
|
||||||
} else {
|
|
||||||
navigate(APP_ROUTE.HOME);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
notifications.show({
|
notifications.show({
|
||||||
@ -118,22 +100,12 @@ export default function useAuth() {
|
|||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await passwordReset(data);
|
await passwordReset(data);
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
|
navigate(APP_ROUTE.HOME);
|
||||||
if (response?.requiresLogin) {
|
notifications.show({
|
||||||
notifications.show({
|
message: t("Password reset was successful"),
|
||||||
message: t(
|
});
|
||||||
"Password reset was successful. Please log in with your new password.",
|
|
||||||
),
|
|
||||||
});
|
|
||||||
navigate(APP_ROUTE.AUTH.LOGIN);
|
|
||||||
} else {
|
|
||||||
navigate(APP_ROUTE.HOME);
|
|
||||||
notifications.show({
|
|
||||||
message: t("Password reset was successful"),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
notifications.show({
|
notifications.show({
|
||||||
|
|||||||
@ -4,16 +4,14 @@ import {
|
|||||||
ICollabToken,
|
ICollabToken,
|
||||||
IForgotPassword,
|
IForgotPassword,
|
||||||
ILogin,
|
ILogin,
|
||||||
ILoginResponse,
|
|
||||||
IPasswordReset,
|
IPasswordReset,
|
||||||
ISetupWorkspace,
|
ISetupWorkspace,
|
||||||
IVerifyUserToken,
|
IVerifyUserToken,
|
||||||
} from "@/features/auth/types/auth.types";
|
} from "@/features/auth/types/auth.types";
|
||||||
import { IWorkspace } from "@/features/workspace/types/workspace.types.ts";
|
import { IWorkspace } from "@/features/workspace/types/workspace.types.ts";
|
||||||
|
|
||||||
export async function login(data: ILogin): Promise<ILoginResponse> {
|
export async function login(data: ILogin): Promise<void> {
|
||||||
const response = await api.post<ILoginResponse>("/auth/login", data);
|
await api.post<void>("/auth/login", data);
|
||||||
return response.data;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function logout(): Promise<void> {
|
export async function logout(): Promise<void> {
|
||||||
@ -38,9 +36,8 @@ export async function forgotPassword(data: IForgotPassword): Promise<void> {
|
|||||||
await api.post<void>("/auth/forgot-password", data);
|
await api.post<void>("/auth/forgot-password", data);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function passwordReset(data: IPasswordReset): Promise<{ requiresLogin?: boolean; }> {
|
export async function passwordReset(data: IPasswordReset): Promise<void> {
|
||||||
const req = await api.post("/auth/password-reset", data);
|
await api.post<void>("/auth/password-reset", data);
|
||||||
return req.data;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function verifyUserToken(data: IVerifyUserToken): Promise<any> {
|
export async function verifyUserToken(data: IVerifyUserToken): Promise<any> {
|
||||||
|
|||||||
@ -10,7 +10,7 @@ export interface IRegister {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ISetupWorkspace {
|
export interface ISetupWorkspace {
|
||||||
workspaceName?: string;
|
workspaceName: string;
|
||||||
name: string;
|
name: string;
|
||||||
email: string;
|
email: string;
|
||||||
password: string;
|
password: string;
|
||||||
@ -36,12 +36,5 @@ export interface IVerifyUserToken {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ICollabToken {
|
export interface ICollabToken {
|
||||||
token?: string;
|
token: string;
|
||||||
}
|
|
||||||
|
|
||||||
export interface ILoginResponse {
|
|
||||||
userHasMfa?: boolean;
|
|
||||||
requiresMfaSetup?: boolean;
|
|
||||||
mfaToken?: string;
|
|
||||||
isMfaEnforced?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { Button, Group, Tooltip } from "@mantine/core";
|
import { Button, Group } from "@mantine/core";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
type CommentActionsProps = {
|
type CommentActionsProps = {
|
||||||
|
|||||||
@ -15,7 +15,6 @@ import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-
|
|||||||
import { useEditor } from "@tiptap/react";
|
import { useEditor } from "@tiptap/react";
|
||||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useQueryEmit } from "@/features/websocket/use-query-emit";
|
|
||||||
|
|
||||||
interface CommentDialogProps {
|
interface CommentDialogProps {
|
||||||
editor: ReturnType<typeof useEditor>;
|
editor: ReturnType<typeof useEditor>;
|
||||||
@ -36,8 +35,6 @@ function CommentDialog({ editor, pageId }: CommentDialogProps) {
|
|||||||
const createCommentMutation = useCreateCommentMutation();
|
const createCommentMutation = useCreateCommentMutation();
|
||||||
const { isPending } = createCommentMutation;
|
const { isPending } = createCommentMutation;
|
||||||
|
|
||||||
const emit = useQueryEmit();
|
|
||||||
|
|
||||||
const handleDialogClose = () => {
|
const handleDialogClose = () => {
|
||||||
setShowCommentPopup(false);
|
setShowCommentPopup(false);
|
||||||
editor.chain().focus().unsetCommentDecoration().run();
|
editor.chain().focus().unsetCommentDecoration().run();
|
||||||
@ -66,23 +63,11 @@ function CommentDialog({ editor, pageId }: CommentDialogProps) {
|
|||||||
.run();
|
.run();
|
||||||
setActiveCommentId(createdComment.id);
|
setActiveCommentId(createdComment.id);
|
||||||
|
|
||||||
//unselect text to close bubble menu
|
|
||||||
editor.commands.setTextSelection({ from: editor.view.state.selection.from, to: editor.view.state.selection.from });
|
|
||||||
|
|
||||||
setAsideState({ tab: "comments", isAsideOpen: true });
|
setAsideState({ tab: "comments", isAsideOpen: true });
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const selector = `div[data-comment-id="${createdComment.id}"]`;
|
const selector = `div[data-comment-id="${createdComment.id}"]`;
|
||||||
const commentElement = document.querySelector(selector);
|
const commentElement = document.querySelector(selector);
|
||||||
commentElement?.scrollIntoView({ behavior: "smooth", block: "center" });
|
commentElement?.scrollIntoView();
|
||||||
|
|
||||||
editor.view.dispatch(
|
|
||||||
editor.state.tr.scrollIntoView()
|
|
||||||
);
|
|
||||||
}, 400);
|
|
||||||
|
|
||||||
emit({
|
|
||||||
operation: "invalidateComment",
|
|
||||||
pageId: pageId,
|
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setShowCommentPopup(false);
|
setShowCommentPopup(false);
|
||||||
@ -124,7 +109,6 @@ function CommentDialog({ editor, pageId }: CommentDialogProps) {
|
|||||||
|
|
||||||
<CommentEditor
|
<CommentEditor
|
||||||
onUpdate={handleCommentEditorChange}
|
onUpdate={handleCommentEditorChange}
|
||||||
onSave={handleAddComment}
|
|
||||||
placeholder={t("Write a comment")}
|
placeholder={t("Write a comment")}
|
||||||
editable={true}
|
editable={true}
|
||||||
autofocus={true}
|
autofocus={true}
|
||||||
|
|||||||
@ -8,12 +8,10 @@ import { useFocusWithin } from "@mantine/hooks";
|
|||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { forwardRef, useEffect, useImperativeHandle } from "react";
|
import { forwardRef, useEffect, useImperativeHandle } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import EmojiCommand from "@/features/editor/extensions/emoji-command";
|
|
||||||
|
|
||||||
interface CommentEditorProps {
|
interface CommentEditorProps {
|
||||||
defaultContent?: any;
|
defaultContent?: any;
|
||||||
onUpdate?: any;
|
onUpdate?: any;
|
||||||
onSave?: any;
|
|
||||||
editable: boolean;
|
editable: boolean;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
autofocus?: boolean;
|
autofocus?: boolean;
|
||||||
@ -24,7 +22,6 @@ const CommentEditor = forwardRef(
|
|||||||
{
|
{
|
||||||
defaultContent,
|
defaultContent,
|
||||||
onUpdate,
|
onUpdate,
|
||||||
onSave,
|
|
||||||
editable,
|
editable,
|
||||||
placeholder,
|
placeholder,
|
||||||
autofocus,
|
autofocus,
|
||||||
@ -45,35 +42,7 @@ const CommentEditor = forwardRef(
|
|||||||
}),
|
}),
|
||||||
Underline,
|
Underline,
|
||||||
Link,
|
Link,
|
||||||
EmojiCommand,
|
|
||||||
],
|
],
|
||||||
editorProps: {
|
|
||||||
handleDOMEvents: {
|
|
||||||
keydown: (_view, event) => {
|
|
||||||
if (
|
|
||||||
[
|
|
||||||
"ArrowUp",
|
|
||||||
"ArrowDown",
|
|
||||||
"ArrowLeft",
|
|
||||||
"ArrowRight",
|
|
||||||
"Enter",
|
|
||||||
].includes(event.key)
|
|
||||||
) {
|
|
||||||
const emojiCommand = document.querySelector("#emoji-command");
|
|
||||||
if (emojiCommand) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ((event.ctrlKey || event.metaKey) && event.key === "Enter") {
|
|
||||||
event.preventDefault();
|
|
||||||
if (onSave) onSave();
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
onUpdate({ editor }) {
|
onUpdate({ editor }) {
|
||||||
if (onUpdate) onUpdate(editor.getJSON());
|
if (onUpdate) onUpdate(editor.getJSON());
|
||||||
},
|
},
|
||||||
@ -84,10 +53,6 @@ const CommentEditor = forwardRef(
|
|||||||
autofocus: (autofocus && "end") || false,
|
autofocus: (autofocus && "end") || false,
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
commentEditor.commands.setContent(defaultContent);
|
|
||||||
}, [defaultContent]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (autofocus) {
|
if (autofocus) {
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { Group, Text, Box, Badge } from "@mantine/core";
|
import { Group, Text, Box } from "@mantine/core";
|
||||||
import React, { useEffect, useState } from "react";
|
import React, { useState } from "react";
|
||||||
import classes from "./comment.module.css";
|
import classes from "./comment.module.css";
|
||||||
import { useAtom, useAtomValue } from "jotai";
|
import { useAtom, useAtomValue } from "jotai";
|
||||||
import { timeAgo } from "@/lib/time";
|
import { timeAgo } from "@/lib/time";
|
||||||
@ -7,34 +7,20 @@ import CommentEditor from "@/features/comment/components/comment-editor";
|
|||||||
import { pageEditorAtom } from "@/features/editor/atoms/editor-atoms";
|
import { pageEditorAtom } from "@/features/editor/atoms/editor-atoms";
|
||||||
import CommentActions from "@/features/comment/components/comment-actions";
|
import CommentActions from "@/features/comment/components/comment-actions";
|
||||||
import CommentMenu from "@/features/comment/components/comment-menu";
|
import CommentMenu from "@/features/comment/components/comment-menu";
|
||||||
import { useIsCloudEE } from "@/hooks/use-is-cloud-ee";
|
|
||||||
import ResolveComment from "@/ee/comment/components/resolve-comment";
|
|
||||||
import { useHover } from "@mantine/hooks";
|
import { useHover } from "@mantine/hooks";
|
||||||
import {
|
import {
|
||||||
useDeleteCommentMutation,
|
useDeleteCommentMutation,
|
||||||
useUpdateCommentMutation,
|
useUpdateCommentMutation,
|
||||||
} from "@/features/comment/queries/comment-query";
|
} from "@/features/comment/queries/comment-query";
|
||||||
import { useResolveCommentMutation } from "@/ee/comment/queries/comment-query";
|
|
||||||
import { IComment } from "@/features/comment/types/comment.types";
|
import { IComment } from "@/features/comment/types/comment.types";
|
||||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||||
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
|
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||||
import { useQueryEmit } from "@/features/websocket/use-query-emit";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
interface CommentListItemProps {
|
interface CommentListItemProps {
|
||||||
comment: IComment;
|
comment: IComment;
|
||||||
pageId: string;
|
|
||||||
canComment: boolean;
|
|
||||||
userSpaceRole?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function CommentListItem({
|
function CommentListItem({ comment }: CommentListItemProps) {
|
||||||
comment,
|
|
||||||
pageId,
|
|
||||||
canComment,
|
|
||||||
userSpaceRole,
|
|
||||||
}: CommentListItemProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const { hovered, ref } = useHover();
|
const { hovered, ref } = useHover();
|
||||||
const [isEditing, setIsEditing] = useState(false);
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
@ -42,14 +28,7 @@ function CommentListItem({
|
|||||||
const [content, setContent] = useState<string>(comment.content);
|
const [content, setContent] = useState<string>(comment.content);
|
||||||
const updateCommentMutation = useUpdateCommentMutation();
|
const updateCommentMutation = useUpdateCommentMutation();
|
||||||
const deleteCommentMutation = useDeleteCommentMutation(comment.pageId);
|
const deleteCommentMutation = useDeleteCommentMutation(comment.pageId);
|
||||||
const resolveCommentMutation = useResolveCommentMutation();
|
|
||||||
const [currentUser] = useAtom(currentUserAtom);
|
const [currentUser] = useAtom(currentUserAtom);
|
||||||
const emit = useQueryEmit();
|
|
||||||
const isCloudEE = useIsCloudEE();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setContent(comment.content);
|
|
||||||
}, [comment]);
|
|
||||||
|
|
||||||
async function handleUpdateComment() {
|
async function handleUpdateComment() {
|
||||||
try {
|
try {
|
||||||
@ -60,11 +39,6 @@ function CommentListItem({
|
|||||||
};
|
};
|
||||||
await updateCommentMutation.mutateAsync(commentToUpdate);
|
await updateCommentMutation.mutateAsync(commentToUpdate);
|
||||||
setIsEditing(false);
|
setIsEditing(false);
|
||||||
|
|
||||||
emit({
|
|
||||||
operation: "invalidateComment",
|
|
||||||
pageId: pageId,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to update comment:", error);
|
console.error("Failed to update comment:", error);
|
||||||
} finally {
|
} finally {
|
||||||
@ -76,54 +50,11 @@ function CommentListItem({
|
|||||||
try {
|
try {
|
||||||
await deleteCommentMutation.mutateAsync(comment.id);
|
await deleteCommentMutation.mutateAsync(comment.id);
|
||||||
editor?.commands.unsetComment(comment.id);
|
editor?.commands.unsetComment(comment.id);
|
||||||
|
|
||||||
emit({
|
|
||||||
operation: "invalidateComment",
|
|
||||||
pageId: pageId,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to delete comment:", error);
|
console.error("Failed to delete comment:", error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleResolveComment() {
|
|
||||||
if (!isCloudEE) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const isResolved = comment.resolvedAt != null;
|
|
||||||
|
|
||||||
await resolveCommentMutation.mutateAsync({
|
|
||||||
commentId: comment.id,
|
|
||||||
pageId: comment.pageId,
|
|
||||||
resolved: !isResolved,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (editor) {
|
|
||||||
editor.commands.setCommentResolved(comment.id, !isResolved);
|
|
||||||
}
|
|
||||||
|
|
||||||
emit({
|
|
||||||
operation: "invalidateComment",
|
|
||||||
pageId: pageId,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to toggle resolved state:", error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleCommentClick(comment: IComment) {
|
|
||||||
const el = document.querySelector(
|
|
||||||
`.comment-mark[data-comment-id="${comment.id}"]`,
|
|
||||||
);
|
|
||||||
if (el) {
|
|
||||||
el.scrollIntoView({ behavior: "smooth", block: "center" });
|
|
||||||
el.classList.add("comment-highlight");
|
|
||||||
setTimeout(() => {
|
|
||||||
el.classList.remove("comment-highlight");
|
|
||||||
}, 3000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleEditToggle() {
|
function handleEditToggle() {
|
||||||
setIsEditing(true);
|
setIsEditing(true);
|
||||||
}
|
}
|
||||||
@ -147,42 +78,28 @@ function CommentListItem({
|
|||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<div style={{ visibility: hovered ? "visible" : "hidden" }}>
|
<div style={{ visibility: hovered ? "visible" : "hidden" }}>
|
||||||
{!comment.parentCommentId && canComment && isCloudEE && (
|
{/*!comment.parentCommentId && (
|
||||||
<ResolveComment
|
<ResolveComment commentId={comment.id} pageId={comment.pageId} resolvedAt={comment.resolvedAt} />
|
||||||
editor={editor}
|
)*/}
|
||||||
commentId={comment.id}
|
|
||||||
pageId={comment.pageId}
|
|
||||||
resolvedAt={comment.resolvedAt}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{(currentUser?.user?.id === comment.creatorId || userSpaceRole === 'admin') && (
|
{currentUser?.user?.id === comment.creatorId && (
|
||||||
<CommentMenu
|
<CommentMenu
|
||||||
onEditComment={handleEditToggle}
|
onEditComment={handleEditToggle}
|
||||||
onDeleteComment={handleDeleteComment}
|
onDeleteComment={handleDeleteComment}
|
||||||
onResolveComment={handleResolveComment}
|
|
||||||
canEdit={currentUser?.user?.id === comment.creatorId}
|
|
||||||
isResolved={comment.resolvedAt != null}
|
|
||||||
isParentComment={!comment.parentCommentId}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<Group gap="xs">
|
<Text size="xs" fw={500} c="dimmed">
|
||||||
<Text size="xs" fw={500} c="dimmed">
|
{timeAgo(comment.createdAt)}
|
||||||
{timeAgo(comment.createdAt)}
|
</Text>
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
</div>
|
</div>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
{!comment.parentCommentId && comment?.selection && (
|
{!comment.parentCommentId && comment?.selection && (
|
||||||
<Box
|
<Box className={classes.textSelection}>
|
||||||
className={classes.textSelection}
|
|
||||||
onClick={() => handleCommentClick(comment)}
|
|
||||||
>
|
|
||||||
<Text size="sm">{comment?.selection}</Text>
|
<Text size="sm">{comment?.selection}</Text>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
@ -195,7 +112,6 @@ function CommentListItem({
|
|||||||
defaultContent={content}
|
defaultContent={content}
|
||||||
editable={true}
|
editable={true}
|
||||||
onUpdate={(newContent: any) => setContent(newContent)}
|
onUpdate={(newContent: any) => setContent(newContent)}
|
||||||
onSave={handleUpdateComment}
|
|
||||||
autofocus={true}
|
autofocus={true}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@ -1,318 +0,0 @@
|
|||||||
import React, { useState, useRef, useCallback, memo, useMemo } from "react";
|
|
||||||
import { useParams } from "react-router-dom";
|
|
||||||
import { Divider, Paper, Tabs, Badge, Text, ScrollArea } from "@mantine/core";
|
|
||||||
import CommentListItem from "@/features/comment/components/comment-list-item";
|
|
||||||
import {
|
|
||||||
useCommentsQuery,
|
|
||||||
useCreateCommentMutation,
|
|
||||||
} from "@/features/comment/queries/comment-query";
|
|
||||||
import CommentEditor from "@/features/comment/components/comment-editor";
|
|
||||||
import CommentActions from "@/features/comment/components/comment-actions";
|
|
||||||
import { useFocusWithin } from "@mantine/hooks";
|
|
||||||
import { IComment } from "@/features/comment/types/comment.types.ts";
|
|
||||||
import { usePageQuery } from "@/features/page/queries/page-query.ts";
|
|
||||||
import { IPagination } from "@/lib/types.ts";
|
|
||||||
import { extractPageSlugId } from "@/lib";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { useQueryEmit } from "@/features/websocket/use-query-emit";
|
|
||||||
import { useIsCloudEE } from "@/hooks/use-is-cloud-ee";
|
|
||||||
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts";
|
|
||||||
import { useSpaceAbility } from "@/features/space/permissions/use-space-ability.ts";
|
|
||||||
import {
|
|
||||||
SpaceCaslAction,
|
|
||||||
SpaceCaslSubject,
|
|
||||||
} from "@/features/space/permissions/permissions.type.ts";
|
|
||||||
|
|
||||||
function CommentListWithTabs() {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const { pageSlug } = useParams();
|
|
||||||
const { data: page } = usePageQuery({ pageId: extractPageSlugId(pageSlug) });
|
|
||||||
const {
|
|
||||||
data: comments,
|
|
||||||
isLoading: isCommentsLoading,
|
|
||||||
isError,
|
|
||||||
} = useCommentsQuery({ pageId: page?.id, limit: 100 });
|
|
||||||
const createCommentMutation = useCreateCommentMutation();
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const emit = useQueryEmit();
|
|
||||||
const isCloudEE = useIsCloudEE();
|
|
||||||
const { data: space } = useGetSpaceBySlugQuery(page?.space?.slug);
|
|
||||||
|
|
||||||
const spaceRules = space?.membership?.permissions;
|
|
||||||
const spaceAbility = useSpaceAbility(spaceRules);
|
|
||||||
|
|
||||||
const canComment: boolean = spaceAbility.can(
|
|
||||||
SpaceCaslAction.Manage,
|
|
||||||
SpaceCaslSubject.Page
|
|
||||||
);
|
|
||||||
|
|
||||||
// Separate active and resolved comments
|
|
||||||
const { activeComments, resolvedComments } = useMemo(() => {
|
|
||||||
if (!comments?.items) {
|
|
||||||
return { activeComments: [], resolvedComments: [] };
|
|
||||||
}
|
|
||||||
|
|
||||||
const parentComments = comments.items.filter(
|
|
||||||
(comment: IComment) => comment.parentCommentId === null
|
|
||||||
);
|
|
||||||
|
|
||||||
const active = parentComments.filter(
|
|
||||||
(comment: IComment) => !comment.resolvedAt
|
|
||||||
);
|
|
||||||
const resolved = parentComments.filter(
|
|
||||||
(comment: IComment) => comment.resolvedAt
|
|
||||||
);
|
|
||||||
|
|
||||||
return { activeComments: active, resolvedComments: resolved };
|
|
||||||
}, [comments]);
|
|
||||||
|
|
||||||
const handleAddReply = useCallback(
|
|
||||||
async (commentId: string, content: string) => {
|
|
||||||
try {
|
|
||||||
setIsLoading(true);
|
|
||||||
const commentData = {
|
|
||||||
pageId: page?.id,
|
|
||||||
parentCommentId: commentId,
|
|
||||||
content: JSON.stringify(content),
|
|
||||||
};
|
|
||||||
|
|
||||||
await createCommentMutation.mutateAsync(commentData);
|
|
||||||
|
|
||||||
emit({
|
|
||||||
operation: "invalidateComment",
|
|
||||||
pageId: page?.id,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to post comment:", error);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[createCommentMutation, page?.id]
|
|
||||||
);
|
|
||||||
|
|
||||||
const renderComments = useCallback(
|
|
||||||
(comment: IComment) => (
|
|
||||||
<Paper
|
|
||||||
shadow="sm"
|
|
||||||
radius="md"
|
|
||||||
p="sm"
|
|
||||||
mb="sm"
|
|
||||||
withBorder
|
|
||||||
key={comment.id}
|
|
||||||
data-comment-id={comment.id}
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<CommentListItem
|
|
||||||
comment={comment}
|
|
||||||
pageId={page?.id}
|
|
||||||
canComment={canComment}
|
|
||||||
userSpaceRole={space?.membership?.role}
|
|
||||||
/>
|
|
||||||
<MemoizedChildComments
|
|
||||||
comments={comments}
|
|
||||||
parentId={comment.id}
|
|
||||||
pageId={page?.id}
|
|
||||||
canComment={canComment}
|
|
||||||
userSpaceRole={space?.membership?.role}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{!comment.resolvedAt && canComment && (
|
|
||||||
<>
|
|
||||||
<Divider my={4} />
|
|
||||||
<CommentEditorWithActions
|
|
||||||
commentId={comment.id}
|
|
||||||
onSave={handleAddReply}
|
|
||||||
isLoading={isLoading}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Paper>
|
|
||||||
),
|
|
||||||
[comments, handleAddReply, isLoading, space?.membership?.role]
|
|
||||||
);
|
|
||||||
|
|
||||||
if (isCommentsLoading) {
|
|
||||||
return <></>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isError) {
|
|
||||||
return <div>{t("Error loading comments.")}</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const totalComments = activeComments.length + resolvedComments.length;
|
|
||||||
|
|
||||||
// If not cloud/enterprise, show simple list without tabs
|
|
||||||
if (!isCloudEE) {
|
|
||||||
if (totalComments === 0) {
|
|
||||||
return <>{t("No comments yet.")}</>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ScrollArea style={{ height: "85vh" }} scrollbarSize={5} type="scroll">
|
|
||||||
<div style={{ paddingBottom: "200px" }}>
|
|
||||||
{comments?.items
|
|
||||||
.filter((comment: IComment) => comment.parentCommentId === null)
|
|
||||||
.map((comment) => (
|
|
||||||
<Paper
|
|
||||||
shadow="sm"
|
|
||||||
radius="md"
|
|
||||||
p="sm"
|
|
||||||
mb="sm"
|
|
||||||
withBorder
|
|
||||||
key={comment.id}
|
|
||||||
data-comment-id={comment.id}
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<CommentListItem
|
|
||||||
comment={comment}
|
|
||||||
pageId={page?.id}
|
|
||||||
canComment={canComment}
|
|
||||||
userSpaceRole={space?.membership?.role}
|
|
||||||
/>
|
|
||||||
<MemoizedChildComments
|
|
||||||
comments={comments}
|
|
||||||
parentId={comment.id}
|
|
||||||
pageId={page?.id}
|
|
||||||
canComment={canComment}
|
|
||||||
userSpaceRole={space?.membership?.role}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Paper>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</ScrollArea>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{ height: "85vh", display: "flex", flexDirection: "column", marginTop: '-15px' }}>
|
|
||||||
<Tabs defaultValue="open" variant="default" style={{ flex: "0 0 auto" }}>
|
|
||||||
<Tabs.List justify="center">
|
|
||||||
<Tabs.Tab
|
|
||||||
value="open"
|
|
||||||
leftSection={
|
|
||||||
<Badge size="sm" variant="light" color="blue">
|
|
||||||
{activeComments.length}
|
|
||||||
</Badge>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{t("Open")}
|
|
||||||
</Tabs.Tab>
|
|
||||||
<Tabs.Tab
|
|
||||||
value="resolved"
|
|
||||||
leftSection={
|
|
||||||
<Badge size="sm" variant="light" color="green">
|
|
||||||
{resolvedComments.length}
|
|
||||||
</Badge>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{t("Resolved")}
|
|
||||||
</Tabs.Tab>
|
|
||||||
</Tabs.List>
|
|
||||||
|
|
||||||
<ScrollArea
|
|
||||||
style={{ flex: "1 1 auto", height: "calc(85vh - 60px)" }}
|
|
||||||
scrollbarSize={5}
|
|
||||||
type="scroll"
|
|
||||||
>
|
|
||||||
<div style={{ paddingBottom: "200px" }}>
|
|
||||||
<Tabs.Panel value="open" pt="xs">
|
|
||||||
{activeComments.length === 0 ? (
|
|
||||||
<Text size="sm" c="dimmed" ta="center" py="md">
|
|
||||||
{t("No open comments.")}
|
|
||||||
</Text>
|
|
||||||
) : (
|
|
||||||
activeComments.map(renderComments)
|
|
||||||
)}
|
|
||||||
</Tabs.Panel>
|
|
||||||
|
|
||||||
<Tabs.Panel value="resolved" pt="xs">
|
|
||||||
{resolvedComments.length === 0 ? (
|
|
||||||
<Text size="sm" c="dimmed" ta="center" py="md">
|
|
||||||
{t("No resolved comments.")}
|
|
||||||
</Text>
|
|
||||||
) : (
|
|
||||||
resolvedComments.map(renderComments)
|
|
||||||
)}
|
|
||||||
</Tabs.Panel>
|
|
||||||
</div>
|
|
||||||
</ScrollArea>
|
|
||||||
</Tabs>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ChildCommentsProps {
|
|
||||||
comments: IPagination<IComment>;
|
|
||||||
parentId: string;
|
|
||||||
pageId: string;
|
|
||||||
canComment: boolean;
|
|
||||||
userSpaceRole?: string;
|
|
||||||
}
|
|
||||||
const ChildComments = ({
|
|
||||||
comments,
|
|
||||||
parentId,
|
|
||||||
pageId,
|
|
||||||
canComment,
|
|
||||||
userSpaceRole,
|
|
||||||
}: ChildCommentsProps) => {
|
|
||||||
const getChildComments = useCallback(
|
|
||||||
(parentId: string) =>
|
|
||||||
comments.items.filter(
|
|
||||||
(comment: IComment) => comment.parentCommentId === parentId
|
|
||||||
),
|
|
||||||
[comments.items]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
{getChildComments(parentId).map((childComment) => (
|
|
||||||
<div key={childComment.id}>
|
|
||||||
<CommentListItem
|
|
||||||
comment={childComment}
|
|
||||||
pageId={pageId}
|
|
||||||
canComment={canComment}
|
|
||||||
userSpaceRole={userSpaceRole}
|
|
||||||
/>
|
|
||||||
<MemoizedChildComments
|
|
||||||
comments={comments}
|
|
||||||
parentId={childComment.id}
|
|
||||||
pageId={pageId}
|
|
||||||
canComment={canComment}
|
|
||||||
userSpaceRole={userSpaceRole}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const MemoizedChildComments = memo(ChildComments);
|
|
||||||
|
|
||||||
const CommentEditorWithActions = ({ commentId, onSave, isLoading }) => {
|
|
||||||
const [content, setContent] = useState("");
|
|
||||||
const { ref, focused } = useFocusWithin();
|
|
||||||
const commentEditorRef = useRef(null);
|
|
||||||
|
|
||||||
const handleSave = useCallback(() => {
|
|
||||||
onSave(commentId, content);
|
|
||||||
setContent("");
|
|
||||||
commentEditorRef.current?.clearContent();
|
|
||||||
}, [commentId, content, onSave]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div ref={ref}>
|
|
||||||
<CommentEditor
|
|
||||||
ref={commentEditorRef}
|
|
||||||
onUpdate={setContent}
|
|
||||||
onSave={handleSave}
|
|
||||||
editable={true}
|
|
||||||
/>
|
|
||||||
{focused && <CommentActions onSave={handleSave} isLoading={isLoading} />}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default CommentListWithTabs;
|
|
||||||
152
apps/client/src/features/comment/components/comment-list.tsx
Normal file
152
apps/client/src/features/comment/components/comment-list.tsx
Normal file
@ -0,0 +1,152 @@
|
|||||||
|
import React, { useState, useRef, useCallback, memo } from "react";
|
||||||
|
import { useParams } from "react-router-dom";
|
||||||
|
import { Divider, Paper } from "@mantine/core";
|
||||||
|
import CommentListItem from "@/features/comment/components/comment-list-item";
|
||||||
|
import {
|
||||||
|
useCommentsQuery,
|
||||||
|
useCreateCommentMutation,
|
||||||
|
} from "@/features/comment/queries/comment-query";
|
||||||
|
import CommentEditor from "@/features/comment/components/comment-editor";
|
||||||
|
import CommentActions from "@/features/comment/components/comment-actions";
|
||||||
|
import { useFocusWithin } from "@mantine/hooks";
|
||||||
|
import { IComment } from "@/features/comment/types/comment.types.ts";
|
||||||
|
import { usePageQuery } from "@/features/page/queries/page-query.ts";
|
||||||
|
import { IPagination } from "@/lib/types.ts";
|
||||||
|
import { extractPageSlugId } from "@/lib";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
function CommentList() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { pageSlug } = useParams();
|
||||||
|
const { data: page } = usePageQuery({ pageId: extractPageSlugId(pageSlug) });
|
||||||
|
const {
|
||||||
|
data: comments,
|
||||||
|
isLoading: isCommentsLoading,
|
||||||
|
isError,
|
||||||
|
} = useCommentsQuery({ pageId: page?.id, limit: 100 });
|
||||||
|
const createCommentMutation = useCreateCommentMutation();
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const handleAddReply = useCallback(
|
||||||
|
async (commentId: string, content: string) => {
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
const commentData = {
|
||||||
|
pageId: page?.id,
|
||||||
|
parentCommentId: commentId,
|
||||||
|
content: JSON.stringify(content),
|
||||||
|
};
|
||||||
|
|
||||||
|
await createCommentMutation.mutateAsync(commentData);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to post comment:", error);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[createCommentMutation, page?.id],
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderComments = useCallback(
|
||||||
|
(comment: IComment) => (
|
||||||
|
<Paper
|
||||||
|
shadow="sm"
|
||||||
|
radius="md"
|
||||||
|
p="sm"
|
||||||
|
mb="sm"
|
||||||
|
withBorder
|
||||||
|
key={comment.id}
|
||||||
|
data-comment-id={comment.id}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<CommentListItem comment={comment} />
|
||||||
|
<MemoizedChildComments comments={comments} parentId={comment.id} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Divider my={4} />
|
||||||
|
|
||||||
|
<CommentEditorWithActions
|
||||||
|
commentId={comment.id}
|
||||||
|
onSave={handleAddReply}
|
||||||
|
isLoading={isLoading}
|
||||||
|
/>
|
||||||
|
</Paper>
|
||||||
|
),
|
||||||
|
[comments, handleAddReply, isLoading],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isCommentsLoading) {
|
||||||
|
return <></>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError) {
|
||||||
|
return <div>{t("Error loading comments.")}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!comments || comments.items.length === 0) {
|
||||||
|
return <>{t("No comments yet.")}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{comments.items
|
||||||
|
.filter((comment) => comment.parentCommentId === null)
|
||||||
|
.map(renderComments)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChildCommentsProps {
|
||||||
|
comments: IPagination<IComment>;
|
||||||
|
parentId: string;
|
||||||
|
}
|
||||||
|
const ChildComments = ({ comments, parentId }: ChildCommentsProps) => {
|
||||||
|
const getChildComments = useCallback(
|
||||||
|
(parentId: string) =>
|
||||||
|
comments.items.filter(
|
||||||
|
(comment: IComment) => comment.parentCommentId === parentId,
|
||||||
|
),
|
||||||
|
[comments.items],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{getChildComments(parentId).map((childComment) => (
|
||||||
|
<div key={childComment.id}>
|
||||||
|
<CommentListItem comment={childComment} />
|
||||||
|
<MemoizedChildComments
|
||||||
|
comments={comments}
|
||||||
|
parentId={childComment.id}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const MemoizedChildComments = memo(ChildComments);
|
||||||
|
|
||||||
|
const CommentEditorWithActions = ({ commentId, onSave, isLoading }) => {
|
||||||
|
const [content, setContent] = useState("");
|
||||||
|
const { ref, focused } = useFocusWithin();
|
||||||
|
const commentEditorRef = useRef(null);
|
||||||
|
|
||||||
|
const handleSave = useCallback(() => {
|
||||||
|
onSave(commentId, content);
|
||||||
|
setContent("");
|
||||||
|
commentEditorRef.current?.clearContent();
|
||||||
|
}, [commentId, content, onSave]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref}>
|
||||||
|
<CommentEditor
|
||||||
|
ref={commentEditorRef}
|
||||||
|
onUpdate={setContent}
|
||||||
|
editable={true}
|
||||||
|
/>
|
||||||
|
{focused && <CommentActions onSave={handleSave} isLoading={isLoading} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CommentList;
|
||||||
@ -1,28 +1,15 @@
|
|||||||
import { ActionIcon, Menu, Tooltip } from "@mantine/core";
|
import { ActionIcon, Menu } from "@mantine/core";
|
||||||
import { IconDots, IconEdit, IconTrash, IconCircleCheck, IconCircleCheckFilled } from "@tabler/icons-react";
|
import { IconDots, IconEdit, IconTrash } from "@tabler/icons-react";
|
||||||
import { modals } from "@mantine/modals";
|
import { modals } from "@mantine/modals";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useIsCloudEE } from "@/hooks/use-is-cloud-ee";
|
|
||||||
|
|
||||||
type CommentMenuProps = {
|
type CommentMenuProps = {
|
||||||
onEditComment: () => void;
|
onEditComment: () => void;
|
||||||
onDeleteComment: () => void;
|
onDeleteComment: () => void;
|
||||||
onResolveComment?: () => void;
|
|
||||||
canEdit?: boolean;
|
|
||||||
isResolved?: boolean;
|
|
||||||
isParentComment?: boolean;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function CommentMenu({
|
function CommentMenu({ onEditComment, onDeleteComment }: CommentMenuProps) {
|
||||||
onEditComment,
|
|
||||||
onDeleteComment,
|
|
||||||
onResolveComment,
|
|
||||||
canEdit = true,
|
|
||||||
isResolved = false,
|
|
||||||
isParentComment = false
|
|
||||||
}: CommentMenuProps) {
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const isCloudEE = useIsCloudEE();
|
|
||||||
|
|
||||||
//@ts-ignore
|
//@ts-ignore
|
||||||
const openDeleteModal = () =>
|
const openDeleteModal = () =>
|
||||||
@ -43,34 +30,9 @@ function CommentMenu({
|
|||||||
</Menu.Target>
|
</Menu.Target>
|
||||||
|
|
||||||
<Menu.Dropdown>
|
<Menu.Dropdown>
|
||||||
{canEdit && (
|
<Menu.Item onClick={onEditComment} leftSection={<IconEdit size={14} />}>
|
||||||
<Menu.Item onClick={onEditComment} leftSection={<IconEdit size={14} />}>
|
{t("Edit comment")}
|
||||||
{t("Edit comment")}
|
</Menu.Item>
|
||||||
</Menu.Item>
|
|
||||||
)}
|
|
||||||
{isParentComment && (
|
|
||||||
isCloudEE ? (
|
|
||||||
<Menu.Item
|
|
||||||
onClick={onResolveComment}
|
|
||||||
leftSection={
|
|
||||||
isResolved ?
|
|
||||||
<IconCircleCheckFilled size={14} /> :
|
|
||||||
<IconCircleCheck size={14} />
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{isResolved ? t("Re-open comment") : t("Resolve comment")}
|
|
||||||
</Menu.Item>
|
|
||||||
) : (
|
|
||||||
<Tooltip label={t("Available in enterprise edition")} position="left">
|
|
||||||
<Menu.Item
|
|
||||||
disabled
|
|
||||||
leftSection={<IconCircleCheck size={14} />}
|
|
||||||
>
|
|
||||||
{t("Resolve comment")}
|
|
||||||
</Menu.Item>
|
|
||||||
</Tooltip>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
leftSection={<IconTrash size={14} />}
|
leftSection={<IconTrash size={14} />}
|
||||||
onClick={openDeleteModal}
|
onClick={openDeleteModal}
|
||||||
|
|||||||
@ -11,31 +11,22 @@
|
|||||||
border-left: 2px solid var(--mantine-color-gray-6);
|
border-left: 2px solid var(--mantine-color-gray-6);
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
background: var(--mantine-color-gray-light);
|
background: var(--mantine-color-gray-light);
|
||||||
cursor: pointer;
|
|
||||||
overflow-wrap: break-word;
|
|
||||||
word-wrap: break-word;
|
|
||||||
word-break: break-word;
|
|
||||||
-ms-word-break: break-word;
|
|
||||||
max-width: 100%;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.commentEditor {
|
.commentEditor {
|
||||||
|
|
||||||
.focused {
|
.focused {
|
||||||
border-radius: var(--mantine-radius-sm);
|
|
||||||
box-shadow: 0 0 0 2px var(--mantine-color-blue-3);
|
box-shadow: 0 0 0 2px var(--mantine-color-blue-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ProseMirror :global(.ProseMirror){
|
.ProseMirror :global(.ProseMirror){
|
||||||
border-radius: var(--mantine-radius-sm);
|
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
max-height: 20vh;
|
max-height: 20vh;
|
||||||
padding-left: 6px;
|
padding-left: 6px;
|
||||||
padding-right: 6px;
|
padding-right: 6px;
|
||||||
margin-top: 10px;
|
margin-top: 2px;
|
||||||
margin-bottom: 2px;
|
margin-bottom: 2px;
|
||||||
overflow: hidden auto;
|
overflow: hidden auto;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,47 @@
|
|||||||
|
import { ActionIcon } from "@mantine/core";
|
||||||
|
import { IconCircleCheck } from "@tabler/icons-react";
|
||||||
|
import { modals } from "@mantine/modals";
|
||||||
|
import { useResolveCommentMutation } from "@/features/comment/queries/comment-query";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
function ResolveComment({ commentId, pageId, resolvedAt }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const resolveCommentMutation = useResolveCommentMutation();
|
||||||
|
|
||||||
|
const isResolved = resolvedAt != null;
|
||||||
|
const iconColor = isResolved ? "green" : "gray";
|
||||||
|
|
||||||
|
//@ts-ignore
|
||||||
|
const openConfirmModal = () =>
|
||||||
|
modals.openConfirmModal({
|
||||||
|
title: t("Are you sure you want to resolve this comment thread?"),
|
||||||
|
centered: true,
|
||||||
|
labels: { confirm: t("Confirm"), cancel: t("Cancel") },
|
||||||
|
onConfirm: handleResolveToggle,
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleResolveToggle = async () => {
|
||||||
|
try {
|
||||||
|
await resolveCommentMutation.mutateAsync({
|
||||||
|
commentId,
|
||||||
|
resolved: !isResolved,
|
||||||
|
});
|
||||||
|
//TODO: remove comment mark
|
||||||
|
// Remove comment thread from state on resolve
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to toggle resolved state:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ActionIcon
|
||||||
|
onClick={openConfirmModal}
|
||||||
|
variant="default"
|
||||||
|
style={{ border: "none" }}
|
||||||
|
>
|
||||||
|
<IconCircleCheck size={20} stroke={2} color={iconColor} />
|
||||||
|
</ActionIcon>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ResolveComment;
|
||||||
@ -8,11 +8,13 @@ import {
|
|||||||
createComment,
|
createComment,
|
||||||
deleteComment,
|
deleteComment,
|
||||||
getPageComments,
|
getPageComments,
|
||||||
|
resolveComment,
|
||||||
updateComment,
|
updateComment,
|
||||||
} from "@/features/comment/services/comment-service";
|
} from "@/features/comment/services/comment-service";
|
||||||
import {
|
import {
|
||||||
ICommentParams,
|
ICommentParams,
|
||||||
IComment,
|
IComment,
|
||||||
|
IResolveComment,
|
||||||
} from "@/features/comment/types/comment.types";
|
} from "@/features/comment/types/comment.types";
|
||||||
import { notifications } from "@mantine/notifications";
|
import { notifications } from "@mantine/notifications";
|
||||||
import { IPagination } from "@/lib/types.ts";
|
import { IPagination } from "@/lib/types.ts";
|
||||||
@ -106,4 +108,34 @@ export function useDeleteCommentMutation(pageId?: string) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// EE: useResolveCommentMutation has been moved to @/ee/comment/queries/comment-query
|
export function useResolveCommentMutation() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (data: IResolveComment) => resolveComment(data),
|
||||||
|
onSuccess: (data: IComment, variables) => {
|
||||||
|
const currentComments = queryClient.getQueryData(
|
||||||
|
RQ_KEY(data.pageId),
|
||||||
|
) as IComment[];
|
||||||
|
|
||||||
|
/*
|
||||||
|
if (currentComments) {
|
||||||
|
const updatedComments = currentComments.map((comment) =>
|
||||||
|
comment.id === variables.commentId
|
||||||
|
? { ...comment, ...data }
|
||||||
|
: comment,
|
||||||
|
);
|
||||||
|
queryClient.setQueryData(RQ_KEY(data.pageId), updatedComments);
|
||||||
|
}*/
|
||||||
|
|
||||||
|
notifications.show({ message: t("Comment resolved successfully") });
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
notifications.show({
|
||||||
|
message: t("Failed to resolve comment"),
|
||||||
|
color: "red",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@ -16,7 +16,6 @@ export interface IComment {
|
|||||||
editedAt?: Date;
|
editedAt?: Date;
|
||||||
deletedAt?: Date;
|
deletedAt?: Date;
|
||||||
creator: IUser;
|
creator: IUser;
|
||||||
resolvedBy?: IUser;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ICommentData {
|
export interface ICommentData {
|
||||||
@ -29,7 +28,6 @@ export interface ICommentData {
|
|||||||
|
|
||||||
export interface IResolveComment {
|
export interface IResolveComment {
|
||||||
commentId: string;
|
commentId: string;
|
||||||
pageId: string;
|
|
||||||
resolved: boolean;
|
resolved: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -5,6 +5,4 @@ export const pageEditorAtom = atom<Editor | null>(null);
|
|||||||
|
|
||||||
export const titleEditorAtom = atom<Editor | null>(null);
|
export const titleEditorAtom = atom<Editor | null>(null);
|
||||||
|
|
||||||
export const readOnlyEditorAtom = atom<Editor | null>(null);
|
|
||||||
|
|
||||||
export const yjsConnectionStatusAtom = atom<string>("");
|
export const yjsConnectionStatusAtom = atom<string>("");
|
||||||
|
|||||||
@ -116,12 +116,6 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
|||||||
},
|
},
|
||||||
tippyOptions: {
|
tippyOptions: {
|
||||||
moveTransition: "transform 0.15s ease-out",
|
moveTransition: "transform 0.15s ease-out",
|
||||||
onCreate: (instance) => {
|
|
||||||
instance.popper.firstChild?.addEventListener("blur", (event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopImmediatePropagation();
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onHide: () => {
|
onHide: () => {
|
||||||
setIsNodeSelectorOpen(false);
|
setIsNodeSelectorOpen(false);
|
||||||
setIsTextAlignmentOpen(false);
|
setIsTextAlignmentOpen(false);
|
||||||
@ -183,8 +177,8 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
|||||||
<LinkSelector
|
<LinkSelector
|
||||||
editor={props.editor}
|
editor={props.editor}
|
||||||
isOpen={isLinkSelectorOpen}
|
isOpen={isLinkSelectorOpen}
|
||||||
setIsOpen={(value) => {
|
setIsOpen={() => {
|
||||||
setIsLinkSelectorOpen(value);
|
setIsLinkSelectorOpen(!isLinkSelectorOpen);
|
||||||
setIsNodeSelectorOpen(false);
|
setIsNodeSelectorOpen(false);
|
||||||
setIsTextAlignmentOpen(false);
|
setIsTextAlignmentOpen(false);
|
||||||
setIsColorSelectorOpen(false);
|
setIsColorSelectorOpen(false);
|
||||||
|
|||||||
@ -156,11 +156,13 @@ export const ColorSelector: FC<ColorSelectorProps> = ({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (name === "Default") {
|
editor.commands.unsetColor();
|
||||||
editor.commands.unsetColor();
|
name !== "Default" &&
|
||||||
} else {
|
editor
|
||||||
editor.chain().focus().setColor(color || "").run();
|
.chain()
|
||||||
}
|
.focus()
|
||||||
|
.setColor(color || "")
|
||||||
|
.run();
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
}}
|
}}
|
||||||
style={{ border: "none" }}
|
style={{ border: "none" }}
|
||||||
|
|||||||
@ -1,96 +0,0 @@
|
|||||||
.wrapper {
|
|
||||||
position: relative;
|
|
||||||
width: 100%;
|
|
||||||
overflow: hidden;
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.resizing {
|
|
||||||
user-select: none;
|
|
||||||
cursor: ns-resize;
|
|
||||||
}
|
|
||||||
|
|
||||||
.overlay {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
z-index: 10;
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.resizeHandleBottom {
|
|
||||||
position: absolute;
|
|
||||||
bottom: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
height: 24px;
|
|
||||||
cursor: ns-resize;
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity 0.2s ease;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
z-index: 2;
|
|
||||||
touch-action: none;
|
|
||||||
-webkit-user-select: none;
|
|
||||||
user-select: none;
|
|
||||||
|
|
||||||
@mixin light {
|
|
||||||
background: linear-gradient(to bottom, transparent, rgba(0, 0, 0, 0.05));
|
|
||||||
}
|
|
||||||
|
|
||||||
@mixin dark {
|
|
||||||
background: linear-gradient(
|
|
||||||
to bottom,
|
|
||||||
transparent,
|
|
||||||
rgba(255, 255, 255, 0.05)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
@mixin light {
|
|
||||||
background: linear-gradient(to bottom, transparent, rgba(0, 0, 0, 0.1));
|
|
||||||
}
|
|
||||||
|
|
||||||
@mixin dark {
|
|
||||||
background: linear-gradient(
|
|
||||||
to bottom,
|
|
||||||
transparent,
|
|
||||||
rgba(255, 255, 255, 0.1)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.wrapper:hover .resizeHandleBottom,
|
|
||||||
.resizing .resizeHandleBottom {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.resizeBar {
|
|
||||||
width: 50px;
|
|
||||||
height: 4px;
|
|
||||||
border-radius: 2px;
|
|
||||||
transition: background-color 0.2s ease;
|
|
||||||
|
|
||||||
@mixin light {
|
|
||||||
background-color: var(--mantine-color-gray-5);
|
|
||||||
}
|
|
||||||
|
|
||||||
@mixin dark {
|
|
||||||
background-color: var(--mantine-color-gray-6);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.resizeHandleBottom:hover .resizeBar,
|
|
||||||
.resizing .resizeBar {
|
|
||||||
@mixin light {
|
|
||||||
background-color: var(--mantine-color-gray-7);
|
|
||||||
}
|
|
||||||
|
|
||||||
@mixin dark {
|
|
||||||
background-color: var(--mantine-color-gray-4);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,112 +0,0 @@
|
|||||||
import React, { ReactNode, useCallback, useEffect, useRef, useState } from "react";
|
|
||||||
import clsx from "clsx";
|
|
||||||
import classes from "./resizable-wrapper.module.css";
|
|
||||||
|
|
||||||
interface ResizableWrapperProps {
|
|
||||||
children: ReactNode;
|
|
||||||
initialHeight?: number;
|
|
||||||
minHeight?: number;
|
|
||||||
maxHeight?: number;
|
|
||||||
onResize?: (height: number) => void;
|
|
||||||
isEditable?: boolean;
|
|
||||||
className?: string;
|
|
||||||
showHandles?: "always" | "hover";
|
|
||||||
direction?: "vertical" | "horizontal" | "both";
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ResizableWrapper: React.FC<ResizableWrapperProps> = ({
|
|
||||||
children,
|
|
||||||
initialHeight = 480,
|
|
||||||
minHeight = 200,
|
|
||||||
maxHeight = 1200,
|
|
||||||
onResize,
|
|
||||||
isEditable = true,
|
|
||||||
className,
|
|
||||||
showHandles = "hover",
|
|
||||||
direction = "vertical",
|
|
||||||
}) => {
|
|
||||||
const [resizeParams, setResizeParams] = useState<{
|
|
||||||
initialSize: number;
|
|
||||||
initialClientY: number;
|
|
||||||
initialClientX: number;
|
|
||||||
} | null>(null);
|
|
||||||
const [currentHeight, setCurrentHeight] = useState(initialHeight);
|
|
||||||
const [isHovered, setIsHovered] = useState(false);
|
|
||||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!resizeParams) return;
|
|
||||||
|
|
||||||
const handleMouseMove = (e: MouseEvent) => {
|
|
||||||
if (!wrapperRef.current) return;
|
|
||||||
|
|
||||||
if (direction === "vertical" || direction === "both") {
|
|
||||||
const deltaY = e.clientY - resizeParams.initialClientY;
|
|
||||||
const newHeight = Math.min(
|
|
||||||
Math.max(resizeParams.initialSize + deltaY, minHeight),
|
|
||||||
maxHeight
|
|
||||||
);
|
|
||||||
setCurrentHeight(newHeight);
|
|
||||||
wrapperRef.current.style.height = `${newHeight}px`;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleMouseUp = () => {
|
|
||||||
setResizeParams(null);
|
|
||||||
if (onResize && currentHeight !== initialHeight) {
|
|
||||||
onResize(currentHeight);
|
|
||||||
}
|
|
||||||
document.body.style.cursor = "";
|
|
||||||
document.body.style.userSelect = "";
|
|
||||||
};
|
|
||||||
|
|
||||||
document.addEventListener("mousemove", handleMouseMove);
|
|
||||||
document.addEventListener("mouseup", handleMouseUp);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener("mousemove", handleMouseMove);
|
|
||||||
document.removeEventListener("mouseup", handleMouseUp);
|
|
||||||
};
|
|
||||||
}, [resizeParams, currentHeight, initialHeight, onResize, minHeight, maxHeight, direction]);
|
|
||||||
|
|
||||||
const handleResizeStart = useCallback((e: React.MouseEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
|
|
||||||
setResizeParams({
|
|
||||||
initialSize: currentHeight,
|
|
||||||
initialClientY: e.clientY,
|
|
||||||
initialClientX: e.clientX,
|
|
||||||
});
|
|
||||||
|
|
||||||
document.body.style.cursor = "ns-resize";
|
|
||||||
document.body.style.userSelect = "none";
|
|
||||||
}, [currentHeight]);
|
|
||||||
|
|
||||||
const shouldShowHandles =
|
|
||||||
isEditable &&
|
|
||||||
(showHandles === "always" || (showHandles === "hover" && (isHovered || resizeParams)));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={wrapperRef}
|
|
||||||
className={clsx(classes.wrapper, className, {
|
|
||||||
[classes.resizing]: !!resizeParams,
|
|
||||||
})}
|
|
||||||
style={{ height: currentHeight }}
|
|
||||||
onMouseEnter={() => setIsHovered(true)}
|
|
||||||
onMouseLeave={() => setIsHovered(false)}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
{!!resizeParams && <div className={classes.overlay} />}
|
|
||||||
{shouldShowHandles && direction === "vertical" && (
|
|
||||||
<div
|
|
||||||
className={classes.resizeHandleBottom}
|
|
||||||
onMouseDown={handleResizeStart}
|
|
||||||
>
|
|
||||||
<div className={classes.resizeBar} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -139,7 +139,7 @@ export default function DrawioView(props: NodeViewProps) {
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{selected && editor.isEditable && (
|
{selected && (
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
onClick={handleOpen}
|
onClick={handleOpen}
|
||||||
variant="default"
|
variant="default"
|
||||||
|
|||||||
@ -1,16 +0,0 @@
|
|||||||
.embedWrapper {
|
|
||||||
@mixin light {
|
|
||||||
background-color: var(--mantine-color-gray-0);
|
|
||||||
}
|
|
||||||
|
|
||||||
@mixin dark {
|
|
||||||
background-color: var(--mantine-color-dark-7);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.embedIframe {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
border: none;
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
|
||||||
@ -1,8 +1,9 @@
|
|||||||
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
||||||
import React, { useMemo, useCallback } from "react";
|
import { useMemo } from "react";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import {
|
import {
|
||||||
ActionIcon,
|
ActionIcon,
|
||||||
|
AspectRatio,
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
FocusTrap,
|
FocusTrap,
|
||||||
@ -13,18 +14,14 @@ import {
|
|||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { IconEdit } from "@tabler/icons-react";
|
import { IconEdit } from "@tabler/icons-react";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { useForm } from "@mantine/form";
|
import { useForm, zodResolver } from "@mantine/form";
|
||||||
import { zodResolver } from "mantine-form-zod-resolver";
|
|
||||||
import { notifications } from "@mantine/notifications";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import i18n from "i18next";
|
|
||||||
import {
|
import {
|
||||||
getEmbedProviderById,
|
getEmbedProviderById,
|
||||||
getEmbedUrlAndProvider,
|
getEmbedUrlAndProvider,
|
||||||
sanitizeUrl,
|
} from "@/features/editor/components/embed/providers.ts";
|
||||||
} from "@docmost/editor-ext";
|
import { notifications } from "@mantine/notifications";
|
||||||
import { ResizableWrapper } from "../common/resizable-wrapper";
|
import { useTranslation } from "react-i18next";
|
||||||
import classes from "./embed-view.module.css";
|
import i18n from "i18next";
|
||||||
|
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
url: z
|
url: z
|
||||||
@ -35,8 +32,8 @@ const schema = z.object({
|
|||||||
|
|
||||||
export default function EmbedView(props: NodeViewProps) {
|
export default function EmbedView(props: NodeViewProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { node, selected, updateAttributes, editor } = props;
|
const { node, selected, updateAttributes } = props;
|
||||||
const { src, provider, height: nodeHeight } = node.attrs;
|
const { src, provider } = node.attrs;
|
||||||
|
|
||||||
const embedUrl = useMemo(() => {
|
const embedUrl = useMemo(() => {
|
||||||
if (src) {
|
if (src) {
|
||||||
@ -52,26 +49,11 @@ export default function EmbedView(props: NodeViewProps) {
|
|||||||
validate: zodResolver(schema),
|
validate: zodResolver(schema),
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleResize = useCallback(
|
|
||||||
(newHeight: number) => {
|
|
||||||
updateAttributes({ height: newHeight });
|
|
||||||
},
|
|
||||||
[updateAttributes],
|
|
||||||
);
|
|
||||||
|
|
||||||
async function onSubmit(data: { url: string }) {
|
async function onSubmit(data: { url: string }) {
|
||||||
if (!editor.isEditable) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (provider) {
|
if (provider) {
|
||||||
const embedProvider = getEmbedProviderById(provider);
|
const embedProvider = getEmbedProviderById(provider);
|
||||||
if (embedProvider.id === "iframe") {
|
|
||||||
updateAttributes({ src: sanitizeUrl(data.url) });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (embedProvider.regex.test(data.url)) {
|
if (embedProvider.regex.test(data.url)) {
|
||||||
updateAttributes({ src: sanitizeUrl(data.url) });
|
updateAttributes({ src: data.url });
|
||||||
} else {
|
} else {
|
||||||
notifications.show({
|
notifications.show({
|
||||||
message: t("Invalid {{provider}} embed link", {
|
message: t("Invalid {{provider}} embed link", {
|
||||||
@ -87,33 +69,19 @@ export default function EmbedView(props: NodeViewProps) {
|
|||||||
return (
|
return (
|
||||||
<NodeViewWrapper>
|
<NodeViewWrapper>
|
||||||
{embedUrl ? (
|
{embedUrl ? (
|
||||||
<ResizableWrapper
|
<>
|
||||||
initialHeight={nodeHeight || 480}
|
<AspectRatio ratio={16 / 9}>
|
||||||
minHeight={200}
|
<iframe
|
||||||
maxHeight={1200}
|
src={embedUrl}
|
||||||
onResize={handleResize}
|
allow="encrypted-media"
|
||||||
isEditable={editor.isEditable}
|
sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
|
||||||
className={clsx(classes.embedWrapper, {
|
allowFullScreen
|
||||||
"ProseMirror-selectednode": selected,
|
frameBorder="0"
|
||||||
})}
|
></iframe>
|
||||||
>
|
</AspectRatio>
|
||||||
<iframe
|
</>
|
||||||
className={classes.embedIframe}
|
|
||||||
src={sanitizeUrl(embedUrl)}
|
|
||||||
allow="encrypted-media"
|
|
||||||
sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
|
|
||||||
allowFullScreen
|
|
||||||
frameBorder="0"
|
|
||||||
/>
|
|
||||||
</ResizableWrapper>
|
|
||||||
) : (
|
) : (
|
||||||
<Popover
|
<Popover width={300} position="bottom" withArrow shadow="md">
|
||||||
width={300}
|
|
||||||
position="bottom"
|
|
||||||
withArrow
|
|
||||||
shadow="md"
|
|
||||||
disabled={!editor.isEditable}
|
|
||||||
>
|
|
||||||
<Popover.Target>
|
<Popover.Target>
|
||||||
<Card
|
<Card
|
||||||
radius="md"
|
radius="md"
|
||||||
@ -133,7 +101,7 @@ export default function EmbedView(props: NodeViewProps) {
|
|||||||
|
|
||||||
<Text component="span" size="lg" c="dimmed">
|
<Text component="span" size="lg" c="dimmed">
|
||||||
{t("Embed {{provider}}", {
|
{t("Embed {{provider}}", {
|
||||||
provider: getEmbedProviderById(provider)?.name,
|
provider: getEmbedProviderById(provider).name,
|
||||||
})}
|
})}
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -7,117 +7,102 @@ export interface IEmbedProvider {
|
|||||||
|
|
||||||
export const embedProviders: IEmbedProvider[] = [
|
export const embedProviders: IEmbedProvider[] = [
|
||||||
{
|
{
|
||||||
id: "loom",
|
id: 'loom',
|
||||||
name: "Loom",
|
name: 'Loom',
|
||||||
regex: /^https?:\/\/(?:www\.)?loom\.com\/(?:share|embed)\/([\da-zA-Z]+)\/?/,
|
regex: /^https?:\/\/(?:www\.)?loom\.com\/(?:share|embed)\/([\da-zA-Z]+)\/?/,
|
||||||
getEmbedUrl: (match, url) => {
|
getEmbedUrl: (match, url) => {
|
||||||
if (url.includes("/embed/")) {
|
if(url.includes("/embed/")){
|
||||||
return url;
|
return url;
|
||||||
}
|
}
|
||||||
return `https://loom.com/embed/${match[1]}`;
|
return `https://loom.com/embed/${match[1]}`;
|
||||||
},
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "airtable",
|
id: 'airtable',
|
||||||
name: "Airtable",
|
name: 'Airtable',
|
||||||
regex: /^https:\/\/(www.)?airtable.com\/([a-zA-Z0-9]{2,})\/.*/,
|
regex: /^https:\/\/(www.)?airtable.com\/([a-zA-Z0-9]{2,})\/.*/,
|
||||||
getEmbedUrl: (match, url: string) => {
|
getEmbedUrl: (match, url: string) => {
|
||||||
const path = url.split("airtable.com/");
|
const path = url.split('airtable.com/');
|
||||||
if (url.includes("/embed/")) {
|
if(url.includes("/embed/")){
|
||||||
return url;
|
return url;
|
||||||
}
|
}
|
||||||
return `https://airtable.com/embed/${path[1]}`;
|
return `https://airtable.com/embed/${path[1]}`;
|
||||||
},
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "figma",
|
id: 'figma',
|
||||||
name: "Figma",
|
name: 'Figma',
|
||||||
regex:
|
regex: /^https:\/\/[\w\.-]+\.?figma.com\/(file|proto|board|design|slides|deck)\/([0-9a-zA-Z]{22,128})/,
|
||||||
/^https:\/\/[\w\.-]+\.?figma.com\/(file|proto|board|design|slides|deck)\/([0-9a-zA-Z]{22,128})/,
|
|
||||||
getEmbedUrl: (match, url: string) => {
|
getEmbedUrl: (match, url: string) => {
|
||||||
return `https://www.figma.com/embed?url=${url}&embed_host=docmost`;
|
return `https://www.figma.com/embed?url=${url}&embed_host=docmost`;
|
||||||
},
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "typeform",
|
'id': 'typeform',
|
||||||
name: "Typeform",
|
name: 'Typeform',
|
||||||
regex: /^(https?:)?(\/\/)?[\w\.]+\.typeform\.com\/to\/.+/,
|
regex: /^(https?:)?(\/\/)?[\w\.]+\.typeform\.com\/to\/.+/,
|
||||||
getEmbedUrl: (match, url: string) => {
|
getEmbedUrl: (match, url: string) => {
|
||||||
return url;
|
return url;
|
||||||
},
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "miro",
|
id: 'miro',
|
||||||
name: "Miro",
|
name: 'Miro',
|
||||||
regex: /^https:\/\/(www\.)?miro\.com\/app\/board\/([\w-]+=)/,
|
regex: /^https:\/\/(www\.)?miro\.com\/app\/board\/([\w-]+=)/,
|
||||||
getEmbedUrl: (match, url) => {
|
getEmbedUrl: (match, url) => {
|
||||||
if (url.includes("/live-embed/")) {
|
if(url.includes("/live-embed/")){
|
||||||
return url;
|
return url;
|
||||||
}
|
}
|
||||||
return `https://miro.com/app/live-embed/${match[2]}?embedMode=view_only_without_ui&autoplay=true&embedSource=docmost`;
|
return `https://miro.com/app/live-embed/${match[2]}?embedMode=view_only_without_ui&autoplay=true&embedSource=docmost`;
|
||||||
},
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "youtube",
|
id: 'youtube',
|
||||||
name: "YouTube",
|
name: 'YouTube',
|
||||||
regex:
|
regex: /^((?:https?:)?\/\/)?((?:www|m|music)\.)?((?:youtube\.com|youtu.be))(\/(?:[\w\-]+\?v=|embed\/|v\/)?)([\w\-]+)(\S+)?$/,
|
||||||
/^((?:https?:)?\/\/)?((?:www|m|music)\.)?((?:youtube\.com|youtu.be))(\/(?:[\w\-]+\?v=|embed\/|v\/)?)([\w\-]+)(\S+)?$/,
|
|
||||||
getEmbedUrl: (match, url) => {
|
getEmbedUrl: (match, url) => {
|
||||||
if (url.includes("/embed/")) {
|
if (url.includes("/embed/")){
|
||||||
return url;
|
return url;
|
||||||
}
|
}
|
||||||
return `https://www.youtube-nocookie.com/embed/${match[5]}`;
|
return `https://www.youtube-nocookie.com/embed/${match[5]}`;
|
||||||
},
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "vimeo",
|
id: 'vimeo',
|
||||||
name: "Vimeo",
|
name: 'Vimeo',
|
||||||
regex:
|
regex: /^(https:)?\/\/(?:www\.|player\.)?vimeo.com\/(?:channels\/(?:\w+\/)?|groups\/([^/]*)\/videos\/|album\/(\d+)\/video\/|video\/|)(\d+)/,
|
||||||
/^(https:)?\/\/(?:www\.|player\.)?vimeo.com\/(?:channels\/(?:\w+\/)?|groups\/([^/]*)\/videos\/|album\/(\d+)\/video\/|video\/|)(\d+)/,
|
|
||||||
getEmbedUrl: (match) => {
|
getEmbedUrl: (match) => {
|
||||||
return `https://player.vimeo.com/video/${match[4]}`;
|
return `https://player.vimeo.com/video/${match[4]}`;
|
||||||
},
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "framer",
|
id: 'framer',
|
||||||
name: "Framer",
|
name: 'Framer',
|
||||||
regex: /^https:\/\/(www\.)?framer\.com\/embed\/([\w-]+)/,
|
regex: /^https:\/\/(www\.)?framer\.com\/embed\/([\w-]+)/,
|
||||||
getEmbedUrl: (match, url: string) => {
|
getEmbedUrl: (match, url: string) => {
|
||||||
return url;
|
return url;
|
||||||
},
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "gdrive",
|
id: 'gdrive',
|
||||||
name: "Google Drive",
|
name: 'Google Drive',
|
||||||
regex:
|
regex: /^((?:https?:)?\/\/)?((?:www|m)\.)?(drive\.google\.com)\/file\/d\/([a-zA-Z0-9_-]+)\/.*$/,
|
||||||
/^((?:https?:)?\/\/)?((?:www|m)\.)?(drive\.google\.com)\/file\/d\/([a-zA-Z0-9_-]+)\/.*$/,
|
|
||||||
getEmbedUrl: (match) => {
|
getEmbedUrl: (match) => {
|
||||||
return `https://drive.google.com/file/d/${match[4]}/preview`;
|
return `https://drive.google.com/file/d/${match[4]}/preview`;
|
||||||
},
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "gsheets",
|
id: 'gsheets',
|
||||||
name: "Google Sheets",
|
name: 'Google Sheets',
|
||||||
regex:
|
regex: /^((?:https?:)?\/\/)?((?:www|m)\.)?(docs\.google\.com)\/spreadsheets\/d\/e\/([a-zA-Z0-9_-]+)\/.*$/,
|
||||||
/^((?:https?:)?\/\/)?((?:www|m)\.)?(docs\.google\.com)\/spreadsheets\/d\/e\/([a-zA-Z0-9_-]+)\/.*$/,
|
|
||||||
getEmbedUrl: (match, url: string) => {
|
getEmbedUrl: (match, url: string) => {
|
||||||
return url;
|
return url
|
||||||
},
|
}
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "iframe",
|
|
||||||
name: "Iframe",
|
|
||||||
regex: /any-iframe/,
|
|
||||||
getEmbedUrl: (match, url) => {
|
|
||||||
return url;
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export function getEmbedProviderById(id: string) {
|
export function getEmbedProviderById(id: string) {
|
||||||
return embedProviders.find(
|
return embedProviders.find(provider => provider.id.toLowerCase() === id.toLowerCase());
|
||||||
(provider) => provider.id.toLowerCase() === id.toLowerCase(),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IEmbedResult {
|
export interface IEmbedResult {
|
||||||
@ -131,12 +116,14 @@ export function getEmbedUrlAndProvider(url: string): IEmbedResult {
|
|||||||
if (match) {
|
if (match) {
|
||||||
return {
|
return {
|
||||||
embedUrl: provider.getEmbedUrl(match, url),
|
embedUrl: provider.getEmbedUrl(match, url),
|
||||||
provider: provider.name.toLowerCase(),
|
provider: provider.name.toLowerCase()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
embedUrl: url,
|
embedUrl: url,
|
||||||
provider: "iframe",
|
provider: 'iframe',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -33,7 +33,7 @@ const renderEmojiItems = () => {
|
|||||||
showOnCreate: true,
|
showOnCreate: true,
|
||||||
interactive: true,
|
interactive: true,
|
||||||
trigger: "manual",
|
trigger: "manual",
|
||||||
placement: "bottom",
|
placement: "bottom-start",
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onStart: (props: {
|
onStart: (props: {
|
||||||
|
|||||||
@ -1,42 +0,0 @@
|
|||||||
type LibraryItems = any;
|
|
||||||
|
|
||||||
type LibraryPersistedData = {
|
|
||||||
libraryItems: LibraryItems;
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface LibraryPersistenceAdapter {
|
|
||||||
load(metadata: { source: "load" | "save" }):
|
|
||||||
| Promise<{ libraryItems: LibraryItems } | null>
|
|
||||||
| {
|
|
||||||
libraryItems: LibraryItems;
|
|
||||||
}
|
|
||||||
| null;
|
|
||||||
|
|
||||||
save(libraryData: LibraryPersistedData): Promise<void> | void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const LOCAL_STORAGE_KEY = "excalidrawLibrary";
|
|
||||||
|
|
||||||
export const localStorageLibraryAdapter: LibraryPersistenceAdapter = {
|
|
||||||
async load() {
|
|
||||||
try {
|
|
||||||
const data = localStorage.getItem(LOCAL_STORAGE_KEY);
|
|
||||||
if (data) {
|
|
||||||
return JSON.parse(data);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error("Error downloading Excalidraw library from localStorage", e);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
async save(libraryData) {
|
|
||||||
try {
|
|
||||||
localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(libraryData));
|
|
||||||
} catch (e) {
|
|
||||||
console.error(
|
|
||||||
"Error while saving library from Excalidraw to localStorage",
|
|
||||||
e,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@ -13,8 +13,7 @@ import { uploadFile } from "@/features/page/services/page-service.ts";
|
|||||||
import { svgStringToFile } from "@/lib";
|
import { svgStringToFile } from "@/lib";
|
||||||
import { useDisclosure } from "@mantine/hooks";
|
import { useDisclosure } from "@mantine/hooks";
|
||||||
import { getFileUrl } from "@/lib/config.ts";
|
import { getFileUrl } from "@/lib/config.ts";
|
||||||
import "@excalidraw/excalidraw/index.css";
|
import { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/types/types";
|
||||||
import type { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/types";
|
|
||||||
import { IAttachment } from "@/lib/types";
|
import { IAttachment } from "@/lib/types";
|
||||||
import ReactClearModal from "react-clear-modal";
|
import ReactClearModal from "react-clear-modal";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
@ -22,8 +21,6 @@ import { IconEdit } from "@tabler/icons-react";
|
|||||||
import { lazy } from "react";
|
import { lazy } from "react";
|
||||||
import { Suspense } from "react";
|
import { Suspense } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useHandleLibrary } from "@excalidraw/excalidraw";
|
|
||||||
import { localStorageLibraryAdapter } from "@/features/editor/components/excalidraw/excalidraw-utils.ts";
|
|
||||||
|
|
||||||
const Excalidraw = lazy(() =>
|
const Excalidraw = lazy(() =>
|
||||||
import("@excalidraw/excalidraw").then((module) => ({
|
import("@excalidraw/excalidraw").then((module) => ({
|
||||||
@ -38,10 +35,6 @@ export default function ExcalidrawView(props: NodeViewProps) {
|
|||||||
|
|
||||||
const [excalidrawAPI, setExcalidrawAPI] =
|
const [excalidrawAPI, setExcalidrawAPI] =
|
||||||
useState<ExcalidrawImperativeAPI>(null);
|
useState<ExcalidrawImperativeAPI>(null);
|
||||||
useHandleLibrary({
|
|
||||||
excalidrawAPI,
|
|
||||||
adapter: localStorageLibraryAdapter,
|
|
||||||
});
|
|
||||||
const [excalidrawData, setExcalidrawData] = useState<any>(null);
|
const [excalidrawData, setExcalidrawData] = useState<any>(null);
|
||||||
const [opened, { open, close }] = useDisclosure(false);
|
const [opened, { open, close }] = useDisclosure(false);
|
||||||
const computedColorScheme = useComputedColorScheme();
|
const computedColorScheme = useComputedColorScheme();
|
||||||
@ -177,7 +170,7 @@ export default function ExcalidrawView(props: NodeViewProps) {
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{selected && editor.isEditable && (
|
{selected && (
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
onClick={handleOpen}
|
onClick={handleOpen}
|
||||||
variant="default"
|
variant="default"
|
||||||
|
|||||||
@ -3,7 +3,6 @@ import React, {
|
|||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
useImperativeHandle,
|
useImperativeHandle,
|
||||||
useMemo,
|
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
@ -19,7 +18,7 @@ import {
|
|||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import classes from "./mention.module.css";
|
import classes from "./mention.module.css";
|
||||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||||
import { IconFileDescription, IconPlus } from "@tabler/icons-react";
|
import { IconFileDescription } from "@tabler/icons-react";
|
||||||
import { useSpaceQuery } from "@/features/space/queries/space-query.ts";
|
import { useSpaceQuery } from "@/features/space/queries/space-query.ts";
|
||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
import { v7 as uuid7 } from "uuid";
|
import { v7 as uuid7 } from "uuid";
|
||||||
@ -29,28 +28,14 @@ import {
|
|||||||
MentionListProps,
|
MentionListProps,
|
||||||
MentionSuggestionItem,
|
MentionSuggestionItem,
|
||||||
} from "@/features/editor/components/mention/mention.type.ts";
|
} from "@/features/editor/components/mention/mention.type.ts";
|
||||||
import { IPage } from "@/features/page/types/page.types";
|
|
||||||
import { useCreatePageMutation, usePageQuery } from "@/features/page/queries/page-query";
|
|
||||||
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom";
|
|
||||||
import { SimpleTree } from "react-arborist";
|
|
||||||
import { SpaceTreeNode } from "@/features/page/tree/types";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { useQueryEmit } from "@/features/websocket/use-query-emit";
|
|
||||||
import { extractPageSlugId } from "@/lib";
|
|
||||||
|
|
||||||
const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
||||||
const [selectedIndex, setSelectedIndex] = useState(1);
|
const [selectedIndex, setSelectedIndex] = useState(1);
|
||||||
const viewportRef = useRef<HTMLDivElement>(null);
|
const viewportRef = useRef<HTMLDivElement>(null);
|
||||||
const { pageSlug, spaceSlug } = useParams();
|
const { spaceSlug } = useParams();
|
||||||
const { data: page } = usePageQuery({ pageId: extractPageSlugId(pageSlug) });
|
|
||||||
const { data: space } = useSpaceQuery(spaceSlug);
|
const { data: space } = useSpaceQuery(spaceSlug);
|
||||||
const [currentUser] = useAtom(currentUserAtom);
|
const [currentUser] = useAtom(currentUserAtom);
|
||||||
const [renderItems, setRenderItems] = useState<MentionSuggestionItem[]>([]);
|
const [renderItems, setRenderItems] = useState<MentionSuggestionItem[]>([]);
|
||||||
const { t } = useTranslation();
|
|
||||||
const [data, setData] = useAtom(treeDataAtom);
|
|
||||||
const tree = useMemo(() => new SimpleTree<SpaceTreeNode>(data), [data]);
|
|
||||||
const createPageMutation = useCreatePageMutation();
|
|
||||||
const emit = useQueryEmit();
|
|
||||||
|
|
||||||
const { data: suggestion, isLoading } = useSearchSuggestionsQuery({
|
const { data: suggestion, isLoading } = useSearchSuggestionsQuery({
|
||||||
query: props.query,
|
query: props.query,
|
||||||
@ -60,23 +45,12 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
|||||||
limit: 10,
|
limit: 10,
|
||||||
});
|
});
|
||||||
|
|
||||||
const createPageItem = (label: string) : MentionSuggestionItem => {
|
|
||||||
return {
|
|
||||||
id: null,
|
|
||||||
label: label,
|
|
||||||
entityType: "page",
|
|
||||||
entityId: null,
|
|
||||||
slugId: null,
|
|
||||||
icon: null,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (suggestion && !isLoading) {
|
if (suggestion && !isLoading) {
|
||||||
let items: MentionSuggestionItem[] = [];
|
let items: MentionSuggestionItem[] = [];
|
||||||
|
|
||||||
if (suggestion?.users?.length > 0) {
|
if (suggestion?.users?.length > 0) {
|
||||||
items.push({ entityType: "header", label: t("Users") });
|
items.push({ entityType: "header", label: "Users" });
|
||||||
|
|
||||||
items = items.concat(
|
items = items.concat(
|
||||||
suggestion.users.map((user) => ({
|
suggestion.users.map((user) => ({
|
||||||
@ -90,7 +64,7 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (suggestion?.pages?.length > 0) {
|
if (suggestion?.pages?.length > 0) {
|
||||||
items.push({ entityType: "header", label: t("Pages") });
|
items.push({ entityType: "header", label: "Pages" });
|
||||||
items = items.concat(
|
items = items.concat(
|
||||||
suggestion.pages.map((page) => ({
|
suggestion.pages.map((page) => ({
|
||||||
id: uuid7(),
|
id: uuid7(),
|
||||||
@ -102,7 +76,6 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
|||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
items.push(createPageItem(props.query));
|
|
||||||
|
|
||||||
setRenderItems(items);
|
setRenderItems(items);
|
||||||
// update editor storage
|
// update editor storage
|
||||||
@ -123,7 +96,7 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
|||||||
creatorId: currentUser?.user.id,
|
creatorId: currentUser?.user.id,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (item.entityType === "page" && item.id!==null) {
|
if (item.entityType === "page") {
|
||||||
props.command({
|
props.command({
|
||||||
id: item.id,
|
id: item.id,
|
||||||
label: item.label || "Untitled",
|
label: item.label || "Untitled",
|
||||||
@ -133,9 +106,6 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
|||||||
creatorId: currentUser?.user.id,
|
creatorId: currentUser?.user.id,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (item.entityType === "page" && item.id===null) {
|
|
||||||
createPage(item.label);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[renderItems],
|
[renderItems],
|
||||||
@ -197,58 +167,6 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const createPage = async (title: string) => {
|
|
||||||
const payload: { spaceId: string; parentPageId?: string; title: string } = {
|
|
||||||
spaceId: space.id,
|
|
||||||
parentPageId: page.id || null,
|
|
||||||
title: title
|
|
||||||
};
|
|
||||||
|
|
||||||
let createdPage: IPage;
|
|
||||||
try {
|
|
||||||
createdPage = await createPageMutation.mutateAsync(payload);
|
|
||||||
const parentId = page.id || null;
|
|
||||||
const data = {
|
|
||||||
id: createdPage.id,
|
|
||||||
slugId: createdPage.slugId,
|
|
||||||
name: createdPage.title,
|
|
||||||
position: createdPage.position,
|
|
||||||
spaceId: createdPage.spaceId,
|
|
||||||
parentPageId: createdPage.parentPageId,
|
|
||||||
children: [],
|
|
||||||
} as any;
|
|
||||||
|
|
||||||
const lastIndex = tree.data.length;
|
|
||||||
|
|
||||||
tree.create({ parentId, index: lastIndex, data });
|
|
||||||
setData(tree.data);
|
|
||||||
|
|
||||||
props.command({
|
|
||||||
id: uuid7(),
|
|
||||||
label: createdPage.title || "Untitled",
|
|
||||||
entityType: "page",
|
|
||||||
entityId: createdPage.id,
|
|
||||||
slugId: createdPage.slugId,
|
|
||||||
creatorId: currentUser?.user.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
emit({
|
|
||||||
operation: "addTreeNode",
|
|
||||||
spaceId: space.id,
|
|
||||||
payload: {
|
|
||||||
parentId,
|
|
||||||
index: lastIndex,
|
|
||||||
data,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}, 50);
|
|
||||||
|
|
||||||
} catch (err) {
|
|
||||||
throw new Error("Failed to create page");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// if no results and enter what to do?
|
// if no results and enter what to do?
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -260,7 +178,7 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
|||||||
if (renderItems.length === 0) {
|
if (renderItems.length === 0) {
|
||||||
return (
|
return (
|
||||||
<Paper shadow="md" p="xs" withBorder>
|
<Paper shadow="md" p="xs" withBorder>
|
||||||
{ t("No results") }
|
No results
|
||||||
</Paper>
|
</Paper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -330,14 +248,14 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
|||||||
color="gray"
|
color="gray"
|
||||||
size={18}
|
size={18}
|
||||||
>
|
>
|
||||||
{ (item.id) ? <IconFileDescription size={18} /> : <IconPlus size={18} /> }
|
<IconFileDescription size={18} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
)}
|
)}
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
|
|
||||||
<div style={{ flex: 1 }}>
|
<div style={{ flex: 1 }}>
|
||||||
<Text size="sm" fw={500}>
|
<Text size="sm" fw={500}>
|
||||||
{ (item.id) ? item.label : t("Create page") + ': ' + item.label }
|
{item.label}
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
</Group>
|
</Group>
|
||||||
|
|||||||
@ -1,34 +1,21 @@
|
|||||||
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
||||||
import { ActionIcon, Anchor, Text } from "@mantine/core";
|
import { ActionIcon, Anchor, Text } from "@mantine/core";
|
||||||
import { IconFileDescription } from "@tabler/icons-react";
|
import { IconFileDescription } from "@tabler/icons-react";
|
||||||
import { Link, useLocation, useParams } from "react-router-dom";
|
import { Link, useParams } from "react-router-dom";
|
||||||
import { usePageQuery } from "@/features/page/queries/page-query.ts";
|
import { usePageQuery } from "@/features/page/queries/page-query.ts";
|
||||||
import {
|
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
||||||
buildPageUrl,
|
|
||||||
buildSharedPageUrl,
|
|
||||||
} from "@/features/page/page.utils.ts";
|
|
||||||
import classes from "./mention.module.css";
|
import classes from "./mention.module.css";
|
||||||
|
|
||||||
export default function MentionView(props: NodeViewProps) {
|
export default function MentionView(props: NodeViewProps) {
|
||||||
const { node } = props;
|
const { node } = props;
|
||||||
const { label, entityType, entityId, slugId } = node.attrs;
|
const { label, entityType, entityId, slugId } = node.attrs;
|
||||||
const { spaceSlug } = useParams();
|
const { spaceSlug } = useParams();
|
||||||
const { shareId } = useParams();
|
|
||||||
const {
|
const {
|
||||||
data: page,
|
data: page,
|
||||||
isLoading,
|
isLoading,
|
||||||
isError,
|
isError,
|
||||||
} = usePageQuery({ pageId: entityType === "page" ? slugId : null });
|
} = usePageQuery({ pageId: entityType === "page" ? slugId : null });
|
||||||
|
|
||||||
const location = useLocation();
|
|
||||||
const isShareRoute = location.pathname.startsWith("/share");
|
|
||||||
|
|
||||||
const shareSlugUrl = buildSharedPageUrl({
|
|
||||||
shareId,
|
|
||||||
pageSlugId: slugId,
|
|
||||||
pageTitle: label,
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NodeViewWrapper style={{ display: "inline" }}>
|
<NodeViewWrapper style={{ display: "inline" }}>
|
||||||
{entityType === "user" && (
|
{entityType === "user" && (
|
||||||
@ -41,9 +28,7 @@ export default function MentionView(props: NodeViewProps) {
|
|||||||
<Anchor
|
<Anchor
|
||||||
component={Link}
|
component={Link}
|
||||||
fw={500}
|
fw={500}
|
||||||
to={
|
to={buildPageUrl(spaceSlug, slugId, label)}
|
||||||
isShareRoute ? shareSlugUrl : buildPageUrl(spaceSlug, slugId, label)
|
|
||||||
}
|
|
||||||
underline="never"
|
underline="never"
|
||||||
className={classes.pageMentionLink}
|
className={classes.pageMentionLink}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -1,9 +0,0 @@
|
|||||||
import { atom } from "jotai";
|
|
||||||
|
|
||||||
type SearchAndReplaceAtomType = {
|
|
||||||
isOpen: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const searchAndReplaceStateAtom = atom<SearchAndReplaceAtomType>({
|
|
||||||
isOpen: false,
|
|
||||||
});
|
|
||||||
@ -1,312 +0,0 @@
|
|||||||
import {
|
|
||||||
ActionIcon,
|
|
||||||
Button,
|
|
||||||
Dialog,
|
|
||||||
Flex,
|
|
||||||
Input,
|
|
||||||
Stack,
|
|
||||||
Text,
|
|
||||||
Tooltip,
|
|
||||||
} from "@mantine/core";
|
|
||||||
import {
|
|
||||||
IconArrowNarrowDown,
|
|
||||||
IconArrowNarrowUp,
|
|
||||||
IconLetterCase,
|
|
||||||
IconReplace,
|
|
||||||
IconSearch,
|
|
||||||
IconX,
|
|
||||||
} from "@tabler/icons-react";
|
|
||||||
import { useEditor } from "@tiptap/react";
|
|
||||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
|
||||||
import { searchAndReplaceStateAtom } from "@/features/editor/components/search-and-replace/atoms/search-and-replace-state-atom.ts";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { getHotkeyHandler, useToggle } from "@mantine/hooks";
|
|
||||||
import { useLocation } from "react-router-dom";
|
|
||||||
import classes from "./search-replace.module.css";
|
|
||||||
|
|
||||||
interface PageFindDialogDialogProps {
|
|
||||||
editor: ReturnType<typeof useEditor>;
|
|
||||||
editable?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
function SearchAndReplaceDialog({ editor, editable = true }: PageFindDialogDialogProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [searchText, setSearchText] = useState("");
|
|
||||||
const [replaceText, setReplaceText] = useState("");
|
|
||||||
const [pageFindState, setPageFindState] = useAtom(searchAndReplaceStateAtom);
|
|
||||||
const inputRef = useRef(null);
|
|
||||||
|
|
||||||
const [replaceButton, replaceButtonToggle] = useToggle([
|
|
||||||
{ isReplaceShow: false, color: "gray" },
|
|
||||||
{ isReplaceShow: true, color: "blue" },
|
|
||||||
]);
|
|
||||||
|
|
||||||
const [caseSensitive, caseSensitiveToggle] = useToggle([
|
|
||||||
{ isCaseSensitive: false, color: "gray" },
|
|
||||||
{ isCaseSensitive: true, color: "blue" },
|
|
||||||
]);
|
|
||||||
|
|
||||||
const searchInputEvent = (event: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
setSearchText(event.target.value);
|
|
||||||
};
|
|
||||||
|
|
||||||
const replaceInputEvent = (event: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
setReplaceText(event.target.value);
|
|
||||||
};
|
|
||||||
|
|
||||||
const closeDialog = () => {
|
|
||||||
setSearchText("");
|
|
||||||
setReplaceText("");
|
|
||||||
setPageFindState({ isOpen: false });
|
|
||||||
// Reset replace button state when closing
|
|
||||||
if (replaceButton.isReplaceShow) {
|
|
||||||
replaceButtonToggle();
|
|
||||||
}
|
|
||||||
// Clear search term in editor
|
|
||||||
if (editor) {
|
|
||||||
editor.commands.setSearchTerm("");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const goToSelection = () => {
|
|
||||||
if (!editor) return;
|
|
||||||
|
|
||||||
const { results, resultIndex } = editor.storage.searchAndReplace;
|
|
||||||
const position: Range = results[resultIndex];
|
|
||||||
|
|
||||||
if (!position) return;
|
|
||||||
|
|
||||||
// @ts-ignore
|
|
||||||
editor.commands.setTextSelection(position);
|
|
||||||
|
|
||||||
const element = document.querySelector(".search-result-current");
|
|
||||||
if (element)
|
|
||||||
element.scrollIntoView({ behavior: "smooth", block: "center" });
|
|
||||||
|
|
||||||
editor.commands.setTextSelection(0);
|
|
||||||
};
|
|
||||||
|
|
||||||
const next = () => {
|
|
||||||
editor.commands.nextSearchResult();
|
|
||||||
goToSelection();
|
|
||||||
};
|
|
||||||
|
|
||||||
const previous = () => {
|
|
||||||
editor.commands.previousSearchResult();
|
|
||||||
goToSelection();
|
|
||||||
};
|
|
||||||
|
|
||||||
const replace = () => {
|
|
||||||
editor.commands.setReplaceTerm(replaceText);
|
|
||||||
editor.commands.replace();
|
|
||||||
goToSelection();
|
|
||||||
};
|
|
||||||
|
|
||||||
const replaceAll = () => {
|
|
||||||
editor.commands.setReplaceTerm(replaceText);
|
|
||||||
editor.commands.replaceAll();
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
editor.commands.setSearchTerm(searchText);
|
|
||||||
editor.commands.resetIndex();
|
|
||||||
editor.commands.selectCurrentItem();
|
|
||||||
}, [searchText]);
|
|
||||||
|
|
||||||
const handleOpenEvent = (e) => {
|
|
||||||
setPageFindState({ isOpen: true });
|
|
||||||
const selectedText = editor.state.doc.textBetween(
|
|
||||||
editor.state.selection.from,
|
|
||||||
editor.state.selection.to,
|
|
||||||
);
|
|
||||||
if (selectedText !== "") {
|
|
||||||
setSearchText(selectedText);
|
|
||||||
}
|
|
||||||
inputRef.current?.focus();
|
|
||||||
inputRef.current?.select();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCloseEvent = (e) => {
|
|
||||||
closeDialog();
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
!pageFindState.isOpen && closeDialog();
|
|
||||||
|
|
||||||
document.addEventListener("openFindDialogFromEditor", handleOpenEvent);
|
|
||||||
document.addEventListener("closeFindDialogFromEditor", handleCloseEvent);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener("openFindDialogFromEditor", handleOpenEvent);
|
|
||||||
document.removeEventListener(
|
|
||||||
"closeFindDialogFromEditor",
|
|
||||||
handleCloseEvent,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
}, [pageFindState.isOpen]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
editor.commands.setCaseSensitive(caseSensitive.isCaseSensitive);
|
|
||||||
editor.commands.resetIndex();
|
|
||||||
goToSelection();
|
|
||||||
}, [caseSensitive]);
|
|
||||||
|
|
||||||
const resultsCount = useMemo(
|
|
||||||
() =>
|
|
||||||
searchText.trim() === ""
|
|
||||||
? ""
|
|
||||||
: editor?.storage?.searchAndReplace?.results.length > 0
|
|
||||||
? editor?.storage?.searchAndReplace?.resultIndex +
|
|
||||||
1 +
|
|
||||||
"/" +
|
|
||||||
editor?.storage?.searchAndReplace?.results.length
|
|
||||||
: t("Not found"),
|
|
||||||
[
|
|
||||||
searchText,
|
|
||||||
editor?.storage?.searchAndReplace?.resultIndex,
|
|
||||||
editor?.storage?.searchAndReplace?.results.length,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
const location = useLocation();
|
|
||||||
useEffect(() => {
|
|
||||||
closeDialog();
|
|
||||||
}, [location]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog
|
|
||||||
className={classes.findDialog}
|
|
||||||
opened={pageFindState.isOpen}
|
|
||||||
|
|
||||||
size="lg"
|
|
||||||
radius="md"
|
|
||||||
w={"auto"}
|
|
||||||
position={{ top: 90, right: 50 }}
|
|
||||||
withBorder
|
|
||||||
transitionProps={{ transition: "slide-down" }}
|
|
||||||
>
|
|
||||||
<Stack gap="xs">
|
|
||||||
<Flex align="center" gap="xs">
|
|
||||||
<Input
|
|
||||||
ref={inputRef}
|
|
||||||
placeholder={t("Find")}
|
|
||||||
leftSection={<IconSearch size={16} />}
|
|
||||||
rightSection={
|
|
||||||
<Text size="xs" ta="right">
|
|
||||||
{resultsCount}
|
|
||||||
</Text>
|
|
||||||
}
|
|
||||||
rightSectionWidth="70"
|
|
||||||
rightSectionPointerEvents="all"
|
|
||||||
size="xs"
|
|
||||||
w={220}
|
|
||||||
onChange={searchInputEvent}
|
|
||||||
value={searchText}
|
|
||||||
autoFocus
|
|
||||||
onKeyDown={getHotkeyHandler([
|
|
||||||
["Enter", next],
|
|
||||||
["shift+Enter", previous],
|
|
||||||
["alt+C", caseSensitiveToggle],
|
|
||||||
//@ts-ignore
|
|
||||||
...(editable ? [["alt+R", replaceButtonToggle]] : []),
|
|
||||||
])}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ActionIcon.Group>
|
|
||||||
<Tooltip label={t("Previous match (Shift+Enter)")}>
|
|
||||||
<ActionIcon variant="subtle" color="gray" onClick={previous}>
|
|
||||||
<IconArrowNarrowUp
|
|
||||||
style={{ width: "70%", height: "70%" }}
|
|
||||||
stroke={1.5}
|
|
||||||
/>
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip label={t("Next match (Enter)")}>
|
|
||||||
<ActionIcon variant="subtle" color="gray" onClick={next}>
|
|
||||||
<IconArrowNarrowDown
|
|
||||||
style={{ width: "70%", height: "70%" }}
|
|
||||||
stroke={1.5}
|
|
||||||
/>
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip label={t("Match case (Alt+C)")}>
|
|
||||||
<ActionIcon
|
|
||||||
variant="subtle"
|
|
||||||
color={caseSensitive.color}
|
|
||||||
onClick={() => caseSensitiveToggle()}
|
|
||||||
>
|
|
||||||
<IconLetterCase
|
|
||||||
style={{ width: "70%", height: "70%" }}
|
|
||||||
stroke={1.5}
|
|
||||||
/>
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
{editable && (
|
|
||||||
<Tooltip label={t("Replace")}>
|
|
||||||
<ActionIcon
|
|
||||||
variant="subtle"
|
|
||||||
color={replaceButton.color}
|
|
||||||
onClick={() => replaceButtonToggle()}
|
|
||||||
>
|
|
||||||
<IconReplace
|
|
||||||
style={{ width: "70%", height: "70%" }}
|
|
||||||
stroke={1.5}
|
|
||||||
/>
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
<Tooltip label={t("Close (Escape)")}>
|
|
||||||
<ActionIcon variant="subtle" color="gray" onClick={closeDialog}>
|
|
||||||
<IconX style={{ width: "70%", height: "70%" }} stroke={1.5} />
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
</ActionIcon.Group>
|
|
||||||
</Flex>
|
|
||||||
{replaceButton.isReplaceShow && editable && (
|
|
||||||
<Flex align="center" gap="xs">
|
|
||||||
<Input
|
|
||||||
placeholder={t("Replace")}
|
|
||||||
leftSection={<IconReplace size={16} />}
|
|
||||||
rightSection={<div></div>}
|
|
||||||
rightSectionPointerEvents="all"
|
|
||||||
size="xs"
|
|
||||||
w={180}
|
|
||||||
autoFocus
|
|
||||||
onChange={replaceInputEvent}
|
|
||||||
value={replaceText}
|
|
||||||
onKeyDown={getHotkeyHandler([
|
|
||||||
["Enter", replace],
|
|
||||||
["ctrl+alt+Enter", replaceAll],
|
|
||||||
])}
|
|
||||||
/>
|
|
||||||
<ActionIcon.Group>
|
|
||||||
<Tooltip label={t("Replace (Enter)")}>
|
|
||||||
<Button
|
|
||||||
size="xs"
|
|
||||||
variant="subtle"
|
|
||||||
color="gray"
|
|
||||||
onClick={replace}
|
|
||||||
>
|
|
||||||
{t("Replace")}
|
|
||||||
</Button>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip label={t("Replace all (Ctrl+Alt+Enter)")}>
|
|
||||||
<Button
|
|
||||||
size="xs"
|
|
||||||
variant="subtle"
|
|
||||||
color="gray"
|
|
||||||
onClick={replaceAll}
|
|
||||||
>
|
|
||||||
{t("Replace all")}
|
|
||||||
</Button>
|
|
||||||
</Tooltip>
|
|
||||||
</ActionIcon.Group>
|
|
||||||
</Flex>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default SearchAndReplaceDialog;
|
|
||||||
@ -1,10 +0,0 @@
|
|||||||
.findDialog{
|
|
||||||
@media print {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.findDialog div[data-position="right"].mantine-Input-section {
|
|
||||||
justify-content: right;
|
|
||||||
padding-right: 8px;
|
|
||||||
}
|
|
||||||
@ -17,8 +17,8 @@ import {
|
|||||||
IconTable,
|
IconTable,
|
||||||
IconTypography,
|
IconTypography,
|
||||||
IconMenu4,
|
IconMenu4,
|
||||||
IconCalendar, IconAppWindow,
|
IconCalendar,
|
||||||
} from '@tabler/icons-react';
|
} from "@tabler/icons-react";
|
||||||
import {
|
import {
|
||||||
CommandProps,
|
CommandProps,
|
||||||
SlashMenuGroupedItemsType,
|
SlashMenuGroupedItemsType,
|
||||||
@ -357,20 +357,6 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
|||||||
.run();
|
.run();
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
title: "Iframe embed",
|
|
||||||
description: "Embed any Iframe",
|
|
||||||
searchTerms: ["iframe"],
|
|
||||||
icon: IconAppWindow,
|
|
||||||
command: ({ editor, range }: CommandProps) => {
|
|
||||||
editor
|
|
||||||
.chain()
|
|
||||||
.focus()
|
|
||||||
.deleteRange(range)
|
|
||||||
.setEmbed({ provider: "iframe" })
|
|
||||||
.run();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
title: "Airtable",
|
title: "Airtable",
|
||||||
description: "Embed Airtable",
|
description: "Embed Airtable",
|
||||||
|
|||||||
@ -52,8 +52,3 @@
|
|||||||
) !important;
|
) !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.leftBorder {
|
|
||||||
border-left: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
|
|
||||||
}
|
|
||||||
|
|||||||
@ -8,7 +8,6 @@ import { useTranslation } from "react-i18next";
|
|||||||
|
|
||||||
type TableOfContentsProps = {
|
type TableOfContentsProps = {
|
||||||
editor: ReturnType<typeof useEditor>;
|
editor: ReturnType<typeof useEditor>;
|
||||||
isShare?: boolean;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type HeadingLink = {
|
export type HeadingLink = {
|
||||||
@ -74,7 +73,6 @@ export const TableOfContents: FC<TableOfContentsProps> = (props) => {
|
|||||||
|
|
||||||
const handleUpdate = () => {
|
const handleUpdate = () => {
|
||||||
const result = recalculateLinks(props.editor?.$nodes("heading"));
|
const result = recalculateLinks(props.editor?.$nodes("heading"));
|
||||||
|
|
||||||
setLinks(result.links);
|
setLinks(result.links);
|
||||||
setHeadingDOMNodes(result.nodes);
|
setHeadingDOMNodes(result.nodes);
|
||||||
};
|
};
|
||||||
@ -87,12 +85,9 @@ export const TableOfContents: FC<TableOfContentsProps> = (props) => {
|
|||||||
};
|
};
|
||||||
}, [props.editor]);
|
}, [props.editor]);
|
||||||
|
|
||||||
useEffect(
|
useEffect(() => {
|
||||||
() => {
|
handleUpdate();
|
||||||
handleUpdate();
|
}, []);
|
||||||
},
|
|
||||||
props.isShare ? [props.editor] : [],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
try {
|
try {
|
||||||
@ -138,29 +133,16 @@ export const TableOfContents: FC<TableOfContentsProps> = (props) => {
|
|||||||
if (!links.length) {
|
if (!links.length) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{!props.isShare && (
|
<Text size="sm">
|
||||||
<Text size="sm">
|
{t("Add headings (H1, H2, H3) to generate a table of contents.")}
|
||||||
{t("Add headings (H1, H2, H3) to generate a table of contents.")}
|
</Text>
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{props.isShare && (
|
|
||||||
<Text size="sm" c="dimmed">
|
|
||||||
{t("No table of contents.")}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{props.isShare && (
|
<div>
|
||||||
<Text mb="md" fw={500}>
|
|
||||||
{t("Table of contents")}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
<div className={props.isShare ? classes.leftBorder : ""}>
|
|
||||||
{links.map((item, idx) => (
|
{links.map((item, idx) => (
|
||||||
<Box<"button">
|
<Box<"button">
|
||||||
component="button"
|
component="button"
|
||||||
|
|||||||
@ -1,145 +0,0 @@
|
|||||||
import React, { FC } from "react";
|
|
||||||
import { IconCheck, IconPalette } from "@tabler/icons-react";
|
|
||||||
import {
|
|
||||||
ActionIcon,
|
|
||||||
ColorSwatch,
|
|
||||||
Popover,
|
|
||||||
Stack,
|
|
||||||
Text,
|
|
||||||
Tooltip,
|
|
||||||
UnstyledButton,
|
|
||||||
} from "@mantine/core";
|
|
||||||
import { useEditor } from "@tiptap/react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
export interface TableColorItem {
|
|
||||||
name: string;
|
|
||||||
color: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TableBackgroundColorProps {
|
|
||||||
editor: ReturnType<typeof useEditor>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const TABLE_COLORS: TableColorItem[] = [
|
|
||||||
{ name: "Default", color: "" },
|
|
||||||
{ name: "Blue", color: "#b4d5ff" },
|
|
||||||
{ name: "Green", color: "#acf5d2" },
|
|
||||||
{ name: "Yellow", color: "#fef1b4" },
|
|
||||||
{ name: "Red", color: "#ffbead" },
|
|
||||||
{ name: "Pink", color: "#ffc7fe" },
|
|
||||||
{ name: "Gray", color: "#eaecef" },
|
|
||||||
{ name: "Purple", color: "#c1b7f2" },
|
|
||||||
];
|
|
||||||
|
|
||||||
export const TableBackgroundColor: FC<TableBackgroundColorProps> = ({
|
|
||||||
editor,
|
|
||||||
}) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [opened, setOpened] = React.useState(false);
|
|
||||||
|
|
||||||
const setTableCellBackground = (color: string, colorName: string) => {
|
|
||||||
editor
|
|
||||||
.chain()
|
|
||||||
.focus()
|
|
||||||
.updateAttributes("tableCell", {
|
|
||||||
backgroundColor: color || null,
|
|
||||||
backgroundColorName: color ? colorName : null
|
|
||||||
})
|
|
||||||
.updateAttributes("tableHeader", {
|
|
||||||
backgroundColor: color || null,
|
|
||||||
backgroundColorName: color ? colorName : null
|
|
||||||
})
|
|
||||||
.run();
|
|
||||||
setOpened(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Get current cell's background color
|
|
||||||
const getCurrentColor = () => {
|
|
||||||
if (editor.isActive("tableCell")) {
|
|
||||||
const attrs = editor.getAttributes("tableCell");
|
|
||||||
return attrs.backgroundColor || "";
|
|
||||||
}
|
|
||||||
if (editor.isActive("tableHeader")) {
|
|
||||||
const attrs = editor.getAttributes("tableHeader");
|
|
||||||
return attrs.backgroundColor || "";
|
|
||||||
}
|
|
||||||
return "";
|
|
||||||
};
|
|
||||||
|
|
||||||
const currentColor = getCurrentColor();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Popover
|
|
||||||
width={200}
|
|
||||||
position="bottom"
|
|
||||||
opened={opened}
|
|
||||||
onChange={setOpened}
|
|
||||||
withArrow
|
|
||||||
transitionProps={{ transition: "pop" }}
|
|
||||||
>
|
|
||||||
<Popover.Target>
|
|
||||||
<Tooltip label={t("Background color")} withArrow>
|
|
||||||
<ActionIcon
|
|
||||||
variant="default"
|
|
||||||
size="lg"
|
|
||||||
aria-label={t("Background color")}
|
|
||||||
onClick={() => setOpened(!opened)}
|
|
||||||
>
|
|
||||||
<IconPalette size={18} />
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
</Popover.Target>
|
|
||||||
|
|
||||||
<Popover.Dropdown>
|
|
||||||
<Stack gap="xs">
|
|
||||||
<Text size="sm" c="dimmed">
|
|
||||||
{t("Background color")}
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "grid",
|
|
||||||
gridTemplateColumns: "repeat(4, 1fr)",
|
|
||||||
gap: "8px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{TABLE_COLORS.map((item, index) => (
|
|
||||||
<UnstyledButton
|
|
||||||
key={index}
|
|
||||||
onClick={() => setTableCellBackground(item.color, item.name)}
|
|
||||||
style={{
|
|
||||||
position: "relative",
|
|
||||||
width: "24px",
|
|
||||||
height: "24px",
|
|
||||||
}}
|
|
||||||
title={t(item.name)}
|
|
||||||
>
|
|
||||||
<ColorSwatch
|
|
||||||
color={item.color || "#ffffff"}
|
|
||||||
size={24}
|
|
||||||
style={{
|
|
||||||
border: item.color === "" ? "1px solid #e5e7eb" : undefined,
|
|
||||||
cursor: "pointer",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{currentColor === item.color && (
|
|
||||||
<IconCheck
|
|
||||||
size={18}
|
|
||||||
style={{
|
|
||||||
color:
|
|
||||||
item.color === "" || item.color.startsWith("#F")
|
|
||||||
? "#000000"
|
|
||||||
: "#ffffff",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</ColorSwatch>
|
|
||||||
</UnstyledButton>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Stack>
|
|
||||||
</Popover.Dropdown>
|
|
||||||
</Popover>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -12,11 +12,8 @@ import {
|
|||||||
IconColumnRemove,
|
IconColumnRemove,
|
||||||
IconRowRemove,
|
IconRowRemove,
|
||||||
IconSquareToggle,
|
IconSquareToggle,
|
||||||
IconTableRow,
|
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { TableBackgroundColor } from "./table-background-color";
|
|
||||||
import { TableTextAlignment } from "./table-text-alignment";
|
|
||||||
|
|
||||||
export const TableCellMenu = React.memo(
|
export const TableCellMenu = React.memo(
|
||||||
({ editor, appendTo }: EditorMenuProps): JSX.Element => {
|
({ editor, appendTo }: EditorMenuProps): JSX.Element => {
|
||||||
@ -48,10 +45,6 @@ export const TableCellMenu = React.memo(
|
|||||||
editor.chain().focus().deleteRow().run();
|
editor.chain().focus().deleteRow().run();
|
||||||
}, [editor]);
|
}, [editor]);
|
||||||
|
|
||||||
const toggleHeaderCell = useCallback(() => {
|
|
||||||
editor.chain().focus().toggleHeaderCell().run();
|
|
||||||
}, [editor]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BaseBubbleMenu
|
<BaseBubbleMenu
|
||||||
editor={editor}
|
editor={editor}
|
||||||
@ -67,9 +60,6 @@ export const TableCellMenu = React.memo(
|
|||||||
shouldShow={shouldShow}
|
shouldShow={shouldShow}
|
||||||
>
|
>
|
||||||
<ActionIcon.Group>
|
<ActionIcon.Group>
|
||||||
<TableBackgroundColor editor={editor} />
|
|
||||||
<TableTextAlignment editor={editor} />
|
|
||||||
|
|
||||||
<Tooltip position="top" label={t("Merge cells")}>
|
<Tooltip position="top" label={t("Merge cells")}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
onClick={mergeCells}
|
onClick={mergeCells}
|
||||||
@ -113,17 +103,6 @@ export const TableCellMenu = React.memo(
|
|||||||
<IconRowRemove size={18} />
|
<IconRowRemove size={18} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
<Tooltip position="top" label={t("Toggle header cell")}>
|
|
||||||
<ActionIcon
|
|
||||||
onClick={toggleHeaderCell}
|
|
||||||
variant="default"
|
|
||||||
size="lg"
|
|
||||||
aria-label={t("Toggle header cell")}
|
|
||||||
>
|
|
||||||
<IconTableRow size={18} />
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
</ActionIcon.Group>
|
</ActionIcon.Group>
|
||||||
</BaseBubbleMenu>
|
</BaseBubbleMenu>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -17,9 +17,9 @@ import {
|
|||||||
IconColumnRemove,
|
IconColumnRemove,
|
||||||
IconRowInsertBottom,
|
IconRowInsertBottom,
|
||||||
IconRowInsertTop,
|
IconRowInsertTop,
|
||||||
IconRowRemove, IconTableColumn, IconTableRow,
|
IconRowRemove,
|
||||||
IconTrashX,
|
IconTrashX,
|
||||||
} from '@tabler/icons-react';
|
} from "@tabler/icons-react";
|
||||||
import { isCellSelection } from "@docmost/editor-ext";
|
import { isCellSelection } from "@docmost/editor-ext";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
@ -50,14 +50,6 @@ export const TableMenu = React.memo(
|
|||||||
return posToDOMRect(editor.view, selection.from, selection.to);
|
return posToDOMRect(editor.view, selection.from, selection.to);
|
||||||
}, [editor]);
|
}, [editor]);
|
||||||
|
|
||||||
const toggleHeaderColumn = useCallback(() => {
|
|
||||||
editor.chain().focus().toggleHeaderColumn().run();
|
|
||||||
}, [editor]);
|
|
||||||
|
|
||||||
const toggleHeaderRow = useCallback(() => {
|
|
||||||
editor.chain().focus().toggleHeaderRow().run();
|
|
||||||
}, [editor]);
|
|
||||||
|
|
||||||
const addColumnLeft = useCallback(() => {
|
const addColumnLeft = useCallback(() => {
|
||||||
editor.chain().focus().addColumnBefore().run();
|
editor.chain().focus().addColumnBefore().run();
|
||||||
}, [editor]);
|
}, [editor]);
|
||||||
@ -188,30 +180,6 @@ export const TableMenu = React.memo(
|
|||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
<Tooltip position="top" label={t("Toggle header row")}
|
|
||||||
>
|
|
||||||
<ActionIcon
|
|
||||||
onClick={toggleHeaderRow}
|
|
||||||
variant="default"
|
|
||||||
size="lg"
|
|
||||||
aria-label={t("Toggle header row")}
|
|
||||||
>
|
|
||||||
<IconTableRow size={18} />
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
<Tooltip position="top" label={t("Toggle header column")}
|
|
||||||
>
|
|
||||||
<ActionIcon
|
|
||||||
onClick={toggleHeaderColumn}
|
|
||||||
variant="default"
|
|
||||||
size="lg"
|
|
||||||
aria-label={t("Toggle header column")}
|
|
||||||
>
|
|
||||||
<IconTableColumn size={18} />
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
<Tooltip position="top" label={t("Delete table")}>
|
<Tooltip position="top" label={t("Delete table")}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
onClick={deleteTable}
|
onClick={deleteTable}
|
||||||
|
|||||||
@ -1,109 +0,0 @@
|
|||||||
import React, { FC } from "react";
|
|
||||||
import {
|
|
||||||
IconAlignCenter,
|
|
||||||
IconAlignLeft,
|
|
||||||
IconAlignRight,
|
|
||||||
IconCheck,
|
|
||||||
} from "@tabler/icons-react";
|
|
||||||
import {
|
|
||||||
ActionIcon,
|
|
||||||
Button,
|
|
||||||
Popover,
|
|
||||||
rem,
|
|
||||||
ScrollArea,
|
|
||||||
Tooltip,
|
|
||||||
} from "@mantine/core";
|
|
||||||
import { useEditor } from "@tiptap/react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
interface TableTextAlignmentProps {
|
|
||||||
editor: ReturnType<typeof useEditor>;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AlignmentItem {
|
|
||||||
name: string;
|
|
||||||
icon: React.ElementType;
|
|
||||||
command: () => void;
|
|
||||||
isActive: () => boolean;
|
|
||||||
value: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const TableTextAlignment: FC<TableTextAlignmentProps> = ({ editor }) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [opened, setOpened] = React.useState(false);
|
|
||||||
|
|
||||||
const items: AlignmentItem[] = [
|
|
||||||
{
|
|
||||||
name: "Align left",
|
|
||||||
value: "left",
|
|
||||||
isActive: () => editor.isActive({ textAlign: "left" }),
|
|
||||||
command: () => editor.chain().focus().setTextAlign("left").run(),
|
|
||||||
icon: IconAlignLeft,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Align center",
|
|
||||||
value: "center",
|
|
||||||
isActive: () => editor.isActive({ textAlign: "center" }),
|
|
||||||
command: () => editor.chain().focus().setTextAlign("center").run(),
|
|
||||||
icon: IconAlignCenter,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Align right",
|
|
||||||
value: "right",
|
|
||||||
isActive: () => editor.isActive({ textAlign: "right" }),
|
|
||||||
command: () => editor.chain().focus().setTextAlign("right").run(),
|
|
||||||
icon: IconAlignRight,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const activeItem = items.find((item) => item.isActive()) || items[0];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Popover
|
|
||||||
opened={opened}
|
|
||||||
onChange={setOpened}
|
|
||||||
position="bottom"
|
|
||||||
withArrow
|
|
||||||
transitionProps={{ transition: 'pop' }}
|
|
||||||
>
|
|
||||||
<Popover.Target>
|
|
||||||
<Tooltip label={t("Text alignment")} withArrow>
|
|
||||||
<ActionIcon
|
|
||||||
variant="default"
|
|
||||||
size="lg"
|
|
||||||
aria-label={t("Text alignment")}
|
|
||||||
onClick={() => setOpened(!opened)}
|
|
||||||
>
|
|
||||||
<activeItem.icon size={18} />
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
</Popover.Target>
|
|
||||||
|
|
||||||
<Popover.Dropdown>
|
|
||||||
<ScrollArea.Autosize type="scroll" mah={300}>
|
|
||||||
<Button.Group orientation="vertical">
|
|
||||||
{items.map((item, index) => (
|
|
||||||
<Button
|
|
||||||
key={index}
|
|
||||||
variant="default"
|
|
||||||
leftSection={<item.icon size={16} />}
|
|
||||||
rightSection={
|
|
||||||
item.isActive() && <IconCheck size={16} />
|
|
||||||
}
|
|
||||||
justify="left"
|
|
||||||
fullWidth
|
|
||||||
onClick={() => {
|
|
||||||
item.command();
|
|
||||||
setOpened(false);
|
|
||||||
}}
|
|
||||||
style={{ border: "none" }}
|
|
||||||
>
|
|
||||||
{t(item.name)}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</Button.Group>
|
|
||||||
</ScrollArea.Autosize>
|
|
||||||
</Popover.Dropdown>
|
|
||||||
</Popover>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -10,6 +10,8 @@ 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 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 +25,6 @@ import {
|
|||||||
MathInline,
|
MathInline,
|
||||||
TableCell,
|
TableCell,
|
||||||
TableRow,
|
TableRow,
|
||||||
TableHeader,
|
|
||||||
CustomTable,
|
|
||||||
TrailingNode,
|
TrailingNode,
|
||||||
TiptapImage,
|
TiptapImage,
|
||||||
Callout,
|
Callout,
|
||||||
@ -36,7 +36,6 @@ import {
|
|||||||
Drawio,
|
Drawio,
|
||||||
Excalidraw,
|
Excalidraw,
|
||||||
Embed,
|
Embed,
|
||||||
SearchAndReplace,
|
|
||||||
Mention,
|
Mention,
|
||||||
} from "@docmost/editor-ext";
|
} from "@docmost/editor-ext";
|
||||||
import {
|
import {
|
||||||
@ -59,7 +58,6 @@ import ExcalidrawView from "@/features/editor/components/excalidraw/excalidraw-v
|
|||||||
import EmbedView from "@/features/editor/components/embed/embed-view.tsx";
|
import EmbedView from "@/features/editor/components/embed/embed-view.tsx";
|
||||||
import plaintext from "highlight.js/lib/languages/plaintext";
|
import plaintext from "highlight.js/lib/languages/plaintext";
|
||||||
import powershell from "highlight.js/lib/languages/powershell";
|
import powershell from "highlight.js/lib/languages/powershell";
|
||||||
import abap from "highlightjs-sap-abap";
|
|
||||||
import elixir from "highlight.js/lib/languages/elixir";
|
import elixir from "highlight.js/lib/languages/elixir";
|
||||||
import erlang from "highlight.js/lib/languages/erlang";
|
import erlang from "highlight.js/lib/languages/erlang";
|
||||||
import dockerfile from "highlight.js/lib/languages/dockerfile";
|
import dockerfile from "highlight.js/lib/languages/dockerfile";
|
||||||
@ -74,12 +72,11 @@ import i18n from "@/i18n.ts";
|
|||||||
import { MarkdownClipboard } from "@/features/editor/extensions/markdown-clipboard.ts";
|
import { MarkdownClipboard } from "@/features/editor/extensions/markdown-clipboard.ts";
|
||||||
import EmojiCommand from "./emoji-command";
|
import EmojiCommand from "./emoji-command";
|
||||||
import { CharacterCount } from "@tiptap/extension-character-count";
|
import { CharacterCount } from "@tiptap/extension-character-count";
|
||||||
import { countWords } from "alfaaz";
|
|
||||||
|
|
||||||
const lowlight = createLowlight(common);
|
const lowlight = createLowlight(common);
|
||||||
lowlight.register("mermaid", plaintext);
|
lowlight.register("mermaid", plaintext);
|
||||||
lowlight.register("powershell", powershell);
|
lowlight.register("powershell", powershell);
|
||||||
lowlight.register("abap", abap);
|
lowlight.register("powershell", powershell);
|
||||||
lowlight.register("erlang", erlang);
|
lowlight.register("erlang", erlang);
|
||||||
lowlight.register("elixir", elixir);
|
lowlight.register("elixir", elixir);
|
||||||
lowlight.register("dockerfile", dockerfile);
|
lowlight.register("dockerfile", dockerfile);
|
||||||
@ -160,7 +157,7 @@ export const mainExtensions = [
|
|||||||
return ReactNodeViewRenderer(MentionView);
|
return ReactNodeViewRenderer(MentionView);
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
CustomTable.configure({
|
Table.configure({
|
||||||
resizable: true,
|
resizable: true,
|
||||||
lastColumnResizable: false,
|
lastColumnResizable: false,
|
||||||
allowTableNodeSelection: true,
|
allowTableNodeSelection: true,
|
||||||
@ -215,25 +212,7 @@ export const mainExtensions = [
|
|||||||
MarkdownClipboard.configure({
|
MarkdownClipboard.configure({
|
||||||
transformPastedText: true,
|
transformPastedText: true,
|
||||||
}),
|
}),
|
||||||
CharacterCount.configure({
|
CharacterCount
|
||||||
wordCounter: (text) => countWords(text),
|
|
||||||
}),
|
|
||||||
SearchAndReplace.extend({
|
|
||||||
addKeyboardShortcuts() {
|
|
||||||
return {
|
|
||||||
'Mod-f': () => {
|
|
||||||
const event = new CustomEvent("openFindDialogFromEditor", {});
|
|
||||||
document.dispatchEvent(event);
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
'Escape': () => {
|
|
||||||
const event = new CustomEvent("closeFindDialogFromEditor", {});
|
|
||||||
document.dispatchEvent(event);
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}).configure(),
|
|
||||||
] as any;
|
] as any;
|
||||||
|
|
||||||
type CollabExtensions = (provider: HocuspocusProvider, user: IUser) => any[];
|
type CollabExtensions = (provider: HocuspocusProvider, user: IUser) => any[];
|
||||||
|
|||||||
@ -42,11 +42,7 @@ export function FullEditor({
|
|||||||
spaceSlug={spaceSlug}
|
spaceSlug={spaceSlug}
|
||||||
editable={editable}
|
editable={editable}
|
||||||
/>
|
/>
|
||||||
<MemoizedPageEditor
|
<MemoizedPageEditor pageId={pageId} editable={editable} content={content} />
|
||||||
pageId={pageId}
|
|
||||||
editable={editable}
|
|
||||||
content={content}
|
|
||||||
/>
|
|
||||||
</Container>
|
</Container>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,11 @@
|
|||||||
import "@/features/editor/styles/index.css";
|
import "@/features/editor/styles/index.css";
|
||||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
import React, {
|
||||||
|
useEffect,
|
||||||
|
useLayoutEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
import { IndexeddbPersistence } from "y-indexeddb";
|
import { IndexeddbPersistence } from "y-indexeddb";
|
||||||
import * as Y from "yjs";
|
import * as Y from "yjs";
|
||||||
import {
|
import {
|
||||||
@ -39,7 +45,6 @@ import LinkMenu from "@/features/editor/components/link/link-menu.tsx";
|
|||||||
import ExcalidrawMenu from "./components/excalidraw/excalidraw-menu";
|
import ExcalidrawMenu from "./components/excalidraw/excalidraw-menu";
|
||||||
import DrawioMenu from "./components/drawio/drawio-menu";
|
import DrawioMenu from "./components/drawio/drawio-menu";
|
||||||
import { useCollabToken } from "@/features/auth/queries/auth-query.tsx";
|
import { useCollabToken } from "@/features/auth/queries/auth-query.tsx";
|
||||||
import SearchAndReplaceDialog from "@/features/editor/components/search-and-replace/search-and-replace-dialog.tsx";
|
|
||||||
import { useDebouncedCallback, useDocumentVisibility } from "@mantine/hooks";
|
import { useDebouncedCallback, useDocumentVisibility } from "@mantine/hooks";
|
||||||
import { useIdle } from "@/hooks/use-idle.ts";
|
import { useIdle } from "@/hooks/use-idle.ts";
|
||||||
import { queryClient } from "@/main.tsx";
|
import { queryClient } from "@/main.tsx";
|
||||||
@ -47,7 +52,6 @@ import { IPage } from "@/features/page/types/page.types.ts";
|
|||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
import { extractPageSlugId } from "@/lib";
|
import { extractPageSlugId } from "@/lib";
|
||||||
import { FIVE_MINUTES } from "@/lib/constants.ts";
|
import { FIVE_MINUTES } from "@/lib/constants.ts";
|
||||||
import { PageEditMode } from "@/features/user/types/user.types.ts";
|
|
||||||
import { jwtDecode } from "jwt-decode";
|
import { jwtDecode } from "jwt-decode";
|
||||||
|
|
||||||
interface PageEditorProps {
|
interface PageEditorProps {
|
||||||
@ -67,15 +71,11 @@ export default function PageEditor({
|
|||||||
const [, setAsideState] = useAtom(asideStateAtom);
|
const [, setAsideState] = useAtom(asideStateAtom);
|
||||||
const [, setActiveCommentId] = useAtom(activeCommentIdAtom);
|
const [, setActiveCommentId] = useAtom(activeCommentIdAtom);
|
||||||
const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom);
|
const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom);
|
||||||
const ydocRef = useRef<Y.Doc | null>(null);
|
const ydoc = useMemo(() => new Y.Doc(), [pageId]);
|
||||||
if (!ydocRef.current) {
|
|
||||||
ydocRef.current = new Y.Doc();
|
|
||||||
}
|
|
||||||
const ydoc = ydocRef.current;
|
|
||||||
const [isLocalSynced, setLocalSynced] = useState(false);
|
const [isLocalSynced, setLocalSynced] = useState(false);
|
||||||
const [isRemoteSynced, setRemoteSynced] = useState(false);
|
const [isRemoteSynced, setRemoteSynced] = useState(false);
|
||||||
const [yjsConnectionStatus, setYjsConnectionStatus] = useAtom(
|
const [yjsConnectionStatus, setYjsConnectionStatus] = useAtom(
|
||||||
yjsConnectionStatusAtom
|
yjsConnectionStatusAtom,
|
||||||
);
|
);
|
||||||
const menuContainerRef = useRef(null);
|
const menuContainerRef = useRef(null);
|
||||||
const documentName = `page.${pageId}`;
|
const documentName = `page.${pageId}`;
|
||||||
@ -85,126 +85,67 @@ export default function PageEditor({
|
|||||||
const [isCollabReady, setIsCollabReady] = useState(false);
|
const [isCollabReady, setIsCollabReady] = useState(false);
|
||||||
const { pageSlug } = useParams();
|
const { pageSlug } = useParams();
|
||||||
const slugId = extractPageSlugId(pageSlug);
|
const slugId = extractPageSlugId(pageSlug);
|
||||||
const userPageEditMode =
|
|
||||||
currentUser?.user?.settings?.preferences?.pageEditMode ?? PageEditMode.Edit;
|
|
||||||
|
|
||||||
// Providers only created once per pageId
|
const localProvider = useMemo(() => {
|
||||||
const providersRef = useRef<{
|
const provider = new IndexeddbPersistence(documentName, ydoc);
|
||||||
local: IndexeddbPersistence;
|
|
||||||
remote: HocuspocusProvider;
|
|
||||||
} | null>(null);
|
|
||||||
const [providersReady, setProvidersReady] = useState(false);
|
|
||||||
|
|
||||||
const localProvider = providersRef.current?.local;
|
provider.on("synced", () => {
|
||||||
const remoteProvider = providersRef.current?.remote;
|
setLocalSynced(true);
|
||||||
|
});
|
||||||
|
|
||||||
// Track when collaborative provider is ready and synced
|
return provider;
|
||||||
const [collabReady, setCollabReady] = useState(false);
|
}, [pageId, ydoc]);
|
||||||
useEffect(() => {
|
|
||||||
if (
|
|
||||||
remoteProvider?.status === WebSocketStatus.Connected &&
|
|
||||||
isLocalSynced &&
|
|
||||||
isRemoteSynced
|
|
||||||
) {
|
|
||||||
setCollabReady(true);
|
|
||||||
}
|
|
||||||
}, [remoteProvider?.status, isLocalSynced, isRemoteSynced]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
const remoteProvider = useMemo(() => {
|
||||||
if (!providersRef.current) {
|
const provider = new HocuspocusProvider({
|
||||||
const local = new IndexeddbPersistence(documentName, ydoc);
|
name: documentName,
|
||||||
local.on("synced", () => setLocalSynced(true));
|
url: collaborationURL,
|
||||||
const remote = new HocuspocusProvider({
|
document: ydoc,
|
||||||
name: documentName,
|
token: collabQuery?.token,
|
||||||
url: collaborationURL,
|
connect: false,
|
||||||
document: ydoc,
|
preserveConnection: false,
|
||||||
token: collabQuery?.token,
|
onAuthenticationFailed: (auth: onAuthenticationFailedParameters) => {
|
||||||
connect: true,
|
const payload = jwtDecode(collabQuery?.token);
|
||||||
preserveConnection: false,
|
const now = Date.now().valueOf() / 1000;
|
||||||
onAuthenticationFailed: (auth: onAuthenticationFailedParameters) => {
|
const isTokenExpired = now >= payload.exp;
|
||||||
const payload = jwtDecode(collabQuery?.token);
|
if (isTokenExpired) {
|
||||||
const now = Date.now().valueOf() / 1000;
|
refetchCollabToken();
|
||||||
const isTokenExpired = now >= payload.exp;
|
}
|
||||||
if (isTokenExpired) {
|
},
|
||||||
refetchCollabToken().then((result) => {
|
onStatus: (status) => {
|
||||||
if (result.data?.token) {
|
if (status.status === "connected") {
|
||||||
remote.disconnect();
|
setYjsConnectionStatus(status.status);
|
||||||
setTimeout(() => {
|
}
|
||||||
remote.configuration.token = result.data.token;
|
},
|
||||||
remote.connect();
|
});
|
||||||
}, 100);
|
|
||||||
}
|
provider.on("synced", () => {
|
||||||
});
|
setRemoteSynced(true);
|
||||||
}
|
});
|
||||||
},
|
|
||||||
onStatus: (status) => {
|
provider.on("disconnect", () => {
|
||||||
if (status.status === "connected") {
|
setYjsConnectionStatus(WebSocketStatus.Disconnected);
|
||||||
setYjsConnectionStatus(status.status);
|
});
|
||||||
}
|
|
||||||
},
|
return provider;
|
||||||
});
|
}, [ydoc, pageId, collabQuery?.token]);
|
||||||
remote.on("synced", () => setRemoteSynced(true));
|
|
||||||
remote.on("disconnect", () => {
|
useLayoutEffect(() => {
|
||||||
setYjsConnectionStatus(WebSocketStatus.Disconnected);
|
remoteProvider.connect();
|
||||||
});
|
|
||||||
providersRef.current = { local, remote };
|
|
||||||
setProvidersReady(true);
|
|
||||||
} else {
|
|
||||||
setProvidersReady(true);
|
|
||||||
}
|
|
||||||
// Only destroy on final unmount
|
|
||||||
return () => {
|
return () => {
|
||||||
providersRef.current?.remote.destroy();
|
setRemoteSynced(false);
|
||||||
providersRef.current?.local.destroy();
|
setLocalSynced(false);
|
||||||
providersRef.current = null;
|
remoteProvider.destroy();
|
||||||
|
localProvider.destroy();
|
||||||
};
|
};
|
||||||
}, [pageId]);
|
}, [remoteProvider, localProvider]);
|
||||||
|
|
||||||
/*
|
|
||||||
useEffect(() => {
|
|
||||||
// Handle token updates by reconnecting with new token
|
|
||||||
if (providersRef.current?.remote && collabQuery?.token) {
|
|
||||||
const currentToken = providersRef.current.remote.configuration.token;
|
|
||||||
if (currentToken !== collabQuery.token) {
|
|
||||||
// Token has changed, need to reconnect with new token
|
|
||||||
providersRef.current.remote.disconnect();
|
|
||||||
providersRef.current.remote.configuration.token = collabQuery.token;
|
|
||||||
providersRef.current.remote.connect();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [collabQuery?.token]);
|
|
||||||
*/
|
|
||||||
|
|
||||||
// Only connect/disconnect on tab/idle, not destroy
|
|
||||||
useEffect(() => {
|
|
||||||
if (!providersReady || !providersRef.current) return;
|
|
||||||
const remoteProvider = providersRef.current.remote;
|
|
||||||
if (
|
|
||||||
isIdle &&
|
|
||||||
documentState === "hidden" &&
|
|
||||||
remoteProvider.status === WebSocketStatus.Connected
|
|
||||||
) {
|
|
||||||
remoteProvider.disconnect();
|
|
||||||
setIsCollabReady(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
documentState === "visible" &&
|
|
||||||
remoteProvider.status === WebSocketStatus.Disconnected
|
|
||||||
) {
|
|
||||||
resetIdle();
|
|
||||||
remoteProvider.connect();
|
|
||||||
setTimeout(() => setIsCollabReady(true), 500);
|
|
||||||
}
|
|
||||||
}, [isIdle, documentState, providersReady, resetIdle]);
|
|
||||||
|
|
||||||
const extensions = useMemo(() => {
|
const extensions = useMemo(() => {
|
||||||
if (!remoteProvider || !currentUser?.user) return mainExtensions;
|
|
||||||
return [
|
return [
|
||||||
...mainExtensions,
|
...mainExtensions,
|
||||||
...collabExtensions(remoteProvider, currentUser?.user),
|
...collabExtensions(remoteProvider, currentUser?.user),
|
||||||
];
|
];
|
||||||
}, [remoteProvider, currentUser?.user]);
|
}, [ydoc, pageId, remoteProvider, currentUser?.user]);
|
||||||
|
|
||||||
const editor = useEditor(
|
const editor = useEditor(
|
||||||
{
|
{
|
||||||
@ -217,10 +158,6 @@ export default function PageEditor({
|
|||||||
scrollMargin: 80,
|
scrollMargin: 80,
|
||||||
handleDOMEvents: {
|
handleDOMEvents: {
|
||||||
keydown: (_view, event) => {
|
keydown: (_view, event) => {
|
||||||
if ((event.ctrlKey || event.metaKey) && event.code === 'KeyS') {
|
|
||||||
event.preventDefault();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (["ArrowUp", "ArrowDown", "Enter"].includes(event.key)) {
|
if (["ArrowUp", "ArrowDown", "Enter"].includes(event.key)) {
|
||||||
const slashCommand = document.querySelector("#slash-command");
|
const slashCommand = document.querySelector("#slash-command");
|
||||||
if (slashCommand) {
|
if (slashCommand) {
|
||||||
@ -262,7 +199,7 @@ export default function PageEditor({
|
|||||||
debouncedUpdateContent(editorJson);
|
debouncedUpdateContent(editorJson);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
[pageId, editable, remoteProvider]
|
[pageId, editable, remoteProvider?.status],
|
||||||
);
|
);
|
||||||
|
|
||||||
const debouncedUpdateContent = useDebouncedCallback((newContent: any) => {
|
const debouncedUpdateContent = useDebouncedCallback((newContent: any) => {
|
||||||
@ -278,21 +215,13 @@ export default function PageEditor({
|
|||||||
}, 3000);
|
}, 3000);
|
||||||
|
|
||||||
const handleActiveCommentEvent = (event) => {
|
const handleActiveCommentEvent = (event) => {
|
||||||
const { commentId, resolved } = event.detail;
|
const { commentId } = event.detail;
|
||||||
|
|
||||||
if (resolved) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setActiveCommentId(commentId);
|
setActiveCommentId(commentId);
|
||||||
setAsideState({ tab: "comments", isAsideOpen: true });
|
setAsideState({ tab: "comments", isAsideOpen: true });
|
||||||
|
|
||||||
//wait if aside is closed
|
const selector = `div[data-comment-id="${commentId}"]`;
|
||||||
setTimeout(() => {
|
const commentElement = document.querySelector(selector);
|
||||||
const selector = `div[data-comment-id="${commentId}"]`;
|
commentElement?.scrollIntoView();
|
||||||
const commentElement = document.querySelector(selector);
|
|
||||||
commentElement?.scrollIntoView({ behavior: "smooth", block: "center" });
|
|
||||||
}, 400);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -300,7 +229,7 @@ export default function PageEditor({
|
|||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener(
|
document.removeEventListener(
|
||||||
"ACTIVE_COMMENT_EVENT",
|
"ACTIVE_COMMENT_EVENT",
|
||||||
handleActiveCommentEvent
|
handleActiveCommentEvent,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
@ -320,6 +249,29 @@ export default function PageEditor({
|
|||||||
}
|
}
|
||||||
}, [remoteProvider?.status]);
|
}, [remoteProvider?.status]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
isIdle &&
|
||||||
|
documentState === "hidden" &&
|
||||||
|
remoteProvider?.status === WebSocketStatus.Connected
|
||||||
|
) {
|
||||||
|
remoteProvider.disconnect();
|
||||||
|
setIsCollabReady(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
documentState === "visible" &&
|
||||||
|
remoteProvider?.status === WebSocketStatus.Disconnected
|
||||||
|
) {
|
||||||
|
resetIdle();
|
||||||
|
remoteProvider.connect();
|
||||||
|
setTimeout(() => {
|
||||||
|
setIsCollabReady(true);
|
||||||
|
}, 600);
|
||||||
|
}
|
||||||
|
}, [isIdle, documentState, remoteProvider]);
|
||||||
|
|
||||||
const isSynced = isLocalSynced && isRemoteSynced;
|
const isSynced = isLocalSynced && isRemoteSynced;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -335,54 +287,11 @@ export default function PageEditor({
|
|||||||
return () => clearTimeout(collabReadyTimeout);
|
return () => clearTimeout(collabReadyTimeout);
|
||||||
}, [isRemoteSynced, isLocalSynced, remoteProvider?.status]);
|
}, [isRemoteSynced, isLocalSynced, remoteProvider?.status]);
|
||||||
|
|
||||||
useEffect(() => {
|
return isCollabReady ? (
|
||||||
// Only honor user default page edit mode preference and permissions
|
<div>
|
||||||
if (editor) {
|
|
||||||
if (userPageEditMode && editable) {
|
|
||||||
if (userPageEditMode === PageEditMode.Edit) {
|
|
||||||
editor.setEditable(true);
|
|
||||||
} else if (userPageEditMode === PageEditMode.Read) {
|
|
||||||
editor.setEditable(false);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
editor.setEditable(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [userPageEditMode, editor, editable]);
|
|
||||||
|
|
||||||
const hasConnectedOnceRef = useRef(false);
|
|
||||||
const [showStatic, setShowStatic] = useState(true);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (
|
|
||||||
!hasConnectedOnceRef.current &&
|
|
||||||
remoteProvider?.status === WebSocketStatus.Connected
|
|
||||||
) {
|
|
||||||
hasConnectedOnceRef.current = true;
|
|
||||||
setShowStatic(false);
|
|
||||||
}
|
|
||||||
}, [remoteProvider?.status]);
|
|
||||||
|
|
||||||
if (showStatic) {
|
|
||||||
return (
|
|
||||||
<EditorProvider
|
|
||||||
editable={false}
|
|
||||||
immediatelyRender={true}
|
|
||||||
extensions={mainExtensions}
|
|
||||||
content={content}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{ position: "relative" }}>
|
|
||||||
<div ref={menuContainerRef}>
|
<div ref={menuContainerRef}>
|
||||||
<EditorContent editor={editor} />
|
<EditorContent editor={editor} />
|
||||||
|
|
||||||
{editor && (
|
|
||||||
<SearchAndReplaceDialog editor={editor} editable={editable} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{editor && editor.isEditable && (
|
{editor && editor.isEditable && (
|
||||||
<div>
|
<div>
|
||||||
<EditorBubbleMenu editor={editor} />
|
<EditorBubbleMenu editor={editor} />
|
||||||
@ -396,12 +305,21 @@ export default function PageEditor({
|
|||||||
<LinkMenu editor={editor} appendTo={menuContainerRef} />
|
<LinkMenu editor={editor} appendTo={menuContainerRef} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showCommentPopup && <CommentDialog editor={editor} pageId={pageId} />}
|
{showCommentPopup && <CommentDialog editor={editor} pageId={pageId} />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
onClick={() => editor.commands.focus("end")}
|
onClick={() => editor.commands.focus("end")}
|
||||||
style={{ paddingBottom: "20vh" }}
|
style={{ paddingBottom: "20vh" }}
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<EditorProvider
|
||||||
|
editable={false}
|
||||||
|
immediatelyRender={true}
|
||||||
|
extensions={mainExtensions}
|
||||||
|
content={content}
|
||||||
|
></EditorProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,67 +0,0 @@
|
|||||||
import "@/features/editor/styles/index.css";
|
|
||||||
import React, { useMemo } from "react";
|
|
||||||
import { EditorProvider } from "@tiptap/react";
|
|
||||||
import { mainExtensions } from "@/features/editor/extensions/extensions";
|
|
||||||
import { Document } from "@tiptap/extension-document";
|
|
||||||
import { Heading } from "@tiptap/extension-heading";
|
|
||||||
import { Text } from "@tiptap/extension-text";
|
|
||||||
import { Placeholder } from "@tiptap/extension-placeholder";
|
|
||||||
import { useAtom } from "jotai/index";
|
|
||||||
import {
|
|
||||||
pageEditorAtom,
|
|
||||||
readOnlyEditorAtom,
|
|
||||||
} from "@/features/editor/atoms/editor-atoms.ts";
|
|
||||||
import { Editor } from "@tiptap/core";
|
|
||||||
|
|
||||||
interface PageEditorProps {
|
|
||||||
title: string;
|
|
||||||
content: any;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ReadonlyPageEditor({
|
|
||||||
title,
|
|
||||||
content,
|
|
||||||
}: PageEditorProps) {
|
|
||||||
const [, setReadOnlyEditor] = useAtom(readOnlyEditorAtom);
|
|
||||||
|
|
||||||
const extensions = useMemo(() => {
|
|
||||||
return [...mainExtensions];
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const titleExtensions = [
|
|
||||||
Document.extend({
|
|
||||||
content: "heading",
|
|
||||||
}),
|
|
||||||
Heading,
|
|
||||||
Text,
|
|
||||||
Placeholder.configure({
|
|
||||||
placeholder: "Untitled",
|
|
||||||
showOnlyWhenEditable: false,
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<EditorProvider
|
|
||||||
editable={false}
|
|
||||||
immediatelyRender={true}
|
|
||||||
extensions={titleExtensions}
|
|
||||||
content={title}
|
|
||||||
></EditorProvider>
|
|
||||||
|
|
||||||
<EditorProvider
|
|
||||||
editable={false}
|
|
||||||
immediatelyRender={true}
|
|
||||||
extensions={extensions}
|
|
||||||
content={content}
|
|
||||||
onCreate={({ editor }) => {
|
|
||||||
if (editor) {
|
|
||||||
// @ts-ignore
|
|
||||||
setReadOnlyEditor(editor);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
></EditorProvider>
|
|
||||||
<div style={{ paddingBottom: "20vh" }}></div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -142,24 +142,6 @@
|
|||||||
.comment-mark {
|
.comment-mark {
|
||||||
background: rgba(255, 215, 0, 0.14);
|
background: rgba(255, 215, 0, 0.14);
|
||||||
border-bottom: 2px solid rgb(166, 158, 12);
|
border-bottom: 2px solid rgb(166, 158, 12);
|
||||||
|
|
||||||
&.resolved {
|
|
||||||
background: none;
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.comment-highlight {
|
|
||||||
animation: flash-highlight 3s ease-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes flash-highlight {
|
|
||||||
0% {
|
|
||||||
background-color: #ff4d4d;
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
background-color: rgba(255, 215, 0, 0.14);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.resize-cursor {
|
.resize-cursor {
|
||||||
@ -192,7 +174,7 @@
|
|||||||
mask-size: 100% 100%;
|
mask-size: 100% 100%;
|
||||||
background-color: currentColor;
|
background-color: currentColor;
|
||||||
|
|
||||||
&-open {
|
& -open {
|
||||||
--svg: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23000' d='M10 3v2H5v14h14v-5h2v6a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1zm7.586 2H13V3h8v8h-2V6.414l-7 7L10.586 12z'/%3E%3C/svg%3E");
|
--svg: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23000' d='M10 3v2H5v14h14v-5h2v6a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1zm7.586 2H13V3h8v8h-2V6.414l-7 7L10.586 12z'/%3E%3C/svg%3E");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user