mirror of
https://github.com/docmost/docmost.git
synced 2025-11-13 17:02:36 +10:00
Compare commits
85 Commits
c6bca6a602
...
feat/resol
| Author | SHA1 | Date | |
|---|---|---|---|
| 7be077fb93 | |||
| 1895c51d10 | |||
| ec12e80423 | |||
| 28fcb11cb4 | |||
| 6b627d289c | |||
| 78bce0e29d | |||
| 0bd7ecb9b0 | |||
| 1f815880a4 | |||
| 03a58c2969 | |||
| f9317b99a9 | |||
| 37b9056070 | |||
| ad5cf1e18b | |||
| 32c7ecd9cf | |||
| b30bf61dc4 | |||
| 662460252f | |||
| 8522844673 | |||
| f8dc9845a7 | |||
| 4dfed2b2af | |||
| 44e592763d | |||
| 90488a95b1 | |||
| 9f39987404 | |||
| 16ec218ba7 | |||
| 608783b5cf | |||
| 5f5f1484db | |||
| f4082171ec | |||
| 6792a191b1 | |||
| e51a93221c | |||
| e856c8eb69 | |||
| c2c165528b | |||
| 9fa2b9636c | |||
| 29388636bf | |||
| f80004817c | |||
| ac79a185de | |||
| 27a9c0ebe4 | |||
| e36ec583ad | |||
| 2db5a85ef0 | |||
| eb9cbabeba | |||
| 67f8bcfeca | |||
| 129aaaa375 | |||
| e190945da8 | |||
| 81ffa6f459 | |||
| 5364702b69 | |||
| 232cea8cc9 | |||
| b9643d3584 | |||
| 9f144d35fb | |||
| e44c170873 | |||
| 1be39d4353 | |||
| 36d028ef4d | |||
| f5a36c60e8 | |||
| d5b84ae0b8 | |||
| e775e4dd8c | |||
| 65b01038d7 | |||
| e07cb57b01 | |||
| 2b53e0a455 | |||
| b9b3406b28 | |||
| 728cac0a34 | |||
| d35e16010b | |||
| 15791d4e59 | |||
| 3318e13225 | |||
| 080900610d | |||
| d1dc6977ab | |||
| 5f62448894 | |||
| 44445fbf46 | |||
| 1c674efddd | |||
| ccf7e34e99 | |||
| f39d48d6ee | |||
| f584ea84b0 | |||
| bc0c4d6258 | |||
| d8da307a61 | |||
| 50b3f9ddd9 | |||
| 0029f84d50 | |||
| 6d024fc3de | |||
| ce1503af85 | |||
| 69447fc375 | |||
| 858ff9da06 | |||
| 343b2976c2 | |||
| 7491224d0f | |||
| 4a0b4040ed | |||
| e3ba817723 | |||
| b0491d5da4 | |||
| 1c200dbd0f | |||
| fb7e4a7956 | |||
| 1413033568 | |||
| 00f4588c21 | |||
| 3a75251e75 |
@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "client",
|
"name": "client",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.20.4",
|
"version": "0.21.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc && vite build",
|
"build": "tsc && vite build",
|
||||||
@ -15,44 +15,48 @@
|
|||||||
"@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.17.6",
|
"@excalidraw/excalidraw": "0.18.0-864353b",
|
||||||
"@mantine/core": "^7.17.0",
|
"@mantine/core": "^8.1.3",
|
||||||
"@mantine/form": "^7.17.0",
|
"@mantine/form": "^8.1.3",
|
||||||
"@mantine/hooks": "^7.17.0",
|
"@mantine/hooks": "^8.1.3",
|
||||||
"@mantine/modals": "^7.17.0",
|
"@mantine/modals": "^8.1.3",
|
||||||
"@mantine/notifications": "^7.17.0",
|
"@mantine/notifications": "^8.1.3",
|
||||||
"@mantine/spotlight": "^7.17.0",
|
"@mantine/spotlight": "^8.1.3",
|
||||||
"@tabler/icons-react": "^3.22.0",
|
"@tabler/icons-react": "^3.34.0",
|
||||||
"@tanstack/react-query": "^5.61.4",
|
"@tanstack/react-query": "^5.80.6",
|
||||||
"@tiptap/extension-character-count": "^2.11.5",
|
"@tiptap/extension-character-count": "^2.10.3",
|
||||||
"axios": "^1.8.4",
|
"alfaaz": "^1.1.0",
|
||||||
|
"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.1",
|
"jotai": "^2.12.5",
|
||||||
"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.21",
|
"katex": "0.16.22",
|
||||||
"lowlight": "^3.2.0",
|
"lowlight": "^3.3.0",
|
||||||
"mermaid": "^11.4.1",
|
"mantine-form-zod-resolver": "^1.3.0",
|
||||||
|
"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.11",
|
"react-clear-modal": "^2.0.15",
|
||||||
"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.1",
|
"semver": "^7.7.2",
|
||||||
"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.23.8"
|
"zod": "^3.25.56"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.16.0",
|
"@eslint/js": "^9.16.0",
|
||||||
@ -76,6 +80,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.2"
|
"vite": "^6.3.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -383,5 +383,8 @@
|
|||||||
"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",
|
"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 deleted successfully": "Freigabe erfolgreich gelöscht",
|
||||||
"Share not found": "Freigabe nicht gefunden",
|
"Share not found": "Freigabe nicht gefunden",
|
||||||
"Failed to share page": "Fehler beim Teilen der Seite"
|
"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,7 +213,18 @@
|
|||||||
"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",
|
||||||
@ -222,7 +233,9 @@
|
|||||||
"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.",
|
||||||
@ -354,6 +367,9 @@
|
|||||||
"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.",
|
||||||
@ -384,7 +400,100 @@
|
|||||||
"Share deleted successfully": "Share deleted successfully",
|
"Share deleted successfully": "Share deleted successfully",
|
||||||
"Share not found": "Share not found",
|
"Share not found": "Share not found",
|
||||||
"Failed to share page": "Failed to share page",
|
"Failed to share page": "Failed to share page",
|
||||||
"Copy page": "Copy page",
|
"Copy page": "Copy page",
|
||||||
"Copy page to a different space.": "Copy page to a different space.",
|
"Copy page to a different space.": "Copy page to a different space.",
|
||||||
"Page copied successfully": "Page copied successfully"
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -383,5 +383,8 @@
|
|||||||
"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í",
|
"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 deleted successfully": "Compartición eliminada con éxito",
|
||||||
"Share not found": "Compartición no encontrada",
|
"Share not found": "Compartición no encontrada",
|
||||||
"Failed to share page": "Error al compartir la página"
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -383,5 +383,8 @@
|
|||||||
"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",
|
"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 deleted successfully": "Partage supprimé avec succès",
|
||||||
"Share not found": "Partage non trouvé",
|
"Share not found": "Partage non trouvé",
|
||||||
"Failed to share page": "Échec du partage de la page"
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -383,5 +383,8 @@
|
|||||||
"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",
|
"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 deleted successfully": "Condivisione eliminata con successo",
|
||||||
"Share not found": "Condivisione non trovata",
|
"Share not found": "Condivisione non trovata",
|
||||||
"Failed to share page": "Condivisione della pagina fallita"
|
"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}}",
|
||||||
@ -383,5 +383,8 @@
|
|||||||
"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 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": "제목 없음",
|
||||||
@ -383,5 +383,8 @@
|
|||||||
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -383,5 +383,8 @@
|
|||||||
"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",
|
"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 deleted successfully": "Delen succesvol verwijderd",
|
||||||
"Share not found": "Delen niet gevonden",
|
"Share not found": "Delen niet gevonden",
|
||||||
"Failed to share page": "Pagina delen mislukt"
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -383,5 +383,8 @@
|
|||||||
"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",
|
"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 deleted successfully": "Compartilhamento excluído com sucesso",
|
||||||
"Share not found": "Compartilhamento não encontrado",
|
"Share not found": "Compartilhamento não encontrado",
|
||||||
"Failed to share page": "Falha ao compartilhar página"
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -383,5 +383,8 @@
|
|||||||
"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 to a different space.": "Копировать страницу в другое пространство.",
|
||||||
|
"Page copied successfully": "Страница успешно скопирована"
|
||||||
}
|
}
|
||||||
|
|||||||
390
apps/client/public/locales/uk-UA/translation.json
Normal file
390
apps/client/public/locales/uk-UA/translation.json
Normal file
@ -0,0 +1,390 @@
|
|||||||
|
{
|
||||||
|
"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": "Сторінку успішно скопійовано"
|
||||||
|
}
|
||||||
@ -383,5 +383,8 @@
|
|||||||
"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 to a different space.": "将页面复制到不同的空间。",
|
||||||
|
"Page copied successfully": "页面复制成功"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -29,8 +29,12 @@ import { useRedirectToCloudSelect } from "@/ee/hooks/use-redirect-to-cloud-selec
|
|||||||
import SharedPage from "@/pages/share/shared-page.tsx";
|
import SharedPage from "@/pages/share/shared-page.tsx";
|
||||||
import Shares from "@/pages/settings/shares/shares.tsx";
|
import Shares from "@/pages/settings/shares/shares.tsx";
|
||||||
import ShareLayout from "@/features/share/components/share-layout.tsx";
|
import ShareLayout from "@/features/share/components/share-layout.tsx";
|
||||||
import ShareRedirect from '@/pages/share/share-redirect.tsx';
|
import ShareRedirect from "@/pages/share/share-redirect.tsx";
|
||||||
import { useTrackOrigin } from "@/hooks/use-track-origin";
|
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();
|
||||||
@ -45,6 +49,11 @@ 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 />} />
|
||||||
@ -58,7 +67,10 @@ export default function App() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<Route element={<ShareLayout />}>
|
<Route element={<ShareLayout />}>
|
||||||
<Route path={"/share/:shareId/p/:pageSlug"} element={<SharedPage />} />
|
<Route
|
||||||
|
path={"/share/:shareId/p/:pageSlug"}
|
||||||
|
element={<SharedPage />}
|
||||||
|
/>
|
||||||
<Route path={"/share/p/:pageSlug"} element={<SharedPage />} />
|
<Route path={"/share/p/:pageSlug"} element={<SharedPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
@ -67,7 +79,9 @@ export default function App() {
|
|||||||
|
|
||||||
<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={
|
||||||
|
|||||||
24
apps/client/src/components/common/user-info.tsx
Normal file
24
apps/client/src/components/common/user-info.tsx
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
20
apps/client/src/components/icons/confluence-icon.tsx
Normal file
20
apps/client/src/components/icons/confluence-icon.tsx
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
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,6 +27,8 @@ 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}>
|
||||||
@ -38,7 +40,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">
|
||||||
{!isHomeRoute && (
|
{!hideSidebar && (
|
||||||
<>
|
<>
|
||||||
<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 CommentList from "@/features/comment/components/comment-list.tsx";
|
import CommentListWithTabs from "@/features/comment/components/comment-list-with-tabs.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 = <CommentList />;
|
component = <CommentListWithTabs />;
|
||||||
title = "Comments";
|
title = "Comments";
|
||||||
break;
|
break;
|
||||||
case "toc":
|
case "toc":
|
||||||
@ -38,13 +38,17 @@ export default function Aside() {
|
|||||||
{t(title)}
|
{t(title)}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<ScrollArea
|
{tab === "comments" ? (
|
||||||
style={{ height: "85vh" }}
|
<CommentListWithTabs />
|
||||||
scrollbarSize={5}
|
) : (
|
||||||
type="scroll"
|
<ScrollArea
|
||||||
>
|
style={{ height: "85vh" }}
|
||||||
<div style={{ paddingBottom: "200px" }}>{component}</div>
|
scrollbarSize={5}
|
||||||
</ScrollArea>
|
type="scroll"
|
||||||
|
>
|
||||||
|
<div style={{ paddingBottom: "200px" }}>{component}</div>
|
||||||
|
</ScrollArea>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@ -73,13 +73,15 @@ 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={
|
||||||
!isHomeRoute && {
|
!hideSidebar && {
|
||||||
width: isSpaceRoute ? sidebarWidth : 300,
|
width: isSpaceRoute ? sidebarWidth : 300,
|
||||||
breakpoint: "sm",
|
breakpoint: "sm",
|
||||||
collapsed: {
|
collapsed: {
|
||||||
@ -100,7 +102,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>
|
||||||
{!isHomeRoute && (
|
{!hideSidebar && (
|
||||||
<AppShell.Navbar
|
<AppShell.Navbar
|
||||||
className={classes.navbar}
|
className={classes.navbar}
|
||||||
withBorder={false}
|
withBorder={false}
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
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 (
|
||||||
@ -8,6 +10,7 @@ export default function Layout() {
|
|||||||
<GlobalAppShell>
|
<GlobalAppShell>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</GlobalAppShell>
|
</GlobalAppShell>
|
||||||
|
{isCloud() && <PosthogUser />}
|
||||||
</UserProvider>
|
</UserProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,9 +1,21 @@
|
|||||||
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";
|
||||||
@ -19,6 +31,7 @@ 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;
|
||||||
@ -75,7 +88,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>
|
||||||
@ -101,6 +114,44 @@ 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} />}>
|
||||||
|
|||||||
@ -30,12 +30,12 @@ export default function BillingDetails() {
|
|||||||
>
|
>
|
||||||
Plan
|
Plan
|
||||||
</Text>
|
</Text>
|
||||||
<Text fw={700} fz="lg">
|
<Text fw={700} fz="lg" tt="capitalize">
|
||||||
{
|
{plans.find(
|
||||||
plans.find(
|
(plan) => plan.productId === billing.stripeProductId,
|
||||||
(plan) => plan.productId === billing.stripeProductId,
|
)?.name ||
|
||||||
)?.name
|
billing.planName ||
|
||||||
}
|
"Standard"}
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
</Group>
|
</Group>
|
||||||
@ -112,18 +112,59 @@ export default function BillingDetails() {
|
|||||||
fz="xs"
|
fz="xs"
|
||||||
className={classes.label}
|
className={classes.label}
|
||||||
>
|
>
|
||||||
Total
|
Cost
|
||||||
</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,24 +2,32 @@ 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 } from "@tabler/icons-react";
|
import { IconCheck, IconInfoCircle } from "@tabler/icons-react";
|
||||||
import { useBillingPlans } from "@/ee/billing/queries/billing-query.ts";
|
|
||||||
import { getCheckoutLink } from "@/ee/billing/services/billing-service.ts";
|
import { getCheckoutLink } from "@/ee/billing/services/billing-service.ts";
|
||||||
|
import { useBillingPlans } from "@/ee/billing/queries/billing-query.ts";
|
||||||
|
import { useAtomValue } from "jotai";
|
||||||
|
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 [interval, setInterval] = useState("yearly");
|
const workspace = useAtomValue(workspaceAtom);
|
||||||
|
const [isAnnual, setIsAnnual] = useState(true);
|
||||||
if (!plans) {
|
const [selectedTierValue, setSelectedTierValue] = useState<string | null>(
|
||||||
return null;
|
null,
|
||||||
}
|
);
|
||||||
|
|
||||||
const handleCheckout = async (priceId: string) => {
|
const handleCheckout = async (priceId: string) => {
|
||||||
try {
|
try {
|
||||||
@ -32,84 +40,194 @@ 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 (
|
||||||
<Group justify="center" p="xl">
|
<Container size="xl" py="xl">
|
||||||
{plans.map((plan) => {
|
{/* Tiered pricing notice for eligible workspaces */}
|
||||||
const price =
|
{showTieredPricingNotice && !hasTieredPlans && (
|
||||||
interval === "monthly" ? plan.price.monthly : plan.price.yearly;
|
<Alert
|
||||||
const priceId = interval === "monthly" ? plan.monthlyId : plan.yearlyId;
|
icon={<IconInfoCircle size={16} />}
|
||||||
const yearlyMonthPrice = parseInt(plan.price.yearly) / 12;
|
title="Want the old tiered pricing?"
|
||||||
|
color="blue"
|
||||||
|
mb="lg"
|
||||||
|
>
|
||||||
|
Contact support to switch back to our tiered pricing model.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
return (
|
{/* Controls Section */}
|
||||||
<Card
|
<Stack gap="xl" mb="md">
|
||||||
key={plan.name}
|
{/* Team Size and Billing Controls */}
|
||||||
withBorder
|
<Group justify="center" align="center" gap="sm">
|
||||||
radius="md"
|
{hasTieredPlans && (
|
||||||
shadow="sm"
|
<Select
|
||||||
p="xl"
|
label="Team size"
|
||||||
w={300}
|
description="Select the number of users"
|
||||||
>
|
value={selectedTierValue}
|
||||||
<SegmentedControl
|
onChange={setSelectedTierValue}
|
||||||
value={interval}
|
data={selectData}
|
||||||
onChange={setInterval}
|
w={250}
|
||||||
fullWidth
|
size="md"
|
||||||
data={[
|
allowDeselect={false}
|
||||||
{ label: "Monthly", value: "monthly" },
|
|
||||||
{ label: "Yearly (25% OFF)", value: "yearly" },
|
|
||||||
]}
|
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<Title order={3} ta="center" mt="sm" mb="xs">
|
<Group justify="center" align="start">
|
||||||
{plan.name}
|
<Flex justify="center" gap="md" align="center">
|
||||||
</Title>
|
<Text size="md">Monthly</Text>
|
||||||
<Text ta="center" size="lg" fw={700}>
|
<Switch
|
||||||
{interval === "monthly" && (
|
defaultChecked={isAnnual}
|
||||||
<>
|
onChange={(event) => setIsAnnual(event.target.checked)}
|
||||||
${price}{" "}
|
|
||||||
<Text span size="sm" fw={500} c="dimmed">
|
|
||||||
/user/month
|
|
||||||
</Text>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{interval === "yearly" && (
|
|
||||||
<>
|
|
||||||
${yearlyMonthPrice}{" "}
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<Card.Section mt="lg">
|
|
||||||
<Button onClick={() => handleCheckout(priceId)} fullWidth>
|
|
||||||
Subscribe
|
|
||||||
</Button>
|
|
||||||
</Card.Section>
|
|
||||||
|
|
||||||
<Card.Section mt="md">
|
|
||||||
<List
|
|
||||||
spacing="xs"
|
|
||||||
size="sm"
|
size="sm"
|
||||||
center
|
/>
|
||||||
icon={
|
<Text size="md">
|
||||||
<ThemeIcon variant="light" size={24} radius="xl">
|
Annually
|
||||||
<IconCheck size={16} />
|
<Badge component="span" variant="light" color="blue">
|
||||||
</ThemeIcon>
|
15% OFF
|
||||||
}
|
</Badge>
|
||||||
>
|
</Text>
|
||||||
{plan.features.map((feature, index) => (
|
</Flex>
|
||||||
<List.Item key={index}>{feature}</List.Item>
|
</Group>
|
||||||
))}
|
</Group>
|
||||||
</List>
|
</Stack>
|
||||||
</Card.Section>
|
|
||||||
</Card>
|
{/* Plans Grid */}
|
||||||
);
|
<Group justify="center" gap="lg" align="stretch">
|
||||||
})}
|
{plans.map((plan, index) => {
|
||||||
</Group>
|
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>
|
||||||
|
{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
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{/* CTA Button */}
|
||||||
|
<Button onClick={() => handleCheckout(priceId)} fullWidth>
|
||||||
|
Subscribe
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* Features */}
|
||||||
|
<List
|
||||||
|
spacing="xs"
|
||||||
|
size="sm"
|
||||||
|
icon={
|
||||||
|
<ThemeIcon size={20} radius="xl">
|
||||||
|
<IconCheck size={14} />
|
||||||
|
</ThemeIcon>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{plan.features.map((feature, featureIndex) => (
|
||||||
|
<List.Item key={featureIndex}>{feature}</List.Item>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Group>
|
||||||
|
</Container>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
export enum BillingPlan {
|
export enum BillingPlan {
|
||||||
STANDARD = "standard",
|
STANDARD = "standard",
|
||||||
|
BUSINESS = "business",
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IBilling {
|
export interface IBilling {
|
||||||
@ -24,6 +25,11 @@ 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 {
|
||||||
@ -41,9 +47,18 @@ 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;
|
||||||
|
}
|
||||||
67
apps/client/src/ee/comment/components/resolve-comment.tsx
Normal file
67
apps/client/src/ee/comment/components/resolve-comment.tsx
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
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;
|
||||||
87
apps/client/src/ee/comment/queries/comment-query.ts
Normal file
87
apps/client/src/ee/comment/queries/comment-query.ts
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
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")
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
41
apps/client/src/ee/components/posthog-user.tsx
Normal file
41
apps/client/src/ee/components/posthog-user.tsx
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
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,14 +2,18 @@ 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";
|
||||||
|
|
||||||
export const usePlan = () => {
|
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();
|
||||||
|
|
||||||
return { isStandard };
|
const isBusiness =
|
||||||
|
typeof workspace?.plan === "string" &&
|
||||||
|
workspace?.plan.toLowerCase() === BillingPlan.BUSINESS.toLowerCase();
|
||||||
|
|
||||||
|
return { isStandard, isBusiness };
|
||||||
};
|
};
|
||||||
|
|
||||||
export default usePlan;
|
export default usePlan;
|
||||||
|
|||||||
81
apps/client/src/ee/mfa/components/mfa-backup-code-input.tsx
Normal file
81
apps/client/src/ee/mfa/components/mfa-backup-code-input.tsx
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
193
apps/client/src/ee/mfa/components/mfa-backup-codes-modal.tsx
Normal file
193
apps/client/src/ee/mfa/components/mfa-backup-codes-modal.tsx
Normal file
@ -0,0 +1,193 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
12
apps/client/src/ee/mfa/components/mfa-challenge.module.css
Normal file
12
apps/client/src/ee/mfa/components/mfa-challenge.module.css
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
.container {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.paper {
|
||||||
|
width: 100%;
|
||||||
|
box-shadow: var(--mantine-shadow-lg);
|
||||||
|
}
|
||||||
160
apps/client/src/ee/mfa/components/mfa-challenge.tsx
Normal file
160
apps/client/src/ee/mfa/components/mfa-challenge.tsx
Normal file
@ -0,0 +1,160 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
124
apps/client/src/ee/mfa/components/mfa-disable-modal.tsx
Normal file
124
apps/client/src/ee/mfa/components/mfa-disable-modal.tsx
Normal file
@ -0,0 +1,124 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
112
apps/client/src/ee/mfa/components/mfa-settings.tsx
Normal file
112
apps/client/src/ee/mfa/components/mfa-settings.tsx
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
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)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
347
apps/client/src/ee/mfa/components/mfa-setup-modal.tsx
Normal file
347
apps/client/src/ee/mfa/components/mfa-setup-modal.tsx
Normal file
@ -0,0 +1,347 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
48
apps/client/src/ee/mfa/components/mfa-setup-required.tsx
Normal file
48
apps/client/src/ee/mfa/components/mfa-setup-required.tsx
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
31
apps/client/src/ee/mfa/components/mfa.module.css
Normal file
31
apps/client/src/ee/mfa/components/mfa.module.css
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
.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;
|
||||||
|
}
|
||||||
51
apps/client/src/ee/mfa/hooks/use-mfa-page-protection.ts
Normal file
51
apps/client/src/ee/mfa/hooks/use-mfa-page-protection.ts
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
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 };
|
||||||
|
}
|
||||||
19
apps/client/src/ee/mfa/index.ts
Normal file
19
apps/client/src/ee/mfa/index.ts
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
// 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";
|
||||||
13
apps/client/src/ee/mfa/pages/mfa-challenge-page.tsx
Normal file
13
apps/client/src/ee/mfa/pages/mfa-challenge-page.tsx
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
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 />;
|
||||||
|
}
|
||||||
113
apps/client/src/ee/mfa/pages/mfa-setup-required-page.tsx
Normal file
113
apps/client/src/ee/mfa/pages/mfa-setup-required-page.tsx
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
61
apps/client/src/ee/mfa/services/mfa-service.ts
Normal file
61
apps/client/src/ee/mfa/services/mfa-service.ts
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
62
apps/client/src/ee/mfa/types/mfa.types.ts
Normal file
62
apps/client/src/ee/mfa/types/mfa.types.ts
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
66
apps/client/src/ee/security/components/enforce-mfa.tsx
Normal file
66
apps/client/src/ee/security/components/enforce-mfa.tsx
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
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")}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -10,11 +10,14 @@ 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;
|
||||||
@ -31,12 +34,15 @@ 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>
|
||||||
|
|
||||||
{/*TODO: revisit when we add a second plan */}
|
{(isCloud() && isBusiness) || (!isCloud() && hasLicenseKey) ? (
|
||||||
{!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, zodResolver } from "@mantine/form";
|
import { useForm } from "@mantine/form";
|
||||||
import {
|
import {
|
||||||
Container,
|
Container,
|
||||||
Title,
|
Title,
|
||||||
@ -11,6 +11,7 @@ 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";
|
||||||
@ -18,6 +19,7 @@ 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),
|
||||||
@ -71,39 +73,43 @@ export function InviteSignUpForm() {
|
|||||||
{t("Join the workspace")}
|
{t("Join the workspace")}
|
||||||
</Title>
|
</Title>
|
||||||
|
|
||||||
<Stack align="stretch" justify="center" gap="xl">
|
<SsoLogin />
|
||||||
<form onSubmit={form.onSubmit(onSubmit)}>
|
|
||||||
<TextInput
|
|
||||||
id="name"
|
|
||||||
type="text"
|
|
||||||
label={t("Name")}
|
|
||||||
placeholder={t("enter your full name")}
|
|
||||||
variant="filled"
|
|
||||||
{...form.getInputProps("name")}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<TextInput
|
{!invitation.enforceSso && (
|
||||||
id="email"
|
<Stack align="stretch" justify="center" gap="xl">
|
||||||
type="email"
|
<form onSubmit={form.onSubmit(onSubmit)}>
|
||||||
label={t("Email")}
|
<TextInput
|
||||||
value={invitation.email}
|
id="name"
|
||||||
disabled
|
type="text"
|
||||||
variant="filled"
|
label={t("Name")}
|
||||||
mt="md"
|
placeholder={t("enter your full name")}
|
||||||
/>
|
variant="filled"
|
||||||
|
{...form.getInputProps("name")}
|
||||||
|
/>
|
||||||
|
|
||||||
<PasswordInput
|
<TextInput
|
||||||
label={t("Password")}
|
id="email"
|
||||||
placeholder={t("Your password")}
|
type="email"
|
||||||
variant="filled"
|
label={t("Email")}
|
||||||
mt="md"
|
value={invitation.email}
|
||||||
{...form.getInputProps("password")}
|
disabled
|
||||||
/>
|
variant="filled"
|
||||||
<Button type="submit" fullWidth mt="xl" loading={isLoading}>
|
mt="md"
|
||||||
{t("Sign Up")}
|
/>
|
||||||
</Button>
|
|
||||||
</form>
|
<PasswordInput
|
||||||
</Stack>
|
label={t("Password")}
|
||||||
|
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().min(3).max(50),
|
workspaceName: z.string().trim().max(50).optional(),
|
||||||
name: z.string().min(1).max(50),
|
name: z.string().min(1).max(50),
|
||||||
email: z
|
email: z
|
||||||
.string()
|
.string()
|
||||||
@ -60,15 +60,17 @@ export function SetupWorkspaceForm() {
|
|||||||
{isCloud() && <SsoCloudSignup />}
|
{isCloud() && <SsoCloudSignup />}
|
||||||
|
|
||||||
<form onSubmit={form.onSubmit(onSubmit)}>
|
<form onSubmit={form.onSubmit(onSubmit)}>
|
||||||
<TextInput
|
{!isCloud() && (
|
||||||
id="workspaceName"
|
<TextInput
|
||||||
type="text"
|
id="workspaceName"
|
||||||
label={t("Workspace Name")}
|
type="text"
|
||||||
placeholder={t("e.g ACME Inc")}
|
label={t("Workspace Name")}
|
||||||
variant="filled"
|
placeholder={t("e.g ACME Inc")}
|
||||||
mt="md"
|
variant="filled"
|
||||||
{...form.getInputProps("workspaceName")}
|
mt="md"
|
||||||
/>
|
{...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, getHostnameUrl } from "@/ee/utils.ts";
|
import { exchangeTokenRedirectUrl } from "@/ee/utils.ts";
|
||||||
|
|
||||||
export default function useAuth() {
|
export default function useAuth() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@ -39,9 +39,17 @@ export default function useAuth() {
|
|||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await login(data);
|
const response = 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);
|
||||||
@ -56,9 +64,19 @@ export default function useAuth() {
|
|||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await acceptInvitation(data);
|
const response = 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({
|
||||||
@ -100,12 +118,22 @@ export default function useAuth() {
|
|||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await passwordReset(data);
|
const response = await passwordReset(data);
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
navigate(APP_ROUTE.HOME);
|
|
||||||
notifications.show({
|
if (response?.requiresLogin) {
|
||||||
message: t("Password reset was successful"),
|
notifications.show({
|
||||||
});
|
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,14 +4,16 @@ 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<void> {
|
export async function login(data: ILogin): Promise<ILoginResponse> {
|
||||||
await api.post<void>("/auth/login", data);
|
const response = await api.post<ILoginResponse>("/auth/login", data);
|
||||||
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function logout(): Promise<void> {
|
export async function logout(): Promise<void> {
|
||||||
@ -36,8 +38,9 @@ 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<void> {
|
export async function passwordReset(data: IPasswordReset): Promise<{ requiresLogin?: boolean; }> {
|
||||||
await api.post<void>("/auth/password-reset", data);
|
const req = await api.post("/auth/password-reset", data);
|
||||||
|
return req.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function verifyUserToken(data: IVerifyUserToken): Promise<any> {
|
export async function verifyUserToken(data: IVerifyUserToken): Promise<any> {
|
||||||
@ -47,4 +50,4 @@ export async function verifyUserToken(data: IVerifyUserToken): Promise<any> {
|
|||||||
export async function getCollabToken(): Promise<ICollabToken> {
|
export async function getCollabToken(): Promise<ICollabToken> {
|
||||||
const req = await api.post<ICollabToken>("/auth/collab-token");
|
const req = await api.post<ICollabToken>("/auth/collab-token");
|
||||||
return req.data;
|
return req.data;
|
||||||
}
|
}
|
||||||
@ -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,5 +36,12 @@ 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 } from "@mantine/core";
|
import { Button, Group, Tooltip } from "@mantine/core";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
type CommentActionsProps = {
|
type CommentActionsProps = {
|
||||||
@ -15,7 +15,7 @@ function CommentActions({
|
|||||||
isCommentEditor,
|
isCommentEditor,
|
||||||
}: CommentActionsProps) {
|
}: CommentActionsProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Group justify="flex-end" pt="sm" wrap="nowrap">
|
<Group justify="flex-end" pt="sm" wrap="nowrap">
|
||||||
{isCommentEditor && (
|
{isCommentEditor && (
|
||||||
|
|||||||
@ -15,6 +15,7 @@ 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>;
|
||||||
@ -35,6 +36,8 @@ 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();
|
||||||
@ -63,11 +66,23 @@ 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();
|
commentElement?.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||||
|
|
||||||
|
editor.view.dispatch(
|
||||||
|
editor.state.tr.scrollIntoView()
|
||||||
|
);
|
||||||
|
}, 400);
|
||||||
|
|
||||||
|
emit({
|
||||||
|
operation: "invalidateComment",
|
||||||
|
pageId: pageId,
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setShowCommentPopup(false);
|
setShowCommentPopup(false);
|
||||||
@ -109,6 +124,7 @@ 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,10 +8,12 @@ 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;
|
||||||
@ -22,6 +24,7 @@ const CommentEditor = forwardRef(
|
|||||||
{
|
{
|
||||||
defaultContent,
|
defaultContent,
|
||||||
onUpdate,
|
onUpdate,
|
||||||
|
onSave,
|
||||||
editable,
|
editable,
|
||||||
placeholder,
|
placeholder,
|
||||||
autofocus,
|
autofocus,
|
||||||
@ -42,7 +45,35 @@ 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());
|
||||||
},
|
},
|
||||||
@ -53,6 +84,10 @@ 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 } from "@mantine/core";
|
import { Group, Text, Box, Badge } from "@mantine/core";
|
||||||
import React, { useState } from "react";
|
import React, { useEffect, 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,20 +7,34 @@ 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({ comment }: CommentListItemProps) {
|
function CommentListItem({
|
||||||
|
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);
|
||||||
@ -28,7 +42,14 @@ function CommentListItem({ comment }: CommentListItemProps) {
|
|||||||
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 {
|
||||||
@ -39,6 +60,11 @@ function CommentListItem({ comment }: CommentListItemProps) {
|
|||||||
};
|
};
|
||||||
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 {
|
||||||
@ -50,11 +76,54 @@ function CommentListItem({ comment }: CommentListItemProps) {
|
|||||||
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);
|
||||||
}
|
}
|
||||||
@ -78,28 +147,42 @@ function CommentListItem({ comment }: CommentListItemProps) {
|
|||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<div style={{ visibility: hovered ? "visible" : "hidden" }}>
|
<div style={{ visibility: hovered ? "visible" : "hidden" }}>
|
||||||
{/*!comment.parentCommentId && (
|
{!comment.parentCommentId && canComment && isCloudEE && (
|
||||||
<ResolveComment commentId={comment.id} pageId={comment.pageId} resolvedAt={comment.resolvedAt} />
|
<ResolveComment
|
||||||
)*/}
|
editor={editor}
|
||||||
|
commentId={comment.id}
|
||||||
|
pageId={comment.pageId}
|
||||||
|
resolvedAt={comment.resolvedAt}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{currentUser?.user?.id === comment.creatorId && (
|
{(currentUser?.user?.id === comment.creatorId || userSpaceRole === 'admin') && (
|
||||||
<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>
|
||||||
|
|
||||||
<Text size="xs" fw={500} c="dimmed">
|
<Group gap="xs">
|
||||||
{timeAgo(comment.createdAt)}
|
<Text size="xs" fw={500} c="dimmed">
|
||||||
</Text>
|
{timeAgo(comment.createdAt)}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
</div>
|
</div>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
{!comment.parentCommentId && comment?.selection && (
|
{!comment.parentCommentId && comment?.selection && (
|
||||||
<Box className={classes.textSelection}>
|
<Box
|
||||||
|
className={classes.textSelection}
|
||||||
|
onClick={() => handleCommentClick(comment)}
|
||||||
|
>
|
||||||
<Text size="sm">{comment?.selection}</Text>
|
<Text size="sm">{comment?.selection}</Text>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
@ -112,6 +195,7 @@ function CommentListItem({ comment }: CommentListItemProps) {
|
|||||||
defaultContent={content}
|
defaultContent={content}
|
||||||
editable={true}
|
editable={true}
|
||||||
onUpdate={(newContent: any) => setContent(newContent)}
|
onUpdate={(newContent: any) => setContent(newContent)}
|
||||||
|
onSave={handleUpdateComment}
|
||||||
autofocus={true}
|
autofocus={true}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,318 @@
|
|||||||
|
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;
|
||||||
@ -1,152 +0,0 @@
|
|||||||
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,15 +1,28 @@
|
|||||||
import { ActionIcon, Menu } from "@mantine/core";
|
import { ActionIcon, Menu, Tooltip } from "@mantine/core";
|
||||||
import { IconDots, IconEdit, IconTrash } from "@tabler/icons-react";
|
import { IconDots, IconEdit, IconTrash, IconCircleCheck, IconCircleCheckFilled } 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({ onEditComment, onDeleteComment }: CommentMenuProps) {
|
function CommentMenu({
|
||||||
|
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 = () =>
|
||||||
@ -30,9 +43,34 @@ function CommentMenu({ onEditComment, onDeleteComment }: CommentMenuProps) {
|
|||||||
</Menu.Target>
|
</Menu.Target>
|
||||||
|
|
||||||
<Menu.Dropdown>
|
<Menu.Dropdown>
|
||||||
<Menu.Item onClick={onEditComment} leftSection={<IconEdit size={14} />}>
|
{canEdit && (
|
||||||
{t("Edit comment")}
|
<Menu.Item onClick={onEditComment} leftSection={<IconEdit size={14} />}>
|
||||||
</Menu.Item>
|
{t("Edit comment")}
|
||||||
|
</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,22 +11,31 @@
|
|||||||
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: 2px;
|
margin-top: 10px;
|
||||||
margin-bottom: 2px;
|
margin-bottom: 2px;
|
||||||
overflow: hidden auto;
|
overflow: hidden auto;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,47 +0,0 @@
|
|||||||
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,13 +8,11 @@ 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";
|
||||||
@ -108,34 +106,4 @@ export function useDeleteCommentMutation(pageId?: string) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useResolveCommentMutation() {
|
// EE: useResolveCommentMutation has been moved to @/ee/comment/queries/comment-query
|
||||||
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,6 +16,7 @@ export interface IComment {
|
|||||||
editedAt?: Date;
|
editedAt?: Date;
|
||||||
deletedAt?: Date;
|
deletedAt?: Date;
|
||||||
creator: IUser;
|
creator: IUser;
|
||||||
|
resolvedBy?: IUser;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ICommentData {
|
export interface ICommentData {
|
||||||
@ -28,6 +29,7 @@ export interface ICommentData {
|
|||||||
|
|
||||||
export interface IResolveComment {
|
export interface IResolveComment {
|
||||||
commentId: string;
|
commentId: string;
|
||||||
|
pageId: string;
|
||||||
resolved: boolean;
|
resolved: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -116,6 +116,12 @@ 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);
|
||||||
@ -177,8 +183,8 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
|||||||
<LinkSelector
|
<LinkSelector
|
||||||
editor={props.editor}
|
editor={props.editor}
|
||||||
isOpen={isLinkSelectorOpen}
|
isOpen={isLinkSelectorOpen}
|
||||||
setIsOpen={() => {
|
setIsOpen={(value) => {
|
||||||
setIsLinkSelectorOpen(!isLinkSelectorOpen);
|
setIsLinkSelectorOpen(value);
|
||||||
setIsNodeSelectorOpen(false);
|
setIsNodeSelectorOpen(false);
|
||||||
setIsTextAlignmentOpen(false);
|
setIsTextAlignmentOpen(false);
|
||||||
setIsColorSelectorOpen(false);
|
setIsColorSelectorOpen(false);
|
||||||
|
|||||||
@ -156,13 +156,11 @@ export const ColorSelector: FC<ColorSelectorProps> = ({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
editor.commands.unsetColor();
|
if (name === "Default") {
|
||||||
name !== "Default" &&
|
editor.commands.unsetColor();
|
||||||
editor
|
} else {
|
||||||
.chain()
|
editor.chain().focus().setColor(color || "").run();
|
||||||
.focus()
|
}
|
||||||
.setColor(color || "")
|
|
||||||
.run();
|
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
}}
|
}}
|
||||||
style={{ border: "none" }}
|
style={{ border: "none" }}
|
||||||
|
|||||||
@ -0,0 +1,96 @@
|
|||||||
|
.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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,112 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,16 @@
|
|||||||
|
.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,9 +1,8 @@
|
|||||||
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
||||||
import { useMemo } from "react";
|
import React, { useMemo, useCallback } from "react";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import {
|
import {
|
||||||
ActionIcon,
|
ActionIcon,
|
||||||
AspectRatio,
|
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
FocusTrap,
|
FocusTrap,
|
||||||
@ -14,14 +13,18 @@ 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, zodResolver } from "@mantine/form";
|
import { useForm } from "@mantine/form";
|
||||||
import {
|
import { zodResolver } from "mantine-form-zod-resolver";
|
||||||
getEmbedProviderById,
|
|
||||||
getEmbedUrlAndProvider,
|
|
||||||
} from "@/features/editor/components/embed/providers.ts";
|
|
||||||
import { notifications } from "@mantine/notifications";
|
import { notifications } from "@mantine/notifications";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import i18n from "i18next";
|
import i18n from "i18next";
|
||||||
|
import {
|
||||||
|
getEmbedProviderById,
|
||||||
|
getEmbedUrlAndProvider,
|
||||||
|
sanitizeUrl,
|
||||||
|
} from "@docmost/editor-ext";
|
||||||
|
import { ResizableWrapper } from "../common/resizable-wrapper";
|
||||||
|
import classes from "./embed-view.module.css";
|
||||||
|
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
url: z
|
url: z
|
||||||
@ -32,8 +35,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 } = props;
|
const { node, selected, updateAttributes, editor } = props;
|
||||||
const { src, provider } = node.attrs;
|
const { src, provider, height: nodeHeight } = node.attrs;
|
||||||
|
|
||||||
const embedUrl = useMemo(() => {
|
const embedUrl = useMemo(() => {
|
||||||
if (src) {
|
if (src) {
|
||||||
@ -49,11 +52,26 @@ 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: data.url });
|
updateAttributes({ src: sanitizeUrl(data.url) });
|
||||||
} else {
|
} else {
|
||||||
notifications.show({
|
notifications.show({
|
||||||
message: t("Invalid {{provider}} embed link", {
|
message: t("Invalid {{provider}} embed link", {
|
||||||
@ -69,19 +87,33 @@ export default function EmbedView(props: NodeViewProps) {
|
|||||||
return (
|
return (
|
||||||
<NodeViewWrapper>
|
<NodeViewWrapper>
|
||||||
{embedUrl ? (
|
{embedUrl ? (
|
||||||
<>
|
<ResizableWrapper
|
||||||
<AspectRatio ratio={16 / 9}>
|
initialHeight={nodeHeight || 480}
|
||||||
<iframe
|
minHeight={200}
|
||||||
src={embedUrl}
|
maxHeight={1200}
|
||||||
allow="encrypted-media"
|
onResize={handleResize}
|
||||||
sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
|
isEditable={editor.isEditable}
|
||||||
allowFullScreen
|
className={clsx(classes.embedWrapper, {
|
||||||
frameBorder="0"
|
"ProseMirror-selectednode": selected,
|
||||||
></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 width={300} position="bottom" withArrow shadow="md">
|
<Popover
|
||||||
|
width={300}
|
||||||
|
position="bottom"
|
||||||
|
withArrow
|
||||||
|
shadow="md"
|
||||||
|
disabled={!editor.isEditable}
|
||||||
|
>
|
||||||
<Popover.Target>
|
<Popover.Target>
|
||||||
<Card
|
<Card
|
||||||
radius="md"
|
radius="md"
|
||||||
@ -101,7 +133,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>
|
||||||
|
|||||||
@ -33,7 +33,7 @@ const renderEmojiItems = () => {
|
|||||||
showOnCreate: true,
|
showOnCreate: true,
|
||||||
interactive: true,
|
interactive: true,
|
||||||
trigger: "manual",
|
trigger: "manual",
|
||||||
placement: "bottom-start",
|
placement: "bottom",
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onStart: (props: {
|
onStart: (props: {
|
||||||
|
|||||||
@ -0,0 +1,42 @@
|
|||||||
|
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,7 +13,8 @@ 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 { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/types/types";
|
import "@excalidraw/excalidraw/index.css";
|
||||||
|
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";
|
||||||
@ -21,6 +22,8 @@ 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) => ({
|
||||||
@ -35,6 +38,10 @@ 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();
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import React, {
|
|||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
useImperativeHandle,
|
useImperativeHandle,
|
||||||
|
useMemo,
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
@ -18,7 +19,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 } from "@tabler/icons-react";
|
import { IconFileDescription, IconPlus } 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";
|
||||||
@ -28,14 +29,28 @@ 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 { spaceSlug } = useParams();
|
const { pageSlug, 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,
|
||||||
@ -45,12 +60,23 @@ 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: "Users" });
|
items.push({ entityType: "header", label: t("Users") });
|
||||||
|
|
||||||
items = items.concat(
|
items = items.concat(
|
||||||
suggestion.users.map((user) => ({
|
suggestion.users.map((user) => ({
|
||||||
@ -64,7 +90,7 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (suggestion?.pages?.length > 0) {
|
if (suggestion?.pages?.length > 0) {
|
||||||
items.push({ entityType: "header", label: "Pages" });
|
items.push({ entityType: "header", label: t("Pages") });
|
||||||
items = items.concat(
|
items = items.concat(
|
||||||
suggestion.pages.map((page) => ({
|
suggestion.pages.map((page) => ({
|
||||||
id: uuid7(),
|
id: uuid7(),
|
||||||
@ -76,6 +102,7 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
|||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
items.push(createPageItem(props.query));
|
||||||
|
|
||||||
setRenderItems(items);
|
setRenderItems(items);
|
||||||
// update editor storage
|
// update editor storage
|
||||||
@ -96,7 +123,7 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
|||||||
creatorId: currentUser?.user.id,
|
creatorId: currentUser?.user.id,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (item.entityType === "page") {
|
if (item.entityType === "page" && item.id!==null) {
|
||||||
props.command({
|
props.command({
|
||||||
id: item.id,
|
id: item.id,
|
||||||
label: item.label || "Untitled",
|
label: item.label || "Untitled",
|
||||||
@ -106,6 +133,9 @@ 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],
|
||||||
@ -167,6 +197,58 @@ 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(() => {
|
||||||
@ -178,7 +260,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>
|
||||||
No results
|
{ t("No results") }
|
||||||
</Paper>
|
</Paper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -248,14 +330,14 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
|||||||
color="gray"
|
color="gray"
|
||||||
size={18}
|
size={18}
|
||||||
>
|
>
|
||||||
<IconFileDescription size={18} />
|
{ (item.id) ? <IconFileDescription size={18} /> : <IconPlus 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.label}
|
{ (item.id) ? item.label : t("Create page") + ': ' + item.label }
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
</Group>
|
</Group>
|
||||||
|
|||||||
@ -0,0 +1,9 @@
|
|||||||
|
import { atom } from "jotai";
|
||||||
|
|
||||||
|
type SearchAndReplaceAtomType = {
|
||||||
|
isOpen: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const searchAndReplaceStateAtom = atom<SearchAndReplaceAtomType>({
|
||||||
|
isOpen: false,
|
||||||
|
});
|
||||||
@ -0,0 +1,312 @@
|
|||||||
|
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;
|
||||||
@ -0,0 +1,10 @@
|
|||||||
|
.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,
|
IconCalendar, IconAppWindow,
|
||||||
} from "@tabler/icons-react";
|
} from '@tabler/icons-react';
|
||||||
import {
|
import {
|
||||||
CommandProps,
|
CommandProps,
|
||||||
SlashMenuGroupedItemsType,
|
SlashMenuGroupedItemsType,
|
||||||
@ -357,6 +357,20 @@ 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",
|
||||||
|
|||||||
@ -0,0 +1,145 @@
|
|||||||
|
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,8 +12,11 @@ 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 => {
|
||||||
@ -45,6 +48,10 @@ 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}
|
||||||
@ -60,6 +67,9 @@ 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}
|
||||||
@ -103,6 +113,17 @@ 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,
|
IconRowRemove, IconTableColumn, IconTableRow,
|
||||||
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,6 +50,14 @@ 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]);
|
||||||
@ -180,6 +188,30 @@ 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}
|
||||||
|
|||||||
@ -0,0 +1,109 @@
|
|||||||
|
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,8 +10,6 @@ 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";
|
||||||
@ -25,6 +23,8 @@ import {
|
|||||||
MathInline,
|
MathInline,
|
||||||
TableCell,
|
TableCell,
|
||||||
TableRow,
|
TableRow,
|
||||||
|
TableHeader,
|
||||||
|
CustomTable,
|
||||||
TrailingNode,
|
TrailingNode,
|
||||||
TiptapImage,
|
TiptapImage,
|
||||||
Callout,
|
Callout,
|
||||||
@ -36,6 +36,7 @@ import {
|
|||||||
Drawio,
|
Drawio,
|
||||||
Excalidraw,
|
Excalidraw,
|
||||||
Embed,
|
Embed,
|
||||||
|
SearchAndReplace,
|
||||||
Mention,
|
Mention,
|
||||||
} from "@docmost/editor-ext";
|
} from "@docmost/editor-ext";
|
||||||
import {
|
import {
|
||||||
@ -58,6 +59,7 @@ 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";
|
||||||
@ -72,11 +74,12 @@ 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("powershell", powershell);
|
lowlight.register("abap", abap);
|
||||||
lowlight.register("erlang", erlang);
|
lowlight.register("erlang", erlang);
|
||||||
lowlight.register("elixir", elixir);
|
lowlight.register("elixir", elixir);
|
||||||
lowlight.register("dockerfile", dockerfile);
|
lowlight.register("dockerfile", dockerfile);
|
||||||
@ -157,7 +160,7 @@ export const mainExtensions = [
|
|||||||
return ReactNodeViewRenderer(MentionView);
|
return ReactNodeViewRenderer(MentionView);
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
Table.configure({
|
CustomTable.configure({
|
||||||
resizable: true,
|
resizable: true,
|
||||||
lastColumnResizable: false,
|
lastColumnResizable: false,
|
||||||
allowTableNodeSelection: true,
|
allowTableNodeSelection: true,
|
||||||
@ -212,7 +215,25 @@ export const mainExtensions = [
|
|||||||
MarkdownClipboard.configure({
|
MarkdownClipboard.configure({
|
||||||
transformPastedText: true,
|
transformPastedText: true,
|
||||||
}),
|
}),
|
||||||
CharacterCount
|
CharacterCount.configure({
|
||||||
|
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[];
|
||||||
@ -228,4 +249,4 @@ export const collabExtensions: CollabExtensions = (provider, user) => [
|
|||||||
color: randomElement(userColors),
|
color: randomElement(userColors),
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
|
|||||||
@ -42,7 +42,11 @@ export function FullEditor({
|
|||||||
spaceSlug={spaceSlug}
|
spaceSlug={spaceSlug}
|
||||||
editable={editable}
|
editable={editable}
|
||||||
/>
|
/>
|
||||||
<MemoizedPageEditor pageId={pageId} editable={editable} content={content} />
|
<MemoizedPageEditor
|
||||||
|
pageId={pageId}
|
||||||
|
editable={editable}
|
||||||
|
content={content}
|
||||||
|
/>
|
||||||
</Container>
|
</Container>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,11 +1,5 @@
|
|||||||
import "@/features/editor/styles/index.css";
|
import "@/features/editor/styles/index.css";
|
||||||
import React, {
|
import React, { useEffect, useMemo, useRef, useState } from "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 {
|
||||||
@ -45,6 +39,7 @@ 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";
|
||||||
@ -52,6 +47,7 @@ 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 {
|
||||||
@ -71,11 +67,15 @@ 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 ydoc = useMemo(() => new Y.Doc(), [pageId]);
|
const ydocRef = useRef<Y.Doc | null>(null);
|
||||||
|
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,67 +85,126 @@ 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;
|
||||||
|
|
||||||
const localProvider = useMemo(() => {
|
// Providers only created once per pageId
|
||||||
const provider = new IndexeddbPersistence(documentName, ydoc);
|
const providersRef = useRef<{
|
||||||
|
local: IndexeddbPersistence;
|
||||||
|
remote: HocuspocusProvider;
|
||||||
|
} | null>(null);
|
||||||
|
const [providersReady, setProvidersReady] = useState(false);
|
||||||
|
|
||||||
provider.on("synced", () => {
|
const localProvider = providersRef.current?.local;
|
||||||
setLocalSynced(true);
|
const remoteProvider = providersRef.current?.remote;
|
||||||
});
|
|
||||||
|
|
||||||
return provider;
|
// Track when collaborative provider is ready and synced
|
||||||
}, [pageId, ydoc]);
|
const [collabReady, setCollabReady] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
remoteProvider?.status === WebSocketStatus.Connected &&
|
||||||
|
isLocalSynced &&
|
||||||
|
isRemoteSynced
|
||||||
|
) {
|
||||||
|
setCollabReady(true);
|
||||||
|
}
|
||||||
|
}, [remoteProvider?.status, isLocalSynced, isRemoteSynced]);
|
||||||
|
|
||||||
const remoteProvider = useMemo(() => {
|
useEffect(() => {
|
||||||
const provider = new HocuspocusProvider({
|
if (!providersRef.current) {
|
||||||
name: documentName,
|
const local = new IndexeddbPersistence(documentName, ydoc);
|
||||||
url: collaborationURL,
|
local.on("synced", () => setLocalSynced(true));
|
||||||
document: ydoc,
|
const remote = new HocuspocusProvider({
|
||||||
token: collabQuery?.token,
|
name: documentName,
|
||||||
connect: false,
|
url: collaborationURL,
|
||||||
preserveConnection: false,
|
document: ydoc,
|
||||||
onAuthenticationFailed: (auth: onAuthenticationFailedParameters) => {
|
token: collabQuery?.token,
|
||||||
const payload = jwtDecode(collabQuery?.token);
|
connect: true,
|
||||||
const now = Date.now().valueOf() / 1000;
|
preserveConnection: false,
|
||||||
const isTokenExpired = now >= payload.exp;
|
onAuthenticationFailed: (auth: onAuthenticationFailedParameters) => {
|
||||||
if (isTokenExpired) {
|
const payload = jwtDecode(collabQuery?.token);
|
||||||
refetchCollabToken();
|
const now = Date.now().valueOf() / 1000;
|
||||||
}
|
const isTokenExpired = now >= payload.exp;
|
||||||
},
|
if (isTokenExpired) {
|
||||||
onStatus: (status) => {
|
refetchCollabToken().then((result) => {
|
||||||
if (status.status === "connected") {
|
if (result.data?.token) {
|
||||||
setYjsConnectionStatus(status.status);
|
remote.disconnect();
|
||||||
}
|
setTimeout(() => {
|
||||||
},
|
remote.configuration.token = result.data.token;
|
||||||
});
|
remote.connect();
|
||||||
|
}, 100);
|
||||||
provider.on("synced", () => {
|
}
|
||||||
setRemoteSynced(true);
|
});
|
||||||
});
|
}
|
||||||
|
},
|
||||||
provider.on("disconnect", () => {
|
onStatus: (status) => {
|
||||||
setYjsConnectionStatus(WebSocketStatus.Disconnected);
|
if (status.status === "connected") {
|
||||||
});
|
setYjsConnectionStatus(status.status);
|
||||||
|
}
|
||||||
return provider;
|
},
|
||||||
}, [ydoc, pageId, collabQuery?.token]);
|
});
|
||||||
|
remote.on("synced", () => setRemoteSynced(true));
|
||||||
useLayoutEffect(() => {
|
remote.on("disconnect", () => {
|
||||||
remoteProvider.connect();
|
setYjsConnectionStatus(WebSocketStatus.Disconnected);
|
||||||
|
});
|
||||||
|
providersRef.current = { local, remote };
|
||||||
|
setProvidersReady(true);
|
||||||
|
} else {
|
||||||
|
setProvidersReady(true);
|
||||||
|
}
|
||||||
|
// Only destroy on final unmount
|
||||||
return () => {
|
return () => {
|
||||||
setRemoteSynced(false);
|
providersRef.current?.remote.destroy();
|
||||||
setLocalSynced(false);
|
providersRef.current?.local.destroy();
|
||||||
remoteProvider.destroy();
|
providersRef.current = null;
|
||||||
localProvider.destroy();
|
|
||||||
};
|
};
|
||||||
}, [remoteProvider, localProvider]);
|
}, [pageId]);
|
||||||
|
|
||||||
|
/*
|
||||||
|
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),
|
||||||
];
|
];
|
||||||
}, [ydoc, pageId, remoteProvider, currentUser?.user]);
|
}, [remoteProvider, currentUser?.user]);
|
||||||
|
|
||||||
const editor = useEditor(
|
const editor = useEditor(
|
||||||
{
|
{
|
||||||
@ -158,6 +217,10 @@ 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) {
|
||||||
@ -199,7 +262,7 @@ export default function PageEditor({
|
|||||||
debouncedUpdateContent(editorJson);
|
debouncedUpdateContent(editorJson);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
[pageId, editable, remoteProvider?.status],
|
[pageId, editable, remoteProvider]
|
||||||
);
|
);
|
||||||
|
|
||||||
const debouncedUpdateContent = useDebouncedCallback((newContent: any) => {
|
const debouncedUpdateContent = useDebouncedCallback((newContent: any) => {
|
||||||
@ -215,13 +278,21 @@ export default function PageEditor({
|
|||||||
}, 3000);
|
}, 3000);
|
||||||
|
|
||||||
const handleActiveCommentEvent = (event) => {
|
const handleActiveCommentEvent = (event) => {
|
||||||
const { commentId } = event.detail;
|
const { commentId, resolved } = event.detail;
|
||||||
|
|
||||||
|
if (resolved) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setActiveCommentId(commentId);
|
setActiveCommentId(commentId);
|
||||||
setAsideState({ tab: "comments", isAsideOpen: true });
|
setAsideState({ tab: "comments", isAsideOpen: true });
|
||||||
|
|
||||||
const selector = `div[data-comment-id="${commentId}"]`;
|
//wait if aside is closed
|
||||||
const commentElement = document.querySelector(selector);
|
setTimeout(() => {
|
||||||
commentElement?.scrollIntoView();
|
const selector = `div[data-comment-id="${commentId}"]`;
|
||||||
|
const commentElement = document.querySelector(selector);
|
||||||
|
commentElement?.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||||
|
}, 400);
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -229,7 +300,7 @@ export default function PageEditor({
|
|||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener(
|
document.removeEventListener(
|
||||||
"ACTIVE_COMMENT_EVENT",
|
"ACTIVE_COMMENT_EVENT",
|
||||||
handleActiveCommentEvent,
|
handleActiveCommentEvent
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
@ -249,29 +320,6 @@ 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(() => {
|
||||||
@ -287,11 +335,54 @@ export default function PageEditor({
|
|||||||
return () => clearTimeout(collabReadyTimeout);
|
return () => clearTimeout(collabReadyTimeout);
|
||||||
}, [isRemoteSynced, isLocalSynced, remoteProvider?.status]);
|
}, [isRemoteSynced, isLocalSynced, remoteProvider?.status]);
|
||||||
|
|
||||||
return isCollabReady ? (
|
useEffect(() => {
|
||||||
<div>
|
// Only honor user default page edit mode preference and permissions
|
||||||
|
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} />
|
||||||
@ -305,21 +396,12 @@ 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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -142,6 +142,24 @@
|
|||||||
.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 {
|
||||||
@ -174,7 +192,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");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -71,4 +71,12 @@
|
|||||||
[data-type="details"][open] > [data-type="detailsButton"] .ProseMirror-icon{
|
[data-type="details"][open] > [data-type="detailsButton"] .ProseMirror-icon{
|
||||||
transform: rotateZ(90deg);
|
transform: rotateZ(90deg);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
[data-type="details"]:has(.search-result) > [data-type="detailsContainer"] > [data-type="detailsContent"]{
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-type="details"]:has(.search-result) > [data-type="detailsButton"] .ProseMirror-icon{
|
||||||
|
transform: rotateZ(90deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
9
apps/client/src/features/editor/styles/find.css
Normal file
9
apps/client/src/features/editor/styles/find.css
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
.search-result{
|
||||||
|
background: #ffff65;
|
||||||
|
color: #212529;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-result-current{
|
||||||
|
background: #ffc266 !important;
|
||||||
|
color: #212529;
|
||||||
|
}
|
||||||
@ -9,5 +9,6 @@
|
|||||||
@import "./media.css";
|
@import "./media.css";
|
||||||
@import "./code.css";
|
@import "./code.css";
|
||||||
@import "./print.css";
|
@import "./print.css";
|
||||||
|
@import "./find.css";
|
||||||
@import "./mention.css";
|
@import "./mention.css";
|
||||||
|
@import "./ordered-list.css";
|
||||||
|
|||||||
34
apps/client/src/features/editor/styles/ordered-list.css
Normal file
34
apps/client/src/features/editor/styles/ordered-list.css
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
/* Ordered list type cycling based on nesting depth */
|
||||||
|
ol,
|
||||||
|
ol ol ol ol,
|
||||||
|
ol ol ol ol ol ol ol,
|
||||||
|
ol ol ol ol ol ol ol ol ol ol {
|
||||||
|
list-style-type: decimal;
|
||||||
|
}
|
||||||
|
|
||||||
|
ol ol,
|
||||||
|
ol ol ol ol ol,
|
||||||
|
ol ol ol ol ol ol ol ol,
|
||||||
|
ol ol ol ol ol ol ol ol ol ol ol {
|
||||||
|
list-style-type: lower-alpha;
|
||||||
|
}
|
||||||
|
|
||||||
|
ol ol ol,
|
||||||
|
ol ol ol ol ol ol,
|
||||||
|
ol ol ol ol ol ol ol ol ol,
|
||||||
|
ol ol ol ol ol ol ol ol ol ol ol ol {
|
||||||
|
list-style-type: lower-roman;
|
||||||
|
}
|
||||||
|
|
||||||
|
ol {
|
||||||
|
list-style-position: outside;
|
||||||
|
margin-left: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Nested list spacing */
|
||||||
|
ol ol,
|
||||||
|
ol ul,
|
||||||
|
ul ol {
|
||||||
|
margin-top: 0.1rem;
|
||||||
|
margin-bottom: 0.1rem;
|
||||||
|
}
|
||||||
@ -4,6 +4,7 @@
|
|||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
& table {
|
& table {
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
|
min-width: 700px !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -38,8 +39,8 @@
|
|||||||
|
|
||||||
th {
|
th {
|
||||||
background-color: light-dark(
|
background-color: light-dark(
|
||||||
var(--mantine-color-gray-1),
|
var(--mantine-color-gray-1),
|
||||||
var(--mantine-color-dark-5)
|
var(--mantine-color-dark-5)
|
||||||
);
|
);
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
@ -47,7 +48,7 @@
|
|||||||
|
|
||||||
.column-resize-handle {
|
.column-resize-handle {
|
||||||
background-color: #adf;
|
background-color: #adf;
|
||||||
bottom: -2px;
|
bottom: -1px;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: -2px;
|
right: -2px;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
@ -66,8 +67,54 @@
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Table cell background colors with dark mode support */
|
||||||
|
.ProseMirror {
|
||||||
|
table {
|
||||||
|
@mixin dark {
|
||||||
|
/* Blue */
|
||||||
|
td[data-background-color="#b4d5ff"],
|
||||||
|
th[data-background-color="#b4d5ff"] {
|
||||||
|
background-color: #1a3a5c !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Green */
|
||||||
|
td[data-background-color="#acf5d2"],
|
||||||
|
th[data-background-color="#acf5d2"] {
|
||||||
|
background-color: #1a4d3a !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Yellow */
|
||||||
|
td[data-background-color="#fef1b4"],
|
||||||
|
th[data-background-color="#fef1b4"] {
|
||||||
|
background-color: #7c5014 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Red */
|
||||||
|
td[data-background-color="#ffbead"],
|
||||||
|
th[data-background-color="#ffbead"] {
|
||||||
|
background-color: #5c2a23 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pink */
|
||||||
|
td[data-background-color="#ffc7fe"],
|
||||||
|
th[data-background-color="#ffc7fe"] {
|
||||||
|
background-color: #4d2a4d !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Gray */
|
||||||
|
td[data-background-color="#eaecef"],
|
||||||
|
th[data-background-color="#eaecef"] {
|
||||||
|
background-color: #2a2e33 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Purple */
|
||||||
|
td[data-background-color="#c1b7f2"],
|
||||||
|
th[data-background-color="#c1b7f2"] {
|
||||||
|
background-color: #3a2f5c !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -10,8 +10,11 @@ import {
|
|||||||
pageEditorAtom,
|
pageEditorAtom,
|
||||||
titleEditorAtom,
|
titleEditorAtom,
|
||||||
} from "@/features/editor/atoms/editor-atoms";
|
} from "@/features/editor/atoms/editor-atoms";
|
||||||
import { useUpdatePageMutation } from "@/features/page/queries/page-query";
|
import {
|
||||||
import { useDebouncedCallback } from "@mantine/hooks";
|
updatePageData,
|
||||||
|
useUpdateTitlePageMutation,
|
||||||
|
} from "@/features/page/queries/page-query";
|
||||||
|
import { useDebouncedCallback, getHotkeyHandler } from "@mantine/hooks";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useQueryEmit } from "@/features/websocket/use-query-emit.ts";
|
import { useQueryEmit } from "@/features/websocket/use-query-emit.ts";
|
||||||
import { History } from "@tiptap/extension-history";
|
import { History } from "@tiptap/extension-history";
|
||||||
@ -21,6 +24,8 @@ import { useTranslation } from "react-i18next";
|
|||||||
import EmojiCommand from "@/features/editor/extensions/emoji-command.ts";
|
import EmojiCommand from "@/features/editor/extensions/emoji-command.ts";
|
||||||
import { UpdateEvent } from "@/features/websocket/types";
|
import { UpdateEvent } from "@/features/websocket/types";
|
||||||
import localEmitter from "@/lib/local-emitter.ts";
|
import localEmitter from "@/lib/local-emitter.ts";
|
||||||
|
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||||
|
import { PageEditMode } from "@/features/user/types/user.types.ts";
|
||||||
|
|
||||||
export interface TitleEditorProps {
|
export interface TitleEditorProps {
|
||||||
pageId: string;
|
pageId: string;
|
||||||
@ -38,12 +43,16 @@ export function TitleEditor({
|
|||||||
editable,
|
editable,
|
||||||
}: TitleEditorProps) {
|
}: TitleEditorProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { mutateAsync: updatePageMutationAsync } = useUpdatePageMutation();
|
const { mutateAsync: updateTitlePageMutationAsync } =
|
||||||
|
useUpdateTitlePageMutation();
|
||||||
const pageEditor = useAtomValue(pageEditorAtom);
|
const pageEditor = useAtomValue(pageEditorAtom);
|
||||||
const [, setTitleEditor] = useAtom(titleEditorAtom);
|
const [, setTitleEditor] = useAtom(titleEditorAtom);
|
||||||
const emit = useQueryEmit();
|
const emit = useQueryEmit();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [activePageId, setActivePageId] = useState(pageId);
|
const [activePageId, setActivePageId] = useState(pageId);
|
||||||
|
const [currentUser] = useAtom(currentUserAtom);
|
||||||
|
const userPageEditMode =
|
||||||
|
currentUser?.user?.settings?.preferences?.pageEditMode ?? PageEditMode.Edit;
|
||||||
|
|
||||||
const titleEditor = useEditor({
|
const titleEditor = useEditor({
|
||||||
extensions: [
|
extensions: [
|
||||||
@ -94,7 +103,7 @@ export function TitleEditor({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
updatePageMutationAsync({
|
updateTitlePageMutationAsync({
|
||||||
pageId: pageId,
|
pageId: pageId,
|
||||||
title: titleEditor.getText(),
|
title: titleEditor.getText(),
|
||||||
}).then((page) => {
|
}).then((page) => {
|
||||||
@ -103,9 +112,18 @@ export function TitleEditor({
|
|||||||
spaceId: page.spaceId,
|
spaceId: page.spaceId,
|
||||||
entity: ["pages"],
|
entity: ["pages"],
|
||||||
id: page.id,
|
id: page.id,
|
||||||
payload: { title: page.title, slugId: page.slugId },
|
payload: {
|
||||||
|
title: page.title,
|
||||||
|
slugId: page.slugId,
|
||||||
|
parentPageId: page.parentPageId,
|
||||||
|
icon: page.icon,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (page.title !== titleEditor.getText()) return;
|
||||||
|
|
||||||
|
updatePageData(page);
|
||||||
|
|
||||||
localEmitter.emit("message", event);
|
localEmitter.emit("message", event);
|
||||||
emit(event);
|
emit(event);
|
||||||
});
|
});
|
||||||
@ -132,9 +150,30 @@ export function TitleEditor({
|
|||||||
};
|
};
|
||||||
}, [pageId]);
|
}, [pageId]);
|
||||||
|
|
||||||
function handleTitleKeyDown(event) {
|
useEffect(() => {
|
||||||
|
// honor user default page edit mode preference
|
||||||
|
if (userPageEditMode && titleEditor && editable) {
|
||||||
|
if (userPageEditMode === PageEditMode.Edit) {
|
||||||
|
titleEditor.setEditable(true);
|
||||||
|
} else if (userPageEditMode === PageEditMode.Read) {
|
||||||
|
titleEditor.setEditable(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [userPageEditMode, titleEditor, editable]);
|
||||||
|
|
||||||
|
const openSearchDialog = () => {
|
||||||
|
const event = new CustomEvent("openFindDialogFromEditor", {});
|
||||||
|
document.dispatchEvent(event);
|
||||||
|
};
|
||||||
|
|
||||||
|
function handleTitleKeyDown(event: any) {
|
||||||
if (!titleEditor || !pageEditor || event.shiftKey) return;
|
if (!titleEditor || !pageEditor || event.shiftKey) return;
|
||||||
|
|
||||||
|
// Prevent focus shift when IME composition is active
|
||||||
|
// `keyCode === 229` is added to support Safari where `isComposing` may not be reliable
|
||||||
|
if (event.nativeEvent.isComposing || event.nativeEvent.keyCode === 229)
|
||||||
|
return;
|
||||||
|
|
||||||
const { key } = event;
|
const { key } = event;
|
||||||
const { $head } = titleEditor.state.selection;
|
const { $head } = titleEditor.state.selection;
|
||||||
|
|
||||||
@ -148,5 +187,16 @@ export function TitleEditor({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return <EditorContent editor={titleEditor} onKeyDown={handleTitleKeyDown} />;
|
return (
|
||||||
|
<EditorContent
|
||||||
|
editor={titleEditor}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
// First handle the search hotkey
|
||||||
|
getHotkeyHandler([["mod+F", openSearchDialog]])(event);
|
||||||
|
|
||||||
|
// Then handle other key events
|
||||||
|
handleTitleKeyDown(event);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,14 @@
|
|||||||
|
import api from "@/lib/api-client";
|
||||||
|
import { IFileTask } from "@/features/file-task/types/file-task.types.ts";
|
||||||
|
|
||||||
|
export async function getFileTaskById(fileTaskId: string): Promise<IFileTask> {
|
||||||
|
const req = await api.post<IFileTask>("/file-tasks/info", {
|
||||||
|
fileTaskId: fileTaskId,
|
||||||
|
});
|
||||||
|
return req.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getFileTasks(): Promise<IFileTask[]> {
|
||||||
|
const req = await api.post<IFileTask[]>("/file-tasks");
|
||||||
|
return req.data;
|
||||||
|
}
|
||||||
17
apps/client/src/features/file-task/types/file-task.types.ts
Normal file
17
apps/client/src/features/file-task/types/file-task.types.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
export interface IFileTask {
|
||||||
|
id: string;
|
||||||
|
type: "import" | "export";
|
||||||
|
source: string;
|
||||||
|
status: string;
|
||||||
|
fileName: string;
|
||||||
|
filePath: string;
|
||||||
|
fileSize: number;
|
||||||
|
fileExt: string;
|
||||||
|
errorMessage: string | null;
|
||||||
|
creatorId: string;
|
||||||
|
spaceId: string;
|
||||||
|
workspaceId: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
deletedAt: string | null;
|
||||||
|
}
|
||||||
@ -1,9 +1,10 @@
|
|||||||
import api from "@/lib/api-client";
|
import api from "@/lib/api-client";
|
||||||
import { IPageHistory } from "@/features/page-history/types/page.types";
|
import { IPageHistory } from "@/features/page-history/types/page.types";
|
||||||
|
import { IPagination } from "@/lib/types.ts";
|
||||||
|
|
||||||
export async function getPageHistoryList(
|
export async function getPageHistoryList(
|
||||||
pageId: string,
|
pageId: string,
|
||||||
): Promise<IPageHistory[]> {
|
): Promise<IPagination<IPageHistory>> {
|
||||||
const req = await api.post("/pages/history", {
|
const req = await api.post("/pages/history", {
|
||||||
pageId,
|
pageId,
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,24 +1,30 @@
|
|||||||
.breadcrumbs {
|
.breadcrumbs {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
overflow: hidden;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--mantine-color-default-color);
|
||||||
|
line-height: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mantine-Breadcrumbs-breadcrumb {
|
||||||
|
min-width: 1px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
flex-wrap: nowrap;
|
}
|
||||||
|
|
||||||
a {
|
|
||||||
color: var(--mantine-color-default-color);
|
|
||||||
line-height: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mantine-Breadcrumbs-breadcrumb {
|
|
||||||
min-width: 1px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.truncatedText {
|
.truncatedText {
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
max-width: 200px;
|
max-width: 200px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.breadcrumbDiv {
|
||||||
|
overflow: hidden;
|
||||||
|
@media (max-width: $mantine-breakpoint-sm) {
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -161,7 +161,7 @@ export default function Breadcrumb() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ overflow: "hidden" }}>
|
<div className={classes.breadcrumbDiv}>
|
||||||
{breadcrumbNodes && (
|
{breadcrumbNodes && (
|
||||||
<Breadcrumbs className={classes.breadcrumbs}>
|
<Breadcrumbs className={classes.breadcrumbs}>
|
||||||
{isMobile ? getMobileBreadcrumbItems() : getBreadcrumbItems()}
|
{isMobile ? getMobileBreadcrumbItems() : getBreadcrumbItems()}
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { Modal, Button, Group, Text } from "@mantine/core";
|
import { Modal, Button, Group, Text } from "@mantine/core";
|
||||||
import { copyPageToSpace } from "@/features/page/services/page-service.ts";
|
import { duplicatePage } from "@/features/page/services/page-service.ts";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { notifications } from "@mantine/notifications";
|
import { notifications } from "@mantine/notifications";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@ -30,7 +30,7 @@ export default function CopyPageModal({
|
|||||||
if (!targetSpace) return;
|
if (!targetSpace) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const copiedPage = await copyPageToSpace({
|
const copiedPage = await duplicatePage({
|
||||||
pageId,
|
pageId,
|
||||||
spaceId: targetSpace.id,
|
spaceId: targetSpace.id,
|
||||||
});
|
});
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import {
|
|||||||
IconList,
|
IconList,
|
||||||
IconMessage,
|
IconMessage,
|
||||||
IconPrinter,
|
IconPrinter,
|
||||||
|
IconSearch,
|
||||||
IconTrash,
|
IconTrash,
|
||||||
IconWifiOff,
|
IconWifiOff,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
@ -16,7 +17,12 @@ import React from "react";
|
|||||||
import useToggleAside from "@/hooks/use-toggle-aside.tsx";
|
import useToggleAside from "@/hooks/use-toggle-aside.tsx";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { historyAtoms } from "@/features/page-history/atoms/history-atoms.ts";
|
import { historyAtoms } from "@/features/page-history/atoms/history-atoms.ts";
|
||||||
import { useClipboard, useDisclosure } from "@mantine/hooks";
|
import {
|
||||||
|
getHotkeyHandler,
|
||||||
|
useClipboard,
|
||||||
|
useDisclosure,
|
||||||
|
useHotkeys,
|
||||||
|
} from "@mantine/hooks";
|
||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
import { usePageQuery } from "@/features/page/queries/page-query.ts";
|
import { usePageQuery } from "@/features/page/queries/page-query.ts";
|
||||||
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
||||||
@ -32,7 +38,9 @@ import {
|
|||||||
pageEditorAtom,
|
pageEditorAtom,
|
||||||
yjsConnectionStatusAtom,
|
yjsConnectionStatusAtom,
|
||||||
} from "@/features/editor/atoms/editor-atoms.ts";
|
} from "@/features/editor/atoms/editor-atoms.ts";
|
||||||
|
import { searchAndReplaceStateAtom } from "@/features/editor/components/search-and-replace/atoms/search-and-replace-state-atom.ts";
|
||||||
import { formattedDate, timeAgo } from "@/lib/time.ts";
|
import { formattedDate, timeAgo } from "@/lib/time.ts";
|
||||||
|
import { PageStateSegmentedControl } from "@/features/user/components/page-state-pref.tsx";
|
||||||
import MovePageModal from "@/features/page/components/move-page-modal.tsx";
|
import MovePageModal from "@/features/page/components/move-page-modal.tsx";
|
||||||
import { useTimeAgo } from "@/hooks/use-time-ago.tsx";
|
import { useTimeAgo } from "@/hooks/use-time-ago.tsx";
|
||||||
import ShareModal from "@/features/share/components/share-modal.tsx";
|
import ShareModal from "@/features/share/components/share-modal.tsx";
|
||||||
@ -45,6 +53,26 @@ export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
|
|||||||
const toggleAside = useToggleAside();
|
const toggleAside = useToggleAside();
|
||||||
const [yjsConnectionStatus] = useAtom(yjsConnectionStatusAtom);
|
const [yjsConnectionStatus] = useAtom(yjsConnectionStatusAtom);
|
||||||
|
|
||||||
|
useHotkeys(
|
||||||
|
[
|
||||||
|
[
|
||||||
|
"mod+F",
|
||||||
|
() => {
|
||||||
|
const event = new CustomEvent("openFindDialogFromEditor", {});
|
||||||
|
document.dispatchEvent(event);
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Escape",
|
||||||
|
() => {
|
||||||
|
const event = new CustomEvent("closeFindDialogFromEditor", {});
|
||||||
|
document.dispatchEvent(event);
|
||||||
|
},
|
||||||
|
],
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{yjsConnectionStatus === "disconnected" && (
|
{yjsConnectionStatus === "disconnected" && (
|
||||||
@ -59,6 +87,8 @@ export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{!readOnly && <PageStateSegmentedControl size="xs" />}
|
||||||
|
|
||||||
<ShareModal readOnly={readOnly} />
|
<ShareModal readOnly={readOnly} />
|
||||||
|
|
||||||
<Tooltip label={t("Comments")} openDelay={250} withArrow>
|
<Tooltip label={t("Comments")} openDelay={250} withArrow>
|
||||||
@ -201,7 +231,7 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
|
|||||||
leftSection={<IconTrash size={16} />}
|
leftSection={<IconTrash size={16} />}
|
||||||
onClick={handleDeletePage}
|
onClick={handleDeletePage}
|
||||||
>
|
>
|
||||||
{t("Delete")}
|
{t("Move to trash")}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -1,15 +1,27 @@
|
|||||||
.header {
|
.header {
|
||||||
height: 45px;
|
height: 45px;
|
||||||
background-color: var(--mantine-color-body);
|
background-color: var(--mantine-color-body);
|
||||||
padding-left: var(--mantine-spacing-md);
|
padding-left: var(--mantine-spacing-md);
|
||||||
padding-right: var(--mantine-spacing-md);
|
padding-right: var(--mantine-spacing-md);
|
||||||
position: fixed;
|
position: fixed;
|
||||||
z-index: 99;
|
z-index: 99;
|
||||||
top: var(--app-shell-header-offset, 0rem);
|
top: var(--app-shell-header-offset, 0rem);
|
||||||
inset-inline-start: var(--app-shell-navbar-offset, 0rem);
|
inset-inline-start: var(--app-shell-navbar-offset, 0rem);
|
||||||
inset-inline-end: var(--app-shell-aside-offset, 0rem);
|
inset-inline-end: var(--app-shell-aside-offset, 0rem);
|
||||||
|
|
||||||
@media print {
|
@media (max-width: $mantine-breakpoint-sm) {
|
||||||
display: none;
|
padding-left: var(--mantine-spacing-xs);
|
||||||
}
|
padding-right: var(--mantine-spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media print {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.group {
|
||||||
|
@media (max-width: $mantine-breakpoint-sm) {
|
||||||
|
gap: var(--mantine-spacing-sm);
|
||||||
|
padding-inline: 0 !important;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,10 +9,10 @@ interface Props {
|
|||||||
export default function PageHeader({ readOnly }: Props) {
|
export default function PageHeader({ readOnly }: Props) {
|
||||||
return (
|
return (
|
||||||
<div className={classes.header}>
|
<div className={classes.header}>
|
||||||
<Group justify="space-between" h="100%" px="md" wrap="nowrap">
|
<Group justify="space-between" h="100%" px="md" wrap="nowrap" className={classes.group}>
|
||||||
<Breadcrumb />
|
<Breadcrumb />
|
||||||
|
|
||||||
<Group justify="flex-end" h="100%" px="md" wrap="nowrap">
|
<Group justify="flex-end" h="100%" px="md" wrap="nowrap" gap="var(--mantine-spacing-xs)">
|
||||||
<PageHeaderMenu readOnly={readOnly} />
|
<PageHeaderMenu readOnly={readOnly} />
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
|
|||||||
@ -1,18 +1,38 @@
|
|||||||
import { Modal, Button, SimpleGrid, FileButton } from "@mantine/core";
|
|
||||||
import {
|
import {
|
||||||
|
Modal,
|
||||||
|
Button,
|
||||||
|
SimpleGrid,
|
||||||
|
FileButton,
|
||||||
|
Group,
|
||||||
|
Text,
|
||||||
|
Tooltip,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import {
|
||||||
|
IconBrandNotion,
|
||||||
IconCheck,
|
IconCheck,
|
||||||
IconFileCode,
|
IconFileCode,
|
||||||
|
IconFileTypeZip,
|
||||||
IconMarkdown,
|
IconMarkdown,
|
||||||
IconX,
|
IconX,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import { importPage } from "@/features/page/services/page-service.ts";
|
import {
|
||||||
|
importPage,
|
||||||
|
importZip,
|
||||||
|
} from "@/features/page/services/page-service.ts";
|
||||||
import { notifications } from "@mantine/notifications";
|
import { notifications } from "@mantine/notifications";
|
||||||
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts";
|
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { buildTree } from "@/features/page/tree/utils";
|
import { buildTree } from "@/features/page/tree/utils";
|
||||||
import { IPage } from "@/features/page/types/page.types.ts";
|
import { IPage } from "@/features/page/types/page.types.ts";
|
||||||
import React from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { ConfluenceIcon } from "@/components/icons/confluence-icon.tsx";
|
||||||
|
import { getFileImportSizeLimit, isCloud } from "@/lib/config.ts";
|
||||||
|
import { formatBytes } from "@/lib";
|
||||||
|
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||||
|
import { getFileTaskById } from "@/features/file-task/services/file-task-service.ts";
|
||||||
|
import { queryClient } from "@/main.tsx";
|
||||||
|
import { useQueryEmit } from "@/features/websocket/use-query-emit.ts";
|
||||||
|
|
||||||
interface PageImportModalProps {
|
interface PageImportModalProps {
|
||||||
spaceId: string;
|
spaceId: string;
|
||||||
@ -36,6 +56,7 @@ export default function PageImportModal({
|
|||||||
yOffset="10vh"
|
yOffset="10vh"
|
||||||
xOffset={0}
|
xOffset={0}
|
||||||
mah={400}
|
mah={400}
|
||||||
|
keepMounted={true}
|
||||||
>
|
>
|
||||||
<Modal.Overlay />
|
<Modal.Overlay />
|
||||||
<Modal.Content style={{ overflow: "hidden" }}>
|
<Modal.Content style={{ overflow: "hidden" }}>
|
||||||
@ -59,6 +80,133 @@ interface ImportFormatSelection {
|
|||||||
function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
|
function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [treeData, setTreeData] = useAtom(treeDataAtom);
|
const [treeData, setTreeData] = useAtom(treeDataAtom);
|
||||||
|
const [workspace] = useAtom(workspaceAtom);
|
||||||
|
const [fileTaskId, setFileTaskId] = useState<string | null>(null);
|
||||||
|
const emit = useQueryEmit();
|
||||||
|
|
||||||
|
const canUseConfluence = isCloud() || workspace?.hasLicenseKey;
|
||||||
|
|
||||||
|
const handleZipUpload = async (selectedFile: File, source: string) => {
|
||||||
|
if (!selectedFile) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
onClose();
|
||||||
|
|
||||||
|
notifications.show({
|
||||||
|
id: "import",
|
||||||
|
title: t("Uploading import file"),
|
||||||
|
message: t("Please don't close this tab."),
|
||||||
|
loading: true,
|
||||||
|
withCloseButton: false,
|
||||||
|
autoClose: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const importTask = await importZip(selectedFile, spaceId, source);
|
||||||
|
notifications.update({
|
||||||
|
id: "import",
|
||||||
|
title: t("Importing pages"),
|
||||||
|
message: t(
|
||||||
|
"Page import is in progress. You can check back later if this takes longer.",
|
||||||
|
),
|
||||||
|
loading: true,
|
||||||
|
withCloseButton: true,
|
||||||
|
autoClose: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
setFileTaskId(importTask.id);
|
||||||
|
} catch (err) {
|
||||||
|
console.log("Failed to upload import file", err);
|
||||||
|
notifications.update({
|
||||||
|
id: "import",
|
||||||
|
color: "red",
|
||||||
|
title: t("Failed to upload import file"),
|
||||||
|
message: err?.response.data.message,
|
||||||
|
icon: <IconX size={18} />,
|
||||||
|
loading: false,
|
||||||
|
withCloseButton: true,
|
||||||
|
autoClose: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!fileTaskId) return;
|
||||||
|
|
||||||
|
const intervalId = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
const fileTask = await getFileTaskById(fileTaskId);
|
||||||
|
const status = fileTask.status;
|
||||||
|
|
||||||
|
if (status === "success") {
|
||||||
|
notifications.update({
|
||||||
|
id: "import",
|
||||||
|
color: "teal",
|
||||||
|
title: t("Import complete"),
|
||||||
|
message: t("Your pages were successfully imported."),
|
||||||
|
icon: <IconCheck size={18} />,
|
||||||
|
loading: false,
|
||||||
|
withCloseButton: true,
|
||||||
|
autoClose: false,
|
||||||
|
});
|
||||||
|
clearInterval(intervalId);
|
||||||
|
setFileTaskId(null);
|
||||||
|
|
||||||
|
await queryClient.refetchQueries({
|
||||||
|
queryKey: ["root-sidebar-pages", fileTask.spaceId],
|
||||||
|
});
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
emit({
|
||||||
|
operation: "refetchRootTreeNodeEvent",
|
||||||
|
spaceId: spaceId,
|
||||||
|
});
|
||||||
|
}, 50);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === "failed") {
|
||||||
|
notifications.update({
|
||||||
|
id: "import",
|
||||||
|
color: "red",
|
||||||
|
title: t("Page import failed"),
|
||||||
|
message: t(
|
||||||
|
"Something went wrong while importing pages: {{reason}}.",
|
||||||
|
{
|
||||||
|
reason: fileTask.errorMessage,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
icon: <IconX size={18} />,
|
||||||
|
loading: false,
|
||||||
|
withCloseButton: true,
|
||||||
|
autoClose: false,
|
||||||
|
});
|
||||||
|
clearInterval(intervalId);
|
||||||
|
setFileTaskId(null);
|
||||||
|
console.error(fileTask.errorMessage);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
notifications.update({
|
||||||
|
id: "import",
|
||||||
|
color: "red",
|
||||||
|
title: t("Import failed"),
|
||||||
|
message: t(
|
||||||
|
"Something went wrong while importing pages: {{reason}}.",
|
||||||
|
{
|
||||||
|
reason: err.response?.data.message,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
icon: <IconX size={18} />,
|
||||||
|
loading: false,
|
||||||
|
withCloseButton: true,
|
||||||
|
autoClose: false,
|
||||||
|
});
|
||||||
|
clearInterval(intervalId);
|
||||||
|
setFileTaskId(null);
|
||||||
|
console.error("Failed to fetch import status", err);
|
||||||
|
}
|
||||||
|
}, 3000);
|
||||||
|
}, [fileTaskId]);
|
||||||
|
|
||||||
const handleFileUpload = async (selectedFiles: File[]) => {
|
const handleFileUpload = async (selectedFiles: File[]) => {
|
||||||
if (!selectedFiles) {
|
if (!selectedFiles) {
|
||||||
@ -120,6 +268,7 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SimpleGrid cols={2}>
|
<SimpleGrid cols={2}>
|
||||||
@ -148,7 +297,76 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</FileButton>
|
</FileButton>
|
||||||
|
|
||||||
|
<FileButton
|
||||||
|
onChange={(file) => handleZipUpload(file, "notion")}
|
||||||
|
accept="application/zip"
|
||||||
|
>
|
||||||
|
{(props) => (
|
||||||
|
<Button
|
||||||
|
justify="start"
|
||||||
|
variant="default"
|
||||||
|
leftSection={<IconBrandNotion size={18} />}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
Notion
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</FileButton>
|
||||||
|
<FileButton
|
||||||
|
onChange={(file) => handleZipUpload(file, "confluence")}
|
||||||
|
accept="application/zip"
|
||||||
|
>
|
||||||
|
{(props) => (
|
||||||
|
<Tooltip
|
||||||
|
label="Available in enterprise edition"
|
||||||
|
disabled={canUseConfluence}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
disabled={!canUseConfluence}
|
||||||
|
justify="start"
|
||||||
|
variant="default"
|
||||||
|
leftSection={<ConfluenceIcon size={18} />}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
Confluence
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</FileButton>
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
|
|
||||||
|
<Group justify="center" gap="xl" mih={150}>
|
||||||
|
<div>
|
||||||
|
<Text ta="center" size="lg" inline>
|
||||||
|
Import zip file
|
||||||
|
</Text>
|
||||||
|
<Text ta="center" size="sm" c="dimmed" inline py="sm">
|
||||||
|
{t(
|
||||||
|
`Upload zip file containing Markdown and HTML files. Max: {{sizeLimit}}`,
|
||||||
|
{
|
||||||
|
sizeLimit: formatBytes(getFileImportSizeLimit()),
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
<FileButton
|
||||||
|
onChange={(file) => handleZipUpload(file, "generic")}
|
||||||
|
accept="application/zip"
|
||||||
|
>
|
||||||
|
{(props) => (
|
||||||
|
<Group justify="center">
|
||||||
|
<Button
|
||||||
|
justify="center"
|
||||||
|
leftSection={<IconFileTypeZip size={18} />}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{t("Upload file")}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
</FileButton>
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,26 +4,37 @@ import { useTranslation } from "react-i18next";
|
|||||||
|
|
||||||
type UseDeleteModalProps = {
|
type UseDeleteModalProps = {
|
||||||
onConfirm: () => void;
|
onConfirm: () => void;
|
||||||
|
isPermanent?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function useDeletePageModal() {
|
export function useDeletePageModal() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const openDeleteModal = ({ onConfirm }: UseDeleteModalProps) => {
|
const openDeleteModal = ({
|
||||||
|
onConfirm,
|
||||||
|
isPermanent = false,
|
||||||
|
}: UseDeleteModalProps) => {
|
||||||
modals.openConfirmModal({
|
modals.openConfirmModal({
|
||||||
title: t("Are you sure you want to delete this page?"),
|
title: isPermanent
|
||||||
|
? t("Are you sure you want to delete this page?")
|
||||||
|
: t("Move this page to trash?"),
|
||||||
children: (
|
children: (
|
||||||
<Text size="sm">
|
<Text size="sm">
|
||||||
{t(
|
{isPermanent
|
||||||
"Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.",
|
? t(
|
||||||
)}
|
"Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.",
|
||||||
|
)
|
||||||
|
: t("Pages in trash will be permanently deleted after 30 days.")}
|
||||||
</Text>
|
</Text>
|
||||||
),
|
),
|
||||||
centered: true,
|
centered: true,
|
||||||
labels: { confirm: t("Delete"), cancel: t("Cancel") },
|
labels: {
|
||||||
|
confirm: isPermanent ? t("Delete") : t("Move to trash"),
|
||||||
|
cancel: t("Cancel"),
|
||||||
|
},
|
||||||
confirmProps: { color: "red" },
|
confirmProps: { color: "red" },
|
||||||
onConfirm,
|
onConfirm,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return { openDeleteModal } as const;
|
return { openDeleteModal } as const;
|
||||||
}
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user